3
0
Fork 0
forked from mirrors/nixpkgs

nixos/{firewall, nat}: add a nftables based implementation

This commit is contained in:
Rvfg 2022-12-23 00:23:23 +08:00
parent 2379de680d
commit a43c7b2a70
No known key found for this signature in database
15 changed files with 1158 additions and 723 deletions

View file

@ -303,6 +303,13 @@
the Nix store.
</para>
</listitem>
<listitem>
<para>
The <literal>firewall</literal> and <literal>nat</literal>
module now has a nftables based implementation. Enable
<literal>networking.nftables</literal> to use it.
</para>
</listitem>
<listitem>
<para>
The <literal>services.fwupd</literal> module now allows

View file

@ -86,6 +86,8 @@ In addition to numerous new and upgraded packages, this release has the followin
- Resilio sync secret keys can now be provided using a secrets file at runtime, preventing these secrets from ending up in the Nix store.
- The `firewall` and `nat` module now has a nftables based implementation. Enable `networking.nftables` to use it.
- The `services.fwupd` module now allows arbitrary daemon settings to be configured in a structured manner ([`services.fwupd.daemonSettings`](#opt-services.fwupd.daemonSettings)).
- The `unifi-poller` package and corresponding NixOS module have been renamed to `unpoller` to match upstream.

View file

@ -821,6 +821,8 @@
./services/networking/firefox-syncserver.nix
./services/networking/fireqos.nix
./services/networking/firewall.nix
./services/networking/firewall-iptables.nix
./services/networking/firewall-nftables.nix
./services/networking/flannel.nix
./services/networking/freenet.nix
./services/networking/freeradius.nix
@ -891,6 +893,8 @@
./services/networking/namecoind.nix
./services/networking/nar-serve.nix
./services/networking/nat.nix
./services/networking/nat-iptables.nix
./services/networking/nat-nftables.nix
./services/networking/nats.nix
./services/networking/nbd.nix
./services/networking/ncdns.nix

View file

@ -53,13 +53,18 @@ in {
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPortRanges = [{ from = 9100; to = 9200; }];
allowedUDPPorts = [ 9003 ];
extraCommands = ''
extraCommands = optionalString (!config.networking.nftables.enable) ''
iptables -A INPUT -s 224.0.0.0/4 -j ACCEPT
iptables -A INPUT -d 224.0.0.0/4 -j ACCEPT
iptables -A INPUT -s 240.0.0.0/5 -j ACCEPT
iptables -A INPUT -m pkttype --pkt-type multicast -j ACCEPT
iptables -A INPUT -m pkttype --pkt-type broadcast -j ACCEPT
'';
extraInputRules = optionalString config.networking.nftables.enable ''
ip saddr { 224.0.0.0/4, 240.0.0.0/5 } accept
ip daddr 224.0.0.0/4 accept
pkttype { multicast, broadcast } accept
'';
};

View file

@ -58,7 +58,7 @@ in {
{ from = 30000; to = 30010; }
];
allowedUDPPorts = [ 9003 ];
extraCommands = ''
extraCommands = optionalString (!config.networking.nftables.enable) ''
## IGMP / Broadcast ##
iptables -A INPUT -s 224.0.0.0/4 -j ACCEPT
iptables -A INPUT -d 224.0.0.0/4 -j ACCEPT
@ -66,6 +66,11 @@ in {
iptables -A INPUT -m pkttype --pkt-type multicast -j ACCEPT
iptables -A INPUT -m pkttype --pkt-type broadcast -j ACCEPT
'';
extraInputRules = optionalString config.networking.nftables.enable ''
ip saddr { 224.0.0.0/4, 240.0.0.0/5 } accept
ip daddr 224.0.0.0/4 accept
pkttype { multicast, broadcast } accept
'';
};

View file

@ -0,0 +1,334 @@
/* This module enables a simple firewall.
The firewall can be customised in arbitrary ways by setting
networking.firewall.extraCommands. For modularity, the firewall
uses several chains:
- nixos-fw is the main chain for input packet processing.
- nixos-fw-accept is called for accepted packets. If you want
additional logging, or want to reject certain packets anyway, you
can insert rules at the start of this chain.
- nixos-fw-log-refuse and nixos-fw-refuse are called for
refused packets. (The former jumps to the latter after logging
the packet.) If you want additional logging, or want to accept
certain packets anyway, you can insert rules at the start of
this chain.
- nixos-fw-rpfilter is used as the main chain in the mangle table,
called from the built-in PREROUTING chain. If the kernel
supports it and `cfg.checkReversePath` is set this chain will
perform a reverse path filter test.
- nixos-drop is used while reloading the firewall in order to drop
all traffic. Since reloading isn't implemented in an atomic way
this'll prevent any traffic from leaking through while reloading
the firewall. However, if the reloading fails, the firewall-stop
script will be called which in return will effectively disable the
complete firewall (in the default configuration).
*/
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.networking.firewall;
inherit (config.boot.kernelPackages) kernel;
kernelHasRPFilter = ((kernel.config.isEnabled or (x: false)) "IP_NF_MATCH_RPFILTER") || (kernel.features.netfilterRPFilter or false);
helpers = import ./helpers.nix { inherit config lib; };
writeShScript = name: text:
let
dir = pkgs.writeScriptBin name ''
#! ${pkgs.runtimeShell} -e
${text}
'';
in
"${dir}/bin/${name}";
startScript = writeShScript "firewall-start" ''
${helpers}
# Flush the old firewall rules. !!! Ideally, updating the
# firewall would be atomic. Apparently that's possible
# with iptables-restore.
ip46tables -D INPUT -j nixos-fw 2> /dev/null || true
for chain in nixos-fw nixos-fw-accept nixos-fw-log-refuse nixos-fw-refuse; do
ip46tables -F "$chain" 2> /dev/null || true
ip46tables -X "$chain" 2> /dev/null || true
done
# The "nixos-fw-accept" chain just accepts packets.
ip46tables -N nixos-fw-accept
ip46tables -A nixos-fw-accept -j ACCEPT
# The "nixos-fw-refuse" chain rejects or drops packets.
ip46tables -N nixos-fw-refuse
${if cfg.rejectPackets then ''
# Send a reset for existing TCP connections that we've
# somehow forgotten about. Send ICMP "port unreachable"
# for everything else.
ip46tables -A nixos-fw-refuse -p tcp ! --syn -j REJECT --reject-with tcp-reset
ip46tables -A nixos-fw-refuse -j REJECT
'' else ''
ip46tables -A nixos-fw-refuse -j DROP
''}
# The "nixos-fw-log-refuse" chain performs logging, then
# jumps to the "nixos-fw-refuse" chain.
ip46tables -N nixos-fw-log-refuse
${optionalString cfg.logRefusedConnections ''
ip46tables -A nixos-fw-log-refuse -p tcp --syn -j LOG --log-level info --log-prefix "refused connection: "
''}
${optionalString (cfg.logRefusedPackets && !cfg.logRefusedUnicastsOnly) ''
ip46tables -A nixos-fw-log-refuse -m pkttype --pkt-type broadcast \
-j LOG --log-level info --log-prefix "refused broadcast: "
ip46tables -A nixos-fw-log-refuse -m pkttype --pkt-type multicast \
-j LOG --log-level info --log-prefix "refused multicast: "
''}
ip46tables -A nixos-fw-log-refuse -m pkttype ! --pkt-type unicast -j nixos-fw-refuse
${optionalString cfg.logRefusedPackets ''
ip46tables -A nixos-fw-log-refuse \
-j LOG --log-level info --log-prefix "refused packet: "
''}
ip46tables -A nixos-fw-log-refuse -j nixos-fw-refuse
# The "nixos-fw" chain does the actual work.
ip46tables -N nixos-fw
# Clean up rpfilter rules
ip46tables -t mangle -D PREROUTING -j nixos-fw-rpfilter 2> /dev/null || true
ip46tables -t mangle -F nixos-fw-rpfilter 2> /dev/null || true
ip46tables -t mangle -X nixos-fw-rpfilter 2> /dev/null || true
${optionalString (kernelHasRPFilter && (cfg.checkReversePath != false)) ''
# Perform a reverse-path test to refuse spoofers
# For now, we just drop, as the mangle table doesn't have a log-refuse yet
ip46tables -t mangle -N nixos-fw-rpfilter 2> /dev/null || true
ip46tables -t mangle -A nixos-fw-rpfilter -m rpfilter --validmark ${optionalString (cfg.checkReversePath == "loose") "--loose"} -j RETURN
# Allows this host to act as a DHCP4 client without first having to use APIPA
iptables -t mangle -A nixos-fw-rpfilter -p udp --sport 67 --dport 68 -j RETURN
# Allows this host to act as a DHCPv4 server
iptables -t mangle -A nixos-fw-rpfilter -s 0.0.0.0 -d 255.255.255.255 -p udp --sport 68 --dport 67 -j RETURN
${optionalString cfg.logReversePathDrops ''
ip46tables -t mangle -A nixos-fw-rpfilter -j LOG --log-level info --log-prefix "rpfilter drop: "
''}
ip46tables -t mangle -A nixos-fw-rpfilter -j DROP
ip46tables -t mangle -A PREROUTING -j nixos-fw-rpfilter
''}
# Accept all traffic on the trusted interfaces.
${flip concatMapStrings cfg.trustedInterfaces (iface: ''
ip46tables -A nixos-fw -i ${iface} -j nixos-fw-accept
'')}
# Accept packets from established or related connections.
ip46tables -A nixos-fw -m conntrack --ctstate ESTABLISHED,RELATED -j nixos-fw-accept
# Accept connections to the allowed TCP ports.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (port:
''
ip46tables -A nixos-fw -p tcp --dport ${toString port} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedTCPPorts
) cfg.allInterfaces)}
# Accept connections to the allowed TCP port ranges.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (rangeAttr:
let range = toString rangeAttr.from + ":" + toString rangeAttr.to; in
''
ip46tables -A nixos-fw -p tcp --dport ${range} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedTCPPortRanges
) cfg.allInterfaces)}
# Accept packets on the allowed UDP ports.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (port:
''
ip46tables -A nixos-fw -p udp --dport ${toString port} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedUDPPorts
) cfg.allInterfaces)}
# Accept packets on the allowed UDP port ranges.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (rangeAttr:
let range = toString rangeAttr.from + ":" + toString rangeAttr.to; in
''
ip46tables -A nixos-fw -p udp --dport ${range} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedUDPPortRanges
) cfg.allInterfaces)}
# Optionally respond to ICMPv4 pings.
${optionalString cfg.allowPing ''
iptables -w -A nixos-fw -p icmp --icmp-type echo-request ${optionalString (cfg.pingLimit != null)
"-m limit ${cfg.pingLimit} "
}-j nixos-fw-accept
''}
${optionalString config.networking.enableIPv6 ''
# Accept all ICMPv6 messages except redirects and node
# information queries (type 139). See RFC 4890, section
# 4.4.
ip6tables -A nixos-fw -p icmpv6 --icmpv6-type redirect -j DROP
ip6tables -A nixos-fw -p icmpv6 --icmpv6-type 139 -j DROP
ip6tables -A nixos-fw -p icmpv6 -j nixos-fw-accept
# Allow this host to act as a DHCPv6 client
ip6tables -A nixos-fw -d fe80::/64 -p udp --dport 546 -j nixos-fw-accept
''}
${cfg.extraCommands}
# Reject/drop everything else.
ip46tables -A nixos-fw -j nixos-fw-log-refuse
# Enable the firewall.
ip46tables -A INPUT -j nixos-fw
'';
stopScript = writeShScript "firewall-stop" ''
${helpers}
# Clean up in case reload fails
ip46tables -D INPUT -j nixos-drop 2>/dev/null || true
# Clean up after added ruleset
ip46tables -D INPUT -j nixos-fw 2>/dev/null || true
${optionalString (kernelHasRPFilter && (cfg.checkReversePath != false)) ''
ip46tables -t mangle -D PREROUTING -j nixos-fw-rpfilter 2>/dev/null || true
''}
${cfg.extraStopCommands}
'';
reloadScript = writeShScript "firewall-reload" ''
${helpers}
# Create a unique drop rule
ip46tables -D INPUT -j nixos-drop 2>/dev/null || true
ip46tables -F nixos-drop 2>/dev/null || true
ip46tables -X nixos-drop 2>/dev/null || true
ip46tables -N nixos-drop
ip46tables -A nixos-drop -j DROP
# Don't allow traffic to leak out until the script has completed
ip46tables -A INPUT -j nixos-drop
${cfg.extraStopCommands}
if ${startScript}; then
ip46tables -D INPUT -j nixos-drop 2>/dev/null || true
else
echo "Failed to reload firewall... Stopping"
${stopScript}
exit 1
fi
'';
in
{
options = {
networking.firewall = {
extraCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -A INPUT -p icmp -j ACCEPT";
description = lib.mdDoc ''
Additional shell commands executed as part of the firewall
initialisation script. These are executed just before the
final "reject" firewall rule is added, so they can be used
to allow packets that would otherwise be refused.
This option only works with the iptables based firewall.
'';
};
extraStopCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -P INPUT ACCEPT";
description = lib.mdDoc ''
Additional shell commands executed as part of the firewall
shutdown script. These are executed just after the removal
of the NixOS input rule, or if the service enters a failed
state.
This option only works with the iptables based firewall.
'';
};
};
};
# FIXME: Maybe if `enable' is false, the firewall should still be
# built but not started by default?
config = mkIf (cfg.enable && config.networking.nftables.enable == false) {
assertions = [
# This is approximately "checkReversePath -> kernelHasRPFilter",
# but the checkReversePath option can include non-boolean
# values.
{
assertion = cfg.checkReversePath == false || kernelHasRPFilter;
message = "This kernel does not support rpfilter";
}
];
networking.firewall.checkReversePath = mkIf (!kernelHasRPFilter) (mkDefault false);
systemd.services.firewall = {
description = "Firewall";
wantedBy = [ "sysinit.target" ];
wants = [ "network-pre.target" ];
before = [ "network-pre.target" ];
after = [ "systemd-modules-load.service" ];
path = [ cfg.package ] ++ cfg.extraPackages;
# FIXME: this module may also try to load kernel modules, but
# containers don't have CAP_SYS_MODULE. So the host system had
# better have all necessary modules already loaded.
unitConfig.ConditionCapability = "CAP_NET_ADMIN";
unitConfig.DefaultDependencies = false;
reloadIfChanged = true;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "@${startScript} firewall-start";
ExecReload = "@${reloadScript} firewall-reload";
ExecStop = "@${stopScript} firewall-stop";
};
};
};
}

