feat: implement l4_protocol for server outbound, fix gui metrics and tunnel startup

This commit is contained in:
ospab 2026-06-23 00:05:04 +03:00
parent b6e78c1d29
commit 2997bfdf16
73 changed files with 462 additions and 95 deletions

BIN
icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::{anyhow, Result};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::watch; use tokio::sync::{mpsc, watch};
use crate::config::{ClientConfig, InboundConfig}; use crate::config::{ClientConfig, InboundConfig};
use crate::tunnel::balancer::Balancer; use crate::tunnel::balancer::Balancer;
@ -16,26 +16,74 @@ pub async fn run_client_core(
use portable_atomic::Ordering; use portable_atomic::Ordering;
tracing::info!("starting client core"); tracing::info!("starting client core");
// Report "connecting" until an inbound has successfully bound. Each inbound // Report "connecting" until the primary inbound has fully come up. The TUN
// flips this to 2 (connected) once it is ready; if they all fail, the // inbound flips this to 2 (connected) only after the device and the server
// select! below returns and we reset to 0 (disconnected). // 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); metrics.connection_state.store(1, Ordering::Relaxed);
let router = Arc::new(Router::new(config.routing.clone())); let router = Arc::new(Router::new(config.routing.clone()));
let balancer = Arc::new(Balancer::new(&config)); let balancer = Arc::new(Balancer::new(&config));
// TODO: Detect physical interface index for bypassing // TODO: Detect physical interface index for bypassing
let phys_if_for_bypass = None; let phys_if_for_bypass = None;
let outbound_manager = Arc::new(OutboundManager::new(balancer.clone(), 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 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() { for inbound in config.inbounds.clone() {
let router_clone = router.clone(); let router_clone = router.clone();
let outbound_manager_clone = outbound_manager.clone(); let outbound_manager_clone = outbound_manager.clone();
let shutdown_rx = shutdown_rx_ext.clone(); let shutdown_rx = shutdown_rx_ext.clone();
let config_clone = config.clone(); let config_clone = config.clone();
let metrics_clone = metrics.clone(); let metrics_clone = metrics.clone();
let failure_tx = failure_tx.clone();
match inbound.clone() { match inbound.clone() {
InboundConfig::Tun { .. } => { InboundConfig::Tun { .. } => {
@ -49,10 +97,12 @@ pub async fn run_client_core(
metrics_clone, metrics_clone,
).await { ).await {
tracing::error!("TUN inbound failed: {}", e); tracing::error!("TUN inbound failed: {}", e);
let _ = failure_tx.send(format!("TUN inbound: {e}")).await;
} }
})); }));
} }
InboundConfig::LocalProxy { .. } => { InboundConfig::LocalProxy { .. } => {
let is_primary = !has_tun;
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
if let Err(e) = crate::tunnel::inbounds::local_proxy::run_socks_inbound( if let Err(e) = crate::tunnel::inbounds::local_proxy::run_socks_inbound(
config_clone, config_clone,
@ -61,23 +111,43 @@ pub async fn run_client_core(
outbound_manager_clone, outbound_manager_clone,
shutdown_rx, shutdown_rx,
metrics_clone, metrics_clone,
is_primary,
).await { ).await {
tracing::error!("SOCKS inbound failed: {}", e); 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 // Run until: an external shutdown, a fatal inbound failure, or all inbounds
tokio::select! { // ending on their own.
let result = tokio::select! {
_ = shutdown_rx_ext.changed() => { _ = shutdown_rx_ext.changed() => {
if *shutdown_rx_ext.borrow() { if *shutdown_rx_ext.borrow() {
tracing::info!("Shutdown signal received in run_client_core"); 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); metrics.connection_state.store(0, Ordering::Relaxed);
Ok(()) result
} }

View File

@ -14,6 +14,7 @@ pub async fn run_socks_inbound(
outbound_manager: Arc<OutboundManager>, outbound_manager: Arc<OutboundManager>,
mut shutdown: watch::Receiver<bool>, mut shutdown: watch::Receiver<bool>,
metrics: Arc<crate::bridge::BridgeMetrics>, metrics: Arc<crate::bridge::BridgeMetrics>,
is_primary: bool,
) -> Result<()> { ) -> Result<()> {
use portable_atomic::Ordering; use portable_atomic::Ordering;
let InboundConfig::LocalProxy { tag, protocol, listen, port, set_system_proxy } = inbound_config else { 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?; let listener = TcpListener::bind(&bind_addr).await?;
// Listener bound successfully — the proxy is ready to accept connections. // Binding a local socket only proves the proxy can accept connections, not
metrics.connection_state.store(2, Ordering::Relaxed); // that the tunnel actually reaches the server. Only report "connected" from
tracing::info!("{} proxy inbound ready on {}, connection state = connected", protocol, bind_addr); // 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 { loop {
tokio::select! { tokio::select! {

View File

@ -160,20 +160,24 @@ pub async fn run_tun_inbound(
_route_guard = Some(tun_interface.guard); _route_guard = Some(tun_interface.guard);
let (mut tun_read, mut tun_write) = tokio::io::split(dev); 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 tun_to_stack = tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
loop { loop {
match tun_read.read(&mut buf).await { match tun_read.read(&mut buf).await {
Ok(0) => break, Ok(0) => break,
Ok(n) => { 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; } if let Err(_) = stack_sink.send(buf[..n].to_vec()).await { break; }
} }
Err(e) => tracing::debug!("tun_read error: {e}"), Err(e) => tracing::debug!("tun_read error: {e}"),
} }
} }
}); });
let m_recv = metrics.clone();
let stack_to_tun = tokio::spawn(async move { let stack_to_tun = tokio::spawn(async move {
while let Some(Ok(frame)) = stack_stream.next().await { 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}"); } if let Err(e) = tun_write.write(&frame).await { tracing::debug!("tun_write error: {e}"); }
} }
}); });

3
ostp-core/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
// Left empty by request
}

View File

@ -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),
}
}

123
ostp-core/src/dnstt.rs Normal file
View File

@ -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.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -59,7 +59,7 @@ dev_dependencies:
flutter_launcher_icons: flutter_launcher_icons:
android: "launcher_icon" android: "launcher_icon"
ios: false 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -80,15 +80,7 @@
<!-- Connection info (shown when connected) --> <!-- Connection info (shown when connected) -->
<div id="connection-info" class="connection-info hidden"> <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-box">
<div class="ping-test-left"> <div class="ping-test-left">

View File

@ -356,10 +356,7 @@ function setState(next) {
statusLabel.textContent = t('status_connected'); statusLabel.textContent = t('status_connected');
// Show connection info // Show connection info
if (serverAddr) { connInfo.classList.remove('hidden');
serverBadgeTxt.textContent = serverAddr;
connInfo.classList.remove('hidden');
}
// Start uptime counter // Start uptime counter
if (!uptimeTimer) { if (!uptimeTimer) {
@ -389,6 +386,11 @@ async function poll() {
if (metrics && pollTimer) { if (metrics && pollTimer) {
metricDown.textContent = fmtBytes(metrics.bytes_recv); metricDown.textContent = fmtBytes(metrics.bytes_recv);
metricUp.textContent = fmtBytes(metrics.bytes_sent); 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) { } catch (err) {
console.error('[OSTP] poll threw:', err); console.error('[OSTP] poll threw:', err);

View File

@ -412,7 +412,7 @@ impl Dispatcher {
}); });
self.addr_to_session.insert(peer, candidate_session_id); 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() candidate_session_id, peer, self.peer_machines.len(), self.replay_cache.len()
); );

View File

@ -22,12 +22,18 @@ pub struct OutboundRule {
pub action: OutboundAction, pub action: OutboundAction,
} }
fn default_l4_protocol() -> String {
"all".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutboundConfig { pub struct OutboundConfig {
pub enabled: bool, pub enabled: bool,
pub protocol: String, pub protocol: String,
pub address: String, pub address: String,
pub port: u16, pub port: u16,
#[serde(default = "default_l4_protocol")]
pub l4_protocol: String,
pub rules: Vec<OutboundRule>, pub rules: Vec<OutboundRule>,
pub default_action: OutboundAction, pub default_action: OutboundAction,
} }
@ -42,24 +48,28 @@ pub async fn connect_target(
let connect_timeout = Duration::from_secs(10); let connect_timeout = Duration::from_secs(10);
if let Some(outbound) = outbound { if let Some(outbound) = outbound {
if outbound.enabled { if outbound.enabled {
let action = select_outbound_action(target, "tcp", outbound, debug).await; let l4 = outbound.l4_protocol.to_lowercase();
if action == OutboundAction::Block { if l4 == "all" || l4 == "tcp" {
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target)); let action = select_outbound_action(target, "tcp", outbound, debug).await;
} if action == OutboundAction::Block {
if action == OutboundAction::Proxy { return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
let proxy_addr = format!("{}:{}", outbound.address, outbound.port); }
return match outbound.protocol.as_str() { if action == OutboundAction::Proxy {
"socks5" => connect_via_socks5(&proxy_addr, target).await, let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
"http" => connect_via_http(&proxy_addr, target).await, return match outbound.protocol.as_str() {
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target)) "socks5" => connect_via_socks5(&proxy_addr, target).await,
.await "http" => connect_via_http(&proxy_addr, target).await,
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))? _ => tokio::time::timeout(connect_timeout, TcpStream::connect(target))
.map_err(Into::into), .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)) tokio::time::timeout(connect_timeout, TcpStream::connect(target))
.await .await
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))? .map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
@ -320,19 +330,23 @@ pub async fn connect_udp_target(
) -> Result<UdpProxySocket> { ) -> Result<UdpProxySocket> {
if let Some(outbound) = outbound { if let Some(outbound) = outbound {
if outbound.enabled { if outbound.enabled {
let action = select_outbound_action(target, "udp", outbound, debug).await; let l4 = outbound.l4_protocol.to_lowercase();
if action == OutboundAction::Block { if l4 == "all" || l4 == "udp" {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target)); let action = select_outbound_action(target, "udp", outbound, debug).await;
} if action == OutboundAction::Block {
if action == OutboundAction::Proxy { return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
let proxy_addr = format!("{}:{}", outbound.address, outbound.port); }
if outbound.protocol == "socks5" { if action == OutboundAction::Proxy {
return connect_udp_via_socks5(&proxy_addr, server_udp).await; 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)) Ok(UdpProxySocket::Direct(server_udp))
} }

View File

@ -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 { 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"), Ok(_) => tracing::info!("tunnel core stopped normally"),
Err(e) => { 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); tracing::error!("tunnel core error: {}", e);
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }) for msg in [
.unwrap_or_default(); HelperMsg::Error { message: e.to_string() },
if let Ok(enc) = crypto_for_err.encrypt(json.as_bytes()) { HelperMsg::Status { value: 0 },
let mut w = writer_for_err.lock().await; ] {
let _ = w.write_all(format!("{}\n", hex::encode(&enc)).as_bytes()).await; 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);
} }
} }
}); });

