Skip to content

Commit 157ee72

Browse files
feat(desktop): serve a PAC in pac mode
Loopback PAC server + the OS pointed at its URL (gsettings auto / kioslaverc ProxyType 2 / WinINET AutoConfigURL); torn down with the data path. The env-var layer can't express a PAC, so it is cleared in this mode.
1 parent af5f1e2 commit 157ee72

3 files changed

Lines changed: 167 additions & 12 deletions

File tree

src-tauri/src/desktop/mod.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! verify) come from `kasumi-backend`.
66
77
pub mod net;
8+
pub mod pac;
89
pub mod singbox;
910
pub mod sysproxy;
1011

@@ -43,19 +44,34 @@ use kasumi_backend::proc::{RunOpts, run};
4344
use kasumi_core::state::ProxyMode;
4445

4546
/// Align the OS-level proxy with the active `mode`: `system` points the OS proxy at
46-
/// the core's local inbound; every other mode clears any previously-set one, so a
47-
/// mode switch can't leave a stale OS proxy behind. Runs in the GUI process (the
48-
/// logged-in user's session — see [`sysproxy`]), never in the privileged helper.
47+
/// the core's local inbound, `pac` starts the PAC server and points the OS at it;
48+
/// every other mode clears any previously-set one, so a mode switch can't leave a
49+
/// stale OS proxy behind. Runs in the GUI process (the logged-in user's session —
50+
/// see [`sysproxy`]), never in the privileged helper.
4951
pub async fn apply_os_proxy(mode: ProxyMode, socks_port: u16, http_port: u16) {
5052
match mode {
51-
ProxyMode::System => sysproxy::set_system_proxy(socks_port, http_port).await,
52-
ProxyMode::Tun | ProxyMode::ProxyOnly | ProxyMode::Pac => clear_os_proxy().await,
53+
ProxyMode::System => {
54+
pac::stop().await;
55+
sysproxy::set_system_proxy(socks_port, http_port).await;
56+
}
57+
ProxyMode::Pac => {
58+
if let Some(url) = pac::start(http_port, socks_port).await {
59+
sysproxy::set_pac(&url).await;
60+
} else {
61+
// The PAC port is taken — leave the OS un-proxied rather than
62+
// pointed at someone else's server.
63+
log::error!("pac server failed to bind; OS proxy left cleared");
64+
sysproxy::clear_system_proxy().await;
65+
}
66+
}
67+
ProxyMode::Tun | ProxyMode::ProxyOnly => clear_os_proxy().await,
5368
}
5469
}
5570

56-
/// Undo [`apply_os_proxy`]: clear the OS proxy. Idempotent — safe whatever mode was
57-
/// (or wasn't) active.
71+
/// Undo [`apply_os_proxy`]: stop the PAC server and clear the OS proxy / PAC
72+
/// pointer. Idempotent — safe whatever mode was (or wasn't) active.
5873
pub async fn clear_os_proxy() {
74+
pac::stop().await;
5975
sysproxy::clear_system_proxy().await;
6076
}
6177

