Skip to content

Commit 392100c

Browse files
Merge pull request #65 from loss-and-quick/feat/proxy-mode
feat(desktop): proxy mode selection (tun / proxy-only / system / pac)
2 parents a4559ea + 157ee72 commit 392100c

30 files changed

Lines changed: 864 additions & 63 deletions

File tree

crates/kasumi-backend/src/commands.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,15 @@ pub(crate) async fn build_profile_config(
229229
.find(|p| p.meta().id == id)
230230
.ok_or_else(|| err(format!("profile not found: {id}")))?;
231231
let srs_dir = paths.srs_dir.to_str().unwrap_or("");
232-
let mut built = build_core_config(
233-
profile,
234-
&state.settings,
235-
&state.routing_rules,
236-
&profiles,
237-
srs_dir,
238-
)
239-
.map_err(err)?;
232+
let mut settings = state.settings;
233+
if !platform.supports_proxy_modes() {
234+
// A platform that always runs tun (the Android root module) must not have
235+
// its tun inbound stripped by a non-tun proxyMode — e.g. one restored from
236+
// a desktop backup.
237+
settings.proxy_mode = kasumi_core::state::ProxyMode::Tun;
238+
}
239+
let mut built = build_core_config(profile, &settings, &state.routing_rules, &profiles, srs_dir)
240+
.map_err(err)?;
240241
platform.tune_config(built.engine, &mut built.config);
241242
Ok(built)
242243
}

crates/kasumi-backend/src/lifecycle.rs

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ use regex::Regex;
1212
use tokio::process::Child;
1313

1414
use kasumi_core::core::default_tun_for;
15-
use kasumi_core::enums::{CoreEngine, TunEngine, tun_from_marker};
15+
use kasumi_core::enums::{CoreEngine, NO_TUN_MARKER, TunEngine, tun_from_marker};
1616
use kasumi_core::hev_config::build_hev_config;
1717
use kasumi_core::singbox_config::build_singbox_bridge_config;
18-
use kasumi_core::state::{AppState, DEFAULT_LOCAL_SOCKS_PORT};
18+
use kasumi_core::state::{AppState, DEFAULT_LOCAL_SOCKS_PORT, ProxyMode};
1919
use kasumi_core::tun2socks_config::build_tun2socks_config;
2020
// Aliased: `tun` alone would shadow the many `tun: TunEngine` params here.
2121
use kasumi_core::tun as tun_addr;
@@ -24,7 +24,7 @@ use kasumi_core::tun::TunOptions;
2424
use crate::commands::{CommandError, build_profile_config};
2525
use crate::fs::{exists, read_text, remove_file, write_text};
2626
use crate::fsjson::{read_json, write_text_atomic};
27-
use crate::platform::{Engine, Platform};
27+
use crate::platform::{Platform, StartDataPath};
2828
use crate::proc::{RunOpts, pid_matches_bin, run, spawn_logged};
2929

3030
/// Map a hex digit to a consonant so the interface name starts with a letter
@@ -58,12 +58,12 @@ pub fn random_tun_iface() -> String {
5858
}
5959

