diff --git a/modules/services/iptables/README.md b/modules/services/iptables/README.md new file mode 100644 index 0000000..ad7fa59 --- /dev/null +++ b/modules/services/iptables/README.md @@ -0,0 +1,39 @@ +# iptables + +Service module for [iptables](https://www.netfilter.org/projects/iptables/), the classic Linux packet filtering framework. + +Loads IPv4 and IPv6 rulesets at boot via a finit task and tears them down cleanly on stop. Also supplies a `providers.firewall` implementation, making it a drop-in alternative to the nftables backend for systems that require iptables. + +## Basic usage + +```nix +{ + services.iptables = { + enable = true; + rulesetV4 = '' + *filter + :INPUT DROP [0:0] + :FORWARD DROP [0:0] + :OUTPUT ACCEPT [0:0] + -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + -A INPUT -p tcp --dport 22 -j ACCEPT + COMMIT + ''; + }; +} +``` + +## Using the firewall provider + +When `services.iptables.enable` is true, this module registers itself as the `providers.firewall` backend (at a lower priority than nftables, so nftables wins if both are enabled). You can use the standard firewall provider options to open ports declaratively: + +```nix +{ + services.iptables.enable = true; + providers.firewall = { + enable = true; + allowedTCPPorts = [ 22 80 443 ]; + allowedUDPPorts = [ 53 ]; + }; +} +``` diff --git a/modules/services/iptables/default.nix b/modules/services/iptables/default.nix new file mode 100644 index 0000000..4bd8afe --- /dev/null +++ b/modules/services/iptables/default.nix @@ -0,0 +1,238 @@ +{ + config, + pkgs, + lib, + ... +}: +let + cfg = config.services.iptables; + + kernel = config.boot.kernelPackages.kernel; + + kernelHasRPFilter = + ((kernel.config.isEnabled or (x: false)) "IP_NF_MATCH_RPFILTER") + || (kernel.features.netfilterRPFilter or false); + + helpers = '' + # Helper command to manipulate both the IPv4 and IPv6 tables. + ip46tables() { + ${cfg.package}/bin/iptables -w "$@" + ${cfg.package}/bin/ip6tables -w "$@" + } + ''; + + acceptAllRules = '' + *filter + :INPUT ACCEPT [0:0] + :FORWARD ACCEPT [0:0] + :OUTPUT ACCEPT [0:0] + COMMIT + ''; + + rulesV4 = pkgs.writeText "iptables-rules.v4" cfg.rulesetV4; + rulesV6 = pkgs.writeText "iptables-rules.v6" cfg.rulesetV6; + + flushRules = pkgs.writeText "iptables-flush" acceptAllRules; + + startScript = pkgs.writeShellScript "iptables-start.sh" '' + ${helpers} + + ${cfg.package}/bin/iptables-restore ${rulesV4} + ${cfg.package}/bin/ip6tables-restore ${rulesV6} + + # Clean up rpfilter rules + ip46tables -t mangle -D PREROUTING -j finix-fw-rpfilter 2> /dev/null || true + ip46tables -t mangle -F finix-fw-rpfilter 2> /dev/null || true + ip46tables -t mangle -X finix-fw-rpfilter 2> /dev/null || true + + ${lib.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 finix-fw-rpfilter 2> /dev/null || true + ip46tables -t mangle -A finix-fw-rpfilter -m rpfilter --validmark ${ + lib.optionalString (cfg.checkReversePath == "loose") "--loose" + } -j RETURN + + # Allows this host to act as a DHCP4 client without first having to use APIPA + ${cfg.package}/bin/iptables -w -t mangle -A finix-fw-rpfilter -p udp --sport 67 --dport 68 -j RETURN + + # Allows this host to act as a DHCPv4 server + ${cfg.package}/bin/iptables -w -t mangle -A finix-fw-rpfilter -s 0.0.0.0 -d 255.255.255.255 -p udp --sport 68 --dport 67 -j RETURN + + ${lib.optionalString cfg.logReversePathDrops '' + ip46tables -t mangle -A finix-fw-rpfilter -j LOG --log-level info --log-prefix "rpfilter drop: " + ''} + ip46tables -t mangle -A finix-fw-rpfilter -j DROP + + ip46tables -t mangle -A PREROUTING -j finix-fw-rpfilter + ''} + + ${cfg.extraCommands} + ''; + + stopScript = pkgs.writeShellScript "iptables-stop.sh" '' + ${helpers} + + ${cfg.package}/bin/iptables-restore ${flushRules} + ${cfg.package}/bin/ip6tables-restore ${flushRules} + + ip46tables -t mangle -D PREROUTING -j finix-fw-rpfilter 2> /dev/null || true + ip46tables -t mangle -F finix-fw-rpfilter 2> /dev/null || true + ip46tables -t mangle -X finix-fw-rpfilter 2> /dev/null || true + + ${cfg.extraStopCommands} + ''; +in +{ + imports = [ + ./providers.firewall.nix + ]; + + options.services.iptables = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to enable [iptables](${pkgs.iptables.meta.homepage}) as a system service. + ''; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.iptables; + defaultText = lib.literalExpression "pkgs.iptables"; + description = '' + The package to use for `iptables`. + ''; + }; + + extraPackages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + example = lib.literalExpression "[ pkgs.ipset ]"; + description = '' + Additional packages to be included in the system environment alongside + the iptables package. + ''; + }; + + trustedInterfaces = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "lo" ]; + example = [ + "lo" + "enp0s2" + ]; + description = '' + Traffic from these interfaces will be accepted unconditionally + by the {option}`providers.firewall` implementation. The loopback + interface is trusted by default. + ''; + }; + + allowPing = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether the {option}`providers.firewall` implementation responds + to incoming ICMPv4 echo requests ("pings"). + ''; + }; + + rulesetV4 = lib.mkOption { + type = lib.types.lines; + default = acceptAllRules; + description = '' + The IPv4 ruleset, in {manpage}`iptables-restore(8)` format. + ''; + }; + + rulesetV6 = lib.mkOption { + type = lib.types.lines; + default = acceptAllRules; + description = '' + The IPv6 ruleset, in {manpage}`ip6tables-restore(8)` format. + ''; + }; + + checkReversePath = lib.mkOption { + type = lib.types.either lib.types.bool ( + lib.types.enum [ + "strict" + "loose" + ] + ); + default = false; + example = "loose"; + description = '' + 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. + + If using asymmetric routing or other complicated routing, set + this option to loose mode or disable it and setup your own + counter-measures. + + 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. + ''; + }; + + logReversePathDrops = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Logs dropped packets failing the reverse path filter test if + the option {option}`checkReversePath` is enabled. + ''; + }; + + extraCommands = lib.mkOption { + type = lib.types.lines; + default = ""; + example = "iptables -A INPUT -p icmp -j ACCEPT"; + description = '' + Additional shell commands executed as part of the iptables + initialisation script, after the rulesets have been loaded. + An `ip46tables` helper is available to run a command against + both the IPv4 and IPv6 tables. + ''; + }; + + extraStopCommands = lib.mkOption { + type = lib.types.lines; + default = ""; + example = "iptables -P INPUT ACCEPT"; + description = '' + Additional shell commands executed as part of the iptables + shutdown script, after the rulesets have been reset. + An `ip46tables` helper is available to run a command against + both the IPv4 and IPv6 tables. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + 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"; + } + ]; + + environment.systemPackages = [ cfg.package ] ++ cfg.extraPackages; + + finit.tasks.iptables = { + runlevels = "23"; + conditions = "service/syslogd/ready"; + command = startScript; + post = stopScript; + log = true; + remain = true; + }; + }; +} diff --git a/modules/services/iptables/providers.firewall.nix b/modules/services/iptables/providers.firewall.nix new file mode 100644 index 0000000..9eca0a1 --- /dev/null +++ b/modules/services/iptables/providers.firewall.nix @@ -0,0 +1,91 @@ +{ + config, + lib, + ... +}: +let + cfg = config.providers.firewall; + svcCfg = config.services.iptables; + + refuseRules = + if cfg.rejectPackets then + '' + -A finix-fw-refuse -p tcp ! --syn -j REJECT --reject-with tcp-reset + -A finix-fw-refuse -j REJECT + '' + else + '' + -A finix-fw-refuse -j DROP + ''; + + commonRules = '' + *filter + :INPUT DROP [0:0] + :FORWARD DROP [0:0] + :OUTPUT ACCEPT [0:0] + -N finix-fw-accept + -N finix-fw-refuse + -N finix-fw-log-refuse + -N finix-fw + -A finix-fw-accept -j ACCEPT + ${refuseRules} + -A finix-fw-log-refuse -j finix-fw-refuse + ${lib.concatMapStrings (iface: '' + -A finix-fw -i ${iface} -j finix-fw-accept + '') svcCfg.trustedInterfaces} + -A finix-fw -m conntrack --ctstate ESTABLISHED,RELATED -j finix-fw-accept + ${lib.concatMapStrings (port: '' + -A finix-fw -p tcp --dport ${toString port} -j finix-fw-accept + '') cfg.allowedTCPPorts} + ${lib.concatMapStrings ({ from, to }: '' + -A finix-fw -p tcp --dport ${toString from}:${toString to} -j finix-fw-accept + '') cfg.allowedTCPPortRanges} + ${lib.concatMapStrings (port: '' + -A finix-fw -p udp --dport ${toString port} -j finix-fw-accept + '') cfg.allowedUDPPorts} + ${lib.concatMapStrings ({ from, to }: '' + -A finix-fw -p udp --dport ${toString from}:${toString to} -j finix-fw-accept + '') cfg.allowedUDPPortRanges} + ''; +in +{ + options.providers.firewall = { + backend = lib.mkOption { + type = lib.types.enum [ "iptables" ]; + }; + }; + + config = lib.mkMerge [ + (lib.mkIf config.services.iptables.enable { + # this module supplies an implementation for `providers.firewall`. + # weaker than the nftables backend's mkDefault so that enabling both + # services does not conflict: nftables wins unless the backend is set + # explicitly + providers.firewall.backend = lib.mkOverride 1250 "iptables"; + }) + + (lib.mkIf (cfg.enable && cfg.backend == "iptables") { + services.iptables.rulesetV4 = + commonRules + + lib.optionalString svcCfg.allowPing '' + -A finix-fw -p icmp --icmp-type echo-request -j finix-fw-accept + '' + + '' + -A finix-fw -j finix-fw-log-refuse + -A INPUT -j finix-fw + COMMIT + ''; + + services.iptables.rulesetV6 = + commonRules + + lib.optionalString svcCfg.allowPing '' + -A finix-fw -p icmpv6 --icmpv6-type echo-request -j finix-fw-accept + '' + + '' + -A finix-fw -j finix-fw-log-refuse + -A INPUT -j finix-fw + COMMIT + ''; + }) + ]; +}