feat: implement built-in DNS server, adblock and dns leak prevention

This commit is contained in:
ospab 2026-06-07 19:55:42 +03:00
parent f798771a35
commit d0b79bd4b5
66 changed files with 5195 additions and 3093 deletions

View File

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

View File

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

7
docs/banner.txt Normal file
View File

@ -0,0 +1,7 @@
____ _____ _______ _____
/ __ \ / ____|__ __| __ \
| | | | (___ | | | |__) |
| | | |\___ \ | | | ___/
| |__| |____) | | | | |
\____/|_____/ |_| |_|

Binary file not shown.

Binary file not shown.

View File

@ -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<Vec<u8>>,
iface_ingress_tx_avail: Arc<AtomicBool>,
tcp_rx: Receiver<AnyIpPktFrame>,
stream_tx: UnboundedSender<TcpStream>,
stream_tx: Sender<TcpStream>,
sockets: HashMap<SocketHandle, SharedControl>,
) -> Runner {
Runner::new(async move {
let notify = Arc::new(Notify::new());
let (socket_tx, socket_rx) = unbounded_channel::<TcpSocketCreation>();
let (socket_tx, socket_rx) = channel::<TcpSocketCreation>(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<Vec<u8>>,
iface_ingress_tx_avail: Arc<AtomicBool>,
mut tcp_rx: Receiver<AnyIpPktFrame>,
stream_tx: UnboundedSender<TcpStream>,
socket_tx: UnboundedSender<TcpSocketCreation>,
stream_tx: Sender<TcpStream>,
socket_tx: Sender<TcpSocketCreation>,
) -> 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<AtomicBool>,
mut sockets: HashMap<SocketHandle, SharedControl>,
mut socket_rx: UnboundedReceiver<TcpSocketCreation>,
mut socket_rx: Receiver<TcpSocketCreation>,
) -> std::io::Result<()> {
let mut socket_set = SocketSet::new(vec![]);
loop {
@ -355,7 +358,7 @@ impl TcpListenerRunner {
}
pub struct TcpListener {
stream_rx: UnboundedReceiver<TcpStream>,
stream_rx: Receiver<TcpStream>,
}
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,

View File

@ -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"] }

View File

@ -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<watch::Sender<crate::config::ExclusionConfig>>,
metrics: Arc<BridgeMetrics>,
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(

View File

@ -22,6 +22,8 @@ pub struct ClientConfig {
pub dns_server: Option<String>,
#[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<bool>,
dns: Option<String>,
stack: Option<String>,
kill_switch: Option<bool>,
}
#[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),
})
}
}

View File

@ -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<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>,
mut config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
) -> 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() => {

View File

@ -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<bool>,
shutdown: tokio::sync::watch::Receiver<bool>,
exclusions_rx: tokio::sync::watch::Receiver<crate::config::ExclusionConfig>,
) -> 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<ExclusionConfig>,
debug: bool,
shutdown: watch::Receiver<bool>,
proxy_events_tx: mpsc::Sender<ProxyEvent>,
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;

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
#[cfg(target_os = "windows")]
pub fn get_process_name_from_port(port: u16) -> Option<String> {
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<String> {
None
}
#[cfg(target_os = "windows")]
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
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<String> {
use winapi::um::processthreadsapi::OpenProcess;
@ -140,3 +188,8 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
pub fn get_process_name_from_port(_port: u16) -> Option<String> {
None
}
#[cfg(not(target_os = "windows"))]
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
get_process_name_from_port(port)
}

View File

@ -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<u32> {
pub fn get_windows_physical_if_index() -> Option<u32> {
#[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::<u32>() {
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<String> {
pub fn get_linux_physical_if_name() -> Option<String> {
#[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<ExclusionConfig>,
debug: bool,
mut shutdown: watch::Receiver<bool>,
proxy_events_tx: mpsc::Sender<ProxyEvent>,
@ -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(&current_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(&current_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;

View File

@ -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<u8> = 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<u32> {
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<u8> = 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);
}
}
}

180
ostp-control/mock-server.js Normal file
View File

@ -0,0 +1,180 @@
import http from 'http';
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
res.setHeader('Content-Type', 'application/json');
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
console.log(`[${req.method}] ${path}`, body ? body : '');
if (path === '/api/login') {
try {
const payload = JSON.parse(body);
if (payload.username === 'test' && payload.password === 'test') {
res.writeHead(200);
res.end(JSON.stringify({ ok: true, data: { token: "mock-token-12345" } }));
return;
}
} catch(e) {}
res.writeHead(401);
res.end(JSON.stringify({ ok: false, error: "Invalid credentials" }));
return;
}
if (path === '/api/server/status') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: {
version: "1.0.0-mock",
uptime_seconds: 12345,
active_users: 1,
total_users: 2
}
}));
return;
}
if (path === '/api/users') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: [
{
access_key: "mock-client-1",
name: "Test Client",
limit_bytes: null,
bytes_up: 1000,
bytes_down: 5000,
connections: 1,
online: true,
last_seen: Math.floor(Date.now() / 1000)
},
{
access_key: "mock-offline-2",
name: "Offline User",
limit_bytes: 10737418240, // 10 GB
bytes_up: 50000000,
bytes_down: 150000000,
connections: 0,
online: false,
last_seen: Math.floor(Date.now() / 1000) - 86400
}
]
}));
return;
}
if (path === '/api/users/bulk') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: [
"mock-new-key-1",
"mock-new-key-2"
]
}));
return;
}
if (path === '/api/router/rules') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: [
{ action: "proxy", domain_suffix: ["google.com", "youtube.com"], ip_cidr: [] },
{ action: "block", domain_suffix: ["ads.example.com"], ip_cidr: [] },
{ action: "direct", domain_suffix: [], ip_cidr: ["192.168.1.0/24"] }
]
}));
return;
}
if (path === '/api/audit' || path === '/api/logs/audit') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: [
{ timestamp: Math.floor(Date.now() / 1000), message: "Mock server started", message_ru: "Мок сервер запущен", success: true }
]
}));
return;
}
if (path === '/api/server/config') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: {
server: { bind: "0.0.0.0:53210" },
api: { enabled: true, user: "test", port: 53210 },
dns: { enabled: true, adblock: true }
}
}));
return;
}
if (path === '/api/connections') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: []
}));
return;
}
if (path === '/api/dns/config') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: {
enabled: true,
doh_upstream: "https://cloudflare-dns.com/dns-query",
adblock_urls: ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"],
custom_domains: { "myrouter.lan": "192.168.1.1" }
}
}));
return;
}
if (path === '/api/dns/queries') {
res.writeHead(200);
res.end(JSON.stringify({
ok: true,
data: [
{ timestamp: Math.floor(Date.now() / 1000), domain: "google.com", client_ip: "10.8.0.2", blocked: false },
{ timestamp: Math.floor(Date.now() / 1000) - 5, domain: "ads.example.com", client_ip: "10.8.0.3", blocked: true }
]
}));
return;
}
// Default 200 OK for anything else (e.g. POST /api/users, DELETE, etc.)
res.writeHead(200);
res.end(JSON.stringify({ ok: true, data: { success: true, message: "Mock response" } }));
});
});
const PORT = 9090;
server.listen(PORT, () => {
console.log(`\n🚀 Mock API server is running on http://localhost:${PORT}`);
console.log(`👉 Login with: username: test | password: test\n`);
});