6060
/// Build the config for `profile_id` (else the active profile), write it and the
61-
/// engine marker, and return the engine + resolved TUN engine + external-engine
62-
/// tuning + local SOCKS port.
61+
/// engine marker, and return the resolved [`StartDataPath`] (engine, TUN engine,
62+
/// external-engine tuning, SOCKS port, proxy mode).
6363
pub async fn resolve_and_write_config(
6464
platform: &dyn Platform,
6565
profile_id: Option<&str>,
66-
) -> Result<(Engine, TunEngine, TunOptions, u16), CommandError> {
66+
) -> Result<StartDataPath, CommandError> {
6767
let paths = platform.paths();
6868
let state = read_json::<AppState>(&paths.app_state).await;
6969
let id = profile_id
@@ -91,7 +91,20 @@ pub async fn resolve_and_write_config(
9191
.local_socks_port
9292
.unwrap_or(DEFAULT_LOCAL_SOCKS_PORT);
9393
let tun_opts = settings.tun_options();
94-
Ok((engine, tun, tun_opts, socks_port))
94+
// Same normalization as the config build: a platform without proxy-mode
95+
// support always starts in tun mode.
96+
let mode = if platform.supports_proxy_modes() {
97+
settings.proxy_mode
98+
} else {
99+
ProxyMode::Tun
100+
};
101+
Ok(StartDataPath {
102+
engine,
103+
tun,
104+
tun_opts,
105+
socks_port,
106+
mode,
107+
})
95108
}
96109

97110
/// The core engine's on-disk label (written to `paths.engine_file` at config
@@ -130,6 +143,11 @@ pub fn running_external_engine(
130143
marker: Option<&str>,
131144
engine_label: Option<&str>,
132145
) -> Option<TunEngine> {
146+
// A no-tun data-path (proxy-only/system/pac) runs no helper whatever the
147+
// core — never fall back to the engine default there.
148+
if marker.map(str::trim) == Some(NO_TUN_MARKER) {
149+
return None;
150+
}
133151
let core = engine_label.and_then(core_from_label);
134152
if let Some(tun) = marker.map(str::trim).and_then(tun_from_marker) {
135153
// Native only when we're sure it's the sing-box core with its own tun; an
@@ -522,6 +540,16 @@ mod tests {
522540
running_external_engine(Some(" \n"), Some("xray")),
523541
Some(TunEngine::Tun2socks)
524542
);
543+
// A no-tun data-path expects no helper, even for xray (no engine-default
544+
// fallback).
545+
assert_eq!(
546+
running_external_engine(Some(NO_TUN_MARKER), Some("xray")),
547+
None
548+
);
549+
assert_eq!(
550+
running_external_engine(Some("no-tun\n"), Some("sing-box")),
551+
None
552+
);
525553
}
526554

527555
#[test]
@@ -572,10 +600,12 @@ mod tests {
572600
.unwrap();
573601

574602
// No explicit id → uses active_id.
575-
let (engine, tun, _tun_opts, socks) = resolve_and_write_config(&p, None).await.unwrap();
576-
assert_eq!(engine, CoreEngine::Xray);
577-
assert_eq!(tun, TunEngine::Tun2socks);
578-
assert_eq!(socks, 11080);
603+
let opts = resolve_and_write_config(&p, None).await.unwrap();
604+
assert_eq!(opts.engine, CoreEngine::Xray);
605+
assert_eq!(opts.tun, TunEngine::Tun2socks);
606+
assert_eq!(opts.socks_port, 11080);
607+
// TestPlatform doesn't support proxy modes → always normalized to tun.
608+
assert_eq!(opts.mode, ProxyMode::Tun);
579609
assert_eq!(
580610
read_text(&p.paths().engine_file).await.as_deref(),
581611
Some("xray")

crates/kasumi-backend/src/platform.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use tokio::sync::mpsc;
1010

1111
use kasumi_core::contract::{LogTarget, ServiceState};
1212
use kasumi_core::enums::{CoreEngine, TunEngine};
13+
use kasumi_core::state::ProxyMode;
1314
use kasumi_core::tun::TunOptions;
1415

1516
use crate::lifecycle::spawn_core;
@@ -127,12 +128,17 @@ pub struct StartDataPath {
127128
pub engine: Engine,
128129
/// The resolved TUN engine. `SingboxTun` uses the core's own tun (native for
129130
/// sing-box); `Tun2socks`/`Hev` front a socks-only core with an external tun.
131+
/// Ignored when `mode` runs no tun.
130132
pub tun: TunEngine,
131133
/// External-engine tuning (mtu, buffers, timeouts, …), resolved once from the
132134
/// settings so the data-path owner (incl. the desktop root helper, across the
133135
/// privilege boundary) needn't re-read the settings schema.
134136
pub tun_opts: TunOptions,
135137
pub socks_port: u16,
138+
/// How to capture traffic: `tun` brings up the tun device + routing; the other
139+
/// modes run the core on its local socks/http inbound alone. Already
140+
/// normalized to `Tun` for platforms without proxy-mode support.
141+
pub mode: ProxyMode,
136142
}
137143

138144
/// Options for [`Platform::stop_data_path`].
@@ -162,13 +168,34 @@ pub trait Platform: Send + Sync {
162168
Ok(())
163169
}
164170

171+
/// Whether this platform honours the non-tun proxy modes (proxy-only / system /
172+
/// pac). Where it doesn't (the Android root module), config build and start
173+
/// normalize `proxyMode` to `tun` — so e.g. a restored desktop backup carrying
174+
/// a non-tun mode can't strip the tun inbound out from under the data path.
175+
fn supports_proxy_modes(&self) -> bool {
176+
false
177+
}
178+
165179
/// Spawn the core for `engine` from the on-disk config and route traffic through
166180
/// it. Resolves once the core is confirmed up, or errors with a reason.
167181
async fn start_data_path(&self, opts: StartDataPath) -> anyhow::Result<()>;
168182

169183
/// Stop the core/helpers and remove all routing. Idempotent.
170184
async fn stop_data_path(&self, opts: StopDataPath) -> anyhow::Result<()>;
171185

186+
/// Align the OS-level proxy with `mode` after a successful data-path start:
187+
/// point the OS at the core's local inbound where the mode asks for it
188+
/// (`system`/`pac`), clear any previously-set one otherwise — so a mode switch
189+
/// can't leave a stale OS proxy behind. Runs in the client process (GUI /
190+
/// daemon), never the privileged helper: the OS proxy lives in the logged-in
191+
/// user's session (gsettings / D-Bus / HKCU), which the helper's isn't.
192+
/// Default: no-op for platforms without an OS-proxy integration.
193+
async fn set_os_proxy(&self, _mode: ProxyMode, _engine: Engine, _socks_port: u16) {}
194+
195+
/// Clear any OS-level proxy [`Platform::set_os_proxy`] may have set. Idempotent;
196+
/// called on every data-path stop whatever the mode. Default: no-op.
197+
async fn clear_os_proxy(&self) {}
198+
172199
/// Current data-path status (liveness + traffic counters).
173200
async fn service_state(&self) -> anyhow::Result<ServiceState>;
174201

crates/kasumi-backend/src/service.rs

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use crate::fs::read_text;
2222
use crate::fsjson::read_json;
2323
use crate::lifecycle::resolve_and_write_config;
2424
use crate::net::{FetchUrlOptions, fetch_url};
25-
use crate::platform::{Platform, StartDataPath, StopDataPath};
25+
use crate::platform::{Platform, StopDataPath};
2626
use crate::sub_update::{self, LifecycleControl};
2727

2828
/// Result of the latest end-to-end connectivity probe (a fetch through the active
@@ -149,25 +149,29 @@ impl Service {
149149
})
150150
.await
151151
.map_err(|e| e.to_string())?;
152-
let (engine, tun, tun_opts, socks_port) =
153-
resolve_and_write_config(&*self.platform, id.as_deref())
154-
.await
155-
.map_err(|e| e.0)?;
152+
let opts = resolve_and_write_config(&*self.platform, id.as_deref())
153+
.await
154+
.map_err(|e| e.0)?;
155+
let (mode, engine, socks_port) = (opts.mode, opts.engine, opts.socks_port);
156+
if let Err(e) = self.platform.start_data_path(opts).await {
157+
// A failed bring-up must not leave a previously-set OS proxy
158+
// pointing at a dead port.
159+
self.platform.clear_os_proxy().await;
160+
return Err(e.to_string());
161+
}
162+
// With the data-path up, align the OS proxy with the mode (set for
163+
// system/pac, cleared otherwise — covers mode switches).
164+
self.platform.set_os_proxy(mode, engine, socks_port).await;
165+
Ok(())
166+
}
167+
LifecycleCmd::Stop => {
168+
// A stopped core must never leave the OS pointed at a dead port.
169+
self.platform.clear_os_proxy().await;
156170
self.platform
157-
.start_data_path(StartDataPath {
158-
engine,
159-
tun,
160-
tun_opts,
161-
socks_port,
162-
})
171+
.stop_data_path(StopDataPath::default())
163172
.await
164173
.map_err(|e| e.to_string())
165174
}
166-
LifecycleCmd::Stop => self
167-
.platform
168-
.stop_data_path(StopDataPath::default())
169-
.await
170-
.map_err(|e| e.to_string()),
171175
LifecycleCmd::ReloadAppFilter => {
172176
// xray reloads per-uid rules live; sing-box bakes them into the
173177
// config and needs a full restart.
@@ -182,17 +186,11 @@ impl Service {
182186
})
183187
.await
184188
.map_err(|e| e.to_string())?;
185-
let (engine, tun, tun_opts, socks_port) =
186-
resolve_and_write_config(&*self.platform, None)
187-
.await
188-
.map_err(|e| e.0)?;
189+
let opts = resolve_and_write_config(&*self.platform, None)
190+
.await
191+
.map_err(|e| e.0)?;
189192
self.platform
190-
.start_data_path(StartDataPath {
191-
engine,
192-
tun,
193-
tun_opts,
194-
socks_port,
195-
})
193+
.start_data_path(opts)
196194
.await
197195
.map_err(|e| e.to_string())
198196
} else if let Some(f) = self.platform.app_filter() {

crates/kasumi-core/src/core_config.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::core::{resolve_core, resolve_tun};
88
use crate::enums::{CoreEngine, TunEngine};
99
use crate::profile::Profile;
1010
use crate::singbox_config::{SingboxBuildOpts, build_singbox_config};
11-
use crate::state::{AdvancedSettings, RoutingRule};
11+
use crate::state::{AdvancedSettings, ProxyMode, RoutingRule};
1212
use crate::xray_config::build_xray_config;
1313

1414
/// Engine + TUN engine + config JSON for a profile, mirroring what the core is
@@ -42,9 +42,11 @@ pub fn build_core_config(
4242
routing_rules,
4343
profiles,
4444
SingboxBuildOpts {
45-
// An external tun engine fronts sing-box → build it socks-only
46-
// (no native tun inbound). SingboxTun keeps the native tun.
47-
no_tun: tun != TunEngine::SingboxTun,
45+
// Socks-only (no native tun inbound) when an external tun engine
46+
// fronts sing-box, or when the proxy mode runs no tun at all
47+
// (proxy-only/system/pac). SingboxTun in tun mode keeps the native
48+
// tun. Xray is already inbound-only either way.
49+
no_tun: settings.proxy_mode != ProxyMode::Tun || tun != TunEngine::SingboxTun,
4850
srs_dir,
4951
},
5052
)?,
@@ -118,6 +120,24 @@ mod tests {
118120
);
119121
}
120122

123+
#[test]
124+
fn non_tun_proxy_mode_is_socks_only() {
125+
let sb = parse_share_link("tuic://u:pw@t.ex:443?sni=t.ex", None).unwrap();
126+
// Any non-tun mode drops the native tun inbound even for SingboxTun; the
127+
// local mixed inbound stays as the whole data path.
128+
for mode in [ProxyMode::ProxyOnly, ProxyMode::System, ProxyMode::Pac] {
129+
let s = AdvancedSettings {
130+
proxy_mode: mode,
131+
..Default::default()
132+
};
133+
let c = build_core_config(&sb, &s, &[], std::slice::from_ref(&sb), "").unwrap();
134+
assert_eq!(c.tun, TunEngine::SingboxTun);
135+
let inbounds = c.config["inbounds"].as_array().unwrap();
136+
assert!(inbounds.iter().all(|i| i["type"] != "tun"), "{mode:?}");
137+
assert!(inbounds.iter().any(|i| i["type"] == "mixed"), "{mode:?}");
138+
}
139+
}
140+
121141
#[test]
122142
fn change_detection() {
123143
let s = AdvancedSettings::default();

crates/kasumi-core/src/enums.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ pub fn tun_from_marker(s: &str) -> Option<TunEngine> {
5757
serde_json::from_value(serde_json::Value::String(s.trim().to_owned())).ok()
5858
}
5959

60+
/// Marker label recorded when the data-path runs with no tun at all (the
61+
/// proxy-only/system/pac modes) — deliberately not a [`TunEngine`] variant, since
62+
/// no engine is involved and no helper process is expected. Watchdog/teardown
63+
/// readers must treat it as "no helper" rather than falling back to an engine
64+
/// default.
65+
pub const NO_TUN_MARKER: &str = "no-tun";
66+
6067
/// Whether a TUN engine reads the userspace tuning knobs (connect / read-write
6168
/// timeouts, buffer sizes) the settings UI surfaces. Only the hev engine consumes
6269
/// them today; a new engine must opt in here (exhaustive match), so the UI can't

crates/kasumi-core/src/state.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ pub enum RoutingMode {
140140
Rules,
141141
}
142142

143+
/// How the data path captures traffic. `tun` (default) brings up a system-wide
144+
/// tun device and rewrites OS routing — it needs the privileged data-path. The
145+
/// other modes run the core with only its local socks/http inbound: `proxy-only`
146+
/// leaves the OS untouched (the user points apps at the port), `system` sets the
147+
/// OS proxy to that inbound, `pac` serves a PAC the OS is pointed at. These are
148+
/// mutually exclusive — there is no "tun + system proxy" combination, since the
149+
/// tun already captures everything.
150+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, specta::Type)]
151+
#[serde(rename_all = "kebab-case")]
152+
pub enum ProxyMode {
153+
#[default]
154+
Tun,
155+
ProxyOnly,
156+
System,
157+
Pac,
158+
}
159+
143160
/// Xray domain resolution strategy.
144161
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, specta::Type)]
145162
pub enum DomainStrategy {
@@ -214,6 +231,9 @@ pub enum AppFilterMode {
214231
#[serde(rename_all = "camelCase", default)]
215232
pub struct AdvancedSettings {
216233
pub routing_mode: RoutingMode,
234+
/// How the data path captures traffic (tun vs proxy-only/system/pac). Desktop
235+
/// only; platforms without proxy-mode support (Android) always run tun.
236+
pub proxy_mode: ProxyMode,
217237
pub domain_sniffing: bool,
218238
pub route_only: bool,
219239
pub domain_strategy: DomainStrategy,
@@ -292,6 +312,7 @@ impl Default for AdvancedSettings {
292312
fn default() -> Self {
293313
Self {
294314
routing_mode: RoutingMode::Global,
315+
proxy_mode: ProxyMode::Tun,
295316
domain_sniffing: true,
296317
route_only: false,
297318
domain_strategy: DomainStrategy::IpIfNonMatch,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,11 +515,14 @@ impl Platform for AndroidPlatform {
515515
}
516516

517517
async fn start_data_path(&self, opts: StartDataPath) -> anyhow::Result<()> {
518+
// `mode` is ignored: this platform reports no proxy-mode support, so it is
519+
// always normalized to tun upstream.
518520
let StartDataPath {
519521
engine,
520522
tun,
521523
tun_opts,
522524
socks_port,
525+
..
523526
} = opts;
524527
set_service_state("connecting").await;
525528
let _ = write_text(SOCKS_PORT_FILE, &socks_port.to_string()).await;

0 commit comments

Comments
 (0)