View File

@ -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); 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); 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(); let mut tun_cfg = tun::Configuration::default();
tun_cfg tun_cfg
.tun_name("ostp_tun") .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))?; let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'ostp_tun' created."); 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(); let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
// A freshly created WinTun adapter can take several seconds to appear in // A freshly created WinTun adapter can take several seconds to appear in
// GetAdaptersAddresses (it only shows up once it has an operational IPv4 // GetAdaptersAddresses (it only shows up once it has an operational IPv4
// binding). The default route via the TUN is what actually captures // binding). The default route via the TUN is what actually captures
// traffic, so this lookup is critical — give it a generous window // traffic. The `tun` crate already added an LUID-based route which works
// (~15s) before giving up rather than the previous 2s. // instantly, but we add a secondary index-based route for robustness.
let mut tun_index = None; // We run this in the background so it doesn't block tunnel startup.
for _ in 0..75 { tokio::spawn(async move {
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") { let mut tun_index = None;
tun_index = Some(idx); for _ in 0..75 {
break; 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 { if let Some(idx) = tun_index {
match windows_route::sys::add_ipv4_route( 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(0, 0, 0, 0), std::net::Ipv4Addr::new(0, 0, 0, 0),
std::net::Ipv4Addr::new(10, 1, 0, 1), std::net::Ipv4Addr::new(10, 1, 0, 1),
idx, idx,
5, 5,
) { ) {
Ok(()) => tracing::info!("Default route via TUN (if_index={idx}, metric=5) added."), 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."), 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 exe1 = current_exe.clone();
let exe2 = current_exe.clone(); let exe2 = current_exe.clone();

View File

@ -11,8 +11,12 @@
pub mod sys { pub mod sys {
use std::mem; use std::mem;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::os::windows::process::CommandExt;
use std::process::Command;
use std::ptr; use std::ptr;
const CREATE_NO_WINDOW: u32 = 0x08000000;
use winapi::shared::ipmib::{MIB_IPFORWARDROW, MIB_IPFORWARDTABLE}; use winapi::shared::ipmib::{MIB_IPFORWARDROW, MIB_IPFORWARDTABLE};
use winapi::shared::minwindef::{DWORD, ULONG}; use winapi::shared::minwindef::{DWORD, ULONG};
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR}; 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). /// Returns true if a route for exactly `dest`/`mask` is present in the table.
/// Each IP gets a /32 host route via the physical gateway so it bypasses the TUN. fn route_exists(dest: Ipv4Addr, mask: Ipv4Addr) -> bool {
/// Returns list of (ip, gw, if_index) that were successfully added, for later cleanup. 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( pub fn add_bypass_routes(
ips: &[Ipv4Addr], ips: &[Ipv4Addr],
gw: Ipv4Addr, gw: Ipv4Addr,
if_index: u32, _if_index: u32,
metric: u32, metric: u32,
) -> Vec<(Ipv4Addr, Ipv4Addr, u32)> { ) -> Vec<(Ipv4Addr, Ipv4Addr, u32)> {
let mut added = Vec::new(); let mut added = Vec::new();
@ -210,21 +244,29 @@ pub mod sys {
let mask = Ipv4Addr::new(255, 255, 255, 255); let mask = Ipv4Addr::new(255, 255, 255, 255);
for &ip in ips { for &ip in ips {
// The server IP is passed both as server_ip and inside bypass_ips, so // 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) { if !seen.insert(ip) {
continue; continue;
} }
// Purge any pre-existing /32 for this dest (e.g. a stale route via an // Purge any pre-existing /32 for this dest (e.g. a stale route via an
// old gateway from a previous session) so CreateIpForwardEntry below // old gateway from a previous session) so the fresh, correct one lands.
// installs the correct one instead of failing with ERROR_OBJECT_ALREADY_EXISTS.
delete_routes_for_dest(ip, mask); delete_routes_for_dest(ip, mask);
match add_ipv4_route(ip, mask, gw, if_index, metric) { let _ = Command::new("route")
Ok(()) => { .creation_flags(CREATE_NO_WINDOW)
added.push((ip, gw, if_index)); .args([
} "add",
Err(e) => { &ip.to_string(),
tracing::warn!("bypass route add {ip}/32 via {gw} (if {if_index}) failed: {e}"); "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 added
@ -232,9 +274,9 @@ pub mod sys {
/// Remove all bypass routes previously added by add_bypass_routes. /// Remove all bypass routes previously added by add_bypass_routes.
pub fn remove_bypass_routes(routes: &[(Ipv4Addr, Ipv4Addr, u32)]) { 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 mask = Ipv4Addr::new(255, 255, 255, 255); for &(ip, _gw, _if_index) in routes {
let _ = delete_ipv4_route(ip, mask, gw, if_index); delete_routes_for_dest(ip, mask);
} }
} }
} }

1
ostp.wiki Submodule

@ -0,0 +1 @@
Subproject commit 43b4935fd2addc284a5ae8719824652f9063b95d