1
0

Merge pull request 'support firewall zones: don't hardcode interface names in rules' (#16) from firescape into main

Reviewed-on: #16
This commit is contained in:
dan 2025-02-10 21:23:15 +00:00
commit 13cc5a8992
9 changed files with 366 additions and 34 deletions

View File

@ -6834,3 +6834,189 @@ looking through the openwrt config changes ...
Sun Jan 5 12:58:52 GMT 2025
We are running with rt3200 and everything appears to work :-)
Sun Jan 5 20:34:18 GMT 2025
what customization do we want from the firewall?
- what's allowed wan->lan
- what's allowed lan->wan
- which dropped packets get logged or don't
plus fix whatever it was RoS found
plus stop hardcoding the interface names
Q: if pppd _makes_ an interface, how do we know what the name of it
is going to be before it's up and passing packets so that we can
have the firewall active before it starts
or could we have ppp service install a "drop everything" firewall
before it starts? what if there's more than one upstream interface,
they shouldn't wipe each other out
so can we have a "default deny" firewall in which every allowed flow
is qualified by the interface name, and any service that brings up an
interface is required to add firewall rules for it according to its
role
or maybe the firewall service could watch for interfaces being added
(and removed) and update the ruleset as appropriate for the interface
role (lan, wan, dmz, management, guest, ???). but how does it know
the role based on the interface name?
Tue Jan 14 19:33:28 GMT 2025
each interface can add its own chain when it comes up, and then we can
figure out some way to jump to the correct chain (vmaps) based on
interface name
% nft add map filter mydict { type ipv4_addr : verdict\; }
% nft add element filter mydict { 192.168.0.10 : drop, 192.168.0.11 : accept }
% nft add rule filter input ip saddr vmap @mydict
_but_ we might be better off declaring static "zones" (lan, world,
dmz, guest, etc etc) with a map for each, and then replace the hardcoded
interface names with a map lookup
% nft add map filter ifzone { type ifname : string ; }
zones { type ifname: ipv4_addr\; }
... and presumably we also need identical maps for nat and
... any other chain type where we need to distinguish
... inside from outside
% nft add element nat porttoip { 80 : 192.168.1.100, 8888 : 192.168.1.101 }
% nft add rule ip nat postrouting snat to tcp dport map @porttoip
Mon Jan 20 20:32:58 GMT 2025
1) maybe we can add a type="chain" or type="set" attribute to the
firewallgen input, then we cna have sets in the default firewall rules
2) then we convert the default firewall rules to use sets instead of
hardcoding ifname
3) then find a nice place to hook "new interface is available" and
add it to the appropriate zone (separately for ip4 and ip6, gah)
4) and how do we find
# nft add element ip table-ip lan { int }
# nft add element ip table-ip wan { ppp0 }
# nft add element ip6 table-ip6 lan { int }
# nft add element ip6 table-ip6 wan { ppp0 }
Tue Jan 28 21:51:46 GMT 2025
Going back and forth on the firewall stuff, in respect of where to aim
for with "general" vs "useful". Specifically, if we have hooks
every time interfaces are added/removed that
expect some specific sets to (a) exist and (b) have particular
semantics, there is necessary coupling between those hooks and the
firewall definition. So, practically speaking, the "a new interface
appears" rules need to be bundled with the firewall ruleset
Which also means that the firewall needs to know which zone the
interface is assigned to, which is a problem if it can't tell from the
name (for example wg0 wg1 wg2 ... could be wan or lan or dmz or
anything)
So the service that owns the interface needs to communicate "another
one for the lan zone" to the firewall and it can't do that by adding
to sets directly unless it knows what the sets are called. Implying we
need an interface between the interface service that knows "new
interface ppp7 added in wan zone" and the firewall service that knows
how to accommodate this. For extra credit this actually should be more
like pubsub: the interface shouldn't really have to know the firewall
exists.
outputs?
- what if each interface wrote a "zone" output and the firewall
subscribed to them? would need the firewall to know which of all the
available services were interfaces
- a zone service that every interface in the zone depends on. it
doesn't do much in itself but it means the interface updown scripts
know a service directory where they can touch lan-zone/eth0 or
whatever. This could work. The firewall service definition specifies
the zone services and uses inotify watcher thingies to update
interface sets when contents change.
Wed Jan 29 17:19:24 GMT 2025
1) make a zone service defn that can be instantiated for each zone.
it should create $output/interfaces
2) add a `zone` attribute to interface definitions, causing
- the zone service to be added to the dependencies
- the interface "up" script to include writing to the zone/interfaces output
2b) any other service that creates an interface (e.g. ppp) needs to also
have `zone` and do the same
3) firewallgen to be able to make sets
4) firewall service to watch the zone outputs
Fri Jan 31 17:11:16 GMT 2025
Do we need zone services? I think we could put zones in the outputs of
the firewall service?
Sun Feb 2 20:59:56 GMT 2025
What's the smallest first step?
- [done] how can we make firewallgen output sets (or could we
make the firewall service tack them on afterwards)
- make a longrun that watches its own zones output and updates the
appropriate sets
The sticking point is that if you give the firewall `rules` instead of
`extraRules` then the longrun may or may not work depending on (1)
whether you made the zone sets; (2) whether your rules use
them. Conclusion: if you supply `rules` then you also have to say
whether you want the longrun or not. So add a param
watchForInterfaceUpdates which defaults true
Mon Feb 3 21:12:55 GMT 2025
the thing that updates sets has to know they exist, so the interface watcher
service must live in the firewall module
the firewall service defn should return the firewall service after
adding the interface watcher as a dependency of it. Or: the watcher
should make the sets and then the firewall service could depend on _it_.
That would mean that the firewall service would fail if it used sets
that the watcher didn't make, is that good or bad or indifferent?
the interface services have to know about the watcher as well in order
to write into its outputs, so it can't be hidden inside the module
maybe the watcher service should _be_ the firewall service.
we could add a "notify" param to an interface which would be an output
reference to (the firewall service / zones / lan ) that the interface would
write its ifname into when the service is up
Wed Feb 5 00:14:29 GMT 2025
another thought: the firewall service could have params to say
which interface services are in which zones
we'd have to ensure that the interface services did not end up as
dependencies of the firewall
then the firewall could
- create the sets
- watch each interface service for the ifname output and add it to the right zone
Sun Feb 9 21:33:57 GMT 2025
nft update set @lan
echo 'flush set table-ip lan; add element table-ip lan { eth0,lo }' | nft -f -

