fix(tun): fix bypass loop by capturing physical iface before tun route overrides

This commit is contained in:
ospab 2026-06-13 23:09:33 +03:00
parent 4543fa82f8
commit 74b6648db1
7 changed files with 103 additions and 115 deletions

View File

@ -0,0 +1,4 @@
fn main() {
let route = ostp_tun::windows::windows_route::sys::get_default_ipv4_route();
println!("Default IPv4 route: {:?}", route);
}

View File

@ -239,7 +239,13 @@ pub async fn run_client_core(
} }
let _sysproxy_guard = if config.mode == "proxy" { let _sysproxy_guard = if config.mode == "proxy" {
Some(crate::sysproxy::SystemProxyGuard::enable(&config.local_proxy.bind_addr)) // Enable system proxy and set initial ProxyOverride with user exclusions
let guard = Some(crate::sysproxy::SystemProxyGuard::enable(&config.local_proxy.bind_addr));
crate::sysproxy::update_proxy_bypass_list(
&config.exclusions.domains,
&config.exclusions.ips,
);
guard
} else { } else {
None None
}; };
@ -355,6 +361,12 @@ pub async fn run_client_core(
} => { } => {
if let Some(ref rx) = config_rx { if let Some(ref rx) = config_rx {
let new_cfg = rx.borrow().clone(); let new_cfg = rx.borrow().clone();
// Update Windows ProxyOverride so excluded domains/IPs
// bypass the system proxy immediately (proxy mode only).
crate::sysproxy::update_proxy_bypass_list(
&new_cfg.exclusions.domains,
&new_cfg.exclusions.ips,
);
let _ = reload_tx.send(new_cfg.exclusions); let _ = reload_tx.send(new_cfg.exclusions);
} }
} }

View File

@ -64,7 +64,79 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
_ => {} _ => {}
} }
// Set bypass list to prevent proxy loop for localhost traffic // Set initial bypass list (will be expanded by update_proxy_bypass_list)
update_proxy_bypass_list_windows(&[], &[]);
refresh_wininet();
tracing::info!("System proxy enabled successfully");
}
/// Update the Windows ProxyOverride registry value to include user-configured
/// excluded domains and IPs. This makes excluded hosts bypass the OSTP proxy
/// entirely at the OS level — the most reliable split-tunneling mechanism.
///
/// For each domain `d`, adds both `d` and `*.d` so both the root and all
/// subdomains bypass the proxy.
/// For IPs, adds them verbatim (Windows supports exact IPs and wildcards like
/// `192.168.*`).
#[cfg(target_os = "windows")]
pub fn update_proxy_bypass_list(domains: &[String], ips: &[String]) {
update_proxy_bypass_list_windows(domains, ips);
refresh_wininet();
}
#[cfg(not(target_os = "windows"))]
pub fn update_proxy_bypass_list(_domains: &[String], _ips: &[String]) {
// Linux/macOS: no-op (gnome/kde proxy bypass list update not implemented)
}
#[cfg(target_os = "windows")]
fn update_proxy_bypass_list_windows(domains: &[String], ips: &[String]) {
// Base list: always bypass local addresses
let mut parts: Vec<String> = vec![
"localhost".into(),
"127.*".into(),
"10.*".into(),
"172.16.*".into(),
"172.17.*".into(),
"172.18.*".into(),
"172.19.*".into(),
"172.20.*".into(),
"172.21.*".into(),
"172.22.*".into(),
"172.23.*".into(),
"172.24.*".into(),
"172.25.*".into(),
"172.26.*".into(),
"172.27.*".into(),
"172.28.*".into(),
"172.29.*".into(),
"172.30.*".into(),
"172.31.*".into(),
"192.168.*".into(),
"<local>".into(),
];
// Add excluded domains: both exact and wildcard subdomain form
for d in domains {
let d = d.trim().trim_start_matches('.').to_lowercase();
if d.is_empty() { continue; }
parts.push(d.clone());
parts.push(format!("*.{}", d));
}
// Add excluded IPs verbatim
for ip in ips {
let ip = ip.trim();
if ip.is_empty() { continue; }
// Strip CIDR suffix if present — Windows ProxyOverride doesn't support CIDR
let host = ip.split('/').next().unwrap_or(ip);
parts.push(host.to_string());
}
let override_value = parts.join(";");
tracing::info!("Updating ProxyOverride: {}", override_value);
let _ = Command::new("reg") let _ = Command::new("reg")
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.args([ .args([
@ -72,13 +144,10 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
"/v", "ProxyOverride", "/v", "ProxyOverride",
"/t", "REG_SZ", "/t", "REG_SZ",
"/d", "localhost;127.*;10.*;192.168.*;<local>", "/d", &override_value,
"/f", "/f",
]) ])
.output(); .output();
refresh_wininet();
tracing::info!("System proxy enabled successfully");
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]

