From 2ad5653fda2c2e12b709330fc6260cdb07ed35f0 Mon Sep 17 00:00:00 2001 From: heather Date: Sat, 26 Sep 2026 23:13:08 +0000 Subject: [PATCH] heather: replace personal mesh with guarded AstrNet stub --- hosts/heather/astrnet.nix | 199 +++++++++++++++++++++++++++++++++ hosts/heather/default.nix | 55 +-------- hosts/heather/mesh-clients.nix | 48 -------- hosts/heather/mesh-egress.nix | 76 ------------- 4 files changed, 203 insertions(+), 175 deletions(-) create mode 100644 hosts/heather/astrnet.nix delete mode 100644 hosts/heather/mesh-clients.nix delete mode 100644 hosts/heather/mesh-egress.nix diff --git a/hosts/heather/astrnet.nix b/hosts/heather/astrnet.nix new file mode 100644 index 0000000..ee67848 --- /dev/null +++ b/hosts/heather/astrnet.nix @@ -0,0 +1,199 @@ +# astrnet.nix — AstrNet stub peer "michaotic". Generated by astrnet-join.sh. +# +# What this host is to the mesh: identity 10.0.0.9 / 2a05:f480:2400:1cf7::9, +# ASN 4200000009, two WireGuard legs that THIS host dials: +# wg-mic-der -> alderamin (Helsinki) primary, pair 38 +# wg-mic-bd8 -> bd8816 (Moscow) backup, pair 39 (paths come prepended) +# It announces only its identity and accepts only mesh address space; it +# never becomes transit and never takes a default route from the mesh. +# +# All values are assigned by the AstrNet admin. Do not renumber anything +# here without them: the far ends are configured to match exactly. +{ config, lib, pkgs, ... }: +let + keyFile = "/var/lib/astrnet/wg.key"; + id4 = "10.0.0.9"; + id6 = "2a05:f480:2400:1cf7::9"; + + mkLeg = { listenPort, ips, publicKey, endpoint }: { + inherit ips listenPort; + privateKeyFile = keyFile; + mtu = 1380; + # Routing is BGP's job. allowedIPs 0/0 only PERMITS any source through + # the tunnel; with routes off, it installs nothing. Turning routes on + # would hijack this host's default route. + allowedIPsAsRoutes = false; + peers = [{ + inherit publicKey endpoint; + allowedIPs = [ "0.0.0.0/0" "::/0" ]; + persistentKeepalive = 25; + }]; + }; +in { + networking.wireguard.interfaces = { + # The fe80:: link-locals are required: without one FRR's IPv6 BGP + # next-hop selection fails and the v6 session never leaves Connect. + wg-mic-der = mkLeg { + listenPort = 52038; + ips = [ "10.99.0.154/30" "fd53:4023:2326:99::9a/126" "fe80::2/64" ]; + publicKey = "a4FyXuoI16TxZxv407lSzJV2YFJdq6FgRAtGu+oMzEY="; + endpoint = "31.77.105.157:52038"; + }; + wg-mic-bd8 = mkLeg { + listenPort = 52039; + ips = [ "10.99.0.158/30" "fd53:4023:2326:99::9e/126" "fe80::2/64" ]; + publicKey = "QIvX+avvJqaHxuksGWb6Hx8fv7R6VI5wpaGqLme6Fgw="; + endpoint = "195.133.60.217:52039"; + }; + }; + + # Heather's public firewall is disabled (the cloud SG is the WAN barrier). + # Explicitly restrict traffic FROM AstrNet before normal Docker/host rules: + # BGP from the two expected neighbors, ping to this mesh ID; no transit. + # Existing outbound sessions may receive established replies. + systemd.services.astrnet-guard = { + description = "Restrict AstrNet tunnel ingress"; + wantedBy = [ "multi-user.target" ]; + requiredBy = [ "wireguard-wg-mic-der.service" "wireguard-wg-mic-bd8.service" ]; + before = [ "wireguard-wg-mic-der.service" "wireguard-wg-mic-bd8.service" ]; + path = [ pkgs.nftables ]; + serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; + script = '' + nft list table inet astrnet_guard >/dev/null 2>&1 && nft delete table inet astrnet_guard + nft -f - <<'RULES' + table inet astrnet_guard { + chain input { + type filter hook input priority -150; policy accept; + iifname { "wg-mic-der", "wg-mic-bd8" } ct state established,related accept + iifname "wg-mic-der" ip saddr 10.99.0.153 tcp dport 179 accept + iifname "wg-mic-bd8" ip saddr 10.99.0.157 tcp dport 179 accept + iifname "wg-mic-der" ip6 saddr fd53:4023:2326:99::99 tcp dport 179 accept + iifname "wg-mic-bd8" ip6 saddr fd53:4023:2326:99::9d tcp dport 179 accept + iifname { "wg-mic-der", "wg-mic-bd8" } ip daddr 10.0.0.9 icmp type echo-request accept + iifname { "wg-mic-der", "wg-mic-bd8" } drop + } + chain forward { + type filter hook forward priority -150; policy accept; + iifname { "wg-mic-der", "wg-mic-bd8" } drop + } + } + RULES + ''; + preStop = "nft delete table inet astrnet_guard || true"; + }; + + # Mesh identity on a dummy interface. It must exist before FRR installs + # routes, because those routes are pinned to it as their source address. + systemd.services.astrnet-mesh0 = { + description = "AstrNet mesh identity (mesh0)"; + wantedBy = [ "multi-user.target" ]; + before = [ "frr.service" ]; + requiredBy = [ "frr.service" ]; + path = [ pkgs.iproute2 ]; + serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; + script = '' + ip link show mesh0 >/dev/null 2>&1 || ip link add mesh0 type dummy + ip link set mesh0 up + ip addr replace ${id4}/32 dev mesh0 + ip -6 addr replace ${id6}/128 dev mesh0 + ''; + preStop = "ip link del mesh0 || true"; + }; + + # BGP only inside the tunnels, never on the public interface. + networking.firewall.interfaces."wg-mic-der".allowedTCPPorts = [ 179 ]; + networking.firewall.interfaces."wg-mic-bd8".allowedTCPPorts = [ 179 ]; + # Two legs means replies can arrive on the leg that is not the best route + # back. Strict reverse-path filtering drops those; loose keeps the spoof + # check (a route must exist) without caring which leg it points at. + networking.firewall.checkReversePath = lib.mkDefault "loose"; + + networking.networkmanager.unmanaged = + lib.mkIf config.networking.networkmanager.enable + [ "interface-name:wg-mic-*" "interface-name:mesh0" ]; + + services.frr = { + bgpd.enable = true; + config = '' + ip prefix-list OWN-V4 seq 5 permit ${id4}/32 + ipv6 prefix-list OWN-V6 seq 5 permit ${id6}/128 + ! + ! Accept mesh space only: never a default, never public prefixes. The + ! mesh carries anycast /32s for public services (DNS resolvers etc.); + ! accepting those would steer this host's own traffic into AstrNet. + ! It also keeps the tunnel endpoints themselves out of the tunnels. + ! Narrow to AstrNet address space; do not accept Yandex Cloud 10.130/24. + ip prefix-list MESH-V4 seq 5 permit 10.0.0.0/24 ge 24 le 32 + ip prefix-list MESH-V4 seq 10 permit 10.0.1.0/24 ge 24 le 32 + ip prefix-list MESH-V4 seq 15 permit 10.0.2.0/24 ge 24 le 32 + ip prefix-list MESH-V4 seq 20 permit 10.99.0.0/22 ge 22 le 32 + ipv6 prefix-list MESH-V6 seq 5 permit 2a05:f480:2400:1cf7::/64 le 128 + ipv6 prefix-list MESH-V6 seq 10 permit fd53:4023:2326::/48 le 128 + ! + route-map MESH-IN-V4 permit 10 + match ip address prefix-list MESH-V4 + exit + route-map MESH-IN-V6 permit 10 + match ipv6 address prefix-list MESH-V6 + exit + route-map MESH-OUT-V4 permit 10 + match ip address prefix-list OWN-V4 + exit + route-map MESH-OUT-V6 permit 10 + match ipv6 address prefix-list OWN-V6 + exit + ! + ! Source mesh traffic from the identity, not from whichever tunnel /30 + ! it leaves by; otherwise replies break whenever the path changes. + route-map SET-MESH-SRC permit 10 + set src ${id4} + exit + route-map SET-MESH-SRC-V6 permit 10 + set src ${id6} + exit + ip protocol bgp route-map SET-MESH-SRC + ipv6 protocol bgp route-map SET-MESH-SRC-V6 + ! + router bgp 4200000009 + bgp router-id ${id4} + no bgp ebgp-requires-policy + no bgp default ipv4-unicast + bgp bestpath as-path multipath-relax + neighbor 10.99.0.153 remote-as 4200000012 + neighbor 10.99.0.153 description alderamin-v4-wg-mic-der + neighbor 10.99.0.153 update-source 10.99.0.154 + neighbor 10.99.0.157 remote-as 4200000005 + neighbor 10.99.0.157 description bd8816-v4-wg-mic-bd8 + neighbor 10.99.0.157 update-source 10.99.0.158 + neighbor fd53:4023:2326:99::99 remote-as 4200000012 + neighbor fd53:4023:2326:99::99 description alderamin-v6-wg-mic-der + neighbor fd53:4023:2326:99::99 interface wg-mic-der + neighbor fd53:4023:2326:99::99 update-source fd53:4023:2326:99::9a + neighbor fd53:4023:2326:99::9d remote-as 4200000005 + neighbor fd53:4023:2326:99::9d description bd8816-v6-wg-mic-bd8 + neighbor fd53:4023:2326:99::9d interface wg-mic-bd8 + neighbor fd53:4023:2326:99::9d update-source fd53:4023:2326:99::9e + ! + address-family ipv4 unicast + network ${id4}/32 + neighbor 10.99.0.153 activate + neighbor 10.99.0.153 route-map MESH-IN-V4 in + neighbor 10.99.0.153 route-map MESH-OUT-V4 out + neighbor 10.99.0.157 activate + neighbor 10.99.0.157 route-map MESH-IN-V4 in + neighbor 10.99.0.157 route-map MESH-OUT-V4 out + exit-address-family + ! + address-family ipv6 unicast + network ${id6}/128 + neighbor fd53:4023:2326:99::99 activate + neighbor fd53:4023:2326:99::99 route-map MESH-IN-V6 in + neighbor fd53:4023:2326:99::99 route-map MESH-OUT-V6 out + neighbor fd53:4023:2326:99::9d activate + neighbor fd53:4023:2326:99::9d route-map MESH-IN-V6 in + neighbor fd53:4023:2326:99::9d route-map MESH-OUT-V6 out + exit-address-family + exit + ''; + }; +} diff --git a/hosts/heather/default.nix b/hosts/heather/default.nix index 1241dd8..0291760 100644 --- a/hosts/heather/default.nix +++ b/hosts/heather/default.nix @@ -23,8 +23,7 @@ ./paseo.nix # paseo daemon (web UI для CLI-агентов, systemd + npm global) ./nick.nix # Nick timer prototype (systemd, localhost:8787) ./jupyter.nix # JupyterLab (systemd, localhost:8890, лимиты) - ./mesh-clients.nix # Sol-style client pool (wg-clients1) - ./mesh-egress.nix # Sol-style selected-destination egress policy + ./astrnet.nix # AstrNet (Sol): two WireGuard legs, FRR/BGP, ingress guard ]; # Heather has 8 GiB RAM and runs several containers. Keep a 4 GiB @@ -36,10 +35,8 @@ } ]; - # First server-to-server WireGuard backbone link: heather ↔ hyacinth. - # The private key is generated on the live host and stays outside the flake. - # Basic diagnostics/admin tools must be available even in non-login SSH - # sessions (and not depend on a stale Home Manager profile). + # WireGuard tooling is also used by AstrNet. Keep this package and the + # diagnostics tools available in non-login SSH sessions. environment.systemPackages = with pkgs; [ wireguard-tools tmux @@ -48,49 +45,7 @@ python3 ]; - # NixOS uses scripted networking here (not systemd-networkd), so create the - # dummy identity interface through a small idempotent systemd unit. - systemd.services.mesh-identity = { - description = "Create mesh identity interface"; - wantedBy = [ "network.target" ]; - after = [ "wireguard-wg-mesh.service" "network-addresses-wg-mesh.service" ]; - wants = [ "wireguard-wg-mesh.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = pkgs.writeShellScript "mesh-identity-up" '' - set -eu - ${pkgs.iproute2}/bin/ip link show mesh0 >/dev/null 2>&1 || ${pkgs.iproute2}/bin/ip link add mesh0 type dummy - ${pkgs.iproute2}/bin/ip addr replace 10.0.0.1/32 dev mesh0 - ${pkgs.iproute2}/bin/ip link set mesh0 up - ''; - ExecStop = "${pkgs.iproute2}/bin/ip link del mesh0"; - }; - }; - - networking.wireguard.interfaces.wg-mesh = { - ips = [ "10.99.0.1/30" ]; - listenPort = 51820; - privateKeyFile = "/var/lib/wireguard/wg-mesh.key"; - # Keep WG as transport/crypto ACL; install the identity route explicitly below. - allowedIPsAsRoutes = false; - peers = [ - { - publicKey = "BxTH8+MiA8Hv48UfpWJDVXIhbO5HgO2KTBEXIqqr23k="; - # Match all destinations for future policy routes; allowedIPsAsRoutes=false - # keeps this from installing a default route automatically. - allowedIPs = [ "0.0.0.0/0" ]; - endpoint = "195.242.119.59:51820"; - persistentKeepalive = 25; - } - ]; - }; - - networking.interfaces.wg-mesh.ipv4.routes = [ - { address = "10.0.0.2"; prefixLength = 32; } - ]; - - # Needed later when this node becomes a transit/exit peer. + # Docker requires forwarding; AstrNet ingress is blocked by astrnet-guard. boot.kernel.sysctl."net.ipv4.ip_forward" = 1; # Arion-проект как systemd-сервис: arion-compose.nix → docker-compose → up. @@ -122,10 +77,8 @@ 22070 # strelaysrv (status) 3478 # coturn TURN (tcp+udp) 8443 # xray VLESS+Reality (публичный прокси) - 51820 # WireGuard mesh backbone ]; firewall.allowedUDPPorts = [ - 51820 # WireGuard mesh backbone 22000 # syncthing sync (QUIC) 10000 # jitsi jvb (RTP media) 3478 # coturn TURN diff --git a/hosts/heather/mesh-clients.nix b/hosts/heather/mesh-clients.nix deleted file mode 100644 index 41c35ca..0000000 --- a/hosts/heather/mesh-clients.nix +++ /dev/null @@ -1,48 +0,0 @@ -# Sol-style client pool for personal mesh. -# Backbone peers use wg-mesh; phones/laptops use this separate interface. -{ config, pkgs, ... }: -{ - environment.systemPackages = [ pkgs.wireguard-tools ]; - - networking.wireguard.interfaces.wg-clients1 = { - ips = [ "10.0.1.1/24" ]; - listenPort = 51821; - privateKeyFile = "/var/lib/wireguard/wg-clients1.key"; - allowedIPsAsRoutes = false; - peers = [ - { - # lilac (Pixel 9 Pro), generated on poppy; private key is not in git. - publicKey = "RAvcRTdFBcvAGZWRm6YBMjRHRGj0iMNxsE0MBPGUPjM="; - allowedIPs = [ "10.0.1.10/32" ]; - } - ]; - }; - - networking.firewall.allowedUDPPorts = [ 51821 ]; - - # allowedIPsAsRoutes=false keeps peer declarations as crypto ACLs; install - # the client identity route explicitly, as in the backbone design. - networking.interfaces.wg-clients1.ipv4.routes = [ - { address = "10.0.1.10"; prefixLength = 32; } - ]; - - systemd.tmpfiles.rules = [ - "d /var/lib/wireguard 0700 root root -" - ]; - - # Generate the server key on first activation, outside the Nix store. - systemd.services.wireguard-key-wg-clients1 = { - description = "Generate wg-clients1 private key if absent"; - wantedBy = [ "wireguard-wg-clients1.service" ]; - before = [ "wireguard-wg-clients1.service" ]; - serviceConfig.Type = "oneshot"; - script = '' - install -d -m 700 /var/lib/wireguard - if [ ! -s /var/lib/wireguard/wg-clients1.key ]; then - umask 077 - ${pkgs.wireguard-tools}/bin/wg genkey > /var/lib/wireguard/wg-clients1.key - chmod 600 /var/lib/wireguard/wg-clients1.key - fi - ''; - }; -} diff --git a/hosts/heather/mesh-egress.nix b/hosts/heather/mesh-egress.nix deleted file mode 100644 index 29a7069..0000000 --- a/hosts/heather/mesh-egress.nix +++ /dev/null @@ -1,76 +0,0 @@ -# Static, Sol-style destination policy for selected traffic via the EU peer. -# This is policy routing, not application learning: prefixes are declared here, -# and OpenRouter addresses are refreshed from DNS on a timer. -{ config, pkgs, ... }: -let - ip = "${pkgs.iproute2}/bin/ip"; - getent = "${pkgs.getent}/bin/getent"; - routeScript = pkgs.writeShellScript "mesh-egress-routes" '' - set -eu - ${ip} link show wg-mesh >/dev/null - - # Rebuild the destination table, while leaving the normal main table alone. - ${ip} route flush table 1101 2>/dev/null || true - ${ip} rule del pref 1101 2>/dev/null || true - ${ip} rule add pref 1101 lookup 1101 - - add_route() { - prefix="$1" - ${ip} route replace "$prefix" dev wg-mesh table 1101 - } - - # Telegram DC ranges (static destination policy, as in Sol's prefix lists). - for prefix in \ - 91.105.192.0/23 \ - 91.108.0.0/22 \ - 91.108.4.0/22 \ - 91.108.8.0/22 \ - 91.108.12.0/22 \ - 91.108.16.0/22 \ - 91.108.20.0/22 \ - 91.108.56.0/22 \ - 95.161.64.0/20 \ - 149.154.160.0/20; do - add_route "$prefix" - done - - # OpenRouter is behind a CDN; refresh its current IPv4 addresses. We do - # not route all Cloudflare, only the names used by the API/web endpoint. - for name in openrouter.ai www.openrouter.ai api.openrouter.ai; do - ${getent} ahostsv4 "$name" 2>/dev/null | ${pkgs.gawk}/bin/awk '{print $1}' | sort -u | while read -r addr; do - [ -n "$addr" ] || continue - add_route "$addr/32" - done - done - ''; -in { - # Clients whose selected destinations match table 1101 are forwarded to - # hyacinth. Other traffic keeps Heather's normal route. - networking.nat = { - enable = true; - internalInterfaces = [ "wg-clients1" ]; - externalInterface = "enp7s0"; - }; - - systemd.services.mesh-egress-routes = { - description = "Install Sol-style selected destination routes via hyacinth"; - wantedBy = [ "network-online.target" ]; - after = [ "network-online.target" "wireguard-wg-mesh.service" ]; - wants = [ "wireguard-wg-mesh.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = routeScript; - }; - }; - - systemd.timers.mesh-egress-routes = { - description = "Refresh selected mesh egress destinations"; - wantedBy = [ "timers.target" ]; - timerConfig = { - OnBootSec = "30s"; - OnUnitActiveSec = "5min"; - Unit = "mesh-egress-routes.service"; - }; - }; -}