From 0b898d982549eab1ba12bf743d001aee6c2e27f9 Mon Sep 17 00:00:00 2001 From: Andrew Guschin Date: Fri, 30 Oct 2026 13:20:18 +0400 Subject: Validate real TUN forwarding and fix firewall routing - Move the default-drop guard to POSTROUTING so it sees final TUN egress - Treat a missing TUN interface as absent and add acceptance coverage - Document successful real-subscription validation on ALT Xray 26.10.29 --- README.md | 2 +- docs/validation/real-subscription-vm.md | 40 ++++++++++++++++++++++++++++ src/linux/firewall.rs | 5 +++- src/linux/routes.rs | 13 +++++++--- tests/routes_acceptance.rs | 23 +++++++++++++++++ tests/vm_tun_acceptance.py | 46 +++++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 docs/validation/real-subscription-vm.md create mode 100644 tests/routes_acceptance.rs create mode 100644 tests/vm_tun_acceptance.py diff --git a/README.md b/README.md index 829bfb1..1aa3587 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Roentgen is a Linux CLI/TUI for importing 3x-ui subscription link lists and controlling the local machine's proxy intent through a root-owned controller. The implementation follows [.scratch/linux-first-release/spec.md](.scratch/linux-first-release/spec.md). -This is an implementation candidate. Full native-TUN forwarding and managed-probe lifecycle acceptance on the required current ALT Xray package is incomplete. The supplied VM passed diagnostics transport, terminal authentication, enabled/blocked reboot protection, and recovery checks. The available ALT test VM and its refreshed Sisyphus repository currently supply Xray 26.3.27. Activation rejects that release before changing enabled intent. This build currently gates activation on the researched Xray 26.10.29 configuration contract. That gate is not a claim that the full release acceptance matrix has passed. +This is an implementation candidate. The full release acceptance matrix remains incomplete. Real-subscription native-TUN forwarding on ALT Xray 26.10.29 now passes the [VM check](docs/validation/real-subscription-vm.md); managed-probe lifecycle acceptance remains incomplete. The supplied VM passed diagnostics transport, terminal authentication, enabled/blocked reboot protection, and recovery checks. The earlier VM run supplied Xray 26.3.27, which activation rejected before changing enabled intent. The upgraded VM now supplies Xray 26.10.29. This build currently gates activation on the researched Xray 26.10.29 configuration contract. That gate is not a claim that the full release acceptance matrix has passed. ## Build diff --git a/docs/validation/real-subscription-vm.md b/docs/validation/real-subscription-vm.md new file mode 100644 index 0000000..7bd06ed --- /dev/null +++ b/docs/validation/real-subscription-vm.md @@ -0,0 +1,40 @@ +# Real subscription TUN validation + +Validated on `altctlrs` on 2026-10-30 after the system upgrade, with ALT's +Xray 26.10.29 package. The recovery checkpoint is +`roentgen-upgraded-xray-20261030`. Subscription credentials are omitted. + +The subscription imports one supported Trojan TCP/TLS configuration and one +unsupported VLESS entry. A temporary SOCKS listener first verified the Trojan +connection independently of host routing. + +Before the firewall fix, the selected configuration enabled TUN, but the real +request timed out. A targeted nftables trace showed the capture mark set in +OUTPUT and the guard dropping the packet because its hook state still named +`ens19`. Moving the guard to POSTROUTING made the request succeed. The final +source applies that change while retaining the guard's default drop policy. +See the kernel's [route hook](https://github.com/torvalds/linux/blob/master/net/netfilter/nft_chain_route.c) +and [hook state](https://github.com/torvalds/linux/blob/master/include/linux/netfilter.h). + +The live acceptance check failed before the fix and passed after rebuilding and +installing the source: + +```sh +sudo python3 tests/vm_tun_acceptance.py --expected-ip 93.95.225.219 +``` + +- Direct IPv4: `185.39.118.143`. +- TUN IPv4: `93.95.225.219`, through both HTTP and HTTPS requests. +- Disabling restored `185.39.118.143`. +- An ordinary `rustacean` user's request also returned `93.95.225.219`. +- Masking and stopping the owned Xray service caused a pinned-IP public request + to time out instead of falling back directly. Unmasking and enabling restored + `93.95.225.219`. +- The six existing `vm_guard_acceptance.py` checks passed, including private + IPv4/IPv6 exceptions, public blocking, UDP DNS policy, incoming TCP continuity, + rejection of preexisting outgoing flows, and explicit disable. +- The missing-interface regression in `routes_acceptance.rs` passed on this VM + during the preceding run. + +The VM was left with the subscription selected and proxying enabled. This is +live validation of this configuration, not the full release acceptance matrix. diff --git a/src/linux/firewall.rs b/src/linux/firewall.rs index 8a0dd43..bbde560 100644 --- a/src/linux/firewall.rs +++ b/src/linux/firewall.rs @@ -126,7 +126,10 @@ fn apply_policy(enabled: bool, xray_uid: u32, allow_tun: bool) -> Result<()> { batch.add(&mark, MsgType::Add); let mut guard = Chain::new(c"guard", &table); guard.set_type(ChainType::Filter); - guard.set_hook(Hook::Out, 100); + // OUTPUT's hook state still names the original interface after the + // route chain changes skb's route. POSTROUTING sees the final egress, + // so the guard can allow TUN traffic without allowing direct fallback. + guard.set_hook(Hook::PostRouting, 100); guard.set_policy(Policy::Drop); batch.add(&guard, MsgType::Add); exceptions(&mut batch, &guard, xray_uid); diff --git a/src/linux/routes.rs b/src/linux/routes.rs index 4212183..0380faf 100644 --- a/src/linux/routes.rs +++ b/src/linux/routes.rs @@ -178,12 +178,19 @@ pub async fn remove() -> Result<()> { pub async fn tun_index() -> Result> { let handle = connection()?; - Ok(handle + let result = handle .link() .get() .match_name("roentgen0") .execute() .try_next() - .await? - .map(|link| link.header.index)) + .await; + match result { + Ok(link) => Ok(link.map(|link| link.header.index)), + // A named RTM_GETLINK request reports ENODEV for an absent interface, + // including before Xray starts and while waiting for it to create TUN. + Err(rtnetlink::Error::NetlinkError(error)) + if error.raw_code() == -libc::ENODEV => Ok(None), + Err(error) => Err(error.into()), + } } diff --git a/tests/routes_acceptance.rs b/tests/routes_acceptance.rs new file mode 100644 index 0000000..608e309 --- /dev/null +++ b/tests/routes_acceptance.rs @@ -0,0 +1,23 @@ +#![cfg(feature = "linux-runtime")] + +use futures_util::TryStreamExt; +use rtnetlink::packet_route::link::LinkAttribute; + +// Run on the disposable ALT VM, including with no active roentgen0. A dump +// omits missing interfaces, whereas the production named lookup returns ENODEV. +#[tokio::test] +async fn tun_lookup_agrees_with_kernel_link_dump() -> anyhow::Result<()> { + let (connection, handle, _) = rtnetlink::new_connection()?; + tokio::spawn(connection); + let mut links = handle.link().get().execute(); + let mut expected = None; + while let Some(link) = links.try_next().await? { + if link.attributes.iter().any(|attribute| { + matches!(attribute, LinkAttribute::IfName(name) if name == "roentgen0") + }) { + expected = Some(link.header.index); + } + } + assert_eq!(roentgen::linux::routes::tun_index().await?, expected); + Ok(()) +} diff --git a/tests/vm_tun_acceptance.py b/tests/vm_tun_acceptance.py new file mode 100644 index 0000000..619459e --- /dev/null +++ b/tests/vm_tun_acceptance.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Verify a selected real configuration on the authorized disposable ALT VM.""" +import argparse +import ipaddress +import json +import os +import socket +import subprocess + + +def command(*args): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=35).stdout.strip() + + +def public_ipv4(): + value = command('curl', '-4', '--noproxy', '*', '--fail', '--silent', '--show-error', + '--connect-timeout', '5', '--max-time', '15', 'https://ifconfig.me') + return str(ipaddress.IPv4Address(value)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--expected-ip', required=True, type=ipaddress.IPv4Address) + args = parser.parse_args() + assert os.geteuid() == 0 and socket.gethostname() == 'altctlrs' + binary = '/usr/local/bin/roentgen' + status = json.loads(command(binary, 'status', '--json')) + assert not status['requested_enabled'], 'Start with proxying disabled' + assert status['selected_configuration'], 'Select the real configuration before running' + direct = public_ipv4() + try: + command(binary, 'enable') + status = json.loads(command(binary, 'status', '--json')) + assert status['requested_enabled'] and status['routing'] == 'tun', status + proxied = public_ipv4() + assert proxied == str(args.expected_ip), (proxied, str(args.expected_ip)) + assert proxied != direct, 'Proxy IPv4 must differ from direct IPv4' + finally: + command(binary, 'disable') + restored = public_ipv4() + assert restored == direct, (restored, direct) + print(json.dumps({'direct_ipv4': direct, 'tun_ipv4': proxied, 'restored_ipv4': restored})) + + +if __name__ == '__main__': + main() -- cgit v1.2.3