mirror of https://github.com/ospab/ostp.git
Compare commits
6 Commits
78c7a9e886
...
e31a651eb1
| Author | SHA1 | Date |
|---|---|---|
|
|
e31a651eb1 | |
|
|
922cf0b142 | |
|
|
da41289336 | |
|
|
1151726415 | |
|
|
9da23b2a4d | |
|
|
a16792986f |
|
|
@ -1447,7 +1447,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1461,6 +1461,7 @@ dependencies = [
|
||||||
"pico-args",
|
"pico-args",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rlimit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -1473,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1508,7 +1509,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
|
@ -1545,7 +1546,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1578,7 +1579,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1591,7 +1592,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hex",
|
"hex",
|
||||||
|
|
@ -1949,6 +1950,15 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rlimit"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-embed"
|
name = "rust-embed"
|
||||||
version = "8.11.0"
|
version = "8.11.0"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ resolver = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "BSL 1.1"
|
license = "BSL 1.1"
|
||||||
version = "0.3.18"
|
version = "0.3.21"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ OSTP (Ospab Stealth Transport Protocol) is an encrypted transport protocol writt
|
||||||
| **Multiplexed Streams**| Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
| **Multiplexed Streams**| Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
||||||
| **Session Roaming** | Connection persistence across IP changes via session ID tracking. |
|
| **Session Roaming** | Connection persistence across IP changes via session ID tracking. |
|
||||||
| **UoT Mode** | UDP-over-TCP encapsulation with length-prefixing to bypass UDP blocking. |
|
| **UoT Mode** | UDP-over-TCP encapsulation with length-prefixing to bypass UDP blocking. |
|
||||||
| **Fallback Server** | TCP proxying to a legitimate web server to resist active probing. |
|
| **TCP Fragmentation** | (Zapret-style) Bypasses Deep Packet Inspection (DPI) by chunking the initial TLS/Noise handshakes. |
|
||||||
|
| **Junk Packets** | Sends randomized dummy UDP packets prior to the handshake to confuse DPI flow analyzers. |
|
||||||
|
| **Adaptive Padding** | Dynamically pads handshake and data frames up to 1024 bytes to prevent packet size fingerprinting. |
|
||||||
| **TUN Mode** | Native network stack integration (`smoltcp`) for full-system routing without external dependencies. |
|
| **TUN Mode** | Native network stack integration (`smoltcp`) for full-system routing without external dependencies. |
|
||||||
| **Management API** | Built-in REST API for server administration, metrics, and key generation. |
|
| **Management API** | Built-in REST API for server administration, metrics, and key generation. |
|
||||||
| **TURN Relay** | RFC 5766 TURN support for NAT traversal. |
|
| **TURN Relay** | RFC 5766 TURN support for NAT traversal. |
|
||||||
|
|
@ -39,18 +41,16 @@ flowchart LR
|
||||||
Apps[Local Apps] -->|SOCKS5 / TUN| CoreC
|
Apps[Local Apps] -->|SOCKS5 / TUN| CoreC
|
||||||
|
|
||||||
subgraph Client [Client Node]
|
subgraph Client [Client Node]
|
||||||
CoreC[OSTP Client] -.->|Encrypt & Mask| NetC[Transport Layer]
|
CoreC[OSTP Client] -.->|Encrypt, Pad & Chunk| NetC[Transport Layer]
|
||||||
end
|
end
|
||||||
|
|
||||||
NetC <==>|Encrypted UDP / UoT| NetS
|
NetC <==>|Encrypted UDP / UoT| NetS
|
||||||
|
|
||||||
subgraph Server [Server Node]
|
subgraph Server [Server Node]
|
||||||
NetS[Transport Layer] -.->|Decrypt & Auth| CoreS[OSTP Server]
|
NetS[Transport Layer] -.->|Decrypt & Auth| CoreS[OSTP Server]
|
||||||
NetS -->|Unauthenticated| Fallback[Fallback Server]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
CoreS -->|Relay| WWW((Internet))
|
CoreS -->|Relay| WWW((Internet))
|
||||||
Fallback -->|Forward| Web((Web / NGINX))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
32
README.ru.md
32
README.ru.md
|
|
@ -19,16 +19,18 @@ OSTP (Ospab Stealth Transport Protocol) — зашифрованный тран
|
||||||
|
|
||||||
| Возможность | Описание |
|
| Возможность | Описание |
|
||||||
|-------------|----------|
|
|-------------|----------|
|
||||||
| **Маскирование трафика** | Шифрование заголовков и полезной нагрузки с помощью HMAC ключей на каждый пакет. Трафик неотличим от шума. |
|
| **Маскировка Трафика** | Шифрование заголовков и данных уникальными ключами для каждого пакета. Выглядит как белый шум. |
|
||||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — аутентификация через PSK, forward secrecy. |
|
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — обмен ключами с forward secrecy и PSK-аутентификацией. |
|
||||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. |
|
| **Надёжный UDP (ARQ)** | Выборочные ACK/NACK, лимитированные повторы, настраиваемый буфер и экспоненциальный бэкофф. |
|
||||||
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
| **Мультиплексирование** | Несколько логических TCP-стримов внутри одной UDP-сессии с по-стримовым контролем потока. |
|
||||||
| **Session Roaming** | Сохранение соединения при смене IP-адреса благодаря отслеживанию по идентификатору сессии (session ID). |
|
| **Смена IP (Roaming)** | Сохранение соединений при переключении сетей благодаря трекингу по ID сессии. |
|
||||||
| **Режим UoT** | Инкапсуляция UDP внутри TCP с указанием длины пакетов для обхода блокировок неизвестного UDP-трафика. |
|
| **Режим UoT** | Упаковка UDP внутрь TCP-соединения для обхода блокировок UDP-трафика. |
|
||||||
| **Fallback Server** | Проксирование неаутентифицированных TCP подключений на веб-сервер для защиты от активного пробинга. |
|
| **TCP Фрагментация** | (В стиле Zapret) Обходит ТСПУ/DPI за счёт нарезки стартовых пакетов Noise/TLS на мелкие куски. |
|
||||||
| **TUN-режим** | Полносистемная маршрутизация через встроенный сетевой стек `smoltcp` без внешних зависимостей. |
|
| **Мусорные Пакеты** | Закидывает анализаторы DPI рандомными пакетами до начала рукопожатия, сбивая сигнатуры. |
|
||||||
| **Management API** | Встроенный REST API для администрирования сервера, сбора метрик и генерации ключей. |
|
| **Адаптивный Паддинг** | Добивает размер пакетов до 1024 байт, предотвращая детектирование по размерам пакетов. |
|
||||||
| **TURN Relay** | Поддержка RFC 5766 TURN для обхода NAT. |
|
| **Режим TUN** | Нативная интеграция сетевого стека (`smoltcp`) для маршрутизации всего устройства без tun2socks. |
|
||||||
|
| **Management API** | Встроенный REST API для управления сервером, генерации ключей и сбора метрик. |
|
||||||
|
| **TURN Relay** | Поддержка стандарта RFC 5766 TURN для обхода NAT. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -36,21 +38,19 @@ OSTP (Ospab Stealth Transport Protocol) — зашифрованный тран
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
Apps[Приложения] -->|SOCKS5 / TUN| CoreC
|
Apps[Локальные приложения] -->|SOCKS5 / TUN| CoreC
|
||||||
|
|
||||||
subgraph Client [Клиент]
|
subgraph Client [Клиент]
|
||||||
CoreC[OSTP Клиент] -.->|Шифрование| NetC[Транспортный уровень]
|
CoreC[OSTP Client] -.->|Шифрует и маскирует| NetC[Транспортный уровень]
|
||||||
end
|
end
|
||||||
|
|
||||||
NetC <==>|Зашифрованный UDP / UoT| NetS
|
NetC <==>|Зашифрованный UDP / UoT| NetS
|
||||||
|
|
||||||
subgraph Server [Сервер]
|
subgraph Server [Сервер]
|
||||||
NetS[Транспортный уровень] -.->|Дешифрование| CoreS[OSTP Сервер]
|
NetS[Транспортный уровень] -.->|Расшифровывает| CoreS[OSTP Server]
|
||||||
NetS -->|Неавторизованные| Fallback[Fallback Сервер]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
CoreS -->|Проксирование| WWW((Интернет))
|
CoreS -->|Релей| WWW((Интернет))
|
||||||
Fallback -->|Перенаправление| Web((Веб-сервер / NGINX))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -260,19 +260,33 @@ impl ClientConfig {
|
||||||
|
|
||||||
// 3. Outbounds
|
// 3. Outbounds
|
||||||
let mut outbounds = Vec::new();
|
let mut outbounds = Vec::new();
|
||||||
let server_full = json.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:50000");
|
|
||||||
let server_parts: Vec<&str> = server_full.split(':').collect();
|
let server_full = json.get("server").and_then(|v| v.as_str())
|
||||||
let server_host = server_parts.get(0).unwrap_or(&"127.0.0.1");
|
.or_else(|| json.get("ostp").and_then(|o| o.get("server_addr")).and_then(|v| v.as_str()))
|
||||||
let server_port = server_parts.get(1).unwrap_or(&"50000").parse::<u16>().unwrap_or(50000);
|
.unwrap_or("127.0.0.1:50000");
|
||||||
let access_key = json.get("access_key").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
let access_key = json.get("access_key").and_then(|v| v.as_str())
|
||||||
|
.or_else(|| json.get("ostp").and_then(|o| o.get("access_key")).and_then(|v| v.as_str()))
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
let transport_type = json.get("transport").and_then(|t| t.get("mode").or(t.get("type"))).and_then(|v| v.as_str()).unwrap_or("udp");
|
let transport_type = json.get("transport").and_then(|t| t.get("mode").or(t.get("type"))).and_then(|v| v.as_str()).unwrap_or("udp");
|
||||||
let mux_enabled = json.get("mux").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
|
let mux_enabled = json.get("mux").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let mux_sessions = json.get("mux").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
|
let mux_sessions = json.get("mux").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
|
||||||
|
|
||||||
|
let servers: Vec<&str> = server_full.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||||
|
let mut ostp_tags = Vec::new();
|
||||||
|
|
||||||
|
for (i, server_str) in servers.iter().enumerate() {
|
||||||
|
let server_parts: Vec<&str> = server_str.split(':').collect();
|
||||||
|
let server_host = server_parts.get(0).unwrap_or(&"127.0.0.1");
|
||||||
|
let server_port = server_parts.get(1).unwrap_or(&"50000").parse::<u16>().unwrap_or(50000);
|
||||||
|
|
||||||
|
let tag = if servers.len() > 1 { format!("proxy-{}", i) } else { "proxy".to_string() };
|
||||||
|
ostp_tags.push(tag.clone());
|
||||||
|
|
||||||
outbounds.push(serde_json::json!({
|
outbounds.push(serde_json::json!({
|
||||||
"type": "ostp",
|
"type": "ostp",
|
||||||
"tag": "proxy",
|
"tag": tag,
|
||||||
"server": server_host,
|
"server": server_host,
|
||||||
"port": server_port,
|
"port": server_port,
|
||||||
"access_key": access_key,
|
"access_key": access_key,
|
||||||
|
|
@ -284,6 +298,17 @@ impl ClientConfig {
|
||||||
"sessions": mux_sessions
|
"sessions": mux_sessions
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if servers.len() > 1 {
|
||||||
|
outbounds.push(serde_json::json!({
|
||||||
|
"type": "urltest",
|
||||||
|
"tag": "proxy",
|
||||||
|
"outbounds": ostp_tags,
|
||||||
|
"url": "http://cp.cloudflare.com",
|
||||||
|
"interval": "3m"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
outbounds.push(serde_json::json!({
|
outbounds.push(serde_json::json!({
|
||||||
"type": "direct",
|
"type": "direct",
|
||||||
|
|
|
||||||
|
|
@ -44,35 +44,58 @@ pub async fn run_client_core(
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
let metrics_ping = metrics.clone();
|
let metrics_ping = metrics.clone();
|
||||||
let server_ip = config.outbounds.iter().find_map(|o| {
|
let server_addr = config.outbounds.iter().find_map(|o| {
|
||||||
match o {
|
match o {
|
||||||
crate::config::OutboundConfig::Ostp { server, .. } => Some(server.clone()),
|
crate::config::OutboundConfig::Ostp { server, port, .. } => Some((server.clone(), *port)),
|
||||||
crate::config::OutboundConfig::Socks { server, .. } => Some(server.clone()),
|
crate::config::OutboundConfig::Socks { server, port, .. } => Some((server.clone(), *port)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(mut server) = server_ip {
|
if let Some((host, port)) = server_addr {
|
||||||
if !server.contains(':') {
|
// Probe the REAL server port. The OSTP server listens for UoT/TCP on the
|
||||||
server.push_str(":443");
|
// same port as UDP, so a plain TCP connect there confirms liveness. The
|
||||||
}
|
// old code hardcoded ":443" — which the server never listens on — so the
|
||||||
|
// probe failed every time and wrongly latched "reconnecting" forever even
|
||||||
|
// while the tunnel was carrying traffic (the button flickered to
|
||||||
|
// disconnected and counters appeared frozen).
|
||||||
|
let server = if host.contains(':') { host } else { format!("{host}:{port}") };
|
||||||
let mut shutdown_rx = shutdown_rx_ext.clone();
|
let mut shutdown_rx = shutdown_rx_ext.clone();
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
|
// Health probe: the authoritative source of "connected". Probe the
|
||||||
|
// server immediately, then every 3s. A reachable server latches
|
||||||
|
// state=2 (even before any app traffic flows), and two consecutive
|
||||||
|
// failures drop it back to 1 (reconnecting). Per-connection dials must
|
||||||
|
// NOT drive this global state or the button flickers as connections
|
||||||
|
// open and close.
|
||||||
|
let mut consecutive_fail = 0u32;
|
||||||
loop {
|
loop {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let ok = matches!(
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
tokio::net::TcpStream::connect(&server),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Ok(Ok(_))
|
||||||
|
);
|
||||||
|
if ok {
|
||||||
|
let rtt = start.elapsed().as_millis() as u32;
|
||||||
|
metrics_ping.rtt_ms.store(rtt, Ordering::Relaxed);
|
||||||
|
metrics_ping.connection_state.store(2, Ordering::Relaxed);
|
||||||
|
consecutive_fail = 0;
|
||||||
|
} else {
|
||||||
|
consecutive_fail += 1;
|
||||||
|
if consecutive_fail >= 2 {
|
||||||
|
metrics_ping.connection_state.store(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {}
|
_ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {}
|
||||||
_ = shutdown_rx.changed() => {
|
_ = shutdown_rx.changed() => {
|
||||||
if *shutdown_rx.borrow() { break; }
|
if *shutdown_rx.borrow() { break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let start = std::time::Instant::now();
|
|
||||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(2),
|
|
||||||
tokio::net::TcpStream::connect(&server)
|
|
||||||
).await {
|
|
||||||
let rtt = start.elapsed().as_millis() as u32;
|
|
||||||
metrics_ping.rtt_ms.store(rtt, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,58 @@ use crate::config::{ClientConfig, InboundConfig};
|
||||||
use crate::tunnel::router::{Router, Session};
|
use crate::tunnel::router::{Router, Session};
|
||||||
use crate::tunnel::outbounds::OutboundManager;
|
use crate::tunnel::outbounds::OutboundManager;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
use portable_atomic::Ordering;
|
||||||
|
|
||||||
|
struct MetricStream<T> {
|
||||||
|
inner: T,
|
||||||
|
metrics: Arc<crate::bridge::BridgeMetrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: AsyncRead + Unpin> AsyncRead for MetricStream<T> {
|
||||||
|
fn poll_read(
|
||||||
|
mut self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
let filled_before = buf.filled().len();
|
||||||
|
let res = std::pin::Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||||
|
if let std::task::Poll::Ready(Ok(())) = &res {
|
||||||
|
let filled_after = buf.filled().len();
|
||||||
|
if filled_after > filled_before {
|
||||||
|
// local client read from remote (this means recv from tunnel)
|
||||||
|
self.metrics.bytes_recv.fetch_add((filled_after - filled_before) as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: AsyncWrite + Unpin> AsyncWrite for MetricStream<T> {
|
||||||
|
fn poll_write(
|
||||||
|
mut self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
buf: &[u8],
|
||||||
|
) -> std::task::Poll<std::io::Result<usize>> {
|
||||||
|
let res = std::pin::Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||||
|
if let std::task::Poll::Ready(Ok(n)) = &res {
|
||||||
|
// local client write to remote (this means sent to tunnel)
|
||||||
|
self.metrics.bytes_sent.fetch_add(*n as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
std::pin::Pin::new(&mut self.inner).poll_flush(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
std::pin::Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_socks_inbound(
|
pub async fn run_socks_inbound(
|
||||||
_config: ClientConfig,
|
_config: ClientConfig,
|
||||||
inbound_config: InboundConfig,
|
inbound_config: InboundConfig,
|
||||||
|
|
@ -16,7 +65,6 @@ pub async fn run_socks_inbound(
|
||||||
metrics: Arc<crate::bridge::BridgeMetrics>,
|
metrics: Arc<crate::bridge::BridgeMetrics>,
|
||||||
is_primary: bool,
|
is_primary: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use portable_atomic::Ordering;
|
|
||||||
let InboundConfig::LocalProxy { tag, protocol, listen, port, set_system_proxy } = inbound_config else {
|
let InboundConfig::LocalProxy { tag, protocol, listen, port, set_system_proxy } = inbound_config else {
|
||||||
return Err(anyhow!("Invalid config for LocalProxy inbound"));
|
return Err(anyhow!("Invalid config for LocalProxy inbound"));
|
||||||
};
|
};
|
||||||
|
|
@ -57,13 +105,14 @@ pub async fn run_socks_inbound(
|
||||||
let proto = protocol.clone();
|
let proto = protocol.clone();
|
||||||
let inbound_tag = tag.clone();
|
let inbound_tag = tag.clone();
|
||||||
|
|
||||||
|
let metrics_clone = metrics.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if proto == "socks" {
|
if proto == "socks" {
|
||||||
if let Err(e) = handle_socks5_connection(&mut stream, &rt, &om, &inbound_tag, client_addr).await {
|
if let Err(e) = handle_socks5_connection(&mut stream, &rt, &om, &inbound_tag, client_addr, metrics_clone).await {
|
||||||
tracing::debug!("SOCKS5 handling error: {}", e);
|
tracing::debug!("SOCKS5 handling error: {}", e);
|
||||||
}
|
}
|
||||||
} else if proto == "http" {
|
} else if proto == "http" {
|
||||||
if let Err(e) = handle_http_connection(&mut stream, &rt, &om, &inbound_tag, client_addr).await {
|
if let Err(e) = handle_http_connection(&mut stream, &rt, &om, &inbound_tag, client_addr, metrics_clone).await {
|
||||||
tracing::debug!("HTTP proxy handling error: {}", e);
|
tracing::debug!("HTTP proxy handling error: {}", e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -84,6 +133,7 @@ async fn handle_socks5_connection(
|
||||||
outbound_manager: &Arc<OutboundManager>,
|
outbound_manager: &Arc<OutboundManager>,
|
||||||
inbound_tag: &str,
|
inbound_tag: &str,
|
||||||
client_addr: std::net::SocketAddr,
|
client_addr: std::net::SocketAddr,
|
||||||
|
metrics: Arc<crate::bridge::BridgeMetrics>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut buf = [0u8; 256];
|
let mut buf = [0u8; 256];
|
||||||
|
|
||||||
|
|
@ -153,7 +203,8 @@ async fn handle_socks5_connection(
|
||||||
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).await?;
|
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).await?;
|
||||||
|
|
||||||
// Forward data
|
// Forward data
|
||||||
tokio::io::copy_bidirectional(stream, &mut remote_stream).await?;
|
let mut metric_remote = MetricStream { inner: remote_stream, metrics };
|
||||||
|
tokio::io::copy_bidirectional(stream, &mut metric_remote).await?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("SOCKS5 TCP dial failed to {}: {}", outbound_tag, e);
|
tracing::warn!("SOCKS5 TCP dial failed to {}: {}", outbound_tag, e);
|
||||||
|
|
@ -171,6 +222,7 @@ async fn handle_http_connection(
|
||||||
outbound_manager: &Arc<OutboundManager>,
|
outbound_manager: &Arc<OutboundManager>,
|
||||||
inbound_tag: &str,
|
inbound_tag: &str,
|
||||||
client_addr: std::net::SocketAddr,
|
client_addr: std::net::SocketAddr,
|
||||||
|
metrics: Arc<crate::bridge::BridgeMetrics>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Basic HTTP CONNECT implementation
|
// Basic HTTP CONNECT implementation
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
|
|
@ -231,7 +283,8 @@ async fn handle_http_connection(
|
||||||
remote_stream.write_all(&buf[0..n]).await?;
|
remote_stream.write_all(&buf[0..n]).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::io::copy_bidirectional(stream, &mut remote_stream).await?;
|
let mut metric_remote = MetricStream { inner: remote_stream, metrics };
|
||||||
|
tokio::io::copy_bidirectional(stream, &mut metric_remote).await?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("HTTP TCP dial failed to {}: {}", outbound_tag, e);
|
tracing::warn!("HTTP TCP dial failed to {}: {}", outbound_tag, e);
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ pub async fn run_tun_inbound(
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
||||||
use netstack_smoltcp::StackBuilder;
|
use netstack_smoltcp::StackBuilder;
|
||||||
use portable_atomic::Ordering;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use futures::{StreamExt, SinkExt};
|
use futures::{StreamExt, SinkExt};
|
||||||
|
use portable_atomic::Ordering;
|
||||||
|
|
||||||
let InboundConfig::Tun { tag, auto_route, mtu, fd: _fd, .. } = inbound_config else {
|
#[allow(unused_variables)]
|
||||||
|
let InboundConfig::Tun { tag, auto_route, mtu, fd, .. } = inbound_config else {
|
||||||
return Err(anyhow!("Invalid config for TUN inbound"));
|
return Err(anyhow!("Invalid config for TUN inbound"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,16 +122,14 @@ pub async fn dial_tcp(
|
||||||
}
|
}
|
||||||
|
|
||||||
if !handshake_success {
|
if !handshake_success {
|
||||||
|
// A single proxied connection failing must NOT mark the whole tunnel
|
||||||
|
// as disconnected — global connection_state is owned by the health
|
||||||
|
// probe in run_client_core, not by per-target dials.
|
||||||
tracing::warn!("TCP handshake failed or protocol machine error");
|
tracing::warn!("TCP handshake failed or protocol machine error");
|
||||||
if let Some(m) = &metrics {
|
|
||||||
m.connection_state.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(m) = &metrics {
|
// The global health probe (in runner.rs) is the only authoritative source of connection state.
|
||||||
m.connection_state.store(2, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send connection request
|
// Send connection request
|
||||||
let connect_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_host_str, target_port));
|
let connect_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_host_str, target_port));
|
||||||
|
|
@ -268,8 +266,17 @@ pub async fn handle_udp(
|
||||||
let config = make_initiator_config(session_id, access_key, transport_cfg);
|
let config = make_initiator_config(session_id, access_key, transport_cfg);
|
||||||
let mut machine = ProtocolMachine::new(config)?;
|
let mut machine = ProtocolMachine::new(config)?;
|
||||||
|
|
||||||
// Send UDP Junk Packets (Amnezia style) to break DPI heuristics
|
// Amnezia-style junk to break DPI heuristics — but ONLY over stream
|
||||||
|
// transports (UoT/TCP), where it rides inside the connection. Over plain
|
||||||
|
// UDP each junk is a standalone datagram of random bytes that the server
|
||||||
|
// cannot tell from a port scan: it logs every one as an "Unauthorized
|
||||||
|
// probe", wastes CPU trying every key on it, and can trip the server's
|
||||||
|
// anti-probe defenses against this very client. The server is not
|
||||||
|
// coordinated to expect/discard junk (unlike AmneziaWG's Jc/Jmin/Jmax), so
|
||||||
|
// junk-over-UDP is pure self-inflicted noise. Gate it to stream transports.
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
let junk_enabled = matches!(transport_cfg.r#type.as_str(), "uot" | "tcp");
|
||||||
|
if junk_enabled {
|
||||||
let num_junk = rand::thread_rng().gen_range(2..=5);
|
let num_junk = rand::thread_rng().gen_range(2..=5);
|
||||||
for _ in 0..num_junk {
|
for _ in 0..num_junk {
|
||||||
let junk_len = rand::thread_rng().gen_range(100..=1000);
|
let junk_len = rand::thread_rng().gen_range(100..=1000);
|
||||||
|
|
@ -279,6 +286,7 @@ pub async fn handle_udp(
|
||||||
let _ = transport.send(&junk_bytes).await;
|
let _ = transport.send(&junk_bytes).await;
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send handshake first
|
// Send handshake first
|
||||||
if let Ok(action) = machine.on_event(OstpEvent::Start) {
|
if let Ok(action) = machine.on_event(OstpEvent::Start) {
|
||||||
|
|
@ -293,15 +301,11 @@ pub async fn handle_udp(
|
||||||
).await {
|
).await {
|
||||||
Ok(Ok(n)) => {
|
Ok(Ok(n)) => {
|
||||||
let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n])));
|
let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n])));
|
||||||
if let Some(m) = &metrics {
|
|
||||||
m.connection_state.store(2, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
// Per-dial timeout: do not touch global connection_state (owned by the
|
||||||
|
// health probe). Just give up on this one target connection.
|
||||||
tracing::warn!("OSTP handshake timeout for {}:{}", server, port);
|
tracing::warn!("OSTP handshake timeout for {}:{}", server, port);
|
||||||
if let Some(m) = &metrics {
|
|
||||||
m.connection_state.store(0, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,14 +194,19 @@ class OstpVpnService : VpnService() {
|
||||||
val builder = Builder()
|
val builder = Builder()
|
||||||
.setSession("OSTP Tunnel")
|
.setSession("OSTP Tunnel")
|
||||||
.addAddress("10.1.0.2", 24)
|
.addAddress("10.1.0.2", 24)
|
||||||
.addAddress("fd00:1:fd00:1:fd00:1:fd00:1", 128)
|
.addAddress("fd00::1", 128)
|
||||||
.addRoute("0.0.0.0", 0)
|
.addRoute("0.0.0.0", 0)
|
||||||
.addRoute("::", 0)
|
.addRoute("::", 0)
|
||||||
.addDnsServer(dnsServer)
|
|
||||||
.setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140))
|
.setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140))
|
||||||
|
|
||||||
|
try {
|
||||||
|
builder.addDnsServer(dnsServer)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("OstpVpnService", "Invalid user DNS server: $dnsServer", e)
|
||||||
|
try { builder.addDnsServer("1.1.1.1") } catch (e2: Throwable) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Always add fallback IPv4 DNS servers
|
// Always add fallback IPv4 DNS servers
|
||||||
try { builder.addDnsServer("1.1.1.1") } catch (e: Throwable) {}
|
|
||||||
try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {}
|
try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {}
|
||||||
// NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS
|
// NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS
|
||||||
// queries over IPv6, but our smoltcp TUN stack processes them as
|
// queries over IPv6, but our smoltcp TUN stack processes them as
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class OstpProfile {
|
||||||
|
String id;
|
||||||
|
String name;
|
||||||
|
String serverAddr;
|
||||||
|
String accessKey;
|
||||||
|
String transportMode;
|
||||||
|
String stealthSni;
|
||||||
|
bool wss;
|
||||||
|
bool active;
|
||||||
|
|
||||||
|
OstpProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.serverAddr,
|
||||||
|
required this.accessKey,
|
||||||
|
this.transportMode = 'udp',
|
||||||
|
this.stealthSni = '',
|
||||||
|
this.wss = false,
|
||||||
|
this.active = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'serverAddr': serverAddr,
|
||||||
|
'accessKey': accessKey,
|
||||||
|
'transportMode': transportMode,
|
||||||
|
'stealthSni': stealthSni,
|
||||||
|
'wss': wss,
|
||||||
|
'active': active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory OstpProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OstpProfile(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? 'Unnamed Profile',
|
||||||
|
serverAddr: json['serverAddr'] as String? ?? '',
|
||||||
|
accessKey: json['accessKey'] as String? ?? '',
|
||||||
|
transportMode: json['transportMode'] as String? ?? 'udp',
|
||||||
|
stealthSni: json['stealthSni'] as String? ?? '',
|
||||||
|
wss: json['wss'] as bool? ?? false,
|
||||||
|
active: json['active'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../models/connection_state_enum.dart';
|
import '../models/connection_state_enum.dart';
|
||||||
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'logs_screen.dart';
|
import 'logs_screen.dart';
|
||||||
import 'app_routing_screen.dart';
|
import 'app_routing_screen.dart';
|
||||||
|
|
@ -29,8 +30,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
Timer? _uptimeTimer;
|
Timer? _uptimeTimer;
|
||||||
int _uptimeSecs = 0;
|
int _uptimeSecs = 0;
|
||||||
|
|
||||||
String _serverAddr = '127.0.0.1:443';
|
List<OstpProfile> _activeProfiles = [];
|
||||||
String _accessKey = 'default_key';
|
|
||||||
|
|
||||||
String _download = '0 B';
|
String _download = '0 B';
|
||||||
String _upload = '0 B';
|
String _upload = '0 B';
|
||||||
|
|
@ -71,64 +71,134 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
void _loadSettings() {
|
void _loadSettings() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
|
final profilesJson = widget.prefs.getString('profiles_json');
|
||||||
_accessKey = widget.prefs.getString('access_key') ?? '';
|
if (profilesJson != null && profilesJson.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final List<dynamic> decoded = jsonDecode(profilesJson);
|
||||||
|
final profiles = decoded.map((e) => OstpProfile.fromJson(e)).toList();
|
||||||
|
_activeProfiles = profiles.where((p) => p.active).toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading profiles: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final oldServer = widget.prefs.getString('server_addr');
|
||||||
|
final oldKey = widget.prefs.getString('access_key');
|
||||||
|
if (oldServer != null && oldServer.isNotEmpty) {
|
||||||
|
final p = OstpProfile(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
name: 'Profile 1',
|
||||||
|
serverAddr: oldServer,
|
||||||
|
accessKey: oldKey ?? '',
|
||||||
|
transportMode: widget.prefs.getString('transport_mode') ?? 'udp',
|
||||||
|
stealthSni: widget.prefs.getString('stealth_sni') ?? '',
|
||||||
|
wss: widget.prefs.getBool('wss') ?? false,
|
||||||
|
active: true
|
||||||
|
);
|
||||||
|
_activeProfiles = [p];
|
||||||
|
widget.prefs.setString('profiles_json', jsonEncode([p.toJson()]));
|
||||||
|
} else {
|
||||||
|
_activeProfiles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
_updateLatestConfigJson();
|
_updateLatestConfigJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateLatestConfigJson() {
|
void _updateLatestConfigJson() {
|
||||||
|
|
||||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
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 wss = widget.prefs.getBool('wss') ?? false;
|
|
||||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||||
|
final tcpFrag = widget.prefs.getBool('tcp_fragmentation') ?? false;
|
||||||
final dnsServer = widget.prefs.getString('dns_server');
|
final dnsServer = widget.prefs.getString('dns_server');
|
||||||
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||||
final tunStack = 'ostp';
|
final tunStack = 'ostp';
|
||||||
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||||
|
|
||||||
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||||
final configMap = {
|
final localParts = localBind.split(':');
|
||||||
"mode": "client",
|
final localListen = localParts.isNotEmpty ? localParts[0] : '127.0.0.1';
|
||||||
"debug": debugMode,
|
final localPort = localParts.length > 1 ? (int.tryParse(localParts[1]) ?? 1088) : 1088;
|
||||||
"ostp": {
|
|
||||||
"server_addr": _serverAddr,
|
List<Map<String, dynamic>> inbounds = [
|
||||||
"local_bind_addr": "0.0.0.0:0",
|
{
|
||||||
"access_key": _accessKey,
|
"type": "local_proxy",
|
||||||
"handshake_timeout_ms": 10000,
|
"tag": "socks-in",
|
||||||
"io_timeout_ms": 5000,
|
"protocol": "socks",
|
||||||
"mtu": int.tryParse(mtu) ?? 1140,
|
"listen": localListen,
|
||||||
},
|
"port": localPort
|
||||||
"local_proxy": {
|
|
||||||
"bind_addr": localBind,
|
|
||||||
"connect_timeout_ms": 15000,
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "tun",
|
||||||
|
"tag": "tun-in",
|
||||||
|
"auto_route": true,
|
||||||
|
"mtu": int.tryParse(mtu) ?? 1140
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> outbounds = [];
|
||||||
|
List<String> ostpTags = [];
|
||||||
|
|
||||||
|
for (int i = 0; i < _activeProfiles.length; i++) {
|
||||||
|
final p = _activeProfiles[i];
|
||||||
|
final tag = _activeProfiles.length > 1 ? 'proxy-$i' : 'proxy';
|
||||||
|
ostpTags.add(tag);
|
||||||
|
|
||||||
|
final parts = p.serverAddr.split(':');
|
||||||
|
final host = parts.isNotEmpty ? parts[0] : '127.0.0.1';
|
||||||
|
final port = parts.length > 1 ? (int.tryParse(parts[1]) ?? 50000) : 50000;
|
||||||
|
|
||||||
|
outbounds.add({
|
||||||
|
"type": "ostp",
|
||||||
|
"tag": tag,
|
||||||
|
"server": host,
|
||||||
|
"port": port,
|
||||||
|
"access_key": p.accessKey,
|
||||||
"transport": {
|
"transport": {
|
||||||
"mode": transportMode,
|
"type": p.transportMode,
|
||||||
"stealth_sni": stealthSni,
|
"stealth_sni": p.stealthSni,
|
||||||
"wss": wss,
|
"wss": p.wss,
|
||||||
"tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false,
|
"tcp_fragmentation": tcpFrag
|
||||||
},
|
},
|
||||||
"multiplex": {
|
"multiplex": {
|
||||||
"enabled": muxEnabled,
|
"enabled": muxEnabled,
|
||||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
"sessions": int.tryParse(muxSessions) ?? 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeProfiles.length > 1) {
|
||||||
|
outbounds.add({
|
||||||
|
"type": "urltest",
|
||||||
|
"tag": "proxy",
|
||||||
|
"outbounds": ostpTags,
|
||||||
|
"url": "http://cp.cloudflare.com",
|
||||||
|
"interval": "3m"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
outbounds.add({"type": "direct", "tag": "direct"});
|
||||||
|
outbounds.add({"type": "block", "tag": "block"});
|
||||||
|
|
||||||
|
final configMap = {
|
||||||
|
"version": "0.3.20",
|
||||||
|
"log": {
|
||||||
|
"level": debugMode ? "debug" : "info"
|
||||||
},
|
},
|
||||||
"tun": {
|
"inbounds": inbounds,
|
||||||
"enable": true,
|
"outbounds": outbounds,
|
||||||
"stack": tunStack
|
"routing": {
|
||||||
},
|
"rules": [
|
||||||
"exclusions": {
|
{
|
||||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
"ips": exIps.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(),
|
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"outbound": "direct"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"app_rules": {
|
"app_rules": {
|
||||||
"mode": appRoutingMode,
|
"mode": appRoutingMode,
|
||||||
|
|
@ -137,6 +207,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
"dns_server": effectiveDnsServer,
|
"dns_server": effectiveDnsServer,
|
||||||
"tun_stack": tunStack
|
"tun_stack": tunStack
|
||||||
};
|
};
|
||||||
|
|
||||||
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||||
platform.invokeMethod('saveConfig', {
|
platform.invokeMethod('saveConfig', {
|
||||||
"configJson": jsonEncode(configMap)
|
"configJson": jsonEncode(configMap)
|
||||||
|
|
@ -154,9 +225,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Future<void> _toggleConnection() async {
|
Future<void> _toggleConnection() async {
|
||||||
if (_state == ConnectionStateEnum.disconnected) {
|
if (_state == ConnectionStateEnum.disconnected) {
|
||||||
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
if (_activeProfiles.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please configure Server and Key in Settings')),
|
const SnackBar(content: Text('Please select at least one profile in Settings')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -176,66 +247,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||||
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||||
final wss = widget.prefs.getBool('wss') ?? false;
|
final wss = widget.prefs.getBool('wss') ?? false;
|
||||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
_updateLatestConfigJson();
|
||||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
final configStr = widget.prefs.getString('latest_config_json') ?? '{}';
|
||||||
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,
|
|
||||||
"wss": wss,
|
|
||||||
"tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false,
|
|
||||||
},
|
|
||||||
"multiplex": {
|
|
||||||
"enabled": muxEnabled,
|
|
||||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
|
||||||
},
|
|
||||||
"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 {
|
try {
|
||||||
await platform.invokeMethod('saveConfig', {
|
|
||||||
"configJson": jsonEncode(configMap)
|
|
||||||
});
|
|
||||||
await platform.invokeMethod('startTunnel', {
|
await platform.invokeMethod('startTunnel', {
|
||||||
"configJson": jsonEncode(configMap)
|
"configJson": configStr
|
||||||
});
|
});
|
||||||
|
|
||||||
bool started = false;
|
bool started = false;
|
||||||
|
|
@ -305,9 +322,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
{'t': 'uot', 'w': false, 'r': true},
|
{'t': 'uot', 'w': false, 'r': true},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
if (_activeProfiles.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please configure Server and Key first')),
|
const SnackBar(content: Text('Please select at least one profile first')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -319,10 +336,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update prefs
|
// Update prefs and active profile
|
||||||
await widget.prefs.setString('mtu', mtu.toString());
|
await widget.prefs.setString('mtu', mtu.toString());
|
||||||
await widget.prefs.setString('transport_mode', mode['t'] as String);
|
setState(() {
|
||||||
await widget.prefs.setBool('wss', mode['w'] as bool);
|
for (var p in _activeProfiles) {
|
||||||
|
p.transportMode = mode['t'] as String;
|
||||||
|
p.wss = mode['w'] as bool;
|
||||||
|
}
|
||||||
|
});
|
||||||
_updateLatestConfigJson();
|
_updateLatestConfigJson();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -773,7 +794,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
_serverAddr,
|
_activeProfiles.isNotEmpty ? _activeProfiles.map((e)=>e.name).join(', ') : 'No profile selected',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,8 @@ import 'app_routing_screen.dart';
|
||||||
import 'logs_screen.dart';
|
import 'logs_screen.dart';
|
||||||
import 'qr_scanner_screen.dart';
|
import 'qr_scanner_screen.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import '../models/ostp_profile.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
|
|
@ -23,10 +22,7 @@ class SettingsScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late TextEditingController _importCtrl;
|
|
||||||
late TextEditingController _serverCtrl;
|
|
||||||
late TextEditingController _localBindCtrl;
|
late TextEditingController _localBindCtrl;
|
||||||
late TextEditingController _keyCtrl;
|
|
||||||
late TextEditingController _dnsCtrl;
|
late TextEditingController _dnsCtrl;
|
||||||
late TextEditingController _mtuCtrl;
|
late TextEditingController _mtuCtrl;
|
||||||
late TextEditingController _domainsCtrl;
|
late TextEditingController _domainsCtrl;
|
||||||
|
|
@ -35,60 +31,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late TextEditingController _dnsDomainCtrl;
|
late TextEditingController _dnsDomainCtrl;
|
||||||
late TextEditingController _pbkCtrl;
|
late TextEditingController _pbkCtrl;
|
||||||
late TextEditingController _sidCtrl;
|
late TextEditingController _sidCtrl;
|
||||||
|
|
||||||
bool _obscureKey = true;
|
|
||||||
bool _debugMode = false;
|
|
||||||
late TextEditingController _dnsRegionCtrl;
|
|
||||||
String _transportMode = 'udp'; // 'udp' | 'uot'
|
|
||||||
String _tunStack = 'ostp'; // 'system' | 'ostp'
|
|
||||||
bool _muxEnabled = false;
|
|
||||||
late TextEditingController _muxSessionsCtrl;
|
late TextEditingController _muxSessionsCtrl;
|
||||||
bool _isCheckingUpdates = false;
|
|
||||||
|
|
||||||
|
bool _debugMode = false;
|
||||||
|
String _tunStack = 'ostp';
|
||||||
|
bool _muxEnabled = false;
|
||||||
bool _tcpFragmentation = false;
|
bool _tcpFragmentation = false;
|
||||||
|
|
||||||
|
List<OstpProfile> _profiles = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_importCtrl = TextEditingController();
|
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadSettings() {
|
void _loadSettings() {
|
||||||
_serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '');
|
|
||||||
_localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088');
|
_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') ?? '');
|
_dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '');
|
||||||
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
|
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
|
||||||
_transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
|
||||||
_tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false;
|
_tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false;
|
||||||
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
|
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
|
||||||
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
|
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
|
||||||
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
|
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
|
||||||
_dnsDomainCtrl = TextEditingController(text: widget.prefs.getString('dns_domain') ?? '');
|
_dnsDomainCtrl = TextEditingController(text: widget.prefs.getString('dns_domain') ?? '');
|
||||||
_dnsRegionCtrl = TextEditingController(text: widget.prefs.getString('dns_region') ?? '1.1.1.1');
|
|
||||||
_pbkCtrl = TextEditingController(text: widget.prefs.getString('tun_pbk') ?? '');
|
_pbkCtrl = TextEditingController(text: widget.prefs.getString('tun_pbk') ?? '');
|
||||||
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
|
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
|
||||||
_tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
|
_tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
|
||||||
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
|
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
|
||||||
|
|
||||||
|
final profilesJson = widget.prefs.getString('profiles_json');
|
||||||
|
if (profilesJson != null && profilesJson.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final List<dynamic> decoded = jsonDecode(profilesJson);
|
||||||
|
_profiles = decoded.map((e) => OstpProfile.fromJson(e)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading profiles: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
_importCtrl.dispose();
|
|
||||||
_serverCtrl.dispose();
|
|
||||||
_localBindCtrl.dispose();
|
_localBindCtrl.dispose();
|
||||||
_keyCtrl.dispose();
|
|
||||||
_dnsCtrl.dispose();
|
_dnsCtrl.dispose();
|
||||||
_mtuCtrl.dispose();
|
_mtuCtrl.dispose();
|
||||||
_domainsCtrl.dispose();
|
_domainsCtrl.dispose();
|
||||||
_ipsCtrl.dispose();
|
_ipsCtrl.dispose();
|
||||||
_processesCtrl.dispose();
|
_processesCtrl.dispose();
|
||||||
_dnsDomainCtrl.dispose();
|
_dnsDomainCtrl.dispose();
|
||||||
_dnsRegionCtrl.dispose();
|
|
||||||
_pbkCtrl.dispose();
|
_pbkCtrl.dispose();
|
||||||
_sidCtrl.dispose();
|
_sidCtrl.dispose();
|
||||||
_muxSessionsCtrl.dispose();
|
_muxSessionsCtrl.dispose();
|
||||||
|
|
@ -96,12 +90,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _saveSettings() {
|
void _saveSettings() {
|
||||||
widget.prefs.setString('server_addr', _serverCtrl.text.trim());
|
|
||||||
widget.prefs.setString('local_bind', _localBindCtrl.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('dns_server', _dnsCtrl.text.trim());
|
||||||
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
|
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
|
||||||
widget.prefs.setString('transport_mode', _transportMode);
|
|
||||||
widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation);
|
widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation);
|
||||||
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
|
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
|
||||||
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
|
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
|
||||||
|
|
@ -109,23 +100,210 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
widget.prefs.setBool('debug_mode', _debugMode);
|
widget.prefs.setBool('debug_mode', _debugMode);
|
||||||
widget.prefs.setString('tun_stack', _tunStack);
|
widget.prefs.setString('tun_stack', _tunStack);
|
||||||
widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim());
|
widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim());
|
||||||
widget.prefs.setString('dns_region', _dnsRegionCtrl.text.trim());
|
|
||||||
widget.prefs.setString('tun_pbk', _pbkCtrl.text.trim());
|
widget.prefs.setString('tun_pbk', _pbkCtrl.text.trim());
|
||||||
widget.prefs.setString('sid', _sidCtrl.text.trim());
|
widget.prefs.setString('sid', _sidCtrl.text.trim());
|
||||||
widget.prefs.setBool('mux_enabled', _muxEnabled);
|
widget.prefs.setBool('mux_enabled', _muxEnabled);
|
||||||
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
|
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
|
||||||
|
|
||||||
|
final profilesJson = jsonEncode(_profiles.map((e) => e.toJson()).toList());
|
||||||
|
widget.prefs.setString('profiles_json', profilesJson);
|
||||||
}
|
}
|
||||||
Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) {
|
|
||||||
return Column(
|
void _importFromLink(String link) {
|
||||||
|
if (link.isEmpty) return;
|
||||||
|
try {
|
||||||
|
if (!link.startsWith('ostp://')) {
|
||||||
|
throw Exception('Link must start with ostp://');
|
||||||
|
}
|
||||||
|
final uri = Uri.parse(link);
|
||||||
|
final key = Uri.decodeComponent(uri.userInfo);
|
||||||
|
final host = uri.authority.replaceFirst(uri.userInfo + '@', '');
|
||||||
|
if (key.isEmpty || host.isEmpty) {
|
||||||
|
throw Exception('Incomplete link parameters');
|
||||||
|
}
|
||||||
|
final type = uri.queryParameters['type'];
|
||||||
|
final transportMode = type == 'tcp' || type == 'http' ? 'uot' : (type == 'dns' ? 'dns' : 'udp');
|
||||||
|
final name = uri.queryParameters['name'] ?? host;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_profiles.add(OstpProfile(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
name: name,
|
||||||
|
serverAddr: host,
|
||||||
|
accessKey: key,
|
||||||
|
transportMode: transportMode,
|
||||||
|
));
|
||||||
|
_saveSettings();
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully')));
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddProfileMenu() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_scanner, color: Colors.white),
|
||||||
|
title: const Text('Import from QR code'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final result = await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const QRScannerScreen()),
|
||||||
|
);
|
||||||
|
if (result != null && result is String && result.startsWith('ostp://')) {
|
||||||
|
_importFromLink(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.link, color: Colors.white),
|
||||||
|
title: const Text('Import from link'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showImportLinkDialog();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit, color: Colors.white),
|
||||||
|
title: const Text('Insert manually'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showEditProfileDialog(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImportLinkDialog() {
|
||||||
|
final TextEditingController linkCtrl = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Import Link'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
content: TextField(
|
||||||
|
controller: linkCtrl,
|
||||||
|
decoration: const InputDecoration(hintText: 'ostp://...'),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_importFromLink(linkCtrl.text.trim());
|
||||||
|
},
|
||||||
|
child: const Text('Import')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditProfileDialog(OstpProfile? profile) {
|
||||||
|
final isNew = profile == null;
|
||||||
|
final nameCtrl = TextEditingController(text: profile?.name ?? '');
|
||||||
|
final serverCtrl = TextEditingController(text: profile?.serverAddr ?? '');
|
||||||
|
final keyCtrl = TextEditingController(text: profile?.accessKey ?? '');
|
||||||
|
String transportMode = profile?.transportMode ?? 'udp';
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(isNew ? 'New Profile' : 'Edit Profile'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name')),
|
||||||
|
TextField(controller: serverCtrl, decoration: const InputDecoration(labelText: 'Server Address (host:port)')),
|
||||||
|
TextField(controller: keyCtrl, decoration: const InputDecoration(labelText: 'Access Key')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: transportMode,
|
||||||
|
decoration: const InputDecoration(labelText: 'Transport'),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'udp', child: Text('UDP')),
|
||||||
|
DropdownMenuItem(value: 'uot', child: Text('TCP (UoT)')),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) setDialogState(() => transportMode = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!isNew)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_profiles.removeWhere((p) => p.id == profile.id);
|
||||||
|
_saveSettings();
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (isNew) {
|
||||||
|
_profiles.add(OstpProfile(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
name: nameCtrl.text.trim(),
|
||||||
|
serverAddr: serverCtrl.text.trim(),
|
||||||
|
accessKey: keyCtrl.text.trim(),
|
||||||
|
transportMode: transportMode,
|
||||||
|
active: true,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
profile.name = nameCtrl.text.trim();
|
||||||
|
profile.serverAddr = serverCtrl.text.trim();
|
||||||
|
profile.accessKey = keyCtrl.text.trim();
|
||||||
|
profile.transportMode = transportMode;
|
||||||
|
}
|
||||||
|
_saveSettings();
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Save')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(String label, TextEditingController controller, {String? hint, int maxLines = 1}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
obscureText: isPassword && _obscureKey,
|
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'),
|
style: const TextStyle(fontSize: 16),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
hintStyle: const TextStyle(color: Colors.white30),
|
hintStyle: const TextStyle(color: Colors.white30),
|
||||||
|
|
@ -133,14 +311,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
fillColor: Theme.of(context).colorScheme.surface,
|
fillColor: Theme.of(context).colorScheme.surface,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
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),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,12 +337,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
Switch(
|
Switch(
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
onChanged(v);
|
setState(() => onChanged(v));
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
},
|
},
|
||||||
activeColor: Theme.of(context).colorScheme.secondary,
|
activeColor: Theme.of(context).colorScheme.secondary,
|
||||||
activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3),
|
|
||||||
inactiveTrackColor: Colors.white10,
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -188,89 +360,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.share_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
tooltip: 'Share Config',
|
tooltip: 'Add Profile',
|
||||||
onPressed: _showShareModal,
|
onPressed: _showAddProfileMenu,
|
||||||
),
|
),
|
||||||
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(
|
body: ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
children: [
|
children: [
|
||||||
// Quick Import Row
|
const Text('PROFILES', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
||||||
Row(
|
const SizedBox(height: 16),
|
||||||
children: [
|
if (_profiles.isEmpty)
|
||||||
Expanded(
|
Center(
|
||||||
child: TextField(
|
child: Padding(
|
||||||
controller: _importCtrl,
|
padding: const EdgeInsets.all(32.0),
|
||||||
decoration: InputDecoration(
|
child: Text('Create a new profile', style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 18)),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
),
|
else
|
||||||
const SizedBox(width: 12),
|
..._profiles.map((p) => Card(
|
||||||
ElevatedButton(
|
color: Theme.of(context).colorScheme.surface,
|
||||||
onPressed: () {
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
final raw = _importCtrl.text.trim();
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
if (raw.isEmpty) return;
|
child: ListTile(
|
||||||
try {
|
leading: Checkbox(
|
||||||
if (!raw.startsWith('ostp://')) {
|
value: p.active,
|
||||||
throw Exception('Link must start with ostp://');
|
onChanged: (val) {
|
||||||
}
|
|
||||||
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(() {
|
setState(() {
|
||||||
_serverCtrl.text = host;
|
p.active = val ?? false;
|
||||||
_keyCtrl.text = key;
|
|
||||||
_dnsDomainCtrl.text = uri.queryParameters['domain'] ?? '';
|
|
||||||
_dnsRegionCtrl.text = uri.queryParameters['resolver'] ?? '1.1.1.1';
|
|
||||||
|
|
||||||
final type = uri.queryParameters['type'];
|
|
||||||
_transportMode = type == 'tcp' || type == 'http' ? 'uot' : (type == 'dns' ? 'dns' : 'udp');
|
|
||||||
_importCtrl.clear();
|
|
||||||
|
|
||||||
_saveSettings();
|
_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: Colors.white,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
),
|
),
|
||||||
child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black)),
|
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
)
|
subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)),
|
||||||
],
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.edit, size: 20, color: Colors.white54),
|
||||||
|
onPressed: () => _showEditProfileDialog(p),
|
||||||
),
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
p.active = !p.active;
|
||||||
|
_saveSettings();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)).toList(),
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 32),
|
||||||
|
const Text('CLIENT SETTINGS', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|
@ -282,144 +422,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildTextField('Server Address', _serverCtrl, hint: 'host:port'),
|
_buildToggle('MUX (Multiplexing)', 'Multiple sessions over single connection', _muxEnabled, (v) => _muxEnabled = v),
|
||||||
|
if (_muxEnabled)
|
||||||
|
_buildTextField('MUX Sessions', _muxSessionsCtrl, hint: 'e.g. 2, 4, 8'),
|
||||||
|
|
||||||
|
_buildToggle('TCP Fragmentation', 'Break TLS Hello into small pieces', _tcpFragmentation, (v) => _tcpFragmentation = v),
|
||||||
|
_buildToggle('Debug Mode', 'Verbose logging', _debugMode, (v) => _debugMode = v),
|
||||||
|
|
||||||
_buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'),
|
_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('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)'),
|
_buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'),
|
||||||
|
|
||||||
// ── Transport Mode ───────────────────────────────────────
|
const SizedBox(height: 24),
|
||||||
const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
SizedBox(
|
||||||
const SizedBox(height: 10),
|
width: double.infinity,
|
||||||
Container(
|
child: ElevatedButton.icon(
|
||||||
decoration: BoxDecoration(
|
icon: const Icon(Icons.route),
|
||||||
color: Theme.of(context).colorScheme.surface,
|
label: const Text('Configure Split Tunneling'),
|
||||||
borderRadius: BorderRadius.circular(12),
|
onPressed: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<String>(
|
|
||||||
value: 'udp',
|
|
||||||
groupValue: _transportMode,
|
|
||||||
title: const Text('UDP (Default)', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
subtitle: const Text('Fast, works on Wi-Fi and most networks', 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: const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
subtitle: const Text('Reliable on strict networks. Enables TCP DPI bypass.', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
|
||||||
activeColor: Theme.of(context).colorScheme.primary,
|
|
||||||
onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
|
|
||||||
),
|
|
||||||
if (_transportMode == 'uot')
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16.0, right: 8.0, bottom: 8.0),
|
|
||||||
child: SwitchListTile(
|
|
||||||
title: const Text('TCP Fragmentation', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
|
||||||
subtitle: const Text('Bypass DPI by chunking handshake (Zapret style)', style: TextStyle(fontSize: 12, color: Colors.white54)),
|
|
||||||
value: _tcpFragmentation,
|
|
||||||
activeColor: Theme.of(context).colorScheme.primary,
|
|
||||||
onChanged: (v) => setState(() { _tcpFragmentation = v; _saveSettings(); }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Divider(color: Colors.white.withOpacity(0.05), height: 1),
|
|
||||||
RadioListTile<String>(
|
|
||||||
value: 'dns',
|
|
||||||
groupValue: _transportMode,
|
|
||||||
title: const Text('DNS Tunnel', style: TextStyle(fontWeight: FontWeight.w600)),
|
|
||||||
subtitle: const Text('Very slow, but works under strict DPI blocks', style: TextStyle(color: Colors.orangeAccent, fontSize: 12)),
|
|
||||||
activeColor: Colors.orangeAccent,
|
|
||||||
onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// DNS Proxy parameters
|
|
||||||
AnimatedCrossFade(
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
crossFadeState: _transportMode == 'dns' ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
|
||||||
firstChild: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.orangeAccent.withOpacity(0.06),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: Colors.orangeAccent.withOpacity(0.2)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.dns, size: 16, color: Colors.orangeAccent),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('DNS Tunnel Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orangeAccent, fontSize: 14)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
const Text(
|
|
||||||
'Specify the domain pointing to your server. Details in Wiki.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.white38),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildTextField('Domain (Points to Server)', _dnsDomainCtrl, hint: 'tunnel.myvpn.com'),
|
SizedBox(
|
||||||
const SizedBox(height: 16),
|
width: double.infinity,
|
||||||
Row(
|
child: ElevatedButton.icon(
|
||||||
children: [
|
icon: const Icon(Icons.article),
|
||||||
Expanded(
|
label: const Text('View Logs'),
|
||||||
child: _buildTextField('DNS Resolver Server', _dnsRegionCtrl, hint: '1.1.1.1'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 24.0),
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: _showDnsProberDialog,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.orangeAccent.withOpacity(0.2),
|
|
||||||
foregroundColor: Colors.orangeAccent,
|
|
||||||
elevation: 0,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
child: const Text('PROBER', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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: () {
|
onPressed: () {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
|
Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
|
||||||
},
|
},
|
||||||
|
|
@ -427,344 +457,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
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: 16),
|
|
||||||
|
|
||||||
InkWell(
|
|
||||||
onTap: _isCheckingUpdates ? null : _checkForUpdates,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.02),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.05)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.system_update_rounded, color: Colors.white70, size: 24),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Check for Updates',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
|
||||||
),
|
|
||||||
SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_isCheckingUpdates ? 'Checking...' : 'Check latest release on GitHub',
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.white54),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isCheckingUpdates)
|
|
||||||
const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _generateShareUrl() {
|
|
||||||
final host = _serverCtrl.text.trim();
|
|
||||||
final key = Uri.encodeComponent(_keyCtrl.text.trim());
|
|
||||||
if (host.isEmpty || key.isEmpty) return '';
|
|
||||||
|
|
||||||
final queryParams = <String>[];
|
|
||||||
if (_dnsDomainCtrl.text.trim().isNotEmpty) {
|
|
||||||
queryParams.add('domain=${Uri.encodeComponent(_dnsDomainCtrl.text.trim())}');
|
|
||||||
}
|
|
||||||
final resolver = _dnsRegionCtrl.text.trim();
|
|
||||||
if (resolver.isNotEmpty && resolver != '1.1.1.1') {
|
|
||||||
queryParams.add('resolver=${Uri.encodeComponent(resolver)}');
|
|
||||||
}
|
|
||||||
if (_pbkCtrl.text.trim().isNotEmpty) {
|
|
||||||
queryParams.add('pbk=${Uri.encodeComponent(_pbkCtrl.text.trim())}');
|
|
||||||
}
|
|
||||||
if (_sidCtrl.text.trim().isNotEmpty) {
|
|
||||||
queryParams.add('sid=${Uri.encodeComponent(_sidCtrl.text.trim())}');
|
|
||||||
}
|
|
||||||
if (_transportMode != 'udp') {
|
|
||||||
queryParams.add('type=$_transportMode');
|
|
||||||
}
|
|
||||||
|
|
||||||
final queryString = queryParams.isEmpty ? '' : '?${queryParams.join('&')}';
|
|
||||||
return 'ostp://$key@$host$queryString';
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showShareModal() {
|
|
||||||
final url = _generateShareUrl();
|
|
||||||
if (url.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Server Address and Access Key are required to share.')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return AlertDialog(
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
title: const Text('Share Config', textAlign: TextAlign.center),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: QrImageView(
|
|
||||||
data: url,
|
|
||||||
version: QrVersions.auto,
|
|
||||||
size: 200.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: url));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.copy_rounded, color: Colors.black),
|
|
||||||
label: const Text('Copy Link', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Close'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showDnsProberDialog() async {
|
|
||||||
const channel = MethodChannel('com.ospab.ostp/vpn');
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) {
|
|
||||||
return StatefulBuilder(
|
|
||||||
builder: (context, setModalState) {
|
|
||||||
return AlertDialog(
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
title: const Text('DNS Prober', textAlign: TextAlign.center),
|
|
||||||
content: FutureBuilder<String?>(
|
|
||||||
future: channel.invokeMethod<String>('runDnsProber', {'domain': _dnsDomainCtrl.text.trim()}),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text('Sending real tunnel probes...', style: TextStyle(color: Colors.white54, fontSize: 13), textAlign: TextAlign.center),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError || !snapshot.hasData) {
|
|
||||||
return Text('Error: ${snapshot.error}', style: const TextStyle(color: Colors.redAccent));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<dynamic> results = [];
|
|
||||||
try {
|
|
||||||
results = jsonDecode(snapshot.data!);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
if (results.isEmpty) {
|
|
||||||
return const Text('No results or all timed out.', style: TextStyle(color: Colors.redAccent));
|
|
||||||
}
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: results.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final res = results[index];
|
|
||||||
final name = res['name'] ?? '';
|
|
||||||
final ip = res['ip'] ?? '';
|
|
||||||
final latency = res['latency_ms'];
|
|
||||||
|
|
||||||
final isBest = index == 0 && latency != null;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
onTap: latency != null ? () {
|
|
||||||
setState(() {
|
|
||||||
_dnsRegionCtrl.text = ip;
|
|
||||||
_saveSettings();
|
|
||||||
});
|
|
||||||
Navigator.pop(context);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('DNS set to $ip')));
|
|
||||||
} : null,
|
|
||||||
title: Text('${isBest ? '⭐ ' : ''}$name', style: const TextStyle(fontSize: 14)),
|
|
||||||
subtitle: Text(ip, style: const TextStyle(fontSize: 12, color: Colors.white54)),
|
|
||||||
trailing: Text(
|
|
||||||
latency != null ? '$latency ms' : 'TIMEOUT',
|
|
||||||
style: TextStyle(
|
|
||||||
color: latency == null ? Colors.redAccent : (latency < 100 ? Colors.greenAccent : Colors.orangeAccent),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
tileColor: isBest ? Colors.blueAccent.withOpacity(0.1) : null,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Close'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkForUpdates() async {
|
|
||||||
if (_isCheckingUpdates) return;
|
|
||||||
setState(() { _isCheckingUpdates = true; });
|
|
||||||
try {
|
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
|
||||||
final currentVersion = packageInfo.version;
|
|
||||||
|
|
||||||
final response = await http.get(Uri.parse('https://api.github.com/repos/ospab/ostp/releases/latest'));
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final data = json.decode(response.body);
|
|
||||||
final latestVersion = (data['tag_name'] as String).replaceAll('v', '');
|
|
||||||
|
|
||||||
final hasUpdate = latestVersion != currentVersion;
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return AlertDialog(
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
||||||
title: Text(hasUpdate ? 'Update Available!' : 'Up to Date'),
|
|
||||||
content: Text(hasUpdate
|
|
||||||
? 'A new version ($latestVersion) is available on GitHub. You are currently running version $currentVersion.'
|
|
||||||
: 'You are running the latest version ($currentVersion).'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
if (hasUpdate)
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
final url = Uri.parse(data['html_url'] ?? 'https://github.com/ospab/ostp/releases/latest');
|
|
||||||
launchUrl(url, mode: LaunchMode.externalApplication);
|
|
||||||
},
|
|
||||||
child: const Text('Download'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw Exception('HTTP ${response.statusCode}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error checking updates: $e')));
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() { _isCheckingUpdates = false; });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.3.18+30
|
version: 0.3.21+33
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.3.17"
|
version = "0.3.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2700,7 +2700,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.3.17"
|
version = "0.3.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
|
@ -2742,7 +2742,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.3.17"
|
version = "0.3.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -420,14 +420,26 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
|
||||||
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
|
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
|
||||||
let mut guard = state.0.lock().await;
|
let mut guard = state.0.lock().await;
|
||||||
|
|
||||||
if let Some(ref t) = guard.tunnel {
|
// If a tunnel is already running (a UI/backend desync left a stale handle, or
|
||||||
match t {
|
// the user is switching servers), tear it down before starting a fresh one —
|
||||||
TunnelHandle::InProcess(s) if !s.handle.is_finished() => return Ok(true),
|
// otherwise we'd silently keep the old connection/server. start_tunnel is only
|
||||||
TunnelHandle::Helper(_) => return Ok(true),
|
// ever invoked on an explicit connect (the UI calls it only while it believes
|
||||||
_ => {}
|
// it is disconnected), so restarting here is safe.
|
||||||
|
match guard.tunnel.take() {
|
||||||
|
Some(TunnelHandle::Helper(h)) => {
|
||||||
|
let stop_cmd = serde_json::json!({ "cmd": "stop", "token": h.token }).to_string();
|
||||||
|
let _ = h.cmd_tx.send(format!("{}\n", stop_cmd)).await;
|
||||||
|
// Let the elevated helper stop the tunnel and release the TUN adapter
|
||||||
|
// before a new helper tries to create it (avoids ostp_tun name clashes).
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
|
||||||
}
|
}
|
||||||
|
Some(TunnelHandle::InProcess(mut s)) => {
|
||||||
|
if let Some(tx) = s.shutdown_tx.take() { let _ = tx.send(true); }
|
||||||
|
s.handle.abort();
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), s.handle).await;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
guard.tunnel = None;
|
|
||||||
|
|
||||||
let path = get_config_path();
|
let path = get_config_path();
|
||||||
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ostp-gui",
|
"productName": "ostp-gui",
|
||||||
"version": "0.3.18",
|
"version": "0.3.21",
|
||||||
"identifier": "com.ospab.ostp",
|
"identifier": "com.ospab.ostp",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|
|
||||||
|
|
@ -156,23 +156,22 @@
|
||||||
<!-- Form card -->
|
<!-- Form card -->
|
||||||
<div class="card scrollable">
|
<div class="card scrollable">
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label class="field-label" for="in-server" data-i18n="label_server">Server Address</label>
|
|
||||||
<input id="in-server" class="field-input" type="text" placeholder="host:port" spellcheck="false" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
<!-- Profiles Section -->
|
||||||
<label class="field-label" for="in-key" data-i18n="label_key">Access Key</label>
|
<div class="profiles-section">
|
||||||
<div class="input-wrap">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px;">
|
||||||
<input id="in-key" class="field-input has-icon" type="password" data-i18n-placeholder="ph_key" placeholder="Secure access key" spellcheck="false" />
|
<span class="field-label">PROFILES</span>
|
||||||
<button class="peek-btn" id="btn-peek-key" tabindex="-1" aria-label="Show key">
|
<button id="btn-add-profile" class="icon-btn" style="width:24px; height:24px;">+</button>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
</div>
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
<div id="profiles-list" class="profiles-list">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<!-- Profiles will be injected here -->
|
||||||
</svg>
|
</div>
|
||||||
</button>
|
<div id="profiles-empty" class="profiles-empty" style="display:none; text-align:center; padding: 20px; color: var(--c-text-muted);">
|
||||||
|
Create a new profile
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr style="border:0; border-top: 1px solid var(--c-surface); margin: 24px 0;" />
|
||||||
|
<div class="field-label" style="margin-bottom: 16px;">CLIENT SETTINGS</div>
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label>
|
<label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label>
|
||||||
|
|
@ -184,14 +183,8 @@
|
||||||
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
|
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group">
|
<!-- Transport moved to profile modal -->
|
||||||
<label class="field-label" for="in-transport" data-i18n="label_transport">Transport Protocol</label>
|
|
||||||
<select id="in-transport" class="field-input">
|
|
||||||
<option value="udp" data-i18n="opt_udp">UDP (Default)</option>
|
|
||||||
<option value="uot" data-i18n="opt_uot">TCP (UoT)</option>
|
|
||||||
<option value="dns" data-i18n="opt_dns">DNS Proxy (Last Resort)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="group-dns-proxy" style="display: none; flex-direction: column; gap: 14px;">
|
<div id="group-dns-proxy" style="display: none; flex-direction: column; gap: 14px;">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
|
|
@ -387,5 +380,39 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js"></script>
|
<script type="module" src="main.js"></script>
|
||||||
|
|
||||||
|
<!-- Profile Modal -->
|
||||||
|
<div id="profile-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 id="profile-modal-title">Edit Profile</h3>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input id="in-prof-name" class="field-input" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Server (host:port)</label>
|
||||||
|
<input id="in-prof-server" class="field-input" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Access Key</label>
|
||||||
|
<input id="in-prof-key" class="field-input" type="password" />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Transport</label>
|
||||||
|
<select id="in-prof-transport" class="field-input">
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
<option value="uot">TCP (UoT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-top: 20px;">
|
||||||
|
<button id="btn-prof-delete" class="danger-btn">Delete</button>
|
||||||
|
<div>
|
||||||
|
<button id="btn-prof-cancel" class="cancel-btn">Cancel</button>
|
||||||
|
<button id="btn-prof-save" class="accent-btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ let appState = 'disconnected'; // 'disconnected' | 'connecting' | 'connected'
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let uptimeTimer = null;
|
let uptimeTimer = null;
|
||||||
let uptimeSecs = 0;
|
let uptimeSecs = 0;
|
||||||
let rawConfig = null; // parsed config.json object
|
let rawConfig = null;
|
||||||
|
let profiles = []; // parsed config.json object
|
||||||
let serverAddr = ''; // current server address (for badge)
|
let serverAddr = ''; // current server address (for badge)
|
||||||
|
|
||||||
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -61,6 +62,20 @@ const toast = $('toast');
|
||||||
|
|
||||||
const btnGoSettings = $('btn-go-settings');
|
const btnGoSettings = $('btn-go-settings');
|
||||||
const btnAutoConnect = $('btn-auto-connect');
|
const btnAutoConnect = $('btn-auto-connect');
|
||||||
|
|
||||||
|
const btnAddProfile = $('btn-add-profile');
|
||||||
|
const profilesList = $('profiles-list');
|
||||||
|
const profilesEmpty = $('profiles-empty');
|
||||||
|
const profileModal = $('profile-modal');
|
||||||
|
const inProfName = $('in-prof-name');
|
||||||
|
const inProfServer = $('in-prof-server');
|
||||||
|
const inProfKey = $('in-prof-key');
|
||||||
|
const inProfTransport = $('in-prof-transport');
|
||||||
|
const btnProfDelete = $('btn-prof-delete');
|
||||||
|
const btnProfCancel = $('btn-prof-cancel');
|
||||||
|
const btnProfSave = $('btn-prof-save');
|
||||||
|
|
||||||
|
let editingProfileId = null;
|
||||||
const btnBack = $('btn-back');
|
const btnBack = $('btn-back');
|
||||||
const btnImport = $('btn-import-url');
|
const btnImport = $('btn-import-url');
|
||||||
const btnPeekKey = $('btn-peek-key');
|
const btnPeekKey = $('btn-peek-key');
|
||||||
|
|
@ -86,8 +101,8 @@ const inLaunchStartup = $('in-launch-startup');
|
||||||
|
|
||||||
function bindSettingsInputs() {
|
function bindSettingsInputs() {
|
||||||
const ids = [
|
const ids = [
|
||||||
'in-server', 'in-key', 'in-socks', 'in-dns',
|
'in-socks', 'in-dns',
|
||||||
'in-transport', 'in-dns-domain', 'in-dns-region',
|
'in-dns-domain', 'in-dns-region',
|
||||||
'in-mtu', 'in-mux-sessions',
|
'in-mtu', 'in-mux-sessions',
|
||||||
'in-tun-mode', 'in-kill-switch', 'in-mux-mode',
|
'in-tun-mode', 'in-kill-switch', 'in-mux-mode',
|
||||||
'in-debug', 'in-autoconnect', 'in-launch-startup'
|
'in-debug', 'in-autoconnect', 'in-launch-startup'
|
||||||
|
|
@ -911,3 +926,90 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function renderProfiles() {
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
profilesList.innerHTML = '';
|
||||||
|
profilesEmpty.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
profilesEmpty.style.display = 'none';
|
||||||
|
profilesList.innerHTML = profiles.map(p => `
|
||||||
|
<div class="profile-item">
|
||||||
|
<input type="checkbox" ${p.active ? 'checked' : ''} onchange="toggleProfile('${p.id}')">
|
||||||
|
<div class="profile-info">
|
||||||
|
<div class="profile-name">${p.name}</div>
|
||||||
|
<div class="profile-addr">${p.serverAddr}</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" onclick="editProfile('${p.id}')" style="width:24px;height:24px;">✎</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleProfile = function(id) {
|
||||||
|
const p = profiles.find(x => x.id === id);
|
||||||
|
if (p) { p.active = !p.active; saveSettings(); renderProfiles(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.editProfile = function(id) {
|
||||||
|
editingProfileId = id;
|
||||||
|
const p = profiles.find(x => x.id === id);
|
||||||
|
$('profile-modal-title').innerText = 'Edit Profile';
|
||||||
|
inProfName.value = p.name;
|
||||||
|
inProfServer.value = p.serverAddr;
|
||||||
|
inProfKey.value = p.accessKey;
|
||||||
|
inProfTransport.value = p.transportMode || 'udp';
|
||||||
|
btnProfDelete.style.display = 'block';
|
||||||
|
profileModal.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (btnAddProfile) {
|
||||||
|
btnAddProfile.addEventListener('click', () => {
|
||||||
|
editingProfileId = null;
|
||||||
|
$('profile-modal-title').innerText = 'New Profile';
|
||||||
|
inProfName.value = '';
|
||||||
|
inProfServer.value = '';
|
||||||
|
inProfKey.value = '';
|
||||||
|
inProfTransport.value = 'udp';
|
||||||
|
btnProfDelete.style.display = 'none';
|
||||||
|
profileModal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnProfCancel) btnProfCancel.addEventListener('click', () => profileModal.classList.add('hidden'));
|
||||||
|
|
||||||
|
if (btnProfDelete) {
|
||||||
|
btnProfDelete.addEventListener('click', () => {
|
||||||
|
profiles = profiles.filter(x => x.id !== editingProfileId);
|
||||||
|
profileModal.classList.add('hidden');
|
||||||
|
saveSettings();
|
||||||
|
renderProfiles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnProfSave) {
|
||||||
|
btnProfSave.addEventListener('click', () => {
|
||||||
|
if (editingProfileId) {
|
||||||
|
const p = profiles.find(x => x.id === editingProfileId);
|
||||||
|
if (p) {
|
||||||
|
p.name = inProfName.value.trim();
|
||||||
|
p.serverAddr = inProfServer.value.trim();
|
||||||
|
p.accessKey = inProfKey.value;
|
||||||
|
p.transportMode = inProfTransport.value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
profiles.push({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: inProfName.value.trim() || 'New Profile',
|
||||||
|
serverAddr: inProfServer.value.trim(),
|
||||||
|
accessKey: inProfKey.value,
|
||||||
|
transportMode: inProfTransport.value,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
profileModal.classList.add('hidden');
|
||||||
|
saveSettings();
|
||||||
|
renderProfiles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1218,3 +1218,14 @@ html[data-theme="light"] .proc-item:hover { background: rgba(0,0,0,0.05); }
|
||||||
border-top: 1px solid var(--c-card-border);
|
border-top: 1px solid var(--c-card-border);
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profiles-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.profile-item { display: flex; align-items: center; background: var(--c-surface); padding: 12px; border-radius: 12px; }
|
||||||
|
.profile-info { flex-grow: 1; margin-left: 12px; }
|
||||||
|
.profile-name { font-weight: bold; font-size: 14px; }
|
||||||
|
.profile-addr { font-size: 11px; color: var(--c-text-muted); }
|
||||||
|
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
|
.modal.hidden { display: none; }
|
||||||
|
.modal-content { background: var(--c-bg); padding: 24px; border-radius: 16px; width: 320px; max-width: 90%; }
|
||||||
|
.danger-btn { background: rgba(255,50,50,0.2); color: #ff5555; padding: 8px 16px; border-radius: 8px; font-weight: bold; }
|
||||||
|
.cancel-btn { background: transparent; color: var(--c-text); padding: 8px 16px; margin-right: 8px; }
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ pub(crate) struct RemoteState {
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub type ConnectRequest = (
|
||||||
|
u32,
|
||||||
|
u16,
|
||||||
|
String,
|
||||||
|
Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>,
|
||||||
|
);
|
||||||
|
|
||||||
pub async fn run_server(
|
pub async fn run_server(
|
||||||
bind_addrs: Vec<String>,
|
bind_addrs: Vec<String>,
|
||||||
server_public_ip: Option<String>,
|
server_public_ip: Option<String>,
|
||||||
|
|
@ -343,6 +350,13 @@ pub async fn run_server(
|
||||||
|
|
||||||
// Headless event logger
|
// Headless event logger
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Rate-limit unauthorized-probe logging: a single client dial sends
|
||||||
|
// several Amnezia-style junk packets, and a real DPI sweep can send
|
||||||
|
// far more. Log the first probe of each ~30s window immediately, then
|
||||||
|
// suppress the rest and emit a count — so the log stays readable and
|
||||||
|
// a genuine probe is never fully hidden.
|
||||||
|
let mut probe_window_start: Option<std::time::Instant> = None;
|
||||||
|
let mut probe_suppressed: u64 = 0;
|
||||||
while let Some(ev) = ui_event_rx.recv().await {
|
while let Some(ev) = ui_event_rx.recv().await {
|
||||||
match ev {
|
match ev {
|
||||||
UiEvent::Log(msg) => {
|
UiEvent::Log(msg) => {
|
||||||
|
|
@ -360,8 +374,23 @@ pub async fn run_server(
|
||||||
tracing::info!("Access key created: {key}");
|
tracing::info!("Access key created: {key}");
|
||||||
}
|
}
|
||||||
UiEvent::UnauthorizedProbe { peer, bytes, reason } => {
|
UiEvent::UnauthorizedProbe { peer, bytes, reason } => {
|
||||||
// Make it a warn so it's always visible outside debug mode!
|
let now = std::time::Instant::now();
|
||||||
tracing::warn!("Unauthorized probe from {peer} ({bytes} bytes): {reason}");
|
let elapsed = probe_window_start
|
||||||
|
.map(|s| now.duration_since(s))
|
||||||
|
.unwrap_or(std::time::Duration::MAX);
|
||||||
|
if elapsed >= std::time::Duration::from_secs(30) {
|
||||||
|
if probe_suppressed > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
"(+{} more unauthorized probes suppressed in the previous ~30s)",
|
||||||
|
probe_suppressed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
probe_window_start = Some(now);
|
||||||
|
probe_suppressed = 0;
|
||||||
|
tracing::debug!("Unauthorized probe from {peer} ({bytes} bytes): {reason}");
|
||||||
|
} else {
|
||||||
|
probe_suppressed += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UiEvent::PeerSeen { .. } => {}
|
UiEvent::PeerSeen { .. } => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -402,7 +431,7 @@ async fn run_server_loop(
|
||||||
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
|
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
|
||||||
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
|
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
|
||||||
let (udp_reply_tx, mut udp_reply_rx) = mpsc::unbounded_channel::<(u32, u16, String, Vec<u8>)>();
|
let (udp_reply_tx, mut udp_reply_rx) = mpsc::unbounded_channel::<(u32, u16, String, Vec<u8>)>();
|
||||||
let (connect_tx, mut connect_rx) = mpsc::unbounded_channel::<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>();
|
let (connect_tx, mut connect_rx) = mpsc::unbounded_channel::<ConnectRequest>();
|
||||||
|
|
||||||
let tcp_map = std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new()));
|
let tcp_map = std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
|
@ -414,17 +443,12 @@ async fn run_server_loop(
|
||||||
let tx = udp_tx.clone();
|
let tx = udp_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = vec![0_u8; 65535];
|
let mut buf = vec![0_u8; 65535];
|
||||||
loop {
|
while let Ok((size, peer)) = sock_clone.recv_from(&mut buf).await {
|
||||||
match sock_clone.recv_from(&mut buf).await {
|
|
||||||
Ok((size, peer)) => {
|
|
||||||
let packet = Bytes::copy_from_slice(&buf[..size]);
|
let packet = Bytes::copy_from_slice(&buf[..size]);
|
||||||
if tx.send((packet, peer)).await.is_err() {
|
if tx.send((packet, peer)).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,6 +604,7 @@ async fn run_server_loop(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn handle_udp_packet(
|
async fn handle_udp_packet(
|
||||||
packet: Bytes,
|
packet: Bytes,
|
||||||
peer: std::net::SocketAddr,
|
peer: std::net::SocketAddr,
|
||||||
|
|
@ -590,7 +615,7 @@ async fn handle_udp_packet(
|
||||||
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
||||||
stream_tx: mpsc::UnboundedSender<(u32, u16, Vec<u8>)>,
|
stream_tx: mpsc::UnboundedSender<(u32, u16, Vec<u8>)>,
|
||||||
udp_reply_tx: mpsc::UnboundedSender<(u32, u16, String, 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>)>,
|
connect_tx: mpsc::UnboundedSender<ConnectRequest>,
|
||||||
router: std::sync::Arc<crate::router::Router>,
|
router: std::sync::Arc<crate::router::Router>,
|
||||||
peer_last_seen: &mut HashMap<IpAddr, Instant>,
|
peer_last_seen: &mut HashMap<IpAddr, Instant>,
|
||||||
peer_available: &mut HashMap<IpAddr, bool>,
|
peer_available: &mut HashMap<IpAddr, bool>,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub async fn handle_tcp_connection<S>(
|
||||||
where
|
where
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
info!("UoT client connected from {}", peer_addr);
|
tracing::debug!("UoT client connected from {}", peer_addr);
|
||||||
|
|
||||||
// Register this connection in the map
|
// Register this connection in the map
|
||||||
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
|
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
|
||||||
|
|
@ -54,6 +54,6 @@ where
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = tokio::join!(writer_task, reader_task);
|
let _ = tokio::join!(writer_task, reader_task);
|
||||||
info!("UoT client disconnected: {}", peer_addr);
|
tracing::debug!("UoT client disconnected: {}", peer_addr);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ pub fn set_dns_servers(adapter_luid: u64, dns: &str) -> Result<()> {
|
||||||
.chain(Some(0))
|
.chain(Some(0))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut settings = DNS_INTERFACE_SETTINGS {
|
let settings = DNS_INTERFACE_SETTINGS {
|
||||||
Version: 1, // DNS_INTERFACE_SETTINGS_VERSION1
|
Version: 1, // DNS_INTERFACE_SETTINGS_VERSION1
|
||||||
Flags: 1, // DNS_SETTING_IPV4
|
Flags: 1, // DNS_SETTING_IPV4
|
||||||
Domain: windows::core::PWSTR::null(),
|
Domain: windows::core::PWSTR::null(),
|
||||||
|
|
@ -124,7 +124,7 @@ pub fn set_dns_servers(adapter_luid: u64, dns: &str) -> Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let luid = windows::Win32::NetworkManagement::Ndis::NET_LUID_LH { Value: adapter_luid };
|
let luid = windows::Win32::NetworkManagement::Ndis::NET_LUID_LH { Value: adapter_luid };
|
||||||
let guid = GUID::zeroed(); // We can pass zeroed GUID and just use LUID? Wait, SetInterfaceDnsSettings requires GUID.
|
let _guid = GUID::zeroed(); // We can pass zeroed GUID and just use LUID? Wait, SetInterfaceDnsSettings requires GUID.
|
||||||
|
|
||||||
// Actually, setting DNS via SetInterfaceDnsSettings requires the interface GUID, which we can get from ConvertInterfaceLuidToGuid.
|
// Actually, setting DNS via SetInterfaceDnsSettings requires the interface GUID, which we can get from ConvertInterfaceLuidToGuid.
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ impl Drop for WindowsRouteGuard {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
let (phys_gw, phys_if) = windows_route::sys::get_default_ipv4_route()
|
let (phys_gw, phys_if) = windows_route::sys::get_default_ipv4_route()
|
||||||
.ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?;
|
.ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ colored = "2.1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
pico-args = "0.5.0"
|
pico-args = "0.5.0"
|
||||||
clipboard-win = "3.1.1"
|
clipboard-win = "3.1.1"
|
||||||
|
rlimit = "0.11.0"
|
||||||
|
|
||||||
[target."cfg(windows)".build-dependencies]
|
[target."cfg(windows)".build-dependencies]
|
||||||
winres = "0.1.12"
|
winres = "0.1.12"
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,7 @@ struct FallbackCfg {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
let _ = rlimit::increase_nofile_limit(1048576);
|
||||||
ostp_client::logging::setup_panic_hook();
|
ostp_client::logging::setup_panic_hook();
|
||||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));
|
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
|
@ -633,23 +634,23 @@ fn wizard_save_config(config_path: &std::path::Path, json_value: &serde_json::Va
|
||||||
}
|
}
|
||||||
|
|
||||||
match fs::write(¤t_path, serde_json::to_string_pretty(json_value)?) {
|
match fs::write(¤t_path, serde_json::to_string_pretty(json_value)?) {
|
||||||
Ok(_) => {
|
Ok(_) if current_path.exists() => {
|
||||||
wizard_ok(&format!("Configuration saved to {:?}", current_path));
|
wizard_ok(&format!("Configuration saved to {:?}", current_path));
|
||||||
return Ok(current_path);
|
Ok(current_path)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
_ => {
|
||||||
wizard_warn(&format!("Could not write to {:?}: {}", current_path, e));
|
wizard_warn(&format!("Could not write to {:?}", current_path));
|
||||||
// Attempt 2: fallback to current directory
|
// Attempt 2: fallback to current directory
|
||||||
let fallback = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join("config.json");
|
let fallback = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join("config.json");
|
||||||
wizard_warn(&format!("Falling back to {:?}", fallback));
|
wizard_warn(&format!("Falling back to {:?}", fallback));
|
||||||
|
|
||||||
match fs::write(&fallback, serde_json::to_string_pretty(json_value)?) {
|
match fs::write(&fallback, serde_json::to_string_pretty(json_value)?) {
|
||||||
Ok(_) => {
|
Ok(_) if fallback.exists() => {
|
||||||
wizard_ok(&format!("Configuration saved to {:?}", fallback));
|
wizard_ok(&format!("Configuration saved to {:?}", fallback));
|
||||||
return Ok(fallback);
|
Ok(fallback)
|
||||||
}
|
}
|
||||||
Err(e2) => {
|
_ => {
|
||||||
wizard_warn(&format!("Could not write to fallback {:?}: {}", fallback, e2));
|
wizard_warn(&format!("Could not write to fallback {:?}", fallback));
|
||||||
anyhow::bail!("Failed to save configuration to any location.");
|
anyhow::bail!("Failed to save configuration to any location.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -777,11 +778,11 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
let _ = &sni;
|
let _ = &sni;
|
||||||
|
|
||||||
let server_parts: Vec<&str> = server.split(':').collect();
|
let server_parts: Vec<&str> = server.split(':').collect();
|
||||||
let server_host = server_parts.get(0).unwrap_or(&"127.0.0.1");
|
let server_host = server_parts.first().unwrap_or(&"127.0.0.1");
|
||||||
let server_port = server_parts.get(1).unwrap_or(&"50000").parse::<u16>().unwrap_or(50000);
|
let server_port = server_parts.get(1).unwrap_or(&"50000").parse::<u16>().unwrap_or(50000);
|
||||||
|
|
||||||
let socks_parts: Vec<&str> = socks_bind.split(':').collect();
|
let socks_parts: Vec<&str> = socks_bind.split(':').collect();
|
||||||
let socks_host = socks_parts.get(0).unwrap_or(&"127.0.0.1");
|
let socks_host = socks_parts.first().unwrap_or(&"127.0.0.1");
|
||||||
let socks_port = socks_parts.get(1).unwrap_or(&"1088").parse::<u16>().unwrap_or(1088);
|
let socks_port = socks_parts.get(1).unwrap_or(&"1088").parse::<u16>().unwrap_or(1088);
|
||||||
|
|
||||||
let client_json = serde_json::json!({
|
let client_json = serde_json::json!({
|
||||||
|
|
@ -884,7 +885,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
|
|
||||||
wizard_step(3, TOTAL, "Service registration");
|
wizard_step(3, TOTAL, "Service registration");
|
||||||
// intentional: step text then daemon call below
|
// intentional: step text then daemon call below
|
||||||
let port_str = listen.split(':').last().unwrap_or("50000");
|
let port_str = listen.split(':').next_back().unwrap_or("50000");
|
||||||
let port: u16 = port_str.parse().unwrap_or(50000);
|
let port: u16 = port_str.parse().unwrap_or(50000);
|
||||||
let server_json = serde_json::json!({
|
let server_json = serde_json::json!({
|
||||||
"mode": "server",
|
"mode": "server",
|
||||||
|
|
@ -925,7 +926,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
|
|
||||||
// Print share links
|
// Print share links
|
||||||
let host = get_or_ask_public_ip(config_path);
|
let host = get_or_ask_public_ip(config_path);
|
||||||
let port = listen.split(':').last().unwrap_or("50000");
|
let port = listen.split(':').next_back().unwrap_or("50000");
|
||||||
println!();
|
println!();
|
||||||
wizard_section("Share links for clients:");
|
wizard_section("Share links for clients:");
|
||||||
for (i, key) in access_keys.iter().enumerate() {
|
for (i, key) in access_keys.iter().enumerate() {
|
||||||
|
|
@ -1058,7 +1059,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
wizard_register_windows_service(&actual_path)?;
|
wizard_register_windows_service(&actual_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = listen.split(':').last().unwrap_or("50000");
|
let port = listen.split(':').next_back().unwrap_or("50000");
|
||||||
println!();
|
println!();
|
||||||
wizard_section("Share links for clients:");
|
wizard_section("Share links for clients:");
|
||||||
for (i, key) in access_keys.iter().enumerate() {
|
for (i, key) in access_keys.iter().enumerate() {
|
||||||
|
|
@ -1683,8 +1684,7 @@ async fn run_app() -> Result<()> {
|
||||||
}
|
}
|
||||||
if let Some(key) = first_key {
|
if let Some(key) = first_key {
|
||||||
let host = get_or_ask_public_ip(&args.config);
|
let host = get_or_ask_public_ip(&args.config);
|
||||||
let mut query_params = Vec::<String>::new();
|
let mut query_params = vec!["type=udp".to_string()];
|
||||||
query_params.push("type=udp".to_string());
|
|
||||||
|
|
||||||
let mut link = format!("ostp://{}@{}:50000", key, host);
|
let mut link = format!("ostp://{}@{}:50000", key, host);
|
||||||
if !query_params.is_empty() {
|
if !query_params.is_empty() {
|
||||||
|
|
@ -1786,8 +1786,7 @@ async fn run_app() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (idx, user) in users.iter().enumerate() {
|
for (idx, user) in users.iter().enumerate() {
|
||||||
let mut query_params = Vec::<String>::new();
|
let mut query_params = vec!["type=udp".to_string()];
|
||||||
query_params.push("type=udp".to_string());
|
|
||||||
|
|
||||||
let mut link = format!("ostp://{}@{}:{}", user.key(), host, port);
|
let mut link = format!("ostp://{}@{}:{}", user.key(), host, port);
|
||||||
if !query_params.is_empty() {
|
if !query_params.is_empty() {
|
||||||
|
|
@ -2433,7 +2432,7 @@ fn extract_server_listen(old: &serde_json::Value) -> (String, u16) {
|
||||||
// Old format: "listen": "0.0.0.0:50000"
|
// Old format: "listen": "0.0.0.0:50000"
|
||||||
if let Some(s) = old.get("listen").and_then(|v| v.as_str()) {
|
if let Some(s) = old.get("listen").and_then(|v| v.as_str()) {
|
||||||
let parts: Vec<&str> = s.split(':').collect();
|
let parts: Vec<&str> = s.split(':').collect();
|
||||||
let h = parts.get(0).unwrap_or(&"0.0.0.0").to_string();
|
let h = parts.first().unwrap_or(&"0.0.0.0").to_string();
|
||||||
let p = parts.get(1).and_then(|x| x.parse().ok()).unwrap_or(50000);
|
let p = parts.get(1).and_then(|x| x.parse().ok()).unwrap_or(50000);
|
||||||
return (h, p);
|
return (h, p);
|
||||||
}
|
}
|
||||||
|
|
@ -2511,7 +2510,7 @@ fn extract_server_api(old: &serde_json::Value) -> (String, u16, String, String,
|
||||||
if let Some(api) = old.get("api") {
|
if let Some(api) = old.get("api") {
|
||||||
let bind = api.get("bind").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:9090");
|
let bind = api.get("bind").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:9090");
|
||||||
let parts: Vec<&str> = bind.split(':').collect();
|
let parts: Vec<&str> = bind.split(':').collect();
|
||||||
let listen = parts.get(0).unwrap_or(&"127.0.0.1").to_string();
|
let listen = parts.first().unwrap_or(&"127.0.0.1").to_string();
|
||||||
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(9090);
|
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(9090);
|
||||||
let token = api.get("token").and_then(|v| v.as_str()).unwrap_or("YOUR_SECRET_TOKEN").to_string();
|
let token = api.get("token").and_then(|v| v.as_str()).unwrap_or("YOUR_SECRET_TOKEN").to_string();
|
||||||
let webpath = api.get("webpath").and_then(|v| v.as_str()).unwrap_or("/admin").to_string();
|
let webpath = api.get("webpath").and_then(|v| v.as_str()).unwrap_or("/admin").to_string();
|
||||||
|
|
@ -2581,7 +2580,7 @@ fn extract_client_server(old: &serde_json::Value) -> (String, u16, String, Strin
|
||||||
// Old flat format
|
// Old flat format
|
||||||
let server_full = old.get("server").and_then(|v| v.as_str()).unwrap_or("YOUR_SERVER_IP:50000");
|
let server_full = old.get("server").and_then(|v| v.as_str()).unwrap_or("YOUR_SERVER_IP:50000");
|
||||||
let parts: Vec<&str> = server_full.split(':').collect();
|
let parts: Vec<&str> = server_full.split(':').collect();
|
||||||
let server = parts.get(0).unwrap_or(&"YOUR_SERVER_IP").to_string();
|
let server = parts.first().unwrap_or(&"YOUR_SERVER_IP").to_string();
|
||||||
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(50000);
|
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(50000);
|
||||||
let key = old.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let key = old.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
let transport = old.get("transport").and_then(|t| t.get("mode").or(t.get("type"))).and_then(|v| v.as_str()).unwrap_or("udp").to_string();
|
let transport = old.get("transport").and_then(|t| t.get("mode").or(t.get("type"))).and_then(|v| v.as_str()).unwrap_or("udp").to_string();
|
||||||
|
|
@ -2604,7 +2603,7 @@ fn extract_client_socks(old: &serde_json::Value) -> (String, u16) {
|
||||||
// Old flat format
|
// Old flat format
|
||||||
let bind = old.get("socks5_bind").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:1088");
|
let bind = old.get("socks5_bind").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:1088");
|
||||||
let parts: Vec<&str> = bind.split(':').collect();
|
let parts: Vec<&str> = bind.split(':').collect();
|
||||||
let listen = parts.get(0).unwrap_or(&"127.0.0.1").to_string();
|
let listen = parts.first().unwrap_or(&"127.0.0.1").to_string();
|
||||||
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(1088);
|
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(1088);
|
||||||
(listen, port)
|
(listen, port)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue