From 5d88ac440aa31a4d60068fd4594595435c4874d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 27 Jun 2025 15:35:08 +0200 Subject: [PATCH] add restricted network nixos modules See README.md for explanation --- checks/flake-module.nix | 2 + checks/user-firewall/common.nix | 100 ++++++++++++++ checks/user-firewall/iptables.nix | 82 +++++++++++ checks/user-firewall/nftables.nix | 77 +++++++++++ checks/user-firewall/router.nix | 32 +++++ nixosModules/flake-module.nix | 1 + nixosModules/user-firewall/README.md | 179 +++++++++++++++++++++++++ nixosModules/user-firewall/default.nix | 147 ++++++++++++++++++++ 8 files changed, 620 insertions(+) create mode 100644 checks/user-firewall/common.nix create mode 100644 checks/user-firewall/iptables.nix create mode 100644 checks/user-firewall/nftables.nix create mode 100644 checks/user-firewall/router.nix create mode 100644 nixosModules/user-firewall/README.md create mode 100644 nixosModules/user-firewall/default.nix diff --git a/checks/flake-module.nix b/checks/flake-module.nix index 916d3b75a..4a42fa05c 100644 --- a/checks/flake-module.nix +++ b/checks/flake-module.nix @@ -49,6 +49,8 @@ in zt-tcp-relay = self.clanLib.test.containerTest ./zt-tcp-relay nixosTestArgs; matrix-synapse = self.clanLib.test.containerTest ./matrix-synapse nixosTestArgs; postgresql = self.clanLib.test.containerTest ./postgresql nixosTestArgs; + user-firewall-iptables = self.clanLib.test.containerTest ./user-firewall/iptables.nix nixosTestArgs; + user-firewall-nftables = self.clanLib.test.containerTest ./user-firewall/nftables.nix nixosTestArgs; dummy-inventory-test = import ./dummy-inventory-test nixosTestArgs; dummy-inventory-test-from-flake = import ./dummy-inventory-test-from-flake nixosTestArgs; diff --git a/checks/user-firewall/common.nix b/checks/user-firewall/common.nix new file mode 100644 index 000000000..b091ebb37 --- /dev/null +++ b/checks/user-firewall/common.nix @@ -0,0 +1,100 @@ +# Shared configuration for user firewall tests +{ self, pkgs, ... }: +{ + imports = [ + self.nixosModules.user-firewall + ]; + + networking.firewall.enable = true; + + # Configure the user firewall module + # Test with default allowedInterfaces (which includes wg*) + networking.user-firewall = { + # Use defaults for allowedInterfaces to test that wg* is included by default + exemptUsers = [ + "root" + "alice" + ]; + }; + + # Create test users + users.users = { + alice = { + isNormalUser = true; + uid = 1001; + initialPassword = "test"; + }; + + bob = { + isNormalUser = true; + uid = 1002; + initialPassword = "test"; + }; + }; + + # Add tools for testing + environment.systemPackages = with pkgs; [ + curl + netcat + iproute2 + ]; + + # Add a local web server for testing + services.nginx = { + enable = true; + virtualHosts = { + "localhost" = { + listen = [ + { + addr = "127.0.0.1"; + port = 8080; + } + ]; + locations."/" = { + return = "200 'test server response'"; + extraConfig = "add_header Content-Type text/plain;"; + }; + }; + "wg0-test" = { + listen = [ + { + addr = "10.100.0.2"; + port = 8081; + } + { + addr = "[fd00::2]"; + port = 8081; + } + ]; + locations."/" = { + return = "200 'wg0 interface test response'"; + extraConfig = "add_header Content-Type text/plain;"; + }; + }; + }; + }; + + # Create a dummy interface to test allowed interface patterns + systemd.services.setup-wg0-interface = { + description = "Setup wg0 dummy interface"; + after = [ "network-pre.target" ]; + before = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + ${pkgs.iproute2}/bin/ip link add wg0 type dummy || true + ${pkgs.iproute2}/bin/ip addr add 10.100.0.2/24 dev wg0 || true + ${pkgs.iproute2}/bin/ip addr add fd00::2/64 dev wg0 || true + ${pkgs.iproute2}/bin/ip link set wg0 up || true + ''; + }; + + # Make nginx wait for the wg0 interface + systemd.services.nginx = { + after = [ "setup-wg0-interface.service" ]; + requires = [ "setup-wg0-interface.service" ]; + }; +} diff --git a/checks/user-firewall/iptables.nix b/checks/user-firewall/iptables.nix new file mode 100644 index 000000000..ebc128a9b --- /dev/null +++ b/checks/user-firewall/iptables.nix @@ -0,0 +1,82 @@ +{ + name = "user-firewall-iptables"; + + nodes = { + router = + { ... }: + { + imports = [ ./router.nix ]; + }; + + machine = + { ... }: + { + imports = [ ./common.nix ]; + + # Force iptables backend + networking.nftables.enable = false; + }; + }; + + testScript = '' + start_all() + router.wait_for_unit("multi-user.target") + router.wait_for_unit("nginx.service") + machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("nginx.service") + + # Get router IPs (both IPv4 and IPv6) + router_ip = router.succeed("ip -4 addr show eth1 | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'").strip() + router_ip6 = router.succeed("ip -6 addr show eth1 | grep -oP '(?<=inet6\\s)[0-9a-f:]+' | grep -v '^fe80' | head -1").strip() + print(f"Router IPv4: {router_ip}") + print(f"Router IPv6: {router_ip6}") + + # Test firewall restart + machine.succeed("systemctl restart firewall") + machine.wait_for_unit("firewall.service") + + # Verify rules are loaded + machine.succeed("iptables -L user-firewall-output >&2") + + # Test alice (exempt user) - should succeed both locally and to router + machine.wait_until_succeeds("runuser -u alice -- curl -s http://127.0.0.1:8080") + machine.succeed(f"runuser -u alice -- curl -s http://{router_ip}") + machine.succeed(f"runuser -u alice -- curl -s http://[{router_ip6}]") + + # Test bob (restricted user) - localhost should work, external should fail + machine.succeed("runuser -u bob -- curl -s http://127.0.0.1:8080") + # This should be blocked by firewall - IPv4 + result = machine.succeed(f"runuser -u bob -- curl -s --connect-timeout 2 http://{router_ip} 2>&1 || echo 'EXIT_CODE='$?") + assert "EXIT_CODE=7" in result, f"Bob should be blocked from external IPv4 access (expected EXIT_CODE=7) but got: {result}" + # This should be blocked by firewall - IPv6 + result6 = machine.succeed(f"runuser -u bob -- curl -s --connect-timeout 2 http://[{router_ip6}] 2>&1 || echo 'EXIT_CODE='$?") + assert "EXIT_CODE=7" in result6, f"Bob should be blocked from external IPv6 access (expected EXIT_CODE=7) but got: {result6}" + + # Verify the rules are actually present for both IPv4 and IPv6 + rules4 = machine.succeed("iptables -L user-firewall-output -n -v") + assert "REJECT" in rules4, "REJECT rule not found in iptables" + rules6 = machine.succeed("ip6tables -L user-firewall-output -n -v") + assert "REJECT" in rules6, "REJECT rule not found in ip6tables" + + # Wait for the dummy interface to be created + machine.wait_for_unit("setup-wg0-interface.service") + machine.wait_for_unit("nginx.service") + machine.wait_for_open_port(8081, "10.100.0.2") + + # Check that wg0 interface exists + machine.succeed("ip link show wg0") + machine.succeed("ip addr show wg0") + + # The key test: users should be able to connect via wg0 interface + # For alice (exempt user) - should work + machine.succeed("runuser -u alice -- curl -s --interface wg0 http://10.100.0.2:8081/") + machine.succeed("runuser -u alice -- curl -s --interface wg0 http://[fd00::2]:8081/") # IPv6 test + + # For bob (restricted user) - should also work because wg* is in default allowedInterfaces + machine.succeed("runuser -u bob -- curl -s --interface wg0 http://10.100.0.2:8081/") + machine.succeed("runuser -u bob -- curl -s --interface wg0 http://[fd00::2]:8081/") # IPv6 test + + # Verify that wg* interfaces are allowed in the firewall rules + machine.succeed("iptables -L user-firewall-output -n -v | grep -E 'wg0|wg\\+' >&2") + ''; +} diff --git a/checks/user-firewall/nftables.nix b/checks/user-firewall/nftables.nix new file mode 100644 index 000000000..360ed6218 --- /dev/null +++ b/checks/user-firewall/nftables.nix @@ -0,0 +1,77 @@ +{ + name = "user-firewall-nftables"; + + nodes = { + router = { + imports = [ ./router.nix ]; + }; + + machine = { + imports = [ ./common.nix ]; + + # Force nftables backend + networking.nftables.enable = true; + }; + }; + + testScript = '' + start_all() + router.wait_for_unit("multi-user.target") + router.wait_for_unit("nginx.service") + machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("nginx.service") + + # Get router IPs (both IPv4 and IPv6) + router_ip = router.succeed("ip -4 addr show eth1 | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'").strip() + router_ip6 = router.succeed("ip -6 addr show eth1 | grep -oP '(?<=inet6\\s)[0-9a-f:]+' | grep -v '^fe80' | head -1").strip() + print(f"Router IPv4: {router_ip}") + print(f"Router IPv6: {router_ip6}") + + # Test nftables restart + machine.succeed("systemctl restart nftables") + machine.wait_for_unit("nftables.service") + + # Verify rules are loaded + machine.succeed("nft list table inet user-firewall >&2") + + # Test alice (exempt user) - should succeed both locally and to router + machine.wait_until_succeeds("runuser -u alice -- curl -s http://127.0.0.1:8080") + machine.succeed(f"runuser -u alice -- curl -s http://{router_ip}") + machine.succeed(f"runuser -u alice -- curl -s http://[{router_ip6}]") + + # Test bob (restricted user) - localhost should work, external should fail + machine.succeed("runuser -u bob -- curl -s http://127.0.0.1:8080") + # This should be blocked by firewall - IPv4 + result = machine.succeed(f"runuser -u bob -- curl -s --connect-timeout 2 http://{router_ip} 2>&1 || echo 'EXIT_CODE='$?") + assert "EXIT_CODE=7" in result, f"Bob should be blocked from external IPv4 access (expected EXIT_CODE=7) but got: {result}" + # This should be blocked by firewall - IPv6 + result6 = machine.succeed(f"runuser -u bob -- curl -s --connect-timeout 2 http://[{router_ip6}] 2>&1 || echo 'EXIT_CODE='$?") + assert "EXIT_CODE=7" in result6, f"Bob should be blocked from external IPv6 access (expected EXIT_CODE=7) but got: {result6}" + + # Verify the rules are actually present + rules = machine.succeed("nft list table inet user-firewall") + assert 'meta skuid 1002' in rules and 'reject' in rules, f"Reject rule for bob (uid 1002) not found in nftables. Actual rules:\n{rules}" + assert "oifname" in rules, f"Interface rules not found in nftables. Actual rules:\n{rules}" + + # Wait for the dummy interface to be created + machine.wait_for_unit("setup-wg0-interface.service") + machine.wait_for_unit("nginx.service") + machine.wait_for_open_port(8081, "10.100.0.2") + + # Check that wg0 interface exists + machine.succeed("ip link show wg0") + machine.succeed("ip addr show wg0") + + # The key test: users should be able to connect via wg0 interface + # For alice (exempt user) - should work + machine.succeed("runuser -u alice -- curl -s --interface wg0 http://10.100.0.2:8081/") + machine.succeed("runuser -u alice -- curl -s --interface wg0 http://[fd00::2]:8081/") # IPv6 test + + # For bob (restricted user) - should also work because wg* is in default allowedInterfaces + machine.succeed("runuser -u bob -- curl -s --interface wg0 http://10.100.0.2:8081/") + machine.succeed("runuser -u bob -- curl -s --interface wg0 http://[fd00::2]:8081/") # IPv6 test + + # Verify that wg* interfaces are allowed in the nftables rules + rules_with_wg = machine.succeed("nft list table inet user-firewall | grep -E 'oifname.*wg' >&2") + ''; +} diff --git a/checks/user-firewall/router.nix b/checks/user-firewall/router.nix new file mode 100644 index 000000000..7f079fe41 --- /dev/null +++ b/checks/user-firewall/router.nix @@ -0,0 +1,32 @@ +# Shared router configuration for user firewall tests +{ ... }: +{ + networking.firewall.enable = false; + networking.useNetworkd = true; + + # Simple web server to test connectivity + services.nginx = { + enable = true; + virtualHosts."router" = { + listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + { + addr = "[::]"; + port = 80; + } + { + addr = "10.100.0.1"; + port = 80; + } + ]; + locations."/" = { + return = "200 'router response'"; + extraConfig = "add_header Content-Type text/plain;"; + }; + }; + }; + +} diff --git a/nixosModules/flake-module.nix b/nixosModules/flake-module.nix index 9dd963656..9bef37e24 100644 --- a/nixosModules/flake-module.nix +++ b/nixosModules/flake-module.nix @@ -26,6 +26,7 @@ in { flake.nixosModules.hidden-ssh-announce = ./hidden-ssh-announce.nix; flake.nixosModules.bcachefs = ./bcachefs.nix; + flake.nixosModules.user-firewall = ./user-firewall; flake.nixosModules.installer.imports = [ ./installer self.nixosModules.hidden-ssh-announce diff --git a/nixosModules/user-firewall/README.md b/nixosModules/user-firewall/README.md new file mode 100644 index 000000000..bd4e00a02 --- /dev/null +++ b/nixosModules/user-firewall/README.md @@ -0,0 +1,179 @@ +# User Firewall Module + +This NixOS module provides network access restrictions for non-privileged users, ensuring they can only access local services and VPN interfaces while blocking direct internet access. + +## Overview + +The `user-firewall` module implements firewall rules that: +- Block all outbound network traffic for normal (non-system) users by default +- Allow specific users to bypass restrictions (exemptUsers) +- Permit traffic on specific interfaces (like VPNs and localhost) +- Support both iptables and nftables backends +- Handle both IPv4 and IPv6 traffic + +## Installation + +Add the module to your NixOS configuration: + +```nix +{ + imports = [ + self.inputs.clan-core.nixosModules.user-firewall + ]; +} +``` + +The module is automatically enabled once imported. It will immediately start restricting network access for all normal users except those listed in `exemptUsers`. + +## Configuration + +### Basic Usage + +```nix +{ + networking.user-firewall = { + exemptUsers = [ "alice" ]; # Users who can access the internet + }; +} +``` + +### Full Configuration Example + +```nix +{ + networking.user-firewall = { + # Users who are exempt from network restrictions + exemptUsers = [ + "alice" + "admin" + ]; + + # Network interfaces that all users can use + # Default includes common VPN interfaces + allowedInterfaces = [ + "lo" # localhost (required for local services) + "tun*" # OpenVPN, OpenConnect + "wg*" # WireGuard (wg0, wg-home, etc.) + "tailscale*" # Tailscale + # Add custom interfaces as needed + ]; + }; +} +``` + +## How It Works + +1. **User Classification**: The module automatically identifies all normal users (non-system users) and applies restrictions to those not in the `exemptUsers` list. + +2. **Firewall Rules**: + - For iptables: Creates a custom chain `user-firewall-output` in the OUTPUT table + - For nftables: Creates a table `inet user-firewall` with an output chain + - Rules check outgoing packets and reject those from restricted users + +3. **Interface Patterns**: Supports wildcards in interface names: + - `*` matches any characters (e.g., `wg*` matches `wg0`, `wg-home`) + +## Default Allowed Interfaces + +The module comes with sensible defaults for common VPN and overlay network interfaces: + +- `lo` - Loopback (localhost access) +- `tun*` - OpenVPN, OpenConnect +- `tap*` - OpenVPN (bridged mode) +- `wg*` - WireGuard +- `tailscale*` - Tailscale +- `zt*` - ZeroTier +- `hyprspace` - Hyprpspace +- `vpn*` - Generic VPN interfaces +- `nebula*` - Nebula mesh network +- `tinc*` - Tinc VPN +- `edge*` - n2n +- `ham0` - Hamachi +- `easytier` - EasyTier +- `mycelium` - Mycelium + +## Use Cases + +### 1. Public Kiosk Systems +Restrict users to only access local services: +```nix +{ + networking.user-firewall = { + allowedInterfaces = [ "lo" ]; # Only localhost + exemptUsers = [ ]; # No exempt users + }; +} +``` + +### 2. Corporate Workstations +Force all traffic through corporate VPN: +```nix +{ + networking.user-firewall = { + allowedInterfaces = [ "lo" "wg-corp" ]; + exemptUsers = [ "sysadmin" ]; + }; +} +``` + +## Testing + +The module includes comprehensive tests for both iptables and nftables backends: + +```bash +# Run iptables backend test +nix build .#checks.x86_64-linux.user-firewall-iptables + +# Run nftables backend test +nix build .#checks.x86_64-linux.user-firewall-nftables +``` + +## Troubleshooting + +### Check Active Rules + +The output includes package counters for each firewall rule, that can help to debug connectivity issues. + +For iptables: +```bash +sudo iptables -L user-firewall-output -n -v +sudo ip6tables -L user-firewall-output -n -v +``` + +For nftables: +```bash +sudo nft list table inet user-firewall + +# Watch counters in real-time +sudo watch -n1 'nft list table inet user-firewall' +``` + +Check which rule your VPN traffic is hitting. If packets are being rejected, verify: +1. Your VPN interface name matches the patterns in `allowedInterfaces` +2. Your user is listed in `exemptUsers` if needed + +To see your current network interfaces: +```bash +ip link show | grep -E '^[0-9]+:' +``` + +### Common Issues + +1. **Service Connection Failures**: If local services fail to connect, ensure `lo` is in `allowedInterfaces`. + +2. **VPN Not Working**: Check that your VPN interface name matches the patterns in `allowedInterfaces`. You can find your interface name with `ip link show`. + +3. **User Still Has Access**: Verify the user is a normal user (not a system user) and not in `exemptUsers`. + +## Security Considerations + +- This module provides defense in depth but should not be the only security measure +- System users (like `nginx`, `systemd-*`) are not restricted +- Root user always has full network access +- Restrictions apply at the packet filter level, not application level + +## Limitations + +- Requires `networking.firewall.enable = true` +- Cannot restrict system users or root +- Interface patterns are evaluated at rule creation time, not dynamically diff --git a/nixosModules/user-firewall/default.nix b/nixosModules/user-firewall/default.nix new file mode 100644 index 000000000..a95a07630 --- /dev/null +++ b/nixosModules/user-firewall/default.nix @@ -0,0 +1,147 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.networking.user-firewall; + + # Get all normal users (excluding system users) + normalUsers = lib.filterAttrs (_name: user: user.isNormalUser) config.users.users; + + # Get usernames for normal users who aren't exempt + restrictedUsers = lib.attrNames ( + lib.filterAttrs (name: _user: !(lib.elem name cfg.exemptUsers)) normalUsers + ); + + # Convert interface patterns for iptables + # iptables uses + for one-or-more, but we use * in our interface + toIptablesPattern = + pattern: + if lib.hasSuffix "*" pattern && pattern != "*" then + # Convert "wg*" to "wg+" for iptables + lib.removeSuffix "*" pattern + "+" + else + pattern; + + # Build interface patterns for iptables with proper escaping + interfaceRules = lib.concatMapStringsSep "\n " ( + iface: + "ip46tables -A user-firewall-output -o ${lib.escapeShellArg (toIptablesPattern iface)} -j RETURN" + ) cfg.allowedInterfaces; + +in +{ + options.networking.user-firewall = { + allowedInterfaces = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "lo" # loopback (allows Tor on localhost) + "tun*" # OpenVPN, OpenConnect + "tap*" # OpenVPN (bridged mode) + "wg*" # WireGuard (wg0, wg-home, etc.) + "tailscale*" # Tailscale + "zt*" # ZeroTier + "vpn*" # Generic VPN interfaces + "ipsec*" # IPSec + "nebula*" # Nebula + "tinc*" # Tinc + "edge*" # n2n + "hyprspace" # Hyprspace + "ham0" # Hamachi + "easytier" # EasyTier + "mycelium" # Mycelium + ]; + description = '' + Network interfaces that normal users can use. + Supports wildcards: * (zero or more characters). + ''; + }; + + exemptUsers = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Users exempt from network restrictions. + ''; + }; + + }; + + config = { + assertions = [ + { + assertion = config.networking.firewall.enable; + message = "networking.user-firewall requires networking.firewall.enable to be true"; + } + ]; + + # For nftables: create fake passwd file for build-time validation + networking.nftables.checkRulesetRedirects = lib.mkIf config.networking.nftables.enable ( + lib.mkOptionDefault { + "/etc/passwd" = pkgs.writeText "passwd" ( + let + userList = lib.attrNames config.users.users; + indexedUsers = lib.imap0 ( + i: name: "${name}:x:${toString (1000 + i)}:100::/home/${name}:/bin/sh" + ) userList; + in + lib.concatStringsSep "\n" indexedUsers + ); + } + ); + + # For iptables backend + networking.firewall.extraCommands = lib.mkIf (!config.networking.nftables.enable) '' + # Create custom chain for user firewall output + ip46tables -N user-firewall-output 2>/dev/null || true + ip46tables -F user-firewall-output + + # Allow traffic on permitted interfaces + ${interfaceRules} + + + # Reject traffic from restricted users (TCP RST for TCP, ICMP for UDP) + ${lib.concatMapStringsSep "\n " ( + user: "ip46tables -A user-firewall-output -m owner --uid-owner ${lib.escapeShellArg user} -j REJECT" + ) restrictedUsers} + + # Allow all other traffic + ip46tables -A user-firewall-output -j RETURN + + # Insert our chain at the beginning of OUTPUT + ip46tables -D OUTPUT -j user-firewall-output 2>/dev/null || true + ip46tables -I OUTPUT -j user-firewall-output + ''; + + networking.firewall.extraStopCommands = lib.mkIf (!config.networking.nftables.enable) '' + # Remove our custom chain + ip46tables -D OUTPUT -j user-firewall-output 2>/dev/null || true + ip46tables -F user-firewall-output 2>/dev/null || true + ip46tables -X user-firewall-output 2>/dev/null || true + ''; + + # For nftables backend + networking.nftables.tables = lib.mkIf config.networking.nftables.enable { + user-firewall = { + family = "inet"; + content = '' + chain output { + type filter hook output priority 0; policy accept; + + # Allow traffic on permitted interfaces + ${lib.concatMapStringsSep "\n " ( + iface: ''oifname "${iface}" counter accept comment "allow ${iface}"'' + ) cfg.allowedInterfaces} + + # Reject traffic from restricted users (TCP RST for TCP, ICMP for UDP) + ${lib.concatMapStringsSep "\n " ( + user: ''meta skuid "${user}" counter reject comment "blocked user ${user}"'' + ) restrictedUsers} + } + ''; + }; + }; + }; +}