From 0165f2f91992042da3358b09979ab9497c6a078c Mon Sep 17 00:00:00 2001 From: Alex Jackson Date: Sun, 6 Sep 2026 16:23:50 -0500 Subject: [PATCH 1/3] feat: add isolated Grace website editor VM --- components/website-editor/default.nix | 9 ++ components/website-editor/networking.nix | 68 +++++++++++ components/website-editor/options.nix | 33 ++++++ components/website-editor/vm.nix | 144 +++++++++++++++++++++++ flake.lock | 132 +++++++++++++++++++-- flake.nix | 5 + hosts/patroclus/configuration.nix | 1 + 7 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 components/website-editor/default.nix create mode 100644 components/website-editor/networking.nix create mode 100644 components/website-editor/options.nix create mode 100644 components/website-editor/vm.nix diff --git a/components/website-editor/default.nix b/components/website-editor/default.nix new file mode 100644 index 0000000..a8d835d --- /dev/null +++ b/components/website-editor/default.nix @@ -0,0 +1,9 @@ +{ inputs, ... }: +{ + imports = [ + inputs.microvm.nixosModules.host + ./networking.nix + ./options.nix + ./vm.nix + ]; +} diff --git a/components/website-editor/networking.nix b/components/website-editor/networking.nix new file mode 100644 index 0000000..f50b500 --- /dev/null +++ b/components/website-editor/networking.nix @@ -0,0 +1,68 @@ +{ config, lib, ... }: +let + cfg = config.components.website-editor; + bridge = "agentbr0"; +in +{ + config = lib.mkIf cfg.enable { + # This is deliberately separate from br0. The guest has Internet access + # through NAT but cannot initiate connections to the homelab or its LAN. + 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; + }; + "31-agent-grace" = { + matchConfig.Name = "agent-grace"; + networkConfig.Bridge = bridge; + }; + }; + }; + + networking = { + nat = { + enable = true; + internalInterfaces = [ bridge ]; + externalInterface = "br0"; + forwardPorts = [ + { + sourcePort = cfg.editorPort; + destination = "${cfg.vm.ip}:${toString cfg.editorPort}"; + proto = "tcp"; + } + { + sourcePort = cfg.previewPort; + destination = "${cfg.vm.ip}:${toString cfg.previewPort}"; + proto = "tcp"; + } + ]; + }; + nftables.enable = true; + nftables.tables.agent-isolation = { + family = "inet"; + content = '' + chain forward { + type filter hook forward priority filter - 1; policy accept; + + # Do not let this autonomous guest reach the host, LAN, tailnet, + # Kubernetes ranges, or RFC1918 destinations. + iifname "${bridge}" ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } drop + iifname "${bridge}" ip6 daddr { ::1/128, fc00::/7, fe80::/10 } drop + + # Only the two explicitly forwarded services are reachable from + # the LAN. Replies to guest-originated Internet traffic continue + # to work through the normal connection tracker. + iifname "br0" oifname "${bridge}" ip daddr ${cfg.vm.ip} tcp dport { ${toString cfg.editorPort}, ${toString cfg.previewPort} } accept + iifname "br0" oifname "${bridge}" drop + } + ''; + }; + }; + }; +} diff --git a/components/website-editor/options.nix b/components/website-editor/options.nix new file mode 100644 index 0000000..864b79c --- /dev/null +++ b/components/website-editor/options.nix @@ -0,0 +1,33 @@ +{ lib, ... }: +let + inherit (lib) mkEnableOption mkOption types; +in +{ + options.components.website-editor = { + enable = mkEnableOption "Grace Bobber's isolated website-editor MicroVM"; + 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.str; + default = "192.168.83.2"; + }; + gateway = mkOption { + type = types.str; + default = "192.168.83.1"; + }; + cidr = mkOption { + type = types.ints.between 0 32; + default = 24; + }; + }; + }; +} diff --git a/components/website-editor/vm.nix b/components/website-editor/vm.nix new file mode 100644 index 0000000..2dc09da --- /dev/null +++ b/components/website-editor/vm.nix @@ -0,0 +1,144 @@ +{ config, lib, inputs, pkgs, ... }: +let + cfg = config.components.website-editor; + hostName = "grace-editor"; + repo = "https://github.com/ajaxbits/gracebobber.git"; +in +{ + config = lib.mkIf cfg.enable { + # Intentionally no autostart: run `systemctl start microvm@grace-editor` + # when the editing environment is wanted. + microvm.vms.${hostName} = { + inherit pkgs; + config = { + system.stateVersion = "26.05"; + networking = { + inherit hostName; + useDHCP = false; + firewall.enable = true; + firewall.allowedTCPPorts = [ cfg.editorPort cfg.previewPort ]; + }; + systemd.network = { + enable = true; + networks."20-agent" = { + matchConfig.Type = "ether"; + address = [ "${cfg.vm.ip}/${toString cfg.vm.cidr}" ]; + routes = [ { Gateway = cfg.vm.gateway; } ]; + networkConfig.DNS = [ "1.1.1.1" "1.0.0.1" ]; + }; + }; + + users.users.agent = { + isNormalUser = true; + uid = 1000; + group = "users"; + home = "/home/agent"; + createHome = true; + extraGroups = [ "wheel" ]; + }; + security.sudo.wheelNeedsPassword = false; + + environment.systemPackages = with pkgs; [ + inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2 + bash + curl + exiftool + fd + git + imagemagick + jj + just + nodejs_22 + poppler-utils + pkg-config + ripgrep + vips + ]; + + microvm = { + hypervisor = "cloud-hypervisor"; + vcpu = 4; + mem = 6144; + vsock.cid = 9; + storeOnDisk = true; + interfaces = [ { + type = "tap"; + id = "agent-grace"; + mac = "02:00:00:00:83:02"; + } ]; + volumes = [ + { + image = "grace-editor-data.img"; + # This is the only durable workspace visible to the agent. + # Keeping the home directory on the volume preserves the jj + # checkout, OpenCode sessions, credentials, and uploaded files. + mountPoint = "/home/agent"; + size = 32768; + } + { + image = "grace-editor-nix-overlay.img"; + mountPoint = "/nix/.rw-store"; + size = 12288; + } + ]; + writableStoreOverlay = "/nix/.rw-store"; + }; + + systemd.services.grace-editor-bootstrap = { + description = "Initialize Grace Bobber website checkout"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + path = with pkgs; [ git jj nodejs_22 ]; + serviceConfig = { + Type = "oneshot"; + User = "agent"; + WorkingDirectory = "/home/agent"; + }; + script = '' + if [ ! -d gracebobber/.git ]; then + git clone ${repo} gracebobber + fi + cd gracebobber + if [ ! -d .jj ]; then + jj git init --colocate + fi + if [ ! -d node_modules ]; then + npm ci + fi + ''; + }; + systemd.services.opencode2-grace-editor = { + description = "OpenCode web editor for Grace Bobber's website"; + wantedBy = [ "multi-user.target" ]; + after = [ "grace-editor-bootstrap.service" ]; + requires = [ "grace-editor-bootstrap.service" ]; + serviceConfig = { + User = "agent"; + WorkingDirectory = "/home/agent/gracebobber"; + Environment = [ + "HOME=/home/agent" + "GRACE_EDITOR_PREVIEW_URL=http://172.22.0.10:${toString cfg.previewPort}" + ]; + ExecStart = "${inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode2}/bin/opencode2 serve --hostname 0.0.0.0 --port ${toString cfg.editorPort}"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + systemd.services.grace-editor-preview = { + description = "Astro preview for Grace Bobber's website"; + wantedBy = [ "multi-user.target" ]; + after = [ "grace-editor-bootstrap.service" ]; + requires = [ "grace-editor-bootstrap.service" ]; + serviceConfig = { + User = "agent"; + WorkingDirectory = "/home/agent/gracebobber"; + ExecStart = "${pkgs.nodejs_22}/bin/npm run dev -- --host 0.0.0.0 --port ${toString cfg.previewPort} --strictPort"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + }; + }; + }; +} diff --git a/flake.lock b/flake.lock index f3ce407..3dc6e3e 100644 --- a/flake.lock +++ b/flake.lock @@ -64,6 +64,40 @@ "type": "github" } }, + "bun2nix": { + "inputs": { + "flake-parts": [ + "llm-agents", + "flake-parts" + ], + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ], + "systems": [ + "llm-agents", + "systems" + ], + "treefmt-nix": [ + "llm-agents", + "treefmt-nix" + ] + }, + "locked": { + "lastModified": 1788011267, + "narHash": "sha256-mEq2kU+ljompTToZ44Afvm7d/rHJ5iMBl0tjZuyShJs=", + "owner": "Mic92", + "repo": "bun2nix", + "rev": "5765b0614591f75ee8ba5596e81ae85c167d1071", + "type": "github" + }, + "original": { + "owner": "Mic92", + "ref": "fix-structured-attrs-hook", + "repo": "bun2nix", + "type": "github" + } + }, "centerpiece": { "inputs": { "crane": "crane", @@ -233,6 +267,27 @@ } }, "flake-parts_3": { + "inputs": { + "nixpkgs-lib": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_4": { "inputs": { "nixpkgs-lib": "nixpkgs-lib_3" }, @@ -250,7 +305,7 @@ "type": "github" } }, - "flake-parts_4": { + "flake-parts_5": { "inputs": { "nixpkgs-lib": [ "neovim", @@ -272,7 +327,7 @@ "type": "github" } }, - "flake-parts_5": { + "flake-parts_6": { "inputs": { "nixpkgs-lib": [ "nur", @@ -293,7 +348,7 @@ "type": "github" } }, - "flake-parts_6": { + "flake-parts_7": { "inputs": { "nixpkgs-lib": "nixpkgs-lib_4" }, @@ -392,6 +447,30 @@ "type": "github" } }, + "llm-agents": { + "inputs": { + "bun2nix": "bun2nix", + "flake-parts": "flake-parts_3", + "nixpkgs": [ + "unstable" + ], + "systems": "systems_2", + "treefmt-nix": "treefmt-nix_2" + }, + "locked": { + "lastModified": 1788727055, + "narHash": "sha256-yky5otgizfQp+NOB8HbbwE5IL/E85X1M2BgSASOa9r8=", + "owner": "numtide", + "repo": "llm-agents.nix", + "rev": "d84ea2fa5364f2ecf826efde728a0744511d008c", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "llm-agents.nix", + "type": "github" + } + }, "microvm": { "inputs": { "nixpkgs": [ @@ -431,7 +510,7 @@ }, "neovim": { "inputs": { - "flake-parts": "flake-parts_3", + "flake-parts": "flake-parts_4", "nixpkgs": "nixpkgs_4", "nixvim": "nixvim" }, @@ -671,9 +750,9 @@ }, "nixvim": { "inputs": { - "flake-parts": "flake-parts_4", + "flake-parts": "flake-parts_5", "nixpkgs": "nixpkgs_5", - "systems": "systems_2" + "systems": "systems_3" }, "locked": { "lastModified": 1775307257, @@ -691,7 +770,7 @@ }, "nur": { "inputs": { - "flake-parts": "flake-parts_5", + "flake-parts": "flake-parts_6", "nixpkgs": "nixpkgs_8" }, "locked": { @@ -814,6 +893,7 @@ "disko": "disko", "flake-parts": "flake-parts_2", "home-manager": "home-manager_3", + "llm-agents": "llm-agents", "microvm": "microvm", "mypkgs": "mypkgs", "neovim": "neovim", @@ -895,6 +975,21 @@ "type": "github" } }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "treefmt-nix": { "inputs": { "nixpkgs": "nixpkgs_3" @@ -913,6 +1008,27 @@ "type": "github" } }, + "treefmt-nix_2": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786901030, + "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + }, "unfree": { "inputs": { "nixpkgs": [ @@ -992,7 +1108,7 @@ }, "vpod": { "inputs": { - "flake-parts": "flake-parts_6", + "flake-parts": "flake-parts_7", "nixpkgs": [ "unstable" ], diff --git a/flake.nix b/flake.nix index cba5fe6..ee78dbc 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,11 @@ inputs.nixpkgs.follows = "unstable"; }; + llm-agents = { + url = "github:numtide/llm-agents.nix"; + inputs.nixpkgs.follows = "unstable"; + }; + # custom pkgs centerpiece = { url = "github:friedow/centerpiece"; diff --git a/hosts/patroclus/configuration.nix b/hosts/patroclus/configuration.nix index 1400622..2482ed4 100644 --- a/hosts/patroclus/configuration.nix +++ b/hosts/patroclus/configuration.nix @@ -54,6 +54,7 @@ in "github:ajaxbits/config#patroclus"; }; cloudflared.enable = true; + website-editor.enable = true; ebooks.enable = false; mediacenter = { enable = true; From 20c6bb1ff31880aa3bfc06890e186182325c97c2 Mon Sep 17 00:00:00 2001 From: Alex Jackson Date: Sun, 6 Sep 2026 16:46:56 -0500 Subject: [PATCH 2/3] fix: isolate editor firewall without disrupting host networking --- components/website-editor/README.md | 75 ++++++ components/website-editor/firewall-rules.nix | 74 ++++++ components/website-editor/networking.nix | 102 ++++---- components/website-editor/options.nix | 21 +- components/website-editor/test-network.py | 235 +++++++++++++++++++ components/website-editor/vm.nix | 9 +- 6 files changed, 472 insertions(+), 44 deletions(-) create mode 100644 components/website-editor/README.md create mode 100644 components/website-editor/firewall-rules.nix create mode 100644 components/website-editor/test-network.py diff --git a/components/website-editor/README.md b/components/website-editor/README.md new file mode 100644 index 0000000..8eb6f46 --- /dev/null +++ b/components/website-editor/README.md @@ -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`. diff --git a/components/website-editor/firewall-rules.nix b/components/website-editor/firewall-rules.nix new file mode 100644 index 0000000..5f44aac --- /dev/null +++ b/components/website-editor/firewall-rules.nix @@ -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 + } + } +'' diff --git a/components/website-editor/networking.nix b/components/website-editor/networking.nix index f50b500..eb09da7 100644 --- a/components/website-editor/networking.nix +++ b/components/website-editor/networking.nix @@ -1,12 +1,22 @@ -{ config, lib, ... }: +{ 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 { - # This is deliberately separate from br0. The guest has Internet access - # through NAT but cannot initiate connections to the homelab or its LAN. + assertions = [ + { + assertion = cfg.editorPort != cfg.previewPort; + message = "The website editor and preview must use different ports."; + } + ]; + systemd.network = { netdevs."30-${bridge}".netdevConfig = { Name = bridge; @@ -16,53 +26,63 @@ in "30-${bridge}" = { matchConfig.Name = bridge; address = [ "${cfg.vm.gateway}/${toString cfg.vm.cidr}" ]; - networkConfig.ConfigureWithoutCarrier = true; + networkConfig = { + ConfigureWithoutCarrier = true; + DHCP = "no"; + IPv6AcceptRA = false; + LinkLocalAddressing = "no"; + }; + linkConfig.RequiredForOnline = "no"; }; "31-agent-grace" = { matchConfig.Name = "agent-grace"; - networkConfig.Bridge = bridge; + networkConfig = { + Bridge = bridge; + DHCP = "no"; + IPv6AcceptRA = false; + LinkLocalAddressing = "no"; + }; + linkConfig.RequiredForOnline = "no"; }; }; }; - networking = { - nat = { - enable = true; - internalInterfaces = [ bridge ]; - externalInterface = "br0"; - forwardPorts = [ - { - sourcePort = cfg.editorPort; - destination = "${cfg.vm.ip}:${toString cfg.editorPort}"; - proto = "tcp"; - } - { - sourcePort = cfg.previewPort; - destination = "${cfg.vm.ip}:${toString cfg.previewPort}"; - proto = "tcp"; - } - ]; - }; - nftables.enable = true; - nftables.tables.agent-isolation = { - family = "inet"; - content = '' - chain forward { - type filter hook forward priority filter - 1; policy accept; - - # Do not let this autonomous guest reach the host, LAN, tailnet, - # Kubernetes ranges, or RFC1918 destinations. - iifname "${bridge}" ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } drop - iifname "${bridge}" ip6 daddr { ::1/128, fc00::/7, fe80::/10 } drop + boot.kernel.sysctl."net.ipv4.ip_forward" = 1; - # Only the two explicitly forwarded services are reachable from - # the LAN. Replies to guest-originated Internet traffic continue - # to work through the normal connection tracker. - iifname "br0" oifname "${bridge}" ip daddr ${cfg.vm.ip} tcp dport { ${toString cfg.editorPort}, ${toString cfg.previewPort} } accept - iifname "br0" oifname "${bridge}" drop - } - ''; + # 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" ]; + }; }; } diff --git a/components/website-editor/options.nix b/components/website-editor/options.nix index 864b79c..50b25b4 100644 --- a/components/website-editor/options.nix +++ b/components/website-editor/options.nix @@ -5,6 +5,23 @@ 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; @@ -17,11 +34,11 @@ in }; vm = { ip = mkOption { - type = types.str; + type = types.strMatching "[0-9.]+"; default = "192.168.83.2"; }; gateway = mkOption { - type = types.str; + type = types.strMatching "[0-9.]+"; default = "192.168.83.1"; }; cidr = mkOption { diff --git a/components/website-editor/test-network.py b/components/website-editor/test-network.py new file mode 100644 index 0000000..8a541c8 --- /dev/null +++ b/components/website-editor/test-network.py @@ -0,0 +1,235 @@ +"""Packet-level regression tests for the component's default network settings. + +Invoked by check-grace-editor-network inside NEW mount/network/PID namespaces. +No real network, Docker, Kubernetes, or Tailscale services are contacted. +""" + +import json +import os +from pathlib import Path +import subprocess +import sys + + +def run(*args, input=None, ns=None): + prefix = ["ip", "netns", "exec", ns] if ns else [] + result = subprocess.run(prefix + list(args), input=input, text=True, capture_output=True) + if result.returncode: + raise RuntimeError(f"{prefix + list(args)} failed:\n{result.stderr}") + return result.stdout + + +def ip(*args, ns=None): + return run("ip", *args, ns=ns) + + +def check(label, condition): + if not condition: + raise AssertionError(label) + print(f"PASS: {label}", flush=True) + + +def tcp(ns, address, port, allowed=True, source=None, payload="hello", expected="hello"): + code = """ +import socket, sys +s = socket.socket() +s.settimeout(0.6) +if sys.argv[3]: s.bind((sys.argv[3], 0)) +try: + s.connect((sys.argv[1], int(sys.argv[2]))) + s.sendall(sys.argv[4].encode()) + result = s.recv(100).decode() +except OSError: + result = 'blocked' +print(result) +""" + result = run(sys.executable, "-c", code, address, str(port), source or "", payload, ns=ns).strip() + check( + f"{ns or 'host'} -> {address}:{port} {'allowed' if allowed else 'blocked'}" + + (f" (source {source})" if source else ""), + result == (expected if allowed else "blocked"), + ) + + +SERVER = """ +import socket, sys, threading +def serve(port): + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('0.0.0.0', port)) + s.listen() + def reply(conn): + with conn: + while data := conn.recv(100): + response = sys.argv[1].encode() if data == b'who' else conn.getpeername()[0].encode() if data == b'peer' else data + conn.sendall(response) + def accept(): + while True: + conn, _ = s.accept() + threading.Thread(target=reply, args=(conn,), daemon=True).start() + threading.Thread(target=accept, daemon=True).start() +for port in map(int, sys.argv[2:]): + serve(port) + print('ready', flush=True) +threading.Event().wait() +""" + + +def server(ns, *ports): + prefix = ["ip", "netns", "exec", ns] if ns else [] + process = subprocess.Popen( + prefix + [sys.executable, "-u", "-c", SERVER, ns or "host", *map(str, ports)], + stdout=subprocess.PIPE, text=True, + ) + for _ in ports: + if process.stdout.readline().strip() != "ready": + raise RuntimeError("echo server failed to start") + return process + + +def topology(): + # Fail rather than run against a populated (potentially host) namespace. + links = json.loads(ip("-j", "link")) + check("fresh isolated network namespace", [link["ifname"] for link in links] == ["lo"]) + check("fresh isolated PID namespace", os.getpid() == 1) + run("mount", "--make-rprivate", "/") + run("mount", "-t", "tmpfs", "tmpfs", "/run") + Path("/run/netns").mkdir() + ip("link", "set", "lo", "up") + for bridge, address in [("br0", "172.22.0.10/15"), ("agentbr0", "192.168.83.1/24")]: + ip("link", "add", bridge, "type", "bridge") + ip("address", "add", address, "dev", bridge) + ip("link", "set", bridge, "up") + + # guest uses the actual agent-grace bridge port; the other namespaces + # represent LAN, the upstream router/Internet, and a VPN peer. + for ns, tap, bridge, address in [ + ("guest", "agent-grace", "agentbr0", "192.168.83.2/24"), + ("lan", "lan-link", "br0", "172.22.0.20/15"), + ("wan", "wan-link", "br0", "172.22.0.1/15"), + ("vpn", "tailscale0", None, "100.64.0.2/30"), + ]: + ip("netns", "add", ns) + ip("link", "add", tap, "type", "veth", "peer", "name", "eth0", "netns", ns) + if bridge: + ip("link", "set", tap, "master", bridge) + else: + ip("address", "add", "100.64.0.1/30", "dev", tap) + ip("link", "set", tap, "up") + ip("link", "set", "lo", "up", ns=ns) + ip("link", "set", "eth0", "up", ns=ns) + ip("address", "add", address, "dev", "eth0", ns=ns) + + Path("/proc/sys/net/ipv4/ip_forward").write_text("1") + ip("route", "add", "default", "via", "172.22.0.1") + for ns, gateway in [("guest", "192.168.83.1"), ("lan", "172.22.0.10"), ("vpn", "100.64.0.1")]: + ip("route", "add", "default", "via", gateway, ns=ns) + # These public IPs exist ONLY in the test. No Internet traffic escapes. + for address in ["1.1.1.1/32", "8.8.8.8/32"]: + ip("address", "add", address, "dev", "lo", ns="wan") + ip("address", "add", "9.9.9.9/32", "dev", "lo", ns="vpn") + ip("route", "add", "9.9.9.9/32", "via", "100.64.0.2") + ip("address", "add", "192.168.83.3/24", "dev", "eth0", ns="guest") + ip("-6", "address", "add", "fd00:83::1/64", "dev", "agentbr0", "nodad") + ip("-6", "address", "add", "fd00:83::2/64", "dev", "eth0", "nodad", ns="guest") + + +def main(): + rules, cleanup = sys.argv[1:] + topology() + # Emulate unrelated owners' tables. Compare the full contents, not just + # table names, across first install, reloads, and removal of our table. + for name in ["DOCKER", "KUBE_SERVICES", "ts_forward"]: + run("nft", "-f", "-", input=f"table ip {name} {{\n chain sentinel {{\n ip saddr 10.0.0.99 drop\n }}\n}}\n") + before = {name: run("nft", "list", "table", "ip", name) for name in ["DOCKER", "KUBE_SERVICES", "ts_forward"]} + + def preserved(): + check("other owners' tables preserved", all(run("nft", "list", "table", "ip", name) == text for name, text in before.items())) + + servers = [] + try: + for ns, ports in [(None, [9999]), ("guest", [4096, 4321, 9999]), ("lan", [9999]), ("wan", [4096, 443, 9999]), ("vpn", [9999])]: + servers.append(server(ns, *ports)) + # Sanity: without the policy, all the prohibited test endpoints really + # are reachable. Failure below must not be due to missing routes. + tcp("guest", "172.22.0.20", 9999) + tcp("guest", "100.64.0.2", 9999) + tcp("lan", "192.168.83.2", 9999) + + run("nft", "--check", "--file", rules) + run("nft", "--file", rules) + preserved() + for port in [4096, 4321]: + tcp("lan", "172.22.0.10", port) + tcp("guest", "1.1.1.1", 443) + tcp("guest", "1.1.1.1", 443, payload="peer", expected="172.22.0.10") + tcp(None, "192.168.83.2", 9999) # host-initiated administration + tcp("guest", "172.22.0.10", 9999, allowed=False) + tcp("guest", "192.168.83.1", 9999, allowed=False) + tcp("guest", "172.22.0.20", 9999, allowed=False) + tcp("guest", "100.64.0.2", 9999, allowed=False) + tcp("guest", "9.9.9.9", 9999, allowed=False) # public address routed via VPN + tcp("guest", "1.1.1.1", 443, allowed=False, source="192.168.83.3") + tcp("lan", "192.168.83.2", 4096, allowed=False) + tcp("lan", "192.168.83.2", 9999, allowed=False) + tcp("vpn", "192.168.83.2", 4096, allowed=False) + tcp("wan", "172.22.0.10", 4096, allowed=False, source="8.8.8.8") + # LAN transit to somebody else's port 4096 must not be DNATed. + tcp("lan", "8.8.8.8", 4096, payload="who", expected="wan") + tcp("lan", "172.22.0.10", 9999) # ordinary host access unchanged + tcp("vpn", "172.22.0.10", 9999) + + udp_code = """ +import socket +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.bind(('1.1.1.1', 53)) +print('ready', flush=True) +while True: + data, peer = s.recvfrom(100) + s.sendto(data, peer) +""" + udp_server = subprocess.Popen(["ip", "netns", "exec", "wan", sys.executable, "-u", "-c", udp_code], stdout=subprocess.PIPE, text=True) + servers.append(udp_server) + check("UDP server ready", udp_server.stdout.readline().strip() == "ready") + run(sys.executable, "-c", "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(2); s.sendto(b'dns',('1.1.1.1',53)); assert s.recv(100)==b'dns'", ns="guest") + print("PASS: guest UDP DNS request and reply", flush=True) + ipv6 = subprocess.run(["ip", "netns", "exec", "guest", "ip", "-6", "route", "get", "fd00:83::1"], capture_output=True) + check("IPv6 test route exists", ipv6.returncode == 0) + # An IPv6 ping would otherwise succeed on this directly connected link. + run(sys.executable, "-c", "import socket; s=socket.socket(socket.AF_INET6,socket.SOCK_DGRAM); s.sendto(b'ipv6',('fd00:83::1',9999))", ns="guest") + # Read the drop counter to prove the packet reached the IPv6 rule. + import time + time.sleep(0.1) + chain = json.loads(run("nft", "-j", "list", "chain", "inet", "grace_editor", "from_guest_to_host")) + ipv6_rule = next(item["rule"] for item in chain["nftables"] if "rule" in item) + check("guest IPv6 dropped on host", any(expr.get("counter", {}).get("packets", 0) > 0 for expr in ipv6_rule["expr"])) + + # Keep an editor connection alive across an atomic policy replacement. + client = subprocess.Popen(["ip", "netns", "exec", "lan", sys.executable, "-u", "-c", "import socket,sys; s=socket.create_connection(('172.22.0.10',4096)); s.settimeout(2); print('ready',flush=True); sys.stdin.readline(); s.sendall(b'alive'); assert s.recv(100)==b'alive'"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + servers.append(client) + check("editor connection established before reload", client.stdout.readline().strip() == "ready") + for _ in range(2): + run("nft", "--file", rules) + preserved() + client.communicate("continue\n", timeout=5) + check("editor connection survives reload", client.returncode == 0) + + # A failed nft transaction must leave the working isolation in place. + invalid = Path(rules).read_text() + "\nthis is not valid nft syntax\n" + result = subprocess.run(["nft", "-f", "-"], input=invalid, text=True, capture_output=True) + check("invalid reload rejected", result.returncode != 0) + tcp("guest", "172.22.0.10", 9999, allowed=False) + tcp("lan", "172.22.0.10", 4096) + for _ in range(2): + run("nft", "--file", cleanup) + preserved() + check("our table removed", "grace_editor" not in run("nft", "list", "tables")) + finally: + for process in servers: + process.terminate() + for process in servers: + process.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/components/website-editor/vm.nix b/components/website-editor/vm.nix index 2dc09da..505bf0a 100644 --- a/components/website-editor/vm.nix +++ b/components/website-editor/vm.nix @@ -10,11 +10,13 @@ in # when the editing environment is wanted. microvm.vms.${hostName} = { inherit pkgs; + autostart = false; config = { system.stateVersion = "26.05"; networking = { inherit hostName; useDHCP = false; + enableIPv6 = false; firewall.enable = true; firewall.allowedTCPPorts = [ cfg.editorPort cfg.previewPort ]; }; @@ -24,7 +26,12 @@ in matchConfig.Type = "ether"; address = [ "${cfg.vm.ip}/${toString cfg.vm.cidr}" ]; routes = [ { Gateway = cfg.vm.gateway; } ]; - networkConfig.DNS = [ "1.1.1.1" "1.0.0.1" ]; + networkConfig = { + DNS = [ "1.1.1.1" "1.0.0.1" ]; + DHCP = "no"; + IPv6AcceptRA = false; + LinkLocalAddressing = "no"; + }; }; }; From 3b0e4713ad3dd41bf9e491edbae8b96e8feff032 Mon Sep 17 00:00:00 2001 From: Alex Jackson Date: Sun, 6 Sep 2026 16:46:56 -0500 Subject: [PATCH 3/3] fix: unblock full patroclus configuration evaluation --- components/bookmarks/linkding/backups.nix | 3 ++- flake.nix | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/components/bookmarks/linkding/backups.nix b/components/bookmarks/linkding/backups.nix index 9f554f1..14af386 100644 --- a/components/bookmarks/linkding/backups.nix +++ b/components/bookmarks/linkding/backups.nix @@ -2,6 +2,7 @@ config, pkgs, lib, + self, ... }: let @@ -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; diff --git a/flake.nix b/flake.nix index ee78dbc..3ed2c73 100644 --- a/flake.nix +++ b/flake.nix @@ -127,7 +127,10 @@ in { patroclus = nixpkgs.lib.nixosSystem { - inherit specialArgs system; + inherit system; + # Module arguments with a function default still need to be + # supplied through specialArgs when loaded by the module system. + specialArgs = specialArgs // { isStripped = false; }; modules = [ "${self}/common" "${self}/components"