View file

@ -0,0 +1,167 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.networking.firewall;
ifaceSet = concatStringsSep ", " (
map (x: ''"${x}"'') cfg.trustedInterfaces
);
portsToNftSet = ports: portRanges: concatStringsSep ", " (
map (x: toString x) ports
++ map (x: "${toString x.from}-${toString x.to}") portRanges
);
in
{
options = {
networking.firewall = {
extraInputRules = mkOption {
type = types.lines;
default = "";
example = "ip6 saddr { fc00::/7, fe80::/10 } tcp dport 24800 accept";
description = lib.mdDoc ''
Additional nftables rules to be appended to the input-allow
chain.
This option only works with the nftables based firewall.
'';
};
extraForwardRules = mkOption {
type = types.lines;
default = "";
example = "iifname wg0 accept";
description = lib.mdDoc ''
Additional nftables rules to be appended to the forward-allow
chain.
This option only works with the nftables based firewall.
'';
};
};
};
config = mkIf (cfg.enable && config.networking.nftables.enable) {
assertions = [
{
assertion = cfg.extraCommands == "";
message = "extraCommands is incompatible with the nftables based firewall: ${cfg.extraCommands}";
}
{
assertion = cfg.extraStopCommands == "";
message = "extraStopCommands is incompatible with the nftables based firewall: ${cfg.extraStopCommands}";
}
{
assertion = cfg.pingLimit == null || !(hasPrefix "--" cfg.pingLimit);
message = "nftables syntax like \"2/second\" should be used in networking.firewall.pingLimit";
}
{
assertion = config.networking.nftables.rulesetFile == null;
message = "networking.nftables.rulesetFile conflicts with the firewall";
}
];
networking.nftables.ruleset = ''
table inet nixos-fw {
${optionalString (cfg.checkReversePath != false) ''
chain rpfilter {
type filter hook prerouting priority mangle + 10; policy drop;
meta nfproto ipv4 udp sport . udp dport { 67 . 68, 68 . 67 } accept comment "DHCPv4 client/server"
fib saddr . mark ${optionalString (cfg.checkReversePath != "loose") ". iif"} oif exists accept
${optionalString cfg.logReversePathDrops ''
log level info prefix "rpfilter drop: "
''}
}
''}
chain input {
type filter hook input priority filter; policy drop;
${optionalString (ifaceSet != "") ''iifname { ${ifaceSet} } accept comment "trusted interfaces"''}
# Some ICMPv6 types like NDP is untracked
ct state vmap { invalid : drop, established : accept, related : accept, * : jump input-allow } comment "*: new and untracked"
${optionalString cfg.logRefusedConnections ''
tcp flags syn / fin,syn,rst,ack log level info prefix "refused connection: "
''}
${optionalString (cfg.logRefusedPackets && !cfg.logRefusedUnicastsOnly) ''
pkttype broadcast log level info prefix "refused broadcast: "
pkttype multicast log level info prefix "refused multicast: "
''}
${optionalString cfg.logRefusedPackets ''
pkttype host log level info prefix "refused packet: "
''}
${optionalString cfg.rejectPackets ''
meta l4proto tcp reject with tcp reset
reject
''}
}
chain input-allow {
${concatStrings (mapAttrsToList (iface: cfg:
let
ifaceExpr = optionalString (iface != "default") "iifname ${iface}";
tcpSet = portsToNftSet cfg.allowedTCPPorts cfg.allowedTCPPortRanges;
udpSet = portsToNftSet cfg.allowedUDPPorts cfg.allowedUDPPortRanges;
in
''
${optionalString (tcpSet != "") "${ifaceExpr} tcp dport { ${tcpSet} } accept"}
${optionalString (udpSet != "") "${ifaceExpr} udp dport { ${udpSet} } accept"}
''
) cfg.allInterfaces)}
${optionalString cfg.allowPing ''
icmp type echo-request ${optionalString (cfg.pingLimit != null) "limit rate ${cfg.pingLimit}"} accept comment "allow ping"
''}
icmpv6 type != { nd-redirect, 139 } accept comment "Accept all ICMPv6 messages except redirects and node information queries (type 139). See RFC 4890, section 4.4."
ip6 daddr fe80::/64 udp dport 546 accept comment "DHCPv6 client"
${cfg.extraInputRules}
}
${optionalString cfg.filterForward ''
chain forward {
type filter hook forward priority filter; policy drop;
ct state vmap { invalid : drop, established : accept, related : accept, * : jump forward-allow } comment "*: new and untracked"
}
chain forward-allow {
icmpv6 type != { router-renumbering, 139 } accept comment "Accept all ICMPv6 messages except renumbering and node information queries (type 139). See RFC 4890, section 4.3."
ct status dnat accept comment "allow port forward"
${cfg.extraForwardRules}
}
''}
}
'';
};
}

