declared network.nix

This commit is contained in:
Danilo Reyes
2026-04-02 00:22:39 -06:00
parent 78c37081d7
commit 29a88a9b05
17 changed files with 390 additions and 359 deletions

View File

@@ -1,15 +1,14 @@
<!--
Sync Impact Report
- Version change: template -> 1.0.0
- Version change: 1.0.0 -> 1.1.0
- Modified principles:
- Template Principle 1 -> I. Constitution Authority
- Template Principle 2 -> II. Module and Host Boundaries
- Template Principle 3 -> III. Host-Local Firewall Ownership
- III. Host-Local Firewall Ownership -> III. Host-Local Network Ownership
- Template Principle 4 -> IV. Nix Structure and Ordering
- Template Principle 5 -> V. Secure Host and Secrets Discipline
- Added sections:
- Repository Constraints
- Delivery Workflow
- None
- Removed sections:
- None
- Templates requiring updates:
@@ -41,13 +40,14 @@ factory helpers belong under `modules/factories/`; repo-wide shared options
belong under `modules/modules.nix` or the relevant shared module. New behavior
MUST NOT be placed in an unrelated host or module file for convenience.
### III. Host-Local Firewall Ownership
Any host that contains firewall rules MUST keep firewall-related logic in
`hosts/<name>/firewall.nix`. Host `configuration.nix` files MAY import that
file, but MUST NOT become the long-term home for firewall rule definitions,
NAT rules, nftables tables, forward-port rules, or other firewall-specific
logic. Firewall changes in specs, plans, and task lists MUST reference the
host-local `firewall.nix` path explicitly.
### III. Host-Local Network Ownership
Any host that owns host-local networking behavior MUST keep that logic in
`hosts/<name>/network.nix`. Host `configuration.nix` files MAY import that
file, but MUST NOT become the long-term home for host-specific firewall rules,
NAT rules, nftables tables, forward-port rules, WireGuard interface
configuration, policy-routing services, or other host-local networking logic.
Networking changes in specs, plans, and task lists MUST reference the
host-local `network.nix` path explicitly.
### IV. Nix Structure and Ordering
Nix code MUST preserve grouped parents when they have multiple children and
@@ -67,24 +67,25 @@ gating, and host-local boundaries.
## Repository Constraints
- Host definitions live in `hosts/<name>/configuration.nix` with optional
imports such as `hosts/<name>/firewall.nix` and `hosts/<name>/toggles.nix`.
imports such as `hosts/<name>/network.nix` and `hosts/<name>/toggles.nix`.
- Module categories remain `apps`, `dev`, `scripts`, `servers`, `services`,
`shell`, `websites`, `network`, `users`, and `nix`, with feature directories
preferred over new flat modules.
- Service ports intrinsic to a server module SHOULD live with that module;
miscellaneous shared ports SHOULD live in `my.ports`.
- Firewall rules, NAT, nftables tables, and forward-port declarations for a
host MUST be reviewed as one unit inside that host's `firewall.nix`.
- Host-local firewall rules, NAT, nftables tables, WireGuard interfaces, and
policy-routing services MUST be reviewed as one unit inside that host's
`network.nix`.
## Delivery Workflow
- Every plan MUST include a constitution check that validates module ownership,
host ownership, secure-host impact, and whether firewall work belongs in
`hosts/<name>/firewall.nix`.
host ownership, secure-host impact, and whether networking work belongs in
`hosts/<name>/network.nix`.
- Every spec that changes networking or exposure MUST state which host owns the
change and which firewall file is affected.
- Every task list that includes firewall work MUST name the concrete
`hosts/<name>/firewall.nix` path.
change and which host-local network file is affected.
- Every task list that includes networking work MUST name the concrete
`hosts/<name>/network.nix` path.
- Runtime guidance docs that describe repository structure MUST be updated when
host boundary rules change.
@@ -98,4 +99,4 @@ principles or materially expanded rules, PATCH for clarifications that do not
change required behavior. Compliance review is mandatory for every plan, spec,
and tasks artifact that claims alignment with this constitution.
**Version**: 1.0.0 | **Ratified**: 2026-04-01 | **Last Amended**: 2026-04-01
**Version**: 1.1.0 | **Ratified**: 2026-04-01 | **Last Amended**: 2026-04-02

View File

@@ -34,8 +34,8 @@
- Confirm each change lives in the directory that owns the behavior.
- Confirm shared logic stays in `modules/` and host-specific assembly stays in
`hosts/<name>/`.
- Confirm any firewall, NAT, nftables, or port-forwarding work is scoped to
`hosts/<name>/firewall.nix` for the affected host.
- Confirm any host-local firewall, NAT, nftables, WireGuard, or policy-routing
work is scoped to `hosts/<name>/network.nix` for the affected host.
- Confirm any secret-dependent behavior respects `config.my.secureHost`.
## Project Structure

View File

@@ -89,8 +89,8 @@
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
- **FR-006**: If the feature changes host firewall behavior, the spec MUST name
the affected `hosts/<name>/firewall.nix` file explicitly.
- **FR-006**: If the feature changes host-local networking behavior, the spec
MUST name the affected `hosts/<name>/network.nix` file explicitly.
*Example of marking unclear requirements:*

View File

