Skip to content

Commit a4559ea

Browse files
Merge pull request #216 from loss-and-quick/fix/desktop-root-traffic-bypasses-tun
fix(desktop): tunnel root traffic; escape the core by fwmark, not uid
2 parents 3769e0d + 64286d3 commit a4559ea

7 files changed

Lines changed: 274 additions & 15 deletions

File tree

crates/kasumi-core/src/outbound_bind.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
//! tun-engine-agnostic; the escape is on the core's socket, so swapping the tun
1111
//! engine changes nothing here.
1212
//! - **self-managed** (sing-box `auto_route`): the core owns the tun and escapes
13-
//! via its own `auto_detect_interface` — do *not* call this there, an explicit
14-
//! `bind_interface` would defeat the auto-detection and pin a stale interface.
13+
//! via the egress fwmark (`route.default_mark` + the desktop's escape ip-rule;
14+
//! see `SINGBOX_ESCAPE_MARK`) and its own `auto_detect_interface` — do *not*
15+
//! call this there, an explicit `bind_interface` would defeat the
16+
//! auto-detection and pin a stale interface.
1517
//! - **Android**: a per-uid policy-routing model excludes root from marking, so the
1618
//! core (run as root) escapes without an explicit bind. It doesn't call this
1719
//! today, but the helper is shared so it can if a future need arises.

crates/kasumi-core/src/singbox_config.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ pub const SINGBOX_MAIN_RULE_PRIO: u32 = 9000;
2525
pub const SINGBOX_FORCE_TABLE: u32 = 2023;
2626
pub const SINGBOX_FORCE_RULE_PRIO: u32 = 9010;
2727

