Skip to content

Commit eace325

Browse files
nichindCopilot
andcommitted
fix: linux tun, add tests for tun
Co-authored-by: Copilot <copilot@github.com>
1 parent 42e0ee8 commit eace325

4 files changed

Lines changed: 163 additions & 1 deletion

File tree

‎.github/workflows/build.yml‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,17 @@ jobs:
4141
python -m pip install -e .
4242
- name: Run Tests
4343
run: |
44-
python -m pytest tests/ --tb=short --disable-warnings
44+
python -m pytest tests/ --tb=short --disable-warnings --ignore=tests/tun_test.py
45+
- name: Run TUN config tests
46+
run: |
47+
python -m pytest tests/tun_test.py::TestTunConfig --tb=short --disable-warnings -v
48+
- name: Run TUN live test (Linux)
49+
if: runner.os == 'Linux' && env.TEST_SINGBOX_LINK != ''
50+
run: |
51+
sudo -E env "PATH=$PATH" "TEST_SINGBOX_LINK=$TEST_SINGBOX_LINK" "$pythonLocation/bin/python" -m pytest tests/tun_test.py::TestTunLiveSystemVpn --tb=short --disable-warnings -v
52+
continue-on-error: true
53+
- name: Run TUN live test (Windows)
54+
if: runner.os == 'Windows' && env.TEST_SINGBOX_LINK != ''
55+
run: |
56+
python -m pytest tests/tun_test.py::TestTunLiveSystemVpn --tb=short --disable-warnings -v
57+
continue-on-error: true

‎singbox2proxy/base.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,6 +1443,7 @@ def __init__(
14431443
tun_stack: str = "system",
14441444
tun_mtu: int = 9000,
14451445
tun_auto_route: bool = True,
1446+
tun_auto_redirect: bool | None = None,
14461447
set_system_proxy: bool = False,
14471448
route: dict = None,
14481449
relay_protocol: str = None,
@@ -1541,6 +1542,9 @@ def __init__(
15411542
self.tun_stack = tun_stack
15421543
self.tun_mtu = tun_mtu
15431544
self.tun_auto_route = tun_auto_route
1545+
if tun_auto_redirect is None:
1546+
tun_auto_redirect = sys.platform.startswith("linux")
1547+
self.tun_auto_redirect = tun_auto_redirect
15441548
self.set_system_proxy = set_system_proxy
15451549
self.route = route
15461550

@@ -2321,6 +2325,13 @@ def generate_config(self, chain_proxy=_SENTINEL):
23212325
"strict_route": True,
23222326
"stack": self.tun_stack,
23232327
}
2328+
if (
2329+
self.tun_auto_redirect
2330+
and self.tun_auto_route
2331+
and sys.platform.startswith("linux")
2332+
and core_version >= (1, 10, 0)
2333+
):
2334+
tun_inbound["auto_redirect"] = True
23242335
# sniff on inbound: works <1.13
23252336
if core_version < (1, 13, 0):
23262337
tun_inbound["sniff"] = True

‎singbox2proxy/cli.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,10 @@ def main():
270270
tun_group.add_argument("--tun-mtu", type=int, default=9000, help="TUN MTU (default: 9000)")
271271
tun_group.add_argument("--tun-auto-route", action="store_true", default=True, help="Auto-configure routes (default: on)")
272272
tun_group.add_argument("--no-tun-auto-route", dest="tun_auto_route", action="store_false", help="Disable auto routing")
273+
tun_group.add_argument("--tun-auto-redirect", dest="tun_auto_redirect", action="store_true", default=None,
274+
help="Enable Linux nftables auto_redirect (default: on for Linux)")
275+
tun_group.add_argument("--no-tun-auto-redirect", dest="tun_auto_redirect", action="store_false",
276+
help="Disable auto_redirect")
273277

274278
relay_group = parser.add_argument_group("relay")
275279
relay_group.add_argument("--relay-host", help="Host/IP for relay URL (default: auto-detect)")
@@ -375,6 +379,7 @@ def main():
375379
tun_stack=args.tun_stack,
376380
tun_mtu=args.tun_mtu,
377381
tun_auto_route=args.tun_auto_route,
382+
tun_auto_redirect=args.tun_auto_redirect,
378383
set_system_proxy=args.set_system_proxy if is_last else False,
379384
)
380385
proxies.append(proxy)
@@ -394,6 +399,7 @@ def main():
394399
tun_stack=args.tun_stack,
395400
tun_mtu=args.tun_mtu,
396401
tun_auto_route=args.tun_auto_route,
402+
tun_auto_redirect=args.tun_auto_redirect,
397403
set_system_proxy=args.set_system_proxy,
398404
relay_protocol=args.relay,
399405
relay_host=args.relay_host,

