Compare commits

..

No commits in common. "e31a651eb1c7aa03b8cdab09c7a70713f921cead" and "78c7a9e886ed89e5c7e7c1be02918baf9687deef" have entirely different histories.

26 changed files with 871 additions and 933 deletions

22
Cargo.lock generated
View File

@ -1447,7 +1447,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.3.21"
version = "0.3.18"
dependencies = [
"anyhow",
"base64",
@ -1461,7 +1461,6 @@ dependencies = [
"pico-args",
"rand 0.8.5",
"reqwest",
"rlimit",
"serde",
"serde_json",
"tokio",
@ -1474,7 +1473,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.3.21"
version = "0.3.18"
dependencies = [
"anyhow",
"base64",
@ -1509,7 +1508,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.3.21"
version = "0.3.18"
dependencies = [
"anyhow",
"byteorder",
@ -1546,7 +1545,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.3.21"
version = "0.3.18"
dependencies = [
"anyhow",
"axum",
@ -1579,7 +1578,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.3.21"
version = "0.3.18"
dependencies = [
"anyhow",
"libc",
@ -1592,7 +1591,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.3.21"
version = "0.3.18"
dependencies = [
"anyhow",
"hex",
@ -1950,15 +1949,6 @@ dependencies = [
"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]]
name = "rust-embed"
version = "8.11.0"

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "BSL 1.1"
version = "0.3.21"
version = "0.3.18"
[workspace.dependencies]
anyhow = "1.0"

View File

@ -25,9 +25,7 @@ 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. |
| **Session Roaming** | Connection persistence across IP changes via session ID tracking. |
| **UoT Mode** | UDP-over-TCP encapsulation with length-prefixing to bypass UDP blocking. |
| **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. |
| **Fallback Server** | TCP proxying to a legitimate web server to resist active probing. |
| **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. |
| **TURN Relay** | RFC 5766 TURN support for NAT traversal. |
@ -41,16 +39,18 @@ flowchart LR
Apps[Local Apps] -->|SOCKS5 / TUN| CoreC
subgraph Client [Client Node]
CoreC[OSTP Client] -.->|Encrypt, Pad & Chunk| NetC[Transport Layer]
CoreC[OSTP Client] -.->|Encrypt & Mask| NetC[Transport Layer]
end
NetC <==>|Encrypted UDP / UoT| NetS
subgraph Server [Server Node]
NetS[Transport Layer] -.->|Decrypt & Auth| CoreS[OSTP Server]
NetS -->|Unauthenticated| Fallback[Fallback Server]
end
CoreS -->|Relay| WWW((Internet))
Fallback -->|Forward| Web((Web / NGINX))
```
---

View File

@ -19,18 +19,16 @@ OSTP (Ospab Stealth Transport Protocol) — зашифрованный тран
| Возможность | Описание |
|-------------|----------|
| **Маскировка Трафика** | Шифрование заголовков и данных уникальными ключами для каждого пакета. Выглядит как белый шум. |
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — обмен ключами с forward secrecy и PSK-аутентификацией. |
| **Надёжный UDP (ARQ)** | Выборочные ACK/NACK, лимитированные повторы, настраиваемый буфер и экспоненциальный бэкофф. |
| **Мультиплексирование** | Несколько логических TCP-стримов внутри одной UDP-сессии с по-стримовым контролем потока. |
| **Смена IP (Roaming)** | Сохранение соединений при переключении сетей благодаря трекингу по ID сессии. |
| **Режим UoT** | Упаковка UDP внутрь TCP-соединения для обхода блокировок UDP-трафика. |
| **TCP Фрагментация** | (В стиле Zapret) Обходит ТСПУ/DPI за счёт нарезки стартовых пакетов Noise/TLS на мелкие куски. |
| **Мусорные Пакеты** | Закидывает анализаторы DPI рандомными пакетами до начала рукопожатия, сбивая сигнатуры. |
| **Адаптивный Паддинг** | Добивает размер пакетов до 1024 байт, предотвращая детектирование по размерам пакетов. |
| **Режим TUN** | Нативная интеграция сетевого стека (`smoltcp`) для маршрутизации всего устройства без tun2socks. |
| **Management API** | Встроенный REST API для управления сервером, генерации ключей и сбора метрик. |
| **TURN Relay** | Поддержка стандарта RFC 5766 TURN для обхода NAT. |
| **Маскирование трафика** | Шифрование заголовков и полезной нагрузки с помощью HMAC ключей на каждый пакет. Трафик неотличим от шума. |
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — аутентификация через PSK, forward secrecy. |
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. |
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
| **Session Roaming** | Сохранение соединения при смене IP-адреса благодаря отслеживанию по идентификатору сессии (session ID). |
| **Режим UoT** | Инкапсуляция UDP внутри TCP с указанием длины пакетов для обхода блокировок неизвестного UDP-трафика. |
| **Fallback Server** | Проксирование неаутентифицированных TCP подключений на веб-сервер для защиты от активного пробинга. |
| **TUN-режим** | Полносистемная маршрутизация через встроенный сетевой стек `smoltcp` без внешних зависимостей. |
| **Management API** | Встроенный REST API для администрирования сервера, сбора метрик и генерации ключей. |
| **TURN Relay** | Поддержка RFC 5766 TURN для обхода NAT. |
---
@ -38,19 +36,21 @@ OSTP (Ospab Stealth Transport Protocol) — зашифрованный тран
```mermaid
flowchart LR
Apps[Локальные приложения] -->|SOCKS5 / TUN| CoreC
Apps[Приложения] -->|SOCKS5 / TUN| CoreC
subgraph Client [Клиент]
CoreC[OSTP Client] -.->|Шифрует и маскирует| NetC[Транспортный уровень]
CoreC[OSTP Клиент] -.->|Шифрование| NetC[Транспортный уровень]
end
NetC <==>|Зашифрованный UDP / UoT| NetS
subgraph Server [Сервер]
NetS[Транспортный уровень] -.->|Расшифровывает| CoreS[OSTP Server]
NetS[Транспортный уровень] -.->|Дешифрование| CoreS[OSTP Сервер]
NetS -->|Неавторизованные| Fallback[Fallback Сервер]
end
CoreS -->|Релей| WWW((Интернет))
CoreS -->|Проксирование| WWW((Интернет))
Fallback -->|Перенаправление| Web((Веб-сервер / NGINX))
```
---

View File

@ -260,55 +260,30 @@ impl ClientConfig {
// 3. Outbounds
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_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 access_key = json.get("access_key").and_then(|v| v.as_str()).unwrap_or("");
let server_full = json.get("server").and_then(|v| v.as_str())
.or_else(|| json.get("ostp").and_then(|o| o.get("server_addr")).and_then(|v| v.as_str()))
.unwrap_or("127.0.0.1:50000");
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 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 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!({
"type": "ostp",
"tag": tag,
"server": server_host,
"port": server_port,
"access_key": access_key,
"transport": {
"type": transport_type
},
"multiplex": {
"enabled": mux_enabled,
"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!({
"type": "ostp",
"tag": "proxy",
"server": server_host,
"port": server_port,
"access_key": access_key,
"transport": {
"type": transport_type
},
"multiplex": {
"enabled": mux_enabled,
"sessions": mux_sessions
}
}));
outbounds.push(serde_json::json!({
"type": "direct",

View File

@ -44,58 +44,35 @@ pub async fn run_client_core(
let mut handles = Vec::new();
let metrics_ping = metrics.clone();
let server_addr = config.outbounds.iter().find_map(|o| {
let server_ip = config.outbounds.iter().find_map(|o| {
match o {
crate::config::OutboundConfig::Ostp { server, port, .. } => Some((server.clone(), *port)),
crate::config::OutboundConfig::Socks { server, port, .. } => Some((server.clone(), *port)),
crate::config::OutboundConfig::Ostp { server, .. } => Some(server.clone()),
crate::config::OutboundConfig::Socks { server, .. } => Some(server.clone()),
_ => None,
}
});
if let Some((host, port)) = server_addr {
// Probe the REAL server port. The OSTP server listens for UoT/TCP on the
// 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}") };
if let Some(mut server) = server_ip {
if !server.contains(':') {
server.push_str(":443");
}
let mut shutdown_rx = shutdown_rx_ext.clone();
handles.push(tokio::spawn(async move {
// 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 {
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::time::sleep(std::time::Duration::from_secs(3)) => {}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() { break; }
}
}
let start = std::time::Instant::now();
if let Ok(Ok(_)) = tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(&server)
).await {
let rtt = start.elapsed().as_millis() as u32;
metrics_ping.rtt_ms.store(rtt, Ordering::Relaxed);
}
}
}));
}

View File

@ -4,58 +4,9 @@ use crate::config::{ClientConfig, InboundConfig};
use crate::tunnel::router::{Router, Session};
use crate::tunnel::outbounds::OutboundManager;
use tokio::net::TcpListener;
use tokio::io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
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(
_config: ClientConfig,
inbound_config: InboundConfig,
@ -65,6 +16,7 @@ pub async fn run_socks_inbound(
metrics: Arc<crate::bridge::BridgeMetrics>,
is_primary: bool,
) -> Result<()> {
use portable_atomic::Ordering;
let InboundConfig::LocalProxy { tag, protocol, listen, port, set_system_proxy } = inbound_config else {
return Err(anyhow!("Invalid config for LocalProxy inbound"));
};
@ -105,14 +57,13 @@ pub async fn run_socks_inbound(
let proto = protocol.clone();
let inbound_tag = tag.clone();
let metrics_clone = metrics.clone();
tokio::spawn(async move {
if proto == "socks" {
if let Err(e) = handle_socks5_connection(&mut stream, &rt, &om, &inbound_tag, client_addr, metrics_clone).await {
if let Err(e) = handle_socks5_connection(&mut stream, &rt, &om, &inbound_tag, client_addr).await {
tracing::debug!("SOCKS5 handling error: {}", e);
}
} else if proto == "http" {
if let Err(e) = handle_http_connection(&mut stream, &rt, &om, &inbound_tag, client_addr, metrics_clone).await {
if let Err(e) = handle_http_connection(&mut stream, &rt, &om, &inbound_tag, client_addr).await {
tracing::debug!("HTTP proxy handling error: {}", e);
}
} else {
@ -133,7 +84,6 @@ async fn handle_socks5_connection(
outbound_manager: &Arc<OutboundManager>,
inbound_tag: &str,
client_addr: std::net::SocketAddr,
metrics: Arc<crate::bridge::BridgeMetrics>,
) -> Result<()> {
let mut buf = [0u8; 256];
@ -203,8 +153,7 @@ async fn handle_socks5_connection(
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).await?;
// Forward data
let mut metric_remote = MetricStream { inner: remote_stream, metrics };
tokio::io::copy_bidirectional(stream, &mut metric_remote).await?;
tokio::io::copy_bidirectional(stream, &mut remote_stream).await?;
}
Err(e) => {
tracing::warn!("SOCKS5 TCP dial failed to {}: {}", outbound_tag, e);
@ -222,7 +171,6 @@ async fn handle_http_connection(
outbound_manager: &Arc<OutboundManager>,
inbound_tag: &str,
client_addr: std::net::SocketAddr,
metrics: Arc<crate::bridge::BridgeMetrics>,
) -> Result<()> {
// Basic HTTP CONNECT implementation
let mut buf = [0u8; 4096];
@ -283,8 +231,7 @@ async fn handle_http_connection(
remote_stream.write_all(&buf[0..n]).await?;
}
let mut metric_remote = MetricStream { inner: remote_stream, metrics };
tokio::io::copy_bidirectional(stream, &mut metric_remote).await?;
tokio::io::copy_bidirectional(stream, &mut remote_stream).await?;
}
Err(e) => {
tracing::warn!("HTTP TCP dial failed to {}: {}", outbound_tag, e);

View File

@ -17,12 +17,11 @@ pub async fn run_tun_inbound(
) -> Result<()> {
use netstack_smoltcp::StackBuilder;
use portable_atomic::Ordering;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use futures::{StreamExt, SinkExt};
use portable_atomic::Ordering;
#[allow(unused_variables)]
let InboundConfig::Tun { tag, auto_route, mtu, fd, .. } = inbound_config else {
let InboundConfig::Tun { tag, auto_route, mtu, fd: _fd, .. } = inbound_config else {
return Err(anyhow!("Invalid config for TUN inbound"));
};

View File

@ -122,14 +122,16 @@ pub async fn dial_tcp(
}
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");
if let Some(m) = &metrics {
m.connection_state.store(0, std::sync::atomic::Ordering::Relaxed);
}
return;
}
// The global health probe (in runner.rs) is the only authoritative source of connection state.
if let Some(m) = &metrics {
m.connection_state.store(2, std::sync::atomic::Ordering::Relaxed);
}
// Send connection request
let connect_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_host_str, target_port));
@ -266,26 +268,16 @@ pub async fn handle_udp(
let config = make_initiator_config(session_id, access_key, transport_cfg);
let mut machine = ProtocolMachine::new(config)?;
// 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.
// Send UDP Junk Packets (Amnezia style) to break DPI heuristics
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);
for _ in 0..num_junk {
let junk_len = rand::thread_rng().gen_range(100..=1000);
let mut junk = vec![0u8; junk_len];
rand::thread_rng().fill(&mut junk[..]);
let junk_bytes = bytes::Bytes::from(junk);
let _ = transport.send(&junk_bytes).await;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let num_junk = rand::thread_rng().gen_range(2..=5);
for _ in 0..num_junk {
let junk_len = rand::thread_rng().gen_range(100..=1000);
let mut junk = vec![0u8; junk_len];
rand::thread_rng().fill(&mut junk[..]);
let junk_bytes = bytes::Bytes::from(junk);
let _ = transport.send(&junk_bytes).await;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
// Send handshake first
@ -301,11 +293,15 @@ pub async fn handle_udp(
).await {
Ok(Ok(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);
if let Some(m) = &metrics {
m.connection_state.store(0, std::sync::atomic::Ordering::Relaxed);
}
return Ok(());
}
}

View File

@ -194,19 +194,14 @@ class OstpVpnService : VpnService() {
val builder = Builder()
.setSession("OSTP Tunnel")
.addAddress("10.1.0.2", 24)
.addAddress("fd00::1", 128)
.addAddress("fd00:1:fd00:1:fd00:1:fd00:1", 128)
.addRoute("0.0.0.0", 0)
.addRoute("::", 0)
.addDnsServer(dnsServer)
.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
try { builder.addDnsServer("1.1.1.1") } 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
// queries over IPv6, but our smoltcp TUN stack processes them as

View File

@ -1,49 +0,0 @@
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,
);
}
}

View File

@ -8,7 +8,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart';
import 'logs_screen.dart';
import 'app_routing_screen.dart';
@ -30,7 +29,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Timer? _uptimeTimer;
int _uptimeSecs = 0;
List<OstpProfile> _activeProfiles = [];
String _serverAddr = '127.0.0.1:443';
String _accessKey = 'default_key';
String _download = '0 B';
String _upload = '0 B';
@ -71,134 +71,64 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
void _loadSettings() {
setState(() {
final profilesJson = widget.prefs.getString('profiles_json');
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 = [];
}
}
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
_accessKey = widget.prefs.getString('access_key') ?? '';
});
_updateLatestConfigJson();
}
void _updateLatestConfigJson() {
final exDomains = widget.prefs.getString('ex_domains') ?? '';
final exIps = widget.prefs.getString('ex_ips') ?? '';
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final wss = widget.prefs.getBool('wss') ?? false;
final mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
final tcpFrag = widget.prefs.getBool('tcp_fragmentation') ?? false;
final dnsServer = widget.prefs.getString('dns_server');
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
final tunStack = 'ostp';
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
final localParts = localBind.split(':');
final localListen = localParts.isNotEmpty ? localParts[0] : '127.0.0.1';
final localPort = localParts.length > 1 ? (int.tryParse(localParts[1]) ?? 1088) : 1088;
List<Map<String, dynamic>> inbounds = [
{
"type": "local_proxy",
"tag": "socks-in",
"protocol": "socks",
"listen": localListen,
"port": localPort
},
{
"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": {
"type": p.transportMode,
"stealth_sni": p.stealthSni,
"wss": p.wss,
"tcp_fragmentation": tcpFrag
},
"multiplex": {
"enabled": muxEnabled,
"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"
"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,
},
"inbounds": inbounds,
"outbounds": outbounds,
"routing": {
"rules": [
{
"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(),
"outbound": "direct"
}
]
"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,
@ -207,7 +137,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"dns_server": effectiveDnsServer,
"tun_stack": tunStack
};
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
@ -225,9 +154,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _toggleConnection() async {
if (_state == ConnectionStateEnum.disconnected) {
if (_activeProfiles.isEmpty) {
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select at least one profile in Settings')),
const SnackBar(content: Text('Please configure Server and Key in Settings')),
);
return;
}
@ -247,12 +176,66 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final wss = widget.prefs.getBool('wss') ?? false;
_updateLatestConfigJson();
final configStr = widget.prefs.getString('latest_config_json') ?? '{}';
final mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
final tunStack = 'ostp';
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
final configMap = {
"mode": "client",
"debug": debugMode,
"ostp": {
"server_addr": _serverAddr,
"local_bind_addr": "0.0.0.0:0",
"access_key": _accessKey,
"handshake_timeout_ms": 10000,
"io_timeout_ms": 5000,
"mtu": int.tryParse(mtu) ?? 1140,
},
"local_proxy": {
"bind_addr": localBind,
"connect_timeout_ms": 15000,
},
"transport": {
"mode": transportMode,
"stealth_sni": stealthSni,
"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 {
await platform.invokeMethod('saveConfig', {
"configJson": jsonEncode(configMap)
});
await platform.invokeMethod('startTunnel', {
"configJson": configStr
"configJson": jsonEncode(configMap)
});
bool started = false;
@ -322,9 +305,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
{'t': 'uot', 'w': false, 'r': true},
];
if (_activeProfiles.isEmpty) {
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select at least one profile first')),
const SnackBar(content: Text('Please configure Server and Key first')),
);
return;
}
@ -336,14 +319,10 @@ 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)),
);
// Update prefs and active profile
// Update prefs
await widget.prefs.setString('mtu', mtu.toString());
setState(() {
for (var p in _activeProfiles) {
p.transportMode = mode['t'] as String;
p.wss = mode['w'] as bool;
}
});
await widget.prefs.setString('transport_mode', mode['t'] as String);
await widget.prefs.setBool('wss', mode['w'] as bool);
_updateLatestConfigJson();
setState(() {
@ -794,7 +773,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
const SizedBox(width: 10),
Text(
_activeProfiles.isNotEmpty ? _activeProfiles.map((e)=>e.name).join(', ') : 'No profile selected',
_serverAddr,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 15,

File diff suppressed because it is too large Load Diff

View File

@ -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
# 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.
version: 0.3.21+33
version: 0.3.18+30
environment:
sdk: ^3.11.4

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.3.18"
version = "0.3.17"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2700,7 +2700,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.3.18"
version = "0.3.17"
dependencies = [
"anyhow",
"byteorder",
@ -2742,7 +2742,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.3.18"
version = "0.3.17"
dependencies = [
"anyhow",
"libc",

View File

@ -420,26 +420,14 @@ 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> {
let mut guard = state.0.lock().await;
// If a tunnel is already running (a UI/backend desync left a stale handle, or
// the user is switching servers), tear it down before starting a fresh one —
// otherwise we'd silently keep the old connection/server. start_tunnel is only
// 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;
if let Some(ref t) = guard.tunnel {
match t {
TunnelHandle::InProcess(s) if !s.handle.is_finished() => return Ok(true),
TunnelHandle::Helper(_) => return Ok(true),
_ => {}
}
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 content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.3.21",
"version": "0.3.18",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"

View File

@ -156,22 +156,23 @@
<!-- Form card -->
<div class="card scrollable">
<!-- Profiles Section -->
<div class="profiles-section">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 12px;">
<span class="field-label">PROFILES</span>
<button id="btn-add-profile" class="icon-btn" style="width:24px; height:24px;">+</button>
</div>
<div id="profiles-list" class="profiles-list">
<!-- Profiles will be injected here -->
</div>
<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 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">
<label class="field-label" for="in-key" data-i18n="label_key">Access Key</label>
<div class="input-wrap">
<input id="in-key" class="field-input has-icon" type="password" data-i18n-placeholder="ph_key" placeholder="Secure access key" spellcheck="false" />
<button class="peek-btn" id="btn-peek-key" tabindex="-1" aria-label="Show key">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</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">
<label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label>
@ -183,8 +184,14 @@
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
</div>
<!-- Transport moved to profile modal -->
<div class="field-group">
<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 class="field-group">
@ -380,39 +387,5 @@
</div>
<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>
</html>

View File

@ -38,8 +38,7 @@ let appState = 'disconnected'; // 'disconnected' | 'connecting' | 'connected'
let pollTimer = null;
let uptimeTimer = null;
let uptimeSecs = 0;
let rawConfig = null;
let profiles = []; // parsed config.json object
let rawConfig = null; // parsed config.json object
let serverAddr = ''; // current server address (for badge)
// ── DOM refs ─────────────────────────────────────────────────────────────────
@ -62,20 +61,6 @@ const toast = $('toast');
const btnGoSettings = $('btn-go-settings');
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 btnImport = $('btn-import-url');
const btnPeekKey = $('btn-peek-key');
@ -101,8 +86,8 @@ const inLaunchStartup = $('in-launch-startup');
function bindSettingsInputs() {
const ids = [
'in-socks', 'in-dns',
'in-dns-domain', 'in-dns-region',
'in-server', 'in-key', 'in-socks', 'in-dns',
'in-transport', 'in-dns-domain', 'in-dns-region',
'in-mtu', 'in-mux-sessions',
'in-tun-mode', 'in-kill-switch', 'in-mux-mode',
'in-debug', 'in-autoconnect', 'in-launch-startup'
@ -926,90 +911,3 @@ 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();
});
}

View File

@ -1218,14 +1218,3 @@ html[data-theme="light"] .proc-item:hover { background: rgba(0,0,0,0.05); }
border-top: 1px solid var(--c-card-border);
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; }

View File

@ -70,13 +70,6 @@ pub(crate) struct RemoteState {
// ── Public API ───────────────────────────────────────────────────────────────
pub type ConnectRequest = (
u32,
u16,
String,
Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>,
);
pub async fn run_server(
bind_addrs: Vec<String>,
server_public_ip: Option<String>,
@ -350,13 +343,6 @@ pub async fn run_server(
// Headless event logger
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 {
match ev {
UiEvent::Log(msg) => {
@ -374,23 +360,8 @@ pub async fn run_server(
tracing::info!("Access key created: {key}");
}
UiEvent::UnauthorizedProbe { peer, bytes, reason } => {
let now = std::time::Instant::now();
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;
}
// Make it a warn so it's always visible outside debug mode!
tracing::warn!("Unauthorized probe from {peer} ({bytes} bytes): {reason}");
}
UiEvent::PeerSeen { .. } => {}
_ => {}
@ -431,7 +402,7 @@ async fn run_server_loop(
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
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 (connect_tx, mut connect_rx) = mpsc::unbounded_channel::<ConnectRequest>();
let (connect_tx, mut connect_rx) = mpsc::unbounded_channel::<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>();
let tcp_map = std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new()));
@ -443,10 +414,15 @@ async fn run_server_loop(
let tx = udp_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
while let Ok((size, peer)) = sock_clone.recv_from(&mut buf).await {
let packet = Bytes::copy_from_slice(&buf[..size]);
if tx.send((packet, peer)).await.is_err() {
break;
loop {
match sock_clone.recv_from(&mut buf).await {
Ok((size, peer)) => {
let packet = Bytes::copy_from_slice(&buf[..size]);
if tx.send((packet, peer)).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
@ -604,7 +580,6 @@ async fn run_server_loop(
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_udp_packet(
packet: Bytes,
peer: std::net::SocketAddr,
@ -615,7 +590,7 @@ async fn handle_udp_packet(
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
stream_tx: mpsc::UnboundedSender<(u32, u16, Vec<u8>)>,
udp_reply_tx: mpsc::UnboundedSender<(u32, u16, String, Vec<u8>)>,
connect_tx: mpsc::UnboundedSender<ConnectRequest>,
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
router: std::sync::Arc<crate::router::Router>,
peer_last_seen: &mut HashMap<IpAddr, Instant>,
peer_available: &mut HashMap<IpAddr, bool>,

View File

@ -16,7 +16,7 @@ pub async fn handle_tcp_connection<S>(
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
tracing::debug!("UoT client connected from {}", peer_addr);
info!("UoT client connected from {}", peer_addr);
// Register this connection in the map
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
@ -54,6 +54,6 @@ where
});
let _ = tokio::join!(writer_task, reader_task);
tracing::debug!("UoT client disconnected: {}", peer_addr);
info!("UoT client disconnected: {}", peer_addr);
Ok(())
}

View File

@ -110,7 +110,7 @@ pub fn set_dns_servers(adapter_luid: u64, dns: &str) -> Result<()> {
.chain(Some(0))
.collect();
let settings = DNS_INTERFACE_SETTINGS {
let mut settings = DNS_INTERFACE_SETTINGS {
Version: 1, // DNS_INTERFACE_SETTINGS_VERSION1
Flags: 1, // DNS_SETTING_IPV4
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 _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.
unsafe {

View File

@ -28,6 +28,7 @@ impl Drop for WindowsRouteGuard {
}
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()
.ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?;

View File

@ -23,7 +23,6 @@ colored = "2.1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
pico-args = "0.5.0"
clipboard-win = "3.1.1"
rlimit = "0.11.0"
[target."cfg(windows)".build-dependencies]
winres = "0.1.12"

View File

@ -463,7 +463,6 @@ struct FallbackCfg {
#[tokio::main]
async fn main() -> Result<()> {
let _ = rlimit::increase_nofile_limit(1048576);
ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));
@ -634,23 +633,23 @@ fn wizard_save_config(config_path: &std::path::Path, json_value: &serde_json::Va
}
match fs::write(&current_path, serde_json::to_string_pretty(json_value)?) {
Ok(_) if current_path.exists() => {
Ok(_) => {
wizard_ok(&format!("Configuration saved to {:?}", current_path));
Ok(current_path)
return Ok(current_path);
}
_ => {
wizard_warn(&format!("Could not write to {:?}", current_path));
Err(e) => {
wizard_warn(&format!("Could not write to {:?}: {}", current_path, e));
// Attempt 2: fallback to current directory
let fallback = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join("config.json");
wizard_warn(&format!("Falling back to {:?}", fallback));
match fs::write(&fallback, serde_json::to_string_pretty(json_value)?) {
Ok(_) if fallback.exists() => {
Ok(_) => {
wizard_ok(&format!("Configuration saved to {:?}", fallback));
Ok(fallback)
return Ok(fallback);
}
_ => {
wizard_warn(&format!("Could not write to fallback {:?}", fallback));
Err(e2) => {
wizard_warn(&format!("Could not write to fallback {:?}: {}", fallback, e2));
anyhow::bail!("Failed to save configuration to any location.");
}
}
@ -778,11 +777,11 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
let _ = &sni;
let server_parts: Vec<&str> = server.split(':').collect();
let server_host = server_parts.first().unwrap_or(&"127.0.0.1");
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 socks_parts: Vec<&str> = socks_bind.split(':').collect();
let socks_host = socks_parts.first().unwrap_or(&"127.0.0.1");
let socks_host = socks_parts.get(0).unwrap_or(&"127.0.0.1");
let socks_port = socks_parts.get(1).unwrap_or(&"1088").parse::<u16>().unwrap_or(1088);
let client_json = serde_json::json!({
@ -885,7 +884,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
wizard_step(3, TOTAL, "Service registration");
// intentional: step text then daemon call below
let port_str = listen.split(':').next_back().unwrap_or("50000");
let port_str = listen.split(':').last().unwrap_or("50000");
let port: u16 = port_str.parse().unwrap_or(50000);
let server_json = serde_json::json!({
"mode": "server",
@ -926,7 +925,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
// Print share links
let host = get_or_ask_public_ip(config_path);
let port = listen.split(':').next_back().unwrap_or("50000");
let port = listen.split(':').last().unwrap_or("50000");
println!();
wizard_section("Share links for clients:");
for (i, key) in access_keys.iter().enumerate() {
@ -1059,7 +1058,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
wizard_register_windows_service(&actual_path)?;
}
let port = listen.split(':').next_back().unwrap_or("50000");
let port = listen.split(':').last().unwrap_or("50000");
println!();
wizard_section("Share links for clients:");
for (i, key) in access_keys.iter().enumerate() {
@ -1684,7 +1683,8 @@ async fn run_app() -> Result<()> {
}
if let Some(key) = first_key {
let host = get_or_ask_public_ip(&args.config);
let mut query_params = vec!["type=udp".to_string()];
let mut query_params = Vec::<String>::new();
query_params.push("type=udp".to_string());
let mut link = format!("ostp://{}@{}:50000", key, host);
if !query_params.is_empty() {
@ -1786,7 +1786,8 @@ async fn run_app() -> Result<()> {
}
}
for (idx, user) in users.iter().enumerate() {
let mut query_params = vec!["type=udp".to_string()];
let mut query_params = Vec::<String>::new();
query_params.push("type=udp".to_string());
let mut link = format!("ostp://{}@{}:{}", user.key(), host, port);
if !query_params.is_empty() {
@ -2432,7 +2433,7 @@ fn extract_server_listen(old: &serde_json::Value) -> (String, u16) {
// Old format: "listen": "0.0.0.0:50000"
if let Some(s) = old.get("listen").and_then(|v| v.as_str()) {
let parts: Vec<&str> = s.split(':').collect();
let h = parts.first().unwrap_or(&"0.0.0.0").to_string();
let h = parts.get(0).unwrap_or(&"0.0.0.0").to_string();
let p = parts.get(1).and_then(|x| x.parse().ok()).unwrap_or(50000);
return (h, p);
}
@ -2510,7 +2511,7 @@ fn extract_server_api(old: &serde_json::Value) -> (String, u16, String, String,
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 parts: Vec<&str> = bind.split(':').collect();
let listen = parts.first().unwrap_or(&"127.0.0.1").to_string();
let listen = parts.get(0).unwrap_or(&"127.0.0.1").to_string();
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 webpath = api.get("webpath").and_then(|v| v.as_str()).unwrap_or("/admin").to_string();
@ -2580,7 +2581,7 @@ fn extract_client_server(old: &serde_json::Value) -> (String, u16, String, Strin
// Old flat format
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 server = parts.first().unwrap_or(&"YOUR_SERVER_IP").to_string();
let server = parts.get(0).unwrap_or(&"YOUR_SERVER_IP").to_string();
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 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();
@ -2603,7 +2604,7 @@ fn extract_client_socks(old: &serde_json::Value) -> (String, u16) {
// Old flat format
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 listen = parts.first().unwrap_or(&"127.0.0.1").to_string();
let listen = parts.get(0).unwrap_or(&"127.0.0.1").to_string();
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(1088);
(listen, port)
}