View file

@ -1,35 +1,3 @@
/* This module enables a simple firewall.
The firewall can be customised in arbitrary ways by setting
networking.firewall.extraCommands. For modularity, the firewall
uses several chains:
- nixos-fw is the main chain for input packet processing.
- nixos-fw-accept is called for accepted packets. If you want
additional logging, or want to reject certain packets anyway, you
can insert rules at the start of this chain.
- nixos-fw-log-refuse and nixos-fw-refuse are called for
refused packets. (The former jumps to the latter after logging
the packet.) If you want additional logging, or want to accept
certain packets anyway, you can insert rules at the start of
this chain.
- nixos-fw-rpfilter is used as the main chain in the mangle table,
called from the built-in PREROUTING chain. If the kernel
supports it and `cfg.checkReversePath` is set this chain will
perform a reverse path filter test.
- nixos-drop is used while reloading the firewall in order to drop
all traffic. Since reloading isn't implemented in an atomic way
this'll prevent any traffic from leaking through while reloading
the firewall. However, if the reloading fails, the firewall-stop
script will be called which in return will effectively disable the
complete firewall (in the default configuration).
*/
{ config, lib, pkgs, ... }:
with lib;
@ -38,216 +6,6 @@ let
cfg = config.networking.firewall;
inherit (config.boot.kernelPackages) kernel;
kernelHasRPFilter = ((kernel.config.isEnabled or (x: false)) "IP_NF_MATCH_RPFILTER") || (kernel.features.netfilterRPFilter or false);
helpers = import ./helpers.nix { inherit config lib; };
writeShScript = name: text: let dir = pkgs.writeScriptBin name ''
#! ${pkgs.runtimeShell} -e
${text}
''; in "${dir}/bin/${name}";
defaultInterface = { default = mapAttrs (name: value: cfg.${name}) commonOptions; };
allInterfaces = defaultInterface // cfg.interfaces;
startScript = writeShScript "firewall-start" ''
${helpers}
# Flush the old firewall rules. !!! Ideally, updating the
# firewall would be atomic. Apparently that's possible
# with iptables-restore.
ip46tables -D INPUT -j nixos-fw 2> /dev/null || true
for chain in nixos-fw nixos-fw-accept nixos-fw-log-refuse nixos-fw-refuse; do
ip46tables -F "$chain" 2> /dev/null || true
ip46tables -X "$chain" 2> /dev/null || true
done
# The "nixos-fw-accept" chain just accepts packets.
ip46tables -N nixos-fw-accept
ip46tables -A nixos-fw-accept -j ACCEPT
# The "nixos-fw-refuse" chain rejects or drops packets.
ip46tables -N nixos-fw-refuse
${if cfg.rejectPackets then ''
# Send a reset for existing TCP connections that we've
# somehow forgotten about. Send ICMP "port unreachable"
# for everything else.
ip46tables -A nixos-fw-refuse -p tcp ! --syn -j REJECT --reject-with tcp-reset
ip46tables -A nixos-fw-refuse -j REJECT
'' else ''
ip46tables -A nixos-fw-refuse -j DROP
''}
# The "nixos-fw-log-refuse" chain performs logging, then
# jumps to the "nixos-fw-refuse" chain.
ip46tables -N nixos-fw-log-refuse
${optionalString cfg.logRefusedConnections ''
ip46tables -A nixos-fw-log-refuse -p tcp --syn -j LOG --log-level info --log-prefix "refused connection: "
''}
${optionalString (cfg.logRefusedPackets && !cfg.logRefusedUnicastsOnly) ''
ip46tables -A nixos-fw-log-refuse -m pkttype --pkt-type broadcast \
-j LOG --log-level info --log-prefix "refused broadcast: "
ip46tables -A nixos-fw-log-refuse -m pkttype --pkt-type multicast \
-j LOG --log-level info --log-prefix "refused multicast: "
''}
ip46tables -A nixos-fw-log-refuse -m pkttype ! --pkt-type unicast -j nixos-fw-refuse
${optionalString cfg.logRefusedPackets ''
ip46tables -A nixos-fw-log-refuse \
-j LOG --log-level info --log-prefix "refused packet: "
''}
ip46tables -A nixos-fw-log-refuse -j nixos-fw-refuse
# The "nixos-fw" chain does the actual work.
ip46tables -N nixos-fw
# Clean up rpfilter rules
ip46tables -t mangle -D PREROUTING -j nixos-fw-rpfilter 2> /dev/null || true
ip46tables -t mangle -F nixos-fw-rpfilter 2> /dev/null || true
ip46tables -t mangle -X nixos-fw-rpfilter 2> /dev/null || true
${optionalString (kernelHasRPFilter && (cfg.checkReversePath != false)) ''
# Perform a reverse-path test to refuse spoofers
# For now, we just drop, as the mangle table doesn't have a log-refuse yet
ip46tables -t mangle -N nixos-fw-rpfilter 2> /dev/null || true
ip46tables -t mangle -A nixos-fw-rpfilter -m rpfilter --validmark ${optionalString (cfg.checkReversePath == "loose") "--loose"} -j RETURN
# Allows this host to act as a DHCP4 client without first having to use APIPA
iptables -t mangle -A nixos-fw-rpfilter -p udp --sport 67 --dport 68 -j RETURN
# Allows this host to act as a DHCPv4 server
iptables -t mangle -A nixos-fw-rpfilter -s 0.0.0.0 -d 255.255.255.255 -p udp --sport 68 --dport 67 -j RETURN
${optionalString cfg.logReversePathDrops ''
ip46tables -t mangle -A nixos-fw-rpfilter -j LOG --log-level info --log-prefix "rpfilter drop: "
''}
ip46tables -t mangle -A nixos-fw-rpfilter -j DROP
ip46tables -t mangle -A PREROUTING -j nixos-fw-rpfilter
''}
# Accept all traffic on the trusted interfaces.
${flip concatMapStrings cfg.trustedInterfaces (iface: ''
ip46tables -A nixos-fw -i ${iface} -j nixos-fw-accept
'')}
# Accept packets from established or related connections.
ip46tables -A nixos-fw -m conntrack --ctstate ESTABLISHED,RELATED -j nixos-fw-accept
# Accept connections to the allowed TCP ports.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (port:
''
ip46tables -A nixos-fw -p tcp --dport ${toString port} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedTCPPorts
) allInterfaces)}
# Accept connections to the allowed TCP port ranges.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (rangeAttr:
let range = toString rangeAttr.from + ":" + toString rangeAttr.to; in
''
ip46tables -A nixos-fw -p tcp --dport ${range} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedTCPPortRanges
) allInterfaces)}
# Accept packets on the allowed UDP ports.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (port:
''
ip46tables -A nixos-fw -p udp --dport ${toString port} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedUDPPorts
) allInterfaces)}
# Accept packets on the allowed UDP port ranges.
${concatStrings (mapAttrsToList (iface: cfg:
concatMapStrings (rangeAttr:
let range = toString rangeAttr.from + ":" + toString rangeAttr.to; in
''
ip46tables -A nixos-fw -p udp --dport ${range} -j nixos-fw-accept ${optionalString (iface != "default") "-i ${iface}"}
''
) cfg.allowedUDPPortRanges
) allInterfaces)}
# Optionally respond to ICMPv4 pings.
${optionalString cfg.allowPing ''
iptables -w -A nixos-fw -p icmp --icmp-type echo-request ${optionalString (cfg.pingLimit != null)
"-m limit ${cfg.pingLimit} "
}-j nixos-fw-accept
''}
${optionalString config.networking.enableIPv6 ''
# Accept all ICMPv6 messages except redirects and node
# information queries (type 139). See RFC 4890, section
# 4.4.
ip6tables -A nixos-fw -p icmpv6 --icmpv6-type redirect -j DROP
ip6tables -A nixos-fw -p icmpv6 --icmpv6-type 139 -j DROP
ip6tables -A nixos-fw -p icmpv6 -j nixos-fw-accept
# Allow this host to act as a DHCPv6 client
ip6tables -A nixos-fw -d fe80::/64 -p udp --dport 546 -j nixos-fw-accept
''}
${cfg.extraCommands}
# Reject/drop everything else.
ip46tables -A nixos-fw -j nixos-fw-log-refuse
# Enable the firewall.
ip46tables -A INPUT -j nixos-fw
'';
stopScript = writeShScript "firewall-stop" ''
${helpers}
# Clean up in case reload fails
ip46tables -D INPUT -j nixos-drop 2>/dev/null || true
# Clean up after added ruleset
ip46tables -D INPUT -j nixos-fw 2>/dev/null || true
${optionalString (kernelHasRPFilter && (cfg.checkReversePath != false)) ''
ip46tables -t mangle -D PREROUTING -j nixos-fw-rpfilter 2>/dev/null || true
''}
${cfg.extraStopCommands}
'';
reloadScript = writeShScript "firewall-reload" ''
${helpers}
# Create a unique drop rule
ip46tables -D INPUT -j nixos-drop 2>/dev/null || true
ip46tables -F nixos-drop 2>/dev/null || true
ip46tables -X nixos-drop 2>/dev/null || true
ip46tables -N nixos-drop
ip46tables -A nixos-drop -j DROP
# Don't allow traffic to leak out until the script has completed
ip46tables -A INPUT -j nixos-drop
${cfg.extraStopCommands}
if ${startScript}; then
ip46tables -D INPUT -j nixos-drop 2>/dev/null || true
else
echo "Failed to reload firewall... Stopping"
${stopScript}
exit 1
fi
'';
canonicalizePortList =
ports: lib.unique (builtins.sort builtins.lessThan ports);
@ -257,8 +15,7 @@ let
default = [ ];
apply = canonicalizePortList;
example = [ 22 80 ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
List of TCP ports on which incoming connections are
accepted.
'';
@ -268,8 +25,7 @@ let
type = types.listOf (types.attrsOf types.port);
default = [ ];
example = [{ from = 8999; to = 9003; }];
description =
lib.mdDoc ''
description = lib.mdDoc ''
A range of TCP ports on which incoming connections are
accepted.
'';
@ -280,8 +36,7 @@ let
default = [ ];
apply = canonicalizePortList;
example = [ 53 ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
List of open UDP ports.
'';
};
@ -290,8 +45,7 @@ let
type = types.listOf (types.attrsOf types.port);
default = [ ];
example = [{ from = 60000; to = 61000; }];
description =
lib.mdDoc ''
description = lib.mdDoc ''
Range of open UDP ports.
'';
};
@ -301,39 +55,33 @@ in
{
###### interface
options = {
networking.firewall = {
enable = mkOption {
type = types.bool;
default = true;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to enable the firewall. This is a simple stateful
firewall that blocks connection attempts to unauthorised TCP
or UDP ports on this machine. It does not affect packet
forwarding.
or UDP ports on this machine.
'';
};
package = mkOption {
type = types.package;
default = pkgs.iptables;
defaultText = literalExpression "pkgs.iptables";
default = if config.networking.nftables.enable then pkgs.nftables else pkgs.iptables;
defaultText = literalExpression ''if config.networking.nftables.enable then "pkgs.nftables" else "pkgs.iptables"'';
example = literalExpression "pkgs.iptables-legacy";
description =
lib.mdDoc ''
The iptables package to use for running the firewall service.
description = lib.mdDoc ''
The package to use for running the firewall service.
'';
};
logRefusedConnections = mkOption {
type = types.bool;
default = true;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to log rejected or dropped incoming connections.
Note: The logs are found in the kernel logs, i.e. dmesg
or journalctl -k.
@ -343,8 +91,7 @@ in
logRefusedPackets = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to log all rejected or dropped incoming packets.
This tends to give a lot of log messages, so it's mostly
useful for debugging.
@ -356,8 +103,7 @@ in
logRefusedUnicastsOnly = mkOption {
type = types.bool;
default = true;
description =
lib.mdDoc ''
description = lib.mdDoc ''
If {option}`networking.firewall.logRefusedPackets`
and this option are enabled, then only log packets
specifically directed at this machine, i.e., not broadcasts
@ -368,8 +114,7 @@ in
rejectPackets = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc ''
description = lib.mdDoc ''
If set, refused packets are rejected rather than dropped
(ignored). This means that an ICMP "port unreachable" error
message is sent back to the client (or a TCP RST packet in
@ -382,8 +127,7 @@ in
type = types.listOf types.str;
default = [ ];
example = [ "enp0s2" ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
Traffic coming in from these interfaces will be accepted
unconditionally. Traffic from the loopback (lo) interface
will always be accepted.
@ -393,8 +137,7 @@ in
allowPing = mkOption {
type = types.bool;
default = true;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to respond to incoming ICMPv4 echo requests
("pings"). ICMPv6 pings are always allowed because the
larger address space of IPv6 makes network scanning much
@ -406,21 +149,23 @@ in
type = types.nullOr (types.separatedString " ");
default = null;
example = "--limit 1/minute --limit-burst 5";
description =
lib.mdDoc ''
If pings are allowed, this allows setting rate limits
on them. If non-null, this option should be in the form of
flags like "--limit 1/minute --limit-burst 5"
description = lib.mdDoc ''
If pings are allowed, this allows setting rate limits on them.
For the iptables based firewall, it should be set like
"--limit 1/minute --limit-burst 5".
For the nftables based firewall, it should be set like
"2/second" or "1/minute burst 5 packets".
'';
};
checkReversePath = mkOption {
type = types.either types.bool (types.enum [ "strict" "loose" ]);
default = kernelHasRPFilter;
defaultText = literalMD "`true` if supported by the chosen kernel";
default = true;
defaultText = literalMD "`true` except if the iptables based firewall is in use and the kernel lacks rpfilter support";
example = "loose";
description =
lib.mdDoc ''
description = lib.mdDoc ''
Performs a reverse path filter test on a packet. If a reply
to the packet would not be sent via the same interface that
the packet arrived on, it is refused.
@ -431,27 +176,34 @@ in
This option can be either true (or "strict"), "loose" (only
drop the packet if the source address is not reachable via any
interface) or false. Defaults to the value of
kernelHasRPFilter.
interface) or false.
'';
};
logReversePathDrops = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Logs dropped packets failing the reverse path filter test if
the option networking.firewall.checkReversePath is enabled.
'';
};
filterForward = mkOption {
type = types.bool;
default = false;
description = lib.mdDoc ''
Enable filtering in IP forwarding.
This option only works with the nftables based firewall.
'';
};
connectionTrackingModules = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "ftp" "irc" "sane" "sip" "tftp" "amanda" "h323" "netbios_sn" "pptp" "snmp" ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
List of connection-tracking helpers that are auto-loaded.
The complete list of possible values is given in the example.
@ -470,8 +222,7 @@ in
autoLoadConntrackHelpers = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to auto-load connection-tracking helpers.
See the description at networking.firewall.connectionTrackingModules
@ -479,62 +230,47 @@ in
'';
};
extraCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -A INPUT -p icmp -j ACCEPT";
description =
lib.mdDoc ''
Additional shell commands executed as part of the firewall
initialisation script. These are executed just before the
final "reject" firewall rule is added, so they can be used
to allow packets that would otherwise be refused.
'';
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [ ];
example = literalExpression "[ pkgs.ipset ]";
description =
lib.mdDoc ''
description = lib.mdDoc ''
Additional packages to be included in the environment of the system
as well as the path of networking.firewall.extraCommands.
'';
};
extraStopCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -P INPUT ACCEPT";
description =
lib.mdDoc ''
Additional shell commands executed as part of the firewall
shutdown script. These are executed just after the removal
of the NixOS input rule, or if the service enters a failed
state.
'';
};
interfaces = mkOption {
default = { };
type = with types; attrsOf (submodule [{ options = commonOptions; }]);
description =
lib.mdDoc ''
description = lib.mdDoc ''
Interface-specific open ports.
'';
};
allInterfaces = mkOption {
internal = true;
visible = false;
default = { default = mapAttrs (name: value: cfg.${name}) commonOptions; } // cfg.interfaces;
type = with types; attrsOf (submodule [{ options = commonOptions; }]);
description = lib.mdDoc ''
All open ports.
'';
};
} // commonOptions;
};
###### implementation
# FIXME: Maybe if `enable' is false, the firewall should still be
# built but not started by default?
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.filterForward -> config.networking.nftables.enable;
message = "filterForward only works with the nftables based firewall";
}
];
networking.firewall.trustedInterfaces = [ "lo" ];
environment.systemPackages = [ cfg.package ] ++ cfg.extraPackages;
@ -545,40 +281,6 @@ in
options nf_conntrack nf_conntrack_helper=1
'';
assertions = [
# This is approximately "checkReversePath -> kernelHasRPFilter",
# but the checkReversePath option can include non-boolean
# values.
{ assertion = cfg.checkReversePath == false || kernelHasRPFilter;
message = "This kernel does not support rpfilter"; }
];
systemd.services.firewall = {
description = "Firewall";
wantedBy = [ "sysinit.target" ];
wants = [ "network-pre.target" ];
before = [ "network-pre.target" ];
after = [ "systemd-modules-load.service" ];
path = [ cfg.package ] ++ cfg.extraPackages;
# FIXME: this module may also try to load kernel modules, but
# containers don't have CAP_SYS_MODULE. So the host system had
# better have all necessary modules already loaded.
unitConfig.ConditionCapability = "CAP_NET_ADMIN";
unitConfig.DefaultDependencies = false;
reloadIfChanged = true;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "@${startScript} firewall-start";
ExecReload = "@${reloadScript} firewall-reload";
ExecStop = "@${stopScript} firewall-stop";
};
};
};
}