View File

@ -62,8 +62,8 @@ in {
# https://www.mankier.com/8/nft#Payload_Expressions-Raw_Payload_Expression
"@nh,192,8 eq 0xff @nh,204,4 le ${toString mcast-scope}")
(accept "oifname \"int\" iifname \"ppp0\" meta l4proto udp ct state established,related")
(accept "iifname \"int\" oifname \"ppp0\" meta l4proto udp")
(accept "oifname @lan iifname @wan meta l4proto udp ct state established,related")
(accept "iifname @lan oifname @wan meta l4proto udp")
(accept "meta l4proto icmpv6")
(accept "meta l4proto ah")
@ -71,31 +71,31 @@ in {
# does this ever get used or does the preceding general udp accept
# already grab anything that might get here?
(accept "oifname \"ppp0\" udp dport 500") # IKE Protocol [RFC5996]. haha zyxel
(accept "oifname @wan udp dport 500") # IKE Protocol [RFC5996]. haha zyxel
(accept "ip6 nexthdr 139") # Host Identity Protocol
## FIXME no support yet for recs 27-30 Mobility Header
(accept "oifname \"int\" iifname \"ppp0\" meta l4proto tcp ct state established,related")
(accept "iifname \"int\" oifname \"ppp0\" meta l4proto tcp")
(accept "oifname @lan iifname @wan meta l4proto tcp ct state established,related")
(accept "iifname @lan oifname @wan meta l4proto tcp")
(accept "oifname \"int\" iifname \"ppp0\" meta l4proto sctp ct state established,related")
(accept "iifname \"int\" oifname \"ppp0\" meta l4proto sctp")
(accept "oifname @lan iifname @wan meta l4proto sctp ct state established,related")
(accept "iifname @lan oifname @wan meta l4proto sctp")
(accept "oifname \"int\" iifname \"ppp0\" meta l4proto dccp ct state established,related")
(accept "iifname \"int\" oifname \"ppp0\" meta l4proto dccp")
(accept "oifname @lan iifname @wan meta l4proto dccp ct state established,related")
(accept "iifname @lan oifname @wan meta l4proto dccp")
# we can allow all reasonable inbound, or we can use an explicit
# allowlist to enumerate the endpoints that are allowed to
# accept inbound from the WAN
(if allow-incoming
then accept "oifname \"int\" iifname \"ppp0\""
else "iifname \"ppp0\" jump incoming-allowed-ip6"
then accept "oifname @lan iifname @wan"
else "iifname @wan jump incoming-allowed-ip6"
)
# allow all outbound and any inbound that's part of a
# recognised (outbound-initiated) flow
(accept "oifname \"int\" iifname \"ppp0\" ct state established,related")
(accept "iifname \"int\" oifname \"ppp0\" ")
(accept "oifname @lan iifname @wan ct state established,related")
(accept "iifname @lan oifname @wan ")
"log prefix \"DENIED CHAIN=forward-ip6 \""
];
@ -128,15 +128,15 @@ in {
hook = "input";
rules = [
(accept "meta l4proto icmpv6")
"iifname int jump input-ip6-lan"
"iifname ppp0 jump input-ip6-wan"
"iifname @lan jump input-ip6-lan"
"iifname @wan jump input-ip6-wan"
(if allow-incoming
then accept "iifname \"ppp0\""
else "iifname \"ppp0\" jump incoming-allowed-ip6"
then accept "iifname @wan"
else "iifname @wan jump incoming-allowed-ip6"
)
# how does this even make sense in an input chain?
(accept "iifname \"ppp0\" ct state established,related")
(accept "iifname \"int\" ")
(accept "iifname @wan ct state established,related")
(accept "iifname @lan ")
"log prefix \"DENIED CHAIN=input-ip6 \""
];
};
@ -146,7 +146,7 @@ in {
family = "ip6";
rules = [
# this is where you put permitted incoming connections
# "oifname \"int\" ip6 daddr 2001:8b0:de3a:40de::e9d tcp dport 22"
# "oifname @lan ip6 daddr 2001:8b0:de3a:40de::e9d tcp dport 22"
];
};
@ -157,7 +157,7 @@ in {
policy = "accept";
family = "ip";
rules = [
"oifname \"ppp0\" masquerade"
"oifname @wan masquerade"
];
};
@ -208,9 +208,9 @@ in {
rules = [
"iifname lo accept"
"icmp type { echo-request, echo-reply } accept"
"iifname int jump input-ip4-lan"
"iifname ppp0 jump input-ip4-wan"
"iifname ppp0 jump incoming-allowed-ip4"
"iifname @lan jump input-ip4-lan"
"iifname @wan jump input-ip4-wan"
"iifname @wan jump incoming-allowed-ip4"
"ct state established,related accept"
"log prefix \"DENIED CHAIN=input-ip4 \""
];
@ -222,9 +222,9 @@ in {
policy = "drop";
hook = "forward";
rules = [
"iifname \"int\" accept"
"iifname @lan accept"
"ct state established,related accept"
"oifname \"int\" iifname \"ppp0\" jump incoming-allowed-ip4"
"oifname @lan iifname @wan jump incoming-allowed-ip4"
"log prefix \"DENIED CHAIN=forward-ip4 \""
];
};

View File

@ -60,6 +60,16 @@ in
description = "firewall ruleset";
default = {};
};
zones = mkOption {
type = types.attrsOf (types.listOf liminix.lib.types.service);
default = {};
example = lib.literalExpression ''
{
lan = with config.hardware.networkInterfaces; [ int ];
wan = [ config.services.ppp0 ];
}
'';
};
rules = mkOption {
type = types.attrsOf types.attrs; # we could usefully tighten this a bit :-)
default = import ./default-rules.nix;

View File

@ -0,0 +1,63 @@
(local { : system : join } (require :anoia))
(local svc (require :anoia.svc))
(local ll (require :lualinux))
;; ifwatch.fnl wan:/nix/store/eee/.outputs/ifname wan:/nix/store/ffff/.outputs/ifname lan:/nix/store/abc123/.outputs/ifname
(fn parse-options [cmdline]
(let [interfaces {}]
(each [_ s (ipairs cmdline)]
(let [(zone service) (string.match s "(.-):(.+)")]
(tset interfaces (svc.open service) zone)))
interfaces))
(local POLLIN 1)
(local POLLHUP 16)
(fn zone-contents [interfaces]
(accumulate [zones {}
intf zone (pairs interfaces)]
(let [ifs (or (. zones zone) [])]
(table.insert ifs (intf:output "ifname"))
(tset zones zone ifs)
zones)))
(fn wait-for-change [interfaces]
(let [pollfds (icollect [k _ (pairs interfaces)]
(bor (lshift (k:fileno) 32)
(lshift (bor POLLIN POLLHUP) 16)))]
(ll.poll pollfds)))
(fn fail [msg]
(io.stderr:write (.. "ERROR: " msg "\n")))
(macro with-popen [[handle command mode] & body]
`(let [,handle (assert (io.popen ,command ,mode))
val# (do ,(unpack body))]
(case (: ,handle :close)
ok# val#
(nil :exit code#) (fail (.. ,command " exited " code#))
(nil :signal sig#) (fail (.. ,command " killed by " sig#)))))
(fn update-zone-str [zone ifnames]
(if (> (# ifnames) 0)
(..
"flush set ip table-ip " zone " ; add element ip table-ip " zone " { " (table.concat ifnames ", ") " };\n"
"flush set ip6 table-ip6 " zone " ; add element ip6 table-ip6 " zone " { " (table.concat ifnames ", ") " };\n"
)
(..
"flush set ip table-ip " zone "; \n"
"flush set ip6 table-ip6 " zone "; \n"
)))
(fn run []
(while true
(let [interfaces (parse-options arg)]
(with-popen [nft "nft -f -" :w]
(each [zone ifnames (pairs (zone-contents interfaces))]
(nft:write (update-zone-str zone ifnames))))
(wait-for-change interfaces)
(each [k _ (pairs interfaces)]
(k:close)))))
{ : run }

View File

@ -3,13 +3,40 @@
, lib
, firewallgen
, nftables
, writeFennel
, anoia
, lualinux
, linotify
}:
{ rules, extraRules }:
{ rules, extraRules, zones }:
let
inherit (liminix.services) oneshot;
script = firewallgen "firewall.nft" (lib.recursiveUpdate rules extraRules);
in oneshot {
inherit (liminix.services) longrun;
inherit (lib.attrsets) mapAttrs' nameValuePair mapAttrsToList;
inherit (lib.strings) concatStringsSep;
inherit (lib.lists) flatten;
mkSet = family : name :
nameValuePair
"${name}-set-${family}"
{
kind = "set";
inherit name family;
type = "ifname";
};
sets = (mapAttrs' (n : _ : mkSet "ip" n) zones) //
(mapAttrs' (n : _ : mkSet "ip6" n) zones);
allRules = lib.recursiveUpdate extraRules (lib.recursiveUpdate (builtins.trace sets sets) rules);
script = firewallgen "firewall1.nft" allRules;
ifwatch = writeFennel "ifwatch" {
packages = [anoia lualinux linotify];
mainFunction = "run";
} ./ifwatch.fnl ;
watchArg = z : intfs : map (i: "${z}:${i}/.outputs") intfs;
in longrun {
name = "firewall";
up = script;
down = "${nftables}/bin/nft flush ruleset";
run = ''
${script}
PATH=${nftables}/bin:$PATH
${ifwatch} ${concatStringsSep " " (flatten (mapAttrsToList watchArg zones))}
'';
finish = "${nftables}/bin/nft flush ruleset";
}

View File

@ -48,6 +48,13 @@ in {
firewall = {
enable = mkEnableOption "firewall";
rules = mkOption { type = types.attrsOf types.attrs; };
zones = mkOption {
type = types.attrsOf (types.listOf liminix.lib.types.service);
default = {
lan = [ config.services.int ];
wan = [ config.services.wan ];
};
};
};
wan = {
@ -143,6 +150,7 @@ in {
services.firewall = mkIf cfg.firewall.enable
(svc.firewall.build {
extraRules = cfg.firewall.rules;
inherit (cfg.firewall) zones;
});
services.resolvconf = oneshot rec {

View File

@ -60,6 +60,7 @@
(write-value (.. directory "/" filename) new-value)
(read-value (.. directory "/" filename))))
:close #(watcher:close)
:fileno #(watcher:fileno)
: events
}))

View File

@ -43,15 +43,33 @@ let
${concatStringsSep "\n" rules}
}
'';
doset = { name, type, elements ? [], ... } : ''
set ${name} {
type ${type}
${if elements != []
then "elements = { ${concatStringsSep ", " elements } }"
else ""
}
}
'';
dochainorset =
{ kind ? "chain", ... } @ params :
{
chain = dochain;
set = doset;
}.${kind} params;
dotable = family : chains : ''
table ${family} table-${family} {
${concatStringsSep "\n" (map dochain chains)}
${concatStringsSep "\n" (map dochainorset chains)}
}
'';
categorise = chains :
groupBy
({ family, ... } : family)
(mapAttrsToList (n : v : v // { name = n; }) chains);
(mapAttrsToList (n : v : { name = n; } // v ) chains);
in writeScript name ''
#!${nftables}/sbin/nft -f

View File

@ -121,4 +121,23 @@ let
};
in {
inherit input-ip6 forward-ip6 bogons-ip6 incoming-allowed-ip6;
lan-set-ip = {
kind = "set";
family = "ip";
type = "ifname";
elements = [
"eth0" "eth1"
];
};
# honours timeout flags gc-interval size policy counter auto-merge
lan-set-ip6 = {
kind = "set";
family = "ip6";
type = "ifname";
elements = [
"eth0" "eth1"
];
};
}