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:
Mic92
2025-06-27 16:52:51 +00:00
16 changed files with 642 additions and 12 deletions

View File

@@ -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;

View 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" ];
};
}

View 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")
'';
}

View 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")
'';
}

View 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;";
};
};
};
}

View File

@@ -52,7 +52,7 @@ This guide shows you how to configure `zerotier` either through `NixOS Options`
};
};
```
## 3. Apply the Configuration
Update the `controller` machine:

View File

@@ -5,7 +5,7 @@ At the moment, NixOS/Clan does not support [Secure Boot](https://wiki.gentoo.org
### Step 2: Access the UEFI/BIOS Menu
- Restart your computer.
- As your computer restarts, press the appropriate key to enter the UEFI/BIOS settings.
- As your computer restarts, press the appropriate key to enter the UEFI/BIOS settings.
??? tip "The key depends on your laptop or motherboard manufacturer. Click to see a reference list:"
| Manufacturer | UEFI/BIOS Key(s) |

View File

@@ -21,7 +21,7 @@ Flake URL-like syntax used to link to clans.
Required to connect the `url-open` application to the `clan-app`.
## facts *(deprecated)*
System for creating secrets and public files in a declarative way.
System for creating secrets and public files in a declarative way.
**Note:** Deprecated, use `vars` instead.
## inventory

View File

@@ -2,6 +2,7 @@ import argparse
import ctypes
import os
import re
import shlex
import shutil
import subprocess
import time
@@ -303,6 +304,15 @@ class Machine:
retry(check_success, timeout)
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:
"""
Wait for a systemd unit to get into "active" state.

View File

@@ -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

View 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

View 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}
}
'';
};
};
};
}

View File

@@ -36,7 +36,7 @@ Examples:
$ agit list
Lists all open pull requests for the current repository
```
References:

View File

@@ -52,7 +52,7 @@ Follow the instructions below to set up your development environment and start t
## Storybook
We use [Storybook] to develop UI components.
We use [Storybook] to develop UI components.
It can be started by running the following:
```console
@@ -63,10 +63,10 @@ This will start a [process-compose] instance containing two processes:
* `storybook` which is the main [storybook] process.
* `luakit` which is a [webkit]-based browser for viewing the stories with. This is the same underlying engine used when
rendering the app.
rendering the app.
You can run storybook tests with `npm run test-storybook`.
If you change how a component(s) renders,
If you change how a component(s) renders,
you will need to update the snapshots with `npm run test-storybook-update-snapshots`.
## Start clan-app without process-compose

View File

@@ -1,7 +1,7 @@
## Overview
We will be updating existing components and developing new components in line with the latest designs inside this
folder. As they become ready, they can be copied into the root `components` folder, replacing any existing components as
We will be updating existing components and developing new components in line with the latest designs inside this
folder. As they become ready, they can be copied into the root `components` folder, replacing any existing components as
necessary.
This is to avoid merge hell and allow us to rapidly match the latest designs without the burden of integration.
This is to avoid merge hell and allow us to rapidly match the latest designs without the burden of integration.

View File

@@ -89,6 +89,6 @@ Here are some important documentation links related to the Clan VM Manager:
## Error handling
> Error dialogs should be avoided where possible, since they are disruptive.
>
> Error dialogs should be avoided where possible, since they are disruptive.
>
> For simple non-critical errors, toasts can be a good alternative.