View file

@ -0,0 +1,191 @@
# This module enables Network Address Translation (NAT).
# XXX: todo: support multiple upstream links
# see http://yesican.chsoft.biz/lartc/MultihomedLinuxNetworking.html
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.networking.nat;
mkDest = externalIP:
if externalIP == null
then "-j MASQUERADE"
else "-j SNAT --to-source ${externalIP}";
dest = mkDest cfg.externalIP;
destIPv6 = mkDest cfg.externalIPv6;
# Whether given IP (plus optional port) is an IPv6.
isIPv6 = ip: builtins.length (lib.splitString ":" ip) > 2;
helpers = import ./helpers.nix { inherit config lib; };
flushNat = ''
${helpers}
ip46tables -w -t nat -D PREROUTING -j nixos-nat-pre 2>/dev/null|| true
ip46tables -w -t nat -F nixos-nat-pre 2>/dev/null || true
ip46tables -w -t nat -X nixos-nat-pre 2>/dev/null || true
ip46tables -w -t nat -D POSTROUTING -j nixos-nat-post 2>/dev/null || true
ip46tables -w -t nat -F nixos-nat-post 2>/dev/null || true
ip46tables -w -t nat -X nixos-nat-post 2>/dev/null || true
ip46tables -w -t nat -D OUTPUT -j nixos-nat-out 2>/dev/null || true
ip46tables -w -t nat -F nixos-nat-out 2>/dev/null || true
ip46tables -w -t nat -X nixos-nat-out 2>/dev/null || true
${cfg.extraStopCommands}
'';
mkSetupNat = { iptables, dest, internalIPs, forwardPorts }: ''
# We can't match on incoming interface in POSTROUTING, so
# mark packets coming from the internal interfaces.
${concatMapStrings (iface: ''
${iptables} -w -t nat -A nixos-nat-pre \
-i '${iface}' -j MARK --set-mark 1
'') cfg.internalInterfaces}
# NAT the marked packets.
${optionalString (cfg.internalInterfaces != []) ''
${iptables} -w -t nat -A nixos-nat-post -m mark --mark 1 \
${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} ${dest}
''}
# NAT packets coming from the internal IPs.
${concatMapStrings (range: ''
${iptables} -w -t nat -A nixos-nat-post \
-s '${range}' ${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} ${dest}
'') internalIPs}
# NAT from external ports to internal ports.
${concatMapStrings (fwd: ''
${iptables} -w -t nat -A nixos-nat-pre \
-i ${toString cfg.externalInterface} -p ${fwd.proto} \
--dport ${builtins.toString fwd.sourcePort} \
-j DNAT --to-destination ${fwd.destination}
${concatMapStrings (loopbackip:
let
matchIP = if isIPv6 fwd.destination then "[[]([0-9a-fA-F:]+)[]]" else "([0-9.]+)";
m = builtins.match "${matchIP}:([0-9-]+)" fwd.destination;
destinationIP = if m == null then throw "bad ip:ports `${fwd.destination}'" else elemAt m 0;
destinationPorts = if m == null then throw "bad ip:ports `${fwd.destination}'" else builtins.replaceStrings ["-"] [":"] (elemAt m 1);
in ''
# Allow connections to ${loopbackip}:${toString fwd.sourcePort} from the host itself
${iptables} -w -t nat -A nixos-nat-out \
-d ${loopbackip} -p ${fwd.proto} \
--dport ${builtins.toString fwd.sourcePort} \
-j DNAT --to-destination ${fwd.destination}
# Allow connections to ${loopbackip}:${toString fwd.sourcePort} from other hosts behind NAT
${iptables} -w -t nat -A nixos-nat-pre \
-d ${loopbackip} -p ${fwd.proto} \
--dport ${builtins.toString fwd.sourcePort} \
-j DNAT --to-destination ${fwd.destination}
${iptables} -w -t nat -A nixos-nat-post \
-d ${destinationIP} -p ${fwd.proto} \
--dport ${destinationPorts} \
-j SNAT --to-source ${loopbackip}
'') fwd.loopbackIPs}
'') forwardPorts}
'';
setupNat = ''
${helpers}
# Create subchains where we store rules
ip46tables -w -t nat -N nixos-nat-pre
ip46tables -w -t nat -N nixos-nat-post
ip46tables -w -t nat -N nixos-nat-out
${mkSetupNat {
iptables = "iptables";
inherit dest;
inherit (cfg) internalIPs;
forwardPorts = filter (x: !(isIPv6 x.destination)) cfg.forwardPorts;
}}
${optionalString cfg.enableIPv6 (mkSetupNat {
iptables = "ip6tables";
dest = destIPv6;
internalIPs = cfg.internalIPv6s;
forwardPorts = filter (x: isIPv6 x.destination) cfg.forwardPorts;
})}
${optionalString (cfg.dmzHost != null) ''
iptables -w -t nat -A nixos-nat-pre \
-i ${toString cfg.externalInterface} -j DNAT \
--to-destination ${cfg.dmzHost}
''}
${cfg.extraCommands}
# Append our chains to the nat tables
ip46tables -w -t nat -A PREROUTING -j nixos-nat-pre
ip46tables -w -t nat -A POSTROUTING -j nixos-nat-post
ip46tables -w -t nat -A OUTPUT -j nixos-nat-out
'';
in
{
options = {
networking.nat.extraCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -A INPUT -p icmp -j ACCEPT";
description = lib.mdDoc ''
Additional shell commands executed as part of the nat
initialisation script.
This option is incompatible with the nftables based nat module.
'';
};
networking.nat.extraStopCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -D INPUT -p icmp -j ACCEPT || true";
description = lib.mdDoc ''
Additional shell commands executed as part of the nat
teardown script.
This option is incompatible with the nftables based nat module.
'';
};
};
config = mkIf (!config.networking.nftables.enable)
(mkMerge [
({ networking.firewall.extraCommands = mkBefore flushNat; })
(mkIf config.networking.nat.enable {
networking.firewall = mkIf config.networking.firewall.enable {
extraCommands = setupNat;
extraStopCommands = flushNat;
};
systemd.services = mkIf (!config.networking.firewall.enable) {
nat = {
description = "Network Address Translation";
wantedBy = [ "network.target" ];
after = [ "network-pre.target" "systemd-modules-load.service" ];
path = [ config.networking.firewall.package ];
unitConfig.ConditionCapability = "CAP_NET_ADMIN";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = flushNat + setupNat;
postStop = flushNat;
};
};
})
]);
}