@@ -17,8 +17,8 @@ description: "Task list template for feature implementation"
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
- If firewall behavior changes, tasks MUST reference `hosts/<name>/firewall.nix`
instead of only `hosts/<name>/configuration.nix`
- If host-local networking behavior changes, tasks MUST reference
`hosts/<name>/network.nix` instead of only `hosts/<name>/configuration.nix`
## Path Conventions
@@ -70,7 +70,7 @@ Examples of foundational tasks (adjust based on your project):
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
- [ ] T010 If networking changes, update the affected `hosts/<name>/firewall.nix`
- [ ] T010 If networking changes, update the affected `hosts/<name>/network.nix`
and import wiring in `hosts/<name>/configuration.nix`
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel

View File

@@ -11,7 +11,7 @@
- Module auto-import: `modules/modules.nix` auto-imports legacy flat modules under `modules/apps`, `modules/dev`, `modules/scripts`, `modules/servers`, `modules/services`, `modules/shell`, `modules/websites`, and `modules/network`, excluding `librewolf.nix`, and also discovers nested `nixos.nix` files under those trees. `config/base.nix` registers `modules/home-manager.nix` as a Home Manager shared module, which discovers nested `home.nix` files under `modules/` for every Home Manager user. Factories live in `modules/factories/` (`mkserver`, `mkscript`), and shared options are in `modules/nix` and `modules/users`.
- Home Manager helper layer: Common Home Manager wrapper logic belongs in `parts/core.nix` helpers under `inputs.self.lib` when it is repeated across multiple `home.nix` modules. Current helpers include split-loader support plus `hmModule`, `hmShellType`, and `hmOnlyUser` for shared enablement and shell-selection patterns.
- Hosts and toggles: Host definitions live in `hosts/<name>/configuration.nix` with host-specific toggles in `hosts/<name>/toggles.nix`. The `my` namespace carries toggles for apps/dev/scripts/services/shell, feature flags like `enableProxy` and `enableContainers`, and per-host `interfaces` and `ips` maps.
- Host-local firewall ownership: Hosts that define firewall rules MUST keep firewall-related logic in `hosts/<name>/firewall.nix`, imported from `hosts/<name>/configuration.nix` as needed. Host `configuration.nix` files are the assembly point, not the long-term home for firewall rule definitions.
- Host-local network ownership: Hosts that own host-local networking behavior MUST keep that logic in `hosts/<name>/network.nix`, imported from `hosts/<name>/configuration.nix` as needed. Host `configuration.nix` files are the assembly point, not the long-term home for host-specific firewall rules, NAT, nftables tables, WireGuard interfaces, or policy-routing services.
- Standalone Home Manager hosts: Home-only hosts may live under `hosts/<name>/home.nix` with `hosts/<name>/toggles.nix`, and should only enable modules that have a `home.nix` surface or are otherwise known to be Home Manager-compatible on that platform.
- Port assignment: Service ports should live with the service module when the port is intrinsic to a server definition under `modules/servers/`. Miscellaneous or host-specific ports that do not belong to a server module should be centralized in `my.ports` in `modules/modules.nix` and referenced via `config.my.ports.*` (use `toString config.my.ports.*` where a string is required).
- Main server and proxies: `my.mainServer` selects the host that should serve traffic by default (default `vps`). Reverse proxies use helpers in `parts/core.nix` (`proxy`, `proxyReverse`, `proxyReverseFix`, `proxyReversePrivate`) and pick IPs from `my.ips` plus the hostName/ip set by `mkserver` options. Nginx defaults to `proxyReverse` for any server with `enableProxy = true` unless `useDefaultProxy = false` or the server is listed in the Fix/Private proxy lists.
@@ -79,8 +79,7 @@ config.services = {
- Conflict handling steps: identify the divergent rule, cite the source files, decide the authoritative rule per this constitution, update both the source file and the relevant doc, and record the decision and timestamp.
## Maintenance Triggers and Update Process
- Triggers: New factory/helper, new module category, new host, new toggle set, new proxy rule, new secret category/file, change to `my.mainServer` or `my.ips`, stylix scheme changes, or new auto-import filters/import trees.
- Triggers: New factory/helper, new module category, new host, new toggle set, new proxy rule, new host firewall ownership rule or `hosts/<name>/firewall.nix` layout change, new secret category/file, change to `my.mainServer` or `my.ips`, stylix scheme changes, or new auto-import filters/import trees.
- Triggers: New factory/helper, new module category, new host, new toggle set, new proxy rule, new host-local network ownership rule or `hosts/<name>/network.nix` layout change, new secret category/file, change to `my.mainServer` or `my.ips`, stylix scheme changes, or new auto-import filters/import trees.
- Update flow: (1) Amend the relevant module or toggle files; (2) Update `docs/constitution.md` for rules/terminology changes; (3) Update playbooks under `docs/playbooks/` affected by the change; (4) Update `docs/reference/index.md` for navigation paths; (5) Note the decision in `specs/001-ai-docs/research.md` and refresh `quickstart.md` if discoverability shifts.
- Validation: Confirm discoverability within two clicks (constitution → reference map/playbook), secrets map completeness, and alignment with success criteria SC-001SC-004.

View File

@@ -8,7 +8,8 @@
1. Choose the correct secrets file from the map in `docs/constitution.md` and add the entry there (YAML, encrypted via sops-nix).
2. If a private key or file path is required, specify `owner`, `group`, and target path consistent with the consuming module.
3. In the consuming module, reference the secret under `config.sops.secrets.<name>` and guard with `lib.mkIf config.my.secureHost`.
4. For WireGuard entries, update `secrets/wireguard.yaml` and corresponding interface configuration under the target host.
4. For WireGuard entries, update `secrets/wireguard.yaml` and the
corresponding host-local network configuration under the target host.
5. Avoid adding secrets for hosts with `secureHost = false`; instead route the workload to a secure host or skip enablement.
- Validation:
- Secret lives in the correct file and encrypts with SOPS; file ownership matches service user where applicable.

View File

@@ -13,8 +13,8 @@
## Steps
1. Add the peer IP to `my.ips` in `modules/modules.nix`.
2. Add the peer to the VPS WireGuard peers list in `modules/services/wireguard.nix`.
3. If the peer is a guest/friend, ensure `allowedIPs` includes the relevant subnets in `hosts/server/configuration.nix`.
4. Add or adjust VPS firewall rules in `hosts/vps/configuration.nix` (`networking.firewall.extraForwardRules`) to allow the requested ports.
3. If the peer is a guest/friend, ensure `allowedIPs` includes the relevant subnets in `hosts/server/network.nix`.
4. Add or adjust VPS networking rules in `hosts/vps/network.nix` (`networking.firewall.extraForwardRules`) to allow the requested ports.
5. Rebuild both hosts:
- `nixos-rebuild switch --flake .#vps`
- `nixos-rebuild switch --flake .#server`

View File

@@ -39,7 +39,7 @@
## Hosts and Roles
- NixOS configs: `hosts/<name>/configuration.nix` with toggles in `hosts/<name>/toggles.nix`.
- Firewall-bearing hosts: keep firewall logic in `hosts/<name>/firewall.nix` and import it from `hosts/<name>/configuration.nix`.
- Network-owning hosts: keep host-local networking logic in `hosts/<name>/network.nix` and import it from `hosts/<name>/configuration.nix`.
- Standalone Home Manager configs: `hosts/<name>/home.nix` with optional toggles in `hosts/<name>/toggles.nix`.
- Active NixOS hosts: `workstation`, `server`, `miniserver`, `galaxy`, `emacs`, `vps`.
- Active Home Manager hosts: `mac`.
@@ -65,7 +65,7 @@
- Default proxying: any server with `enableProxy = true` gets a `proxyReverse` vhost unless `useDefaultProxy = false` or it is listed in `proxyReverseFix` / `proxyReversePrivate`.
- Main server selection: `my.mainServer` chooses where services live by default (default `vps`); `mkserver` sets `isLocal` based on this and picks IPs from `my.ips`.
- Firewall generation: `inputs.self.lib.generateFirewallPorts` combines static ports, additional ports, and service ports from `my.servers` (excluding native firewall services). Use `my.network.firewall` settings and `getServicesWithNativeFirewall` to derive open ports.
- Host firewall placement: host-specific firewall rules, NAT, nftables tables, and forward-port definitions belong in `hosts/<name>/firewall.nix`.
- Host network placement: host-specific firewall rules, NAT, nftables tables, forward-port definitions, WireGuard interfaces, and policy-routing services belong in `hosts/<name>/network.nix`.
## Secrets Map
- Files and purposes:

View File

@@ -2,6 +2,7 @@
{
imports = [
./hardware-configuration.nix
./network.nix
../../config/base.nix
../../config/stylix.nix
];
@@ -31,13 +32,7 @@
(buildMachine "workstation" 8 40)
(buildMachine "server" 6 17)
];
networking = {
hostName = "miniserver";
firewall = {
allowedTCPPorts = [ 2049 ];
allowedUDPPorts = [ 2049 ];
};
};
networking.hostName = "miniserver";
services = {
btrfs.autoScrub = {
enable = true;

View File

@@ -0,0 +1,6 @@
_: {
networking.firewall = {
allowedTCPPorts = [ 2049 ];
allowedUDPPorts = [ 2049 ];
};
}

View File

@@ -7,16 +7,12 @@
}:
let
lidarrMbGapId = 968;
qbittorrentRouteTable = 200;
qbitUser = config.services.qbittorrent.user;
serverInterface = config.my.interfaces.server;
wgInterface = "wg0";
wgServerIp = config.my.ips.wg-server;
in
{
imports = [
inputs.lidarr-mb-gap.nixosModules.lidarr-mb-gap
./hardware-configuration.nix
./network.nix
../../config/base.nix
../../config/stylix.nix
];
@@ -29,19 +25,6 @@ in
"nixminiserver"
];
};
network.firewall = {
enabledServicePorts = true;
additionalPorts = [
2049
config.my.ports.syncthingGui
config.my.ports.syncthingRelay
config.my.ports.sonarqube
config.my.ports.synapseSsl
config.my.ports.tdarr
config.my.ports.mediaMap
config.my.ports.qbittorrent
];
};
};
nix.buildMachines = [
{
@@ -73,114 +56,7 @@ in
path = "${usr.home}/.ssh/ed25519_lidarr-mb-gap";
};
};
networking = {
hostName = "server";
iproute2.rttablesExtraConfig = ''
${toString qbittorrentRouteTable} qbittorrent
'';
wireguard.interfaces.${wgInterface} = lib.mkIf config.my.secureHost {
allowedIPsAsRoutes = false;
ips = [ "${wgServerIp}/32" ];
privateKeyFile = config.sops.secrets."server/private".path;
peers = [
{
publicKey = "dFbiSekBwnZomarcS31o5+w6imHjMPNCipkfc2fZ3GY=";
endpoint = "${config.my.ips.vps}:51820";
allowedIPs = [
"0.0.0.0/0"
"${config.my.ips.wg-vps}/32"
config.my.subnets.wg-homelab
config.my.subnets.wg-friends
config.my.subnets.wg-guests
];
persistentKeepalive = 25;
}
];
};
firewall = {
allowedUDPPorts = config.networking.firewall.allowedTCPPorts;
interfaces.${wgInterface}.allowedTCPPorts = [ config.my.servers.nextcloud.port ];
};
};
systemd.services = {
qbittorrent-vpn-routing = lib.mkIf (config.my.secureHost && config.services.qbittorrent.enable) {
description = "Route qBittorrent user traffic through the VPS WireGuard tunnel";
before = [ "qbittorrent.service" ];
wantedBy = [ "multi-user.target" ];
after = [
"network-online.target"
"wireguard-${wgInterface}.service"
];
wants = [
"network-online.target"
"wireguard-${wgInterface}.service"
];
path = with pkgs; [
coreutils
gnugrep
iproute2
shadow
];
script = ''
qbit_uid="$(id -u ${qbitUser})"
for _ in $(seq 1 30); do
if ip -4 addr show dev ${wgInterface} >/dev/null 2>&1; then
break
fi
sleep 1
done
if ! ip -4 addr show dev ${wgInterface} >/dev/null 2>&1; then
echo "${wgInterface} is not available"
exit 1
fi
ip -4 route replace ${config.my.subnets.wg-homelab} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace ${config.my.subnets.wg-friends} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace ${config.my.subnets.wg-guests} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.subnets.wg-homelab} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.subnets.wg-friends} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.subnets.wg-guests} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} 10.88.0.0/16 dev podman0 src 10.88.0.1
ip -4 route replace table ${toString qbittorrentRouteTable} 192.168.100.0/24 dev ${serverInterface} src ${config.my.ips.server}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.ips.vps}/32 via ${config.my.ips.router} dev ${serverInterface} src ${config.my.ips.server}
ip -4 route replace table ${toString qbittorrentRouteTable} default via ${config.my.ips.wg-vps} dev ${wgInterface} src ${wgServerIp}
while ip -4 rule show | grep -q "uidrange ''${qbit_uid}-''${qbit_uid} lookup ${toString qbittorrentRouteTable}"; do
ip -4 rule del uidrange "''${qbit_uid}-''${qbit_uid}" lookup ${toString qbittorrentRouteTable}
done
while ip -4 rule show | grep -q 'from ${wgServerIp} lookup ${toString qbittorrentRouteTable}'; do
ip -4 rule del from ${wgServerIp}/32 lookup ${toString qbittorrentRouteTable}
done
ip -4 rule add from ${wgServerIp}/32 lookup ${toString qbittorrentRouteTable} priority 9999
ip -4 rule add uidrange "''${qbit_uid}-''${qbit_uid}" lookup ${toString qbittorrentRouteTable} priority 10000
'';
preStop = ''
qbit_uid="$(id -u ${qbitUser})"
while ip -4 rule show | grep -q 'from ${wgServerIp} lookup ${toString qbittorrentRouteTable}'; do
ip -4 rule del from ${wgServerIp}/32 lookup ${toString qbittorrentRouteTable} || true
done
while ip -4 rule show | grep -q "uidrange ''${qbit_uid}-''${qbit_uid} lookup ${toString qbittorrentRouteTable}"; do
ip -4 rule del uidrange "''${qbit_uid}-''${qbit_uid}" lookup ${toString qbittorrentRouteTable} || true
done
ip -4 route del ${config.my.subnets.wg-homelab} dev ${wgInterface} || true
ip -4 route del ${config.my.subnets.wg-friends} dev ${wgInterface} || true
ip -4 route del ${config.my.subnets.wg-guests} dev ${wgInterface} || true
ip -4 route flush table ${toString qbittorrentRouteTable} || true
'';
serviceConfig = {
RemainAfterExit = true;
Type = "oneshot";
};
};
};
networking.hostName = "server";
users = {
groups.lidarr-mb-gap.gid = lidarrMbGapId;
users = {

135
hosts/server/network.nix Normal file
View File

@@ -0,0 +1,135 @@
{
config,
lib,
pkgs,
...
}:
let
qbittorrentRouteTable = 200;
qbitUser = config.services.qbittorrent.user;
serverInterface = config.my.interfaces.server;
wgInterface = "wg0";
wgServerIp = config.my.ips.wg-server;
in
{
my.network.firewall = {
enabledServicePorts = true;
additionalPorts = [
2049
config.my.ports.syncthingGui
config.my.ports.syncthingRelay
config.my.ports.sonarqube
config.my.ports.synapseSsl
config.my.ports.tdarr
config.my.ports.mediaMap
config.my.ports.qbittorrent
];
};
networking = {
iproute2.rttablesExtraConfig = ''
${toString qbittorrentRouteTable} qbittorrent
'';
wireguard.interfaces.${wgInterface} = lib.mkIf config.my.secureHost {
allowedIPsAsRoutes = false;
ips = [ "${wgServerIp}/32" ];
privateKeyFile = config.sops.secrets."server/private".path;
peers = [
{
publicKey = "dFbiSekBwnZomarcS31o5+w6imHjMPNCipkfc2fZ3GY=";
endpoint = "${config.my.ips.vps}:51820";
allowedIPs = [
"0.0.0.0/0"
"${config.my.ips.wg-vps}/32"
config.my.subnets.wg-homelab
config.my.subnets.wg-friends
config.my.subnets.wg-guests
];
persistentKeepalive = 25;
}
];
};
firewall = {
allowedUDPPorts = config.networking.firewall.allowedTCPPorts;
interfaces.${wgInterface}.allowedTCPPorts = [ config.my.servers.nextcloud.port ];
};
};
systemd.services.qbittorrent-vpn-routing =
lib.mkIf (config.my.secureHost && config.services.qbittorrent.enable)
{
description = "Route qBittorrent user traffic through the VPS WireGuard tunnel";
before = [ "qbittorrent.service" ];
wantedBy = [ "multi-user.target" ];
after = [
"network-online.target"
"wireguard-${wgInterface}.service"
];
wants = [
"network-online.target"
"wireguard-${wgInterface}.service"
];
path = with pkgs; [
coreutils
gnugrep
iproute2
shadow
];
script = ''
qbit_uid="$(id -u ${qbitUser})"
for _ in $(seq 1 30); do
if ip -4 addr show dev ${wgInterface} >/dev/null 2>&1; then
break
fi
sleep 1
done
if ! ip -4 addr show dev ${wgInterface} >/dev/null 2>&1; then
echo "${wgInterface} is not available"
exit 1
fi
ip -4 route replace ${config.my.subnets.wg-homelab} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace ${config.my.subnets.wg-friends} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace ${config.my.subnets.wg-guests} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.subnets.wg-homelab} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.subnets.wg-friends} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.subnets.wg-guests} dev ${wgInterface} src ${wgServerIp}
ip -4 route replace table ${toString qbittorrentRouteTable} 10.88.0.0/16 dev podman0 src 10.88.0.1
ip -4 route replace table ${toString qbittorrentRouteTable} 192.168.100.0/24 dev ${serverInterface} src ${config.my.ips.server}
ip -4 route replace table ${toString qbittorrentRouteTable} ${config.my.ips.vps}/32 via ${config.my.ips.router} dev ${serverInterface} src ${config.my.ips.server}
ip -4 route replace table ${toString qbittorrentRouteTable} default via ${config.my.ips.wg-vps} dev ${wgInterface} src ${wgServerIp}
while ip -4 rule show | grep -q "uidrange ''${qbit_uid}-''${qbit_uid} lookup ${toString qbittorrentRouteTable}"; do
ip -4 rule del uidrange "''${qbit_uid}-''${qbit_uid}" lookup ${toString qbittorrentRouteTable}
done
while ip -4 rule show | grep -q 'from ${wgServerIp} lookup ${toString qbittorrentRouteTable}'; do
ip -4 rule del from ${wgServerIp}/32 lookup ${toString qbittorrentRouteTable}
done
ip -4 rule add from ${wgServerIp}/32 lookup ${toString qbittorrentRouteTable} priority 9999
ip -4 rule add uidrange "''${qbit_uid}-''${qbit_uid}" lookup ${toString qbittorrentRouteTable} priority 10000
'';
preStop = ''
qbit_uid="$(id -u ${qbitUser})"
while ip -4 rule show | grep -q 'from ${wgServerIp} lookup ${toString qbittorrentRouteTable}'; do
ip -4 rule del from ${wgServerIp}/32 lookup ${toString qbittorrentRouteTable} || true
done
while ip -4 rule show | grep -q "uidrange ''${qbit_uid}-''${qbit_uid} lookup ${toString qbittorrentRouteTable}"; do
ip -4 rule del uidrange "''${qbit_uid}-''${qbit_uid}" lookup ${toString qbittorrentRouteTable} || true
done
ip -4 route del ${config.my.subnets.wg-homelab} dev ${wgInterface} || true
ip -4 route del ${config.my.subnets.wg-friends} dev ${wgInterface} || true
ip -4 route del ${config.my.subnets.wg-guests} dev ${wgInterface} || true
ip -4 route flush table ${toString qbittorrentRouteTable} || true
'';
serviceConfig = {
RemainAfterExit = true;
Type = "oneshot";
};
};
}