src-tauri/src/desktop/pac.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! A tiny PAC (proxy auto-config) server for the `pac` proxy mode. It serves one
2+
//! static PAC over HTTP on loopback that points browsers at the core's local http +
3+
//! socks inbound, with a DIRECT fallback for loopback/plain hosts. There is only one
4+
//! server per process, so it is held in a module global rather than on the platform.
5+
//! Like the rest of the OS-proxy layer it runs in the GUI process, not the helper.
6+
7+
use std::sync::Mutex;
8+
use std::time::Duration;
9+
10+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
11+
use tokio::net::TcpListener;
12+
use tokio::task::JoinHandle;
13+
14+
/// The loopback port the PAC is served on (socks 10808 / http 10809 / pac 10811).
15+
pub const DEFAULT_PAC_PORT: u16 = 10811;
16+
17+
static SERVER: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
18+
19+
/// The PAC script pointing at the local proxy, DIRECT for loopback/plain hosts.
20+
fn build_pac(http_port: u16, socks_port: u16) -> String {
21+
format!(
22+
"function FindProxyForURL(url, host) {{\n \
23+
if (isPlainHostName(host) || host == \"localhost\" || shExpMatch(host, \"127.*\")) \
24+
return \"DIRECT\";\n \
25+
return \"PROXY 127.0.0.1:{http_port}; SOCKS5 127.0.0.1:{socks_port}; DIRECT\";\n}}\n"
26+
)
27+
}
28+
29+
/// Start (replacing any prior) the PAC server and return the URL to hand the OS, or
30+
/// `None` if the port could not be bound.
31+
pub async fn start(http_port: u16, socks_port: u16) -> Option<String> {
32+
stop().await;
33+
let listener = TcpListener::bind(("127.0.0.1", DEFAULT_PAC_PORT))
34+
.await
35+
.ok()?;
36+
let pac = build_pac(http_port, socks_port);
37+
let handle = tokio::spawn(serve(listener, pac));
38+
*SERVER.lock().unwrap() = Some(handle);
39+
Some(format!("http://127.0.0.1:{DEFAULT_PAC_PORT}/proxy.pac"))
40+
}
41+
42+
/// Stop the PAC server if running. Idempotent. Awaits the aborted task so the
43+
/// listener is truly dropped before a caller re-binds the port (restart with new
44+
/// ports).
45+
pub async fn stop() {
46+
let handle = SERVER.lock().unwrap().take();
47+
if let Some(h) = handle {
48+
h.abort();
49+
let _ = h.await;
50+
}
51+
}
52+
53+
/// Answer every connection with the same PAC document until the task is aborted.
54+
async fn serve(listener: TcpListener, pac: String) {
55+
let body = pac.into_bytes();
56+
let response = format!(
57+
"HTTP/1.0 200 OK\r\nContent-Type: application/x-ns-proxy-autoconfig\r\n\
58+
Content-Length: {}\r\nConnection: close\r\n\r\n",
59+
body.len()
60+
);
61+
loop {
62+
let Ok((mut sock, _)) = listener.accept().await else {
63+
// Transient accept errors (EMFILE, …) mustn't spin the loop hot.
64+
tokio::time::sleep(Duration::from_millis(100)).await;
65+
continue;
66+
};
67+
let head = response.clone();
68+
let body = body.clone();
69+
tokio::spawn(async move {
70+
// Drain the request line so the client doesn't see a reset before the body.
71+
let mut buf = [0u8; 1024];
72+
let _ = sock.read(&mut buf).await;
73+
let _ = sock.write_all(head.as_bytes()).await;
74+
let _ = sock.write_all(&body).await;
75+
let _ = sock.flush().await;
76+
});
77+
}
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use super::*;
83+
84+
#[tokio::test]
85+
async fn serves_the_pac_and_stops() {
86+
let url = start(10809, 10808).await.expect("pac server binds");
87+
assert_eq!(url, "http://127.0.0.1:10811/proxy.pac");
88+
89+
let mut sock = tokio::net::TcpStream::connect(("127.0.0.1", DEFAULT_PAC_PORT))
90+
.await
91+
.unwrap();
92+
sock.write_all(b"GET /proxy.pac HTTP/1.0\r\n\r\n")
93+
.await
94+
.unwrap();
95+
let mut out = String::new();
96+
sock.read_to_string(&mut out).await.unwrap();
97+
assert!(out.starts_with("HTTP/1.0 200 OK"));
98+
assert!(out.contains("PROXY 127.0.0.1:10809; SOCKS5 127.0.0.1:10808; DIRECT"));
99+
100+
// Stop frees the port for a re-bind (restart with new ports).
101+
stop().await;
102+
let again = start(1081, 1080).await.expect("rebind after stop");
103+
assert!(again.ends_with("/proxy.pac"));
104+
stop().await;
105+
}
106+
}

src-tauri/src/desktop/sysproxy.rs

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
//! OS system-proxy integration for the `system` proxy mode: point the OS proxy at
2-
//! the core's local inbound and clear it again.
1+
//! OS system-proxy integration for the `system` and `pac` proxy modes: point the
2+
//! OS proxy (or PAC auto-config URL) at the core's local inbound and clear it
3+
//! again.
34
//!
45
//! Linux has no single system-proxy store, so the apply/clear pipeline is *layered*
56
//! and the layers are NOT mutually exclusive — each reaches a disjoint set of apps:
@@ -21,12 +22,12 @@
2122
//! (gsettings/D-Bus/HKCU), which root's session isn't.
2223
2324
#[cfg(target_os = "linux")]
24-
pub use linux::{clear_system_proxy, set_system_proxy};
25+
pub use linux::{clear_system_proxy, set_pac, set_system_proxy};
2526
#[cfg(target_os = "windows")]
26-
pub use windows::{clear_system_proxy, set_system_proxy};
27+
pub use windows::{clear_system_proxy, set_pac, set_system_proxy};
2728

2829
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
29-
pub use other::{clear_system_proxy, set_system_proxy};
30+
pub use other::{clear_system_proxy, set_pac, set_system_proxy};
3031

3132
#[cfg(target_os = "linux")]
3233
mod linux {
@@ -63,6 +64,14 @@ mod linux {
6364
env_apply(socks_port, http_port).await;
6465
}
6566

67+
/// Point the OS at a PAC auto-config URL (`pac` mode). Env vars can't express a
68+
/// PAC, so the env layer is cleared rather than set.
69+
pub async fn set_pac(pac_url: &str) {
70+
gsettings_auto(pac_url).await;
71+
kde_pac(pac_url).await;
72+
env_clear().await;
73+
}
74+
6675
/// Disable every layer. Idempotent — safe whatever was (or wasn't) set.
6776
pub async fn clear_system_proxy() {
6877
gsettings_none().await;
@@ -104,6 +113,14 @@ mod linux {
104113
gsettings(&["org.gnome.system.proxy", "ignore-hosts", IGNORE_GNOME]).await;
105114
}
106115

116+
async fn gsettings_auto(pac_url: &str) {
117+
if !gnome_schema_present().await {
118+
return;
119+
}
120+
gsettings(&["org.gnome.system.proxy", "mode", "auto"]).await;
121+
gsettings(&["org.gnome.system.proxy", "autoconfig-url", pac_url]).await;
122+
}
123+
107124
async fn gsettings_none() {
108125
if !gnome_schema_present().await {
109126
return;
@@ -147,6 +164,15 @@ mod linux {
147164
kde_reparse().await;
148165
}
149166

167+
async fn kde_pac(pac_url: &str) {
168+
let Some(w) = (is_kde().then(kde_writer)).flatten() else {
169+
return;
170+
};
171+
kde(w, "ProxyType", "2").await;
172+
kde(w, "Proxy Config Script", pac_url).await;
173+
kde_reparse().await;
174+
}
175+
150176
async fn kde_none() {
151177
let Some(w) = (is_kde().then(kde_writer)).flatten() else {
152178
return;
@@ -316,6 +342,12 @@ mod windows {
316342
refresh();
317343
}
318344

345+
pub async fn set_pac(pac_url: &str) {
346+
set_dword("ProxyEnable", 0);
347+
set_sz("AutoConfigURL", pac_url);
348+
refresh();
349+
}
350+
319351
pub async fn clear_system_proxy() {
320352
set_dword("ProxyEnable", 0);
321353
set_sz("AutoConfigURL", "");
@@ -326,5 +358,6 @@ mod windows {
326358
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
327359
mod other {
328360
pub async fn set_system_proxy(_socks_port: u16, _http_port: u16) {}
361+
pub async fn set_pac(_pac_url: &str) {}
329362
pub async fn clear_system_proxy() {}
330363
}

0 commit comments

Comments
 (0)