View file

@ -0,0 +1,184 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.networking.nat;
mkDest = externalIP:
if externalIP == null
then "masquerade"
else "snat ${externalIP}";
dest = mkDest cfg.externalIP;
destIPv6 = mkDest cfg.externalIPv6;
toNftSet = list: concatStringsSep ", " list;
toNftRange = ports: replaceStrings [ ":" ] [ "-" ] (toString ports);
ifaceSet = toNftSet (map (x: ''"${x}"'') cfg.internalInterfaces);
ipSet = toNftSet cfg.internalIPs;
ipv6Set = toNftSet cfg.internalIPv6s;
oifExpr = optionalString (cfg.externalInterface != null) ''oifname "${cfg.externalInterface}"'';
# Whether given IP (plus optional port) is an IPv6.
isIPv6 = ip: length (lib.splitString ":" ip) > 2;
splitIPPorts = IPPorts:
let
matchIP = if isIPv6 IPPorts then "[[]([0-9a-fA-F:]+)[]]" else "([0-9.]+)";
m = builtins.match "${matchIP}:([0-9-]+)" IPPorts;
in
{
IP = if m == null then throw "bad ip:ports `${IPPorts}'" else elemAt m 0;
ports = if m == null then throw "bad ip:ports `${IPPorts}'" else elemAt m 1;
};
mkTable = { ipVer, dest, ipSet, forwardPorts, dmzHost }:
let
# nftables does not support both port and port range as values in a dnat map.
# e.g. "dnat th dport map { 80 : 10.0.0.1 . 80, 443 : 10.0.0.2 . 900-1000 }"
# So we split them.
fwdPorts = filter (x: length (splitString "-" x.destination) == 1) forwardPorts;
fwdPortsRange = filter (x: length (splitString "-" x.destination) > 1) forwardPorts;
# nftables maps for port forward
# l4proto . dport : addr . port
toFwdMap = forwardPorts: toNftSet (map
(fwd:
with (splitIPPorts fwd.destination);
"${fwd.proto} . ${toNftRange fwd.sourcePort} : ${IP} . ${ports}"
)
forwardPorts);
fwdMap = toFwdMap fwdPorts;
fwdRangeMap = toFwdMap fwdPortsRange;
# nftables maps for port forward loopback dnat
# daddr . l4proto . dport : addr . port
toFwdLoopDnatMap = forwardPorts: toNftSet (concatMap
(fwd: map
(loopbackip:
with (splitIPPorts fwd.destination);
"${loopbackip} . ${fwd.proto} . ${toNftRange fwd.sourcePort} : ${IP} . ${ports}"
)
fwd.loopbackIPs)
forwardPorts);
fwdLoopDnatMap = toFwdLoopDnatMap fwdPorts;
fwdLoopDnatRangeMap = toFwdLoopDnatMap fwdPortsRange;
# nftables set for port forward loopback snat
# daddr . l4proto . dport
fwdLoopSnatSet = toNftSet (map
(fwd:
with (splitIPPorts fwd.destination);
"${IP} . ${fwd.proto} . ${ports}"
)
forwardPorts);
in
''
chain pre {
type nat hook prerouting priority dstnat;
${optionalString (fwdMap != "") ''
iifname "${cfg.externalInterface}" dnat meta l4proto . th dport map { ${fwdMap} } comment "port forward"
''}
${optionalString (fwdRangeMap != "") ''
iifname "${cfg.externalInterface}" dnat meta l4proto . th dport map { ${fwdRangeMap} } comment "port forward"
''}
${optionalString (fwdLoopDnatMap != "") ''
dnat ${ipVer} daddr . meta l4proto . th dport map { ${fwdLoopDnatMap} } comment "port forward loopback from other hosts behind NAT"
''}
${optionalString (fwdLoopDnatRangeMap != "") ''
dnat ${ipVer} daddr . meta l4proto . th dport map { ${fwdLoopDnatRangeMap} } comment "port forward loopback from other hosts behind NAT"
''}
${optionalString (dmzHost != null) ''
iifname "${cfg.externalInterface}" dnat ${dmzHost} comment "dmz"
''}
}
chain post {
type nat hook postrouting priority srcnat;
${optionalString (ifaceSet != "") ''
iifname { ${ifaceSet} } ${oifExpr} ${dest} comment "from internal interfaces"
''}
${optionalString (ipSet != "") ''
${ipVer} saddr { ${ipSet} } ${oifExpr} ${dest} comment "from internal IPs"
''}
${optionalString (fwdLoopSnatSet != "") ''
iifname != "${cfg.externalInterface}" ${ipVer} daddr . meta l4proto . th dport { ${fwdLoopSnatSet} } masquerade comment "port forward loopback snat"
''}
}
chain out {
type nat hook output priority mangle;
${optionalString (fwdLoopDnatMap != "") ''
dnat ${ipVer} daddr . meta l4proto . th dport map { ${fwdLoopDnatMap} } comment "port forward loopback from the host itself"
''}
${optionalString (fwdLoopDnatRangeMap != "") ''
dnat ${ipVer} daddr . meta l4proto . th dport map { ${fwdLoopDnatRangeMap} } comment "port forward loopback from the host itself"
''}
}
'';
in
{
config = mkIf (config.networking.nftables.enable && cfg.enable) {
assertions = [
{
assertion = cfg.extraCommands == "";
message = "extraCommands is incompatible with the nftables based nat module: ${cfg.extraCommands}";
}
{
assertion = cfg.extraStopCommands == "";
message = "extraStopCommands is incompatible with the nftables based nat module: ${cfg.extraStopCommands}";
}
{
assertion = config.networking.nftables.rulesetFile == null;
message = "networking.nftables.rulesetFile conflicts with the nat module";
}
];
networking.nftables.ruleset = ''
table ip nixos-nat {
${mkTable {
ipVer = "ip";
inherit dest ipSet;
forwardPorts = filter (x: !(isIPv6 x.destination)) cfg.forwardPorts;
inherit (cfg) dmzHost;
}}
}
${optionalString cfg.enableIPv6 ''
table ip6 nixos-nat {
${mkTable {
ipVer = "ip6";
dest = destIPv6;
ipSet = ipv6Set;
forwardPorts = filter (x: isIPv6 x.destination) cfg.forwardPorts;
dmzHost = null;
}}
}
''}
'';
networking.firewall.extraForwardRules = optionalString config.networking.firewall.filterForward ''
${optionalString (ifaceSet != "") ''
iifname { ${ifaceSet} } ${oifExpr} accept comment "from internal interfaces"
''}
${optionalString (ipSet != "") ''
ip saddr { ${ipSet} } ${oifExpr} accept comment "from internal IPs"
''}
${optionalString (ipv6Set != "") ''
ip6 saddr { ${ipv6Set} } ${oifExpr} accept comment "from internal IPv6s"
''}
'';
};
}

