commit 13cc5a89922495931cb1cb45e3bfc66ef6b9fca8
parent eff255fe1252e3614c99db654c93212db8eb5ab0
Author: dan <dan@telent.net>
Date: Mon, 10 Feb 2025 21:23:15 +0000
Merge pull request 'support firewall zones: don't hardcode interface names in rules' (#16) from firescape into main
Reviewed-on: https://gti.telent.net/dan/liminix/pulls/16
Diffstat:
9 files changed, 366 insertions(+), 34 deletions(-)
diff --git a/THOUGHTS.txt b/THOUGHTS.txt
@@ -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 -
diff --git a/modules/firewall/default-rules.nix b/modules/firewall/default-rules.nix
@@ -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 \""
];
};
diff --git a/modules/firewall/default.nix b/modules/firewall/default.nix
@@ -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;
diff --git a/modules/firewall/ifwatch.fnl b/modules/firewall/ifwatch.fnl
@@ -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 }
diff --git a/modules/firewall/service.nix b/modules/firewall/service.nix
@@ -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";
}
diff --git a/modules/profiles/gateway.nix b/modules/profiles/gateway.nix
@@ -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 {
diff --git a/pkgs/anoia/svc.fnl b/pkgs/anoia/svc.fnl
@@ -60,6 +60,7 @@
(write-value (.. directory "/" filename) new-value)
(read-value (.. directory "/" filename))))
:close #(watcher:close)
+ :fileno #(watcher:fileno)
: events
}))
diff --git a/pkgs/firewallgen/default.nix b/pkgs/firewallgen/default.nix
@@ -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
diff --git a/pkgs/firewallgen/test-rules-min.nix b/pkgs/firewallgen/test-rules-min.nix
@@ -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"
+ ];
+
+ };
}