28+
/// fwmark the desktop native-tun core stamps on its own egress sockets
29+
/// (`route.default_mark`), and the ip-rule priority that diverts marked traffic to
30+
/// the system's main table ahead of every `auto_route` rule above — so the core's
31+
/// uplink and geo-`direct` dials escape the tun while unmarked traffic (any uid,
32+
/// root included) is captured. Distinct from sing-tun's own auto-redirect marks
33+
/// (0x2023–0x2025); the rule priority sits below `SINGBOX_MAIN_RULE_PRIO` so it is
34+
/// evaluated first. The escape rule is a `goto` to the main-table rule (32766);
35+
/// the backstop priority right behind it carries an `unreachable` rule for the
36+
/// same mark, catching hosts where 32766 was deleted (the kernel then skips the
37+
/// unresolved `goto`) so marked traffic hard-fails instead of falling through
38+
/// into the `auto_route` rules and looping.
39+
pub const SINGBOX_ESCAPE_MARK: u32 = 0x4b53;
40+
pub const SINGBOX_ESCAPE_RULE_PRIO: u32 = 8990;
41+
pub const SINGBOX_ESCAPE_BACKSTOP_RULE_PRIO: u32 = 8991;
42+
2843
fn wire<T: serde::Serialize>(v: &T) -> String {
2944
serde_json::to_value(v)
3045
.ok()
@@ -985,14 +1000,20 @@ fn build_singbox_tun_inbounds(s: &AdvancedSettings) -> Vec<Value> {
9851000
} else {
9861001
json!([crate::tun::TUN_IPV4_CIDR])
9871002
};
988-
let mut exclude_uid = vec![0i64];
989-
exclude_uid.extend(bypass_uids.iter().copied());
1003+
// Per-app bypass/force uids only (the Android app filter; empty elsewhere).
1004+
// Whether uid 0 also skips the tun is a platform decision, not the builder's:
1005+
// Android's root-binary model adds it in `tune_config` (the daemon and core run
1006+
// as root and must not be captured), while desktop tunnels root like any other
1007+
// uid and escapes the core's own traffic by fwmark (`SINGBOX_ESCAPE_MARK`).
1008+
let mut exclude_uid = bypass_uids;
9901009
exclude_uid.extend(force_uids.iter().copied());
9911010

9921011
let mut main_tun = singbox_tun_inbound("tun-in", None, main_addr, s.tun_mtu, &stack);
9931012
main_tun["auto_route"] = json!(true);
9941013
main_tun["strict_route"] = json!(s.strict_route);
995-
main_tun["exclude_uid"] = json!(exclude_uid);
1014+
if !exclude_uid.is_empty() {
1015+
main_tun["exclude_uid"] = json!(exclude_uid);
1016+
}
9961017
let mut inbounds = vec![main_tun];
9971018
if !force_uids.is_empty() {
9981019
let force_addr = if v6 {
@@ -1383,6 +1404,55 @@ mod tests {
13831404
);
13841405
}
13851406

1407+
#[test]
1408+
fn tun_uid_exclusion_is_app_filter_only() {
1409+
let p = crate::share::parse_share_link("tuic://u:pw@t.ex:443?sni=t.ex", None).unwrap();
1410+
// No app filter → no uid exclusion baked in; whether uid 0 skips the tun
1411+
// is layered per-platform (Android's tune_config), not builder policy.
1412+
let cfg = build_singbox_config(
1413+
&p,
1414+
&AdvancedSettings::default(),
1415+
&[],
1416+
std::slice::from_ref(&p),
1417+
SingboxBuildOpts::default(),
1418+
)
1419+
.unwrap();
1420+
let tun = cfg["inbounds"]
1421+
.as_array()
1422+
.unwrap()
1423+
.iter()
1424+
.find(|i| i["tag"] == "tun-in")
1425+
.expect("tun-in inbound present");
1426+
assert!(tun.get("exclude_uid").is_none());
1427+
1428+
// Bypass + force uids land in exclude_uid; force uids also get their own tun.
1429+
let mut s = AdvancedSettings::default();
1430+
s.app_filter
1431+
.insert("com.a:10001".into(), AppFilterMode::Bypass);
1432+
s.app_filter
1433+
.insert("com.b:10002".into(), AppFilterMode::ForceProxy);
1434+
let cfg = build_singbox_config(
1435+
&p,
1436+
&s,
1437+
&[],
1438+
std::slice::from_ref(&p),
1439+
SingboxBuildOpts::default(),
1440+
)
1441+
.unwrap();
1442+
let inbounds = cfg["inbounds"].as_array().unwrap();
1443+
let tun = inbounds.iter().find(|i| i["tag"] == "tun-in").unwrap();
1444+
let ex: Vec<i64> = tun["exclude_uid"]
1445+
.as_array()
1446+
.unwrap()
1447+
.iter()
1448+
.map(|v| v.as_i64().unwrap())
1449+
.collect();
1450+
assert!(ex.contains(&10001) && ex.contains(&10002));
1451+
assert!(!ex.contains(&0));
1452+
let force = inbounds.iter().find(|i| i["tag"] == "tun-force").unwrap();
1453+
assert_eq!(force["include_uid"][0], 10002);
1454+
}
1455+
13861456
#[test]
13871457
fn ech_raw_base64_is_wrapped_in_a_pem_block() {
13881458
let cfg = ech_config_pem("AEX+DQBB...base64...");

crates/kasumi-daemon/src/android/platform.rs

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -670,18 +670,36 @@ impl Platform for AndroidPlatform {
670670
if engine != CoreEngine::SingBox {
671671
return;
672672
}
673-
// The sing-box "system" stack can't grab tun connections in this root-binary
674-
// data-path without sing-box's own nftables output redirect, which only
675-
// catches network-bound sockets when strict_route is on. An Android specific,
676-
// so it lives here, not the neutral builder. (gvisor needs neither.)
673+
// Android specifics the neutral builder must not assume, so they live here:
674+
// - The sing-box "system" stack can't grab tun connections in this
675+
// root-binary data-path without sing-box's own nftables output redirect,
676+
// which only catches network-bound sockets when strict_route is on.
677+
// (gvisor needs neither.)
678+
// - Root (uid 0) must bypass the tun: the daemon and the core itself run as
679+
// root, and this per-uid policy model spares root instead of marking
680+
// sockets. Prepended to every capture-all tun (one with an `include_uid`
681+
// allowlist can't capture root in the first place). Idempotent — a
682+
// config that already excludes root is left as-is.
677683
if let Some(inbounds) = config.get_mut("inbounds").and_then(|v| v.as_array_mut()) {
678684
for ib in inbounds {
679-
let is_system_tun = ib.get("type").and_then(Value::as_str) == Some("tun")
680-
&& ib.get("stack").and_then(Value::as_str) == Some("system");
681-
if is_system_tun {
685+
if ib.get("type").and_then(Value::as_str) != Some("tun") {
686+
continue;
687+
}
688+
if ib.get("stack").and_then(Value::as_str) == Some("system") {
682689
ib["auto_redirect"] = Value::Bool(true);
683690
ib["strict_route"] = Value::Bool(true);
684691
}
692+
if ib.get("include_uid").is_none() {
693+
let mut uids = ib
694+
.get("exclude_uid")
695+
.and_then(Value::as_array)
696+
.cloned()
697+
.unwrap_or_default();
698+
if !uids.iter().any(|v| v.as_i64() == Some(0)) {
699+
uids.insert(0, Value::from(0i64));
700+
ib["exclude_uid"] = Value::Array(uids);
701+
}
702+
}
685703
}
686704
}
687705
}
@@ -787,4 +805,54 @@ mod tests {
787805
assert_eq!(bin, SINGBOX_BIN);
788806
assert!(cfg.ends_with("singbox.json"));
789807
}
808+
809+
#[test]
810+
fn tune_config_excludes_root_from_capture_all_tuns() {
811+
let platform = AndroidPlatform::new();
812+
// A neutral build: gvisor main tun with app-filter bypass uids, plus a
813+
// force tun with an include_uid allowlist.
814+
let mut cfg = serde_json::json!({ "inbounds": [
815+
{ "type": "tun", "tag": "tun-in", "stack": "gvisor",
816+
"exclude_uid": [10001] },
817+
{ "type": "tun", "tag": "tun-force", "stack": "gvisor",
818+
"include_uid": [10002] },
819+
{ "type": "mixed", "tag": "socks-in" },
820+
] });
821+
platform.tune_config(CoreEngine::SingBox, &mut cfg);
822+
// Root heads the exclusion of the capture-all tun (the daemon and core run
823+
// as root); the allowlisted force tun and non-tun inbounds are untouched.
824+
assert_eq!(
825+
cfg["inbounds"][0]["exclude_uid"],
826+
serde_json::json!([0, 10001])
827+
);
828+
assert!(cfg["inbounds"][1].get("exclude_uid").is_none());
829+
assert!(cfg["inbounds"][2].get("exclude_uid").is_none());
830+
// Tuning is idempotent: a second pass doesn't duplicate the root exclusion.
831+
platform.tune_config(CoreEngine::SingBox, &mut cfg);
832+
assert_eq!(
833+
cfg["inbounds"][0]["exclude_uid"],
834+
serde_json::json!([0, 10001])
835+
);
836+
837+
// A capture-all tun without any app filter still gets the root exclusion.
838+
let mut cfg = serde_json::json!({ "inbounds": [
839+
{ "type": "tun", "tag": "tun-in", "stack": "gvisor" },
840+
] });
841+
platform.tune_config(CoreEngine::SingBox, &mut cfg);
842+
assert_eq!(cfg["inbounds"][0]["exclude_uid"], serde_json::json!([0]));
843+
844+
// The system stack additionally needs sing-box's own output redirect.
845+
let mut cfg = serde_json::json!({ "inbounds": [
846+
{ "type": "tun", "tag": "tun-in", "stack": "system" },
847+
] });
848+
platform.tune_config(CoreEngine::SingBox, &mut cfg);
849+
assert_eq!(cfg["inbounds"][0]["auto_redirect"], true);
850+
assert_eq!(cfg["inbounds"][0]["strict_route"], true);
851+
assert_eq!(cfg["inbounds"][0]["exclude_uid"], serde_json::json!([0]));
852+
853+
// Xray configs pass through untouched.
854+
let mut cfg = serde_json::json!({ "inbounds": [{ "type": "tun" }] });
855+
platform.tune_config(CoreEngine::Xray, &mut cfg);
856+
assert!(cfg["inbounds"][0].get("exclude_uid").is_none());
857+
}
790858
}

src-tauri/src/desktop/linux/routing.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,56 @@ pub async fn clear_external_tun_routing(route_state_file: &str) {
122122
kasumi_backend::fs::remove_file(route_state_file).await;
123123
}
124124

125+
/// Install the fwmark escape rule for a native sing-box tun. The core stamps its
126+
/// own egress sockets with `SINGBOX_ESCAPE_MARK` (`route.default_mark`, injected by
127+
/// `prepare_singbox_config`); this rule — evaluated ahead of every `auto_route`
128+
/// rule — jumps marked traffic straight to the system's main-table rule (32766),
129+
/// so the core's uplink and geo-`direct` dials leave via the physical default
130+
/// route while everything unmarked (any uid, root included) is captured by the
131+
/// tun. `goto 32766` rather than `lookup main`: were the main lookup to fail
132+
/// (uplink flap), evaluation must not fall through into the auto_route rules
133+
/// below and loop the marked traffic. A host may lack the 32766 main rule
134+
/// altogether (some VPNs delete it), which leaves the `goto` unresolved and the
135+
/// kernel skips it — the `unreachable` backstop right behind then hard-fails
136+
/// marked traffic instead of letting it loop. Idempotent — sweeps the
137+
/// priorities first.
138+
pub async fn apply_singbox_escape_rule() {
139+
use kasumi_core::singbox_config::{
140+
SINGBOX_ESCAPE_BACKSTOP_RULE_PRIO, SINGBOX_ESCAPE_MARK, SINGBOX_ESCAPE_RULE_PRIO,
141+
};
142+
143+
let prio = SINGBOX_ESCAPE_RULE_PRIO.to_string();
144+
let backstop = SINGBOX_ESCAPE_BACKSTOP_RULE_PRIO.to_string();
145+
let mark = format!("{SINGBOX_ESCAPE_MARK:#x}");
146+
for v6 in [false, true] {
147+
let mut base = vec![IP];
148+
if v6 {
149+
base.push("-6");
150+
}
151+
for p in [&prio, &backstop] {
152+
let mut del = base.clone();
153+
del.extend(["rule", "del", "priority", p]);
154+
while silent(&del).await == 0 {}
155+
}
156+
let mut add = base.clone();
157+
add.extend([
158+
"rule", "add", "priority", &prio, "fwmark", &mark, "goto", "32766",
159+
]);
160+
silent(&add).await;
161+
let mut add = base;
162+
add.extend([
163+
"rule",
164+
"add",
165+
"priority",
166+
&backstop,
167+
"fwmark",
168+
&mark,
169+
"unreachable",
170+
]);
171+
silent(&add).await;
172+
}
173+
}
174+
125175
/// Tear down orphaned native-sing-box `auto_route` artifacts (policy ip-rules, route
126176
/// tables, split-default) that a core left behind when it didn't exit cleanly — a
127177
/// crash or a SIGKILL after the graceful window. sing-box removes these itself on a
@@ -134,9 +184,19 @@ pub async fn clear_external_tun_routing(route_state_file: &str) {
134184
/// untouched. Safe to call in xray mode (no rules at these priorities exist there).
135185
pub async fn clear_singbox_autoroute(tun_iface_file: &str, tun2_iface_file: &str) {
136186
use kasumi_core::singbox_config::{
137-
SINGBOX_FORCE_RULE_PRIO, SINGBOX_FORCE_TABLE, SINGBOX_MAIN_RULE_PRIO, SINGBOX_MAIN_TABLE,
187+
SINGBOX_ESCAPE_BACKSTOP_RULE_PRIO, SINGBOX_ESCAPE_RULE_PRIO, SINGBOX_FORCE_RULE_PRIO,
188+
SINGBOX_FORCE_TABLE, SINGBOX_MAIN_RULE_PRIO, SINGBOX_MAIN_TABLE,
138189
};
139190

191+
// The fwmark escape rules (goto + unreachable backstop) are ours, not
192+
// sing-box's, but they orphan the same way when the data-path dies uncleanly
193+
// (both families; v6 exists when installed).
194+
for prio in [SINGBOX_ESCAPE_RULE_PRIO, SINGBOX_ESCAPE_BACKSTOP_RULE_PRIO] {
195+
let p = prio.to_string();
196+
while silent(&[IP, "rule", "del", "priority", &p]).await == 0 {}
197+
while silent(&[IP, "-6", "rule", "del", "priority", &p]).await == 0 {}
198+
}
199+
140200
// Policy ip-rules: a single priority can carry more than one rule, so loop on
141201
// `del` until it reports there's nothing left at that priority.
142202
for prio in SINGBOX_MAIN_RULE_PRIO..=SINGBOX_FORCE_RULE_PRIO {

src-tauri/src/desktop/platform.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,12 @@ impl DesktopPlatform {
228228
// clears nothing on a clean start.
229229
routing::clear_singbox_autoroute(&self.p.tun_iface_file, &self.p.tun2_iface_file).await;
230230

231+
// The escape rule must exist before the core dials anything: its very
232+
// first connections (DNS bring-up, the server uplink) already carry the
233+
// egress mark. Everything unmarked — any uid, root included — stays
234+
// captured by the tun.
235+
routing::apply_singbox_escape_rule().await;
236+
231237
self.spawn_core_verify(&core_bin, &cfg, &log).await?;
232238
return Ok(());
233239
}

src-tauri/src/desktop/singbox.rs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
//! captured by the tun and loops, causing timeouts. The fix is `route_exclude_address`
77
//! on the tun inbound with the resolved server IPs (and literal DNS server IPs),
88
//! which excludes them at the OS routing level regardless of fwmark.
9+
//!
10+
//! On Linux the core's `direct` outbound additionally dials arbitrary
11+
//! geo-`direct` hosts whose IPs can't be pre-resolved into that exclude list, so
12+
//! every egress socket is stamped with `route.default_mark` and a fwmark ip-rule
13+
//! installed above the `auto_route` rules diverts it to the main table (see
14+
//! `routing::apply_singbox_escape_rule`). Everything unmarked — any uid, root
15+
//! included — stays captured by the tun.
916
1017
use std::collections::HashSet;
1118
use std::path::Path;
@@ -50,8 +57,30 @@ fn collect_bypass_hosts(cfg: &Value) -> HashSet<String> {
5057
hosts
5158
}
5259

53-
/// Inject tun interface names (persisted for traffic counters) and the proxy-server
54-
/// bypass into the on-disk sing-box config. Returns the main tun iface name.
60+
/// Stamp every egress socket of the core with the escape fwmark (Linux only; the
61+
/// option is rejected by sing-box elsewhere, and Windows needs no mark — its tun
62+
/// escape is route-based). The matching ip-rule is installed by
63+
/// `routing::apply_singbox_escape_rule`.
64+
#[cfg(target_os = "linux")]
65+
fn inject_escape_mark(cfg: &mut Value) -> bool {
66+
let Some(route) = cfg.get_mut("route").and_then(Value::as_object_mut) else {
67+
return false;
68+
};
69+
route.insert(
70+
"default_mark".into(),
71+
kasumi_core::singbox_config::SINGBOX_ESCAPE_MARK.into(),
72+
);
73+
true
74+
}
75+
76+
#[cfg(not(target_os = "linux"))]
77+
fn inject_escape_mark(_cfg: &mut Value) -> bool {
78+
false
79+
}
80+
81+
/// Inject tun interface names (persisted for traffic counters), the proxy-server
82+
/// bypass and the egress fwmark into the on-disk sing-box config. Returns the main
83+
/// tun iface name.
5584
pub async fn prepare_singbox_config(
5685
cfg_path: &str,
5786
tun_iface_file: &str,
@@ -67,6 +96,8 @@ pub async fn prepare_singbox_config(
6796
let raw = read_text(cfg_path).await.unwrap_or_default();
6897
let mut cfg: Value = serde_json::from_str(&raw).unwrap_or(Value::Null);
6998

99+
let mut changed = inject_escape_mark(&mut cfg);
100+
70101
let mut excludes = HashSet::new();
71102
for host in collect_bypass_hosts(&cfg) {
72103
for ip in resolve_ips(&host).await {
@@ -80,9 +111,12 @@ pub async fn prepare_singbox_config(
80111
for ib in inbounds {
81112
if ib.get("type").and_then(Value::as_str) == Some("tun") {
82113
ib["route_exclude_address"] = serde_json::to_value(&list)?;
114+
changed = true;
83115
}
84116
}
85117
}
118+
}
119+
if changed {
86120
write_text(cfg_path, &serde_json::to_string_pretty(&cfg)?).await?;
87121
}
88122
Ok(tun)
@@ -111,4 +145,18 @@ mod tests {
111145
assert!(!hosts.contains("127.0.0.1"));
112146
assert!(!hosts.contains("dns.google"));
113147
}
148+
149+
#[cfg(target_os = "linux")]
150+
#[test]
151+
fn escape_mark_lands_in_route_on_linux() {
152+
let mut cfg = serde_json::json!({ "route": { "auto_detect_interface": true } });
153+
assert!(inject_escape_mark(&mut cfg));
154+
assert_eq!(
155+
cfg["route"]["default_mark"],
156+
kasumi_core::singbox_config::SINGBOX_ESCAPE_MARK
157+
);
158+
// A config without a route section (not ours) is left alone.
159+
let mut cfg = serde_json::json!({ "inbounds": [] });
160+
assert!(!inject_escape_mark(&mut cfg));
161+
}
114162
}

0 commit comments

Comments
 (0)