From ffb7554594700357f1b3d97121bc49dc6e529396 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sun, 8 Nov 2026 05:08:45 +0400 Subject: [PATCH] dpi-tunnel: add an Xray Reality PQ full-tunnel client DPI on some uplinks throttles WireGuard. Tunnel everything through Xray VLESS + REALITY with post-quantum VLESS Encryption on tun1. Routes in the main table that are more specific than the default (LAN, wg* prefixes) keep priority; prefixes listed in the routes secret are forced through the tunnel. The client's own uplink is fwmarked so it never loops back into tun1. UDP sessions on the tun device stay pinned to a dead mux link after suspend or Wi-Fi loss, so restart the client on resume and whenever NetworkManager brings up a new uplink. Stopping the unit removes all the rules and routes. Secrets come from secrets/dpi-tunnel.yaml through sops. Only the .example skeleton is tracked. Assisted-by: Claude:opus-5-5 --- configuration.nix | 1 + modules/dpi-tunnel.nix | 145 ++++++++++++++++++++++++++++++++ secrets/dpi-tunnel.yaml.example | 10 +++ 3 files changed, 156 insertions(+) create mode 100644 modules/dpi-tunnel.nix create mode 100644 secrets/dpi-tunnel.yaml.example diff --git a/configuration.nix b/configuration.nix index 4d42a46..f461dd5 100644 --- a/configuration.nix +++ b/configuration.nix @@ -18,6 +18,7 @@ ./modules/sops.nix ./modules/power.nix ./modules/xray.nix + ./modules/dpi-tunnel.nix ./modules/ollama.nix ./modules/lemonade.nix ./modules/gaming.nix diff --git a/modules/dpi-tunnel.nix b/modules/dpi-tunnel.nix new file mode 100644 index 0000000..51bd55a --- /dev/null +++ b/modules/dpi-tunnel.nix @@ -0,0 +1,145 @@ +# Xray VLESS + REALITY + post-quantum VLESS Encryption tunnel around DPI that +# throttles WireGuard. Server side (terraform/ansible) lives in the +# dpi-tunnel repo; keys and server in secrets/dpi-tunnel.yaml. +# +# Full tunnel: everything goes via tun1 except what the main table routes +# more specifically than a default (LAN, wg0/wg2 prefixes — their WireGuard +# endpoints then go through the tunnel). Policy rules: +# 999 fwmark -> main xray's own uplink to the server +# 1000 to -> 1011 forced prefixes (beat even specific wg* routes) +# 1001 main, suppress /0 specific routes stay where they are +# 1002 -> 1011 everything else +# `systemctl stop dpi-tunnel` removes all of it (back to plain uplink). +{ config, lib, pkgs, utils, ... }: + +let + dev = "tun1"; + table = "1011"; + mark = 4113; # 0x1011 + socksPort = 11081; + sopsFile = "/etc/nixos/secrets/dpi-tunnel.yaml"; + keys = [ "server" "server_name" "uuid" "short_id" "reality_password" "vless_encryption" "routes" ]; + secret = key: config.sops.secrets."dpi-tunnel/${key}".path; + ip = "${pkgs.iproute2}/bin/ip"; + jq = lib.getExe pkgs.jq; + + settings = { + log = { loglevel = "warning"; access = "none"; }; # no per-site journal trail + inbounds = [ + { + tag = "tun-in"; + protocol = "tun"; + settings = { name = dev; MTU = 1500; }; + } + { + tag = "socks-in"; + listen = "127.0.0.1"; + port = socksPort; + protocol = "socks"; + settings.udp = true; + } + ]; + outbounds = [{ + tag = "proxy"; + protocol = "vless"; + # UDP via XUDP; allow UDP/443 (QUIC, and WireGuard endpoints on 443), + # which vision rejects by default. TCP stays unmuxed. + mux = { + enabled = true; + concurrency = -1; + xudpConcurrency = 8; + xudpProxyUDP443 = "allow"; + }; + settings.vnext = [{ + address = { _secret = secret "server"; }; + port = 443; + users = [{ + id = { _secret = secret "uuid"; }; + flow = "xtls-rprx-vision"; + encryption = { _secret = secret "vless_encryption"; }; + }]; + }]; + streamSettings = { + network = "raw"; + security = "reality"; + realitySettings = { + serverName = { _secret = secret "server_name"; }; + fingerprint = "chrome"; + password = { _secret = secret "reality_password"; }; + shortId = { _secret = secret "short_id"; }; + }; + # Marked so rule 999 keeps the uplink off tun1 (no loop). + sockopt.mark = mark; + }; + }]; + }; +in +{ + sops.secrets = lib.genAttrs (map (k: "dpi-tunnel/${k}") keys) (name: { + inherit sopsFile; + key = lib.removePrefix "dpi-tunnel/" name; + restartUnits = [ "dpi-tunnel.service" ]; + }); + + systemd.services.dpi-tunnel = { + description = "Xray VLESS Reality PQ client"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + preStart = utils.genJqSecretsReplacementSnippet settings "/run/dpi-tunnel/config.json"; + postStart = '' + for _ in $(seq 50); do [ -e /sys/class/net/${dev} ] && break; sleep 0.1; done + ${ip} link set ${dev} up + for v in -4 -6; do + ${ip} $v route replace default dev ${dev} table ${table} + ${ip} $v rule add fwmark ${toString mark} lookup main priority 999 + ${ip} $v rule add lookup main suppress_prefixlength 0 priority 1001 + ${ip} $v rule add lookup ${table} priority 1002 + done + for cidr in $(${jq} -r '.[]' ${secret "routes"}); do + ${ip} route replace "$cidr" dev ${dev} table ${table} + ${ip} rule add to "$cidr" lookup ${table} priority 1000 + done + ''; + postStop = '' + for v in -4 -6; do + for p in 999 1000 1001 1002; do + while ${ip} $v rule del priority $p 2>/dev/null; do :; done + done + ${ip} $v route flush table ${table} 2>/dev/null || true + done + ''; + serviceConfig = { + ExecStart = "${lib.getExe pkgs.xray} run -c /run/dpi-tunnel/config.json"; + RuntimeDirectory = "dpi-tunnel"; + RuntimeDirectoryMode = "0700"; + AmbientCapabilities = [ "CAP_NET_ADMIN" ]; + CapabilityBoundingSet = [ "CAP_NET_ADMIN" ]; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # Xray keeps tun UDP sessions alive while packets arrive, so a WireGuard + # keepalive pins a session whose mux link died (suspend, Wi-Fi loss) + # forever. Start clean after resume and on every new uplink. + systemd.services.dpi-tunnel-resume = rec { + description = "Restart dpi-tunnel after resume"; + after = [ "suspend.target" "hibernate.target" "hybrid-sleep.target" "suspend-then-hibernate.target" ]; + wantedBy = after; + serviceConfig.Type = "oneshot"; + script = "systemctl try-restart dpi-tunnel.service"; + }; + + networking.networkmanager.dispatcherScripts = [{ + type = "basic"; + source = pkgs.writeShellScript "dpi-tunnel-uplink" '' + case "$1" in lo|wg*|tun*|docker*|br-*|veth*|virbr*) exit 0 ;; esac + [ "$2" = up ] && systemctl try-restart dpi-tunnel.service + exit 0 + ''; + }]; +} diff --git a/secrets/dpi-tunnel.yaml.example b/secrets/dpi-tunnel.yaml.example new file mode 100644 index 0000000..f2b2fd7 --- /dev/null +++ b/secrets/dpi-tunnel.yaml.example @@ -0,0 +1,10 @@ +# sops-encrypted; generate with scripts/gen-secrets.sh in the dpi-tunnel repo +server: 203.0.113.10 +server_name: www.microsoft.com +uuid: 00000000-0000-0000-0000-000000000000 +short_id: 0123456789abcdef +reality_private_key: +reality_password: +vless_decryption: +vless_encryption: mlkem768x25519plus.native.0rtt.<...> +routes: '["198.51.100.1/32"]' # forced via tun1 even over specific wg* routes