Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion components/bookmarks/linkding/backups.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
config,
pkgs,
lib,
self,
...
}:
let
Expand Down Expand Up @@ -80,7 +81,7 @@ in

age.secrets = {
"rclone/rclone.conf" = {
file = ../../../secrets/rclone/rclone.conf.age;
file = "${self}/secrets/rclone/rclone.conf.age";
mode = "440";
owner = config.users.users.paperless.name;
group = config.users.groups.rcloneoperators.name;
Expand Down
75 changes: 75 additions & 0 deletions components/website-editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Grace's isolated website editor

The VM is installed declaratively but started manually:

```sh
sudo systemctl start microvm@grace-editor
sudo journalctl -fu microvm@grace-editor
```

Deploy the host configuration first. Starting the VM also requests its
installation unit and the `grace-editor-firewall` unit. It does not autostart.
Guest services start inside the VM, not on the host.

- Editor: `http://172.22.0.10:4096`
- Preview: `http://172.22.0.10:4321`
- Guest: `192.168.83.2/24`, gateway `192.168.83.1`
- Host bridge: `agentbr0`, containing only the `agent-grace` tap.

## Network ownership

This component does **not** enable the NixOS global nftables/NAT services or
change the host's existing `br0` address, default gateway, or DNS. In particular,
the global nftables service would default to flushing the entire ruleset with
this host's `system.stateVersion = "23.05"`, destroying Docker, Kubernetes and
Tailscale rules.

`grace-editor-firewall` uses the nft binary directly to manage only the
`inet grace_editor` table. Creation and reload atomically replace that table;
cleanup removes only that table. There is no global ruleset flush or iptables
kernel-module blacklist. All filtering is scoped to traffic entering/leaving
`agentbr0`; unrelated traffic continues through the existing owners' policies.

New LAN connections must originate in `172.22.0.0/15`, arrive on `br0`, and target
`172.22.0.10` on one of the two forwarded ports. Direct routed guest access is
blocked. Host-initiated administration and replies to allowed connections work.
New guest connections can leave only through `br0` toward public IPv4 addresses;
host services, private/link-local/multicast destinations and the Tailscale CGNAT
range are blocked. Guest IPv6 and source-address spoofing are dropped by the
host. The host does not accept DHCP or IPv6 router advertisements on the guest
bridge/tap, so the guest cannot supply a replacement host default route.

The VM has `BindsTo=` and `After=` dependencies on its firewall: a failed startup
prevents VM startup, and stopping the firewall stops the VM before removing the
rules. Reload uses an atomic nft transaction, preserving established connections
and the previous policy if a new ruleset is invalid.

The current host's shared IPv4 FORWARD chain has policy ACCEPT. Our table's
ACCEPT verdicts do not bypass other owners' later DROP rules; if the host's
forwarding policy changes, re-test connectivity rather than overriding those
owners' rules globally.

## Verification without touching the live network

The test uses the **generated deployment rules**, inside new network, mount,
and PID namespaces. It creates a synthetic host, guest, LAN, upstream router,
and VPN peer. All test IPs, including the public-looking addresses, remain in
these namespaces. It requires root for namespace setup, not for live changes.

With the default network options:

```sh
test_package=$(nix build --no-link --print-out-paths \
.#nixosConfigurations.patroclus.config.system.build.graceEditorNetworkTest)
sudo "$test_package/bin/check-grace-editor-network"
```

Checks cover TCP/UDP replies, SNAT, both LAN forwards, host/LAN/VPN isolation,
IPv6 and spoofing, unrelated host/transit traffic, rule-owner preservation,
live connections across reload, a rejected invalid reload, and idempotent cleanup.

Before deployment, also evaluate/build the intended host configuration. The
component's focused checks do not replace checks for unrelated host modules.
After deployment, confirm existing container/cluster/tailnet services remain
reachable before starting the editor. A failed editor startup can be diagnosed
with `systemctl status grace-editor-firewall microvm@grace-editor`.
9 changes: 9 additions & 0 deletions components/website-editor/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{ inputs, ... }:
{
imports = [
inputs.microvm.nixosModules.host
./networking.nix
./options.nix
./vm.nix
];
}
74 changes: 74 additions & 0 deletions components/website-editor/firewall-rules.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{ cfg }:
''
# Atomic replacement of our table only, including on first installation.
table inet grace_editor;
delete table inet grace_editor;
table inet grace_editor {
set non_public_v4 {
type ipv4_addr;
flags interval;
elements = {
0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24,
192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24,
224.0.0.0/4, 240.0.0.0/4
};
}

chain input {
type filter hook input priority filter - 10; policy accept;
iifname "agentbr0" jump from_guest_to_host
}

chain from_guest_to_host {
meta nfproto != ipv4 counter drop
ip saddr != ${cfg.vm.ip} counter drop
ct state invalid counter drop
# Permit responses to host-initiated administration, never new guest
# connections to ANY host address (including public and tailnet IPs).
ct state established,related counter accept
counter drop
}

chain forward {
type filter hook forward priority filter - 10; policy accept;
iifname "agentbr0" jump from_guest
oifname "agentbr0" jump to_guest
}

chain from_guest {
meta nfproto != ipv4 counter drop
ip saddr != ${cfg.vm.ip} counter drop
ct state invalid counter drop
# Even a public destination must leave via the LAN gateway, not a VPN
# or another VM/container interface. This also constrains reply traffic.
oifname != "${cfg.lan.interface}" counter drop
ct state established,related counter accept
ip daddr @non_public_v4 counter drop
counter accept
}

chain to_guest {
meta nfproto != ipv4 counter drop
ip daddr != ${cfg.vm.ip} counter drop
ct state invalid counter drop
iifname != "${cfg.lan.interface}" counter drop
ct state established,related counter accept
# Only connections actually DNATed from the intended LAN address and
# subnet may open the UI. Direct routed access to the guest is denied.
ip saddr ${cfg.lan.cidr} ct status dnat ct original ip daddr ${cfg.lan.hostIP} tcp dport { ${toString cfg.editorPort}, ${toString cfg.previewPort} } counter accept
counter drop
}

chain prerouting {
type nat hook prerouting priority dstnat - 10; policy accept;
iifname "${cfg.lan.interface}" ip saddr ${cfg.lan.cidr} ip daddr ${cfg.lan.hostIP} tcp dport ${toString cfg.editorPort} counter dnat ip to ${cfg.vm.ip}:${toString cfg.editorPort}
iifname "${cfg.lan.interface}" ip saddr ${cfg.lan.cidr} ip daddr ${cfg.lan.hostIP} tcp dport ${toString cfg.previewPort} counter dnat ip to ${cfg.vm.ip}:${toString cfg.previewPort}
}

chain postrouting {
type nat hook postrouting priority srcnat + 10; policy accept;
iifname "agentbr0" oifname "${cfg.lan.interface}" ip saddr ${cfg.vm.ip} counter masquerade
}
}
''
88 changes: 88 additions & 0 deletions components/website-editor/networking.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
{ config, lib, pkgs, ... }:
let
cfg = config.components.website-editor;
bridge = "agentbr0";
rules = pkgs.writeText "grace-editor-firewall.nft" (import ./firewall-rules.nix { inherit cfg; });
cleanup = pkgs.writeText "grace-editor-firewall-stop.nft" ''
table inet grace_editor;
delete table inet grace_editor;
'';
in
{
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.editorPort != cfg.previewPort;
message = "The website editor and preview must use different ports.";
}
];

systemd.network = {
netdevs."30-${bridge}".netdevConfig = {
Name = bridge;
Kind = "bridge";
};
networks = {
"30-${bridge}" = {
matchConfig.Name = bridge;
address = [ "${cfg.vm.gateway}/${toString cfg.vm.cidr}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
DHCP = "no";
IPv6AcceptRA = false;
LinkLocalAddressing = "no";
};
linkConfig.RequiredForOnline = "no";
};
"31-agent-grace" = {
matchConfig.Name = "agent-grace";
networkConfig = {
Bridge = bridge;
DHCP = "no";
IPv6AcceptRA = false;
LinkLocalAddressing = "no";
};
linkConfig.RequiredForOnline = "no";
};
};
};

boot.kernel.sysctl."net.ipv4.ip_forward" = 1;

# Do NOT enable networking.nftables or networking.nat here. On this host's
# stateVersion, the global nftables service defaults to flushing all tables
# (including Docker, k3s and Tailscale) and blacklists ip_tables. This service
# owns exactly one table; nft applies each replacement as one transaction.
system.build.graceEditorFirewall = rules;
system.build.graceEditorFirewallCleanup = cleanup;
system.build.graceEditorNetworkTest = pkgs.writeShellApplication {
name = "check-grace-editor-network";
runtimeInputs = with pkgs; [ iproute2 nftables util-linux ];
text = ''
# The test creates interfaces, routes and rules only after unshare.
exec unshare --mount --net --pid --fork --mount-proc \
${pkgs.python3}/bin/python3 ${./test-network.py} ${rules} ${cleanup}
'';
};
systemd.services.grace-editor-firewall = {
description = "Network isolation and LAN port forwarding for Grace's editor";
before = [ "microvm@grace-editor.service" ];
reloadIfChanged = true;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pkgs.nftables}/bin/nft --file ${rules}";
ExecReload = "${pkgs.nftables}/bin/nft --file ${rules}";
ExecStop = "${pkgs.nftables}/bin/nft --file ${cleanup}";
};
};