View File

@@ -5,87 +5,10 @@
inputs,
...
}:
let
externalInterface = config.my.interfaces.${config.networking.hostName};
wgInterface = "wg0";
ips = {
homeServer = config.my.ips.wg-server;
wgWorkstation = config.my.ips.wg-workstation;
wgFriend1 = config.my.ips.wg-friend1;
wgFriend6 = config.my.ips.wg-friend6;
wgGuest1 = config.my.ips.wg-guest1;
wgGuest2 = config.my.ips.wg-guest2;
};
subnets = {
wgFriends = config.my.subnets.wg-friends;
wgGuests = config.my.subnets.wg-guests;
wgHomelab = config.my.subnets.wg-homelab;
};
ports = {
inherit (config.my.ports)
giteaSsh
qbittorrent
ssh
wg
;
web = [
80
443
];
syncthing = config.my.ports.syncthingRelay;
synapseFederation = config.my.ports.synapseSsl;
};
portsStr = {
syncthing = toString ports.syncthing;
synapseFederation = toString ports.synapseFederation;
synapseClient = toString config.my.servers.synapse.port;
syncplay = toString config.my.servers.syncplay.port;
synctube = toString config.my.servers.synctube.port;
stash = toString config.my.servers.stash.port;
jellyfin = toString config.my.servers.jellyfin.port;
audiobookshelf = toString config.my.servers.audiobookshelf.port;
kavita = toString config.my.servers.kavita.port;
openWebui = toString config.my.ports.openWebui;
sillytavern = toString config.my.ports.sillytavern;
ollama = toString config.my.ports.ollama;
comfyui = toString config.my.ports.comfyui;
};
forwardedPorts = [
{
comment = "snat ssh forward";
port = ports.giteaSsh;
proto = "tcp";
}
{
comment = "snat qbittorrent tcp forward";
port = ports.qbittorrent;
proto = "tcp";
}
{
comment = "snat qbittorrent udp forward";
port = ports.qbittorrent;
proto = "udp";
}
];
mkForwardPort =
{ port, proto, ... }:
{
sourcePort = port;
inherit proto;
destination = "${ips.homeServer}:${toString port}";
};
mkSnatRule =
{
comment,
port,
proto,
...
}:
''iifname "${externalInterface}" oifname "${wgInterface}" ip daddr ${ips.homeServer}/32 ${proto} dport ${toString port} masquerade comment "${comment}"'';
in
{
imports = [
./hardware-configuration.nix
./network.nix
./nginx-nextcloud.nix
../../config/base.nix
];
@@ -128,72 +51,10 @@ in
keyFile = "/var/lib/sops-nix/key.txt";
sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
};
networking = {
hostName = "vps";
nat = {
inherit externalInterface;
enable = true;
internalInterfaces = [ wgInterface ];
forwardPorts = map mkForwardPort forwardedPorts;
};
nftables = {
enable = true;
tables.vps-snat = {
family = "ip";
content = ''
chain postrouting {
type nat hook postrouting priority srcnat;
iifname "${wgInterface}" oifname "${externalInterface}" ip saddr ${subnets.wgHomelab} masquerade comment "snat homelab egress"
${lib.concatStringsSep "\n " (map mkSnatRule forwardedPorts)}
}
'';
};
};
firewall = {
enable = true;
filterForward = true;
checkReversePath = "loose";
allowedTCPPorts = [
ports.ssh
ports.qbittorrent
]
++ ports.web;
allowedUDPPorts = [
ports.wg
ports.qbittorrent
];
extraForwardRules = ''
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} ip daddr ${ips.homeServer}/32 tcp dport ${portsStr.syncthing} accept
iifname "${wgInterface}" ip saddr ${ips.homeServer}/32 ip daddr ${subnets.wgFriends} tcp dport ${portsStr.syncthing} accept
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} ip daddr ${ips.homeServer}/32 tcp dport { ${portsStr.synapseClient}, ${portsStr.synapseFederation}, ${portsStr.syncplay}, ${portsStr.synctube} } accept
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} ip daddr ${ips.homeServer}/32 icmp type echo-request accept
iifname "${wgInterface}" ip saddr ${ips.wgFriend1}/32 ip daddr ${ips.homeServer}/32 tcp dport ${portsStr.stash} accept
iifname "${wgInterface}" ip saddr ${ips.wgFriend6}/32 ip daddr ${ips.homeServer}/32 tcp dport ${portsStr.stash} accept
iifname "${wgInterface}" ip saddr ${subnets.wgGuests} ip daddr ${ips.homeServer}/32 tcp dport { ${portsStr.stash}, ${portsStr.jellyfin}, ${portsStr.audiobookshelf}, ${portsStr.kavita} } accept
iifname "${wgInterface}" ip saddr ${subnets.wgGuests} ip daddr ${ips.homeServer}/32 icmp type echo-request accept
iifname "${wgInterface}" ip saddr ${subnets.wgHomelab} ip daddr ${ips.homeServer}/32 accept
iifname "${wgInterface}" ip saddr ${subnets.wgHomelab} ip daddr ${ips.wgWorkstation}/32 tcp dport { ${portsStr.openWebui}, ${portsStr.sillytavern}, ${portsStr.ollama}, ${portsStr.comfyui} } accept
iifname "${wgInterface}" ip saddr ${ips.wgWorkstation}/32 ip daddr ${subnets.wgHomelab} tcp sport { ${portsStr.openWebui}, ${portsStr.sillytavern}, ${portsStr.ollama}, ${portsStr.comfyui} } accept
iifname "${wgInterface}" ip saddr ${subnets.wgHomelab} oifname "${externalInterface}" accept
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} oifname "${externalInterface}" accept
iifname "${wgInterface}" ip saddr ${subnets.wgGuests} oifname "${externalInterface}" accept
ip saddr ${subnets.wgFriends} ip daddr ${subnets.wgHomelab} drop
ip saddr ${subnets.wgHomelab} ip daddr ${subnets.wgFriends} drop
ip saddr ${subnets.wgGuests} ip daddr ${subnets.wgHomelab} drop
ip saddr ${subnets.wgHomelab} ip daddr ${subnets.wgGuests} drop
ip saddr ${subnets.wgGuests} ip daddr ${subnets.wgFriends} drop
ip saddr ${subnets.wgFriends} ip daddr ${subnets.wgGuests} drop
'';
};
};
networking.hostName = "vps";
services = {
smartd.enable = lib.mkForce false;
openssh.ports = [ ports.ssh ];
openssh.ports = [ config.my.ports.ssh ];
};
users = {
groups = {

145
hosts/vps/network.nix Normal file
View File

@@ -0,0 +1,145 @@
{
config,
lib,
...
}:
let
externalInterface = config.my.interfaces.${config.networking.hostName};
wgInterface = "wg0";
ips = {
homeServer = config.my.ips.wg-server;
wgWorkstation = config.my.ips.wg-workstation;
wgFriend1 = config.my.ips.wg-friend1;
wgFriend6 = config.my.ips.wg-friend6;
};
subnets = {
wgFriends = config.my.subnets.wg-friends;
wgGuests = config.my.subnets.wg-guests;
wgHomelab = config.my.subnets.wg-homelab;
};
ports = {
inherit (config.my.ports)
giteaSsh
qbittorrent
ssh
wg
;
web = [
80
443
];
syncthing = config.my.ports.syncthingRelay;
synapseFederation = config.my.ports.synapseSsl;
};
portsStr = {
syncthing = toString config.my.ports.syncthingRelay;
synapseFederation = toString config.my.ports.synapseSsl;
synapseClient = toString config.my.servers.synapse.port;
syncplay = toString config.my.servers.syncplay.port;
synctube = toString config.my.servers.synctube.port;
stash = toString config.my.servers.stash.port;
jellyfin = toString config.my.servers.jellyfin.port;
audiobookshelf = toString config.my.servers.audiobookshelf.port;
kavita = toString config.my.servers.kavita.port;
openWebui = toString config.my.ports.openWebui;
sillytavern = toString config.my.ports.sillytavern;
ollama = toString config.my.ports.ollama;
comfyui = toString config.my.ports.comfyui;
};
forwardedPorts = [
{
comment = "snat ssh forward";
port = ports.giteaSsh;
proto = "tcp";
}
{
comment = "snat qbittorrent tcp forward";
port = ports.qbittorrent;
proto = "tcp";
}
{
comment = "snat qbittorrent udp forward";
port = ports.qbittorrent;
proto = "udp";
}
];
mkForwardPort =
{ port, proto, ... }:
{
sourcePort = port;
inherit proto;
destination = "${ips.homeServer}:${toString port}";
};
mkSnatRule =
{
comment,
port,
proto,
...
}:
''iifname "${externalInterface}" oifname "${wgInterface}" ip daddr ${ips.homeServer}/32 ${proto} dport ${toString port} masquerade comment "${comment}"'';
in
{
networking = {
nat = {
inherit externalInterface;
enable = true;
internalInterfaces = [ wgInterface ];
forwardPorts = map mkForwardPort forwardedPorts;
};
nftables = {
enable = true;
tables.vps-snat = {
family = "ip";
content = ''
chain postrouting {
type nat hook postrouting priority srcnat;
iifname "${wgInterface}" oifname "${externalInterface}" ip saddr ${subnets.wgHomelab} masquerade comment "snat homelab egress"
${lib.concatStringsSep "\n " (map mkSnatRule forwardedPorts)}
}
'';
};
};
firewall = {
enable = true;
filterForward = true;
checkReversePath = "loose";
allowedTCPPorts = [
ports.ssh
ports.qbittorrent
]
++ ports.web;
allowedUDPPorts = [
ports.wg
ports.qbittorrent
];
extraForwardRules = ''
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} ip daddr ${ips.homeServer}/32 tcp dport ${portsStr.syncthing} accept
iifname "${wgInterface}" ip saddr ${ips.homeServer}/32 ip daddr ${subnets.wgFriends} tcp dport ${portsStr.syncthing} accept
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} ip daddr ${ips.homeServer}/32 tcp dport { ${portsStr.synapseClient}, ${portsStr.synapseFederation}, ${portsStr.syncplay}, ${portsStr.synctube} } accept
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} ip daddr ${ips.homeServer}/32 icmp type echo-request accept
iifname "${wgInterface}" ip saddr ${ips.wgFriend1}/32 ip daddr ${ips.homeServer}/32 tcp dport ${portsStr.stash} accept
iifname "${wgInterface}" ip saddr ${ips.wgFriend6}/32 ip daddr ${ips.homeServer}/32 tcp dport ${portsStr.stash} accept
iifname "${wgInterface}" ip saddr ${subnets.wgGuests} ip daddr ${ips.homeServer}/32 tcp dport { ${portsStr.stash}, ${portsStr.jellyfin}, ${portsStr.audiobookshelf}, ${portsStr.kavita} } accept
iifname "${wgInterface}" ip saddr ${subnets.wgGuests} ip daddr ${ips.homeServer}/32 icmp type echo-request accept
iifname "${wgInterface}" ip saddr ${subnets.wgHomelab} ip daddr ${ips.homeServer}/32 accept
iifname "${wgInterface}" ip saddr ${subnets.wgHomelab} ip daddr ${ips.wgWorkstation}/32 tcp dport { ${portsStr.openWebui}, ${portsStr.sillytavern}, ${portsStr.ollama}, ${portsStr.comfyui} } accept
iifname "${wgInterface}" ip saddr ${ips.wgWorkstation}/32 ip daddr ${subnets.wgHomelab} tcp sport { ${portsStr.openWebui}, ${portsStr.sillytavern}, ${portsStr.ollama}, ${portsStr.comfyui} } accept
iifname "${wgInterface}" ip saddr ${subnets.wgHomelab} oifname "${externalInterface}" accept
iifname "${wgInterface}" ip saddr ${subnets.wgFriends} oifname "${externalInterface}" accept
iifname "${wgInterface}" ip saddr ${subnets.wgGuests} oifname "${externalInterface}" accept
ip saddr ${subnets.wgFriends} ip daddr ${subnets.wgHomelab} drop
ip saddr ${subnets.wgHomelab} ip daddr ${subnets.wgFriends} drop
ip saddr ${subnets.wgGuests} ip daddr ${subnets.wgHomelab} drop
ip saddr ${subnets.wgHomelab} ip daddr ${subnets.wgGuests} drop
ip saddr ${subnets.wgGuests} ip daddr ${subnets.wgFriends} drop
ip saddr ${subnets.wgFriends} ip daddr ${subnets.wgGuests} drop
'';
};
};
}

