mirror of https://github.com/ospab/ostp.git
feat: implement l4_protocol for server outbound, fix gui metrics and tunnel startup
This commit is contained in:
parent
b6e78c1d29
commit
2997bfdf16
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
use crate::config::{ClientConfig, InboundConfig};
|
||||
use crate::tunnel::balancer::Balancer;
|
||||
|
|
@ -16,26 +16,74 @@ pub async fn run_client_core(
|
|||
use portable_atomic::Ordering;
|
||||
tracing::info!("starting client core");
|
||||
|
||||
// Report "connecting" until an inbound has successfully bound. Each inbound
|
||||
// flips this to 2 (connected) once it is ready; if they all fail, the
|
||||
// select! below returns and we reset to 0 (disconnected).
|
||||
// Report "connecting" until the primary inbound has fully come up. The TUN
|
||||
// inbound flips this to 2 (connected) only after the device and the server
|
||||
// bypass route are installed; the SOCKS inbound does so only when it is the
|
||||
// primary (SOCKS-only mode). If any inbound's setup fails the whole connect
|
||||
// aborts and we reset to 0 — the GUI never sees a fake "connected".
|
||||
metrics.connection_state.store(1, Ordering::Relaxed);
|
||||
|
||||
let router = Arc::new(Router::new(config.routing.clone()));
|
||||
let balancer = Arc::new(Balancer::new(&config));
|
||||
|
||||
|
||||
// TODO: Detect physical interface index for bypassing
|
||||
let phys_if_for_bypass = None;
|
||||
let outbound_manager = Arc::new(OutboundManager::new(balancer.clone(), phys_if_for_bypass, None));
|
||||
|
||||
// When a TUN inbound is present it is the primary one and owns the connected
|
||||
// state; the SOCKS proxy is then secondary and must not report "connected".
|
||||
let has_tun = config
|
||||
.inbounds
|
||||
.iter()
|
||||
.any(|i| matches!(i, InboundConfig::Tun { .. }));
|
||||
|
||||
// Any inbound that fails its setup reports the error here; the first report
|
||||
// aborts the whole connect so we never come up half-broken.
|
||||
let (failure_tx, mut failure_rx) = mpsc::channel::<String>(4);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let metrics_ping = metrics.clone();
|
||||
let server_ip = config.outbounds.iter().find_map(|o| {
|
||||
match o {
|
||||
crate::config::OutboundConfig::Ostp { server, .. } => Some(server.clone()),
|
||||
crate::config::OutboundConfig::Socks { server, .. } => Some(server.clone()),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(mut server) = server_ip {
|
||||
if !server.contains(':') {
|
||||
server.push_str(":443");
|
||||
}
|
||||
let mut shutdown_rx = shutdown_rx_ext.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() { break; }
|
||||
}
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(&server)
|
||||
).await {
|
||||
let rtt = start.elapsed().as_millis() as u32;
|
||||
metrics_ping.rtt_ms.store(rtt, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for inbound in config.inbounds.clone() {
|
||||
let router_clone = router.clone();
|
||||
let outbound_manager_clone = outbound_manager.clone();
|
||||
let shutdown_rx = shutdown_rx_ext.clone();
|
||||
let config_clone = config.clone();
|
||||
let metrics_clone = metrics.clone();
|
||||
let failure_tx = failure_tx.clone();
|
||||
|
||||
match inbound.clone() {
|
||||
InboundConfig::Tun { .. } => {
|
||||
|
|
@ -49,10 +97,12 @@ pub async fn run_client_core(
|
|||
metrics_clone,
|
||||
).await {
|
||||
tracing::error!("TUN inbound failed: {}", e);
|
||||
let _ = failure_tx.send(format!("TUN inbound: {e}")).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
InboundConfig::LocalProxy { .. } => {
|
||||
let is_primary = !has_tun;
|
||||
handles.push(tokio::spawn(async move {
|
||||
if let Err(e) = crate::tunnel::inbounds::local_proxy::run_socks_inbound(
|
||||
config_clone,
|
||||
|
|
@ -61,23 +111,43 @@ pub async fn run_client_core(
|
|||
outbound_manager_clone,
|
||||
shutdown_rx,
|
||||
metrics_clone,
|
||||
is_primary,
|
||||
).await {
|
||||
tracing::error!("SOCKS inbound failed: {}", e);
|
||||
let _ = failure_tx.send(format!("SOCKS inbound: {e}")).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop our own sender so the channel closes once every inbound task has ended.
|
||||
drop(failure_tx);
|
||||
|
||||
// Wait for shutdown or for tasks to fail
|
||||
tokio::select! {
|
||||
// Run until: an external shutdown, a fatal inbound failure, or all inbounds
|
||||
// ending on their own.
|
||||
let result = tokio::select! {
|
||||
_ = shutdown_rx_ext.changed() => {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
tracing::info!("Shutdown signal received in run_client_core");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
maybe_err = failure_rx.recv() => {
|
||||
match maybe_err {
|
||||
Some(err) => {
|
||||
tracing::error!("tunnel startup failed: {err}");
|
||||
Err(anyhow!("tunnel startup failed: {err}"))
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Tear down every inbound regardless of why we are exiting, then report
|
||||
// disconnected so the GUI reflects the real state.
|
||||
for h in &handles {
|
||||
h.abort();
|
||||
}
|
||||
metrics.connection_state.store(0, Ordering::Relaxed);
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub async fn run_socks_inbound(
|
|||
outbound_manager: Arc<OutboundManager>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
metrics: Arc<crate::bridge::BridgeMetrics>,
|
||||
is_primary: bool,
|
||||
) -> Result<()> {
|
||||
use portable_atomic::Ordering;
|
||||
let InboundConfig::LocalProxy { tag, protocol, listen, port, set_system_proxy } = inbound_config else {
|
||||
|
|
@ -32,9 +33,17 @@ pub async fn run_socks_inbound(
|
|||
|
||||
let listener = TcpListener::bind(&bind_addr).await?;
|
||||
|
||||
// Listener bound successfully — the proxy is ready to accept connections.
|
||||
metrics.connection_state.store(2, Ordering::Relaxed);
|
||||
tracing::info!("{} proxy inbound ready on {}, connection state = connected", protocol, bind_addr);
|
||||
// Binding a local socket only proves the proxy can accept connections, not
|
||||
// that the tunnel actually reaches the server. Only report "connected" from
|
||||
// here when this proxy is the primary inbound (SOCKS-only mode). In TUN mode
|
||||
// the TUN inbound owns the connected state — it is set after the device and
|
||||
// server bypass route are in place — so we must not flip it prematurely.
|
||||
if is_primary {
|
||||
metrics.connection_state.store(2, Ordering::Relaxed);
|
||||
tracing::info!("{} proxy inbound ready on {}, connection state = connected", protocol, bind_addr);
|
||||
} else {
|
||||
tracing::info!("{} proxy inbound ready on {}", protocol, bind_addr);
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
|
|
|||
|
|
@ -160,20 +160,24 @@ pub async fn run_tun_inbound(
|
|||
_route_guard = Some(tun_interface.guard);
|
||||
|
||||
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
||||
let m_sent = metrics.clone();
|
||||
let tun_to_stack = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
match tun_read.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
m_sent.bytes_sent.fetch_add(n as u64, Ordering::Relaxed);
|
||||
if let Err(_) = stack_sink.send(buf[..n].to_vec()).await { break; }
|
||||
}
|
||||
Err(e) => tracing::debug!("tun_read error: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
let m_recv = metrics.clone();
|
||||
let stack_to_tun = tokio::spawn(async move {
|
||||
while let Some(Ok(frame)) = stack_stream.next().await {
|
||||
m_recv.bytes_recv.fetch_add(frame.len() as u64, Ordering::Relaxed);
|
||||
if let Err(e) = tun_write.write(&frame).await { tracing::debug!("tun_write error: {e}"); }
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
// Left empty by request
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
use ostp_core::{ProtocolMachine, ProtocolConfig, OstpEvent, ProtocolAction, NoiseRole};
|
||||
|
||||
fn main() {
|
||||
let key = "3f5dfaf68e377a3724bdde3ac7b4f4de";
|
||||
let secrets = ostp_core::crypto::derive_all_secrets(key.as_bytes());
|
||||
|
||||
let mut init_cfg = ProtocolConfig {
|
||||
role: NoiseRole::Initiator,
|
||||
session_id: 12345,
|
||||
psk: secrets.psk,
|
||||
obfuscation_key: secrets.obfuscation_key,
|
||||
handshake_pad_min: secrets.handshake_pad_min,
|
||||
handshake_pad_max: secrets.handshake_pad_max,
|
||||
max_reorder: 10,
|
||||
max_reorder_buffer: 10,
|
||||
ack_delay_ms: 10,
|
||||
rto_ms: 100,
|
||||
max_retries: 5,
|
||||
max_sent_history: 100,
|
||||
handshake_payload: vec![],
|
||||
mtu: 1400,
|
||||
max_padding: 0,
|
||||
padding_strategy: ostp_core::PaddingStrategy::Adaptive,
|
||||
};
|
||||
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(&0u64.to_be_bytes()); // time
|
||||
payload.extend_from_slice(&12345u32.to_be_bytes());
|
||||
payload.extend_from_slice(key.as_bytes());
|
||||
init_cfg.handshake_payload = payload;
|
||||
|
||||
let mut init_machine = ProtocolMachine::new(init_cfg.clone()).unwrap();
|
||||
let action = init_machine.on_event(OstpEvent::Start).unwrap();
|
||||
|
||||
let pkt = match action {
|
||||
ProtocolAction::SendDatagram(p) => p,
|
||||
_ => panic!("Expected SendDatagram"),
|
||||
};
|
||||
|
||||
println!("Initiator sent {} bytes", pkt.len());
|
||||
|
||||
let mut resp_cfg = init_cfg.clone();
|
||||
resp_cfg.role = NoiseRole::Responder;
|
||||
|
||||
let mut resp_machine = ProtocolMachine::new(resp_cfg).unwrap();
|
||||
|
||||
// Simulate what server dispatcher does
|
||||
let mut raw_vec = pkt.to_vec();
|
||||
ostp_core::crypto::deobfuscate_packet_inplace(&mut raw_vec, &secrets.obfuscation_key, true);
|
||||
println!("Deobfuscated length: {}", raw_vec.len());
|
||||
|
||||
let action = resp_machine.on_event(OstpEvent::Inbound(pkt));
|
||||
match action {
|
||||
Ok(ProtocolAction::HandshakePayload(_, _)) => println!("Responder: Handshake OK!"),
|
||||
Ok(_) => println!("Responder: Not HandshakePayload"),
|
||||
Err(e) => println!("Responder error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub struct DnsttProcess {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl Drop for DnsttProcess {
|
||||
fn drop(&mut self) {
|
||||
debug!("Stopping dnstt process...");
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn find_bin(name: &str) -> Result<PathBuf> {
|
||||
let ext = if cfg!(target_os = "windows") { ".exe" } else { "" };
|
||||
let file_name = format!("{}{}", name, ext);
|
||||
|
||||
// Check current working directory
|
||||
let mut path = env::current_dir()?.join(&file_name);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
// Check next to the executable
|
||||
if let Ok(exe_path) = env::current_exe() {
|
||||
if let Some(parent) = exe_path.parent() {
|
||||
path = parent.join(&file_name);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("{} not found. Please place {} in the same directory as ostp.", name, file_name);
|
||||
}
|
||||
|
||||
/// Spawns the dnstt-server process.
|
||||
/// Listens on `public_ip:53` and forwards to `127.0.0.1:<local_port>`.
|
||||
pub fn spawn_server(public_ip: &str, local_port: u16, privkey: &str, debug: bool) -> Result<DnsttProcess> {
|
||||
let bin_path = find_bin("dnstt-server")?;
|
||||
let listen_addr = format!("{}:53", public_ip);
|
||||
let forward_addr = format!("127.0.0.1:{}", local_port);
|
||||
|
||||
info!("Starting dnstt-server on {} forwarding to {}", listen_addr, forward_addr);
|
||||
|
||||
let child = Command::new(&bin_path)
|
||||
.arg("-udp")
|
||||
.arg(&listen_addr)
|
||||
.arg("-privkey")
|
||||
.arg(privkey)
|
||||
.arg(&forward_addr)
|
||||
.stdout(if debug { Stdio::inherit() } else { Stdio::null() })
|
||||
.stderr(if debug { Stdio::inherit() } else { Stdio::null() })
|
||||
.spawn()
|
||||
.context("Failed to start dnstt-server process")?;
|
||||
|
||||
Ok(DnsttProcess { child })
|
||||
}
|
||||
|
||||
/// Spawns the dnstt-client process.
|
||||
/// Returns the local port it bound to, along with the process handle.
|
||||
pub fn spawn_client(pubkey: &str, domain: &str, resolver: &str, debug: bool) -> Result<(u16, DnsttProcess)> {
|
||||
let bin_path = find_bin("dnstt-client")?;
|
||||
|
||||
let local_port = {
|
||||
let listener = std::net::UdpSocket::bind("127.0.0.1:0")?;
|
||||
listener.local_addr()?.port()
|
||||
};
|
||||
|
||||
let listen_addr = format!("127.0.0.1:{}", local_port);
|
||||
info!("Starting dnstt-client on {} via {}", listen_addr, resolver);
|
||||
|
||||
let child = Command::new(&bin_path)
|
||||
.arg("-udp")
|
||||
.arg(resolver)
|
||||
.arg("-pubkey")
|
||||
.arg(pubkey)
|
||||
.arg(domain)
|
||||
.arg(&listen_addr)
|
||||
.stdout(if debug { Stdio::inherit() } else { Stdio::null() })
|
||||
.stderr(if debug { Stdio::inherit() } else { Stdio::null() })
|
||||
.spawn()
|
||||
.context("Failed to start dnstt-client process")?;
|
||||
|
||||
Ok((local_port, DnsttProcess { child }))
|
||||
}
|
||||
|
||||
/// Helper to generate a new keypair using dnstt-server -gen-key
|
||||
pub fn generate_keypair() -> Result<(String, String)> {
|
||||
let bin_path = find_bin("dnstt-server")?;
|
||||
|
||||
let output = Command::new(&bin_path)
|
||||
.arg("-gen-key")
|
||||
.output()
|
||||
.context("Failed to run dnstt-server -gen-key")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("dnstt-server -gen-key failed");
|
||||
}
|
||||
|
||||
let out_str = String::from_utf8_lossy(&output.stdout);
|
||||
let mut privkey = String::new();
|
||||
let mut pubkey = String::new();
|
||||
|
||||
for line in out_str.lines() {
|
||||
if line.starts_with("privkey ") {
|
||||
privkey = line.trim_start_matches("privkey ").trim().to_string();
|
||||
} else if line.starts_with("pubkey ") {
|
||||
pubkey = line.trim_start_matches("pubkey ").trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if privkey.is_empty() || pubkey.is_empty() {
|
||||
anyhow::bail!("Failed to parse keys from dnstt-server output");
|
||||
}
|
||||
|
||||
Ok((privkey, pubkey))
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -59,7 +59,7 @@ dev_dependencies:
|
|||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
ios: false
|
||||
image_path: "../icons/sqare.png"
|
||||
image_path: "../ostp-gui/src-tauri/icons/icon.png"
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -80,15 +80,7 @@
|
|||
|
||||
<!-- Connection info (shown when connected) -->
|
||||
<div id="connection-info" class="connection-info hidden">
|
||||
<div class="server-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||
</svg>
|
||||
<span id="server-badge-text">—</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="ping-test-box">
|
||||
<div class="ping-test-left">
|
||||
|
|
|
|||
|
|
@ -356,10 +356,7 @@ function setState(next) {
|
|||
statusLabel.textContent = t('status_connected');
|
||||
|
||||
// Show connection info
|
||||
if (serverAddr) {
|
||||
serverBadgeTxt.textContent = serverAddr;
|
||||
connInfo.classList.remove('hidden');
|
||||
}
|
||||
connInfo.classList.remove('hidden');
|
||||
|
||||
// Start uptime counter
|
||||
if (!uptimeTimer) {
|
||||
|
|
@ -389,6 +386,11 @@ async function poll() {
|
|||
if (metrics && pollTimer) {
|
||||
metricDown.textContent = fmtBytes(metrics.bytes_recv);
|
||||
metricUp.textContent = fmtBytes(metrics.bytes_sent);
|
||||
if (metrics.rtt_ms > 0 && pingValueTxt.textContent !== 'Testing...') {
|
||||
const rtt = metrics.rtt_ms;
|
||||
pingValueTxt.textContent = `Target Ping: ${rtt} ms`;
|
||||
pingValueTxt.className = 'ping-test-value ' + (rtt < 80 ? 'good' : rtt < 200 ? 'warn' : 'bad');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[OSTP] poll threw:', err);
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ impl Dispatcher {
|
|||
});
|
||||
self.addr_to_session.insert(peer, candidate_session_id);
|
||||
|
||||
tracing::info!("New session authenticated: sid={} peer={} (active_sessions={}, replay_cache={})",
|
||||
tracing::debug!("New session authenticated: sid={} peer={} (active_sessions={}, replay_cache={})",
|
||||
candidate_session_id, peer, self.peer_machines.len(), self.replay_cache.len()
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,12 +22,18 @@ pub struct OutboundRule {
|
|||
pub action: OutboundAction,
|
||||
}
|
||||
|
||||
fn default_l4_protocol() -> String {
|
||||
"all".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutboundConfig {
|
||||
pub enabled: bool,
|
||||
pub protocol: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
#[serde(default = "default_l4_protocol")]
|
||||
pub l4_protocol: String,
|
||||
pub rules: Vec<OutboundRule>,
|
||||
pub default_action: OutboundAction,
|
||||
}
|
||||
|
|
@ -42,24 +48,28 @@ pub async fn connect_target(
|
|||
let connect_timeout = Duration::from_secs(10);
|
||||
if let Some(outbound) = outbound {
|
||||
if outbound.enabled {
|
||||
let action = select_outbound_action(target, "tcp", outbound, debug).await;
|
||||
if action == OutboundAction::Block {
|
||||
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
|
||||
}
|
||||
if action == OutboundAction::Proxy {
|
||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||
return match outbound.protocol.as_str() {
|
||||
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
||||
"http" => connect_via_http(&proxy_addr, target).await,
|
||||
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
||||
.map_err(Into::into),
|
||||
};
|
||||
let l4 = outbound.l4_protocol.to_lowercase();
|
||||
if l4 == "all" || l4 == "tcp" {
|
||||
let action = select_outbound_action(target, "tcp", outbound, debug).await;
|
||||
if action == OutboundAction::Block {
|
||||
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
|
||||
}
|
||||
if action == OutboundAction::Proxy {
|
||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||
return match outbound.protocol.as_str() {
|
||||
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
||||
"http" => connect_via_http(&proxy_addr, target).await,
|
||||
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
||||
.map_err(Into::into),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
||||
|
|
@ -320,19 +330,23 @@ pub async fn connect_udp_target(
|
|||
) -> Result<UdpProxySocket> {
|
||||
if let Some(outbound) = outbound {
|
||||
if outbound.enabled {
|
||||
let action = select_outbound_action(target, "udp", outbound, debug).await;
|
||||
if action == OutboundAction::Block {
|
||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||
}
|
||||
if action == OutboundAction::Proxy {
|
||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||
if outbound.protocol == "socks5" {
|
||||
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
|
||||
let l4 = outbound.l4_protocol.to_lowercase();
|
||||
if l4 == "all" || l4 == "udp" {
|
||||
let action = select_outbound_action(target, "udp", outbound, debug).await;
|
||||
if action == OutboundAction::Block {
|
||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||
}
|
||||
if action == OutboundAction::Proxy {
|
||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||
if outbound.protocol == "socks5" {
|
||||
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
|
||||
}
|
||||
// HTTP CONNECT does not support UDP. Fallback to direct.
|
||||
}
|
||||
// HTTP CONNECT does not support UDP. Fallback to direct.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UdpProxySocket::Direct(server_udp))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,13 +229,25 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
|||
match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core, Some(config_rx)).await {
|
||||
Ok(_) => tracing::info!("tunnel core stopped normally"),
|
||||
Err(e) => {
|
||||
// A fatal startup failure (e.g. the server bypass route
|
||||
// could not be installed). Tell the GUI and force the
|
||||
// connection state to 0 so the button reflects reality,
|
||||
// then terminate the helper so the next connect starts
|
||||
// from a clean slate (fresh helper, no stale adapter).
|
||||
tracing::error!("tunnel core error: {}", e);
|
||||
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() })
|
||||
.unwrap_or_default();
|
||||
if let Ok(enc) = crypto_for_err.encrypt(json.as_bytes()) {
|
||||
let mut w = writer_for_err.lock().await;
|
||||
let _ = w.write_all(format!("{}\n", hex::encode(&enc)).as_bytes()).await;
|
||||
for msg in [
|
||||
HelperMsg::Error { message: e.to_string() },
|
||||
HelperMsg::Status { value: 0 },
|
||||
] {
|
||||
let json = serde_json::to_string(&msg).unwrap_or_default();
|
||||
if let Ok(enc) = crypto_for_err.encrypt(json.as_bytes()) {
|
||||
let mut w = writer_for_err.lock().await;
|
||||
let _ = w.write_all(format!("{}\n", hex::encode(&enc)).as_bytes()).await;
|
||||
}
|
||||
}
|
||||
// Give the messages a moment to flush to the GUI, then exit.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,6 +63,39 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
|||
let bypass_routes = windows_route::sys::add_bypass_routes(&bypass_v4, phys_gw, phys_if, 1);
|
||||
tracing::info!("Added {} bypass routes via {} (if_index={})", bypass_routes.len(), phys_gw, phys_if);
|
||||
|
||||
// The bypass route for the OSTP server itself is mandatory: the TUN default
|
||||
// route installed below captures ALL traffic, so without a /32 carve-out the
|
||||
// client's own connection to the server loops back into the tunnel — every
|
||||
// handshake times out and there is no connectivity. Treat a missing server
|
||||
// bypass as fatal so the connect flow aborts cleanly instead of coming up in a
|
||||
// fake "connected" state with no internet.
|
||||
if let std::net::IpAddr::V4(server_v4) = opts.server_ip {
|
||||
if !server_v4.is_loopback()
|
||||
&& !server_v4.is_unspecified()
|
||||
&& !bypass_routes.iter().any(|(ip, _, _)| *ip == server_v4)
|
||||
{
|
||||
windows_route::sys::remove_bypass_routes(&bypass_routes);
|
||||
return Err(anyhow!(
|
||||
"Failed to install bypass route for OSTP server {server_v4}. Without it the \
|
||||
tunnel would capture its own server connection (routing loop, no internet). \
|
||||
Aborting tunnel startup."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any stale Wintun adapters matching our name prefix. This prevents
|
||||
// Wintun from creating "ostp_tun 2" (which violates the strict naming requirement
|
||||
// and causes the 15-second interface index lookup timeout below).
|
||||
tracing::info!("Cleaning up any stale 'ostp_tun*' adapters...");
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.creation_flags(0x08000000)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"try { Get-NetAdapter -Name 'ostp_tun*' -ErrorAction Stop | Remove-NetAdapter -Confirm:$false -ErrorAction SilentlyContinue } catch {}"
|
||||
])
|
||||
.output();
|
||||
|
||||
let mut tun_cfg = tun::Configuration::default();
|
||||
tun_cfg
|
||||
.tun_name("ostp_tun")
|
||||
|
|
@ -105,37 +138,41 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
|||
};
|
||||
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
|
||||
tracing::info!("TUN device 'ostp_tun' created.");
|
||||
let name_owned = "ostp_tun".to_string();
|
||||
|
||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||
|
||||
// A freshly created WinTun adapter can take several seconds to appear in
|
||||
// GetAdaptersAddresses (it only shows up once it has an operational IPv4
|
||||
// binding). The default route via the TUN is what actually captures
|
||||
// traffic, so this lookup is critical — give it a generous window
|
||||
// (~15s) before giving up rather than the previous 2s.
|
||||
let mut tun_index = None;
|
||||
for _ in 0..75 {
|
||||
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") {
|
||||
tun_index = Some(idx);
|
||||
break;
|
||||
// traffic. The `tun` crate already added an LUID-based route which works
|
||||
// instantly, but we add a secondary index-based route for robustness.
|
||||
// We run this in the background so it doesn't block tunnel startup.
|
||||
tokio::spawn(async move {
|
||||
let mut tun_index = None;
|
||||
for _ in 0..75 {
|
||||
if let Some(idx) = windows_route::sys::get_interface_index(&name_owned) {
|
||||
tun_index = Some(idx);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
if let Some(idx) = tun_index {
|
||||
match windows_route::sys::add_ipv4_route(
|
||||
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||
std::net::Ipv4Addr::new(10, 1, 0, 1),
|
||||
idx,
|
||||
5,
|
||||
) {
|
||||
Ok(()) => tracing::info!("Default route via TUN (if_index={idx}, metric=5) added."),
|
||||
Err(e) => tracing::error!("Failed to add default route via TUN (if_index={idx}): {e} — traffic will NOT be captured."),
|
||||
if let Some(idx) = tun_index {
|
||||
match windows_route::sys::add_ipv4_route(
|
||||
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||
std::net::Ipv4Addr::new(10, 1, 0, 1),
|
||||
idx,
|
||||
5,
|
||||
) {
|
||||
Ok(()) => tracing::info!("Default route via TUN (if_index={idx}, metric=5) added."),
|
||||
Err(e) => tracing::error!("Failed to add default route via TUN (if_index={idx}): {e} — traffic will NOT be captured."),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Could not find '{}' index in routing table after 15s — fallback route not installed.", name_owned);
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Could not find ostp_tun index in routing table after 15s — traffic will NOT be captured.");
|
||||
}
|
||||
});
|
||||
|
||||
let exe1 = current_exe.clone();
|
||||
let exe2 = current_exe.clone();
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@
|
|||
pub mod sys {
|
||||
use std::mem;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
use std::ptr;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
use winapi::shared::ipmib::{MIB_IPFORWARDROW, MIB_IPFORWARDTABLE};
|
||||
use winapi::shared::minwindef::{DWORD, ULONG};
|
||||
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
||||
|
|
@ -196,13 +200,43 @@ pub mod sys {
|
|||
}
|
||||
}
|
||||
|
||||
/// Add bypass routes for a list of resolved IP addresses (typically from exclusion config).
|
||||
/// Each IP gets a /32 host route via the physical gateway so it bypasses the TUN.
|
||||
/// Returns list of (ip, gw, if_index) that were successfully added, for later cleanup.
|
||||
/// Returns true if a route for exactly `dest`/`mask` is present in the table.
|
||||
fn route_exists(dest: Ipv4Addr, mask: Ipv4Addr) -> bool {
|
||||
unsafe {
|
||||
let mut size: ULONG = 0;
|
||||
if GetIpForwardTable(ptr::null_mut(), &mut size, 0) != ERROR_INSUFFICIENT_BUFFER {
|
||||
return false;
|
||||
}
|
||||
let mut buf: Vec<u8> = vec![0; size as usize];
|
||||
let table = buf.as_mut_ptr() as *mut MIB_IPFORWARDTABLE;
|
||||
if GetIpForwardTable(table, &mut size, 0) != NO_ERROR {
|
||||
return false;
|
||||
}
|
||||
let want_dest = ipv4_to_dword(dest);
|
||||
let want_mask = ipv4_to_dword(mask);
|
||||
let entries =
|
||||
std::slice::from_raw_parts((*table).table.as_ptr(), (*table).dwNumEntries as usize);
|
||||
entries
|
||||
.iter()
|
||||
.any(|r| r.dwForwardDest == want_dest && r.dwForwardMask == want_mask)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add bypass routes for a list of resolved IP addresses (typically the OSTP
|
||||
/// server plus any exclusions). Each IP gets a /32 host route via the physical
|
||||
/// gateway so it bypasses the TUN. Returns the list of IPs that were verified
|
||||
/// present in the routing table afterwards, for later cleanup.
|
||||
///
|
||||
/// The route is installed with `route.exe` resolved by **gateway** (no explicit
|
||||
/// interface index). The legacy `CreateIpForwardEntry` API uses a different
|
||||
/// interface-index space than the modern stack and rejects a mismatched index
|
||||
/// with ERROR_BAD_ARGUMENTS (160); letting Windows pick the interface from the
|
||||
/// on-link gateway sidesteps that entirely. `route.exe`'s exit code is not
|
||||
/// reliable, so success is confirmed by re-reading the routing table.
|
||||
pub fn add_bypass_routes(
|
||||
ips: &[Ipv4Addr],
|
||||
gw: Ipv4Addr,
|
||||
if_index: u32,
|
||||
_if_index: u32,
|
||||
metric: u32,
|
||||
) -> Vec<(Ipv4Addr, Ipv4Addr, u32)> {
|
||||
let mut added = Vec::new();
|
||||
|
|
@ -210,21 +244,29 @@ pub mod sys {
|
|||
let mask = Ipv4Addr::new(255, 255, 255, 255);
|
||||
for &ip in ips {
|
||||
// The server IP is passed both as server_ip and inside bypass_ips, so
|
||||
// dedupe to avoid a guaranteed "already exists" failure on the second add.
|
||||
// dedupe to avoid redundant work.
|
||||
if !seen.insert(ip) {
|
||||
continue;
|
||||
}
|
||||
// Purge any pre-existing /32 for this dest (e.g. a stale route via an
|
||||
// old gateway from a previous session) so CreateIpForwardEntry below
|
||||
// installs the correct one instead of failing with ERROR_OBJECT_ALREADY_EXISTS.
|
||||
// old gateway from a previous session) so the fresh, correct one lands.
|
||||
delete_routes_for_dest(ip, mask);
|
||||
match add_ipv4_route(ip, mask, gw, if_index, metric) {
|
||||
Ok(()) => {
|
||||
added.push((ip, gw, if_index));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("bypass route add {ip}/32 via {gw} (if {if_index}) failed: {e}");
|
||||
}
|
||||
let _ = Command::new("route")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
"add",
|
||||
&ip.to_string(),
|
||||
"mask",
|
||||
"255.255.255.255",
|
||||
&gw.to_string(),
|
||||
"metric",
|
||||
&metric.to_string(),
|
||||
])
|
||||
.output();
|
||||
if route_exists(ip, mask) {
|
||||
added.push((ip, gw, 0));
|
||||
} else {
|
||||
tracing::warn!("bypass route add {ip}/32 via {gw} failed (not present in table after route.exe add)");
|
||||
}
|
||||
}
|
||||
added
|
||||
|
|
@ -232,9 +274,9 @@ pub mod sys {
|
|||
|
||||
/// Remove all bypass routes previously added by add_bypass_routes.
|
||||
pub fn remove_bypass_routes(routes: &[(Ipv4Addr, Ipv4Addr, u32)]) {
|
||||
for &(ip, gw, if_index) in routes {
|
||||
let mask = Ipv4Addr::new(255, 255, 255, 255);
|
||||
let _ = delete_ipv4_route(ip, mask, gw, if_index);
|
||||
let mask = Ipv4Addr::new(255, 255, 255, 255);
|
||||
for &(ip, _gw, _if_index) in routes {
|
||||
delete_routes_for_dest(ip, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 43b4935fd2addc284a5ae8719824652f9063b95d
|
||||
Loading…
Reference in New Issue