# BindsTo plus After means a stopped/failed firewall also stops the guest;
# on shutdown, the guest stops before its isolation rules are removed.
systemd.services."microvm@grace-editor" = {
requires = [ "install-microvm-grace-editor.service" ];
bindsTo = [ "grace-editor-firewall.service" ];
after = [ "install-microvm-grace-editor.service" "grace-editor-firewall.service" ];
};
};
}
50 changes: 50 additions & 0 deletions components/website-editor/options.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{ lib, ... }:
let
inherit (lib) mkEnableOption mkOption types;
in
{
options.components.website-editor = {
enable = mkEnableOption "Grace Bobber's isolated website-editor MicroVM";
lan = {
interface = mkOption {
type = types.strMatching "[a-zA-Z0-9_.-]+";
default = "br0";
description = "Existing LAN interface; never attached to the guest bridge.";
};
hostIP = mkOption {
type = types.strMatching "[0-9.]+";
default = "172.22.0.10";
description = "Only connections addressed to this host IPv4 address are forwarded.";
};
cidr = mkOption {
type = types.strMatching "[0-9.]+/[0-9]+";
default = "172.22.0.0/15";
description = "LAN client IPv4 subnet allowed to use the editor and preview.";
};
};
editorPort = mkOption {
type = types.port;
default = 4096;
description = "Host port forwarded to the OpenCode web UI.";
};
previewPort = mkOption {
type = types.port;
default = 4321;
description = "Host port forwarded to the Astro preview server.";
};
vm = {
ip = mkOption {
type = types.strMatching "[0-9.]+";
default = "192.168.83.2";
};
gateway = mkOption {
type = types.strMatching "[0-9.]+";
default = "192.168.83.1";
};
cidr = mkOption {
type = types.ints.between 0 32;
default = 24;
};
};
};
}
Loading
Loading