Merge pull request 'Add restricted network nixos modules' (#4125) from speed-up-flake-select into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4125
This commit is contained in:
@@ -49,6 +49,8 @@ in
|
|||||||
zt-tcp-relay = self.clanLib.test.containerTest ./zt-tcp-relay nixosTestArgs;
|
zt-tcp-relay = self.clanLib.test.containerTest ./zt-tcp-relay nixosTestArgs;
|
||||||
matrix-synapse = self.clanLib.test.containerTest ./matrix-synapse nixosTestArgs;
|
matrix-synapse = self.clanLib.test.containerTest ./matrix-synapse nixosTestArgs;
|
||||||
postgresql = self.clanLib.test.containerTest ./postgresql 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 = import ./dummy-inventory-test nixosTestArgs;
|
||||||
dummy-inventory-test-from-flake = import ./dummy-inventory-test-from-flake nixosTestArgs;
|
dummy-inventory-test-from-flake = import ./dummy-inventory-test-from-flake nixosTestArgs;
|
||||||
|
|||||||
100
checks/user-firewall/common.nix
Normal file
100
checks/user-firewall/common.nix
Normal file
@@ -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" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
82
checks/user-firewall/iptables.nix
Normal file
82
checks/user-firewall/iptables.nix
Normal file
@@ -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")
|
||||||
|
'';
|
||||||
|
}
|
||||||
77
checks/user-firewall/nftables.nix
Normal file
77
checks/user-firewall/nftables.nix
Normal file
@@ -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")
|
||||||
|
'';
|
||||||
|
}
|
||||||
32
checks/user-firewall/router.nix
Normal file
32
checks/user-firewall/router.nix
Normal file
@@ -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;";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import argparse
|
|||||||
import ctypes
|
import ctypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -303,6 +304,15 @@ class Machine:
|
|||||||
retry(check_success, timeout)
|
retry(check_success, timeout)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def wait_for_open_port(
|
||||||
|
self, port: int, addr: str = "localhost", timeout: int = 900
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Wait for a port to be open on the given address.
|
||||||
|
"""
|
||||||
|
command = f"nc -z {shlex.quote(addr)} {port}"
|
||||||
|
self.wait_until_succeeds(command, timeout=timeout)
|
||||||
|
|
||||||
def wait_for_unit(self, unit: str, timeout: int = 900) -> None:
|
def wait_for_unit(self, unit: str, timeout: int = 900) -> None:
|
||||||
"""
|
"""
|
||||||
Wait for a systemd unit to get into "active" state.
|
Wait for a systemd unit to get into "active" state.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ in
|
|||||||
{
|
{
|
||||||
flake.nixosModules.hidden-ssh-announce = ./hidden-ssh-announce.nix;
|
flake.nixosModules.hidden-ssh-announce = ./hidden-ssh-announce.nix;
|
||||||
flake.nixosModules.bcachefs = ./bcachefs.nix;
|
flake.nixosModules.bcachefs = ./bcachefs.nix;
|
||||||
|
flake.nixosModules.user-firewall = ./user-firewall;
|
||||||
flake.nixosModules.installer.imports = [
|
flake.nixosModules.installer.imports = [
|
||||||
./installer
|
./installer
|
||||||
self.nixosModules.hidden-ssh-announce
|
self.nixosModules.hidden-ssh-announce
|
||||||
|
|||||||
179
nixosModules/user-firewall/README.md
Normal file
179
nixosModules/user-firewall/README.md
Normal file
@@ -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
|
||||||
147
nixosModules/user-firewall/default.nix
Normal file
147
nixosModules/user-firewall/default.nix
Normal file
@@ -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}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user