diff --git a/README.md b/README.md index 1109557..a1be4f4 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ [Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases) -![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=flat-square&color=blue) -![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=flat-square) -![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=flat-square) -![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=flat-square) -![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=flat-square) +![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=for-the-badge&color=blue) +![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=for-the-badge) +![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=for-the-badge) +![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=for-the-badge) +![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=for-the-badge) -**OSTP** is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise. Resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis. +**OSTP** (Ospab Stealth Transport Protocol) is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise. Resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis. --- @@ -53,40 +53,36 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git ## Architecture -``` -┌─────────────────────────────────────────────────────────────┐ -│ Client │ -│ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │ -│ │ Browser │──▸│ SOCKS5/ │──▸│ Bridge (Mux) │ │ -│ │ / Apps │ │ HTTP │ │ ┌─────────────────┐ │ │ -│ │ │ │ Proxy │ │ │ ProtocolMachine │ │ │ -│ └──────────┘ └──────────┘ │ │ (Noise + AEAD) │ │ │ -│ │ └────────┬────────┘ │ │ -│ ┌──────────┐ │ │ │ │ -│ │ TUN Mode │──────────────────┤ UDP Socket │ │ -│ │tun2socks │ │ (32MB buffers, │ │ -│ └──────────┘ │ obfuscated wire) │ │ -│ └───────────┬────────────┘ │ -└────────────────────────────────────────────┼────────────────┘ - │ UDP -┌────────────────────────────────────────────┼────────────────┐ -│ Server │ │ -│ ┌─────────────────────────────────────────┴───────────┐ │ -│ │ Dispatcher │ │ -│ │ (Session lookup, roaming, replay guard, per-user │ │ -│ │ traffic accounting, limit enforcement) │ │ -│ └──┬──────────────────────┬───────────────────────────┘ │ -│ │ │ │ -│ ┌──▾──────────────────┐ ┌─▾──────────────────────────┐ │ -│ │ Relay Loop │ │ Management API (REST) │ │ -│ │ (per-stream TCP) │ │ /api/users, /api/stats │ │ -│ │ ──▸ Internet │ │ Bearer token auth │ │ -│ └─────────────────────┘ └────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ Fallback TCP Proxy ──▸ nginx/caddy (anti-DPI) │ │ -│ └──────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +```mermaid +graph TD + subgraph Client ["Client"] + A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer) + TUN[TUN Interface] -->|IP Packets| B + + subgraph OSTPCoreClient ["OSTP Core Protocol"] + B --> C{Protocol Machine} + C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD] + D -->|Obfuscated UDP Payload| E((UDP Socket)) + end + end + + E <==>|Encrypted & Obfuscated UDP Tunnel| F + + subgraph Server ["Server"] + F((UDP Socket)) --> G{Dispatcher} + + subgraph OSTPCoreServer ["OSTP Core Backend"] + G -->|Auth & Decrypt| H[Session & State Guard] + H -->|TCP Stream| I[Relay Loop] + end + + G -->|Active Probing / Unauth| FB[TCP Fallback Proxy] + FB -->|Forward| NGINX[nginx / Caddy] + + H -->|Stats & Traffic| API[Management API] + + I -->|Outbound| WWW((Internet)) + end ``` --- @@ -142,7 +138,9 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git ```bash ./ostp "ostp://ACCESS_KEY@server.com:50000?..." ``` -> **Note**: Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`. + +> [!WARNING] +> Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`. --- diff --git a/README.ru.md b/README.ru.md index ad115eb..0d50802 100644 --- a/README.ru.md +++ b/README.ru.md @@ -2,11 +2,13 @@ [English](README.md) · [Contributing](CONTRIBUTING.ru.md) -![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=flat-square&color=blue) -![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=flat-square) -![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=flat-square) +![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=for-the-badge&color=blue) +![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=for-the-badge) +![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=for-the-badge) +![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=for-the-badge) +![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=for-the-badge) -OSTP — высокопроизводительный транспортный протокол, устойчивый к цензуре. Туннелирует TCP-трафик поверх UDP с полной обфускацией. Устойчив к Deep Packet Inspection (DPI), активному зондированию и статистическому анализу трафика. +**OSTP** (Ospab Stealth Transport Protocol) — высокопроизводительный транспортный протокол, устойчивый к цензуре. Туннелирует TCP-трафик поверх UDP с полной обфускацией. Устойчив к Deep Packet Inspection (DPI), активному зондированию и статистическому анализу трафика. --- @@ -30,33 +32,34 @@ OSTP — высокопроизводительный транспортный ## Архитектура -``` -┌────────────────────────────────────────────────────────────┐ -│ Клиент │ -│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │ -│ │ Браузер │──▸│ SOCKS5/ │──▸│ Bridge (Mux) │ │ -│ │ / Прил. │ │ HTTP │ │ ┌─────────────────┐ │ │ -│ │ │ │ Прокси │ │ │ ProtocolMachine │ │ │ -│ └──────────┘ └──────────┘ │ │ (Noise + AEAD) │ │ │ -│ │ └────────┬────────┘ │ │ -│ ┌──────────┐ │ │ │ │ -│ │ TUN Mode │──────────────────┤ UDP-сокет │ │ -│ │tun2socks │ │ (32МБ буферы, │ │ -│ └──────────┘ │ обфускация) │ │ -│ └───────────┬────────────┘ │ -└────────────────────────────────────────────┼────────────────┘ - │ UDP -┌────────────────────────────────────────────┼────────────────┐ -│ Сервер │ │ -│ ┌─────────────────────────────────────────┴──────────┐ │ -│ │ Dispatcher │ │ -│ │ (Поиск сессий, роуминг, защита от replay) │ │ -│ └──────────────┬──────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────▾──────────────────┐ │ -│ │ Relay Loop (TCP per-stream) │──▸ Интернет / Backend │ -│ └─────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ +```mermaid +graph TD + subgraph Client ["Клиент"] + A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer) + TUN[TUN Интерфейс] -->|IP Пакеты| B + + subgraph OSTPCoreClient ["OSTP Core Протокол"] + B --> C{Protocol Machine} + C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD] + D -->|Обфусцированный UDP| E((UDP Сокет)) + end + end + + E <==>|Зашифрованный UDP Туннель| F + + subgraph Server ["Сервер"] + F((UDP Сокет)) --> G{Dispatcher} + + subgraph OSTPCoreServer ["OSTP Core Backend"] + G -->|Auth & Decrypt| H[Session & State Guard] + H -->|TCP Поток| I[Relay Loop] + end + + G -->|Active Probing / Unauth| FB[TCP Fallback Proxy] + FB -->|Перенаправление| NGINX[nginx / Caddy] + + I -->|Outbound| WWW((Интернет)) + end ``` --- diff --git a/docs/banner.txt b/docs/banner.txt new file mode 100644 index 0000000..b75cf38 --- /dev/null +++ b/docs/banner.txt @@ -0,0 +1,7 @@ + + ____ _____ _______ _____ + / __ \ / ____|__ __| __ \ +| | | | (___ | | | |__) | +| | | |\___ \ | | | ___/ +| |__| |____) | | | | | + \____/|_____/ |_| |_| diff --git a/docs/en/obfuscation.md b/docs/en/obfuscation.md index d99a747..8548566 100644 Binary files a/docs/en/obfuscation.md and b/docs/en/obfuscation.md differ diff --git a/docs/ru/obfuscation.md b/docs/ru/obfuscation.md index b0ba097..49de434 100644 Binary files a/docs/ru/obfuscation.md and b/docs/ru/obfuscation.md differ diff --git a/netstack-smoltcp/src/tcp.rs b/netstack-smoltcp/src/tcp.rs index b03a306..1af8f36 100644 --- a/netstack-smoltcp/src/tcp.rs +++ b/netstack-smoltcp/src/tcp.rs @@ -22,7 +22,7 @@ use spin::Mutex as SpinMutex; use tokio::{ io::{AsyncRead, AsyncWrite, ReadBuf}, sync::{ - mpsc::{unbounded_channel, Receiver, Sender, UnboundedReceiver, UnboundedSender}, + mpsc::{channel, unbounded_channel, Receiver, Sender, UnboundedReceiver, UnboundedSender}, Notify, }, }; @@ -72,12 +72,12 @@ impl TcpListenerRunner { iface_ingress_tx: UnboundedSender>, iface_ingress_tx_avail: Arc, tcp_rx: Receiver, - stream_tx: UnboundedSender, + stream_tx: Sender, sockets: HashMap, ) -> Runner { Runner::new(async move { let notify = Arc::new(Notify::new()); - let (socket_tx, socket_rx) = unbounded_channel::(); + let (socket_tx, socket_rx) = channel::(1024); let res = tokio::select! { v = Self::handle_packet(notify.clone(), iface_ingress_tx, iface_ingress_tx_avail.clone(), tcp_rx, stream_tx, socket_tx) => v, v = Self::handle_socket(notify, device, iface, iface_ingress_tx_avail, sockets, socket_rx) => v, @@ -93,8 +93,8 @@ impl TcpListenerRunner { iface_ingress_tx: UnboundedSender>, iface_ingress_tx_avail: Arc, mut tcp_rx: Receiver, - stream_tx: UnboundedSender, - socket_tx: UnboundedSender, + stream_tx: Sender, + socket_tx: Sender, ) -> std::io::Result<()> { while let Some(frame) = tcp_rx.recv().await { let packet = match IpPacket::new_checked(frame.as_slice()) { @@ -160,17 +160,20 @@ impl TcpListenerRunner { send_state: TcpSocketState::Normal, })); - stream_tx - .send(TcpStream { - src_addr, - dst_addr, - notify: notify.clone(), - control: control.clone(), - }) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?; - socket_tx - .send(TcpSocketCreation { control, socket }) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?; + if let Err(_) = stream_tx.try_send(TcpStream { + src_addr, + dst_addr, + notify: notify.clone(), + control: control.clone(), + }) { + error!("stream_tx full or dropped, dropping SYN from {}", src_addr); + continue; + } + + if let Err(_) = socket_tx.try_send(TcpSocketCreation { control, socket }) { + error!("socket_tx full or dropped, dropping SYN from {}", src_addr); + continue; + } } // Pipeline tcp stream packet @@ -189,7 +192,7 @@ impl TcpListenerRunner { mut iface: Interface, iface_ingress_tx_avail: Arc, mut sockets: HashMap, - mut socket_rx: UnboundedReceiver, + mut socket_rx: Receiver, ) -> std::io::Result<()> { let mut socket_set = SocketSet::new(vec![]); loop { @@ -355,7 +358,7 @@ impl TcpListenerRunner { } pub struct TcpListener { - stream_rx: UnboundedReceiver, + stream_rx: Receiver, } impl TcpListener { @@ -368,7 +371,7 @@ impl TcpListener { VirtualDevice::new(stack_tx, mtu); let iface = Self::create_interface(&mut device)?; - let (stream_tx, stream_rx) = unbounded_channel(); + let (stream_tx, stream_rx) = channel(1024); let runner = TcpListenerRunner::create( device, diff --git a/ostp-client/Cargo.toml b/ostp-client/Cargo.toml index dd6c83f..64bbfba 100644 --- a/ostp-client/Cargo.toml +++ b/ostp-client/Cargo.toml @@ -29,4 +29,4 @@ libc = "0.2.186" x25519-dalek = "2.0.1" chacha20poly1305.workspace = true hex = "0.4.3" -winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt"] } +winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] } diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 602df97..608e8f3 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -72,6 +72,8 @@ pub struct Bridge { pub reality_enabled: bool, pub reality_pbk: String, pub reality_sid: String, + pub kill_switch: bool, + pub reload_tx: Option>, metrics: Arc, sample_sent: u64, @@ -107,6 +109,8 @@ impl Bridge { reality_enabled: config.reality.enabled, reality_pbk: config.reality.pbk.clone(), reality_sid: config.reality.sid.clone(), + kill_switch: config.kill_switch, + reload_tx: None, metrics, sample_sent: 0, @@ -465,16 +469,32 @@ impl Bridge { Some(BridgeCommand::ReloadConfig) => { match ClientConfig::reload_from_json_near_binary() { Ok(cfg) => { + let old_server = self.server_addr.clone(); + let old_mode = self.mode.clone(); + let old_transport = self.transport_mode.clone(); + self.apply_runtime_config(&cfg); - tx.send(UiEvent::Log("Runtime config reloaded".to_string())).await.ok(); - if self.running { - self.running = false; - self.metrics.connection_state.store(0, Ordering::Relaxed); - *proxy_guard = None; - *sessions_opt = None; - stream_map.clear(); - self.reset_proxy_streams(&tx, &proxy_tx, "config reload"); - let _ = tx.send(UiEvent::TunnelStopped).await; + + let requires_restart = self.server_addr != old_server || + self.mode != old_mode || + self.transport_mode != old_transport; + + if !requires_restart { + if let Some(tx_watch) = &self.reload_tx { + let _ = tx_watch.send(cfg.exclusions.clone()); + } + tx.send(UiEvent::Log("Exclusions updated in real-time (hot reload)".to_string())).await.ok(); + } else { + tx.send(UiEvent::Log("Runtime config reloaded. Restarting tunnel due to critical parameter changes.".to_string())).await.ok(); + if self.running { + self.running = false; + self.metrics.connection_state.store(0, Ordering::Relaxed); + *proxy_guard = None; + *sessions_opt = None; + stream_map.clear(); + self.reset_proxy_streams(&tx, &proxy_tx, "config reload"); + let _ = tx.send(UiEvent::TunnelStopped).await; + } } } Err(err) => { @@ -504,18 +524,23 @@ impl Bridge { if self.last_valid_recv.elapsed().as_secs() > 25 { let elapsed = self.last_valid_recv.elapsed().as_secs(); if elapsed > 180 { - let _ = tx.send(UiEvent::Log("Connection permanently lost (3-minute hard timeout). Stopping tunnel.".into())).await; - self.running = false; - *proxy_guard = None; - *sessions_opt = None; - stream_map.clear(); - self.reset_proxy_streams(&tx, &proxy_tx, "keepalive hard timeout"); - let _ = tx.send(UiEvent::TunnelStopped).await; - self.metrics.connection_state.store(0, Ordering::Relaxed); - return; + if self.kill_switch { + let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await; + } else { + let _ = tx.send(UiEvent::Log("Connection permanently lost (3-minute hard timeout). Stopping tunnel.".into())).await; + self.running = false; + *proxy_guard = None; + *sessions_opt = None; + stream_map.clear(); + self.reset_proxy_streams(&tx, &proxy_tx, "keepalive hard timeout"); + let _ = tx.send(UiEvent::TunnelStopped).await; + self.metrics.connection_state.store(0, Ordering::Relaxed); + return; + } + } else { + let _ = tx.send(UiEvent::Log(format!("Connection stall detected ({}s silence). Attempting background reconnect...", elapsed))).await; } - let _ = tx.send(UiEvent::Log(format!("Connection stall detected ({}s silence). Attempting background reconnect...", elapsed))).await; self.metrics.connection_state.store(1, Ordering::Relaxed); let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 }; @@ -970,8 +995,9 @@ impl Bridge { self.reality_enabled = cfg.reality.enabled; self.reality_pbk = cfg.reality.pbk.clone(); self.reality_sid = cfg.reality.sid.clone(); - self.mtu = cfg.ostp.mtu; // Fix: mtu was never updated on hot-reload - self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; // Fix: keepalive was never updated on hot-reload + self.mtu = cfg.ostp.mtu; + self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; + self.kill_switch = cfg.kill_switch; } async fn try_connect_transport( diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index e9db5df..99ebe5a 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -22,6 +22,8 @@ pub struct ClientConfig { pub dns_server: Option, #[serde(default = "default_tun_stack")] pub tun_stack: String, + #[serde(default)] + pub kill_switch: bool, } fn default_tun_stack() -> String { "system".to_string() } @@ -153,6 +155,7 @@ impl Default for ClientConfig { multiplex: MultiplexConfig::default(), dns_server: None, tun_stack: "system".to_string(), + kill_switch: false, } } } @@ -197,6 +200,7 @@ struct RawTunSection { enable: Option, dns: Option, stack: Option, + kill_switch: Option, } #[derive(Debug, Deserialize)] @@ -292,7 +296,7 @@ impl ClientConfig { }, dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()), tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()), + kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), }) - } } diff --git a/ostp-client/src/runner.rs b/ostp-client/src/runner.rs index dfd8650..ad605dd 100644 --- a/ostp-client/src/runner.rs +++ b/ostp-client/src/runner.rs @@ -180,13 +180,14 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> { } }); - run_client_core(config, metrics, shutdown_rx).await + run_client_core(config, metrics, shutdown_rx, None).await } pub async fn run_client_core( mut config: crate::config::ClientConfig, metrics: Arc, mut shutdown_rx_ext: watch::Receiver, + mut config_rx: Option>, ) -> Result<()> { #[cfg(target_os = "windows")] if config.mode == "tun" && !is_admin() { @@ -249,8 +250,12 @@ pub async fn run_client_core( let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256); let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel(); + + // Setup exclusions hot-reload channel + let (reload_tx, reload_rx) = watch::channel(config.exclusions.clone()); - let bridge = Bridge::new(&config, metrics)?; + let mut bridge = Bridge::new(&config, metrics)?; + bridge.reload_tx = Some(reload_tx.clone()); let (ui_tx, mut ui_rx) = mpsc::channel(512); let (cmd_tx, cmd_rx) = mpsc::channel(128); @@ -305,11 +310,12 @@ pub async fn run_client_core( }); let config_clone = config.clone(); + let proxy_exclusions_rx = reload_rx.clone(); let mut proxy_task = tokio::spawn(async move { tunnel::run_local_proxy( config.local_proxy, config.ostp, - config.exclusions, + proxy_exclusions_rx, config.debug, proxy_shutdown_rx, proxy_events_tx, @@ -319,14 +325,43 @@ pub async fn run_client_core( }); let wintun_shutdown_rx = shutdown_tx.subscribe(); + let wintun_exclusions_rx = reload_rx.clone(); let mut wintun_task = if config_clone.mode == "tun" { Some(tokio::spawn(async move { - tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx).await + tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx, wintun_exclusions_rx).await })) } else { None }; + // Wait for local_shutdown + let mut local_shutdown = shutdown_rx_ext.clone(); + let cmd_tx_loop = cmd_tx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = local_shutdown.changed() => { + if *local_shutdown.borrow() { + let _ = cmd_tx_loop.send(BridgeCommand::Shutdown).await; + break; + } + } + Some(Ok(_)) = async { + if let Some(ref mut rx) = config_rx { + Some(rx.changed().await) + } else { + std::future::pending().await + } + } => { + if let Some(ref rx) = config_rx { + let new_cfg = rx.borrow().clone(); + let _ = reload_tx.send(new_cfg.exclusions); + } + } + } + } + }); + // Wait for either external shutdown OR any task to fail tokio::select! { _ = shutdown_rx_ext.changed() => { diff --git a/ostp-client/src/tunnel/mod.rs b/ostp-client/src/tunnel/mod.rs index c0a7b5a..6d98001 100644 --- a/ostp-client/src/tunnel/mod.rs +++ b/ostp-client/src/tunnel/mod.rs @@ -1,12 +1,14 @@ mod proxy; pub mod native_handler; +pub mod windows_route; mod udp_nat; pub async fn run_tun_tunnel( config: crate::config::ClientConfig, - shutdown: watch::Receiver, + shutdown: tokio::sync::watch::Receiver, + exclusions_rx: tokio::sync::watch::Receiver, ) -> anyhow::Result<()> { - native_handler::run_native_tunnel(config, shutdown).await + native_handler::run_native_tunnel(config, shutdown, exclusions_rx).await } use tokio::sync::{mpsc, watch}; @@ -51,17 +53,15 @@ pub enum ProxyToClientMsg { pub async fn run_local_proxy( cfg: LocalProxyConfig, ostp: OstpConfig, - exclusions: ExclusionConfig, + exclusions_rx: watch::Receiver, debug: bool, shutdown: watch::Receiver, proxy_events_tx: mpsc::Sender, client_msgs_rx: mpsc::UnboundedReceiver<(u16, ProxyToClientMsg)>, ) -> anyhow::Result<()> { - run_local_socks5_proxy(cfg, ostp, exclusions, debug, shutdown, proxy_events_tx, client_msgs_rx).await + run_local_socks5_proxy(cfg, ostp, exclusions_rx, debug, shutdown, proxy_events_tx, client_msgs_rx).await } - - pub mod exclusion; pub mod process_lookup; pub mod sni_sniff; diff --git a/ostp-client/src/tunnel/native_handler.rs b/ostp-client/src/tunnel/native_handler.rs index 3a5fb38..cc15624 100644 --- a/ostp-client/src/tunnel/native_handler.rs +++ b/ostp-client/src/tunnel/native_handler.rs @@ -1,10 +1,15 @@ use anyhow::{anyhow, Result}; use tokio::sync::watch; +// ────────────────────────────────────────────────────────────────────────────── +// Windows / Linux desktop TUN +// ────────────────────────────────────────────────────────────────────────────── + #[cfg(any(target_os = "windows", target_os = "linux"))] pub async fn run_native_tunnel( config: crate::config::ClientConfig, mut shutdown: watch::Receiver, + mut exclusions_rx: watch::Receiver, ) -> Result<()> { use std::net::ToSocketAddrs; use std::process::Command; @@ -26,12 +31,12 @@ pub async fn run_native_tunnel( println!("===================================================================\n"); print!("Are you sure you want to initialize the TUN interface? [yes/no]: "); io::stdout().flush().unwrap(); - + let mut input = String::new(); io::stdin().read_line(&mut input).unwrap(); let ans = input.trim().to_lowercase(); if ans != "y" && ans != "yes" { - return Err(anyhow!("TUN initialization aborted by user. Run without TUN to use as a local proxy.")); + return Err(anyhow!("TUN initialization aborted by user.")); } } } @@ -39,111 +44,234 @@ pub async fn run_native_tunnel( let debug = config.debug; tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)..."); - let server_ip = config.ostp.server_addr.to_socket_addrs() - .map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))? + // ── 1. Resolve server IP ────────────────────────────────────────────────── + let server_ip = config + .ostp + .server_addr + .to_socket_addrs() + .map_err(|e| anyhow!("Failed to resolve server IP: {}", e))? .next() - .map(|addr| addr.ip()) - .ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?; - - let server_ip_str = server_ip.to_string(); + .map(|a| a.ip()) + .ok_or_else(|| anyhow!("Could not resolve server host"))?; + let _server_ip_str = server_ip.to_string(); + // ── 2. Windows: grab physical gateway BEFORE we touch any routes ────────── + #[cfg(target_os = "windows")] + let (phys_gw, phys_if) = super::windows_route::sys::get_default_ipv4_route() + .ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?; + + // ── 3. Resolve excluded domains → IPv4 addresses for bypass routing ─────── + // + // Strategy identical to sing-box / v2rayN: + // • IP exclusions → add /32 host routes via physical gateway right now + // • Domain exclusions → resolve them NOW, add /32 routes for the IPs + // • Process exclusions → NOT possible via pure routing on Windows without + // WFP; we log a warning and skip them at the routing level + #[cfg(target_os = "windows")] + // Will be populated after TUN is up; tracks /32 routes added for cleanup. + let bypass_routes: Vec<(std::net::Ipv4Addr, std::net::Ipv4Addr, u32)>; + + #[cfg(target_os = "windows")] + { + // Collect all IPs to bypass: server IP + configured IPs + resolved domains + let mut bypass_v4: Vec = Vec::new(); + + // Server IP always bypasses TUN + if let std::net::IpAddr::V4(v4) = server_ip { + bypass_v4.push(v4); + } + + // Explicitly configured IPs / CIDRs + for ip_str in &config.exclusions.ips { + // Accept single IPs ("1.2.3.4") or CIDR ("1.2.3.0/24") + let host = ip_str.split('/').next().unwrap_or(ip_str); + if let Ok(std::net::IpAddr::V4(v4)) = host.parse() { + bypass_v4.push(v4); + } + } + + // Resolve configured excluded domains (best-effort, DNS at startup). + // Use (host, port) tuple so lookup_host does NOT borrow a temporary string. + for domain in &config.exclusions.domains { + match tokio::net::lookup_host((domain.as_str(), 443u16)).await { + Ok(addrs) => { + for addr in addrs { + if let std::net::IpAddr::V4(v4) = addr.ip() { + bypass_v4.push(v4); + } + } + } + Err(e) => { + tracing::warn!("Failed to pre-resolve excluded domain {domain}: {e}"); + } + } + } + + if !config.exclusions.processes.is_empty() { + tracing::warn!( + "Process-based split tunneling is not supported in TUN mode on Windows \ + without WFP. Processes in the exclusion list will still be tunneled. \ + Use IP or domain exclusions instead." + ); + } + + // Add /32 bypass routes via physical gateway BEFORE setting up TUN default route + bypass_routes = super::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 + ); + } + + // ── 4. Create TUN device ────────────────────────────────────────────────── let mut tun_cfg = tun::Configuration::default(); - tun_cfg.tun_name("ostp_tun") - .address((10, 1, 0, 2)) - .netmask((255, 255, 255, 0)) - .destination((10, 1, 0, 1)) - .mtu(config.ostp.mtu as u16) - .up(); + tun_cfg + .tun_name("ostp_tun") + .address((10, 1, 0, 2)) + .netmask((255, 255, 255, 0)) + .destination((10, 1, 0, 1)) + .mtu(config.ostp.mtu as u16) + .up(); #[cfg(target_os = "linux")] - tun_cfg.platform_config(|config| { - config.packet_information(false); + tun_cfg.platform_config(|cfg| { + cfg.packet_information(false); }); - let dev = tun::create(&tun_cfg) - .map_err(|e| anyhow!("Failed to create TUN device: {}", e))?; - let dev = tun::AsyncDevice::new(dev) - .map_err(|e| anyhow!("Failed to make TUN device async: {}", e))?; - - tracing::info!("TUN device created natively."); + let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?; + let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?; + tracing::info!("TUN device 'ostp_tun' created."); + // ── 5. Windows: set default route through TUN + miscellaneous setup ─────── #[cfg(target_os = "windows")] { const CREATE_NO_WINDOW: u32 = 0x08000000; let current_exe = std::env::current_exe()?.to_string_lossy().into_owned(); - let setup_script = format!( - "$remote_ip = '{}'\n\ - $exe_path = '{}'\n\ - $route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object {{ $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' }} | Sort-Object RouteMetric | Select-Object -First 1\n\ - if ($route) {{\n\ - $gw = $route.NextHop\n\ - $ifIndex = $route.InterfaceIndex\n\ - if ($gw -eq '0.0.0.0' -or $gw -eq '::') {{\n\ - New-NetRoute -DestinationPrefix \"$remote_ip/32\" -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\ - }} else {{\n\ - New-NetRoute -DestinationPrefix \"$remote_ip/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\ - }}\n\ - if ($gw -ne '0.0.0.0') {{\n\ - New-NetRoute -DestinationPrefix \"$gw/32\" -NextHop '0.0.0.0' -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\ - }}\n\ - }}\n\ - New-NetFirewallRule -DisplayName 'OSTP Tunnel In' -Direction Inbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\ - New-NetFirewallRule -DisplayName 'OSTP Tunnel Out' -Direction Outbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\ - netsh interface ipv4 set interface name=\"ostp_tun\" metric=1\n\ - New-NetRoute -DestinationPrefix '0.0.0.0/0' -InterfaceAlias 'ostp_tun' -NextHop '10.1.0.1' -RouteMetric 1 -ErrorAction SilentlyContinue\n", - server_ip_str, current_exe - ); + // Wait for ostp_tun to be visible in the routing table + let mut tun_index = None; + for _ in 0..20 { + if let Some(idx) = super::windows_route::sys::get_interface_index("ostp_tun") { + tun_index = Some(idx); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + if let Some(idx) = tun_index { + // Default route through TUN with metric=5 — higher than bypass routes (metric=1) + // so that non-excluded traffic is captured but excluded IPs go via real NIC. + let _ = super::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, + ); + tracing::info!("Default route via TUN (if_index={idx}, metric=5) added."); + } else { + tracing::warn!("Could not find ostp_tun index in routing table — traffic may not be captured."); + } + + let exe1 = current_exe.clone(); + let exe2 = current_exe.clone(); let _ = tokio::task::spawn_blocking(move || { - Command::new("powershell") + // Firewall allow-rules for OSTP binary + let _ = Command::new("netsh") .creation_flags(CREATE_NO_WINDOW) - .args(["-NoProfile", "-Command", &setup_script]) - .output() - }).await.unwrap()?; - + .args(["advfirewall", "firewall", "add", "rule", + "name=OSTP Tunnel In", "dir=in", "action=allow", + &format!("program={}", exe1)]) + .output(); + let _ = Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .args(["advfirewall", "firewall", "add", "rule", + "name=OSTP Tunnel Out", "dir=out", "action=allow", + &format!("program={}", exe2)]) + .output(); + // Disable DAD / Router Discovery to avoid 15s delay + let _ = Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .args(["interface", "ipv4", "set", "interface", "name=ostp_tun", + "routerdiscovery=disabled", "dadtransmits=0", + "managedaddress=disabled", "otherstateful=disabled"]) + .output(); + }); + if let Some(ref dns) = config.dns_server { if !dns.is_empty() { - let net_setup = format!("netsh interface ipv4 set dnsservers name=\"ostp_tun\" static {} primary\n", dns); + let dns_clone = dns.clone(); let _ = tokio::task::spawn_blocking(move || { - Command::new("powershell") + let _ = Command::new("netsh") .creation_flags(CREATE_NO_WINDOW) - .args(["-NoProfile", "-Command", &net_setup]) - .output() - }).await.unwrap()?; + .args(["interface", "ipv4", "set", "dnsservers", + "name=ostp_tun", "static", &dns_clone, "primary"]) + .output(); + }); } } + + if config.kill_switch { + tracing::info!("Kill Switch enabled: Adding metric 10 blackhole route to prevent leakage"); + let _ = tokio::task::spawn_blocking(move || { + let _ = Command::new("route") + .creation_flags(CREATE_NO_WINDOW) + .args(["add", "0.0.0.0", "mask", "0.0.0.0", "127.0.0.1", "metric", "10", "if", "1"]) + .output(); + }); + } } + // ── 6. Linux: exclusion routes via real gateway ─────────────────────────── #[cfg(target_os = "linux")] { - // Get real gateway before routing through TUN let gw_out = Command::new("ip") .args(["route", "show", "default"]) .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()); - + let real_gw = gw_out.as_deref().and_then(|s| { - // "default via 192.168.1.1 dev eth0" -> "192.168.1.1" - s.split_whitespace().skip_while(|w| *w != "via").nth(1).map(|s| s.to_string()) + s.split_whitespace() + .skip_while(|w| *w != "via") + .nth(1) + .map(|s| s.to_string()) }); let real_dev = gw_out.as_deref().and_then(|s| { - s.split_whitespace().skip_while(|w| *w != "dev").nth(1).map(|s| s.to_string()) + s.split_whitespace() + .skip_while(|w| *w != "dev") + .nth(1) + .map(|s| s.to_string()) }); - // Add exclusion route for server IP via real gateway (bypass TUN) if let (Some(ref gw), Some(ref dev)) = (&real_gw, &real_dev) { - let _ = Command::new("ip").args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev]).output(); + // Server IP bypass + let _ = Command::new("ip") + .args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev]) + .output(); + // Configured IP exclusions + for ip_str in &config.exclusions.ips { + let host = ip_str.split('/').next().unwrap_or(ip_str); + let route = if ip_str.contains('/') { ip_str.as_str() } else { &format!("{}/32", host) }; + let _ = Command::new("ip") + .args(["route", "add", route, "via", gw, "dev", dev]) + .output(); + } } - // Add default route through TUN (lower metric to take priority) - let _ = Command::new("ip").args(["route", "add", "default", "via", "10.1.0.1", "dev", "ostp_tun", "metric", "10"]).output(); + // Default route through TUN + let _ = Command::new("ip") + .args(["route", "add", "default", "via", "10.1.0.1", "dev", "ostp_tun", "metric", "10"]) + .output(); } + // ── 7. Build smoltcp network stack ──────────────────────────────────────── let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default() - .stack_buffer_size(100000) - .tcp_buffer_size(100000) - .udp_buffer_size(100000) + .stack_buffer_size(100_000) + .tcp_buffer_size(100_000) + .udp_buffer_size(100_000) .enable_tcp(true) .enable_udp(true) .mtu(config.ostp.mtu) @@ -155,6 +283,7 @@ pub async fn run_native_tunnel( } }); + // ── 8. Wire TUN ↔ smoltcp stack ─────────────────────────────────────────── let (mut stack_sink, mut stack_stream) = stack.split(); let (mut tun_read, mut tun_write) = tokio::io::split(dev); @@ -172,8 +301,7 @@ pub async fn run_native_tunnel( } } Err(e) => { - tracing::debug!("tun_read error: {}", e); - // continue reading + tracing::debug!("tun_read error: {e}"); } } } @@ -182,58 +310,218 @@ pub async fn run_native_tunnel( let mut stack_to_tun = tokio::spawn(async move { while let Some(Ok(frame)) = stack_stream.next().await { if let Err(e) = tun_write.write(&frame).await { - tracing::debug!("tun_write error: {}", e); + tracing::debug!("tun_write error: {e}"); } } }); - let udp_proxy_addr = config.local_proxy.bind_addr.clone(); - let debug_udp = config.debug; + // ── 9. UDP: forward everything through OSTP proxy ───────────────────────── + // UDP exclusions are handled at the routing table level (step 5), so + // UDP packets for excluded IPs never reach smoltcp at all. + let udp_proxy_addr = { + let mut a = config.local_proxy.bind_addr.clone(); + if a.starts_with("0.0.0.0:") { + a = a.replace("0.0.0.0:", "127.0.0.1:"); + } + a + }; + let debug_udp = debug; let mut udp_proxy_task = tokio::spawn(async move { if let Some(udp_sock) = udp_socket { super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await; } }); - let proxy_addr = config.local_proxy.bind_addr.clone(); - let mut tcp_accept_task = tokio::spawn(async move { - if let Some(mut listener) = tcp_listener { - while let Some((mut stream, _local, remote)) = listener.next().await { - let proxy_addr = proxy_addr.clone(); - tokio::spawn(async move { - if debug { tracing::info!("Native TUN intercepted TCP to {}", remote); } - if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await { - // SOCKS5 bypass handshake locally (loopback) - if socks.write_all(&[5, 1, 0]).await.is_err() { return; } - let mut buf = [0u8; 2]; - if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; } - - let ip = remote.ip(); - let port = remote.port(); - let mut req = vec![5, 1, 0]; - match ip { - std::net::IpAddr::V4(v4) => { - req.push(1); - req.extend_from_slice(&v4.octets()); - } - std::net::IpAddr::V6(v6) => { - req.push(4); - req.extend_from_slice(&v6.octets()); - } - } - req.extend_from_slice(&port.to_be_bytes()); - if socks.write_all(&req).await.is_err() { return; } - - let mut rep = [0u8; 10]; - if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; } + // ── 10. TCP: forward to OSTP proxy (with domain-level bypass via SNI) ───── + // + // For IP-based exclusions: handled by routing table → packets never arrive here. + // For domain-based exclusions: The IP is already in routing table (pre-resolved in + // step 3), so most traffic won't arrive. As a belt-and-suspenders fallback, + // we also sniff TLS SNI and bypass if it matches — this covers CDN cases where + // the IP wasn't known at startup. + // + // For bypassed connections we bind the outgoing socket to the physical interface + // (IP_UNICAST_IF) so it goes out via the real NIC, not TUN. - let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await; - } - }); + let proxy_addr_tcp = { + let mut a = config.local_proxy.bind_addr.clone(); + if a.starts_with("0.0.0.0:") { + a = a.replace("0.0.0.0:", "127.0.0.1:"); + } + a + }; + + // Build exclusion matcher for SNI-based domain bypass (fallback / CDN handling) + let current_exclusions = exclusions_rx.borrow().clone(); + let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None); + let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher)); + + let matcher_clone = matcher_arc.clone(); + tokio::spawn(async move { + while let Ok(_) = exclusions_rx.changed().await { + let current = exclusions_rx.borrow().clone(); + let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None); + *matcher_clone.write().await = new_matcher; + if debug { + tracing::info!("Desktop TUN exclusions hot-reloaded"); } } }); + // Physical interface index — Some on Windows, None everywhere else + #[cfg(target_os = "windows")] + let phys_if_for_bypass: Option = Some(phys_if); + #[cfg(not(target_os = "windows"))] + let phys_if_for_bypass: Option = None; + + // Linux: physical interface name for SO_BINDTODEVICE + #[cfg(target_os = "linux")] + let linux_phys_name = crate::tunnel::proxy::get_linux_physical_if_name(); + #[cfg(not(target_os = "linux"))] + let linux_phys_name: Option = None; + let _ = &linux_phys_name; // suppress unused warning on Windows + + let mut tcp_accept_task = tokio::spawn(async move { + let Some(mut listener) = tcp_listener else { return; }; + + while let Some((mut stream, local, remote)) = listener.next().await { + let proxy_addr = proxy_addr_tcp.clone(); + let matcher_arc = matcher_arc.clone(); + #[cfg(target_os = "linux")] + let lin_name = linux_phys_name.clone(); + + tokio::spawn(async move { + let matcher = matcher_arc.read().await.clone(); + if debug { + tracing::info!("TUN TCP {local} → {remote}"); + } + + // ── Sniff TLS ClientHello for SNI ───────────────────────────── + let mut sniff_buf = [0u8; 2048]; + let sniff_len = + match tokio::time::timeout( + std::time::Duration::from_millis(100), + stream.read(&mut sniff_buf), + ) + .await + { + Ok(Ok(n)) => n, + _ => 0, + }; + + // ── Decide: bypass or tunnel? ───────────────────────────────── + let mut should_bypass = false; + + // 1. SNI domain check (belt-and-suspenders for CDNs / late-resolved IPs) + if sniff_len > 0 { + if let Some(sni) = + crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len]) + { + if debug { + tracing::info!("TUN SNI: {sni}"); + } + if matcher.match_domain(&sni) { + if debug { + tracing::info!("TUN BYPASS (SNI domain): {sni} → {remote}"); + } + should_bypass = true; + } + } + } + + // 2. Destination IP CIDR check (for IPs not in routing table / IPv6) + if !should_bypass && matcher.match_ip(&remote.ip()) { + if debug { + tracing::info!("TUN BYPASS (IP match): {remote}"); + } + should_bypass = true; + } + + // ── Bypass path: direct TCP bypassing TUN ───────────────────── + if should_bypass { + let socket = match remote { + std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(), + std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(), + }; + let Ok(socket) = socket else { return; }; + + // Bind to physical interface so packets don't loop back into TUN + #[cfg(target_os = "windows")] + if let Some(idx) = phys_if_for_bypass { + if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface( + &socket, + remote.is_ipv6(), + idx, + ) { + tracing::warn!("bind_socket_to_interface failed: {e}"); + } + } + #[cfg(target_os = "linux")] + if let Some(ref name) = lin_name { + let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name); + } + + match tokio::time::timeout( + std::time::Duration::from_secs(10), + socket.connect(remote), + ) + .await + { + Ok(Ok(mut direct)) => { + if sniff_len > 0 { + if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() { + return; + } + } + let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await; + } + _ => { + tracing::debug!("Direct bypass connect to {remote} failed"); + } + } + return; + } + + // ── Tunnel path: forward via local OSTP SOCKS5 proxy ────────── + let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else { + return; + }; + + // SOCKS5 handshake (no auth) + if socks.write_all(&[5, 1, 0]).await.is_err() { return; } + let mut buf2 = [0u8; 2]; + if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 { + return; + } + + // CONNECT request + let mut req = vec![5u8, 1, 0]; + match remote.ip() { + std::net::IpAddr::V4(v4) => { + req.push(1); + req.extend_from_slice(&v4.octets()); + } + std::net::IpAddr::V6(v6) => { + req.push(4); + req.extend_from_slice(&v6.octets()); + } + } + req.extend_from_slice(&remote.port().to_be_bytes()); + if socks.write_all(&req).await.is_err() { return; } + + let mut rep = [0u8; 10]; + if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; } + + // Replay sniffed bytes + if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() { + return; + } + + let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await; + }); + } + }); + tracing::info!("NATIVE TUN tunnel active."); tokio::select! { @@ -246,45 +534,81 @@ pub async fn run_native_tunnel( } tracing::info!("Deactivating NATIVE TUN tunnel..."); - // Cleanup routes + + // ── Cleanup ─────────────────────────────────────────────────────────────── #[cfg(target_os = "windows")] { const CREATE_NO_WINDOW: u32 = 0x08000000; - let cleanup_script = format!( - "$remote_ip = '{}'\n\ - Remove-NetRoute -DestinationPrefix \"$remote_ip/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\ - Remove-NetFirewallRule -DisplayName 'OSTP Tunnel*' -ErrorAction SilentlyContinue\n\ - netsh interface ipv4 set dnsservers name=\"ostp_tun\" source=dhcp 2>$null\n", - server_ip_str - ); - let _ = Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .args(["-NoProfile", "-Command", &cleanup_script]) - .output(); + + // Remove all bypass /32 host routes we added + super::windows_route::sys::remove_bypass_routes(&bypass_routes); + tracing::info!("Removed {} bypass routes.", bypass_routes.len()); + + let is_kill_switch = config.kill_switch; + let _ = tokio::task::spawn_blocking(move || { + let _ = Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .args(["advfirewall", "firewall", "delete", "rule", "name=OSTP Tunnel In"]) + .output(); + let _ = Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .args(["advfirewall", "firewall", "delete", "rule", "name=OSTP Tunnel Out"]) + .output(); + let _ = Command::new("netsh") + .creation_flags(CREATE_NO_WINDOW) + .args(["interface", "ipv4", "set", "dnsservers", + "name=ostp_tun", "source=dhcp"]) + .output(); + if is_kill_switch { + let _ = Command::new("route") + .creation_flags(CREATE_NO_WINDOW) + .args(["delete", "0.0.0.0", "mask", "0.0.0.0", "127.0.0.1"]) + .output(); + } + }); } #[cfg(target_os = "linux")] { - // Remove default route via TUN and server exclusion route let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output(); - let _ = Command::new("ip").args(["route", "del", &format!("{}/32", server_ip_str)]).output(); + let _ = Command::new("ip") + .args(["route", "del", &format!("{}/32", server_ip_str)]) + .output(); + for ip_str in &config.exclusions.ips { + let host = ip_str.split('/').next().unwrap_or(ip_str); + let route = if ip_str.contains('/') { + ip_str.as_str().to_string() + } else { + format!("{}/32", host) + }; + let _ = Command::new("ip").args(["route", "del", &route]).output(); + } } Ok(()) } +// ────────────────────────────────────────────────────────────────────────────── +// Stub for unsupported platforms +// ────────────────────────────────────────────────────────────────────────────── + #[cfg(not(any(target_os = "windows", target_os = "linux")))] pub async fn run_native_tunnel( _config: crate::config::ClientConfig, _shutdown: watch::Receiver, ) -> Result<()> { - Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux currently")) + Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux")) } +// ────────────────────────────────────────────────────────────────────────────── +// Android: TUN from file-descriptor (opened by VpnService) +// ────────────────────────────────────────────────────────────────────────────── + #[cfg(target_os = "android")] pub async fn run_native_tunnel_from_fd( config: crate::config::ClientConfig, mut shutdown: watch::Receiver, + mut exclusions_rx: watch::Receiver, fd: i32, ) -> Result<()> { use netstack_smoltcp::StackBuilder; @@ -304,16 +628,16 @@ pub async fn run_native_tunnel_from_fd( let read_fd = unsafe { libc::dup(fd) }; if read_fd < 0 { - return Err(anyhow::anyhow!("Failed to dup tun fd for reading")); + return Err(anyhow!("Failed to dup tun fd for reading")); } - + let file = unsafe { std::fs::File::from_raw_fd(read_fd) }; let tun_stream = tokio::io::unix::AsyncFd::new(file)?; let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default() - .stack_buffer_size(100000) - .tcp_buffer_size(100000) - .udp_buffer_size(100000) + .stack_buffer_size(100_000) + .tcp_buffer_size(100_000) + .udp_buffer_size(100_000) .enable_tcp(true) .enable_udp(true) .mtu(config.ostp.mtu) @@ -334,25 +658,29 @@ pub async fn run_native_tunnel_from_fd( Ok(g) => g, Err(_) => break, }; - let n = match guard.try_io(|inner| { - let res = unsafe { libc::read(inner.as_raw_fd(), buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + let res = unsafe { + libc::read( + inner.as_raw_fd(), + buf.as_mut_ptr() as *mut libc::c_void, + buf.len(), + ) + }; if res < 0 { let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::WouldBlock { Err(err) } else { - // EINTR or other transient error — treat as zero (will continue) Ok(0_isize) } } else { - Ok(res as isize) + Ok(res) } }) { Ok(Ok(n)) if n > 0 => n as usize, - Ok(Ok(_)) => continue, // 0 = EINTR or transient error, try again - Ok(Err(_)) => continue, // WouldBlock retry - Err(_would_block) => continue, + Ok(Ok(_)) => continue, + Ok(Err(_)) => continue, + Err(_) => continue, }; let frame = buf[..n].to_vec(); @@ -366,7 +694,7 @@ pub async fn run_native_tunnel_from_fd( let write_fd = unsafe { libc::dup(fd) }; if write_fd < 0 { - return Err(anyhow!("Failed to dup tun fd")); + return Err(anyhow!("Failed to dup tun fd for writing")); } unsafe { let flags = libc::fcntl(write_fd, libc::F_GETFL); @@ -385,9 +713,14 @@ pub async fn run_native_tunnel_from_fd( Ok(g) => g, Err(_) => break, }; - let res = guard.try_io(|inner| { - let res = unsafe { libc::write(inner.as_raw_fd(), frame[written..].as_ptr() as *const libc::c_void, frame.len() - written) }; + let res = unsafe { + libc::write( + inner.as_raw_fd(), + frame[written..].as_ptr() as *const libc::c_void, + frame.len() - written, + ) + }; if res < 0 { let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::WouldBlock { @@ -399,11 +732,9 @@ pub async fn run_native_tunnel_from_fd( Ok(res) } }); - match res { Ok(Ok(n)) if n > 0 => written += n as usize, - Ok(Ok(n)) if n == 0 => break, - Ok(Ok(_)) => break, // n < 0, error writing, drop this frame + Ok(Ok(_)) => break, Ok(Err(_)) => break, Err(_) => continue, } @@ -411,111 +742,157 @@ pub async fn run_native_tunnel_from_fd( } }); - - let mut proxy_addr = config.local_proxy.bind_addr.clone(); if proxy_addr.starts_with("0.0.0.0:") { proxy_addr = proxy_addr.replace("0.0.0.0:", "127.0.0.1:"); } let udp_proxy_addr = proxy_addr.clone(); - let debug_udp = config.debug; + let debug_udp = debug; let mut udp_proxy_task = tokio::spawn(async move { if let Some(udp_sock) = udp_socket { super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await; } }); - let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(&config.exclusions, None, None); + let current_exclusions = exclusions_rx.borrow().clone(); + let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None); + let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher)); + + let matcher_clone = matcher_arc.clone(); + tokio::spawn(async move { + while let Ok(_) = exclusions_rx.changed().await { + let current = exclusions_rx.borrow().clone(); + let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None); + *matcher_clone.write().await = new_matcher; + if debug { + tracing::info!("Android TUN exclusions hot-reloaded"); + } + } + }); let mut tcp_accept_task = tokio::spawn(async move { - if let Some(mut listener) = tcp_listener { - while let Some((mut stream, local, remote)) = listener.next().await { - let proxy_addr = proxy_addr.clone(); - let matcher = matcher.clone(); - tokio::spawn(async move { - if debug { tracing::info!("Native TUN intercepted TCP {local} -> {remote}"); } + let Some(mut listener) = tcp_listener else { return; }; - // Peak first chunk to see SNI - let mut sniff_buf = [0u8; 1500]; - let sniff_len = match tokio::time::timeout(std::time::Duration::from_millis(50), stream.read(&mut sniff_buf)).await { + while let Some((mut stream, local, remote)) = listener.next().await { + let proxy_addr = proxy_addr.clone(); + let matcher_arc = matcher_arc.clone(); + + tokio::spawn(async move { + let matcher = matcher_arc.read().await.clone(); + + if debug { + tracing::info!("Android TUN TCP {local} → {remote}"); + } + + // Sniff SNI + let mut sniff_buf = [0u8; 2048]; + let sniff_len = + match tokio::time::timeout( + std::time::Duration::from_millis(100), + stream.read(&mut sniff_buf), + ) + .await + { Ok(Ok(n)) => n, - _ => 0, // Timeout or error + _ => 0, }; - let mut should_bypass = false; + let mut should_bypass = false; - // 1. Check SNI - if sniff_len > 0 { - if let Some(sni) = crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len]) { - if debug { tracing::info!("Native TUN sniffed SNI: {}", sni); } - if matcher.match_domain(&sni) { - should_bypass = true; - } - } - } - - // 2. Check Process - if !should_bypass { - if let Some(exe) = crate::tunnel::process_lookup::get_process_name_from_port(local.port()) { - if debug { tracing::info!("Native TUN source port {} maps to EXE: {}", local.port(), exe); } - if matcher.match_process(&exe) { - should_bypass = true; - } - } - } - - // 3. Check Target IP - if !should_bypass { - if matcher.match_ip(&remote.ip()) { + // 1. SNI domain + if sniff_len > 0 { + if let Some(sni) = + crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len]) + { + if debug { tracing::info!("Android TUN SNI: {sni}"); } + if matcher.match_domain(&sni) { should_bypass = true; } } + } - if should_bypass { - if debug { tracing::info!("Native TUN BYPASS matched for {}", remote); } - if let Ok(mut direct) = tokio::time::timeout(std::time::Duration::from_secs(5), tokio::net::TcpStream::connect(remote)).await.unwrap_or(Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "Direct connect timeout"))) { + // 2. Process (Android: /proc/net lookup) + if !should_bypass { + if let Some(exe) = + crate::tunnel::process_lookup::get_process_name_from_port(local.port()) + { + if debug { + tracing::info!("Android TUN port {} → EXE: {}", local.port(), exe); + } + if matcher.match_process(&exe) { + should_bypass = true; + } + } + } + + // 3. IP CIDR + if !should_bypass && matcher.match_ip(&remote.ip()) { + should_bypass = true; + } + + // Bypass: connect directly (Android VPN service already protects the socket + // from re-entering the TUN through VpnService.protect()) + if should_bypass { + if debug { + tracing::info!("Android TUN BYPASS: {remote}"); + } + let socket = match remote { + std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(), + std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(), + }; + let Ok(socket) = socket else { return; }; + + match tokio::time::timeout( + std::time::Duration::from_secs(10), + socket.connect(remote), + ) + .await + { + Ok(Ok(mut direct)) => { if sniff_len > 0 { - let _ = direct.write_all(&sniff_buf[..sniff_len]).await; + if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() { + return; + } } let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await; } - return; - } - - if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await { - if socks.write_all(&[5, 1, 0]).await.is_err() { return; } - let mut buf = [0u8; 2]; - if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; } - - let ip = remote.ip(); - let port = remote.port(); - let mut req = vec![5, 1, 0]; - match ip { - std::net::IpAddr::V4(v4) => { - req.push(1); - req.extend_from_slice(&v4.octets()); - } - std::net::IpAddr::V6(v6) => { - req.push(4); - req.extend_from_slice(&v6.octets()); - } + _ => { + tracing::debug!("Android bypass connect to {remote} failed"); } - req.extend_from_slice(&port.to_be_bytes()); - if socks.write_all(&req).await.is_err() { return; } - - let mut rep = [0u8; 10]; - if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; } - - // Write sniffed buffer to socks - if sniff_len > 0 { - if socks.write_all(&sniff_buf[..sniff_len]).await.is_err() { return; } - } - - let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await; } - }); - } + return; + } + + // Tunnel via SOCKS5 proxy + let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else { + return; + }; + if socks.write_all(&[5, 1, 0]).await.is_err() { return; } + let mut buf2 = [0u8; 2]; + if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 { + return; + } + let mut req = vec![5u8, 1, 0]; + match remote.ip() { + std::net::IpAddr::V4(v4) => { + req.push(1); + req.extend_from_slice(&v4.octets()); + } + std::net::IpAddr::V6(v6) => { + req.push(4); + req.extend_from_slice(&v6.octets()); + } + } + req.extend_from_slice(&remote.port().to_be_bytes()); + if socks.write_all(&req).await.is_err() { return; } + let mut rep = [0u8; 10]; + if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; } + if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() { + return; + } + let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await; + }); } }); @@ -530,7 +907,7 @@ pub async fn run_native_tunnel_from_fd( _ = &mut tcp_accept_task => {} } - tracing::info!("Deactivating NATIVE TUN tunnel..."); + tracing::info!("NATIVE TUN (Android) deactivated."); Ok(()) } @@ -542,4 +919,3 @@ pub async fn run_native_tunnel_from_fd( ) -> Result<()> { Err(anyhow!("Native TUN from FD is only supported on Android")) } - diff --git a/ostp-client/src/tunnel/process_lookup.rs b/ostp-client/src/tunnel/process_lookup.rs index b140eb2..ed2e1ee 100644 --- a/ostp-client/src/tunnel/process_lookup.rs +++ b/ostp-client/src/tunnel/process_lookup.rs @@ -1,6 +1,6 @@ #[cfg(target_os = "windows")] pub fn get_process_name_from_port(port: u16) -> Option { - use winapi::shared::minwindef::{DWORD, ULONG}; + use winapi::shared::minwindef::ULONG; use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER; use winapi::um::iphlpapi::GetExtendedTcpTable; use winapi::shared::tcpmib::{MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID}; @@ -47,6 +47,54 @@ pub fn get_process_name_from_port(port: u16) -> Option { None } +#[cfg(target_os = "windows")] +pub fn get_process_name_from_port_udp(port: u16) -> Option { + use winapi::shared::minwindef::ULONG; + use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER; + use winapi::um::iphlpapi::GetExtendedUdpTable; + use winapi::shared::udpmib::{MIB_UDPTABLE_OWNER_PID, MIB_UDPROW_OWNER_PID}; + + let mut size: ULONG = 0; + let table_class = 1; // UDP_TABLE_OWNER_PID + let mut table = vec![0u8; 1024]; + + unsafe { + let mut ret = GetExtendedUdpTable( + table.as_mut_ptr() as *mut _, + &mut size, + 0, + 2, // AF_INET + table_class, + 0, + ); + + if ret == ERROR_INSUFFICIENT_BUFFER { + table.resize(size as usize, 0); + ret = GetExtendedUdpTable( + table.as_mut_ptr() as *mut _, + &mut size, + 0, + 2, // AF_INET + table_class, + 0, + ); + } + + if ret == 0 { + let udp_table = &*(table.as_ptr() as *const MIB_UDPTABLE_OWNER_PID); + let row_ptr = &udp_table.table[0] as *const MIB_UDPROW_OWNER_PID; + for i in 0..udp_table.dwNumEntries { + let row = &*row_ptr.add(i as usize); + let local_port = u16::from_be(row.dwLocalPort as u16); + if local_port == port { + return get_process_name_from_pid(row.dwOwningPid); + } + } + } + } + None +} + #[cfg(target_os = "windows")] fn get_process_name_from_pid(pid: u32) -> Option { use winapi::um::processthreadsapi::OpenProcess; @@ -140,3 +188,8 @@ pub fn get_process_name_from_port(port: u16) -> Option { pub fn get_process_name_from_port(_port: u16) -> Option { None } + +#[cfg(not(target_os = "windows"))] +pub fn get_process_name_from_port_udp(port: u16) -> Option { + get_process_name_from_port(port) +} diff --git a/ostp-client/src/tunnel/proxy.rs b/ostp-client/src/tunnel/proxy.rs index b0a267e..09eaa73 100644 --- a/ostp-client/src/tunnel/proxy.rs +++ b/ostp-client/src/tunnel/proxy.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use crate::tunnel::exclusion::{ExclusionMatcher, Cidr}; +use crate::tunnel::exclusion::ExclusionMatcher; use anyhow::{anyhow, Context, Result}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream, UdpSocket}; @@ -29,7 +29,7 @@ extern "system" { } #[cfg(target_os = "windows")] -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; if is_ipv6 { let optval = if_index; @@ -64,7 +64,7 @@ fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: } #[cfg(target_os = "linux")] -fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> { +pub fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> { let fd = socket.as_raw_fd(); let mut if_name_bytes = if_name.as_bytes().to_vec(); if_name_bytes.push(0); @@ -83,31 +83,18 @@ fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Re Ok(()) } -fn get_windows_physical_if_index() -> Option { +pub fn get_windows_physical_if_index() -> Option { #[cfg(target_os = "windows")] { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let output = std::process::Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .args([ - "-NoProfile", - "-Command", - "Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object { $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' } | Sort-Object RouteMetric | Select-Object -ExpandProperty InterfaceIndex -First 1" - ]) - .output() - .ok()?; - if output.status.success() { - let s = String::from_utf8_lossy(&output.stdout); - if let Ok(index) = s.trim().parse::() { - return Some(index); - } - } + return super::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx); + } + #[cfg(not(target_os = "windows"))] + { + None } - None } -fn get_linux_physical_if_name() -> Option { +pub fn get_linux_physical_if_name() -> Option { #[cfg(target_os = "linux")] { let output = std::process::Command::new("ip") @@ -208,7 +195,7 @@ async fn create_udp_socket_bypassing_tun( pub async fn run_local_socks5_proxy( cfg: LocalProxyConfig, ostp: OstpConfig, - exclusions: ExclusionConfig, + mut exclusions_rx: watch::Receiver, debug: bool, mut shutdown: watch::Receiver, proxy_events_tx: mpsc::Sender, @@ -234,7 +221,8 @@ pub async fn run_local_socks5_proxy( tracing::info!("Local proxy physical interface name: {:?}", physical_if_name); } - let matcher = ExclusionMatcher::new(&exclusions, physical_if_index, physical_if_name.clone()); + let mut current_exclusions = exclusions_rx.borrow().clone(); + let mut matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone()); let (connect_tx, mut connect_rx) = mpsc::channel(128); let max_chunk = ostp.mtu.saturating_sub(150).max(512); @@ -248,6 +236,13 @@ pub async fn run_local_socks5_proxy( break; } } + Ok(_) = exclusions_rx.changed() => { + current_exclusions = exclusions_rx.borrow().clone(); + matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone()); + if debug { + tracing::info!("Local proxy exclusions hot-reloaded"); + } + } accepted = listener.accept() => { let (socket, _) = accepted?; let stream_id = next_stream_id; diff --git a/ostp-client/src/tunnel/windows_route.rs b/ostp-client/src/tunnel/windows_route.rs new file mode 100644 index 0000000..13ca88f --- /dev/null +++ b/ostp-client/src/tunnel/windows_route.rs @@ -0,0 +1,203 @@ +/// Windows routing table utilities for OSTP split tunneling. +/// +/// The approach used here matches how sing-box/v2rayN implement split tunneling on Windows: +/// - A high-priority default route (metric=1) via ostp_tun captures ALL traffic. +/// - Per-host /32 routes via the REAL gateway with an even lower metric (=0, auto-managed by OS) +/// force excluded IPs to bypass the TUN. +/// - Process-based exclusions are NOT supported via pure routing — they would require WFP. +/// Instead, we surface a diagnostic warning in logs. + +#[cfg(target_os = "windows")] +pub mod sys { + use std::mem; + use std::net::Ipv4Addr; + use std::ptr; + + use winapi::shared::ipmib::{MIB_IPFORWARDROW, MIB_IPFORWARDTABLE}; + use winapi::shared::minwindef::{DWORD, ULONG}; + use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR}; + use winapi::um::iphlpapi::{ + CreateIpForwardEntry, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable, + }; + use winapi::um::iptypes::{ + GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES, + }; + use winapi::shared::ws2def::AF_INET; + + fn ipv4_to_dword(ip: Ipv4Addr) -> DWORD { + u32::from_ne_bytes(ip.octets()) + } + + fn dword_to_ipv4(dw: DWORD) -> Ipv4Addr { + Ipv4Addr::from(dw.to_ne_bytes()) + } + + /// Returns the (gateway_ip, interface_index) of the physical default IPv4 route, + /// excluding any route that goes through an interface named "ostp_tun". + pub fn get_default_ipv4_route() -> Option<(Ipv4Addr, u32)> { + // Enumerate adapters to find the ostp_tun interface index, so we can skip it. + let tun_index = get_interface_index("ostp_tun"); + + unsafe { + let mut size: ULONG = 0; + let mut ret = GetIpForwardTable(ptr::null_mut(), &mut size, 0); + if ret != ERROR_INSUFFICIENT_BUFFER { + return None; + } + + let mut buf: Vec = vec![0; size as usize]; + let table = buf.as_mut_ptr() as *mut MIB_IPFORWARDTABLE; + + ret = GetIpForwardTable(table, &mut size, 0); + if ret != NO_ERROR { + return None; + } + + let entries = std::slice::from_raw_parts((*table).table.as_ptr(), (*table).dwNumEntries as usize); + + let mut best_gw = None; + let mut best_metric = u32::MAX; + let mut best_ifindex = 0u32; + + for row in entries { + // Only consider default routes (0.0.0.0/0) + if row.dwForwardDest == 0 && row.dwForwardMask == 0 { + // Skip the TUN interface + if let Some(ti) = tun_index { + if row.dwForwardIfIndex == ti { + continue; + } + } + let metric = row.dwForwardMetric1; + if metric < best_metric { + best_metric = metric; + best_gw = Some(dword_to_ipv4(row.dwForwardNextHop)); + best_ifindex = row.dwForwardIfIndex; + } + } + } + + best_gw.map(|gw| (gw, best_ifindex)) + } + } + + pub fn add_ipv4_route( + dest: Ipv4Addr, + mask: Ipv4Addr, + nexthop: Ipv4Addr, + if_index: u32, + metric: u32, + ) -> Result<(), String> { + let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() }; + row.dwForwardDest = ipv4_to_dword(dest); + row.dwForwardMask = ipv4_to_dword(mask); + row.dwForwardNextHop = ipv4_to_dword(nexthop); + row.dwForwardIfIndex = if_index; + row.ForwardType = if nexthop == Ipv4Addr::UNSPECIFIED || dest == nexthop { 3 } else { 4 }; + row.ForwardProto = 3; // MIB_IPPROTO_NETMGMT + row.dwForwardMetric1 = metric; + + let ret = unsafe { CreateIpForwardEntry(&mut row) }; + if ret == NO_ERROR { + Ok(()) + } else { + Err(format!("CreateIpForwardEntry failed: {}", ret)) + } + } + + pub fn delete_ipv4_route( + dest: Ipv4Addr, + mask: Ipv4Addr, + nexthop: Ipv4Addr, + if_index: u32, + ) -> Result<(), String> { + let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() }; + row.dwForwardDest = ipv4_to_dword(dest); + row.dwForwardMask = ipv4_to_dword(mask); + row.dwForwardNextHop = ipv4_to_dword(nexthop); + row.dwForwardIfIndex = if_index; + + let ret = unsafe { DeleteIpForwardEntry(&mut row) }; + if ret == NO_ERROR || ret == 2 { + Ok(()) + } else { + Err(format!("DeleteIpForwardEntry failed: {}", ret)) + } + } + + pub fn get_interface_index(name: &str) -> Option { + unsafe { + let mut size: ULONG = 0; + let flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER; + let mut ret = GetAdaptersAddresses( + AF_INET as u32, + flags, + ptr::null_mut(), + ptr::null_mut(), + &mut size, + ); + if ret != ERROR_INSUFFICIENT_BUFFER { + return None; + } + + let mut buf: Vec = vec![0; size as usize]; + let addresses = buf.as_mut_ptr() as *mut IP_ADAPTER_ADDRESSES; + + ret = GetAdaptersAddresses(AF_INET as u32, flags, ptr::null_mut(), addresses, &mut size); + if ret != NO_ERROR { + return None; + } + + let mut curr = addresses; + while !curr.is_null() { + let friendly_name_ptr = (*curr).FriendlyName; + if !friendly_name_ptr.is_null() { + let mut len = 0; + while *friendly_name_ptr.offset(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(friendly_name_ptr, len as usize); + let friendly_name = String::from_utf16_lossy(slice); + if friendly_name == name { + return Some((*(*curr).u.s()).IfIndex); + } + } + curr = (*curr).Next; + } + None + } + } + + /// 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. + pub fn add_bypass_routes( + ips: &[Ipv4Addr], + gw: Ipv4Addr, + if_index: u32, + metric: u32, + ) -> Vec<(Ipv4Addr, Ipv4Addr, u32)> { + let mut added = Vec::new(); + for &ip in ips { + let mask = Ipv4Addr::new(255, 255, 255, 255); + match add_ipv4_route(ip, mask, gw, if_index, metric) { + Ok(()) => { + added.push((ip, gw, if_index)); + } + Err(e) => { + // 87 = ERROR_INVALID_PARAMETER (route may already exist) + tracing::debug!("bypass route add {ip}/32 via {gw}: {e}"); + } + } + } + added + } + + /// 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); + } + } +} diff --git a/ostp-control/mock-server.js b/ostp-control/mock-server.js new file mode 100644 index 0000000..0e10930 --- /dev/null +++ b/ostp-control/mock-server.js @@ -0,0 +1,180 @@ +import http from 'http'; + +const server = http.createServer((req, res) => { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + res.setHeader('Content-Type', 'application/json'); + + const url = new URL(req.url, `http://${req.headers.host}`); + const path = url.pathname; + + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', () => { + console.log(`[${req.method}] ${path}`, body ? body : ''); + + if (path === '/api/login') { + try { + const payload = JSON.parse(body); + if (payload.username === 'test' && payload.password === 'test') { + res.writeHead(200); + res.end(JSON.stringify({ ok: true, data: { token: "mock-token-12345" } })); + return; + } + } catch(e) {} + res.writeHead(401); + res.end(JSON.stringify({ ok: false, error: "Invalid credentials" })); + return; + } + + if (path === '/api/server/status') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: { + version: "1.0.0-mock", + uptime_seconds: 12345, + active_users: 1, + total_users: 2 + } + })); + return; + } + + if (path === '/api/users') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: [ + { + access_key: "mock-client-1", + name: "Test Client", + limit_bytes: null, + bytes_up: 1000, + bytes_down: 5000, + connections: 1, + online: true, + last_seen: Math.floor(Date.now() / 1000) + }, + { + access_key: "mock-offline-2", + name: "Offline User", + limit_bytes: 10737418240, // 10 GB + bytes_up: 50000000, + bytes_down: 150000000, + connections: 0, + online: false, + last_seen: Math.floor(Date.now() / 1000) - 86400 + } + ] + })); + return; + } + + if (path === '/api/users/bulk') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: [ + "mock-new-key-1", + "mock-new-key-2" + ] + })); + return; + } + + if (path === '/api/router/rules') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: [ + { action: "proxy", domain_suffix: ["google.com", "youtube.com"], ip_cidr: [] }, + { action: "block", domain_suffix: ["ads.example.com"], ip_cidr: [] }, + { action: "direct", domain_suffix: [], ip_cidr: ["192.168.1.0/24"] } + ] + })); + return; + } + + if (path === '/api/audit' || path === '/api/logs/audit') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: [ + { timestamp: Math.floor(Date.now() / 1000), message: "Mock server started", message_ru: "Мок сервер запущен", success: true } + ] + })); + return; + } + + if (path === '/api/server/config') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: { + server: { bind: "0.0.0.0:53210" }, + api: { enabled: true, user: "test", port: 53210 }, + dns: { enabled: true, adblock: true } + } + })); + return; + } + + if (path === '/api/connections') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: [] + })); + return; + } + + if (path === '/api/dns/config') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: { + enabled: true, + doh_upstream: "https://cloudflare-dns.com/dns-query", + adblock_urls: ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"], + custom_domains: { "myrouter.lan": "192.168.1.1" } + } + })); + return; + } + + if (path === '/api/dns/queries') { + res.writeHead(200); + res.end(JSON.stringify({ + ok: true, + data: [ + { timestamp: Math.floor(Date.now() / 1000), domain: "google.com", client_ip: "10.8.0.2", blocked: false }, + { timestamp: Math.floor(Date.now() / 1000) - 5, domain: "ads.example.com", client_ip: "10.8.0.3", blocked: true } + ] + })); + return; + } + + // Default 200 OK for anything else (e.g. POST /api/users, DELETE, etc.) + res.writeHead(200); + res.end(JSON.stringify({ ok: true, data: { success: true, message: "Mock response" } })); + }); +}); + +const PORT = 9090; +server.listen(PORT, () => { + console.log(`\n🚀 Mock API server is running on http://localhost:${PORT}`); + console.log(`👉 Login with: username: test | password: test\n`); +}); diff --git a/ostp-control/package.json b/ostp-control/package.json index eed60c9..a3ac56b 100644 --- a/ostp-control/package.json +++ b/ostp-control/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "mock": "node mock-server.js", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/ostp-control/src/App.tsx b/ostp-control/src/App.tsx index 25f23ed..802a1ee 100644 --- a/ostp-control/src/App.tsx +++ b/ostp-control/src/App.tsx @@ -1,5 +1,5 @@ import { HashRouter as Router, Routes, Route, Link, Navigate, useLocation } from 'react-router-dom'; -import { Activity, Users, Settings, Shield, MoreVertical, RefreshCw, BookOpen, Wrench, History, Globe, LogOut } from 'lucide-react'; +import { Activity, Users, Settings, Shield, MoreVertical, RefreshCw, BookOpen, Wrench, History, Globe, LogOut, Route as RouteIcon } from 'lucide-react'; import { useState, useEffect } from 'react'; import type { ReactNode } from 'react'; @@ -12,6 +12,7 @@ import Tools from './pages/Tools'; import AuditLogs from './pages/AuditLogs'; import Login from './pages/Login'; import Dns from './pages/Dns'; +import Routing from './pages/Routing'; // State and Context import { api } from './lib/api'; @@ -78,6 +79,10 @@ function MainLayout() { {isSidebarOpen && {t('sidebar_wiki')}} + + + {isSidebarOpen && Routing} + {isSidebarOpen && {t('sidebar_dns')}} @@ -147,6 +152,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ostp-control/src/lib/api.ts b/ostp-control/src/lib/api.ts index 061e7c3..7bf8856 100644 --- a/ostp-control/src/lib/api.ts +++ b/ostp-control/src/lib/api.ts @@ -6,6 +6,15 @@ export interface UserStatsSnapshot { limit_bytes: number | null; online: boolean; name?: string | null; + last_seen?: number | null; +} + +export type OutboundAction = 'proxy' | 'direct' | 'block'; + +export interface OutboundRule { + domain_suffix: string[]; + ip_cidr: string[]; + action: OutboundAction; } export interface ServerStatus { @@ -154,4 +163,15 @@ export const api = { refreshDnsBlocklists: () => request('/api/dns/blocklists/refresh', { method: 'POST', }), + + getAuditLogs: () => request('/api/audit'), + createAuditLog: (eventEn: string, eventRu: string, success: boolean) => + request('/api/audit', { method: 'POST', body: JSON.stringify({ eventEn, eventRu, success }) }), + clearAuditLogs: () => request('/api/audit', { method: 'DELETE' }), + + bulkCreateUsers: (count: number, limit_bytes: number | null) => + request('/api/users/bulk', { method: 'POST', body: JSON.stringify({ count, limit_bytes }) }), + + getRouterRules: () => request('/api/router/rules'), + updateRouterRules: (rules: OutboundRule[]) => request('/api/router/rules', { method: 'PUT', body: JSON.stringify(rules) }), }; diff --git a/ostp-control/src/lib/audit.ts b/ostp-control/src/lib/audit.ts index 1a34fed..d014eea 100644 --- a/ostp-control/src/lib/audit.ts +++ b/ostp-control/src/lib/audit.ts @@ -1,3 +1,5 @@ +import { api } from './api'; + export interface AuditLogEntry { id: string; time: string; @@ -6,38 +8,29 @@ export interface AuditLogEntry { success: boolean; } -const AUDIT_LOG_KEY = 'ostp_audit_logs'; - -export function getAuditLogs(): AuditLogEntry[] { +export async function getAuditLogs(): Promise { try { - const raw = localStorage.getItem(AUDIT_LOG_KEY); - return raw ? JSON.parse(raw) : []; - } catch { + return await api.getAuditLogs(); + } catch (e) { + console.error('Failed to get audit logs', e); return []; } } -export function addAuditLog(eventEn: string, eventRu: string, success: boolean) { +export async function addAuditLog(eventEn: string, eventRu: string, success: boolean) { try { - const logs = getAuditLogs(); - const newEntry: AuditLogEntry = { - id: Math.random().toString(36).substring(2, 9), - time: new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }), - eventEn, - eventRu, - success, - }; - // Keep last 100 logs - const updated = [newEntry, ...logs].slice(0, 100); - localStorage.setItem(AUDIT_LOG_KEY, JSON.stringify(updated)); - // Dispatch custom event to notify listeners + await api.createAuditLog(eventEn, eventRu, success); window.dispatchEvent(new Event('ostp_audit_log_added')); } catch (e) { console.error('Failed to write audit log', e); } } -export function clearAuditLogs() { - localStorage.removeItem(AUDIT_LOG_KEY); - window.dispatchEvent(new Event('ostp_audit_log_added')); +export async function clearAuditLogs() { + try { + await api.clearAuditLogs(); + window.dispatchEvent(new Event('ostp_audit_log_added')); + } catch (e) { + console.error('Failed to clear audit logs', e); + } } diff --git a/ostp-control/src/pages/AuditLogs.tsx b/ostp-control/src/pages/AuditLogs.tsx index 03ef95e..8792ab0 100644 --- a/ostp-control/src/pages/AuditLogs.tsx +++ b/ostp-control/src/pages/AuditLogs.tsx @@ -8,8 +8,9 @@ export default function AuditLogs() { const { t, language } = useLanguage(); const [logs, setLogs] = useState([]); - const loadLogs = () => { - setLogs(getAuditLogs()); + const loadLogs = async () => { + const data = await getAuditLogs(); + setLogs(data); }; useEffect(() => { @@ -22,9 +23,9 @@ export default function AuditLogs() { }; }, []); - const handleClear = () => { + const handleClear = async () => { if (confirm(language === 'ru' ? 'Очистить журнал действий?' : 'Clear audit log history?')) { - clearAuditLogs(); + await clearAuditLogs(); } }; diff --git a/ostp-control/src/pages/Clients.tsx b/ostp-control/src/pages/Clients.tsx index 5eb422d..7498155 100644 --- a/ostp-control/src/pages/Clients.tsx +++ b/ostp-control/src/pages/Clients.tsx @@ -1,10 +1,15 @@ import { useState, useEffect, useRef } from 'react'; -import { Users, Plus, Key, Trash2, Edit2, Copy, Search, RefreshCw, X, Share2, ShieldAlert, Download } from 'lucide-react'; import QRCode from 'qrcode'; +import { Users, Plus, Search, RefreshCw, ShieldAlert, Zap } from 'lucide-react'; import { api } from '../lib/api'; import type { UserStatsSnapshot } from '../lib/api'; import { useLanguage } from '../lib/LanguageContext'; import { addAuditLog } from '../lib/audit'; +import { AddClientModal } from './components/AddClientModal'; +import { EditClientModal } from './components/EditClientModal'; +import { ShareClientModal } from './components/ShareClientModal'; +import { ClientsTable } from './components/ClientsTable'; +import { BulkKeysModal } from './components/BulkKeysModal'; export default function Clients() { const { t } = useLanguage(); @@ -16,6 +21,7 @@ export default function Clients() { // Modals state const [showAddModal, setShowAddModal] = useState(false); + const [showBulkModal, setShowBulkModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showShareModal, setShowShareModal] = useState(false); @@ -58,6 +64,27 @@ export default function Clients() { return () => clearInterval(interval); }, []); + const handleBulkGenerate = async (count: number, limitBytes: number | null): Promise => { + try { + const keys = await api.bulkCreateUsers(count, limitBytes); + fetchUsers(false); + addAuditLog( + `Bulk generated ${count} keys`, + `Сгенерирован пакет из ${count} ключей`, + true + ); + return keys; + } catch (err: any) { + setErrorMsg(err.message || 'Failed to bulk generate keys'); + addAuditLog( + `Failed to bulk generate keys: ${err.message || err}`, + `Не удалось сгенерировать ключи: ${err.message || err}`, + false + ); + throw err; + } + }; + const handleAddClient = async (e: React.FormEvent) => { e.preventDefault(); setErrorMsg(null); @@ -266,6 +293,13 @@ export default function Clients() { > + -

