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:
commit
13cc5a8992
186
THOUGHTS.txt
186
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 -
|
||||
|
@ -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 \""
|
||||
];
|
||||
};
|
||||
|
@ -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;
|
||||
|
63
modules/firewall/ifwatch.fnl
Normal file
63
modules/firewall/ifwatch.fnl
Normal 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 }
|
@ -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";
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -60,6 +60,7 @@
|
||||
(write-value (.. directory "/" filename) new-value)
|
||||
(read-value (.. directory "/" filename))))
|
||||
:close #(watcher:close)
|
||||
:fileno #(watcher:fileno)
|
||||
: events
|
||||
}))
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
];
|
||||
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user