View File

@ -40,6 +40,12 @@ pub async fn run_native_tunnel(
let debug = config.debug; let debug = config.debug;
tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)..."); tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)...");
// Capture physical interface index for bypass BEFORE we create the TUN device and alter routes.
#[cfg(target_os = "windows")]
let phys_if_for_bypass: Option<u32> = ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
#[cfg(not(target_os = "windows"))]
let phys_if_for_bypass: Option<u32> = None;
// ── 1. Resolve server IP ────────────────────────────────────────────────── // ── 1. Resolve server IP ──────────────────────────────────────────────────
let server_ip = config let server_ip = config
.ostp .ostp
@ -204,11 +210,7 @@ pub async fn run_native_tunnel(
} }
}); });
// Physical interface index — Some on Windows, None everywhere else // Physical interface index was captured at the start of the function.
#[cfg(target_os = "windows")]
let phys_if_for_bypass: Option<u32> = ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
#[cfg(not(target_os = "windows"))]
let phys_if_for_bypass: Option<u32> = None;
// Linux: physical interface name for SO_BINDTODEVICE // Linux: physical interface name for SO_BINDTODEVICE
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]

View File

@ -32,6 +32,7 @@ extern "system" {
pub fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> { pub fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
let s = socket.as_raw_socket() as usize; let s = socket.as_raw_socket() as usize;
if is_ipv6 { if is_ipv6 {
// IPV6_UNICAST_IF expects interface index in host byte order
let optval = if_index; let optval = if_index;
let ret = unsafe { let ret = unsafe {
setsockopt( setsockopt(
@ -46,7 +47,8 @@ pub fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_ind
return Err(std::io::Error::last_os_error()); return Err(std::io::Error::last_os_error());
} }
} else { } else {
let optval = if_index.to_be(); // IP_UNICAST_IF expects interface index in host byte order (NOT big-endian)
let optval = if_index;
let ret = unsafe { let ret = unsafe {
setsockopt( setsockopt(
s, s,

View File

@ -341,22 +341,13 @@
</div> </div>
<div class="field-group"> <div class="field-group">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px"> <label class="field-label" for="tag-input-processes" data-i18n="excl_processes">Bypass Processes</label>
<label class="field-label" style="margin-bottom:0" for="tag-input-processes" data-i18n="excl_processes">Bypass Processes</label>
<div style="display:flex;gap:6px;align-items:center">
<span class="tun-badge" id="proc-tun-badge">TUN only</span>
<button id="btn-pick-process" class="pick-proc-btn" type="button">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Pick
</button>
</div>
</div>
<div class="tag-input-wrap" id="tag-wrap-processes"> <div class="tag-input-wrap" id="tag-wrap-processes">
<div class="tag-list" id="tag-list-processes"></div> <div class="tag-list" id="tag-list-processes"></div>
<input id="tag-input-processes" class="tag-input-field" type="text" <input id="tag-input-processes" class="tag-input-field" type="text"
placeholder="chrome.exe" spellcheck="false" autocomplete="off" /> placeholder="chrome.exe" spellcheck="false" autocomplete="off" />
</div> </div>
<span class="field-hint" id="proc-hint">Only works in TUN mode. Excluded apps bypass the VPN.</span> <span class="field-hint" id="proc-hint">Only works in TUN mode. Type process name and press Enter.</span>
</div> </div>
</div> </div>
@ -368,24 +359,6 @@
<!-- Toast --> <!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></div> <div id="toast" class="toast" role="status" aria-live="polite"></div>
<!-- Process Picker Modal -->
<div id="proc-picker-modal" class="modal-overlay hidden">
<div class="modal-content proc-picker-content">
<div class="proc-picker-header">
<h3 class="modal-title">Running Processes</h3>
<div class="proc-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input id="proc-search" class="proc-search-input" type="text" placeholder="Search..." autocomplete="off" />
</div>
</div>
<div id="proc-list" class="proc-list"></div>
<div class="modal-actions">
<button id="btn-proc-cancel" class="btn secondary">Cancel</button>
<button id="btn-proc-add" class="btn primary">Add Selected</button>
</div>
</div>
</div>
<!-- Wintun Modal --> <!-- Wintun Modal -->
<div id="wintun-modal" class="modal-overlay hidden"> <div id="wintun-modal" class="modal-overlay hidden">
<div class="modal-content"> <div class="modal-content">

View File

@ -610,80 +610,6 @@ window.addEventListener('DOMContentLoaded', async () => {
wireTagInput('ips'); wireTagInput('ips');
wireTagInput('processes'); wireTagInput('processes');
// Process picker
const procPickerModal = $('proc-picker-modal');
const btnPickProcess = $('btn-pick-process');
const btnProcCancel = $('btn-proc-cancel');
const btnProcAdd = $('btn-proc-add');
const procList = $('proc-list');
const procSearch = $('proc-search');
let allProcs = [];
let selectedProcs = new Set();
function renderProcList(filter) {
if (!procList) return;
const q = (filter || '').toLowerCase();
const filtered = q ? allProcs.filter(p => p.toLowerCase().includes(q)) : allProcs;
if (!filtered.length) {
procList.innerHTML = `<div class="proc-empty">${q ? 'No matches' : 'No processes found'}</div>`;
return;
}
procList.innerHTML = filtered.map(p => {
const sel = selectedProcs.has(p) ? 'selected' : '';
return `<div class="proc-item ${sel}" data-name="${p}"><div class="proc-item-check"></div><span class="proc-item-name">${p}</span></div>`;
}).join('');
procList.querySelectorAll('.proc-item').forEach(el => {
el.addEventListener('click', () => {
const name = el.dataset.name;
if (selectedProcs.has(name)) selectedProcs.delete(name);
else selectedProcs.add(name);
el.classList.toggle('selected');
el.querySelector('.proc-item-check') // rerender
renderProcList(procSearch ? procSearch.value : '');
});
});
}
if (btnPickProcess && procPickerModal) {
btnPickProcess.addEventListener('click', async () => {
selectedProcs = new Set([...tagState.processes]);
procPickerModal.classList.remove('hidden');
procList.innerHTML = '<div class="proc-loading"><span>Loading...</span></div>';
if (procSearch) procSearch.value = '';
try {
allProcs = await invoke('list_running_processes');
} catch {
allProcs = [];
}
renderProcList('');
if (procSearch) procSearch.focus();
});
}
if (procSearch) {
procSearch.addEventListener('input', () => renderProcList(procSearch.value));
}
if (btnProcCancel) {
btnProcCancel.addEventListener('click', () => procPickerModal.classList.add('hidden'));
}
if (btnProcAdd) {
btnProcAdd.addEventListener('click', () => {
for (const p of selectedProcs) tagState.processes.add(p);
renderTagList('processes');
scheduleAutoSave();
procPickerModal.classList.add('hidden');
});
}
// Close picker on backdrop click
if (procPickerModal) {
procPickerModal.addEventListener('click', e => {
if (e.target === procPickerModal) procPickerModal.classList.add('hidden');
});
}
btnTestPing.addEventListener('click', runPingTest); btnTestPing.addEventListener('click', runPingTest);
btnWintunCancel.addEventListener('click', () => { btnWintunCancel.addEventListener('click', () => {