View File

@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"mock": "node mock-server.js",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"

View File

@ -1,5 +1,5 @@
import { HashRouter as Router, Routes, Route, Link, Navigate, useLocation } from 'react-router-dom';
import { Activity, Users, Settings, Shield, MoreVertical, RefreshCw, BookOpen, Wrench, History, Globe, LogOut } from 'lucide-react';
import { Activity, Users, Settings, Shield, MoreVertical, RefreshCw, BookOpen, Wrench, History, Globe, LogOut, Route as RouteIcon } from 'lucide-react';
import { useState, useEffect } from 'react';
import type { ReactNode } from 'react';
@ -12,6 +12,7 @@ import Tools from './pages/Tools';
import AuditLogs from './pages/AuditLogs';
import Login from './pages/Login';
import Dns from './pages/Dns';
import Routing from './pages/Routing';
// State and Context
import { api } from './lib/api';
@ -78,6 +79,10 @@ function MainLayout() {
<BookOpen className="w-5 h-5 text-blue-400" />
{isSidebarOpen && <span>{t('sidebar_wiki')}</span>}
</Link>
<Link to="/routing" className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-white/5 transition-colors text-text-muted hover:text-white">
<RouteIcon className="w-5 h-5 text-orange-400" />
{isSidebarOpen && <span>Routing</span>}
</Link>
<Link to="/dns" className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-white/5 transition-colors text-text-muted hover:text-white">
<Globe className="w-5 h-5 text-emerald-400" />
{isSidebarOpen && <span>{t('sidebar_dns')}</span>}
@ -147,6 +152,7 @@ function MainLayout() {
<Route path="/clients" element={<Clients />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/routing" element={<Routing />} />
<Route path="/tools" element={<Tools />} />
<Route path="/dns" element={<Dns />} />
<Route path="/logs" element={<AuditLogs />} />

View File

@ -6,6 +6,15 @@ export interface UserStatsSnapshot {
limit_bytes: number | null;
online: boolean;
name?: string | null;
last_seen?: number | null;
}
export type OutboundAction = 'proxy' | 'direct' | 'block';
export interface OutboundRule {
domain_suffix: string[];
ip_cidr: string[];
action: OutboundAction;
}
export interface ServerStatus {
@ -154,4 +163,15 @@ export const api = {
refreshDnsBlocklists: () => request<boolean>('/api/dns/blocklists/refresh', {
method: 'POST',
}),
getAuditLogs: () => request<AuditLogEntry[]>('/api/audit'),
createAuditLog: (eventEn: string, eventRu: string, success: boolean) =>
request<void>('/api/audit', { method: 'POST', body: JSON.stringify({ eventEn, eventRu, success }) }),
clearAuditLogs: () => request<void>('/api/audit', { method: 'DELETE' }),
bulkCreateUsers: (count: number, limit_bytes: number | null) =>
request<string[]>('/api/users/bulk', { method: 'POST', body: JSON.stringify({ count, limit_bytes }) }),
getRouterRules: () => request<OutboundRule[]>('/api/router/rules'),
updateRouterRules: (rules: OutboundRule[]) => request<boolean>('/api/router/rules', { method: 'PUT', body: JSON.stringify(rules) }),
};

View File

@ -1,3 +1,5 @@
import { api } from './api';
export interface AuditLogEntry {
id: string;
time: string;
@ -6,38 +8,29 @@ export interface AuditLogEntry {
success: boolean;
}
const AUDIT_LOG_KEY = 'ostp_audit_logs';
export function getAuditLogs(): AuditLogEntry[] {
export async function getAuditLogs(): Promise<AuditLogEntry[]> {
try {
const raw = localStorage.getItem(AUDIT_LOG_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return await api.getAuditLogs();
} catch (e) {
console.error('Failed to get audit logs', e);
return [];
}
}
export function addAuditLog(eventEn: string, eventRu: string, success: boolean) {
export async function addAuditLog(eventEn: string, eventRu: string, success: boolean) {
try {
const logs = getAuditLogs();
const newEntry: AuditLogEntry = {
id: Math.random().toString(36).substring(2, 9),
time: new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
eventEn,
eventRu,
success,
};
// Keep last 100 logs
const updated = [newEntry, ...logs].slice(0, 100);
localStorage.setItem(AUDIT_LOG_KEY, JSON.stringify(updated));
// Dispatch custom event to notify listeners
await api.createAuditLog(eventEn, eventRu, success);
window.dispatchEvent(new Event('ostp_audit_log_added'));
} catch (e) {
console.error('Failed to write audit log', e);
}
}
export function clearAuditLogs() {
localStorage.removeItem(AUDIT_LOG_KEY);
window.dispatchEvent(new Event('ostp_audit_log_added'));
export async function clearAuditLogs() {
try {
await api.clearAuditLogs();
window.dispatchEvent(new Event('ostp_audit_log_added'));
} catch (e) {
console.error('Failed to clear audit logs', e);
}
}

View File

@ -8,8 +8,9 @@ export default function AuditLogs() {
const { t, language } = useLanguage();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const loadLogs = () => {
setLogs(getAuditLogs());
const loadLogs = async () => {
const data = await getAuditLogs();
setLogs(data);
};
useEffect(() => {
@ -22,9 +23,9 @@ export default function AuditLogs() {
};
}, []);
const handleClear = () => {
const handleClear = async () => {
if (confirm(language === 'ru' ? 'Очистить журнал действий?' : 'Clear audit log history?')) {
clearAuditLogs();
await clearAuditLogs();
}
};

View File

@ -1,10 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { Users, Plus, Key, Trash2, Edit2, Copy, Search, RefreshCw, X, Share2, ShieldAlert, Download } from 'lucide-react';
import QRCode from 'qrcode';
import { Users, Plus, Search, RefreshCw, ShieldAlert, Zap } from 'lucide-react';
import { api } from '../lib/api';
import type { UserStatsSnapshot } from '../lib/api';
import { useLanguage } from '../lib/LanguageContext';
import { addAuditLog } from '../lib/audit';
import { AddClientModal } from './components/AddClientModal';
import { EditClientModal } from './components/EditClientModal';
import { ShareClientModal } from './components/ShareClientModal';
import { ClientsTable } from './components/ClientsTable';
import { BulkKeysModal } from './components/BulkKeysModal';
export default function Clients() {
const { t } = useLanguage();
@ -16,6 +21,7 @@ export default function Clients() {
// Modals state
const [showAddModal, setShowAddModal] = useState(false);
const [showBulkModal, setShowBulkModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
@ -58,6 +64,27 @@ export default function Clients() {
return () => clearInterval(interval);
}, []);
const handleBulkGenerate = async (count: number, limitBytes: number | null): Promise<string[]> => {
try {
const keys = await api.bulkCreateUsers(count, limitBytes);
fetchUsers(false);
addAuditLog(
`Bulk generated ${count} keys`,
`Сгенерирован пакет из ${count} ключей`,
true
);
return keys;
} catch (err: any) {
setErrorMsg(err.message || 'Failed to bulk generate keys');
addAuditLog(
`Failed to bulk generate keys: ${err.message || err}`,
`Не удалось сгенерировать ключи: ${err.message || err}`,
false
);
throw err;
}
};
const handleAddClient = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
@ -266,6 +293,13 @@ export default function Clients() {
>
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin text-primary' : ''}`} />
</button>
<button
onClick={() => setShowBulkModal(true)}
className="flex items-center gap-2 bg-secondary hover:bg-secondary/90 text-black px-4 py-2.5 rounded-xl font-medium transition-colors shadow-[0_0_15px_rgba(34,211,165,0.3)]"
>
<Zap className="w-5 h-5" />
<span className="hidden sm:inline">Bulk Gen</span>
</button>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-xl font-medium transition-colors shadow-[0_0_15px_rgba(108,114,255,0.3)]"
@ -297,332 +331,66 @@ export default function Clients() {
</div>
{/* Clients Table */}
<div className="glass-panel rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/5 bg-white/[0.02]">
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_status')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_name')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_key')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_usage')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_limit')}</th>
<th className="px-6 py-4 font-medium text-text-muted text-right">{t('cl_actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredUsers.map((user) => (
<tr key={user.access_key} className="hover:bg-white/[0.02] transition-colors group">
<td className="px-6 py-4">
{user.online ? (
<div className="flex items-center gap-2 text-secondary">
<span className="w-2 h-2 rounded-full bg-secondary shadow-[0_0_8px_#22D3A5]"></span>
<span className="text-sm font-medium">{t('cl_active')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-text-muted">
<span className="w-2 h-2 rounded-full bg-text-muted"></span>
<span className="text-sm">{t('cl_offline')}</span>
</div>
)}
</td>
<td className="px-6 py-4 font-medium text-white">
{user.name || (
<span className="text-text-muted italic">{t('cl_unnamed')}</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-text-muted font-mono text-sm">
<Key className="w-4 h-4 shrink-0 text-primary/70" />
<span title={user.access_key}>
{user.access_key.length > 20 ? `${user.access_key.substring(0, 16)}...` : user.access_key}
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-sm text-white">
<span className="text-xs text-text-muted w-8">Up:</span>
<span className="text-red-400 font-mono">{formatBytes(user.bytes_up || 0)}</span>
</div>
<div className="flex items-center gap-2 text-sm text-white">
<span className="text-xs text-text-muted w-8">Down:</span>
<span className="text-secondary font-mono">{formatBytes(user.bytes_down || 0)}</span>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
<span>Sessions:</span>
<span className="font-mono text-white">{user.connections}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-mono text-text-muted">
{user.limit_bytes ? (
<span className={(user.bytes_up || 0) + (user.bytes_down || 0) >= user.limit_bytes ? 'text-red-400 font-bold' : 'text-white'}>
{formatBytes(user.limit_bytes)}
</span>
) : (
t('cl_unlimited')
)}
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleOpenShare(user)}
className="p-2 hover:bg-white/10 rounded-lg text-text-muted hover:text-white transition-colors"
title="Get Share Connection Link"
>
<Share2 className="w-4 h-4" />
</button>
<button
onClick={() => handleResetStats(user.access_key)}
className="p-2 hover:bg-white/10 rounded-lg text-text-muted hover:text-yellow-400 transition-colors"
title="Reset Traffic Counters"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => openEditModal(user)}
className="p-2 hover:bg-white/10 rounded-lg text-text-muted hover:text-white transition-colors"
title="Edit Client Description/Limit"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteClient(user.access_key)}
className="p-2 hover:bg-red-500/20 rounded-lg text-text-muted hover:text-red-400 transition-colors"
title="Delete Client"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
{filteredUsers.length === 0 && !isLoading && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-text-muted">
<Users className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>No clients found matching query.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<ClientsTable
users={filteredUsers}
isLoading={isLoading}
formatBytes={formatBytes}
handleOpenShare={handleOpenShare}
handleResetStats={handleResetStats}
openEditModal={openEditModal}
handleDeleteClient={handleDeleteClient}
/>
{/* Add Client Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md rounded-2xl p-6 space-y-4 relative animate-in fade-in zoom-in-95 duration-200">
<button
onClick={() => setShowAddModal(false)}
className="absolute top-4 right-4 p-1 rounded-lg hover:bg-white/10 text-text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-white">{t('cl_add_title')}</h2>
<form onSubmit={handleAddClient} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_name')}</label>
<input
type="text"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors"
placeholder="e.g. My Phone, Home Laptop"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_limit')}</label>
<div className="flex gap-2">
<input
type="number"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors font-mono"
placeholder={t('cl_form_limit_sub')}
value={clientLimit}
onChange={(e) => setClientLimit(e.target.value)}
/>
<select
className="bg-surface-light border border-white/10 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-primary"
value={clientLimitUnit}
onChange={(e) => setClientLimitUnit(e.target.value)}
>
<option value="MB">MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_custom')}</label>
<input
type="text"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors font-mono"
placeholder={t('cl_form_custom_sub')}
value={clientCustomKey}
onChange={(e) => setClientCustomKey(e.target.value)}
/>
</div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white py-2.5 rounded-xl font-medium transition-colors mt-2 shadow-[0_0_15px_rgba(108,114,255,0.3)]"
>
{t('cl_add')}
</button>
</form>
</div>
</div>
)}
<AddClientModal
show={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddClient}
clientName={clientName}
setClientName={setClientName}
clientLimit={clientLimit}
setClientLimit={setClientLimit}
clientLimitUnit={clientLimitUnit}
setClientLimitUnit={setClientLimitUnit}
clientCustomKey={clientCustomKey}
setClientCustomKey={setClientCustomKey}
/>
{/* Edit Client Modal */}
{showEditModal && editingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md rounded-2xl p-6 space-y-4 relative animate-in fade-in zoom-in-95 duration-200">
<button
onClick={() => {
setShowEditModal(false);
setEditingUser(null);
}}
className="absolute top-4 right-4 p-1 rounded-lg hover:bg-white/10 text-text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-white">{t('cl_edit_title')}</h2>
<form onSubmit={handleEditClient} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_name')}</label>
<input
type="text"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors"
placeholder="e.g. My Phone, Home Laptop"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_limit')}</label>
<div className="flex gap-2">
<input
type="number"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors font-mono"
placeholder={t('cl_form_limit_sub')}
value={editLimit}
onChange={(e) => setEditLimit(e.target.value)}
/>
<select
className="bg-surface-light border border-white/10 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-primary"
value={editLimitUnit}
onChange={(e) => setEditLimitUnit(e.target.value)}
>
<option value="MB">MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
</div>
<div className="text-xs text-text-muted font-mono truncate">
Access Key: {editingUser.access_key}
</div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white py-2.5 rounded-xl font-medium transition-colors mt-2 shadow-[0_0_15px_rgba(108,114,255,0.3)]"
>
{t('cl_form_save')}
</button>
</form>
</div>
</div>
)}
<EditClientModal
show={showEditModal}
onClose={() => {
setShowEditModal(false);
setEditingUser(null);
}}
onSubmit={handleEditClient}
editingUser={editingUser}
editName={editName}
setEditName={setEditName}
editLimit={editLimit}
setEditLimit={setEditLimit}
editLimitUnit={editLimitUnit}
setEditLimitUnit={setEditLimitUnit}
/>
{/* Share Connection Modal */}
{showShareModal && sharingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg rounded-2xl relative animate-in fade-in zoom-in-95 duration-200 flex flex-col" style={{ maxHeight: '90vh' }}>
{/* Sticky header */}
<div className="flex items-start justify-between p-6 pb-4 shrink-0">
<div>
<h2 className="text-xl font-bold text-white">{t('cl_share_title')}</h2>
<p className="text-sm text-text-muted mt-0.5">{t('cl_share_sub')}</p>
</div>
<button
onClick={() => {
setShowShareModal(false);
setSharingUser(null);
}}
className="ml-4 shrink-0 p-1.5 rounded-lg hover:bg-white/10 text-text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<ShareClientModal
user={sharingUser}
shareLink={shareLink}
isFetchingLink={isFetchingLink}
qrCanvasRef={qrCanvasRef}
onClose={() => setShowShareModal(false)}
downloadQr={downloadQr}
copyToClipboard={copyToClipboard}
/>
)}
{/* Scrollable body */}
<div className="overflow-y-auto px-6 pb-6 space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_name')}</label>
<div className="text-white font-medium">{sharingUser.name || t('cl_unnamed')}</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_share_link')}</label>
{isFetchingLink ? (
<div className="bg-white/5 border border-white/10 rounded-xl p-4 flex items-center justify-center">
<RefreshCw className="w-6 h-6 animate-spin text-primary mr-2" />
<span className="text-sm text-text-muted">Generating link...</span>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
readOnly
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white font-mono text-xs select-all focus:outline-none"
value={shareLink}
/>
<button
onClick={() => copyToClipboard(shareLink)}
className="p-2.5 bg-primary hover:bg-primary/90 text-white rounded-xl transition-colors shrink-0"
title="Copy Link"
>
<Copy className="w-5 h-5" />
</button>
</div>
)}
</div>
{/* QR Code — compact, side layout */}
{!isFetchingLink && shareLink && (
<div className="flex items-center gap-4 p-3 rounded-xl border border-white/10" style={{ background: 'linear-gradient(135deg, rgba(108,114,255,0.10) 0%, rgba(34,211,165,0.07) 100%)' }}>
<div className="shrink-0" style={{ background: 'rgba(0,0,0,0.3)', borderRadius: '0.5rem', padding: '8px' }}>
<canvas ref={qrCanvasRef} style={{ display: 'block', borderRadius: '4px' }} />
</div>
<div className="flex flex-col gap-2 min-w-0">
<p className="text-xs text-text-muted leading-snug">{t('cl_share_scan')}</p>
<button
onClick={downloadQr}
className="flex items-center gap-2 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 text-white text-xs rounded-lg transition-colors w-fit"
>
<Download className="w-3.5 h-3.5" />
{t('cl_share_download_qr')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
{showBulkModal && (
<BulkKeysModal
onClose={() => setShowBulkModal(false)}
onGenerate={handleBulkGenerate}
/>
)}
</div>
);

View File

@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import { Route, Plus, Trash2, Save, Activity, ShieldAlert, ShieldCheck, HelpCircle } from 'lucide-react';
import { api } from '../lib/api';
import type { OutboundRule, OutboundAction } from '../lib/api';
import { useLanguage } from '../lib/LanguageContext';
import { addAuditLog } from '../lib/audit';
export default function Routing() {
const { t } = useLanguage();
const [rules, setRules] = useState<OutboundRule[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const fetchRules = async () => {
setIsLoading(true);
try {
const data = await api.getRouterRules();
setRules(data || []);
setErrorMsg(null);
} catch (err: any) {
setErrorMsg(err.message || 'Failed to fetch routing rules');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchRules();
}, []);
const handleSave = async () => {
setIsSaving(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
await api.updateRouterRules(rules);
setSuccessMsg('Routing rules saved successfully');
addAuditLog('Updated outbound routing rules', 'Обновлены правила маршрутизации исходящего трафика', true);
} catch (err: any) {
setErrorMsg(err.message || 'Failed to save rules');
addAuditLog(`Failed to update rules: ${err.message || err}`, `Не удалось обновить правила: ${err.message || err}`, false);
} finally {
setIsSaving(false);
}
};
const addRule = () => {
setRules([...rules, { action: 'proxy', domain_suffix: [], ip_cidr: [] }]);
};
const removeRule = (index: number) => {
const newRules = [...rules];
newRules.splice(index, 1);
setRules(newRules);
};
const updateRuleAction = (index: number, action: OutboundAction) => {
const newRules = [...rules];
newRules[index].action = action;
setRules(newRules);
};
const updateRuleDomains = (index: number, domainsStr: string) => {
const newRules = [...rules];
newRules[index].domain_suffix = domainsStr.split(',').map(d => d.trim()).filter(Boolean);
setRules(newRules);
};
const updateRuleIps = (index: number, ipsStr: string) => {
const newRules = [...rules];
newRules[index].ip_cidr = ipsStr.split(',').map(i => i.trim()).filter(Boolean);
setRules(newRules);
};
return (
<div className="relative z-10 w-full max-w-5xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-1 flex items-center gap-3">
<Route className="w-8 h-8 text-primary" /> Outbound Routing
</h1>
<p className="text-text-muted">Manage how outbound traffic is routed for connected clients.</p>
</div>
<div className="flex gap-2">
<button
onClick={addRule}
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-4 py-2.5 rounded-xl font-medium transition-colors border border-white/5"
>
<Plus className="w-5 h-5" />
Add Rule
</button>
<button
onClick={handleSave}
disabled={isSaving || isLoading}
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-6 py-2.5 rounded-xl font-medium transition-colors shadow-[0_0_15px_rgba(108,114,255,0.3)] disabled:opacity-50"
>
{isSaving ? <Activity className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Save Changes
</button>
</div>
</div>
{errorMsg && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-xl flex items-center gap-3 animate-in fade-in duration-300">
<ShieldAlert className="w-5 h-5 shrink-0" />
<p>{errorMsg}</p>
</div>
)}
{successMsg && (
<div className="bg-secondary/10 border border-secondary/20 text-secondary p-4 rounded-xl flex items-center gap-3 animate-in fade-in duration-300">
<ShieldCheck className="w-5 h-5 shrink-0" />
<p>{successMsg}</p>
</div>
)}
<div className="glass-panel p-6 rounded-2xl">
<div className="flex items-center gap-2 mb-6 text-text-muted text-sm">
<HelpCircle className="w-4 h-4 text-primary" />
<p>Rules are evaluated from top to bottom. The first matching rule determines the action.</p>
</div>
{isLoading ? (
<div className="py-12 flex justify-center">
<Activity className="w-8 h-8 text-primary animate-spin" />
</div>
) : rules.length === 0 ? (
<div className="text-center py-12 text-text-muted border border-dashed border-white/10 rounded-xl">
<Route className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>No routing rules defined. All traffic follows the default action.</p>
<button onClick={addRule} className="mt-4 text-primary hover:text-primary/80 transition-colors">Create your first rule</button>
</div>
) : (
<div className="space-y-4">
{rules.map((rule, index) => (
<div key={index} className="bg-black/20 border border-white/5 rounded-xl p-4 flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="flex items-center justify-center bg-white/5 rounded-lg w-8 h-8 text-text-muted font-mono shrink-0">
{index + 1}
</div>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-4 w-full">
<div>
<label className="block text-xs font-medium text-text-muted mb-1">Domain Suffixes (comma separated)</label>
<input
type="text"
value={rule.domain_suffix?.join(', ') || ''}
onChange={e => updateRuleDomains(index, e.target.value)}
placeholder="e.g. google.com, netflix.com"
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1">IP CIDRs (comma separated)</label>
<input
type="text"
value={rule.ip_cidr?.join(', ') || ''}
onChange={e => updateRuleIps(index, e.target.value)}
placeholder="e.g. 192.168.1.0/24, 10.0.0.1/32"
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors"
/>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<div>
<label className="block text-xs font-medium text-text-muted mb-1">Action</label>
<select
value={rule.action}
onChange={e => updateRuleAction(index, e.target.value as OutboundAction)}
className="bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary/50 transition-colors appearance-none cursor-pointer"
>
<option value="proxy">Proxy</option>
<option value="direct">Direct</option>
<option value="block">Block</option>
</select>
</div>
<button
onClick={() => removeRule(index)}
className="p-2 text-text-muted hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors mt-5"
title="Remove Rule"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
import type { FormEvent } from 'react';
import { X } from 'lucide-react';
import { useLanguage } from '../../lib/LanguageContext';
export interface AddClientModalProps {
show: boolean;
onClose: () => void;
onSubmit: (e: FormEvent) => void;
clientName: string;
setClientName: (v: string) => void;
clientLimit: string;
setClientLimit: (v: string) => void;
clientLimitUnit: string;
setClientLimitUnit: (v: string) => void;
clientCustomKey: string;
setClientCustomKey: (v: string) => void;
}
export function AddClientModal({
show,
onClose,
onSubmit,
clientName,
setClientName,
clientLimit,
setClientLimit,
clientLimitUnit,
setClientLimitUnit,
clientCustomKey,
setClientCustomKey
}: AddClientModalProps) {
const { t } = useLanguage();
if (!show) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md rounded-2xl p-6 space-y-4 relative animate-in fade-in zoom-in-95 duration-200">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-lg hover:bg-white/10 text-text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-white">{t('cl_add_title')}</h2>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_name')}</label>
<input
type="text"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors"
placeholder="e.g. My Phone, Home Laptop"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_limit')}</label>
<div className="flex gap-2">
<input
type="number"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors font-mono"
placeholder={t('cl_form_limit_sub')}
value={clientLimit}
onChange={(e) => setClientLimit(e.target.value)}
/>
<select
className="bg-surface-light border border-white/10 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-primary"
value={clientLimitUnit}
onChange={(e) => setClientLimitUnit(e.target.value)}
>
<option value="MB">MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_custom')}</label>
<input
type="text"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors font-mono"
placeholder={t('cl_form_custom_sub')}
value={clientCustomKey}
onChange={(e) => setClientCustomKey(e.target.value)}
/>
</div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white py-2.5 rounded-xl font-medium transition-colors mt-2 shadow-[0_0_15px_rgba(108,114,255,0.3)]"
>
{t('cl_add')}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { X, Copy, CheckCircle2, Zap } from 'lucide-react';
import { useLanguage } from '../../lib/LanguageContext';
interface BulkKeysModalProps {
onClose: () => void;
onGenerate: (count: number, limitBytes: number | null) => Promise<string[]>;
}
export function BulkKeysModal({ onClose, onGenerate }: BulkKeysModalProps) {
const { t } = useLanguage();
const [count, setCount] = useState<number>(10);
const [limitGB, setLimitGB] = useState<string>('');
const [loading, setLoading] = useState(false);
const [generatedKeys, setGeneratedKeys] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const handleGenerate = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const limitBytes = limitGB ? parseInt(limitGB) * 1024 * 1024 * 1024 : null;
const keys = await onGenerate(count, limitBytes);
setGeneratedKeys(keys);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(generatedKeys.join('\n'));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-background-light border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
<div className="p-6 border-b border-white/5 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
Bulk Generate Keys
</h2>
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-lg transition-colors text-text-muted hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
{generatedKeys.length === 0 ? (
<form onSubmit={handleGenerate} className="p-6 space-y-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-muted mb-2">Number of Keys to Generate</label>
<input
type="number"
min="1"
max="1000"
required
value={count}
onChange={e => setCount(parseInt(e.target.value))}
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary/50 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-muted mb-2">Traffic Limit per Key (GB, optional)</label>
<input
type="number"
min="1"
value={limitGB}
onChange={e => setLimitGB(e.target.value)}
placeholder="Unlimited"
className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary/50 transition-colors placeholder:text-white/20"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-3 rounded-xl font-medium text-white hover:bg-white/5 transition-colors border border-white/10"
>
{t('cancel')}
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-3 bg-primary hover:bg-primary/90 text-white rounded-xl font-medium transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? 'Generating...' : 'Generate'}
</button>
</div>
</form>
) : (
<div className="p-6 space-y-6">
<p className="text-secondary font-medium flex items-center gap-2">
<CheckCircle2 className="w-5 h-5" /> Successfully generated {generatedKeys.length} keys
</p>
<div className="bg-black/40 rounded-xl p-4 border border-white/5 relative group">
<textarea
readOnly
value={generatedKeys.join('\n')}
className="w-full h-48 bg-transparent text-sm font-mono text-white/80 resize-none outline-none"
/>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-2 bg-white/10 hover:bg-white/20 rounded-lg backdrop-blur-md text-white transition-colors flex items-center gap-2"
>
{copied ? <CheckCircle2 className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
<span className="text-xs font-medium">{copied ? 'Copied' : 'Copy All'}</span>
</button>
</div>
<button
onClick={onClose}
className="w-full px-4 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl font-medium transition-colors"
>
Close
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
import { Users, Key, Share2, RefreshCw, Edit2, Trash2 } from 'lucide-react';
import { useLanguage } from '../../lib/LanguageContext';
import type { UserStatsSnapshot } from '../../lib/api';
export interface ClientsTableProps {
users: UserStatsSnapshot[];
isLoading: boolean;
formatBytes: (bytes: number) => string;
handleOpenShare: (user: UserStatsSnapshot) => void;
handleResetStats: (key: string) => void;
openEditModal: (user: UserStatsSnapshot) => void;
handleDeleteClient: (key: string) => void;
}
export function ClientsTable({
users,
isLoading,
formatBytes,
handleOpenShare,
handleResetStats,
openEditModal,
handleDeleteClient
}: ClientsTableProps) {
const { t } = useLanguage();
return (
<div className="glass-panel rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/5 bg-white/[0.02]">
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_status')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_name')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_key')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_usage')}</th>
<th className="px-6 py-4 font-medium text-text-muted">{t('cl_limit')}</th>
<th className="px-6 py-4 font-medium text-text-muted text-right">{t('cl_actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{users.map((user) => (
<tr key={user.access_key} className="hover:bg-white/[0.02] transition-colors group">
<td className="px-6 py-4">
{user.online ? (
<div className="flex items-center gap-2 text-secondary">
<span className="w-2 h-2 rounded-full bg-secondary shadow-[0_0_8px_#22D3A5]"></span>
<span className="text-sm font-medium">{t('cl_active')}</span>
</div>
) : (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-text-muted">
<span className="w-2 h-2 rounded-full bg-text-muted"></span>
<span className="text-sm">{t('cl_offline')}</span>
</div>
{user.last_seen ? (
<div className="text-[10px] text-text-muted/60 pl-4 whitespace-nowrap">
{new Date(user.last_seen * 1000).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})}
</div>
) : null}
</div>
)}
</td>
<td className="px-6 py-4 font-medium text-white">
{user.name || (
<span className="text-text-muted italic">{t('cl_unnamed')}</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-text-muted font-mono text-sm">
<Key className="w-4 h-4 shrink-0 text-primary/70" />
<span title={user.access_key}>
{user.access_key.length > 20 ? `${user.access_key.substring(0, 16)}...` : user.access_key}
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-sm text-white">
<span className="text-xs text-text-muted w-8">Up:</span>
<span className="text-red-400 font-mono">{formatBytes(user.bytes_up || 0)}</span>
</div>
<div className="flex items-center gap-2 text-sm text-white">
<span className="text-xs text-text-muted w-8">Down:</span>
<span className="text-secondary font-mono">{formatBytes(user.bytes_down || 0)}</span>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
<span>Sessions:</span>
<span className="font-mono text-white">{user.connections}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-mono text-text-muted">
{user.limit_bytes ? (
<span className={(user.bytes_up || 0) + (user.bytes_down || 0) >= user.limit_bytes ? 'text-red-400 font-bold' : 'text-white'}>
{formatBytes(user.limit_bytes)}
</span>
) : (
t('cl_unlimited')
)}
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleOpenShare(user)}
className="p-2 hover:bg-white/10 rounded-lg text-text-muted hover:text-white transition-colors"
title="Get Share Connection Link"
>
<Share2 className="w-4 h-4" />
</button>
<button
onClick={() => handleResetStats(user.access_key)}
className="p-2 hover:bg-white/10 rounded-lg text-text-muted hover:text-yellow-400 transition-colors"
title="Reset Traffic Counters"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={() => openEditModal(user)}
className="p-2 hover:bg-white/10 rounded-lg text-text-muted hover:text-white transition-colors"
title="Edit Client Description/Limit"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteClient(user.access_key)}
className="p-2 hover:bg-red-500/20 rounded-lg text-text-muted hover:text-red-400 transition-colors"
title="Delete Client"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
{users.length === 0 && !isLoading && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-text-muted">
<Users className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>No clients found matching query.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import type { FormEvent } from 'react';
import { X } from 'lucide-react';
import { useLanguage } from '../../lib/LanguageContext';
import type { UserStatsSnapshot } from '../../lib/api';
export interface EditClientModalProps {
show: boolean;
onClose: () => void;
onSubmit: (e: FormEvent) => void;
editingUser: UserStatsSnapshot | null;
editName: string;
setEditName: (v: string) => void;
editLimit: string;
setEditLimit: (v: string) => void;
editLimitUnit: string;
setEditLimitUnit: (v: string) => void;
}
export function EditClientModal({
show,
onClose,
onSubmit,
editingUser,
editName,
setEditName,
editLimit,
setEditLimit,
editLimitUnit,
setEditLimitUnit
}: EditClientModalProps) {
const { t } = useLanguage();
if (!show || !editingUser) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md rounded-2xl p-6 space-y-4 relative animate-in fade-in zoom-in-95 duration-200">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-lg hover:bg-white/10 text-text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-xl font-bold text-white">{t('cl_edit_title')}</h2>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_name')}</label>
<input
type="text"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors"
placeholder="e.g. My Phone, Home Laptop"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_form_limit')}</label>
<div className="flex gap-2">
<input
type="number"
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-text-muted focus:outline-none focus:border-primary transition-colors font-mono"
placeholder={t('cl_form_limit_sub')}
value={editLimit}
onChange={(e) => setEditLimit(e.target.value)}
/>
<select
className="bg-surface-light border border-white/10 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-primary"
value={editLimitUnit}
onChange={(e) => setEditLimitUnit(e.target.value)}
>
<option value="MB">MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
</div>
<div className="text-xs text-text-muted font-mono truncate">
Access Key: {editingUser.access_key}
</div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white py-2.5 rounded-xl font-medium transition-colors mt-2 shadow-[0_0_15px_rgba(108,114,255,0.3)]"
>
{t('cl_form_save')}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import type { RefObject } from 'react';
import { X, RefreshCw, Copy, Download } from 'lucide-react';
import { useLanguage } from '../../lib/LanguageContext';
import type { UserStatsSnapshot } from '../../lib/api';
export interface ShareClientModalProps {
show: boolean;
onClose: () => void;
sharingUser: UserStatsSnapshot | null;
shareLink: string;
isFetchingLink: boolean;
qrCanvasRef: RefObject<HTMLCanvasElement | null>;
copyToClipboard: (text: string) => void;
downloadQr: () => void;
}
export function ShareClientModal({
show,
onClose,
sharingUser,
shareLink,
isFetchingLink,
qrCanvasRef,
copyToClipboard,
downloadQr
}: ShareClientModalProps) {
const { t } = useLanguage();
if (!show || !sharingUser) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg rounded-2xl relative animate-in fade-in zoom-in-95 duration-200 flex flex-col" style={{ maxHeight: '90vh' }}>
{/* Sticky header */}
<div className="flex items-start justify-between p-6 pb-4 shrink-0">
<div>
<h2 className="text-xl font-bold text-white">{t('cl_share_title')}</h2>
<p className="text-sm text-text-muted mt-0.5">{t('cl_share_sub')}</p>
</div>
<button
onClick={onClose}
className="ml-4 shrink-0 p-1.5 rounded-lg hover:bg-white/10 text-text-muted hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Scrollable body */}
<div className="overflow-y-auto px-6 pb-6 space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_name')}</label>
<div className="text-white font-medium">{sharingUser.name || t('cl_unnamed')}</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-text-muted uppercase">{t('cl_share_link')}</label>
{isFetchingLink ? (
<div className="bg-white/5 border border-white/10 rounded-xl p-4 flex items-center justify-center">
<RefreshCw className="w-6 h-6 animate-spin text-primary mr-2" />
<span className="text-sm text-text-muted">Generating link...</span>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
readOnly
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white font-mono text-xs select-all focus:outline-none"
value={shareLink}
/>
<button
onClick={() => copyToClipboard(shareLink)}
className="p-2.5 bg-primary hover:bg-primary/90 text-white rounded-xl transition-colors shrink-0"
title="Copy Link"
>
<Copy className="w-5 h-5" />
</button>
</div>
)}
</div>
{/* QR Code — compact, side layout */}
{!isFetchingLink && shareLink && (
<div className="flex items-center gap-4 p-3 rounded-xl border border-white/10" style={{ background: 'linear-gradient(135deg, rgba(108,114,255,0.10) 0%, rgba(34,211,165,0.07) 100%)' }}>
<div className="shrink-0" style={{ background: 'rgba(0,0,0,0.3)', borderRadius: '0.5rem', padding: '8px' }}>
<canvas ref={qrCanvasRef} style={{ display: 'block', borderRadius: '4px' }} />
</div>
<div className="flex flex-col gap-2 min-w-0">
<p className="text-xs text-text-muted leading-snug">{t('cl_share_scan')}</p>
<button
onClick={downloadQr}
className="flex items-center gap-2 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 text-white text-xs rounded-lg transition-colors w-fit"
>
<Download className="w-3.5 h-3.5" />
{t('cl_share_download_qr')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,3 @@
-keep class net.ostp.client.OstpClientSdk { *; }
-keep class com.ospab.ostp_client.OstpVpnService { *; }
-keep class com.ospab.ostp_client.MainActivity { *; }

View File

@ -9,7 +9,7 @@
<application
android:label="ostp_client"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/launcher_icon"
android:extractNativeLibs="true">
<activity
android:name=".MainActivity"
@ -51,7 +51,7 @@
<!-- Quick Settings Tile -->
<service
android:name=".OstpTileService"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/launcher_icon"
android:label="OSTP VPN"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
enum ConnectionStateEnum { disconnected, connecting, connected }

View File

@ -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<AppRoutingScreen> createState() => _AppRoutingScreenState();
}
class _AppRoutingScreenState extends State<AppRoutingScreen> {
static const platform = MethodChannel('com.ospab.ostp/vpn');
List<Map<String, dynamic>> _allApps = [];
List<Map<String, dynamic>> _filteredApps = [];
Set<String> _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<void> _fetchInstalledApps() async {
try {
final List<dynamic>? rawApps = await platform.invokeMethod('getInstalledApps');
if (rawApps != null) {
final List<Map<String, dynamic>> apps = rawApps.map((e) {
final Map<dynamic, dynamic> m = e as Map<dynamic, dynamic>;
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();
},
),
),
);
},
),
),
],
),
);
}
}

View File

@ -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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> 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<void> _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<void> _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<void> _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<String, dynamic> 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<String, dynamic> 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<void> _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,
),
),
],
)
],
);
}
}

View File

@ -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<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> {
static const platform = MethodChannel('com.ospab.ostp/vpn');
Timer? _pollTimer;
final List<String> _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<void> _fetchLogs() async {
try {
final String logsJson = await platform.invokeMethod('getLogs');
if (logsJson.isNotEmpty && logsJson != "[]") {
final List<dynamic> 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<void> _clearLogs() async {
await platform.invokeMethod('clearLogs');
setState(() {
_logs.clear();
});
}
Future<void> _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,
),
),
);
},
),
),
);
}
}

View File

@ -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<QRScannerScreen> createState() => _QRScannerScreenState();
}
class _QRScannerScreenState extends State<QRScannerScreen> {
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<Barcode> 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,
);
}
}

View File

@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
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<bool> 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<String>(
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<String>(
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<String> 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<String>(
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<String>(
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),
],
),
);
}
}

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

@ -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",

View File

@ -43,6 +43,7 @@ struct TunConfig {
ipv4_address: Option<String>,
dns: Option<String>,
stack: Option<String>,
kill_switch: Option<bool>,
}
#[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<String, String> {
"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<bool, String
return Ok(false);
}
let config_path = get_config_path();
let config_str = std::fs::read_to_string(&config_path)
let path = get_config_path();
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Read config error: {}", e))?;
let mut stripped = json_comments::StripComments::new(content.as_bytes());
let unified: UnifiedConfig = serde_json::from_reader(&mut stripped)
.map_err(|e| format!("Parse config error: {}", e))?;
let client_cfg = match unified.mode {
AppMode::Client(c) => 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<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let verb_wstr: Vec<u16> = 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<u16> = OsStr::new(&params_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<u16> = 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<u16> = std::ffi::OsStr::new(msg).encode_wide().chain(Some(0)).collect();
let title_w: Vec<u16> = 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::net::TcpListener> = 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())

View File

@ -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: 'Транспортный протокол',

View File

@ -257,6 +257,19 @@
</label>
</div>
<div class="toggle-row" id="group-kill-switch" style="display: none;">
<div class="toggle-text">
<span class="toggle-name" data-i18n="label_kill_switch">Kill Switch</span>
<span class="toggle-hint" data-i18n="kill_switch_hint">Block traffic if connection drops</span>
</div>
<label class="toggle">
<input type="checkbox" id="in-kill-switch" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-text">

View File

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

View File

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

View File

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

View File

@ -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<std::path::PathBuf>,
pub dns_server: std::sync::Arc<crate::dns::DnsServer>,
pub audit_logs: Arc<RwLock<Vec<AuditLogEntry>>>,
pub router: std::sync::Arc<crate::router::Router>,
}
#[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<std::path::PathBuf>,
dns_server: std::sync::Arc<crate::dns::DnsServer>,
router: std::sync::Arc<crate::router::Router>,
) {
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<UserStatsSnapshot> = 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<ApiState>) -> impl IntoResponse {
let logs = state.audit_logs.read().unwrap();
ApiResponse::success(logs.clone())
}
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse {
let mut logs = state.audit_logs.write().unwrap();
let id = format!("{:x}", rand::random::<u64>());
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<u64>,
}
async fn handle_bulk_create_users(
State(state): State<ApiState>,
headers: axum::http::HeaderMap,
Json(payload): Json<BulkCreateRequest>,
) -> 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<ApiState>,
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<ApiState>,
headers: axum::http::HeaderMap,
Json(new_rules): Json<Vec<OutboundRule>>,
) -> 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<ApiState>) -> impl IntoResponse {
let mut logs = state.audit_logs.write().unwrap();
logs.clear();
ApiResponse::success(())
}

View File

@ -61,6 +61,7 @@ pub struct UserStatsSnapshot {
pub connections: u64,
pub limit_bytes: Option<u64>,
pub online: bool,
pub last_seen: Option<u64>,
}
pub struct PeerState {
@ -109,17 +110,37 @@ impl Dispatcher {
/// Snapshot all user stats for API responses.
pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> {
let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner());
let online_keys: std::collections::HashSet<String> = 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<String, std::time::Instant> = 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()
}

View File

@ -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).
/// Клиенты могут указать <server_ip>:50053 в качестве DNS-сервера.
#[serde(default = "default_dns_local_port")]
pub local_port: u16,
pub doh_upstream: String,
pub adblock_urls: Vec<String>,
pub custom_domains: HashMap<String, String>,
}
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<DnsConfig>,
adblock_trie: RwLock<HashSet<String>>, // Simplified to HashSet for now, or maybe a suffix tree
adblock_trie: RwLock<HashSet<String>>,
query_log: Mutex<VecDeque<DnsQueryLog>>,
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<Vec<u8>> {
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::<std::net::Ipv4Addr>() {
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::<std::net::Ipv4Addr>() {
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`.
///
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
pub async fn run_local_udp_listener(self: Arc<Self>) {
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,

View File

@ -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<UiCommand>,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
outbound: Option<OutboundConfig>,
debug: bool,
router: std::sync::Arc<crate::router::Router>,
reality_config: Option<std::sync::Arc<RealityServerConfig>>,
dns_server: std::sync::Arc<dns::DnsServer>,
) -> Result<()> {
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
@ -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<u8>)>,
udp_reply_tx: mpsc::UnboundedSender<(u32, u16, String, Vec<u8>)>,
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
outbound: Option<OutboundConfig>,
dns_server: std::sync::Arc<dns::DnsServer>,
debug: bool,
router: std::sync::Arc<crate::router::Router>,
peer_last_seen: &mut HashMap<IpAddr, Instant>,
peer_available: &mut HashMap<IpAddr, bool>,
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?;
}

View File

@ -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<String>,
#[serde(default)]
pub ip_cidr: Vec<String>,
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() {

View File

@ -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<u8>)>,
udp_reply_tx: mpsc::UnboundedSender<(u32, u16, String, Vec<u8>)>,
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
outbound_cfg: Option<OutboundConfig>,
dns_server: std::sync::Arc<crate::dns::DnsServer>,
debug: bool,
router: std::sync::Arc<crate::router::Router>,
tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>,
) -> 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)));
}

36
ostp-server/src/router.rs Normal file
View File

@ -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<RwLock<Option<OutboundConfig>>>,
pub dns_server: Arc<DnsServer>,
pub debug: bool,
}
impl Router {
pub fn new(outbound_cfg: Option<OutboundConfig>, dns_server: Arc<DnsServer>, 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<TcpStream> {
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<Vec<u8>> {
self.dns_server.resolve(payload, client_ip).await
}
}

View File

@ -46,6 +46,7 @@ enum HelperMsg {
struct TunnelState {
shutdown_tx: Option<watch::Sender<bool>>,
config_tx: Option<watch::Sender<ostp_client::config::ClientConfig>>,
metrics: Option<Arc<ostp_client::bridge::BridgeMetrics>>,
}
@ -61,18 +62,19 @@ async fn main() -> Result<()> {
let mut port = 53211u16;
let args: Vec<String> = 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 } => {

View File

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

View File

@ -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<ClientConfig> {
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<String>,
ipv4_address: Option<String>,
dns: Option<String>,
kill_switch: Option<bool>,
}
#[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 <server_ip>: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

View File

@ -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 <<EOF > "$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"

View File

@ -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 (клиент может указать <server_ip>: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"
}
}
}