View File

@@ -20,6 +20,7 @@ in
{
imports = [
./hardware-configuration.nix
./network.nix
../../config/base.nix
../../config/stylix.nix
../../environments/gnome.nix
@@ -55,51 +56,7 @@ in
settings.term = "xterm-256color";
};
};
networking = {
hostName = "workstation";
wireguard.interfaces.wg0 = lib.mkIf config.my.secureHost {
ips = [ "${config.my.ips.wg-workstation}/32" ];
privateKeyFile = config.sops.secrets."workstation/private".path;
peers = [
{
publicKey = "dFbiSekBwnZomarcS31o5+w6imHjMPNCipkfc2fZ3GY=";
endpoint = "${config.my.ips.vps}:51820";
persistentKeepalive = 25;
allowedIPs = [
"${config.my.ips.wg-vps}/32"
config.my.subnets.wg-homelab
];
}
];
};
firewall = {
allowedTCPPorts = [
config.my.ports.nsUsbloader
config.my.ports.syncthingGui
];
allowedTCPPortRanges = [
{
from = 1714;
to = 1764;
}
];
interfaces.wg0.allowedTCPPorts = [
config.services.ollama.port
config.services.open-webui.port
config.services.sillytavern.port
config.my.ports.comfyui
];
};
nftables.tables.wg-local-redirect = {
family = "ip";
content = ''
chain prerouting {
type nat hook prerouting priority dstnat;
iifname "wg0" ip daddr ${config.my.ips.wg-workstation}/32 tcp dport ${toString config.my.ports.sillytavern} redirect to :${toString config.my.ports.sillytavern}
}
'';
};
};
networking.hostName = "workstation";
users = {
groups.ai = { };
users.jawz.packages = [

View File

@@ -0,0 +1,55 @@
{
config,
lib,
...
}:
let
wgInterface = "wg0";
wgWorkstationIp = config.my.ips.wg-workstation;
in
{
networking = {
wireguard.interfaces.${wgInterface} = lib.mkIf config.my.secureHost {
ips = [ "${wgWorkstationIp}/32" ];
privateKeyFile = config.sops.secrets."workstation/private".path;
peers = [
{
publicKey = "dFbiSekBwnZomarcS31o5+w6imHjMPNCipkfc2fZ3GY=";
endpoint = "${config.my.ips.vps}:51820";
persistentKeepalive = 25;
allowedIPs = [
"${config.my.ips.wg-vps}/32"
config.my.subnets.wg-homelab
];
}
];
};
firewall = {
allowedTCPPorts = [
config.my.ports.nsUsbloader
config.my.ports.syncthingGui
];
allowedTCPPortRanges = [
{
from = 1714;
to = 1764;
}
];
interfaces.${wgInterface}.allowedTCPPorts = [
config.services.ollama.port
config.services.open-webui.port
config.services.sillytavern.port
config.my.ports.comfyui
];
};
nftables.tables.wg-local-redirect = {
family = "ip";
content = ''
chain prerouting {
type nat hook prerouting priority dstnat;
iifname "${wgInterface}" ip daddr ${wgWorkstationIp}/32 tcp dport ${toString config.my.ports.sillytavern} redirect to :${toString config.my.ports.sillytavern}
}
'';
};
};
}

View File

@@ -55,7 +55,7 @@
- **Rationale**: This keeps modules scan-friendly, reduces unnecessary indentation, and makes the high-signal contract (`options`) appear before implementation (`config`) consistently across the repo.
- **Alternatives considered**: (a) Leave structure to formatter defaults only (rejected: formatters do not enforce these semantic grouping rules); (b) prefer fully flattened attrpaths everywhere (rejected: harms readability once a parent has multiple children); (c) keep `config` before `options` when it was written first (rejected: makes module interfaces harder to scan).
## Decision 12 (2026-04-01): Host-local firewall files
- **Decision**: Any host that owns firewall rules MUST keep firewall-related logic in `hosts/<name>/firewall.nix`, with `hosts/<name>/configuration.nix` importing that file rather than accumulating the firewall logic inline.
- **Rationale**: Firewall behavior is a distinct host concern that becomes hard to review and maintain when mixed into general host assembly. A dedicated `firewall.nix` preserves ownership boundaries and makes networking changes easier to audit.
- **Alternatives considered**: (a) Keep firewall rules inline in `configuration.nix` (rejected: mixes host assembly with a dense, security-sensitive subsystem); (b) centralize all firewall logic under `modules/network/` (rejected: hides host-specific rule ownership and deployment context).
## Decision 12 (2026-04-02): Host-local network files
- **Decision**: Any host that owns host-local networking behavior MUST keep that logic in `hosts/<name>/network.nix`, with `hosts/<name>/configuration.nix` importing that file rather than accumulating the networking logic inline.
- **Rationale**: Firewall behavior, NAT, nftables tables, WireGuard interfaces, and policy-routing services form one host-owned networking surface that becomes hard to review and maintain when spread across general host assembly. A dedicated `network.nix` preserves ownership boundaries and makes host networking changes easier to audit.
- **Alternatives considered**: (a) Keep host networking rules inline in `configuration.nix` (rejected: mixes host assembly with a dense, security-sensitive subsystem); (b) keep only firewall rules in a dedicated file and leave the rest inline (rejected: splits one host-owned networking surface across files); (c) centralize all host networking logic under `modules/network/` (rejected: hides host-specific ownership and deployment context).