‎tests/tun_test.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import sys
6+
import time
7+
import unittest
8+
from unittest import mock
9+
10+
from singbox2proxy import SingBoxProxy
11+
12+
13+
TEST_LINK = os.environ.get("TEST_SINGBOX_LINK")
14+
15+
16+
def _read_config(proxy: SingBoxProxy) -> dict:
17+
path = proxy.create_config_file()
18+
try:
19+
with open(path, "r", encoding="utf-8") as f:
20+
return json.load(f)
21+
finally:
22+
if os.path.exists(path):
23+
os.remove(path)
24+
25+
26+
def _find_tun_inbound(config: dict) -> dict | None:
27+
for inbound in config.get("inbounds", []):
28+
if inbound.get("type") == "tun":
29+
return inbound
30+
return None
31+
32+
33+
class TestTunConfig(unittest.TestCase):
34+
def test_tun_disabled_by_default(self):
35+
proxy = SingBoxProxy("socks://127.0.0.1:1080", config_only=True)
36+
config = _read_config(proxy)
37+
self.assertIsNone(_find_tun_inbound(config))
38+
39+
def test_tun_basic_fields(self):
40+
proxy = SingBoxProxy("socks://127.0.0.1:1080", config_only=True, tun_enabled=True)
41+
config = _read_config(proxy)
42+
tun = _find_tun_inbound(config)
43+
self.assertIsNotNone(tun)
44+
self.assertEqual(tun["type"], "tun")
45+
self.assertTrue(tun["auto_route"])
46+
self.assertTrue(tun["strict_route"])
47+
self.assertEqual(tun["stack"], "system")
48+
self.assertEqual(tun["address"], ["172.19.0.1/30"])
49+
50+
def test_tun_auto_redirect_on_linux(self):
51+
with mock.patch.object(sys, "platform", "linux"):
52+
proxy = SingBoxProxy("socks://127.0.0.1:1080", config_only=True, tun_enabled=True)
53+
with mock.patch.object(SingBoxProxy, "_parse_core_version", return_value=(1, 13, 0)):
54+
config = _read_config(proxy)
55+
self.assertTrue(_find_tun_inbound(config).get("auto_redirect"))
56+
57+
def test_tun_no_auto_redirect_on_windows(self):
58+
with mock.patch.object(sys, "platform", "win32"):
59+
proxy = SingBoxProxy("socks://127.0.0.1:1080", config_only=True, tun_enabled=True)
60+
with mock.patch.object(SingBoxProxy, "_parse_core_version", return_value=(1, 13, 0)):
61+
config = _read_config(proxy)
62+
self.assertNotIn("auto_redirect", _find_tun_inbound(config))
63+
64+
def test_tun_auto_redirect_skipped_for_old_core(self):
65+
with mock.patch.object(sys, "platform", "linux"):
66+
proxy = SingBoxProxy("socks://127.0.0.1:1080", config_only=True, tun_enabled=True)
67+
with mock.patch.object(SingBoxProxy, "_parse_core_version", return_value=(1, 9, 0)):
68+
config = _read_config(proxy)
69+
self.assertNotIn("auto_redirect", _find_tun_inbound(config))
70+
71+
def test_tun_auto_redirect_explicit_off(self):
72+
with mock.patch.object(sys, "platform", "linux"):
73+
proxy = SingBoxProxy(
74+
"socks://127.0.0.1:1080",
75+
config_only=True,
76+
tun_enabled=True,
77+
tun_auto_redirect=False,
78+
)
79+
with mock.patch.object(SingBoxProxy, "_parse_core_version", return_value=(1, 13, 0)):
80+
config = _read_config(proxy)
81+
self.assertNotIn("auto_redirect", _find_tun_inbound(config))
82+
83+
84+
def _has_privileges() -> bool:
85+
if sys.platform.startswith("win"):
86+
try:
87+
import ctypes
88+
89+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
90+
except Exception:
91+
return False
92+
return hasattr(os, "geteuid") and os.geteuid() == 0
93+
94+
95+
@unittest.skipUnless(
96+
TEST_LINK
97+
and (sys.platform.startswith("linux") or sys.platform.startswith("win"))
98+
and _has_privileges(),
99+
"Live TUN test requires Linux/Windows + root/admin + TEST_SINGBOX_LINK",
100+
)
101+
class TestTunLiveSystemVpn(unittest.TestCase):
102+
IP_URL = "https://api.ipify.org?format=json"
103+
104+
def _fetch_proxy_ip(self) -> str:
105+
proxy = SingBoxProxy(TEST_LINK)
106+
try:
107+
return proxy.get(self.IP_URL, timeout=15).json()["ip"]
108+
finally:
109+
proxy.stop()
110+
111+
def _fetch_system_ip(self) -> str:
112+
import requests
113+
114+
return requests.get(self.IP_URL, timeout=15).json()["ip"]
115+
116+
def test_tun_captures_system_traffic(self):
117+
proxy_ip = self._fetch_proxy_ip()
118+
baseline_ip = self._fetch_system_ip()
119+
self.assertNotEqual(proxy_ip, baseline_ip)
120+
121+
tun_proxy = SingBoxProxy(TEST_LINK, tun_enabled=True)
122+
try:
123+
time.sleep(5)
124+
observed = self._fetch_system_ip()
125+
finally:
126+
tun_proxy.stop()
127+
128+
self.assertEqual(observed, proxy_ip)
129+
130+
131+
if __name__ == "__main__":
132+
unittest.main()

0 commit comments

Comments
 (0)