View file

@ -7,136 +7,19 @@
with lib;
let
cfg = config.networking.nat;
mkDest = externalIP: if externalIP == null
then "-j MASQUERADE"
else "-j SNAT --to-source ${externalIP}";
dest = mkDest cfg.externalIP;
destIPv6 = mkDest cfg.externalIPv6;
# Whether given IP (plus optional port) is an IPv6.
isIPv6 = ip: builtins.length (lib.splitString ":" ip) > 2;
helpers = import ./helpers.nix { inherit config lib; };
flushNat = ''
${helpers}
ip46tables -w -t nat -D PREROUTING -j nixos-nat-pre 2>/dev/null|| true
ip46tables -w -t nat -F nixos-nat-pre 2>/dev/null || true
ip46tables -w -t nat -X nixos-nat-pre 2>/dev/null || true
ip46tables -w -t nat -D POSTROUTING -j nixos-nat-post 2>/dev/null || true
ip46tables -w -t nat -F nixos-nat-post 2>/dev/null || true
ip46tables -w -t nat -X nixos-nat-post 2>/dev/null || true
ip46tables -w -t nat -D OUTPUT -j nixos-nat-out 2>/dev/null || true
ip46tables -w -t nat -F nixos-nat-out 2>/dev/null || true
ip46tables -w -t nat -X nixos-nat-out 2>/dev/null || true
${cfg.extraStopCommands}
'';
mkSetupNat = { iptables, dest, internalIPs, forwardPorts }: ''
# We can't match on incoming interface in POSTROUTING, so
# mark packets coming from the internal interfaces.
${concatMapStrings (iface: ''
${iptables} -w -t nat -A nixos-nat-pre \
-i '${iface}' -j MARK --set-mark 1
'') cfg.internalInterfaces}
# NAT the marked packets.
${optionalString (cfg.internalInterfaces != []) ''
${iptables} -w -t nat -A nixos-nat-post -m mark --mark 1 \
${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} ${dest}
''}
# NAT packets coming from the internal IPs.
${concatMapStrings (range: ''
${iptables} -w -t nat -A nixos-nat-post \
-s '${range}' ${optionalString (cfg.externalInterface != null) "-o ${cfg.externalInterface}"} ${dest}
'') internalIPs}
# NAT from external ports to internal ports.
${concatMapStrings (fwd: ''
${iptables} -w -t nat -A nixos-nat-pre \
-i ${toString cfg.externalInterface} -p ${fwd.proto} \
--dport ${builtins.toString fwd.sourcePort} \
-j DNAT --to-destination ${fwd.destination}
${concatMapStrings (loopbackip:
let
matchIP = if isIPv6 fwd.destination then "[[]([0-9a-fA-F:]+)[]]" else "([0-9.]+)";
m = builtins.match "${matchIP}:([0-9-]+)" fwd.destination;
destinationIP = if m == null then throw "bad ip:ports `${fwd.destination}'" else elemAt m 0;
destinationPorts = if m == null then throw "bad ip:ports `${fwd.destination}'" else builtins.replaceStrings ["-"] [":"] (elemAt m 1);
in ''
# Allow connections to ${loopbackip}:${toString fwd.sourcePort} from the host itself
${iptables} -w -t nat -A nixos-nat-out \
-d ${loopbackip} -p ${fwd.proto} \
--dport ${builtins.toString fwd.sourcePort} \
-j DNAT --to-destination ${fwd.destination}
# Allow connections to ${loopbackip}:${toString fwd.sourcePort} from other hosts behind NAT
${iptables} -w -t nat -A nixos-nat-pre \
-d ${loopbackip} -p ${fwd.proto} \
--dport ${builtins.toString fwd.sourcePort} \
-j DNAT --to-destination ${fwd.destination}
${iptables} -w -t nat -A nixos-nat-post \
-d ${destinationIP} -p ${fwd.proto} \
--dport ${destinationPorts} \
-j SNAT --to-source ${loopbackip}
'') fwd.loopbackIPs}
'') forwardPorts}
'';
setupNat = ''
${helpers}
# Create subchains where we store rules
ip46tables -w -t nat -N nixos-nat-pre
ip46tables -w -t nat -N nixos-nat-post
ip46tables -w -t nat -N nixos-nat-out
${mkSetupNat {
iptables = "iptables";
inherit dest;
inherit (cfg) internalIPs;
forwardPorts = filter (x: !(isIPv6 x.destination)) cfg.forwardPorts;
}}
${optionalString cfg.enableIPv6 (mkSetupNat {
iptables = "ip6tables";
dest = destIPv6;
internalIPs = cfg.internalIPv6s;
forwardPorts = filter (x: isIPv6 x.destination) cfg.forwardPorts;
})}
${optionalString (cfg.dmzHost != null) ''
iptables -w -t nat -A nixos-nat-pre \
-i ${toString cfg.externalInterface} -j DNAT \
--to-destination ${cfg.dmzHost}
''}
${cfg.extraCommands}
# Append our chains to the nat tables
ip46tables -w -t nat -A PREROUTING -j nixos-nat-pre
ip46tables -w -t nat -A POSTROUTING -j nixos-nat-post
ip46tables -w -t nat -A OUTPUT -j nixos-nat-out
'';
in
{
###### interface
options = {
networking.nat.enable = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to enable Network Address Translation (NAT).
'';
};
@ -144,8 +27,7 @@ in
networking.nat.enableIPv6 = mkOption {
type = types.bool;
default = false;
description =
lib.mdDoc ''
description = lib.mdDoc ''
Whether to enable IPv6 NAT.
'';
};
@ -154,8 +36,7 @@ in
type = types.listOf types.str;
default = [ ];
example = [ "eth0" ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
The interfaces for which to perform NAT. Packets coming from
these interface and destined for the external interface will
be rewritten.
@ -166,8 +47,7 @@ in
type = types.listOf types.str;
default = [ ];
example = [ "192.168.1.0/24" ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
The IP address ranges for which to perform NAT. Packets
coming from these addresses (on any interface) and destined
for the external interface will be rewritten.
@ -178,8 +58,7 @@ in
type = types.listOf types.str;
default = [ ];
example = [ "fc00::/64" ];
description =
lib.mdDoc ''
description = lib.mdDoc ''
The IPv6 address ranges for which to perform NAT. Packets
coming from these addresses (on any interface) and destined
for the external interface will be rewritten.
@ -190,8 +69,7 @@ in
type = types.nullOr types.str;
default = null;
example = "eth1";
description =
lib.mdDoc ''
description = lib.mdDoc ''
The name of the external network interface.
'';
};
@ -200,8 +78,7 @@ in
type = types.nullOr types.str;
default = null;
example = "203.0.113.123";
description =
lib.mdDoc ''
description = lib.mdDoc ''
The public IP address to which packets from the local
network are to be rewritten. If this is left empty, the
IP address associated with the external interface will be
@ -213,8 +90,7 @@ in
type = types.nullOr types.str;
default = null;
example = "2001:dc0:2001:11::175";
description =
lib.mdDoc ''
description = lib.mdDoc ''
The public IPv6 address to which packets from the local
network are to be rewritten. If this is left empty, the
IP address associated with the external interface will be
@ -257,8 +133,7 @@ in
{ sourcePort = 8080; destination = "10.0.0.1:80"; proto = "tcp"; }
{ sourcePort = 8080; destination = "[fc00::2]:80"; proto = "tcp"; }
];
description =
lib.mdDoc ''
description = lib.mdDoc ''
List of forwarded ports from the external interface to
internal destinations by using DNAT. Destination can be
IPv6 if IPv6 NAT is enabled.
@ -269,52 +144,28 @@ in
type = types.nullOr types.str;
default = null;
example = "10.0.0.1";
description =
lib.mdDoc ''
description = lib.mdDoc ''
The local IP address to which all traffic that does not match any
forwarding rule is forwarded.
'';
};
networking.nat.extraCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -A INPUT -p icmp -j ACCEPT";
description =
lib.mdDoc ''
Additional shell commands executed as part of the nat
initialisation script.
'';
};
networking.nat.extraStopCommands = mkOption {
type = types.lines;
default = "";
example = "iptables -D INPUT -p icmp -j ACCEPT || true";
description =
lib.mdDoc ''
Additional shell commands executed as part of the nat
teardown script.
'';
};
};
###### implementation
config = mkMerge [
{ networking.firewall.extraCommands = mkBefore flushNat; }
(mkIf config.networking.nat.enable {
config = mkIf config.networking.nat.enable {
assertions = [
{ assertion = cfg.enableIPv6 -> config.networking.enableIPv6;
{
assertion = cfg.enableIPv6 -> config.networking.enableIPv6;
message = "networking.nat.enableIPv6 requires networking.enableIPv6";
}
{ assertion = (cfg.dmzHost != null) -> (cfg.externalInterface != null);
{
assertion = (cfg.dmzHost != null) -> (cfg.externalInterface != null);
message = "networking.nat.dmzHost requires networking.nat.externalInterface";
}
{ assertion = (cfg.forwardPorts != []) -> (cfg.externalInterface != null);
{
assertion = (cfg.forwardPorts != [ ]) -> (cfg.externalInterface != null);
message = "networking.nat.forwardPorts requires networking.nat.externalInterface";
}
];
@ -341,27 +192,5 @@ in
};
};
networking.firewall = mkIf config.networking.firewall.enable {
extraCommands = setupNat;
extraStopCommands = flushNat;
};
systemd.services = mkIf (!config.networking.firewall.enable) { nat = {
description = "Network Address Translation";
wantedBy = [ "network.target" ];
after = [ "network-pre.target" "systemd-modules-load.service" ];
path = [ config.networking.firewall.package ];
unitConfig.ConditionCapability = "CAP_NET_ADMIN";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = flushNat + setupNat;
postStop = flushNat;
}; };
})
];
}

View file

@ -12,11 +12,9 @@ in
default = false;
description =
lib.mdDoc ''
Whether to enable nftables. nftables is a Linux-based packet
filtering framework intended to replace frameworks like iptables.
This conflicts with the standard networking firewall, so make sure to
disable it before using nftables.
Whether to enable nftables and use nftables based firewall if enabled.
nftables is a Linux-based packet filtering framework intended to
replace frameworks like iptables.
Note that if you have Docker enabled you will not be able to use
nftables without intervention. Docker uses iptables internally to
@ -79,19 +77,17 @@ in
lib.mdDoc ''
The ruleset to be used with nftables. Should be in a format that
can be loaded using "/bin/nft -f". The ruleset is updated atomically.
This option conflicts with rulesetFile.
'';
};
networking.nftables.rulesetFile = mkOption {
type = types.path;
default = pkgs.writeTextFile {
name = "nftables-rules";
text = cfg.ruleset;
};
defaultText = literalMD ''a file with the contents of {option}`networking.nftables.ruleset`'';
type = types.nullOr types.path;
default = null;
description =
lib.mdDoc ''
The ruleset file to be used with nftables. Should be in a format that
can be loaded using "nft -f". The ruleset is updated atomically.
This option conflicts with ruleset and nftables based firewall.
'';
};
};
@ -99,10 +95,6 @@ in
###### implementation
config = mkIf cfg.enable {
assertions = [{
assertion = config.networking.firewall.enable == false;
message = "You can not use nftables and iptables at the same time. networking.firewall.enable must be set to false.";
}];
boot.blacklistedKernelModules = [ "ip_tables" ];
environment.systemPackages = [ pkgs.nftables ];
networking.networkmanager.firewallBackend = mkDefault "nftables";
@ -116,7 +108,9 @@ in
rulesScript = pkgs.writeScript "nftables-rules" ''
#! ${pkgs.nftables}/bin/nft -f
flush ruleset
${if cfg.rulesetFile != null then ''
include "${cfg.rulesetFile}"
'' else cfg.ruleset}
'';
in {
Type = "oneshot";

View file

@ -211,7 +211,8 @@ in {
firefox-esr = handleTest ./firefox.nix { firefoxPackage = pkgs.firefox-esr; }; # used in `tested` job
firefox-esr-102 = handleTest ./firefox.nix { firefoxPackage = pkgs.firefox-esr-102; };
firejail = handleTest ./firejail.nix {};
firewall = handleTest ./firewall.nix {};
firewall = handleTest ./firewall.nix { nftables = false; };
firewall-nftables = handleTest ./firewall.nix { nftables = true; };
fish = handleTest ./fish.nix {};
flannel = handleTestOn ["x86_64-linux"] ./flannel.nix {};
fluentd = handleTest ./fluentd.nix {};
@ -412,6 +413,9 @@ in {
nat.firewall = handleTest ./nat.nix { withFirewall = true; };
nat.firewall-conntrack = handleTest ./nat.nix { withFirewall = true; withConntrackHelpers = true; };
nat.standalone = handleTest ./nat.nix { withFirewall = false; };
nat.nftables.firewall = handleTest ./nat.nix { withFirewall = true; nftables = true; };
nat.nftables.firewall-conntrack = handleTest ./nat.nix { withFirewall = true; withConntrackHelpers = true; nftables = true; };
nat.nftables.standalone = handleTest ./nat.nix { withFirewall = false; nftables = true; };
nats = handleTest ./nats.nix {};
navidrome = handleTest ./navidrome.nix {};
nbd = handleTest ./nbd.nix {};

View file

@ -1,7 +1,7 @@
# Test the firewall module.
import ./make-test-python.nix ( { pkgs, ... } : {
name = "firewall";
import ./make-test-python.nix ( { pkgs, nftables, ... } : {
name = "firewall" + pkgs.lib.optionalString nftables "-nftables";
meta = with pkgs.lib.maintainers; {
maintainers = [ eelco ];
};
@ -11,6 +11,7 @@ import ./make-test-python.nix ( { pkgs, ... } : {
{ ... }:
{ networking.firewall.enable = true;
networking.firewall.logRefusedPackets = true;
networking.nftables.enable = nftables;
services.httpd.enable = true;
services.httpd.adminAddr = "foo@example.org";
};
@ -23,6 +24,7 @@ import ./make-test-python.nix ( { pkgs, ... } : {
{ ... }:
{ networking.firewall.enable = true;
networking.firewall.rejectPackets = true;
networking.nftables.enable = nftables;
};
attacker =
@ -35,10 +37,11 @@ import ./make-test-python.nix ( { pkgs, ... } : {
testScript = { nodes, ... }: let
newSystem = nodes.walled2.config.system.build.toplevel;
unit = if nftables then "nftables" else "firewall";
in ''
start_all()
walled.wait_for_unit("firewall")
walled.wait_for_unit("${unit}")
walled.wait_for_unit("httpd")
attacker.wait_for_unit("network.target")
@ -54,12 +57,12 @@ import ./make-test-python.nix ( { pkgs, ... } : {
walled.succeed("ping -c 1 attacker >&2")
# If we stop the firewall, then connections should succeed.
walled.stop_job("firewall")
walled.stop_job("${unit}")
attacker.succeed("curl -v http://walled/ >&2")
# Check whether activation of a new configuration reloads the firewall.
walled.succeed(
"${newSystem}/bin/switch-to-configuration test 2>&1 | grep -qF firewall.service"
"${newSystem}/bin/switch-to-configuration test 2>&1 | grep -qF ${unit}.service"
)
'';
})

View file

@ -3,14 +3,16 @@
# client on the inside network, a server on the outside network, and a
# router connected to both that performs Network Address Translation
# for the client.
import ./make-test-python.nix ({ pkgs, lib, withFirewall, withConntrackHelpers ? false, ... }:
import ./make-test-python.nix ({ pkgs, lib, withFirewall, withConntrackHelpers ? false, nftables ? false, ... }:
let
unit = if withFirewall then "firewall" else "nat";
unit = if nftables then "nftables" else (if withFirewall then "firewall" else "nat");
routerBase =
lib.mkMerge [
{ virtualisation.vlans = [ 2 1 ];
networking.firewall.enable = withFirewall;
networking.firewall.filterForward = nftables;
networking.nftables.enable = nftables;
networking.nat.internalIPs = [ "192.168.1.0/24" ];
networking.nat.externalInterface = "eth1";
}
@ -21,7 +23,8 @@ import ./make-test-python.nix ({ pkgs, lib, withFirewall, withConntrackHelpers ?
];
in
{
name = "nat" + (if withFirewall then "WithFirewall" else "Standalone")
name = "nat" + (lib.optionalString nftables "Nftables")
+ (if withFirewall then "WithFirewall" else "Standalone")
+ (lib.optionalString withConntrackHelpers "withConntrackHelpers");
meta = with pkgs.lib.maintainers; {
maintainers = [ eelco rob ];
@ -34,6 +37,7 @@ import ./make-test-python.nix ({ pkgs, lib, withFirewall, withConntrackHelpers ?
{ virtualisation.vlans = [ 1 ];
networking.defaultGateway =
(pkgs.lib.head nodes.router.config.networking.interfaces.eth2.ipv4.addresses).address;
networking.nftables.enable = nftables;
}
(lib.optionalAttrs withConntrackHelpers {
networking.firewall.connectionTrackingModules = [ "ftp" ];
@ -111,7 +115,7 @@ import ./make-test-python.nix ({ pkgs, lib, withFirewall, withConntrackHelpers ?
# FIXME: this should not be necessary, but nat.service is not started because
# network.target is not triggered
# (https://github.com/NixOS/nixpkgs/issues/16230#issuecomment-226408359)
${lib.optionalString (!withFirewall) ''
${lib.optionalString (!withFirewall && !nftables) ''
router.succeed("systemctl start nat.service")
''}
client.succeed("curl --fail http://server/ >&2")