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-flutter/android/app/build.gradle.kts b/ostp-flutter/android/app/build.gradle.kts index 686b244..a61acab 100644 --- a/ostp-flutter/android/app/build.gradle.kts +++ b/ostp-flutter/android/app/build.gradle.kts @@ -35,6 +35,7 @@ android { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } diff --git a/ostp-flutter/android/app/proguard-rules.pro b/ostp-flutter/android/app/proguard-rules.pro new file mode 100644 index 0000000..0deb170 --- /dev/null +++ b/ostp-flutter/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-keep class net.ostp.client.OstpClientSdk { *; } +-keep class com.ospab.ostp_client.OstpVpnService { *; } +-keep class com.ospab.ostp_client.MainActivity { *; } diff --git a/ostp-flutter/android/app/src/main/AndroidManifest.xml b/ostp-flutter/android/app/src/main/AndroidManifest.xml index 5f080f7..99a2676 100644 --- a/ostp-flutter/android/app/src/main/AndroidManifest.xml +++ b/ostp-flutter/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 0000000..65ca8b0 Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000..1a2e743 Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000..d4af7ca Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000..9f2b10b Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000..f7e472b Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/ostp-flutter/lib/main.dart b/ostp-flutter/lib/main.dart index 94bbb3b..81def1a 100644 --- a/ostp-flutter/lib/main.dart +++ b/ostp-flutter/lib/main.dart @@ -7,6 +7,8 @@ import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import 'ui/home_screen.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); @@ -37,2143 +39,3 @@ class OstpApp extends StatelessWidget { ); } } - -class HomeScreen extends StatefulWidget { - final SharedPreferences prefs; - const HomeScreen({super.key, required this.prefs}); - - @override - State createState() => _HomeScreenState(); -} - -enum ConnectionStateEnum { disconnected, connecting, connected } - -class _HomeScreenState extends State with TickerProviderStateMixin { - static const platform = MethodChannel('com.ospab.ostp/vpn'); - - ConnectionStateEnum _state = ConnectionStateEnum.disconnected; - Timer? _pollTimer; - Timer? _uptimeTimer; - int _uptimeSecs = 0; - - String _serverAddr = '127.0.0.1:443'; - String _accessKey = 'default_key'; - - String _download = '0 B'; - String _upload = '0 B'; - - late AnimationController _pulseController; - late AnimationController _spinController; - - bool _isCheckingPing = false; - String _pingText = 'Target Ping: -- ms'; - Color _pingColor = Colors.white54; - - @override - void initState() { - super.initState(); - _loadSettings(); - _pulseController = AnimationController( - vsync: this, - duration: const Duration(seconds: 2), - ); - _spinController = AnimationController( - vsync: this, - duration: const Duration(seconds: 4), - ); - _checkInitialState(); - } - - Future _checkInitialState() async { - try { - final isRunning = await platform.invokeMethod('isRunning'); - if (isRunning == true && mounted) { - _setConnected(); - } - } catch (e) { - debugPrint("Failed to check initial state: $e"); - } - } - - void _loadSettings() { - setState(() { - _serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443'; - _accessKey = widget.prefs.getString('access_key') ?? ''; - }); - _updateLatestConfigJson(); - } - - void _updateLatestConfigJson() { - - final exDomains = widget.prefs.getString('ex_domains') ?? ''; - final exIps = widget.prefs.getString('ex_ips') ?? ''; - final exProcesses = widget.prefs.getString('ex_processes') ?? ''; - final debugMode = widget.prefs.getBool('debug_mode') ?? false; - final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; - final stealthPort = widget.prefs.getString('stealth_port') ?? '443'; - final wss = widget.prefs.getBool('wss') ?? false; - final mtu = widget.prefs.getString('mtu') ?? '1140'; - final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; - final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; - final dnsServer = widget.prefs.getString('dns_server'); - final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; - final tunStack = 'ostp'; - final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; - final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; - - final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; - final configMap = { - "mode": "client", - "debug": debugMode, - "ostp": { - "server_addr": _serverAddr, - "local_bind_addr": "0.0.0.0:0", - "access_key": _accessKey, - "handshake_timeout_ms": 10000, - "io_timeout_ms": 5000, - "mtu": int.tryParse(mtu) ?? 1140, - }, - "local_proxy": { - "bind_addr": localBind, - "connect_timeout_ms": 15000, - }, - "transport": { - "mode": transportMode, - "stealth_sni": stealthSni, - "stealth_port": int.tryParse(stealthPort) ?? 443, - "wss": wss, - }, - "multiplex": { - "enabled": muxEnabled, - "sessions": int.tryParse(muxSessions) ?? 2, - }, - "reality": { - "enabled": widget.prefs.getBool('reality_enabled') ?? false, - "dest": "", - "private_key": "", - "pbk": widget.prefs.getString('pbk') ?? "", - "sid": widget.prefs.getString('sid') ?? "", - "sni_list": [] - }, - "tun": { - "enable": true, - "stack": tunStack - }, - "exclusions": { - "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), - }, - "app_rules": { - "mode": appRoutingMode, - "packages": appRoutingPackages, - }, - "dns_server": effectiveDnsServer, - "tun_stack": tunStack - }; - widget.prefs.setString('latest_config_json', jsonEncode(configMap)); - platform.invokeMethod('saveConfig', { - "configJson": jsonEncode(configMap) - }); - } - - @override - void dispose() { - _pollTimer?.cancel(); - _uptimeTimer?.cancel(); - _pulseController.dispose(); - _spinController.dispose(); - super.dispose(); - } - - Future _toggleConnection() async { - if (_state == ConnectionStateEnum.disconnected) { - if (_serverAddr.isEmpty || _accessKey.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please configure Server and Key in Settings')), - ); - return; - } - - setState(() { - _state = ConnectionStateEnum.connecting; - }); - _pulseController.repeat(reverse: true); - _spinController.repeat(); - - final dnsServer = widget.prefs.getString('dns_server'); - final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; - final exDomains = widget.prefs.getString('ex_domains') ?? ''; - final exIps = widget.prefs.getString('ex_ips') ?? ''; - final exProcesses = widget.prefs.getString('ex_processes') ?? ''; - final debugMode = widget.prefs.getBool('debug_mode') ?? false; - final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; - final stealthPort = widget.prefs.getString('stealth_port') ?? '443'; - final wss = widget.prefs.getBool('wss') ?? false; - final mtu = widget.prefs.getString('mtu') ?? '1140'; - final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; - final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; - final tunStack = 'ostp'; - - final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; - final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; - - final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; - final configMap = { - "mode": "client", - "debug": debugMode, - "ostp": { - "server_addr": _serverAddr, - "local_bind_addr": "0.0.0.0:0", - "access_key": _accessKey, - "handshake_timeout_ms": 10000, - "io_timeout_ms": 5000, - "mtu": int.tryParse(mtu) ?? 1140, - }, - "local_proxy": { - "bind_addr": localBind, - "connect_timeout_ms": 15000, - }, - "transport": { - "mode": transportMode, - "stealth_sni": stealthSni, - "stealth_port": int.tryParse(stealthPort) ?? 443, - "wss": wss, - }, - "multiplex": { - "enabled": muxEnabled, - "sessions": int.tryParse(muxSessions) ?? 2, - }, - "reality": { - "enabled": widget.prefs.getBool('reality_enabled') ?? false, - "dest": "", - "private_key": "", - "pbk": widget.prefs.getString('pbk') ?? "", - "sid": widget.prefs.getString('sid') ?? "", - "sni_list": [] - }, - "tun": { - "enable": true, - "stack": tunStack - }, - "exclusions": { - "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), - }, - "app_rules": { - "mode": appRoutingMode, - "packages": appRoutingPackages, - }, - "dns_server": dnsServer, - "tun_stack": tunStack - }; - - widget.prefs.setString('latest_config_json', jsonEncode(configMap)); - - - try { - await platform.invokeMethod('saveConfig', { - "configJson": jsonEncode(configMap) - }); - await platform.invokeMethod('startTunnel', { - "configJson": jsonEncode(configMap) - }); - - bool started = false; - for (int i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 500)); - final isRunning = await platform.invokeMethod('isRunning'); - if (isRunning == true) { - started = true; - break; - } - } - - if (started) { - _setConnected(); - } else { - _setDisconnected(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to connect. Check logs for details.')), - ); - } - } - } catch (e, stackTrace) { - debugPrint("Failed to start tunnel: $e\n$stackTrace"); - _setDisconnected(); - if (mounted) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Error', style: TextStyle(color: Colors.redAccent)), - content: SingleChildScrollView( - child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), - ), - actions: [ - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: e.toString())); - ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!'))); - }, - child: const Text('Copy'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ), - ); - } - } - } else { - try { - await platform.invokeMethod('stopTunnel'); - } catch (e) { - debugPrint("Stop error: $e"); - } - _setDisconnected(); - } - } - - Future _runAutoMode() async { - final mtus = [1500, 1350, 1280, 1140]; - final modes = [ - {'t': 'udp', 'w': false, 'r': false}, - {'t': 'uot', 'w': false, 'r': false}, - {'t': 'uot', 'w': true, 'r': false}, - {'t': 'uot', 'w': false, 'r': true}, - ]; - - if (_serverAddr.isEmpty || _accessKey.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please configure Server and Key first')), - ); - return; - } - - for (var mode in modes) { - for (var mtu in mtus) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)), - ); - - // Update prefs - await widget.prefs.setString('mtu', mtu.toString()); - await widget.prefs.setString('transport_mode', mode['t'] as String); - await widget.prefs.setBool('wss', mode['w'] as bool); - await widget.prefs.setBool('reality_enabled', mode['r'] as bool); - _updateLatestConfigJson(); - - setState(() { - _state = ConnectionStateEnum.connecting; - }); - _pulseController.repeat(reverse: true); - _spinController.repeat(); - - try { - final configJson = widget.prefs.getString('latest_config_json') ?? '{}'; - await platform.invokeMethod('startTunnel', {"configJson": configJson}); - - bool started = false; - for (int i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 500)); - final isRunning = await platform.invokeMethod('isRunning'); - if (isRunning == true) { - started = true; - break; - } - } - - if (started) { - _setConnected(); - // Wait to see if connection is stable and ping is successful - await Future.delayed(const Duration(seconds: 3)); - try { - final metricsJson = await platform.invokeMethod('getMetrics'); - if (metricsJson != null && metricsJson.isNotEmpty) { - final Map parsed = jsonDecode(metricsJson); - final rttMs = parsed['rtt_ms'] as int? ?? 0; - if (rttMs > 0) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')), - ); - } - return; // Stop on first working config - } - } - } catch (e) { - // Ignore metrics error - } - - // Connection seems unstable or no ping, stop and try next - await platform.invokeMethod('stopTunnel'); - _setDisconnected(); - } else { - _setDisconnected(); - } - } catch (e) { - _setDisconnected(); - } - } - } - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Auto search finished. No working config found.')), - ); - } - } - - void _setConnected() { - if (!mounted) return; - setState(() { - _state = ConnectionStateEnum.connected; - }); - _pulseController.stop(); - _pulseController.value = 1.0; - - _uptimeSecs = 0; - _uptimeTimer?.cancel(); - _uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted) return; - setState(() => _uptimeSecs++); - }); - - _startPollingMetrics(); - } - - void _startPollingMetrics() { - _pollTimer?.cancel(); - _pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async { - if (!mounted) return; - try { - final metricsJson = await platform.invokeMethod('getMetrics'); - if (metricsJson != null && metricsJson.isNotEmpty) { - final Map parsed = jsonDecode(metricsJson); - final bytesSent = parsed['bytes_sent'] as int? ?? 0; - final bytesRecv = parsed['bytes_recv'] as int? ?? 0; - final connState = parsed['connection_state'] as int? ?? 2; - final rttMs = parsed['rtt_ms'] as int? ?? 0; - - if (connState == 0 && _state != ConnectionStateEnum.disconnected) { - try { - await platform.invokeMethod('stopTunnel'); - } catch (e) { - debugPrint("Failed to stop background tunnel: $e"); - } - _setDisconnected(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Connection failed. Check logs for details.')), - ); - } - return; - } - - if (mounted) { - setState(() { - _download = _formatBytes(bytesRecv); - _upload = _formatBytes(bytesSent); - if (rttMs > 0 && !_isCheckingPing) { - _pingText = 'Server Ping: $rttMs ms'; - if (rttMs < 100) { - _pingColor = const Color(0xFF22D3A5); - } else if (rttMs < 250) { - _pingColor = Colors.amberAccent; - } else { - _pingColor = Colors.redAccent; - } - } - }); - } - } - } catch (e) { - debugPrint("Failed to get metrics: $e"); - } - }); - } - - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; - } - - Future _checkConnectionLatency() async { - if (_state != ConnectionStateEnum.connected) return; - - setState(() { - _isCheckingPing = true; - _pingText = 'Updating...'; - _pingColor = Colors.white70; - }); - - await Future.delayed(const Duration(milliseconds: 500)); - - if (mounted) { - setState(() { - _isCheckingPing = false; - }); - } - } - - void _setDisconnected() { - if (!mounted) return; - setState(() { - _state = ConnectionStateEnum.disconnected; - _download = '0 B'; - _upload = '0 B'; - _pingText = 'Target Ping: -- ms'; - _pingColor = Colors.white54; - _isCheckingPing = false; - }); - _pulseController.stop(); - _pulseController.value = 0.0; - _spinController.stop(); - _uptimeTimer?.cancel(); - _pollTimer?.cancel(); - } - - String _formatTime(int s) { - final h = s ~/ 3600; - final m = (s % 3600) ~/ 60; - final sec = s % 60; - final pad = (int n) => n.toString().padLeft(2, '0'); - return h > 0 ? '$h:${pad(m)}:${pad(sec)}' : '${pad(m)}:${pad(sec)}'; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - body: Stack( - children: [ - Positioned( - top: -150, right: -100, - child: Container( - width: 400, height: 400, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: theme.colorScheme.primary.withOpacity(0.15), - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), - child: Container(), - ), - ), - ), - Positioned( - bottom: -100, left: -100, - child: Container( - width: 350, height: 350, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: theme.colorScheme.secondary.withOpacity(0.1), - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), - child: Container(), - ), - ), - ), - - SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - children: [ - _buildTopBar(theme), - Expanded(child: _buildStage(theme)), - _buildMetricsBar(theme), - ], - ), - ), - ), - ); - }, - ), - ), - ], - ), - ); - } - - Widget _buildTopBar(ThemeData theme) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: 12, height: 12, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: _state == ConnectionStateEnum.connected - ? theme.colorScheme.secondary - : theme.colorScheme.primary, - boxShadow: [ - BoxShadow( - color: _state == ConnectionStateEnum.connected - ? theme.colorScheme.secondary.withOpacity(0.5) - : theme.colorScheme.primary.withOpacity(0.5), - blurRadius: 10, - ) - ] - ), - ), - const SizedBox(width: 12), - const Text( - 'OSTP', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: 2.5, - color: Colors.white, - ), - ), - ], - ), - Row( - children: [ - IconButton( - iconSize: 30, - icon: const Icon(Icons.auto_mode_rounded, color: Colors.white), - onPressed: () { - if (_state != ConnectionStateEnum.disconnected) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Disconnect first to run Auto mode')), - ); - return; - } - _runAutoMode(); - }, - ), - IconButton( - iconSize: 30, - icon: const Icon(Icons.settings_outlined, color: Colors.white), - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute(builder: (context) => SettingsScreen(prefs: widget.prefs)), - ); - _loadSettings(); - }, - ) - ], - ) - ], - ), - ); - } - - Widget _buildStage(ThemeData theme) { - Color getAccentColor() { - if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary; - return theme.colorScheme.primary; - } - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 260, height: 260, - child: Stack( - alignment: Alignment.center, - children: [ - if (_state != ConnectionStateEnum.disconnected) - RotationTransition( - turns: _spinController, - child: Container( - width: 240, height: 240, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: getAccentColor().withOpacity(0.25), - width: 2.0, - ), - ), - ), - ), - if (_state != ConnectionStateEnum.disconnected) - RotationTransition( - turns: ReverseAnimation(_spinController), - child: Container( - width: 200, height: 200, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: getAccentColor().withOpacity(0.15), - width: 1.5, - ), - ), - ), - ), - - AnimatedBuilder( - animation: _pulseController, - builder: (context, child) { - return Container( - width: 140, height: 140, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: theme.colorScheme.surface, - border: Border.all( - color: _state == ConnectionStateEnum.disconnected - ? Colors.white.withOpacity(0.15) - : getAccentColor(), - width: 3, - ), - boxShadow: [ - if (_state != ConnectionStateEnum.disconnected) - BoxShadow( - color: getAccentColor().withOpacity(0.4 * (_state == ConnectionStateEnum.connected ? 1.0 : _pulseController.value)), - blurRadius: 40, - spreadRadius: 8, - ) - ] - ), - child: child, - ); - }, - child: Material( - color: Colors.transparent, - child: InkWell( - customBorder: const CircleBorder(), - onTap: _toggleConnection, - child: Icon( - Icons.power_settings_new_rounded, - size: 60, - color: _state == ConnectionStateEnum.disconnected - ? Colors.white54 - : getAccentColor(), - ), - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 40), - - Text( - _state == ConnectionStateEnum.disconnected ? 'Disconnected' : - _state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: _state == ConnectionStateEnum.disconnected ? Colors.white70 : getAccentColor(), - ), - ), - const SizedBox(height: 8), - Text( - _state == ConnectionStateEnum.connected ? _formatTime(_uptimeSecs) : 'Tap to protect your traffic', - style: const TextStyle( - fontSize: 16, - color: Colors.white54, - ), - ), - - const SizedBox(height: 30), - - AnimatedOpacity( - opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.08), - borderRadius: BorderRadius.circular(30), - border: Border.all(color: Colors.white.withOpacity(0.15)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), - const SizedBox(width: 10), - Text( - _serverAddr, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 15, - fontWeight: FontWeight.w600, - color: Colors.white70, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Container( - margin: const EdgeInsets.symmetric(horizontal: 32), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.03), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withOpacity(0.06)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'CONNECTION TEST', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white38, - letterSpacing: 0.8, - ), - ), - const SizedBox(height: 4), - Text( - _pingText, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: _pingColor, - ), - ), - ], - ), - _isCheckingPing - ? const SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70), - ) - : TextButton.icon( - onPressed: _checkConnectionLatency, - icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary), - label: Text( - 'Test Ping', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 13, - color: theme.colorScheme.primary, - ), - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - backgroundColor: theme.colorScheme.primary.withOpacity(0.1), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ], - ), - ), - ], - ), - ) - ], - ); - } - - Widget _buildMetricsBar(ThemeData theme) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.04), - border: Border(top: BorderSide(color: Colors.white.withOpacity(0.08))), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary), - Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)), - _buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary), - ], - ), - ); - } - - Widget _buildMetricItem(IconData icon, String label, String value, Color color) { - return Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 20, color: color), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Colors.white54, - letterSpacing: 0.8, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ], - ) - ], - ); - } -} - -class SettingsScreen extends StatefulWidget { - final SharedPreferences prefs; - const SettingsScreen({super.key, required this.prefs}); - - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - late TextEditingController _importCtrl; - late TextEditingController _serverCtrl; - late TextEditingController _localBindCtrl; - late TextEditingController _keyCtrl; - late TextEditingController _dnsCtrl; - late TextEditingController _mtuCtrl; - late TextEditingController _domainsCtrl; - late TextEditingController _ipsCtrl; - late TextEditingController _processesCtrl; - late TextEditingController _stealthSniCtrl; - late TextEditingController _stealthPortCtrl; - late TextEditingController _pbkCtrl; - late TextEditingController _sidCtrl; - - bool _obscureKey = true; - bool _debugMode = false; - bool _wss = false; - bool _realityEnabled = false; - String _transportMode = 'udp'; // 'udp' | 'uot' - String _tunStack = 'ostp'; // 'system' | 'ostp' - bool _muxEnabled = false; - late TextEditingController _muxSessionsCtrl; - bool _owndns = false; - - @override - void initState() { - super.initState(); - _importCtrl = TextEditingController(); - _serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443'); - _localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'); - _keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? ''); - _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1'); - _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140'); - _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); - _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); - _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? ''); - _stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? ''); - _stealthPortCtrl = TextEditingController(text: widget.prefs.getString('stealth_port') ?? '443'); - _pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? ''); - _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? ''); - _wss = widget.prefs.getBool('wss') ?? false; - _realityEnabled = widget.prefs.getBool('reality_enabled') ?? false; - _transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; - _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp'; - _debugMode = widget.prefs.getBool('debug_mode') ?? false; - _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; - _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); - _owndns = widget.prefs.getBool('owndns') ?? false; - } - - @override - void dispose() { - _saveSettings(); - _importCtrl.dispose(); - _serverCtrl.dispose(); - _localBindCtrl.dispose(); - _keyCtrl.dispose(); - _dnsCtrl.dispose(); - _mtuCtrl.dispose(); - _domainsCtrl.dispose(); - _ipsCtrl.dispose(); - _processesCtrl.dispose(); - _stealthSniCtrl.dispose(); - _stealthPortCtrl.dispose(); - _pbkCtrl.dispose(); - _sidCtrl.dispose(); - _muxSessionsCtrl.dispose(); - super.dispose(); - } - - void _saveSettings() { - widget.prefs.setString('server_addr', _serverCtrl.text.trim()); - widget.prefs.setString('local_bind', _localBindCtrl.text.trim()); - widget.prefs.setString('access_key', _keyCtrl.text.trim()); - widget.prefs.setString('dns_server', _dnsCtrl.text.trim()); - widget.prefs.setString('mtu', _mtuCtrl.text.trim()); - widget.prefs.setString('ex_domains', _domainsCtrl.text.trim()); - widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); - widget.prefs.setString('ex_processes', _processesCtrl.text.trim()); - widget.prefs.setBool('debug_mode', _debugMode); - widget.prefs.setBool('wss', _wss); - widget.prefs.setBool('reality_enabled', _realityEnabled); - widget.prefs.setString('transport_mode', _transportMode); - widget.prefs.setString('tun_stack', _tunStack); - widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim()); - widget.prefs.setString('stealth_port', _stealthPortCtrl.text.trim()); - widget.prefs.setString('pbk', _pbkCtrl.text.trim()); - widget.prefs.setString('sid', _sidCtrl.text.trim()); - widget.prefs.setBool('mux_enabled', _muxEnabled); - widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); - widget.prefs.setBool('owndns', _owndns); - } - - Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), - const SizedBox(height: 10), - TextField( - controller: controller, - obscureText: isPassword && _obscureKey, - maxLines: maxLines, - style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'), - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Colors.white30), - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - suffixIcon: isPassword ? IconButton( - icon: Icon(_obscureKey ? Icons.visibility : Icons.visibility_off, color: Colors.white54), - onPressed: () => setState(() => _obscureKey = !_obscureKey), - ) : null, - ), - ), - const SizedBox(height: 24), - ], - ); - } - - Widget _buildToggle(String title, String subtitle, bool value, ValueChanged onChanged) { - return Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text(subtitle, style: const TextStyle(fontSize: 13, color: Colors.white54)), - ], - ), - ), - Switch( - value: value, - onChanged: (v) { - onChanged(v); - _saveSettings(); - }, - activeColor: Theme.of(context).colorScheme.secondary, - activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3), - inactiveTrackColor: Colors.white10, - ) - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Configuration', style: TextStyle(fontWeight: FontWeight.bold)), - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_rounded), - onPressed: () => Navigator.pop(context), - ), - actions: [ - IconButton( - icon: const Icon(Icons.qr_code_scanner_rounded), - onPressed: () async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const QRScannerScreen()), - ); - if (result != null && result is String && result.startsWith('ostp://')) { - setState(() { - _importCtrl.text = result; - }); - } - }, - ) - ], - ), - body: ListView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - children: [ - // Quick Import Row - Row( - children: [ - Expanded( - child: TextField( - controller: _importCtrl, - decoration: InputDecoration( - hintText: 'Paste ostp:// share link...', - hintStyle: const TextStyle(color: Colors.white30, fontSize: 14), - filled: true, - fillColor: Colors.white.withOpacity(0.05), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), - ), - const SizedBox(width: 12), - ElevatedButton( - onPressed: () { - final raw = _importCtrl.text.trim(); - if (raw.isEmpty) return; - try { - if (!raw.startsWith('ostp://')) { - throw Exception('Link must start with ostp://'); - } - final uri = Uri.parse(raw); - final key = Uri.decodeComponent(uri.userInfo); - final host = uri.authority.replaceFirst(uri.userInfo + '@', ''); - if (key.isEmpty || host.isEmpty) { - throw Exception('Incomplete link parameters'); - } - setState(() { - _serverCtrl.text = host; - _keyCtrl.text = key; - _stealthSniCtrl.text = uri.queryParameters['sni'] ?? ''; - _pbkCtrl.text = uri.queryParameters['pbk'] ?? ''; - _sidCtrl.text = uri.queryParameters['sid'] ?? ''; - _wss = uri.queryParameters['wss'] == 'true'; - _realityEnabled = uri.queryParameters['reality'] == 'true'; - final type = uri.queryParameters['type'] ?? 'udp'; - _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp'; - _owndns = uri.queryParameters['owndns'] == 'true'; - _importCtrl.clear(); - _saveSettings(); - }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'))); - } - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - backgroundColor: Theme.of(context).colorScheme.primary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white)), - ) - ], - ), - - const SizedBox(height: 30), - - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.02), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withOpacity(0.05)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTextField('Server Address', _serverCtrl, hint: 'host:port'), - _buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'), - _buildTextField('Access Key', _keyCtrl, hint: 'Secure access key', isPassword: true), - _buildTextField('Custom DNS Server', _dnsCtrl, hint: '1.1.1.1 (e.g. 8.8.8.8)'), - _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'), - - // ── Transport Mode ─────────────────────────────────────── - const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - RadioListTile( - value: 'udp', - groupValue: _transportMode, - title: const Text('UDP (по умолчанию)', style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text('Быстро, работает через Wi-Fi и большинство сетей', style: TextStyle(color: Colors.white54, fontSize: 12)), - activeColor: Theme.of(context).colorScheme.secondary, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - Divider(color: Colors.white.withOpacity(0.05), height: 1), - RadioListTile( - value: 'uot', - groupValue: _transportMode, - title: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - children: [ - const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)), - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFF6C72FF).withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - ), - child: const Text('xHTTP Стелс', style: TextStyle(fontSize: 10, color: Color(0xFF6C72FF), fontWeight: FontWeight.bold)), - ), - ], - ), - subtitle: const Text('Маскировка под HTTP-поток, обходит белые списки (уровень 1)', style: TextStyle(color: Colors.white54, fontSize: 12)), - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), - ), - ], - ), - ), - const SizedBox(height: 16), - _buildToggle('WebSocket (WSS)', 'Инкапсулировать транспорт в RFC 6455 (для строгого DPI)', _wss, (val) { - setState(() { - _wss = val; - }); - }), - const SizedBox(height: 16), - - // Stealth parameters - AnimatedCrossFade( - duration: const Duration(milliseconds: 250), - crossFadeState: _transportMode == 'uot' ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF6C72FF).withOpacity(0.06), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF6C72FF).withOpacity(0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.security, size: 16, color: Color(0xFF6C72FF)), - const SizedBox(width: 8), - const Text('Стелс параметры', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF6C72FF), fontSize: 14)), - ], - ), - const SizedBox(height: 4), - const Text( - 'Укажи домен из белого списка. OSTP подключится к серверу и подделает SNI / HTTP Host.', - style: TextStyle(fontSize: 12, color: Colors.white38), - ), - const SizedBox(height: 16), - Builder(builder: (context) { - final List domains = [ - 'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me', - 'top-fwz1.mail.ru', 'sso.passport.yandex.ru', - 'sberbank.ru', 'ad.mail.ru', 'ads.vk.com', - 'login.vk.com', 'api.sberbank.ru', 'ok.ru', - 'rostelecom.ru', 'rt.ru', 'tinkoff.ru', - 'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com' - ]; - String currentVal = _stealthSniCtrl.text.trim(); - if (currentVal.isEmpty) currentVal = 'vk.com'; - if (!domains.contains(currentVal)) { - domains.add(currentVal); - } - return DropdownButtonFormField( - value: currentVal, - dropdownColor: const Color(0xFF1E1E2C), - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - labelText: 'Стелс Домен (Автоподставление)', - labelStyle: const TextStyle(color: Colors.white54, fontSize: 13), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - items: domains.map((String domain) { - return DropdownMenuItem( - value: domain, - child: Text(domain), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _stealthSniCtrl.text = newValue; - _stealthPortCtrl.text = '443'; - _saveSettings(); - }); - } - }, - ); - }), - const SizedBox(height: 16), - _buildToggle('XTLS-Reality', 'Подделка TLS-сессии (Stealth-домен должен быть TLS 1.3)', _realityEnabled, (val) { - setState(() { - _realityEnabled = val; - }); - }), - const SizedBox(height: 16), - _buildTextField('Reality PublicKey (pbk)', _pbkCtrl, hint: 'Публичный ключ сервера'), - _buildTextField('Reality ShortId (sid)', _sidCtrl, hint: 'Опционально (необязательно)'), - ], - ), - ), - secondChild: const SizedBox.shrink(), - ), - - - const SizedBox(height: 16), - _buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)), - AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: _muxEnabled ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Padding( - padding: const EdgeInsets.only(top: 12.0), - child: _buildTextField('Mux Sessions', _muxSessionsCtrl, hint: '4'), - ), - secondChild: const SizedBox.shrink(), - ), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: _buildToggle('Debug Logs', 'Verbose output', _debugMode, (v) => setState(() => _debugMode = v))), - Padding( - padding: const EdgeInsets.only(bottom: 24.0, left: 10), - child: IconButton( - icon: const Icon(Icons.receipt_long_rounded), - color: Theme.of(context).colorScheme.primary, - tooltip: 'View Logs', - onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); - }, - ), - ), - ], - ), - - - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - SizedBox(width: 10), - Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)), - ], - ), - ), - - _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3, isMono: true), - _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3, isMono: true), - - // Premium app routing trigger button - InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), - ), - child: Row( - children: [ - Icon(Icons.apps_rounded, color: Theme.of(context).colorScheme.primary, size: 24), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Per-App Connection Rules', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), - ), - SizedBox(height: 4), - Text( - 'Choose which apps bypass or use VPN', - style: TextStyle(fontSize: 13, color: Colors.white54), - ), - ], - ), - ), - const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), - ], - ), - ), - ), - const SizedBox(height: 10), - ], - ), - ), - - const SizedBox(height: 40), - ], - ), - ); - } -} - -class LogsScreen extends StatefulWidget { - const LogsScreen({super.key}); - - @override - State createState() => _LogsScreenState(); -} - -class _LogsScreenState extends State { - static const platform = MethodChannel('com.ospab.ostp/vpn'); - Timer? _pollTimer; - final List _logs = []; - final ScrollController _scrollCtrl = ScrollController(); - - @override - void initState() { - super.initState(); - _fetchLogs(); - _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _fetchLogs()); - } - - @override - void dispose() { - _pollTimer?.cancel(); - _scrollCtrl.dispose(); - super.dispose(); - } - - Future _fetchLogs() async { - try { - final String logsJson = await platform.invokeMethod('getLogs'); - if (logsJson.isNotEmpty && logsJson != "[]") { - final List parsed = jsonDecode(logsJson); - if (parsed.isNotEmpty) { - setState(() { - _logs.addAll(parsed.map((e) => e.toString())); - }); - Future.delayed(const Duration(milliseconds: 100), () { - if (_scrollCtrl.hasClients) { - _scrollCtrl.animateTo(_scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); - } - }); - } - } - } catch (e, stackTrace) { - debugPrint("Failed to fetch logs: $e\n$stackTrace"); - if (mounted) { - Navigator.of(context).popUntil((route) => route.isFirst); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Logs Error', style: TextStyle(color: Colors.redAccent)), - content: SingleChildScrollView( - child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), - ), - actions: [ - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: e.toString())); - ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!'))); - }, - child: const Text('Copy'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ), - ); - } - } - } - - Future _clearLogs() async { - await platform.invokeMethod('clearLogs'); - setState(() { - _logs.clear(); - }); - } - - Future _copyLogs() async { - final text = _logs.join('\n'); - await Clipboard.setData(ClipboardData(text: text)); - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Logs copied to clipboard'))); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('System Logs', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), - backgroundColor: Theme.of(context).colorScheme.surface, - elevation: 0, - actions: [ - IconButton(icon: const Icon(Icons.delete_outline), onPressed: _clearLogs, tooltip: 'Clear'), - IconButton(icon: const Icon(Icons.copy_rounded), onPressed: _copyLogs, tooltip: 'Copy All'), - ], - ), - body: Container( - color: Colors.black, - padding: const EdgeInsets.all(12), - child: ListView.builder( - controller: _scrollCtrl, - itemCount: _logs.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Text( - _logs[index], - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Colors.greenAccent, - ), - ), - ); - }, - ), - ), - ); - } -} - -class AppRoutingScreen extends StatefulWidget { - final SharedPreferences prefs; - const AppRoutingScreen({super.key, required this.prefs}); - - @override - State createState() => _AppRoutingScreenState(); -} - -class _AppRoutingScreenState extends State { - static const platform = MethodChannel('com.ospab.ostp/vpn'); - - List> _allApps = []; - List> _filteredApps = []; - Set _selectedPackages = {}; - String _routingMode = 'bypass'; - bool _hideSystemApps = true; - bool _isLoading = true; - String _searchQuery = ''; - - final TextEditingController _searchCtrl = TextEditingController(); - - @override - void initState() { - super.initState(); - _loadSavedConfig(); - _fetchInstalledApps(); - } - - void _loadSavedConfig() { - setState(() { - _routingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; - _selectedPackages = (widget.prefs.getStringList('app_routing_packages') ?? []).toSet(); - }); - } - - Future _fetchInstalledApps() async { - try { - final List? rawApps = await platform.invokeMethod('getInstalledApps'); - if (rawApps != null) { - final List> apps = rawApps.map((e) { - final Map m = e as Map; - return { - "name": m["name"] as String? ?? "Unknown", - "package": m["package"] as String? ?? "", - "isSystem": m["isSystem"] as bool? ?? false, - "icon": m["icon"] as String? ?? "", - }; - }).toList(); - - apps.sort((a, b) => (a["name"] as String).toLowerCase().compareTo((b["name"] as String).toLowerCase())); - - setState(() { - _allApps = apps; - _isLoading = false; - }); - _filterApps(); - } - } catch (e) { - debugPrint("Error fetching apps: $e"); - setState(() => _isLoading = false); - } - } - - void _filterApps() { - setState(() { - _filteredApps = _allApps.where((app) { - final name = (app["name"] as String).toLowerCase(); - final package = (app["package"] as String).toLowerCase(); - final query = _searchQuery.toLowerCase(); - - final matchesSearch = name.contains(query) || package.contains(query); - final matchesSystemFilter = !_hideSystemApps || !(app["isSystem"] as bool); - - return matchesSearch && matchesSystemFilter; - }).toList(); - }); - } - - void _saveConfig() { - widget.prefs.setString('app_routing_mode', _routingMode); - widget.prefs.setStringList('app_routing_packages', _selectedPackages.toList()); - } - - void _resetConfig() { - setState(() { - _selectedPackages.clear(); - _routingMode = 'bypass'; - _hideSystemApps = true; - _searchCtrl.clear(); - _searchQuery = ''; - }); - _saveConfig(); - _filterApps(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('App routing rules reset successfully')), - ); - } - - @override - void dispose() { - _searchCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar( - title: const Text('App Routing Rules', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), - backgroundColor: theme.colorScheme.surface, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Reset Rules', - onPressed: _resetConfig, - ), - ], - ), - body: Column( - children: [ - Container( - padding: const EdgeInsets.all(16), - color: theme.colorScheme.surface.withOpacity(0.5), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () { - setState(() { - _routingMode = 'bypass'; - }); - _saveConfig(); - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1), - ), - ), - child: const Center( - child: Text( - 'Bypass Mode', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), - ), - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: GestureDetector( - onTap: () { - setState(() { - _routingMode = 'proxy'; - }); - _saveConfig(); - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1), - ), - ), - child: const Center( - child: Text( - 'Proxy Mode', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - _routingMode == 'bypass' - ? 'Selected apps bypass the VPN (direct connection).' - : 'Only selected apps are routed through the VPN.', - style: const TextStyle(fontSize: 13, color: Colors.white54), - textAlign: TextAlign.center, - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _searchCtrl, - onChanged: (val) { - setState(() { - _searchQuery = val; - }); - _filterApps(); - }, - decoration: InputDecoration( - hintText: 'Search apps...', - prefixIcon: const Icon(Icons.search_rounded, color: Colors.white54), - suffixIcon: _searchQuery.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear_rounded, color: Colors.white54), - onPressed: () { - _searchCtrl.clear(); - setState(() { - _searchQuery = ''; - }); - _filterApps(); - }, - ) : null, - filled: true, - fillColor: Colors.white.withOpacity(0.05), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ), - ), - const SizedBox(width: 12), - InkWell( - onTap: () { - setState(() { - _hideSystemApps = !_hideSystemApps; - }); - _filterApps(); - }, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.15) : Colors.white.withOpacity(0.05), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.4) : Colors.white.withOpacity(0.1), - ), - ), - child: Icon( - _hideSystemApps ? Icons.visibility_off_rounded : Icons.visibility_rounded, - color: _hideSystemApps ? theme.colorScheme.primary : Colors.white70, - ), - ), - ), - ], - ), - ), - - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _filteredApps.isEmpty - ? const Center(child: Text('No applications found', style: TextStyle(color: Colors.white54))) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _filteredApps.length, - itemBuilder: (context, index) { - final app = _filteredApps[index]; - final pkg = app["package"] as String; - final name = app["name"] as String; - final isSystem = app["isSystem"] as bool; - final isSelected = _selectedPackages.contains(pkg); - final String? iconBase64 = app["icon"] as String?; - - final String initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; - final int colorHash = pkg.hashCode.abs(); - final double hue = (colorHash % 360).toDouble(); - - return Container( - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: isSelected - ? (_routingMode == 'bypass' - ? theme.colorScheme.primary.withOpacity(0.08) - : theme.colorScheme.secondary.withOpacity(0.08)) - : Colors.white.withOpacity(0.02), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? (_routingMode == 'bypass' - ? theme.colorScheme.primary.withOpacity(0.3) - : theme.colorScheme.secondary.withOpacity(0.3)) - : Colors.white.withOpacity(0.05), - ), - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - leading: iconBase64 != null && iconBase64.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.memory( - base64Decode(iconBase64), - width: 40, height: 40, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 40, height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [ - HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(), - HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Center( - child: Text( - initial, - style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16), - ), - ), - ), - ), - ) - : Container( - width: 40, height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [ - HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(), - HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Center( - child: Text( - initial, - style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16), - ), - ), - ), - title: Row( - children: [ - Expanded( - child: Text( - name, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), - maxLines: 1, overflow: TextOverflow.ellipsis, - ), - ), - if (isSystem) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SYS', - style: TextStyle(fontSize: 9, color: Colors.white60, fontWeight: FontWeight.bold), - ), - ) - ] - ], - ), - subtitle: Text( - pkg, - style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white38), - maxLines: 1, overflow: TextOverflow.ellipsis, - ), - trailing: Switch( - value: isSelected, - activeColor: _routingMode == 'bypass' ? theme.colorScheme.primary : theme.colorScheme.secondary, - onChanged: (val) { - setState(() { - if (val) { - _selectedPackages.add(pkg); - } else { - _selectedPackages.remove(pkg); - } - }); - _saveConfig(); - }, - ), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class QRScannerScreen extends StatefulWidget { - const QRScannerScreen({super.key}); - - @override - State createState() => _QRScannerScreenState(); -} - -class _QRScannerScreenState extends State { - final MobileScannerController controller = MobileScannerController( - detectionSpeed: DetectionSpeed.normal, - facing: CameraFacing.back, - ); - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - DateTime? lastErrorTime; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Scan QR Code'), - backgroundColor: Colors.transparent, - elevation: 0, - ), - body: Stack( - alignment: Alignment.center, - children: [ - MobileScanner( - controller: controller, - onDetect: (capture) { - final List barcodes = capture.barcodes; - for (final barcode in barcodes) { - if (barcode.rawValue != null) { - if (barcode.rawValue!.startsWith('ostp://')) { - controller.stop(); - Navigator.pop(context, barcode.rawValue); - return; - } else { - final now = DateTime.now(); - if (lastErrorTime == null || now.difference(lastErrorTime!) > const Duration(seconds: 3)) { - lastErrorTime = now; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid QR Code. Must be an OSTP connection link.'), - backgroundColor: Colors.redAccent, - duration: Duration(seconds: 2), - ), - ); - } - } - } - } - }, - ), - Container( - decoration: ShapeDecoration( - shape: QrScannerOverlayShape( - borderColor: Theme.of(context).colorScheme.primary, - borderRadius: 10, - borderLength: 30, - borderWidth: 10, - cutOutSize: 300, - ), - ), - ), - ], - ), - ); - } -} - -class QrScannerOverlayShape extends ShapeBorder { - final Color borderColor; - final double borderWidth; - final double borderRadius; - final double borderLength; - final double cutOutSize; - - const QrScannerOverlayShape({ - this.borderColor = Colors.red, - this.borderWidth = 3.0, - this.borderRadius = 0.0, - this.borderLength = 20.0, - this.cutOutSize = 250.0, - }); - - @override - EdgeInsetsGeometry get dimensions => const EdgeInsets.all(10); - - @override - Path getInnerPath(Rect rect, {TextDirection? textDirection}) { - return Path() - ..fillType = PathFillType.evenOdd - ..addPath(getOuterPath(rect), Offset.zero); - } - - @override - Path getOuterPath(Rect rect, {TextDirection? textDirection}) { - Path path = Path()..addRect(rect); - rect = Rect.fromCenter( - center: rect.center, - width: cutOutSize, - height: cutOutSize, - ); - path.addRect(rect); - return path; - } - - @override - void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { - final borderPaint = Paint() - ..color = borderColor - ..style = PaintingStyle.stroke - ..strokeWidth = borderWidth; - - final backgroundPaint = Paint() - ..color = Colors.black54 - ..style = PaintingStyle.fill; - - final cutOutRect = Rect.fromCenter( - center: rect.center, - width: cutOutSize, - height: cutOutSize, - ); - - final backgroundPath = Path() - ..addRect(rect) - ..addRect(cutOutRect) - ..fillType = PathFillType.evenOdd; - - canvas.drawPath(backgroundPath, backgroundPaint); - - final path = Path(); - // Top left - path.moveTo(cutOutRect.left, cutOutRect.top + borderLength); - path.lineTo(cutOutRect.left, cutOutRect.top + borderRadius); - path.arcToPoint( - Offset(cutOutRect.left + borderRadius, cutOutRect.top), - radius: Radius.circular(borderRadius), - ); - path.lineTo(cutOutRect.left + borderLength, cutOutRect.top); - - // Top right - path.moveTo(cutOutRect.right - borderLength, cutOutRect.top); - path.lineTo(cutOutRect.right - borderRadius, cutOutRect.top); - path.arcToPoint( - Offset(cutOutRect.right, cutOutRect.top + borderRadius), - radius: Radius.circular(borderRadius), - ); - path.lineTo(cutOutRect.right, cutOutRect.top + borderLength); - - // Bottom left - path.moveTo(cutOutRect.left, cutOutRect.bottom - borderLength); - path.lineTo(cutOutRect.left, cutOutRect.bottom - borderRadius); - path.arcToPoint( - Offset(cutOutRect.left + borderRadius, cutOutRect.bottom), - radius: Radius.circular(borderRadius), - clockwise: false, - ); - path.lineTo(cutOutRect.left + borderLength, cutOutRect.bottom); - - // Bottom right - path.moveTo(cutOutRect.right - borderLength, cutOutRect.bottom); - path.lineTo(cutOutRect.right - borderRadius, cutOutRect.bottom); - path.arcToPoint( - Offset(cutOutRect.right, cutOutRect.bottom - borderRadius), - radius: Radius.circular(borderRadius), - clockwise: false, - ); - path.lineTo(cutOutRect.right, cutOutRect.bottom - borderLength); - - canvas.drawPath(path, borderPaint); - - // Line in the middle - final linePaint = Paint() - ..color = borderColor.withOpacity(0.8) - ..style = PaintingStyle.stroke - ..strokeWidth = 2.0; - - canvas.drawLine( - Offset(cutOutRect.left + 20, cutOutRect.center.dy), - Offset(cutOutRect.right - 20, cutOutRect.center.dy), - linePaint, - ); - } - - @override - ShapeBorder scale(double t) { - return QrScannerOverlayShape( - borderColor: borderColor, - borderWidth: borderWidth * t, - borderRadius: borderRadius * t, - borderLength: borderLength * t, - cutOutSize: cutOutSize * t, - ); - } -} diff --git a/ostp-flutter/lib/models/connection_state_enum.dart b/ostp-flutter/lib/models/connection_state_enum.dart new file mode 100644 index 0000000..525855a --- /dev/null +++ b/ostp-flutter/lib/models/connection_state_enum.dart @@ -0,0 +1,2 @@ +enum ConnectionStateEnum { disconnected, connecting, connected } + diff --git a/ostp-flutter/lib/ui/app_routing_screen.dart b/ostp-flutter/lib/ui/app_routing_screen.dart new file mode 100644 index 0000000..f8e4cf4 --- /dev/null +++ b/ostp-flutter/lib/ui/app_routing_screen.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class AppRoutingScreen extends StatefulWidget { + final SharedPreferences prefs; + const AppRoutingScreen({super.key, required this.prefs}); + + @override + State createState() => _AppRoutingScreenState(); +} + +class _AppRoutingScreenState extends State { + static const platform = MethodChannel('com.ospab.ostp/vpn'); + + List> _allApps = []; + List> _filteredApps = []; + Set _selectedPackages = {}; + String _routingMode = 'bypass'; + bool _hideSystemApps = true; + bool _isLoading = true; + String _searchQuery = ''; + + final TextEditingController _searchCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadSavedConfig(); + _fetchInstalledApps(); + } + + void _loadSavedConfig() { + setState(() { + _routingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; + _selectedPackages = (widget.prefs.getStringList('app_routing_packages') ?? []).toSet(); + }); + } + + Future _fetchInstalledApps() async { + try { + final List? rawApps = await platform.invokeMethod('getInstalledApps'); + if (rawApps != null) { + final List> apps = rawApps.map((e) { + final Map m = e as Map; + return { + "name": m["name"] as String? ?? "Unknown", + "package": m["package"] as String? ?? "", + "isSystem": m["isSystem"] as bool? ?? false, + "icon": m["icon"] as String? ?? "", + }; + }).toList(); + + apps.sort((a, b) => (a["name"] as String).toLowerCase().compareTo((b["name"] as String).toLowerCase())); + + setState(() { + _allApps = apps; + _isLoading = false; + }); + _filterApps(); + } + } catch (e) { + debugPrint("Error fetching apps: $e"); + setState(() => _isLoading = false); + } + } + + void _filterApps() { + setState(() { + _filteredApps = _allApps.where((app) { + final name = (app["name"] as String).toLowerCase(); + final package = (app["package"] as String).toLowerCase(); + final query = _searchQuery.toLowerCase(); + + final matchesSearch = name.contains(query) || package.contains(query); + final matchesSystemFilter = !_hideSystemApps || !(app["isSystem"] as bool); + + return matchesSearch && matchesSystemFilter; + }).toList(); + }); + } + + void _saveConfig() { + widget.prefs.setString('app_routing_mode', _routingMode); + widget.prefs.setStringList('app_routing_packages', _selectedPackages.toList()); + } + + void _resetConfig() { + setState(() { + _selectedPackages.clear(); + _routingMode = 'bypass'; + _hideSystemApps = true; + _searchCtrl.clear(); + _searchQuery = ''; + }); + _saveConfig(); + _filterApps(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('App routing rules reset successfully')), + ); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('App Routing Rules', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), + backgroundColor: theme.colorScheme.surface, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Reset Rules', + onPressed: _resetConfig, + ), + ], + ), + body: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + color: theme.colorScheme.surface.withOpacity(0.5), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _routingMode = 'bypass'; + }); + _saveConfig(); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1), + ), + ), + child: const Center( + child: Text( + 'Bypass Mode', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _routingMode = 'proxy'; + }); + _saveConfig(); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1), + ), + ), + child: const Center( + child: Text( + 'Proxy Mode', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _routingMode == 'bypass' + ? 'Selected apps bypass the VPN (direct connection).' + : 'Only selected apps are routed through the VPN.', + style: const TextStyle(fontSize: 13, color: Colors.white54), + textAlign: TextAlign.center, + ), + ], + ), + ), + + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + onChanged: (val) { + setState(() { + _searchQuery = val; + }); + _filterApps(); + }, + decoration: InputDecoration( + hintText: 'Search apps...', + prefixIcon: const Icon(Icons.search_rounded, color: Colors.white54), + suffixIcon: _searchQuery.isNotEmpty ? IconButton( + icon: const Icon(Icons.clear_rounded, color: Colors.white54), + onPressed: () { + _searchCtrl.clear(); + setState(() { + _searchQuery = ''; + }); + _filterApps(); + }, + ) : null, + filled: true, + fillColor: Colors.white.withOpacity(0.05), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + InkWell( + onTap: () { + setState(() { + _hideSystemApps = !_hideSystemApps; + }); + _filterApps(); + }, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.15) : Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.4) : Colors.white.withOpacity(0.1), + ), + ), + child: Icon( + _hideSystemApps ? Icons.visibility_off_rounded : Icons.visibility_rounded, + color: _hideSystemApps ? theme.colorScheme.primary : Colors.white70, + ), + ), + ), + ], + ), + ), + + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredApps.isEmpty + ? const Center(child: Text('No applications found', style: TextStyle(color: Colors.white54))) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _filteredApps.length, + itemBuilder: (context, index) { + final app = _filteredApps[index]; + final pkg = app["package"] as String; + final name = app["name"] as String; + final isSystem = app["isSystem"] as bool; + final isSelected = _selectedPackages.contains(pkg); + final String? iconBase64 = app["icon"] as String?; + + final String initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final int colorHash = pkg.hashCode.abs(); + final double hue = (colorHash % 360).toDouble(); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isSelected + ? (_routingMode == 'bypass' + ? theme.colorScheme.primary.withOpacity(0.08) + : theme.colorScheme.secondary.withOpacity(0.08)) + : Colors.white.withOpacity(0.02), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? (_routingMode == 'bypass' + ? theme.colorScheme.primary.withOpacity(0.3) + : theme.colorScheme.secondary.withOpacity(0.3)) + : Colors.white.withOpacity(0.05), + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: iconBase64 != null && iconBase64.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.memory( + base64Decode(iconBase64), + width: 40, height: 40, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 40, height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(), + HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + initial, + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16), + ), + ), + ), + ), + ) + : Container( + width: 40, height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(), + HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + initial, + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16), + ), + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + maxLines: 1, overflow: TextOverflow.ellipsis, + ), + ), + if (isSystem) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SYS', + style: TextStyle(fontSize: 9, color: Colors.white60, fontWeight: FontWeight.bold), + ), + ) + ] + ], + ), + subtitle: Text( + pkg, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white38), + maxLines: 1, overflow: TextOverflow.ellipsis, + ), + trailing: Switch( + value: isSelected, + activeColor: _routingMode == 'bypass' ? theme.colorScheme.primary : theme.colorScheme.secondary, + onChanged: (val) { + setState(() { + if (val) { + _selectedPackages.add(pkg); + } else { + _selectedPackages.remove(pkg); + } + }); + _saveConfig(); + }, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + diff --git a/ostp-flutter/lib/ui/home_screen.dart b/ostp-flutter/lib/ui/home_screen.dart new file mode 100644 index 0000000..c824cdb --- /dev/null +++ b/ostp-flutter/lib/ui/home_screen.dart @@ -0,0 +1,922 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import '../models/connection_state_enum.dart'; +import 'settings_screen.dart'; +import 'logs_screen.dart'; +import 'app_routing_screen.dart'; +import 'qr_scanner_screen.dart'; + +class HomeScreen extends StatefulWidget { + final SharedPreferences prefs; + const HomeScreen({super.key, required this.prefs}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State with TickerProviderStateMixin { + static const platform = MethodChannel('com.ospab.ostp/vpn'); + + ConnectionStateEnum _state = ConnectionStateEnum.disconnected; + Timer? _pollTimer; + Timer? _uptimeTimer; + int _uptimeSecs = 0; + + String _serverAddr = '127.0.0.1:443'; + String _accessKey = 'default_key'; + + String _download = '0 B'; + String _upload = '0 B'; + + late AnimationController _pulseController; + late AnimationController _spinController; + + bool _isCheckingPing = false; + String _pingText = 'Target Ping: -- ms'; + Color _pingColor = Colors.white54; + + @override + void initState() { + super.initState(); + _loadSettings(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + _spinController = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + ); + _checkInitialState(); + } + + Future _checkInitialState() async { + try { + final isRunning = await platform.invokeMethod('isRunning'); + if (isRunning == true && mounted) { + _setConnected(); + } + } catch (e) { + debugPrint("Failed to check initial state: $e"); + } + } + + void _loadSettings() { + setState(() { + _serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443'; + _accessKey = widget.prefs.getString('access_key') ?? ''; + }); + _updateLatestConfigJson(); + } + + void _updateLatestConfigJson() { + + final exDomains = widget.prefs.getString('ex_domains') ?? ''; + final exIps = widget.prefs.getString('ex_ips') ?? ''; + final exProcesses = widget.prefs.getString('ex_processes') ?? ''; + final debugMode = widget.prefs.getBool('debug_mode') ?? false; + final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; + final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; + final stealthPort = widget.prefs.getString('stealth_port') ?? '443'; + final wss = widget.prefs.getBool('wss') ?? false; + final mtu = widget.prefs.getString('mtu') ?? '1140'; + final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; + final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; + final dnsServer = widget.prefs.getString('dns_server'); + final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; + final tunStack = 'ostp'; + final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; + final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; + + final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; + final configMap = { + "mode": "client", + "debug": debugMode, + "ostp": { + "server_addr": _serverAddr, + "local_bind_addr": "0.0.0.0:0", + "access_key": _accessKey, + "handshake_timeout_ms": 10000, + "io_timeout_ms": 5000, + "mtu": int.tryParse(mtu) ?? 1140, + }, + "local_proxy": { + "bind_addr": localBind, + "connect_timeout_ms": 15000, + }, + "transport": { + "mode": transportMode, + "stealth_sni": stealthSni, + "stealth_port": int.tryParse(stealthPort) ?? 443, + "wss": wss, + }, + "multiplex": { + "enabled": muxEnabled, + "sessions": int.tryParse(muxSessions) ?? 2, + }, + "reality": { + "enabled": widget.prefs.getBool('reality_enabled') ?? false, + "dest": "", + "private_key": "", + "pbk": widget.prefs.getString('pbk') ?? "", + "sid": widget.prefs.getString('sid') ?? "", + "sni_list": [] + }, + "tun": { + "enable": true, + "stack": tunStack + }, + "exclusions": { + "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), + }, + "app_rules": { + "mode": appRoutingMode, + "packages": appRoutingPackages, + }, + "dns_server": effectiveDnsServer, + "tun_stack": tunStack + }; + widget.prefs.setString('latest_config_json', jsonEncode(configMap)); + platform.invokeMethod('saveConfig', { + "configJson": jsonEncode(configMap) + }); + } + + @override + void dispose() { + _pollTimer?.cancel(); + _uptimeTimer?.cancel(); + _pulseController.dispose(); + _spinController.dispose(); + super.dispose(); + } + + Future _toggleConnection() async { + if (_state == ConnectionStateEnum.disconnected) { + if (_serverAddr.isEmpty || _accessKey.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please configure Server and Key in Settings')), + ); + return; + } + + setState(() { + _state = ConnectionStateEnum.connecting; + }); + _pulseController.repeat(reverse: true); + _spinController.repeat(); + + final dnsServer = widget.prefs.getString('dns_server'); + final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer; + final exDomains = widget.prefs.getString('ex_domains') ?? ''; + final exIps = widget.prefs.getString('ex_ips') ?? ''; + final exProcesses = widget.prefs.getString('ex_processes') ?? ''; + final debugMode = widget.prefs.getBool('debug_mode') ?? false; + final transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; + final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com'; + final stealthPort = widget.prefs.getString('stealth_port') ?? '443'; + final wss = widget.prefs.getBool('wss') ?? false; + final mtu = widget.prefs.getString('mtu') ?? '1140'; + final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; + final muxSessions = widget.prefs.getString('mux_sessions') ?? '2'; + final tunStack = 'ostp'; + + final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass'; + final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? []; + + final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'; + final configMap = { + "mode": "client", + "debug": debugMode, + "ostp": { + "server_addr": _serverAddr, + "local_bind_addr": "0.0.0.0:0", + "access_key": _accessKey, + "handshake_timeout_ms": 10000, + "io_timeout_ms": 5000, + "mtu": int.tryParse(mtu) ?? 1140, + }, + "local_proxy": { + "bind_addr": localBind, + "connect_timeout_ms": 15000, + }, + "transport": { + "mode": transportMode, + "stealth_sni": stealthSni, + "stealth_port": int.tryParse(stealthPort) ?? 443, + "wss": wss, + }, + "multiplex": { + "enabled": muxEnabled, + "sessions": int.tryParse(muxSessions) ?? 2, + }, + "reality": { + "enabled": widget.prefs.getBool('reality_enabled') ?? false, + "dest": "", + "private_key": "", + "pbk": widget.prefs.getString('pbk') ?? "", + "sid": widget.prefs.getString('sid') ?? "", + "sni_list": [] + }, + "tun": { + "enable": true, + "stack": tunStack + }, + "exclusions": { + "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), + "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), + }, + "app_rules": { + "mode": appRoutingMode, + "packages": appRoutingPackages, + }, + "dns_server": dnsServer, + "tun_stack": tunStack + }; + + widget.prefs.setString('latest_config_json', jsonEncode(configMap)); + + + try { + await platform.invokeMethod('saveConfig', { + "configJson": jsonEncode(configMap) + }); + await platform.invokeMethod('startTunnel', { + "configJson": jsonEncode(configMap) + }); + + bool started = false; + for (int i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + final isRunning = await platform.invokeMethod('isRunning'); + if (isRunning == true) { + started = true; + break; + } + } + + if (started) { + _setConnected(); + } else { + _setDisconnected(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to connect. Check logs for details.')), + ); + } + } + } catch (e, stackTrace) { + debugPrint("Failed to start tunnel: $e\n$stackTrace"); + _setDisconnected(); + if (mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Error', style: TextStyle(color: Colors.redAccent)), + content: SingleChildScrollView( + child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: e.toString())); + ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!'))); + }, + child: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } + } + } else { + try { + await platform.invokeMethod('stopTunnel'); + } catch (e) { + debugPrint("Stop error: $e"); + } + _setDisconnected(); + } + } + + Future _runAutoMode() async { + final mtus = [1500, 1350, 1280, 1140]; + final modes = [ + {'t': 'udp', 'w': false, 'r': false}, + {'t': 'uot', 'w': false, 'r': false}, + {'t': 'uot', 'w': true, 'r': false}, + {'t': 'uot', 'w': false, 'r': true}, + ]; + + if (_serverAddr.isEmpty || _accessKey.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please configure Server and Key first')), + ); + return; + } + + for (var mode in modes) { + for (var mtu in mtus) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)), + ); + + // Update prefs + await widget.prefs.setString('mtu', mtu.toString()); + await widget.prefs.setString('transport_mode', mode['t'] as String); + await widget.prefs.setBool('wss', mode['w'] as bool); + await widget.prefs.setBool('reality_enabled', mode['r'] as bool); + _updateLatestConfigJson(); + + setState(() { + _state = ConnectionStateEnum.connecting; + }); + _pulseController.repeat(reverse: true); + _spinController.repeat(); + + try { + final configJson = widget.prefs.getString('latest_config_json') ?? '{}'; + await platform.invokeMethod('startTunnel', {"configJson": configJson}); + + bool started = false; + for (int i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + final isRunning = await platform.invokeMethod('isRunning'); + if (isRunning == true) { + started = true; + break; + } + } + + if (started) { + _setConnected(); + // Wait to see if connection is stable and ping is successful + await Future.delayed(const Duration(seconds: 3)); + try { + final metricsJson = await platform.invokeMethod('getMetrics'); + if (metricsJson != null && metricsJson.isNotEmpty) { + final Map parsed = jsonDecode(metricsJson); + final rttMs = parsed['rtt_ms'] as int? ?? 0; + if (rttMs > 0) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')), + ); + } + return; // Stop on first working config + } + } + } catch (e) { + // Ignore metrics error + } + + // Connection seems unstable or no ping, stop and try next + await platform.invokeMethod('stopTunnel'); + _setDisconnected(); + } else { + _setDisconnected(); + } + } catch (e) { + _setDisconnected(); + } + } + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Auto search finished. No working config found.')), + ); + } + } + + void _setConnected() { + if (!mounted) return; + setState(() { + _state = ConnectionStateEnum.connected; + }); + _pulseController.stop(); + _pulseController.value = 1.0; + + _uptimeSecs = 0; + _uptimeTimer?.cancel(); + _uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() => _uptimeSecs++); + }); + + _startPollingMetrics(); + } + + void _startPollingMetrics() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async { + if (!mounted) return; + try { + final metricsJson = await platform.invokeMethod('getMetrics'); + if (metricsJson != null && metricsJson.isNotEmpty) { + final Map parsed = jsonDecode(metricsJson); + final bytesSent = parsed['bytes_sent'] as int? ?? 0; + final bytesRecv = parsed['bytes_recv'] as int? ?? 0; + final connState = parsed['connection_state'] as int? ?? 2; + final rttMs = parsed['rtt_ms'] as int? ?? 0; + + if (connState == 0 && _state != ConnectionStateEnum.disconnected) { + try { + await platform.invokeMethod('stopTunnel'); + } catch (e) { + debugPrint("Failed to stop background tunnel: $e"); + } + _setDisconnected(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Connection failed. Check logs for details.')), + ); + } + return; + } + + if (mounted) { + setState(() { + _download = _formatBytes(bytesRecv); + _upload = _formatBytes(bytesSent); + if (rttMs > 0 && !_isCheckingPing) { + _pingText = 'Server Ping: $rttMs ms'; + if (rttMs < 100) { + _pingColor = const Color(0xFF22D3A5); + } else if (rttMs < 250) { + _pingColor = Colors.amberAccent; + } else { + _pingColor = Colors.redAccent; + } + } + }); + } + } + } catch (e) { + debugPrint("Failed to get metrics: $e"); + } + }); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + Future _checkConnectionLatency() async { + if (_state != ConnectionStateEnum.connected) return; + + setState(() { + _isCheckingPing = true; + _pingText = 'Updating...'; + _pingColor = Colors.white70; + }); + + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + setState(() { + _isCheckingPing = false; + }); + } + } + + void _setDisconnected() { + if (!mounted) return; + setState(() { + _state = ConnectionStateEnum.disconnected; + _download = '0 B'; + _upload = '0 B'; + _pingText = 'Target Ping: -- ms'; + _pingColor = Colors.white54; + _isCheckingPing = false; + }); + _pulseController.stop(); + _pulseController.value = 0.0; + _spinController.stop(); + _uptimeTimer?.cancel(); + _pollTimer?.cancel(); + } + + String _formatTime(int s) { + final h = s ~/ 3600; + final m = (s % 3600) ~/ 60; + final sec = s % 60; + final pad = (int n) => n.toString().padLeft(2, '0'); + return h > 0 ? '$h:${pad(m)}:${pad(sec)}' : '${pad(m)}:${pad(sec)}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + body: Stack( + children: [ + Positioned( + top: -150, right: -100, + child: Container( + width: 400, height: 400, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primary.withOpacity(0.15), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), + child: Container(), + ), + ), + ), + Positioned( + bottom: -100, left: -100, + child: Container( + width: 350, height: 350, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.secondary.withOpacity(0.1), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), + child: Container(), + ), + ), + ), + + SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + _buildTopBar(theme), + Expanded(child: _buildStage(theme)), + _buildMetricsBar(theme), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildTopBar(ThemeData theme) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 12, height: 12, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: _state == ConnectionStateEnum.connected + ? theme.colorScheme.secondary + : theme.colorScheme.primary, + boxShadow: [ + BoxShadow( + color: _state == ConnectionStateEnum.connected + ? theme.colorScheme.secondary.withOpacity(0.5) + : theme.colorScheme.primary.withOpacity(0.5), + blurRadius: 10, + ) + ] + ), + ), + const SizedBox(width: 12), + const Text( + 'OSTP', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: 2.5, + color: Colors.white, + ), + ), + ], + ), + Row( + children: [ + IconButton( + iconSize: 30, + icon: const Icon(Icons.auto_mode_rounded, color: Colors.white), + onPressed: () { + if (_state != ConnectionStateEnum.disconnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Disconnect first to run Auto mode')), + ); + return; + } + _runAutoMode(); + }, + ), + IconButton( + iconSize: 30, + icon: const Icon(Icons.settings_outlined, color: Colors.white), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => SettingsScreen(prefs: widget.prefs)), + ); + _loadSettings(); + }, + ) + ], + ) + ], + ), + ); + } + + Widget _buildStage(ThemeData theme) { + Color getAccentColor() { + if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary; + return theme.colorScheme.primary; + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 260, height: 260, + child: Stack( + alignment: Alignment.center, + children: [ + if (_state != ConnectionStateEnum.disconnected) + RotationTransition( + turns: _spinController, + child: Container( + width: 240, height: 240, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: getAccentColor().withOpacity(0.25), + width: 2.0, + ), + ), + ), + ), + if (_state != ConnectionStateEnum.disconnected) + RotationTransition( + turns: ReverseAnimation(_spinController), + child: Container( + width: 200, height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: getAccentColor().withOpacity(0.15), + width: 1.5, + ), + ), + ), + ), + + AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + return Container( + width: 140, height: 140, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.surface, + border: Border.all( + color: _state == ConnectionStateEnum.disconnected + ? Colors.white.withOpacity(0.15) + : getAccentColor(), + width: 3, + ), + boxShadow: [ + if (_state != ConnectionStateEnum.disconnected) + BoxShadow( + color: getAccentColor().withOpacity(0.4 * (_state == ConnectionStateEnum.connected ? 1.0 : _pulseController.value)), + blurRadius: 40, + spreadRadius: 8, + ) + ] + ), + child: child, + ); + }, + child: Material( + color: Colors.transparent, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _toggleConnection, + child: Icon( + Icons.power_settings_new_rounded, + size: 60, + color: _state == ConnectionStateEnum.disconnected + ? Colors.white54 + : getAccentColor(), + ), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 40), + + Text( + _state == ConnectionStateEnum.disconnected ? 'Disconnected' : + _state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: _state == ConnectionStateEnum.disconnected ? Colors.white70 : getAccentColor(), + ), + ), + const SizedBox(height: 8), + Text( + _state == ConnectionStateEnum.connected ? _formatTime(_uptimeSecs) : 'Tap to protect your traffic', + style: const TextStyle( + fontSize: 16, + color: Colors.white54, + ), + ), + + const SizedBox(height: 30), + + AnimatedOpacity( + opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.08), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.dns_rounded, size: 18, color: Colors.white70), + const SizedBox(width: 10), + Text( + _serverAddr, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.03), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withOpacity(0.06)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'CONNECTION TEST', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white38, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + _pingText, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: _pingColor, + ), + ), + ], + ), + _isCheckingPing + ? const SizedBox( + width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70), + ) + : TextButton.icon( + onPressed: _checkConnectionLatency, + icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary), + label: Text( + 'Test Ping', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: theme.colorScheme.primary, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + backgroundColor: theme.colorScheme.primary.withOpacity(0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ), + ], + ), + ) + ], + ); + } + + Widget _buildMetricsBar(ThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.04), + border: Border(top: BorderSide(color: Colors.white.withOpacity(0.08))), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary), + Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)), + _buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary), + ], + ), + ); + } + + Widget _buildMetricItem(IconData icon, String label, String value, Color color) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: color), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white54, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ) + ], + ); + } +} + diff --git a/ostp-flutter/lib/ui/logs_screen.dart b/ostp-flutter/lib/ui/logs_screen.dart new file mode 100644 index 0000000..f114256 --- /dev/null +++ b/ostp-flutter/lib/ui/logs_screen.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class LogsScreen extends StatefulWidget { + const LogsScreen({super.key}); + + @override + State createState() => _LogsScreenState(); +} + +class _LogsScreenState extends State { + static const platform = MethodChannel('com.ospab.ostp/vpn'); + Timer? _pollTimer; + final List _logs = []; + final ScrollController _scrollCtrl = ScrollController(); + + @override + void initState() { + super.initState(); + _fetchLogs(); + _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _fetchLogs()); + } + + @override + void dispose() { + _pollTimer?.cancel(); + _scrollCtrl.dispose(); + super.dispose(); + } + + Future _fetchLogs() async { + try { + final String logsJson = await platform.invokeMethod('getLogs'); + if (logsJson.isNotEmpty && logsJson != "[]") { + final List parsed = jsonDecode(logsJson); + if (parsed.isNotEmpty) { + setState(() { + _logs.addAll(parsed.map((e) => e.toString())); + }); + Future.delayed(const Duration(milliseconds: 100), () { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo(_scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + } + }); + } + } + } catch (e, stackTrace) { + debugPrint("Failed to fetch logs: $e\n$stackTrace"); + if (mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Logs Error', style: TextStyle(color: Colors.redAccent)), + content: SingleChildScrollView( + child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: e.toString())); + ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!'))); + }, + child: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } + } + } + + Future _clearLogs() async { + await platform.invokeMethod('clearLogs'); + setState(() { + _logs.clear(); + }); + } + + Future _copyLogs() async { + final text = _logs.join('\n'); + await Clipboard.setData(ClipboardData(text: text)); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Logs copied to clipboard'))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('System Logs', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), + backgroundColor: Theme.of(context).colorScheme.surface, + elevation: 0, + actions: [ + IconButton(icon: const Icon(Icons.delete_outline), onPressed: _clearLogs, tooltip: 'Clear'), + IconButton(icon: const Icon(Icons.copy_rounded), onPressed: _copyLogs, tooltip: 'Copy All'), + ], + ), + body: Container( + color: Colors.black, + padding: const EdgeInsets.all(12), + child: ListView.builder( + controller: _scrollCtrl, + itemCount: _logs.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Text( + _logs[index], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Colors.greenAccent, + ), + ), + ); + }, + ), + ), + ); + } +} + diff --git a/ostp-flutter/lib/ui/qr_scanner_screen.dart b/ostp-flutter/lib/ui/qr_scanner_screen.dart new file mode 100644 index 0000000..08c8a58 --- /dev/null +++ b/ostp-flutter/lib/ui/qr_scanner_screen.dart @@ -0,0 +1,212 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class QRScannerScreen extends StatefulWidget { + const QRScannerScreen({super.key}); + + @override + State createState() => _QRScannerScreenState(); +} + +class _QRScannerScreenState extends State { + final MobileScannerController controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + DateTime? lastErrorTime; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan QR Code'), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Stack( + alignment: Alignment.center, + children: [ + MobileScanner( + controller: controller, + onDetect: (capture) { + final List barcodes = capture.barcodes; + for (final barcode in barcodes) { + if (barcode.rawValue != null) { + if (barcode.rawValue!.startsWith('ostp://')) { + controller.stop(); + Navigator.pop(context, barcode.rawValue); + return; + } else { + final now = DateTime.now(); + if (lastErrorTime == null || now.difference(lastErrorTime!) > const Duration(seconds: 3)) { + lastErrorTime = now; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid QR Code. Must be an OSTP connection link.'), + backgroundColor: Colors.redAccent, + duration: Duration(seconds: 2), + ), + ); + } + } + } + } + }, + ), + Container( + decoration: ShapeDecoration( + shape: QrScannerOverlayShape( + borderColor: Theme.of(context).colorScheme.primary, + borderRadius: 10, + borderLength: 30, + borderWidth: 10, + cutOutSize: 300, + ), + ), + ), + ], + ), + ); + } +} + +class QrScannerOverlayShape extends ShapeBorder { + final Color borderColor; + final double borderWidth; + final double borderRadius; + final double borderLength; + final double cutOutSize; + + const QrScannerOverlayShape({ + this.borderColor = Colors.red, + this.borderWidth = 3.0, + this.borderRadius = 0.0, + this.borderLength = 20.0, + this.cutOutSize = 250.0, + }); + + @override + EdgeInsetsGeometry get dimensions => const EdgeInsets.all(10); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path() + ..fillType = PathFillType.evenOdd + ..addPath(getOuterPath(rect), Offset.zero); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + Path path = Path()..addRect(rect); + rect = Rect.fromCenter( + center: rect.center, + width: cutOutSize, + height: cutOutSize, + ); + path.addRect(rect); + return path; + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth; + + final backgroundPaint = Paint() + ..color = Colors.black54 + ..style = PaintingStyle.fill; + + final cutOutRect = Rect.fromCenter( + center: rect.center, + width: cutOutSize, + height: cutOutSize, + ); + + final backgroundPath = Path() + ..addRect(rect) + ..addRect(cutOutRect) + ..fillType = PathFillType.evenOdd; + + canvas.drawPath(backgroundPath, backgroundPaint); + + final path = Path(); + // Top left + path.moveTo(cutOutRect.left, cutOutRect.top + borderLength); + path.lineTo(cutOutRect.left, cutOutRect.top + borderRadius); + path.arcToPoint( + Offset(cutOutRect.left + borderRadius, cutOutRect.top), + radius: Radius.circular(borderRadius), + ); + path.lineTo(cutOutRect.left + borderLength, cutOutRect.top); + + // Top right + path.moveTo(cutOutRect.right - borderLength, cutOutRect.top); + path.lineTo(cutOutRect.right - borderRadius, cutOutRect.top); + path.arcToPoint( + Offset(cutOutRect.right, cutOutRect.top + borderRadius), + radius: Radius.circular(borderRadius), + ); + path.lineTo(cutOutRect.right, cutOutRect.top + borderLength); + + // Bottom left + path.moveTo(cutOutRect.left, cutOutRect.bottom - borderLength); + path.lineTo(cutOutRect.left, cutOutRect.bottom - borderRadius); + path.arcToPoint( + Offset(cutOutRect.left + borderRadius, cutOutRect.bottom), + radius: Radius.circular(borderRadius), + clockwise: false, + ); + path.lineTo(cutOutRect.left + borderLength, cutOutRect.bottom); + + // Bottom right + path.moveTo(cutOutRect.right - borderLength, cutOutRect.bottom); + path.lineTo(cutOutRect.right - borderRadius, cutOutRect.bottom); + path.arcToPoint( + Offset(cutOutRect.right, cutOutRect.bottom - borderRadius), + radius: Radius.circular(borderRadius), + clockwise: false, + ); + path.lineTo(cutOutRect.right, cutOutRect.bottom - borderLength); + + canvas.drawPath(path, borderPaint); + + // Line in the middle + final linePaint = Paint() + ..color = borderColor.withOpacity(0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + + canvas.drawLine( + Offset(cutOutRect.left + 20, cutOutRect.center.dy), + Offset(cutOutRect.right - 20, cutOutRect.center.dy), + linePaint, + ); + } + + @override + ShapeBorder scale(double t) { + return QrScannerOverlayShape( + borderColor: borderColor, + borderWidth: borderWidth * t, + borderRadius: borderRadius * t, + borderLength: borderLength * t, + cutOutSize: cutOutSize * t, + ); + } +} + diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart new file mode 100644 index 0000000..1ace5a1 --- /dev/null +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -0,0 +1,516 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'app_routing_screen.dart'; +import 'logs_screen.dart'; +import 'qr_scanner_screen.dart'; + +class SettingsScreen extends StatefulWidget { + final SharedPreferences prefs; + const SettingsScreen({super.key, required this.prefs}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late TextEditingController _importCtrl; + late TextEditingController _serverCtrl; + late TextEditingController _localBindCtrl; + late TextEditingController _keyCtrl; + late TextEditingController _dnsCtrl; + late TextEditingController _mtuCtrl; + late TextEditingController _domainsCtrl; + late TextEditingController _ipsCtrl; + late TextEditingController _processesCtrl; + late TextEditingController _stealthSniCtrl; + late TextEditingController _stealthPortCtrl; + late TextEditingController _pbkCtrl; + late TextEditingController _sidCtrl; + + bool _obscureKey = true; + bool _debugMode = false; + bool _wss = false; + bool _realityEnabled = false; + String _transportMode = 'udp'; // 'udp' | 'uot' + String _tunStack = 'ostp'; // 'system' | 'ostp' + bool _muxEnabled = false; + late TextEditingController _muxSessionsCtrl; + bool _owndns = false; + + @override + void initState() { + super.initState(); + _importCtrl = TextEditingController(); + _serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443'); + _localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088'); + _keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? ''); + _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1'); + _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140'); + _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? ''); + _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? ''); + _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? ''); + _stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? ''); + _stealthPortCtrl = TextEditingController(text: widget.prefs.getString('stealth_port') ?? '443'); + _pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? ''); + _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? ''); + _wss = widget.prefs.getBool('wss') ?? false; + _realityEnabled = widget.prefs.getBool('reality_enabled') ?? false; + _transportMode = widget.prefs.getString('transport_mode') ?? 'udp'; + _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp'; + _debugMode = widget.prefs.getBool('debug_mode') ?? false; + _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; + _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); + _owndns = widget.prefs.getBool('owndns') ?? false; + } + + @override + void dispose() { + _saveSettings(); + _importCtrl.dispose(); + _serverCtrl.dispose(); + _localBindCtrl.dispose(); + _keyCtrl.dispose(); + _dnsCtrl.dispose(); + _mtuCtrl.dispose(); + _domainsCtrl.dispose(); + _ipsCtrl.dispose(); + _processesCtrl.dispose(); + _stealthSniCtrl.dispose(); + _stealthPortCtrl.dispose(); + _pbkCtrl.dispose(); + _sidCtrl.dispose(); + _muxSessionsCtrl.dispose(); + super.dispose(); + } + + void _saveSettings() { + widget.prefs.setString('server_addr', _serverCtrl.text.trim()); + widget.prefs.setString('local_bind', _localBindCtrl.text.trim()); + widget.prefs.setString('access_key', _keyCtrl.text.trim()); + widget.prefs.setString('dns_server', _dnsCtrl.text.trim()); + widget.prefs.setString('mtu', _mtuCtrl.text.trim()); + widget.prefs.setString('ex_domains', _domainsCtrl.text.trim()); + widget.prefs.setString('ex_ips', _ipsCtrl.text.trim()); + widget.prefs.setString('ex_processes', _processesCtrl.text.trim()); + widget.prefs.setBool('debug_mode', _debugMode); + widget.prefs.setBool('wss', _wss); + widget.prefs.setBool('reality_enabled', _realityEnabled); + widget.prefs.setString('transport_mode', _transportMode); + widget.prefs.setString('tun_stack', _tunStack); + widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim()); + widget.prefs.setString('stealth_port', _stealthPortCtrl.text.trim()); + widget.prefs.setString('pbk', _pbkCtrl.text.trim()); + widget.prefs.setString('sid', _sidCtrl.text.trim()); + widget.prefs.setBool('mux_enabled', _muxEnabled); + widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); + widget.prefs.setBool('owndns', _owndns); + } + + Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 10), + TextField( + controller: controller, + obscureText: isPassword && _obscureKey, + maxLines: maxLines, + style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: Colors.white30), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + suffixIcon: isPassword ? IconButton( + icon: Icon(_obscureKey ? Icons.visibility : Icons.visibility_off, color: Colors.white54), + onPressed: () => setState(() => _obscureKey = !_obscureKey), + ) : null, + ), + ), + const SizedBox(height: 24), + ], + ); + } + + Widget _buildToggle(String title, String subtitle, bool value, ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text(subtitle, style: const TextStyle(fontSize: 13, color: Colors.white54)), + ], + ), + ), + Switch( + value: value, + onChanged: (v) { + onChanged(v); + _saveSettings(); + }, + activeColor: Theme.of(context).colorScheme.secondary, + activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3), + inactiveTrackColor: Colors.white10, + ) + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Configuration', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: const Icon(Icons.qr_code_scanner_rounded), + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QRScannerScreen()), + ); + if (result != null && result is String && result.startsWith('ostp://')) { + setState(() { + _importCtrl.text = result; + }); + } + }, + ) + ], + ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + children: [ + // Quick Import Row + Row( + children: [ + Expanded( + child: TextField( + controller: _importCtrl, + decoration: InputDecoration( + hintText: 'Paste ostp:// share link...', + hintStyle: const TextStyle(color: Colors.white30, fontSize: 14), + filled: true, + fillColor: Colors.white.withOpacity(0.05), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () { + final raw = _importCtrl.text.trim(); + if (raw.isEmpty) return; + try { + if (!raw.startsWith('ostp://')) { + throw Exception('Link must start with ostp://'); + } + final uri = Uri.parse(raw); + final key = Uri.decodeComponent(uri.userInfo); + final host = uri.authority.replaceFirst(uri.userInfo + '@', ''); + if (key.isEmpty || host.isEmpty) { + throw Exception('Incomplete link parameters'); + } + setState(() { + _serverCtrl.text = host; + _keyCtrl.text = key; + _stealthSniCtrl.text = uri.queryParameters['sni'] ?? ''; + _pbkCtrl.text = uri.queryParameters['pbk'] ?? ''; + _sidCtrl.text = uri.queryParameters['sid'] ?? ''; + _wss = uri.queryParameters['wss'] == 'true'; + _realityEnabled = uri.queryParameters['reality'] == 'true'; + final type = uri.queryParameters['type'] ?? 'udp'; + _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp'; + _owndns = uri.queryParameters['owndns'] == 'true'; + _importCtrl.clear(); + _saveSettings(); + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}'))); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + backgroundColor: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white)), + ) + ], + ), + + const SizedBox(height: 30), + + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.02), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withOpacity(0.05)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField('Server Address', _serverCtrl, hint: 'host:port'), + _buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'), + _buildTextField('Access Key', _keyCtrl, hint: 'Secure access key', isPassword: true), + _buildTextField('Custom DNS Server', _dnsCtrl, hint: '1.1.1.1 (e.g. 8.8.8.8)'), + _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'), + + // ── Transport Mode ─────────────────────────────────────── + const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)), + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + RadioListTile( + value: 'udp', + groupValue: _transportMode, + title: const Text('UDP (по умолчанию)', style: TextStyle(fontWeight: FontWeight.w600)), + subtitle: const Text('Быстро, работает через Wi-Fi и большинство сетей', style: TextStyle(color: Colors.white54, fontSize: 12)), + activeColor: Theme.of(context).colorScheme.secondary, + onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), + ), + Divider(color: Colors.white.withOpacity(0.05), height: 1), + RadioListTile( + value: 'uot', + groupValue: _transportMode, + title: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF6C72FF).withOpacity(0.2), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('xHTTP Стелс', style: TextStyle(fontSize: 10, color: Color(0xFF6C72FF), fontWeight: FontWeight.bold)), + ), + ], + ), + subtitle: const Text('Маскировка под HTTP-поток, обходит белые списки (уровень 1)', style: TextStyle(color: Colors.white54, fontSize: 12)), + activeColor: Theme.of(context).colorScheme.primary, + onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildToggle('WebSocket (WSS)', 'Инкапсулировать транспорт в RFC 6455 (для строгого DPI)', _wss, (val) { + setState(() { + _wss = val; + }); + }), + const SizedBox(height: 16), + + // Stealth parameters + AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + crossFadeState: _transportMode == 'uot' ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF6C72FF).withOpacity(0.06), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFF6C72FF).withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.security, size: 16, color: Color(0xFF6C72FF)), + const SizedBox(width: 8), + const Text('Стелс параметры', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF6C72FF), fontSize: 14)), + ], + ), + const SizedBox(height: 4), + const Text( + 'Укажи домен из белого списка. OSTP подключится к серверу и подделает SNI / HTTP Host.', + style: TextStyle(fontSize: 12, color: Colors.white38), + ), + const SizedBox(height: 16), + Builder(builder: (context) { + final List domains = [ + 'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me', + 'top-fwz1.mail.ru', 'sso.passport.yandex.ru', + 'sberbank.ru', 'ad.mail.ru', 'ads.vk.com', + 'login.vk.com', 'api.sberbank.ru', 'ok.ru', + 'rostelecom.ru', 'rt.ru', 'tinkoff.ru', + 'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com' + ]; + String currentVal = _stealthSniCtrl.text.trim(); + if (currentVal.isEmpty) currentVal = 'vk.com'; + if (!domains.contains(currentVal)) { + domains.add(currentVal); + } + return DropdownButtonFormField( + value: currentVal, + dropdownColor: const Color(0xFF1E1E2C), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + labelText: 'Стелс Домен (Автоподставление)', + labelStyle: const TextStyle(color: Colors.white54, fontSize: 13), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + items: domains.map((String domain) { + return DropdownMenuItem( + value: domain, + child: Text(domain), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _stealthSniCtrl.text = newValue; + _stealthPortCtrl.text = '443'; + _saveSettings(); + }); + } + }, + ); + }), + const SizedBox(height: 16), + _buildToggle('XTLS-Reality', 'Подделка TLS-сессии (Stealth-домен должен быть TLS 1.3)', _realityEnabled, (val) { + setState(() { + _realityEnabled = val; + }); + }), + const SizedBox(height: 16), + _buildTextField('Reality PublicKey (pbk)', _pbkCtrl, hint: 'Публичный ключ сервера'), + _buildTextField('Reality ShortId (sid)', _sidCtrl, hint: 'Опционально (необязательно)'), + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), + + + const SizedBox(height: 16), + _buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)), + AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: _muxEnabled ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: _buildTextField('Mux Sessions', _muxSessionsCtrl, hint: '4'), + ), + secondChild: const SizedBox.shrink(), + ), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: _buildToggle('Debug Logs', 'Verbose output', _debugMode, (v) => setState(() => _debugMode = v))), + Padding( + padding: const EdgeInsets.only(bottom: 24.0, left: 10), + child: IconButton( + icon: const Icon(Icons.receipt_long_rounded), + color: Theme.of(context).colorScheme.primary, + tooltip: 'View Logs', + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen())); + }, + ), + ), + ], + ), + + + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Row( + children: [ + Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(width: 10), + Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)), + ], + ), + ), + + _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3, isMono: true), + _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3, isMono: true), + + // Premium app routing trigger button + InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), + ), + child: Row( + children: [ + Icon(Icons.apps_rounded, color: Theme.of(context).colorScheme.primary, size: 24), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Per-App Connection Rules', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white), + ), + SizedBox(height: 4), + Text( + 'Choose which apps bypass or use VPN', + style: TextStyle(fontSize: 13, color: Colors.white54), + ), + ], + ), + ), + const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16), + ], + ), + ), + ), + const SizedBox(height: 10), + ], + ), + ), + + const SizedBox(height: 40), + ], + ), + ); + } +} + diff --git a/ostp-flutter/ostp-client-release.apk b/ostp-flutter/ostp-client-release.apk index 5ce1de0..f83a53a 100644 Binary files a/ostp-flutter/ostp-client-release.apk and b/ostp-flutter/ostp-client-release.apk differ diff --git a/ostp-flutter/pubspec.lock b/ostp-flutter/pubspec.lock index 6334819..c1f396a 100644 --- a/ostp-flutter/pubspec.lock +++ b/ostp-flutter/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -25,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -78,6 +110,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -96,6 +136,14 @@ packages: description: flutter source: sdk version: "0.0.0" + image: + dependency: transitive + description: + name: image + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + url: "https://pub.dev" + source: hosted + version: "4.9.1" json_annotation: dependency: transitive description: @@ -208,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -224,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" screen_retriever: dependency: transitive description: @@ -429,6 +493,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.11.4 <4.0.0" flutter: ">=3.35.0" diff --git a/ostp-flutter/pubspec.yaml b/ostp-flutter/pubspec.yaml index 732595a..0b940c0 100644 --- a/ostp-flutter/pubspec.yaml +++ b/ostp-flutter/pubspec.yaml @@ -49,6 +49,12 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + flutter_launcher_icons: "^0.13.1" + +flutter_launcher_icons: + android: "launcher_icon" + ios: false + image_path: "../ostp-gui/src-tauri/icons/icon.png" # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/ostp-flutter/test/widget_test.dart b/ostp-flutter/test/widget_test.dart index b3e934c..015d318 100644 Binary files a/ostp-flutter/test/widget_test.dart and b/ostp-flutter/test/widget_test.dart differ diff --git a/ostp-gui/src-tauri/Cargo.lock b/ostp-gui/src-tauri/Cargo.lock index 5bca46c..914abec 100644 --- a/ostp-gui/src-tauri/Cargo.lock +++ b/ostp-gui/src-tauri/Cargo.lock @@ -2641,7 +2641,7 @@ dependencies = [ [[package]] name = "ostp-client" -version = "0.2.79" +version = "0.2.83" dependencies = [ "anyhow", "base64 0.22.1", @@ -2672,7 +2672,7 @@ dependencies = [ [[package]] name = "ostp-core" -version = "0.2.79" +version = "0.2.83" dependencies = [ "anyhow", "bytes", diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 2c52aa8..badaa76 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ struct TunConfig { ipv4_address: Option, dns: Option, stack: Option, + kill_switch: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -186,6 +187,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi }, 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), } } @@ -214,7 +216,8 @@ async fn get_config() -> Result { "enable": false, "wintun_path": "./wintun.dll", "ipv4_address": "10.1.0.2/24", - "dns": "1.1.1.1" + "dns": "1.1.1.1", + "kill_switch": false }, "_comment_exclude": "Bypass tunnel for these domains/IPs (only works in proxy mode)", @@ -290,10 +293,20 @@ async fn reload_tunnel(state: tauri::State<'_, AppState>) -> Result c, + AppMode::Server(_) => return Err("GUI only supports Client mode.".into()), + }; + let mode_str = if client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false) { "tun" } else { "proxy" }; + let core_cfg = map_to_client_config(&client_cfg, mode_str); + let config_str = serde_json::to_string(&core_cfg).unwrap(); + match &guard.tunnel { Some(TunnelHandle::Helper(h)) => { let cmd = format!( @@ -389,7 +402,7 @@ async fn start_proxy_in_process( let (shutdown_tx, shutdown_rx) = watch::channel(false); let metrics_clone = metrics.clone(); let handle = tokio::spawn(async move { - match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx).await { + match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx, None).await { Ok(_) => Ok(()), Err(e) => Err(e.to_string()), } @@ -527,7 +540,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow:: let exe_wstr: Vec = exe.as_os_str().encode_wide().chain(Some(0)).collect(); let verb_wstr: Vec = OsStr::new("runas").encode_wide().chain(Some(0)).collect(); - let params_str = format!("--token {} --port {}", token, port); + let params_str = format!("--port {} --token {}", port, token); let params_wstr: Vec = OsStr::new(¶ms_str).encode_wide().chain(Some(0)).collect(); #[link(name = "shell32")] extern "system" { fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize; } @@ -536,6 +549,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow:: let dir_wstr: Vec = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect(); let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 0) }; + if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); } Ok(()) } @@ -543,8 +557,31 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow:: #[cfg(not(target_os = "windows"))] fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); } +#[cfg(target_os = "windows")] +fn show_error_dialog(msg: &str) { + use std::os::windows::ffi::OsStrExt; + let msg_w: Vec = std::ffi::OsStr::new(msg).encode_wide().chain(Some(0)).collect(); + let title_w: Vec = std::ffi::OsStr::new("OSTP GUI Error").encode_wide().chain(Some(0)).collect(); + #[link(name = "user32")] extern "system" { fn MessageBoxW(hWnd: *mut std::ffi::c_void, lpText: *const u16, lpCaption: *const u16, uType: u32) -> i32; } + unsafe { MessageBoxW(std::ptr::null_mut(), msg_w.as_ptr(), title_w.as_ptr(), 0x10); } // 0x10 is MB_ICONERROR +} + +#[cfg(not(target_os = "windows"))] +fn show_error_dialog(msg: &str) { + println!("ERROR: {}", msg); +} + +static SINGLE_INSTANCE_LOCK: std::sync::OnceLock = std::sync::OnceLock::new(); + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + if let Ok(listener) = std::net::TcpListener::bind("127.0.0.1:49153") { + let _ = SINGLE_INSTANCE_LOCK.set(listener); + } else { + show_error_dialog("Приложение OSTP GUI уже запущено!"); + return; + } + let state = AppState(Mutex::new(AppStateInner { tunnel: None })); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) diff --git a/ostp-gui/src/i18n.js b/ostp-gui/src/i18n.js index a94422c..20b6657 100644 --- a/ostp-gui/src/i18n.js +++ b/ostp-gui/src/i18n.js @@ -25,6 +25,8 @@ const translations = { owndns_hint: 'Route DNS queries through the VPN server (10.1.0.1)', label_tun: 'TUN Tunnel Mode', tun_hint: 'Route all system traffic (Admin req.)', + label_kill_switch: 'Kill Switch', + kill_switch_hint: 'Block non-VPN traffic when tunnel drops', label_transport: 'Transport Protocol', label_mtu: 'MTU Size', label_transport: 'Transport Protocol', @@ -72,6 +74,8 @@ const translations = { owndns_hint: 'Направлять DNS-запросы через VPN сервер (10.1.0.1)', label_tun: 'Режим TUN-туннеля', tun_hint: 'Направить весь трафик (нужны права администратора)', + label_kill_switch: 'Kill Switch', + kill_switch_hint: 'Блокировать трафик вне VPN при обрыве связи', label_transport: 'Транспортный протокол', label_mtu: 'Размер MTU', label_transport: 'Транспортный протокол', diff --git a/ostp-gui/src/index.html b/ostp-gui/src/index.html index 6175df9..d33ddf3 100644 --- a/ostp-gui/src/index.html +++ b/ostp-gui/src/index.html @@ -257,6 +257,19 @@ + +
diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index 30b29d1..05dce35 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -51,6 +51,7 @@ const inPbk = $('in-pbk'); const inSid = $('in-sid'); const inMtu = $('in-mtu'); const inTun = $('in-tun-mode'); +const inKillSwitch = $('in-kill-switch'); const inMux = $('in-mux-mode'); const inMuxSessions = $('in-mux-sessions'); const inDebug = $('in-debug'); @@ -91,12 +92,18 @@ function showToast(msg, variant = '') { }, 2400); } -// ── DNS visibility ──────────────────────────────────────────────────────────── +// ── DNS & Kill Switch visibility ────────────────────────────────────────────── function updateDnsVisibility() { if (!groupCustomDns || !inOwndns) return; groupCustomDns.style.display = inOwndns.checked ? 'none' : 'block'; } +function updateKillSwitchVisibility() { + const group = $('group-kill-switch'); + if (!group || !inTun) return; + group.style.display = inTun.checked ? 'flex' : 'none'; +} + // ── State machine ──────────────────────────────────────────────────────────── function setState(next) { @@ -162,19 +169,22 @@ function setState(next) { // ── Polling ────────────────────────────────────────────────────────────────── async function poll() { + if (!pollTimer) return; try { const code = await invoke('get_tunnel_status'); + if (!pollTimer) return; // Prevent race condition if disconnected during await + if (code === 0) { setState('disconnected'); return; } else if (code === 1) setState('connecting'); else if (code === 2) setState('connected'); const metrics = await invoke('get_metrics'); - if (metrics) { + if (metrics && pollTimer) { metricDown.textContent = fmtBytes(metrics.bytes_recv); metricUp.textContent = fmtBytes(metrics.bytes_sent); } } catch { - setState('disconnected'); + if (pollTimer) setState('disconnected'); } } @@ -202,10 +212,12 @@ async function handleToggle() { } else { setState('disconnected'); showToast(t('toast_error') || 'Failed to connect', 'error'); + alert(t('toast_error') || 'Failed to connect'); } } catch (err) { setState('disconnected'); showToast(String(err), 'error'); + alert(String(err)); } } else { try { await invoke('stop_tunnel'); } catch { /* ignore */ } @@ -243,7 +255,8 @@ async function loadConfigIntoForm() { inPbk.value = c.reality?.pbk || ''; inSid.value = c.reality?.sid || ''; inMtu.value = c.mtu || ''; - inTun.checked = !!c.tun?.enabled; + inTun.checked = !!c.tun?.enable; + if (inKillSwitch) inKillSwitch.checked = !!c.tun?.kill_switch; inMux.checked = !!c.mux?.enabled; inMuxSessions.value = c.mux?.sessions || ''; @@ -253,6 +266,7 @@ async function loadConfigIntoForm() { inOwndns.checked = isOwndns; inDns.value = isOwndns ? '' : savedDns; updateDnsVisibility(); + updateKillSwitchVisibility(); inDebug.checked = !!c.debug; @@ -307,8 +321,8 @@ async function handleSave(silent = false) { } const mtuStr = inMtu.value.trim(); - if (mtuStr) rawConfig.ostp.mtu = parseInt(mtuStr, 10); - else delete rawConfig.ostp.mtu; + if (mtuStr) rawConfig.mtu = parseInt(mtuStr, 10); + else delete rawConfig.mtu; if (inMux.checked) { const s = parseInt(inMuxSessions.value.trim(), 10); @@ -319,6 +333,7 @@ async function handleSave(silent = false) { rawConfig.tun = rawConfig.tun || {}; rawConfig.tun.enable = inTun.checked; + rawConfig.tun.kill_switch = inKillSwitch ? inKillSwitch.checked : false; rawConfig.tun.wintun_path = rawConfig.tun.wintun_path || './wintun.dll'; rawConfig.tun.ipv4_address = rawConfig.tun.ipv4_address || '10.1.0.2/24'; rawConfig.tun.stack = 'ostp'; @@ -384,12 +399,14 @@ window.addEventListener('DOMContentLoaded', async () => { applyTranslations(); setState('disconnected'); updateDnsVisibility(); // initialise field visibility from current checkbox state + updateKillSwitchVisibility(); // Event wiring if (window.__TAURI__ && window.__TAURI__.event) { window.__TAURI__.event.listen('tunnel-error', (evt) => { setState('disconnected'); showToast(String(evt.payload), 'error'); + alert(String(evt.payload)); }); } @@ -474,6 +491,10 @@ window.addEventListener('DOMContentLoaded', async () => { updateDnsVisibility(); scheduleAutoSave(); }); + inTun.addEventListener('change', () => { + updateKillSwitchVisibility(); + scheduleAutoSave(); + }); importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); }); // Auto-save wiring diff --git a/ostp-jni/src/lib.rs b/ostp-jni/src/lib.rs index e8fdd46..ee93cad 100644 --- a/ostp-jni/src/lib.rs +++ b/ostp-jni/src/lib.rs @@ -212,6 +212,9 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient( let (cmd_tx, cmd_rx) = mpsc::channel(128); let (shutdown_tx, shutdown_rx) = watch::channel(false); let proxy_shutdown_rx = shutdown_tx.subscribe(); + + // Create exclusions channel + let (_, exclusions_rx) = watch::channel(config.exclusions.clone()); let metrics_clone = Arc::clone(&metrics); @@ -225,7 +228,7 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient( tunnel::run_local_proxy( config_proxy.local_proxy, config_proxy.ostp, - config_proxy.exclusions, + exclusions_rx, config_proxy.debug, proxy_shutdown_rx, proxy_events_tx, diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml index 111ce3c..2cff111 100644 --- a/ostp-server/Cargo.toml +++ b/ostp-server/Cargo.toml @@ -30,3 +30,4 @@ simple-dns = "0.11.3" hex = "0.4.3" chacha20poly1305.workspace = true x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } +chrono = "0.4.44" diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index df80329..80ec1f4 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -32,6 +32,7 @@ use serde::{Deserialize, Serialize}; use tower_http::cors::{Any, CorsLayer}; use crate::dispatcher::{UserStats, UserStatsSnapshot}; +use crate::outbound::OutboundRule; // ── Shared state for API handlers ──────────────────────────────────────────── @@ -52,6 +53,28 @@ pub struct ApiState { pub reality_query: String, pub config_path: Option, pub dns_server: std::sync::Arc, + pub audit_logs: Arc>>, + pub router: std::sync::Arc, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditLogEntry { + pub id: String, + pub time: String, + #[serde(rename = "eventEn")] + pub event_en: String, + #[serde(rename = "eventRu")] + pub event_ru: String, + pub success: bool, +} + +#[derive(Deserialize)] +pub struct CreateAuditLogRequest { + #[serde(rename = "eventEn")] + pub event_en: String, + #[serde(rename = "eventRu")] + pub event_ru: String, + pub success: bool, } // ── API configuration ──────────────────────────────────────────────────────── @@ -223,7 +246,15 @@ pub fn create_api_router(state: ApiState) -> Router { .route("/login", post(handle_login)) .route("/dns/config", get(handle_get_dns_config).post(handle_post_dns_config)) .route("/dns/queries", get(handle_get_dns_queries)) - .route("/dns/blocklists/refresh", post(handle_refresh_blocklists)); + .route("/dns/blocklists/refresh", post(handle_refresh_blocklists)) + .route( + "/audit", + get(handle_get_audit) + .post(handle_create_audit) + .delete(handle_clear_audit), + ) + .route("/users/bulk", post(handle_bulk_create_users)) + .route("/router/rules", get(handle_get_rules).put(handle_put_rules)); let webpath = state.webpath.clone(); let webpath = webpath.trim_matches('/'); @@ -262,6 +293,7 @@ pub async fn start_api_server( reality_query: String, config_path: Option, dns_server: std::sync::Arc, + router: std::sync::Arc, ) { let state = ApiState { access_keys, @@ -277,6 +309,8 @@ pub async fn start_api_server( reality_query, config_path, dns_server, + audit_logs: Arc::new(RwLock::new(Vec::new())), + router, }; let app = create_api_router(state); @@ -542,15 +576,16 @@ async fn handle_list_users( let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner()); let mut users: Vec = keys.iter().map(|(key, meta)| { - if let Some(us) = stats.get(key) { + if let Some(st) = stats.get(key) { UserStatsSnapshot { access_key: key.clone(), name: meta.name.clone(), - bytes_up: us.bytes_up.load(Ordering::Relaxed), - bytes_down: us.bytes_down.load(Ordering::Relaxed), - connections: us.connections.load(Ordering::Relaxed), - limit_bytes: us.limit_bytes, - online: true, + bytes_up: st.bytes_up.load(Ordering::Relaxed), + bytes_down: st.bytes_down.load(Ordering::Relaxed), + connections: st.connections.load(Ordering::Relaxed), + limit_bytes: st.limit_bytes, + online: st.connections.load(Ordering::Relaxed) > 0, + last_seen: None, } } else { UserStatsSnapshot { @@ -561,6 +596,7 @@ async fn handle_list_users( connections: 0, limit_bytes: meta.limit_bytes, online: false, + last_seen: None, } } }).collect(); @@ -586,15 +622,16 @@ async fn handle_get_user( }; let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner()); - let snapshot = if let Some(us) = stats.get(&key) { + let snapshot = if let Some(st) = stats.get(&key) { UserStatsSnapshot { access_key: key.clone(), name: meta.name.clone(), - bytes_up: us.bytes_up.load(Ordering::Relaxed), - bytes_down: us.bytes_down.load(Ordering::Relaxed), - connections: us.connections.load(Ordering::Relaxed), - limit_bytes: us.limit_bytes, - online: true, + bytes_up: st.bytes_up.load(Ordering::Relaxed), + bytes_down: st.bytes_down.load(Ordering::Relaxed), + connections: st.connections.load(Ordering::Relaxed), + limit_bytes: st.limit_bytes, + online: st.connections.load(Ordering::Relaxed) > 0, + last_seen: None, } } else { UserStatsSnapshot { @@ -605,6 +642,7 @@ async fn handle_get_user( connections: 0, limit_bytes: meta.limit_bytes, online: false, + last_seen: None, } }; @@ -939,6 +977,8 @@ mod tests { reality_query: "".to_string(), config_path: None, dns_server: crate::dns::DnsServer::new(Default::default()), + audit_logs: Arc::new(RwLock::new(Vec::new())), + router: Arc::new(crate::router::Router::new(None, crate::dns::DnsServer::new(Default::default()), false)), } } @@ -955,3 +995,126 @@ mod tests { } } +async fn handle_get_audit(State(state): State) -> impl IntoResponse { + let logs = state.audit_logs.read().unwrap(); + ApiResponse::success(logs.clone()) +} + +async fn handle_create_audit(State(state): State, Json(req): Json) -> impl IntoResponse { + let mut logs = state.audit_logs.write().unwrap(); + let id = format!("{:x}", rand::random::()); + let now = chrono::Local::now(); + let entry = AuditLogEntry { + id, + time: now.format("%H:%M:%S").to_string(), + event_en: req.event_en, + event_ru: req.event_ru, + success: req.success, + }; + logs.insert(0, entry); + if logs.len() > 100 { + logs.truncate(100); + } + + ApiResponse::success(true) +} + +// ── Bulk keys & Router Rules ───────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct BulkCreateRequest { + pub count: usize, + pub limit_bytes: Option, +} + +async fn handle_bulk_create_users( + State(state): State, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> impl IntoResponse { + if !check_token(&state, &headers) { return api_unauthorized(); } + if payload.count > 1000 { + return api_error("Count too large, max 1000"); + } + + let mut new_keys = Vec::new(); + let mut keys = match state.access_keys.write() { + Ok(k) => k, + Err(e) => e.into_inner(), + }; + + for _ in 0..payload.count { + let key = uuid::Uuid::new_v4().to_string().replace("-", "")[..16].to_string(); + keys.insert(key.clone(), UserMeta { + name: None, + limit_bytes: payload.limit_bytes, + }); + new_keys.push(key); + } + // Save keys by dropping lock, then saving to config + drop(keys); + let _ = save_config_keys(&state); + + (StatusCode::OK, ApiResponse::success(new_keys)) +} + +async fn handle_get_rules( + State(state): State, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + if !check_token(&state, &headers) { return api_unauthorized(); } + + let lock = state.router.outbound_cfg.read().unwrap(); + let rules = if let Some(cfg) = lock.as_ref() { + cfg.rules.clone() + } else { + Vec::new() + }; + + (StatusCode::OK, ApiResponse::success(rules)) +} + +async fn handle_put_rules( + State(state): State, + headers: axum::http::HeaderMap, + Json(new_rules): Json>, +) -> impl IntoResponse { + if !check_token(&state, &headers) { return api_unauthorized(); } + + // Update memory + { + let mut lock = state.router.outbound_cfg.write().unwrap(); + if let Some(cfg) = lock.as_mut() { + cfg.rules = new_rules.clone(); + } else { + return api_error("Outbound routing is not enabled in config"); + } + } + + // Save to config.json + if let Some(path) = &state.config_path { + if let Ok(content) = std::fs::read_to_string(path) { + let mut cfg: serde_json::Value = serde_json::from_str(&content).unwrap_or_default(); + if let Some(obj) = cfg.as_object_mut() { + if let Some(outbound) = obj.get_mut("outbound") { + if let Some(outbound_obj) = outbound.as_object_mut() { + if let Ok(rules_json) = serde_json::to_value(&new_rules) { + outbound_obj.insert("rules".to_string(), rules_json); + } + } + } + } + let _ = std::fs::write(path, serde_json::to_string_pretty(&cfg).unwrap_or_default()); + } + } + + (StatusCode::OK, ApiResponse::success(true)) +} + +async fn handle_clear_audit(State(state): State) -> impl IntoResponse { + let mut logs = state.audit_logs.write().unwrap(); + logs.clear(); + ApiResponse::success(()) +} + + diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index e84c0a4..5052aa8 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -61,6 +61,7 @@ pub struct UserStatsSnapshot { pub connections: u64, pub limit_bytes: Option, pub online: bool, + pub last_seen: Option, } pub struct PeerState { @@ -109,17 +110,37 @@ impl Dispatcher { /// Snapshot all user stats for API responses. pub fn snapshot_all_users(&self) -> Vec { let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner()); - let online_keys: std::collections::HashSet = self.peer_machines.values() - .map(|ps| ps.access_key.clone()) - .collect(); - stats.iter().map(|(key, us)| UserStatsSnapshot { - access_key: key.clone(), - name: None, - bytes_up: us.bytes_up.load(Ordering::Relaxed), - bytes_down: us.bytes_down.load(Ordering::Relaxed), - connections: us.connections.load(Ordering::Relaxed), - limit_bytes: us.limit_bytes, - online: online_keys.contains(key), + let mut online_keys: HashMap = HashMap::new(); + for ps in self.peer_machines.values() { + let key = ps.access_key.clone(); + if let Some(existing) = online_keys.get(&key) { + if ps.last_seen > *existing { + online_keys.insert(key, ps.last_seen); + } + } else { + online_keys.insert(key, ps.last_seen); + } + } + + let now = std::time::Instant::now(); + let current_sys_time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + + stats.iter().map(|(key, us)| { + let last_seen_unix = online_keys.get(key).map(|&instant| { + let diff = now.duration_since(instant).as_secs(); + current_sys_time.saturating_sub(diff) + }); + + UserStatsSnapshot { + access_key: key.clone(), + name: None, + bytes_up: us.bytes_up.load(Ordering::Relaxed), + bytes_down: us.bytes_down.load(Ordering::Relaxed), + connections: us.connections.load(Ordering::Relaxed), + limit_bytes: us.limit_bytes, + online: online_keys.contains_key(key), + last_seen: last_seen_unix, + } }).collect() } diff --git a/ostp-server/src/dns.rs b/ostp-server/src/dns.rs index fb6c70a..1beb153 100644 --- a/ostp-server/src/dns.rs +++ b/ostp-server/src/dns.rs @@ -6,16 +6,29 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DnsConfig { + /// Включить полный DNS: кастомные домены + AdBlock списки + DoH форвардинг pub enabled: bool, + /// Перехватывать весь UDP-трафик к порту :53 и резолвить через DoH, + /// даже если `enabled = false`. Это предотвращает DNS-утечки через сервер. + #[serde(default)] + pub intercept_all_port53: bool, + /// Порт на котором встроенный DNS-сервер слушает UDP-запросы (по умолчанию 50053). + /// Клиенты могут указать :50053 в качестве DNS-сервера. + #[serde(default = "default_dns_local_port")] + pub local_port: u16, pub doh_upstream: String, pub adblock_urls: Vec, pub custom_domains: HashMap, } +fn default_dns_local_port() -> u16 { 50053 } + impl Default for DnsConfig { fn default() -> Self { Self { enabled: false, + intercept_all_port53: false, + local_port: 50053, doh_upstream: "https://cloudflare-dns.com/dns-query".to_string(), adblock_urls: vec![], custom_domains: HashMap::new(), @@ -33,7 +46,7 @@ pub struct DnsQueryLog { pub struct DnsServer { pub config: RwLock, - adblock_trie: RwLock>, // Simplified to HashSet for now, or maybe a suffix tree + adblock_trie: RwLock>, query_log: Mutex>, reqwest_client: reqwest::Client, } @@ -49,7 +62,7 @@ impl DnsServer { .unwrap_or_default(), }); - // Spawn a background task to download blocklists + // Загружаем блок-листы при старте если DNS включён if config.enabled && !config.adblock_urls.is_empty() { let server_clone = server.clone(); tokio::spawn(async move { @@ -60,6 +73,7 @@ impl DnsServer { server } + /// Скачать и обновить все AdBlock-листы. pub async fn update_blocklists(&self) { let urls = { let cfg = self.config.read().await; @@ -67,122 +81,221 @@ impl DnsServer { }; let mut new_blocked = HashSet::new(); - - for url in urls { - if let Ok(resp) = self.reqwest_client.get(&url).send().await { - if let Ok(text) = resp.text().await { - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; + + for url in &urls { + tracing::info!("DNS: downloading AdBlock list from {url}"); + match self.reqwest_client.get(url).send().await { + Ok(resp) => { + match resp.text().await { + Ok(text) => { + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('!') { + continue; + } + // Формат hosts: "0.0.0.0 ads.google.com" или просто "ads.google.com" + // Формат adblock: "||ads.google.com^" или "ads.google.com" + let domain = if line.starts_with("||") && line.ends_with('^') { + line.trim_start_matches("||").trim_end_matches('^') + } else { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && (parts[0] == "0.0.0.0" || parts[0] == "127.0.0.1") { + parts[1] + } else if parts.len() == 1 { + parts[0] + } else { + continue; + } + }; + // Пропускаем localhost и wildcard-мусор + if domain == "localhost" || domain.contains('*') || domain.contains(' ') { + continue; + } + new_blocked.insert(domain.to_lowercase()); + } } - // Support standard hosts format: "0.0.0.0 ads.google.com" or just "ads.google.com" - let parts: Vec<&str> = line.split_whitespace().collect(); - let domain = if parts.len() >= 2 && (parts[0] == "0.0.0.0" || parts[0] == "127.0.0.1") { - parts[1] - } else { - parts[0] - }; - new_blocked.insert(domain.to_lowercase()); + Err(e) => tracing::warn!("DNS: failed to read AdBlock list {url}: {e}"), } } + Err(e) => tracing::warn!("DNS: failed to fetch AdBlock list {url}: {e}"), } } - tracing::info!("Loaded {} domains into AdBlock engine", new_blocked.len()); + tracing::info!("DNS: loaded {} domains into AdBlock engine from {} lists", new_blocked.len(), urls.len()); *self.adblock_trie.write().await = new_blocked; } + /// Резолвить DNS-запрос. + /// + /// Поведение зависит от конфигурации: + /// - `enabled=true`: кастомные домены → AdBlock → DoH + /// - `intercept_all_port53=true`: минуя AdBlock/custom, всегда форвардит через DoH + /// - оба `false`: возвращает `None` (трафик идёт напрямую к целевому DNS-серверу) pub async fn resolve(&self, payload: &[u8], client_ip: std::net::IpAddr) -> Option> { let cfg = self.config.read().await; - if !cfg.enabled { - return None; // If DNS is disabled, fallback to standard UDP proxying + + // Если оба флага выключены — не вмешиваемся + if !cfg.enabled && !cfg.intercept_all_port53 { + return None; } - // Parse DNS packet + let enabled = cfg.enabled; + let intercept = cfg.intercept_all_port53; + let doh_url = cfg.doh_upstream.clone(); + drop(cfg); // Освобождаем блокировку до IO + + // Парсим DNS-пакет let packet = match Packet::parse(payload) { Ok(p) => p, Err(_) => return None, }; - if packet.questions.is_empty() { return None; } let question = &packet.questions[0]; let qname = question.qname.to_string().to_lowercase(); - - // Check Custom Domains - if let Some(ip_str) = cfg.custom_domains.get(&qname) { - if let Ok(ip) = ip_str.parse::() { - if question.qtype == QTYPE::TYPE(TYPE::A) { - let mut response = Packet::new_reply(packet.id()); - response.questions.push(question.clone()); - response.answers.push(ResourceRecord::new( - question.qname.clone(), - CLASS::IN, - 60, - RData::A(ip.into()), - )); - self.log_query(qname, client_ip.to_string(), false).await; - return response.build_bytes_vec().ok(); + + // ── Полный DNS-режим (enabled=true) ─────────────────────────────────── + if enabled { + // 1. Кастомные домены (прямой ответ из конфига) + { + let cfg = self.config.read().await; + if let Some(ip_str) = cfg.custom_domains.get(&qname) { + if let Ok(ip) = ip_str.parse::() { + if question.qtype == QTYPE::TYPE(TYPE::A) { + let mut response = Packet::new_reply(packet.id()); + response.questions.push(question.clone()); + response.answers.push(ResourceRecord::new( + question.qname.clone(), + CLASS::IN, + 60, + RData::A(ip.into()), + )); + self.log_query(qname, client_ip.to_string(), false).await; + return response.build_bytes_vec().ok(); + } + } } } + + // 2. AdBlock (suffix matching) + let blocked = { + let blocked_domains = self.adblock_trie.read().await; + let mut parts: Vec<&str> = qname.split('.').collect(); + let mut is_blocked = false; + while !parts.is_empty() { + let suffix = parts.join("."); + if blocked_domains.contains(&suffix) { + is_blocked = true; + break; + } + parts.remove(0); + } + is_blocked + }; + + if blocked { + // Возвращаем пустой NXDOMAIN-ответ + let mut response = Packet::new_reply(packet.id()); + response.questions.push(question.clone()); + self.log_query(qname.clone(), client_ip.to_string(), true).await; + tracing::debug!("DNS AdBlock: blocked {qname} for {client_ip}"); + return response.build_bytes_vec().ok(); + } } - // Check AdBlock (Suffix matching not implemented in this simple hashset, for full pi-hole we need suffix match) - // Let's do a simple suffix check - let blocked = { - let blocked_domains = self.adblock_trie.read().await; - let mut parts: Vec<&str> = qname.split('.').collect(); - let mut is_blocked = false; - while !parts.is_empty() { - let suffix = parts.join("."); - if blocked_domains.contains(&suffix) { - is_blocked = true; - break; - } - parts.remove(0); - } - is_blocked - }; - - if blocked { - let mut response = Packet::new_reply(packet.id()); - response.questions.push(question.clone()); - self.log_query(qname, client_ip.to_string(), true).await; - return response.build_bytes_vec().ok(); - } - - // Forward to DoH - let doh_url = cfg.doh_upstream.clone(); - drop(cfg); // Release config lock before making network request - - if let Ok(resp) = self.reqwest_client.post(&doh_url) + // ── Форвардинг через DoH ────────────────────────────────────────────── + // Работает и при enabled=true и при intercept_all_port53=true + tracing::debug!("DNS: resolving {qname} via DoH for {client_ip}"); + match self.reqwest_client + .post(&doh_url) .header("Content-Type", "application/dns-message") .header("Accept", "application/dns-message") .body(payload.to_vec()) + .timeout(std::time::Duration::from_secs(5)) .send() - .await + .await { - if resp.status().is_success() { + Ok(resp) if resp.status().is_success() => { if let Ok(bytes) = resp.bytes().await { self.log_query(qname, client_ip.to_string(), false).await; return Some(bytes.to_vec()); } } + Ok(resp) => { + tracing::warn!("DNS DoH upstream returned {}: {qname}", resp.status()); + } + Err(e) => { + tracing::warn!("DNS DoH upstream error for {qname}: {e}"); + } + } + + // Если DoH упал и мы в режиме перехвата — возвращаем SERVFAIL + // чтобы не пустить запрос напрямую к 8.8.8.8 с IP сервера + if intercept && !enabled { + let mut response = Packet::new_reply(packet.id()); + response.questions.push(question.clone()); + // Устанавливаем RCODE=2 (SERVFAIL) вручную в raw байтах + if let Ok(mut bytes) = response.build_bytes_vec() { + if bytes.len() >= 4 { + bytes[3] = (bytes[3] & 0xF0) | 0x02; // RCODE=SERVFAIL + } + return Some(bytes); + } } None } + /// Запустить встроенный UDP DNS-сервер на порту `config.local_port`. + /// + /// Клиент может явно указать `:` как DNS-сервер + /// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь. + pub async fn run_local_udp_listener(self: Arc) { + let port = self.config.read().await.local_port; + let bind_addr = format!("0.0.0.0:{port}"); + + let socket = match tokio::net::UdpSocket::bind(&bind_addr).await { + Ok(s) => Arc::new(s), + Err(e) => { + tracing::error!("Built-in DNS server failed to bind on {bind_addr}: {e}"); + return; + } + }; + tracing::info!("Built-in DNS server listening on UDP {bind_addr}"); + + let mut buf = vec![0u8; 4096]; + loop { + match socket.recv_from(&mut buf).await { + Ok((n, peer)) => { + let query = buf[..n].to_vec(); + let srv = self.clone(); + let sock = socket.clone(); + let client_ip = peer.ip(); + tokio::spawn(async move { + if let Some(response) = srv.resolve(&query, client_ip).await { + let _ = sock.send_to(&response, peer).await; + } + }); + } + Err(e) => { + tracing::warn!("Built-in DNS listener recv error: {e}"); + } + } + } + } + async fn log_query(&self, domain: String, client_ip: String, blocked: bool) { let mut log = self.query_log.lock().await; if log.len() >= 1000 { log.pop_front(); } log.push_back(DnsQueryLog { - timestamp: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), domain, client_ip, blocked, diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index d837027..47e30a0 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -19,6 +19,7 @@ pub mod relay_node; mod relay; mod signal; pub mod dns; +pub mod router; pub use outbound::{OutboundAction, OutboundConfig, OutboundRule}; pub use api::ApiConfig; @@ -245,8 +246,22 @@ pub async fn run_server( } }); - // Initialize DNS server - let dns_server = dns::DnsServer::new(dns_config.unwrap_or_default()); + // Инициализируем DNS-сервер + let dns_cfg = dns_config.unwrap_or_default(); + // Запускаем UDP listener если dns.enabled=true (полный режим) или intercept_all_port53=true + let start_dns_listener = dns_cfg.enabled || dns_cfg.intercept_all_port53; + let dns_server = dns::DnsServer::new(dns_cfg); + if start_dns_listener { + let dns_srv = dns_server.clone(); + tokio::spawn(async move { dns_srv.run_local_udp_listener().await }); + } + + // Initialize Router + let router = std::sync::Arc::new(router::Router::new( + outbound.clone(), + dns_server.clone(), + debug, + )); // Spawn Management API if configured if let Some(api_cfg) = api_config { @@ -261,8 +276,9 @@ pub async fn run_server( let rq = reality_query.clone().unwrap_or_default(); let config_path_api = config_path.clone(); let dns_server_api = dns_server.clone(); + let router_api = router.clone(); tokio::spawn(async move { - api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api).await; + api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api, router_api).await; }); } } @@ -314,7 +330,7 @@ pub async fn run_server( let reality_config_arc = reality_config.map(std::sync::Arc::new); tokio::select! { - res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug, reality_config_arc, dns_server) => { + res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router, reality_config_arc) => { if let Err(e) = res { tracing::error!("Server error: {e}"); } @@ -337,10 +353,8 @@ async fn run_server_loop( mut ui_cmd_rx: mpsc::UnboundedReceiver, ui_event_tx: mpsc::UnboundedSender, shared_keys: std::sync::Arc>>, - outbound: Option, - debug: bool, + router: std::sync::Arc, reality_config: Option>, - dns_server: std::sync::Arc, ) -> Result<()> { let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new(); let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec)>(); @@ -432,7 +446,7 @@ async fn run_server_loop( drop(udp_tx); // Drop the original sender so the channel closes when all tasks end - if debug { + if router.debug { let _ = ui_event_tx.send(UiEvent::Log("Server loop started".to_string())); let _ = ui_event_tx.send(UiEvent::KeyCount(shared_keys.read().unwrap_or_else(|e| e.into_inner()).len())); } @@ -462,7 +476,7 @@ async fn run_server_loop( if let Err(e) = handle_udp_packet( packet, peer, &mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx, stream_tx.clone(), udp_reply_tx.clone(), connect_tx.clone(), - outbound.clone(), dns_server.clone(), debug, + router.clone(), &mut peer_last_seen, &mut peer_available, &mut last_empty_app_log ).await { tracing::error!("handle_udp_packet error: {}", e); @@ -529,9 +543,7 @@ async fn handle_udp_packet( stream_tx: mpsc::UnboundedSender<(u32, u16, Vec)>, udp_reply_tx: mpsc::UnboundedSender<(u32, u16, String, Vec)>, connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>, - outbound: Option, - dns_server: std::sync::Arc, - debug: bool, + router: std::sync::Arc, peer_last_seen: &mut HashMap, peer_available: &mut HashMap, last_empty_app_log: &mut Instant, @@ -594,9 +606,7 @@ async fn handle_udp_packet( stream_tx.clone(), udp_reply_tx.clone(), connect_tx.clone(), - outbound.clone(), - dns_server.clone(), - debug, + router.clone(), tcp_map, ).await?; } diff --git a/ostp-server/src/outbound.rs b/ostp-server/src/outbound.rs index 2b6980a..c7a647d 100644 --- a/ostp-server/src/outbound.rs +++ b/ostp-server/src/outbound.rs @@ -1,21 +1,26 @@ use anyhow::Result; use tokio::net::TcpStream; use tokio::time::Duration; +use serde::{Serialize, Deserialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum OutboundAction { Proxy, Direct, + Block, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutboundRule { + #[serde(default)] pub domain_suffix: Vec, + #[serde(default)] pub ip_cidr: Vec, pub action: OutboundAction, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutboundConfig { pub enabled: bool, pub protocol: String, @@ -36,6 +41,9 @@ pub async fn connect_target( if let Some(outbound) = outbound { if outbound.enabled { let action = select_outbound_action(target, outbound, debug).await; + if action == OutboundAction::Block { + return Err(anyhow::anyhow!("blocked by outbound rule: {}", target)); + } if action == OutboundAction::Proxy { let proxy_addr = format!("{}:{}", outbound.address, outbound.port); return match outbound.protocol.as_str() { diff --git a/ostp-server/src/relay.rs b/ostp-server/src/relay.rs index c7066db..c65bf24 100644 --- a/ostp-server/src/relay.rs +++ b/ostp-server/src/relay.rs @@ -8,7 +8,6 @@ use tokio::net::UdpSocket; use tokio::sync::mpsc; use crate::dispatcher::Dispatcher; -use crate::outbound::{self, OutboundConfig}; use crate::{RemoteState, UiEvent}; pub async fn handle_relay_message( @@ -23,15 +22,13 @@ pub async fn handle_relay_message( stream_tx: mpsc::UnboundedSender<(u32, u16, Vec)>, udp_reply_tx: mpsc::UnboundedSender<(u32, u16, String, Vec)>, connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>, - outbound_cfg: Option, - dns_server: std::sync::Arc, - debug: bool, + router: std::sync::Arc, tcp_map: &std::sync::Arc>>>, ) -> Result<()> { match RelayMessage::decode(&payload)? { RelayMessage::Connect(target) => { // DNS interception disabled for stability - let is_internal_dns = false; + let _is_internal_dns = false; let mut connect_target = target.clone(); if connect_target.starts_with("10.1.0.1:") { @@ -41,9 +38,9 @@ pub async fn handle_relay_message( let target_clone = connect_target.clone(); let connect_tx_clone = connect_tx.clone(); let stream_tx_clone = stream_tx.clone(); - let outbound_clone = outbound_cfg.clone(); + let router_clone = router.clone(); tokio::spawn(async move { - let stream_res = outbound::connect_target(&target_clone, outbound_clone.as_ref(), debug).await; + let stream_res = router_clone.route_tcp(&target_clone).await; match stream_res { Ok(stream) => { let (mut reader, writer) = stream.into_split(); @@ -100,7 +97,7 @@ pub async fn handle_relay_message( } RelayMessage::Pong(_) => {} RelayMessage::UdpAssociate => { - if debug { + if router.debug { let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}"))); } let udp_bind_result = match UdpSocket::bind("[::]:0").await { @@ -121,9 +118,9 @@ pub async fn handle_relay_message( // Outbound UDP loop (tunnel -> target) let tx_sock = server_udp.clone(); - let dns_srv = dns_server.clone(); - let udp_reply_clone_dns = udp_reply_tx.clone(); - let client_ip = peer_addr.ip(); + let _dns_srv = router.dns_server.clone(); + let _udp_reply_clone_dns = udp_reply_tx.clone(); + let _client_ip = peer_addr.ip(); tokio::spawn(async move { while let Some((target, data)) = udp_rx.recv().await { let mut forward_target = target.clone(); @@ -175,6 +172,41 @@ pub async fn handle_relay_message( } RelayMessage::UdpData(target, data) => { if let Some(remote) = remotes.get_mut(&(session_id, stream_id)) { + // Если целевой порт 53 — пробуем перехватить через встроенный DNS + if target.ends_with(":53") { + let should_intercept = { + let cfg = router.dns_server.config.read().await; + cfg.enabled || cfg.intercept_all_port53 + }; + + if should_intercept { + match router.route_dns(peer_addr.ip(), &data).await { + Some(response) => { + let _ = udp_reply_tx.send((session_id, stream_id, target, response)); + return Ok(()); + } + None => { + // route_dns вернул None — значит DoH упал и enabled=true + // в режиме перехвата уже вернул SERVFAIL + // просто блокируем, не пускаем к 8.8.8.8 с IP сервера + if router.debug { + let _ = ui_event_tx.send(UiEvent::Log(format!( + "DNS [{session_id}:{stream_id}] DoH failed for {target}, dropping (intercept=true)" + ))); + } + return Ok(()); + } + } + } else { + // intercept отключён: forward как обычный UDP + if router.debug { + let _ = ui_event_tx.send(UiEvent::Log(format!( + "DNS [{session_id}:{stream_id}] passthrough to {target} (intercept disabled)" + ))); + } + } + } + if let Some(ref udp_tx) = remote.udp_tx { let _ = udp_tx.send((target, Bytes::from(data))); } diff --git a/ostp-server/src/router.rs b/ostp-server/src/router.rs new file mode 100644 index 0000000..e9d860c --- /dev/null +++ b/ostp-server/src/router.rs @@ -0,0 +1,36 @@ +use std::sync::{Arc, RwLock}; +use tokio::net::TcpStream; +use anyhow::Result; +use crate::outbound::{OutboundConfig, connect_target}; +use crate::dns::DnsServer; + +#[derive(Clone)] +pub struct Router { + pub outbound_cfg: Arc>>, + pub dns_server: Arc, + pub debug: bool, +} + +impl Router { + pub fn new(outbound_cfg: Option, dns_server: Arc, debug: bool) -> Self { + Self { + outbound_cfg: Arc::new(RwLock::new(outbound_cfg)), + dns_server, + debug, + } + } + + /// TCP Target Routing + pub async fn route_tcp(&self, target: &str) -> Result { + let cfg = { + let lock = self.outbound_cfg.read().unwrap(); + lock.clone() // Clone config to avoid holding lock across await point + }; + connect_target(target, cfg.as_ref(), self.debug).await + } + + /// Unified DNS Routing and Resolution (AdBlock / Custom Domains / DoH) + pub async fn route_dns(&self, client_ip: std::net::IpAddr, payload: &[u8]) -> Option> { + self.dns_server.resolve(payload, client_ip).await + } +} diff --git a/ostp-tun-helper/src/main.rs b/ostp-tun-helper/src/main.rs index 9dc93b8..1c1e60a 100644 --- a/ostp-tun-helper/src/main.rs +++ b/ostp-tun-helper/src/main.rs @@ -46,6 +46,7 @@ enum HelperMsg { struct TunnelState { shutdown_tx: Option>, + config_tx: Option>, metrics: Option>, } @@ -61,18 +62,19 @@ async fn main() -> Result<()> { let mut port = 53211u16; let args: Vec = std::env::args().collect(); for i in 1..args.len() { + if args[i] == "--port" && i + 1 < args.len() { + port = args[i + 1].parse().unwrap_or(53211); + } if args[i] == "--token" && i + 1 < args.len() { expected_token = args[i + 1].clone(); - } else if args[i] == "--port" && i + 1 < args.len() { - port = args[i + 1].parse().unwrap_or(53211); } } log_to_file("Helper started (TCP mode)"); if expected_token.is_empty() { - log_to_file("FATAL: --token argument is required for security. Unauthorized access denied."); - return Err(anyhow::anyhow!("--token argument is required")); + log_to_file("FATAL: OSTP_TUN_TOKEN environment variable is required for security. Unauthorized access denied."); + return Err(anyhow::anyhow!("OSTP_TUN_TOKEN environment variable is required")); } if let Err(e) = run_server(expected_token, port).await { @@ -85,6 +87,7 @@ async fn main() -> Result<()> { async fn run_server(expected_token: String, port: u16) -> Result<()> { let state = Arc::new(Mutex::new(TunnelState { shutdown_tx: None, + config_tx: None, metrics: None, })); @@ -180,10 +183,12 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> { }); let (shutdown_tx, shutdown_rx) = watch::channel(false); + let (config_tx, config_rx) = watch::channel(cfg.clone()); { let mut st = state.lock().await; st.shutdown_tx = Some(shutdown_tx); + st.config_tx = Some(config_tx); st.metrics = Some(metrics.clone()); } @@ -192,7 +197,7 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> { let shutdown_rx_for_core = shutdown_rx.clone(); tokio::spawn(async move { log_to_file("Starting tunnel core..."); - match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core).await { + match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core, Some(config_rx)).await { Ok(_) => { log_to_file("Tunnel core stopped normally"); } Err(e) => { log_to_file(&format!("Tunnel core error: {}", e)); @@ -243,15 +248,6 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> { } log_to_file("Received RELOAD command"); - // Signal shutdown to current core - { - let mut st = state.lock().await; - if let Some(tx) = st.shutdown_tx.take() { - let _ = tx.send(true); - } - tokio::time::sleep(Duration::from_millis(500)).await; // give it time to shutdown cleanly - } - let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) { Ok(c) => c, Err(e) => { @@ -260,69 +256,14 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> { } }; - let metrics = Arc::new(ostp_client::bridge::BridgeMetrics { - bytes_sent: portable_atomic::AtomicU64::new(0), - bytes_recv: portable_atomic::AtomicU64::new(0), - connection_state: portable_atomic::AtomicU8::new(0), - rtt_ms: portable_atomic::AtomicU32::new(0), - }); - - let (shutdown_tx, shutdown_rx) = watch::channel(false); - { - let mut st = state.lock().await; - st.shutdown_tx = Some(shutdown_tx); - st.metrics = Some(metrics.clone()); + let st = state.lock().await; + if let Some(tx) = &st.config_tx { + let _ = tx.send(cfg); + log_to_file("Config sent to running core for seamless hot-reload"); + } } - let metrics_for_runner = metrics.clone(); - let writer_for_err = writer.clone(); - let shutdown_rx_for_core = shutdown_rx.clone(); - tokio::spawn(async move { - log_to_file("Restarting tunnel core for reload..."); - match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core).await { - Ok(_) => { log_to_file("Reloaded core stopped normally"); } - Err(e) => { - let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }).unwrap_or_default(); - let mut w = writer_for_err.lock().await; - let _ = w.write_all(format!("{}\n", json).as_bytes()).await; - } - } - }); - - // Status tick loop is already running and using old metrics? - // Wait! We re-created metrics, so the old tick loop will continue reporting old metrics (which are disconnected)! - // We should probably share the tick loop or spawn a new one and let the old one die. - // It's easier if `metrics` in state is a generic watcher, but since we re-spawned it: - let writer_tick = writer.clone(); - let metrics_tick = metrics.clone(); - let mut shutdown_rx_tick = shutdown_rx.clone(); - tokio::spawn(async move { - let mut last_state = 99u8; - loop { - tokio::select! { - _ = tokio::time::sleep(Duration::from_secs(1)) => {} - _ = shutdown_rx_tick.changed() => { - if *shutdown_rx_tick.borrow() { break; } - } - } - let cs = metrics_tick.connection_state.load(Ordering::Relaxed); - let sent = metrics_tick.bytes_sent.load(Ordering::Relaxed); - let recv = metrics_tick.bytes_recv.load(Ordering::Relaxed); - let rtt = metrics_tick.rtt_ms.load(Ordering::Relaxed); - - let mut w = writer_tick.lock().await; - if cs != last_state { - last_state = cs; - let json = serde_json::to_string(&HelperMsg::Status { value: cs }).unwrap_or_default(); - if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; } - } - let json = serde_json::to_string(&HelperMsg::Metrics { bytes_sent: sent, bytes_recv: recv, rtt_ms: rtt }).unwrap_or_default(); - if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; } - drop(w); - } - }); - send_msg(HelperMsg::Status { value: 1 }); } GuiCmd::Stop { token } => { diff --git a/ostp/Cargo.toml b/ostp/Cargo.toml index bf2e120..9334264 100644 --- a/ostp/Cargo.toml +++ b/ostp/Cargo.toml @@ -19,3 +19,4 @@ url = "2.5" tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } ostp-core = { version = "0.2.68", path = "../ostp-core" } +colored = "2.1" diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 99b1c75..a316b93 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -3,6 +3,7 @@ use clap::Parser; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use colored::Colorize; #[derive(Parser, Debug)] #[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)] @@ -117,6 +118,7 @@ fn parse_ostp_link(link: &str) -> Result { wintun_path: Some("./wintun.dll".to_string()), ipv4_address: Some("10.1.0.2/24".to_string()), dns: tun_dns, + kill_switch: Some(false), }), reality: Some(RealityConfigRaw { enabled: true, @@ -355,6 +357,7 @@ struct TunConfig { wintun_path: Option, ipv4_address: Option, dns: Option, + kill_switch: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -426,7 +429,7 @@ async fn main() -> Result<()> { let res = run_app().await; if let Err(e) = res { eprintln!(); - eprintln!("[ostp] Fatal error: {}", e); + eprintln!("{} {}", "[FATAL ERROR]".red().bold(), e); eprintln!(); #[cfg(target_os = "windows")] @@ -590,7 +593,7 @@ async fn run_app() -> Result<()> { } if let Some(import_url) = args.import { - println!("[ostp] Importing configuration from share link..."); + println!("{} Importing configuration from share link...", "[ostp]".cyan().bold()); let client_cfg = parse_ostp_link(&import_url) .map_err(|e| anyhow!("Share Link Error: {e}"))?; let unified = UnifiedConfig { @@ -604,19 +607,19 @@ async fn run_app() -> Result<()> { } } fs::write(&args.config, content)?; - println!("[ostp] Configuration successfully imported and saved to {:?}", args.config); + println!("{} Configuration successfully imported and saved to {:?}", "[ostp]".green().bold(), args.config); return Ok(()); } if let Some(url) = args.url { - println!("[ostp] Connecting via share link..."); + println!("{} Connecting via share link...", "[ostp]".cyan().bold()); let mut client_cfg = parse_ostp_link(&url) .map_err(|e| anyhow!("Share Link Error: {e}"))?; // Interactive prompt for URL launch use std::io::Write; - print!("Enable TUN (VPN) mode? [y/N]: "); + print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold()); std::io::stdout().flush().unwrap(); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); @@ -626,7 +629,7 @@ async fn run_app() -> Result<()> { } } - print!("Enable connection multiplexing (mux)? [y/N]: "); + print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold()); std::io::stdout().flush().unwrap(); input.clear(); std::io::stdin().read_line(&mut input).unwrap(); @@ -675,9 +678,9 @@ async fn run_app() -> Result<()> { config.validate()?; match &config.mode { AppMode::Server(s) => { - println!("[ostp] Config OK: server mode"); - println!(" Listen: {:?}", s.listen.primary()); - println!(" Access keys: {}", s.access_keys.len()); + println!("{} Config OK: server mode", "[ostp]".green().bold()); + println!(" Listen: {:?}", s.listen.primary().as_str().cyan()); + println!(" Access keys: {}", s.access_keys.len().to_string().yellow()); if let Some(api) = &s.api { println!(" API: {} (bind: {})", if api.enabled.unwrap_or(false) { "enabled" } else { "disabled" }, @@ -696,16 +699,16 @@ async fn run_app() -> Result<()> { } } AppMode::Client(c) => { - println!("[ostp] Config OK: client mode"); - println!(" Server: {}", c.server); - println!(" Key: {}...", &c.access_key[..8.min(c.access_key.len())]); + println!("{} Config OK: client mode", "[ostp]".green().bold()); + println!(" Server: {}", c.server.cyan()); + println!(" Key: {}...", &c.access_key[..8.min(c.access_key.len())].yellow()); } AppMode::Relay(r) => { - println!("[ostp] Config OK: relay mode"); - println!(" Listen: {:?}", r.listen.primary()); - println!(" Upstream TCP: {}", r.upstream_tcp); - println!(" Upstream UDP: {}", r.upstream_udp); - println!(" API sync: {}", r.upstream_api_url); + println!("{} Config OK: relay mode", "[ostp]".green().bold()); + println!(" Listen: {:?}", r.listen.primary().cyan()); + println!(" Upstream TCP: {}", r.upstream_tcp.cyan()); + println!(" Upstream UDP: {}", r.upstream_udp.cyan()); + println!(" API sync: {}", r.upstream_api_url.yellow()); } } } @@ -781,6 +784,29 @@ async fn run_app() -> Result<()> { "sid": "{}", "sni_list": ["www.microsoft.com"] }}, + + // Built-in DNS server + "dns": {{ + // Full mode: custom domains + AdBlock lists + DoH forwarding + "enabled": false, + // Intercept ALL UDP port 53 traffic and resolve via DoH (prevents DNS leaks through the server) + // Works even if enabled=false — just strips AdBlock/custom domains logic + "intercept_all_port53": false, + // UDP port the built-in DNS server listens on (clients can use :50053 as DNS) + "local_port": 50053, + // DoH upstream: Cloudflare, Google, NextDNS, etc. + "doh_upstream": "https://cloudflare-dns.com/dns-query", + // AdBlock lists (hosts format, ||domain^, or one domain per line) + "adblock_urls": [ + // "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", + // "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt" + ], + // Custom domains: respond with A record directly (bypasses DoH) + "custom_domains": {{ + // "myserver.internal": "10.0.0.1", + // "home.local": "192.168.1.100" + }} + }}, "debug": false }}"#, key, priv_key, pub_key, sid) } else if mode_str == "relay" { @@ -1001,11 +1027,13 @@ async fn run_app() -> Result<()> { match config.mode { AppMode::Server(server_cfg) => { + println!("{}", include_str!("../../docs/banner.txt").blue().bold()); + let listen_addrs = server_cfg.listen.addresses(); - println!("[ostp] Starting server on {:?}", listen_addrs); + println!("{} Starting server on {:?}", "[ostp]".cyan().bold(), listen_addrs); if let Some(ref reality) = server_cfg.reality { if reality.enabled { - println!("[ostp] Reality mode enabled (dest: {})", reality.dest); + println!("{} Reality mode enabled (dest: {})", "[ostp]".cyan().bold(), reality.dest); } } let debug = server_cfg.debug.unwrap_or(false); @@ -1065,14 +1093,16 @@ async fn run_app() -> Result<()> { ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, dns_cfg, Some(args.config)).await?; } AppMode::Client(client_cfg) => { + println!("{}", include_str!("../../docs/banner.txt").blue().bold()); run_client_directly(client_cfg).await?; } AppMode::Relay(relay_cfg) => { + println!("{}", include_str!("../../docs/banner.txt").blue().bold()); let listen_addrs = relay_cfg.listen.addresses(); - println!("[ostp] Starting relay node on {:?}", listen_addrs); - println!("[ostp] Upstream TCP: {}", relay_cfg.upstream_tcp); - println!("[ostp] Upstream UDP: {}", relay_cfg.upstream_udp); - println!("[ostp] Key sync API: {}", relay_cfg.upstream_api_url); + println!("{} Starting relay node on {:?}", "[ostp]".cyan().bold(), listen_addrs); + println!("{} Upstream TCP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_tcp); + println!("{} Upstream UDP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_udp); + println!("{} Key sync API: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_api_url); let relay_config = ostp_server::RelayConfig { listen_addrs, upstream_tcp: relay_cfg.upstream_tcp, @@ -1171,7 +1201,7 @@ fn cmd_update() -> Result<()> { async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false); let mode_str = if is_tun_enabled { "tun" } else { "proxy" }; - println!("[ostp] Starting client (mode={}, server={})", mode_str, client_cfg.server); + println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan()); let reality_cfg = client_cfg.reality.as_ref(); let client_conf = ostp_client::config::ClientConfig { mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() }, @@ -1214,6 +1244,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { wss: client_cfg.transport.as_ref().and_then(|t| t.wss).unwrap_or(false), }, dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()), + kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), }; // Run the client implementation diff --git a/scripts/install.sh b/scripts/install.sh index c6c7595..7aa275d 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -106,7 +106,9 @@ if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then fi else ARCHIVE_NAME="ostp-linux-${ARCH}.tar.gz" + GUI_ARCHIVE_NAME="ostp-gui-linux-${ARCH}.tar.gz" DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${ARCHIVE_NAME}" + GUI_DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${GUI_ARCHIVE_NAME}" echo "Downloading: $ARCHIVE_NAME ($LATEST_RELEASE)" TEMP_TAR="/tmp/ostp_temp.tar.gz" @@ -132,6 +134,9 @@ else exit 1 fi +# We don't download GUI binary immediately, we will do it if the user selects Client + GUI mode + + # ── Create global symlink ──────────────────────────────────────────── ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK" @@ -317,8 +322,9 @@ echo " 1) Server" echo " 2) Client" echo " 3) Relay" echo " 4) Server + Web Panel" +echo " 5) Client + GUI" echo "--------------------------------------------------------" -read -p "Choice [1-4]: " NODE_MODE +read -p "Choice [1-5]: " NODE_MODE cd "$INSTALL_DIR" @@ -413,7 +419,7 @@ with open('$CONFIG_FILE', 'w') as f: echo "Password: $PASSWORD" echo "========================================================" -elif [ "$NODE_MODE" == "2" ]; then +elif [ "$NODE_MODE" == "2" ] || [ "$NODE_MODE" == "5" ]; then echo "Initializing client configuration..." ./ostp --init client --config "$CONFIG_FILE" @@ -437,6 +443,45 @@ elif [ "$NODE_MODE" == "2" ]; then fi echo "Client configuration saved: $CONFIG_FILE" + if [ "$NODE_MODE" == "5" ]; then + echo "Installing GUI..." + if [ -n "$LATEST_RELEASE" ]; then + TEMP_GUI_TAR="/tmp/ostp_gui_temp.tar.gz" + echo "Downloading GUI: $GUI_ARCHIVE_NAME ($LATEST_RELEASE)" + HTTP_CODE_GUI=$(curl -sL -w "%{http_code}" "$GUI_DOWNLOAD_URL" -o "$TEMP_GUI_TAR") + if [ "$HTTP_CODE_GUI" -eq 200 ]; then + tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR" ostp-gui 2>/dev/null || tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR" + rm -f "$TEMP_GUI_TAR" + if [ -f "$INSTALL_DIR/ostp-gui" ]; then + chmod +x "$INSTALL_DIR/ostp-gui" + ln -sf "$INSTALL_DIR/ostp-gui" "/usr/local/bin/ostp-gui" + echo "GUI binary installed at $INSTALL_DIR/ostp-gui" + + # Create desktop entry + DESKTOP_FILE="/usr/share/applications/ostp-gui.desktop" + cat < "$DESKTOP_FILE" +[Desktop Entry] +Name=OSTP Client +Comment=Ospab Stealth Transport Protocol Client +Exec=/usr/local/bin/ostp-gui +Icon=utilities-terminal +Terminal=false +Type=Application +Categories=Network;Utility; +EOF + echo "Desktop entry created at $DESKTOP_FILE" + else + echo "[error] GUI binary not found in archive." + fi + else + echo "[error] Download failed for GUI (HTTP $HTTP_CODE_GUI)." + rm -f "$TEMP_GUI_TAR" + fi + else + echo "[notice] Automatic download not possible. Install GUI manually." + fi + fi + elif [ "$NODE_MODE" == "3" ]; then echo "Initializing relay configuration..." ./ostp --init relay --config "$CONFIG_FILE" diff --git a/server.json b/server.json index 7a6b8f3..3a76329 100644 --- a/server.json +++ b/server.json @@ -57,5 +57,30 @@ "sid": "960223edfa174fc5", "sni_list": ["www.microsoft.com"] }, - "debug": false + "debug": false, + + // Встроенный DNS-сервер + "dns": { + // Полный режим: кастомные домены + AdBlock списки + DoH форвардинг + "enabled": false, + // Перехватывать весь UDP-трафик к порту 53 и резолвить через DoH + // (работает даже если enabled=false, предотвращает DNS-утечки через сервер) + "intercept_all_port53": false, + // УДП порт встроенного DNS (клиент может указать :50053 как DNS) + "local_port": 50053, + // DoH вверх: Cloudflare, Google, NextDNS и др. + "doh_upstream": "https://cloudflare-dns.com/dns-query", + // "doh_upstream": "https://dns.google/dns-query", + // "doh_upstream": "https://dns10.quad9.net/dns-query", + // Списки доменов для блокировки (формат: hosts-файл, ||domain^, один домен на строку) + "adblock_urls": [ + // "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", + // "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt" + ], + // Кастомные домены: отвечаем A-записью напрямую (не через DoH) + "custom_domains": { + // "myserver.internal": "10.0.0.1", + // "home.local": "192.168.1.100" + } + } } \ No newline at end of file