{t('cl_add_title')}

- -
-
- - setClientName(e.target.value)} - /> -
- -
- -
- setClientLimit(e.target.value)} - /> - -
-
- -
- - setClientCustomKey(e.target.value)} - /> -
- - -
- - - )} + setShowAddModal(false)} + onSubmit={handleAddClient} + clientName={clientName} + setClientName={setClientName} + clientLimit={clientLimit} + setClientLimit={setClientLimit} + clientLimitUnit={clientLimitUnit} + setClientLimitUnit={setClientLimitUnit} + clientCustomKey={clientCustomKey} + setClientCustomKey={setClientCustomKey} + /> {/* Edit Client Modal */} - {showEditModal && editingUser && ( -
-
- -

{t('cl_edit_title')}

- -
-
- - setEditName(e.target.value)} - /> -
- -
- -
- setEditLimit(e.target.value)} - /> - -
-
- -
- Access Key: {editingUser.access_key} -
- - -
-
-
- )} + { + setShowEditModal(false); + setEditingUser(null); + }} + onSubmit={handleEditClient} + editingUser={editingUser} + editName={editName} + setEditName={setEditName} + editLimit={editLimit} + setEditLimit={setEditLimit} + editLimitUnit={editLimitUnit} + setEditLimitUnit={setEditLimitUnit} + /> {/* Share Connection Modal */} {showShareModal && sharingUser && ( -
-
- {/* Sticky header */} -
-
-

{t('cl_share_title')}

-

{t('cl_share_sub')}

-
- -
+ setShowShareModal(false)} + downloadQr={downloadQr} + copyToClipboard={copyToClipboard} + /> + )} - {/* Scrollable body */} -
-
- -
{sharingUser.name || t('cl_unnamed')}
-
- -
- - {isFetchingLink ? ( -
- - Generating link... -
- ) : ( -
- - -
- )} -
- - {/* QR Code — compact, side layout */} - {!isFetchingLink && shareLink && ( -
-
- -
-
-

{t('cl_share_scan')}

- -
-
- )} - - -
-
-
+ {showBulkModal && ( + setShowBulkModal(false)} + onGenerate={handleBulkGenerate} + /> )} ); diff --git a/ostp-control/src/pages/Routing.tsx b/ostp-control/src/pages/Routing.tsx new file mode 100644 index 0000000..06cb7c1 --- /dev/null +++ b/ostp-control/src/pages/Routing.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect } from 'react'; +import { Route, Plus, Trash2, Save, Activity, ShieldAlert, ShieldCheck, HelpCircle } from 'lucide-react'; +import { api } from '../lib/api'; +import type { OutboundRule, OutboundAction } from '../lib/api'; +import { useLanguage } from '../lib/LanguageContext'; +import { addAuditLog } from '../lib/audit'; + +export default function Routing() { + const { t } = useLanguage(); + const [rules, setRules] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + const fetchRules = async () => { + setIsLoading(true); + try { + const data = await api.getRouterRules(); + setRules(data || []); + setErrorMsg(null); + } catch (err: any) { + setErrorMsg(err.message || 'Failed to fetch routing rules'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchRules(); + }, []); + + const handleSave = async () => { + setIsSaving(true); + setErrorMsg(null); + setSuccessMsg(null); + try { + await api.updateRouterRules(rules); + setSuccessMsg('Routing rules saved successfully'); + addAuditLog('Updated outbound routing rules', 'Обновлены правила маршрутизации исходящего трафика', true); + } catch (err: any) { + setErrorMsg(err.message || 'Failed to save rules'); + addAuditLog(`Failed to update rules: ${err.message || err}`, `Не удалось обновить правила: ${err.message || err}`, false); + } finally { + setIsSaving(false); + } + }; + + const addRule = () => { + setRules([...rules, { action: 'proxy', domain_suffix: [], ip_cidr: [] }]); + }; + + const removeRule = (index: number) => { + const newRules = [...rules]; + newRules.splice(index, 1); + setRules(newRules); + }; + + const updateRuleAction = (index: number, action: OutboundAction) => { + const newRules = [...rules]; + newRules[index].action = action; + setRules(newRules); + }; + + const updateRuleDomains = (index: number, domainsStr: string) => { + const newRules = [...rules]; + newRules[index].domain_suffix = domainsStr.split(',').map(d => d.trim()).filter(Boolean); + setRules(newRules); + }; + + const updateRuleIps = (index: number, ipsStr: string) => { + const newRules = [...rules]; + newRules[index].ip_cidr = ipsStr.split(',').map(i => i.trim()).filter(Boolean); + setRules(newRules); + }; + + return ( +
+
+
+

+ Outbound Routing +

+

Manage how outbound traffic is routed for connected clients.

+
+
+ + +
+
+ + {errorMsg && ( +
+ +

{errorMsg}

+
+ )} + + {successMsg && ( +
+ +

{successMsg}

+
+ )} + +
+
+ +

Rules are evaluated from top to bottom. The first matching rule determines the action.

+
+ + {isLoading ? ( +
+ +
+ ) : rules.length === 0 ? ( +
+ +

No routing rules defined. All traffic follows the default action.

+ +
+ ) : ( +
+ {rules.map((rule, index) => ( +
+ +
+ {index + 1} +
+ +
+
+ + updateRuleDomains(index, e.target.value)} + placeholder="e.g. google.com, netflix.com" + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors" + /> +
+
+ + updateRuleIps(index, e.target.value)} + placeholder="e.g. 192.168.1.0/24, 10.0.0.1/32" + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors" + /> +
+
+ +
+
+ + +
+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ostp-control/src/pages/components/AddClientModal.tsx b/ostp-control/src/pages/components/AddClientModal.tsx new file mode 100644 index 0000000..d42e60a --- /dev/null +++ b/ostp-control/src/pages/components/AddClientModal.tsx @@ -0,0 +1,103 @@ + +import type { FormEvent } from 'react'; +import { X } from 'lucide-react'; +import { useLanguage } from '../../lib/LanguageContext'; + +export interface AddClientModalProps { + show: boolean; + onClose: () => void; + onSubmit: (e: FormEvent) => void; + clientName: string; + setClientName: (v: string) => void; + clientLimit: string; + setClientLimit: (v: string) => void; + clientLimitUnit: string; + setClientLimitUnit: (v: string) => void; + clientCustomKey: string; + setClientCustomKey: (v: string) => void; +} + +export function AddClientModal({ + show, + onClose, + onSubmit, + clientName, + setClientName, + clientLimit, + setClientLimit, + clientLimitUnit, + setClientLimitUnit, + clientCustomKey, + setClientCustomKey +}: AddClientModalProps) { + const { t } = useLanguage(); + + if (!show) return null; + + return ( +
+
+ +

{t('cl_add_title')}

+ +
+
+ + setClientName(e.target.value)} + /> +
+ +
+ +
+ setClientLimit(e.target.value)} + /> + +
+
+ +
+ + setClientCustomKey(e.target.value)} + /> +
+ + +
+
+
+ ); +} diff --git a/ostp-control/src/pages/components/BulkKeysModal.tsx b/ostp-control/src/pages/components/BulkKeysModal.tsx new file mode 100644 index 0000000..cffbc6d --- /dev/null +++ b/ostp-control/src/pages/components/BulkKeysModal.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { X, Copy, CheckCircle2, Zap } from 'lucide-react'; +import { useLanguage } from '../../lib/LanguageContext'; + +interface BulkKeysModalProps { + onClose: () => void; + onGenerate: (count: number, limitBytes: number | null) => Promise; +} + +export function BulkKeysModal({ onClose, onGenerate }: BulkKeysModalProps) { + const { t } = useLanguage(); + const [count, setCount] = useState(10); + const [limitGB, setLimitGB] = useState(''); + const [loading, setLoading] = useState(false); + const [generatedKeys, setGeneratedKeys] = useState([]); + const [copied, setCopied] = useState(false); + + const handleGenerate = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const limitBytes = limitGB ? parseInt(limitGB) * 1024 * 1024 * 1024 : null; + const keys = await onGenerate(count, limitBytes); + setGeneratedKeys(keys); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleCopy = () => { + navigator.clipboard.writeText(generatedKeys.join('\n')); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+

+ + Bulk Generate Keys +

+ +
+ + {generatedKeys.length === 0 ? ( +
+
+
+ + setCount(parseInt(e.target.value))} + className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary/50 transition-colors" + /> +
+ +
+ + setLimitGB(e.target.value)} + placeholder="Unlimited" + className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary/50 transition-colors placeholder:text-white/20" + /> +
+
+ +
+ + +
+
+ ) : ( +
+

+ Successfully generated {generatedKeys.length} keys +

+ +
+