mirror of https://github.com/ospab/ostp.git
feat: implement built-in DNS server, adblock and dns leak prevention
This commit is contained in:
parent
4d0249e8ef
commit
730eab8553
80
README.md
80
README.md
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
**OSTP** is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise. Resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis.
|
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise. Resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -53,40 +53,36 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
graph TD
|
||||||
│ Client │
|
subgraph Client ["Client"]
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
|
A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
||||||
│ │ Browser │──▸│ SOCKS5/ │──▸│ Bridge (Mux) │ │
|
TUN[TUN Interface] -->|IP Packets| B
|
||||||
│ │ / Apps │ │ HTTP │ │ ┌─────────────────┐ │ │
|
|
||||||
│ │ │ │ Proxy │ │ │ ProtocolMachine │ │ │
|
subgraph OSTPCoreClient ["OSTP Core Protocol"]
|
||||||
│ └──────────┘ └──────────┘ │ │ (Noise + AEAD) │ │ │
|
B --> C{Protocol Machine}
|
||||||
│ │ └────────┬────────┘ │ │
|
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
||||||
│ ┌──────────┐ │ │ │ │
|
D -->|Obfuscated UDP Payload| E((UDP Socket))
|
||||||
│ │ TUN Mode │──────────────────┤ UDP Socket │ │
|
end
|
||||||
│ │tun2socks │ │ (32MB buffers, │ │
|
end
|
||||||
│ └──────────┘ │ obfuscated wire) │ │
|
|
||||||
│ └───────────┬────────────┘ │
|
E <==>|Encrypted & Obfuscated UDP Tunnel| F
|
||||||
└────────────────────────────────────────────┼────────────────┘
|
|
||||||
│ UDP
|
subgraph Server ["Server"]
|
||||||
┌────────────────────────────────────────────┼────────────────┐
|
F((UDP Socket)) --> G{Dispatcher}
|
||||||
│ Server │ │
|
|
||||||
│ ┌─────────────────────────────────────────┴───────────┐ │
|
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
||||||
│ │ Dispatcher │ │
|
G -->|Auth & Decrypt| H[Session & State Guard]
|
||||||
│ │ (Session lookup, roaming, replay guard, per-user │ │
|
H -->|TCP Stream| I[Relay Loop]
|
||||||
│ │ traffic accounting, limit enforcement) │ │
|
end
|
||||||
│ └──┬──────────────────────┬───────────────────────────┘ │
|
|
||||||
│ │ │ │
|
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
||||||
│ ┌──▾──────────────────┐ ┌─▾──────────────────────────┐ │
|
FB -->|Forward| NGINX[nginx / Caddy]
|
||||||
│ │ Relay Loop │ │ Management API (REST) │ │
|
|
||||||
│ │ (per-stream TCP) │ │ /api/users, /api/stats │ │
|
H -->|Stats & Traffic| API[Management API]
|
||||||
│ │ ──▸ Internet │ │ Bearer token auth │ │
|
|
||||||
│ └─────────────────────┘ └────────────────────────────┘ │
|
I -->|Outbound| WWW((Internet))
|
||||||
│ │
|
end
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Fallback TCP Proxy ──▸ nginx/caddy (anti-DPI) │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -142,7 +138,9 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
||||||
```bash
|
```bash
|
||||||
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
|
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
|
||||||
```
|
```
|
||||||
> **Note**: Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`.
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
65
README.ru.md
65
README.ru.md
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
OSTP — высокопроизводительный транспортный протокол, устойчивый к цензуре. Туннелирует TCP-трафик поверх UDP с полной обфускацией. Устойчив к Deep Packet Inspection (DPI), активному зондированию и статистическому анализу трафика.
|
**OSTP** (Ospab Stealth Transport Protocol) — высокопроизводительный транспортный протокол, устойчивый к цензуре. Туннелирует TCP-трафик поверх UDP с полной обфускацией. Устойчив к Deep Packet Inspection (DPI), активному зондированию и статистическому анализу трафика.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -30,33 +32,34 @@ OSTP — высокопроизводительный транспортный
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌────────────────────────────────────────────────────────────┐
|
graph TD
|
||||||
│ Клиент │
|
subgraph Client ["Клиент"]
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
|
A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
||||||
│ │ Браузер │──▸│ SOCKS5/ │──▸│ Bridge (Mux) │ │
|
TUN[TUN Интерфейс] -->|IP Пакеты| B
|
||||||
│ │ / Прил. │ │ HTTP │ │ ┌─────────────────┐ │ │
|
|
||||||
│ │ │ │ Прокси │ │ │ ProtocolMachine │ │ │
|
subgraph OSTPCoreClient ["OSTP Core Протокол"]
|
||||||
│ └──────────┘ └──────────┘ │ │ (Noise + AEAD) │ │ │
|
B --> C{Protocol Machine}
|
||||||
│ │ └────────┬────────┘ │ │
|
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
||||||
│ ┌──────────┐ │ │ │ │
|
D -->|Обфусцированный UDP| E((UDP Сокет))
|
||||||
│ │ TUN Mode │──────────────────┤ UDP-сокет │ │
|
end
|
||||||
│ │tun2socks │ │ (32МБ буферы, │ │
|
end
|
||||||
│ └──────────┘ │ обфускация) │ │
|
|
||||||
│ └───────────┬────────────┘ │
|
E <==>|Зашифрованный UDP Туннель| F
|
||||||
└────────────────────────────────────────────┼────────────────┘
|
|
||||||
│ UDP
|
subgraph Server ["Сервер"]
|
||||||
┌────────────────────────────────────────────┼────────────────┐
|
F((UDP Сокет)) --> G{Dispatcher}
|
||||||
│ Сервер │ │
|
|
||||||
│ ┌─────────────────────────────────────────┴──────────┐ │
|
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
||||||
│ │ Dispatcher │ │
|
G -->|Auth & Decrypt| H[Session & State Guard]
|
||||||
│ │ (Поиск сессий, роуминг, защита от replay) │ │
|
H -->|TCP Поток| I[Relay Loop]
|
||||||
│ └──────────────┬──────────────────────────────────────┘ │
|
end
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────▾──────────────────┐ │
|
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
||||||
│ │ Relay Loop (TCP per-stream) │──▸ Интернет / Backend │
|
FB -->|Перенаправление| NGINX[nginx / Caddy]
|
||||||
│ └─────────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
I -->|Outbound| WWW((Интернет))
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
|
||||||
|
____ _____ _______ _____
|
||||||
|
/ __ \ / ____|__ __| __ \
|
||||||
|
| | | | (___ | | | |__) |
|
||||||
|
| | | |\___ \ | | | ___/
|
||||||
|
| |__| |____) | | | | |
|
||||||
|
\____/|_____/ |_| |_|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -22,7 +22,7 @@ use spin::Mutex as SpinMutex;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncRead, AsyncWrite, ReadBuf},
|
io::{AsyncRead, AsyncWrite, ReadBuf},
|
||||||
sync::{
|
sync::{
|
||||||
mpsc::{unbounded_channel, Receiver, Sender, UnboundedReceiver, UnboundedSender},
|
mpsc::{channel, unbounded_channel, Receiver, Sender, UnboundedReceiver, UnboundedSender},
|
||||||
Notify,
|
Notify,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -72,12 +72,12 @@ impl TcpListenerRunner {
|
||||||
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
iface_ingress_tx_avail: Arc<AtomicBool>,
|
||||||
tcp_rx: Receiver<AnyIpPktFrame>,
|
tcp_rx: Receiver<AnyIpPktFrame>,
|
||||||
stream_tx: UnboundedSender<TcpStream>,
|
stream_tx: Sender<TcpStream>,
|
||||||
sockets: HashMap<SocketHandle, SharedControl>,
|
sockets: HashMap<SocketHandle, SharedControl>,
|
||||||
) -> Runner {
|
) -> Runner {
|
||||||
Runner::new(async move {
|
Runner::new(async move {
|
||||||
let notify = Arc::new(Notify::new());
|
let notify = Arc::new(Notify::new());
|
||||||
let (socket_tx, socket_rx) = unbounded_channel::<TcpSocketCreation>();
|
let (socket_tx, socket_rx) = channel::<TcpSocketCreation>(1024);
|
||||||
let res = tokio::select! {
|
let res = tokio::select! {
|
||||||
v = Self::handle_packet(notify.clone(), iface_ingress_tx, iface_ingress_tx_avail.clone(), tcp_rx, stream_tx, socket_tx) => v,
|
v = Self::handle_packet(notify.clone(), iface_ingress_tx, iface_ingress_tx_avail.clone(), tcp_rx, stream_tx, socket_tx) => v,
|
||||||
v = Self::handle_socket(notify, device, iface, iface_ingress_tx_avail, sockets, socket_rx) => v,
|
v = Self::handle_socket(notify, device, iface, iface_ingress_tx_avail, sockets, socket_rx) => v,
|
||||||
|
|
@ -93,8 +93,8 @@ impl TcpListenerRunner {
|
||||||
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
iface_ingress_tx_avail: Arc<AtomicBool>,
|
||||||
mut tcp_rx: Receiver<AnyIpPktFrame>,
|
mut tcp_rx: Receiver<AnyIpPktFrame>,
|
||||||
stream_tx: UnboundedSender<TcpStream>,
|
stream_tx: Sender<TcpStream>,
|
||||||
socket_tx: UnboundedSender<TcpSocketCreation>,
|
socket_tx: Sender<TcpSocketCreation>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
while let Some(frame) = tcp_rx.recv().await {
|
while let Some(frame) = tcp_rx.recv().await {
|
||||||
let packet = match IpPacket::new_checked(frame.as_slice()) {
|
let packet = match IpPacket::new_checked(frame.as_slice()) {
|
||||||
|
|
@ -160,17 +160,20 @@ impl TcpListenerRunner {
|
||||||
send_state: TcpSocketState::Normal,
|
send_state: TcpSocketState::Normal,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
stream_tx
|
if let Err(_) = stream_tx.try_send(TcpStream {
|
||||||
.send(TcpStream {
|
|
||||||
src_addr,
|
src_addr,
|
||||||
dst_addr,
|
dst_addr,
|
||||||
notify: notify.clone(),
|
notify: notify.clone(),
|
||||||
control: control.clone(),
|
control: control.clone(),
|
||||||
})
|
}) {
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
error!("stream_tx full or dropped, dropping SYN from {}", src_addr);
|
||||||
socket_tx
|
continue;
|
||||||
.send(TcpSocketCreation { control, socket })
|
}
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
|
||||||
|
if let Err(_) = socket_tx.try_send(TcpSocketCreation { control, socket }) {
|
||||||
|
error!("socket_tx full or dropped, dropping SYN from {}", src_addr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipeline tcp stream packet
|
// Pipeline tcp stream packet
|
||||||
|
|
@ -189,7 +192,7 @@ impl TcpListenerRunner {
|
||||||
mut iface: Interface,
|
mut iface: Interface,
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
iface_ingress_tx_avail: Arc<AtomicBool>,
|
||||||
mut sockets: HashMap<SocketHandle, SharedControl>,
|
mut sockets: HashMap<SocketHandle, SharedControl>,
|
||||||
mut socket_rx: UnboundedReceiver<TcpSocketCreation>,
|
mut socket_rx: Receiver<TcpSocketCreation>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let mut socket_set = SocketSet::new(vec![]);
|
let mut socket_set = SocketSet::new(vec![]);
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -355,7 +358,7 @@ impl TcpListenerRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TcpListener {
|
pub struct TcpListener {
|
||||||
stream_rx: UnboundedReceiver<TcpStream>,
|
stream_rx: Receiver<TcpStream>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TcpListener {
|
impl TcpListener {
|
||||||
|
|
@ -368,7 +371,7 @@ impl TcpListener {
|
||||||
VirtualDevice::new(stack_tx, mtu);
|
VirtualDevice::new(stack_tx, mtu);
|
||||||
let iface = Self::create_interface(&mut device)?;
|
let iface = Self::create_interface(&mut device)?;
|
||||||
|
|
||||||
let (stream_tx, stream_rx) = unbounded_channel();
|
let (stream_tx, stream_rx) = channel(1024);
|
||||||
|
|
||||||
let runner = TcpListenerRunner::create(
|
let runner = TcpListenerRunner::create(
|
||||||
device,
|
device,
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,4 @@ libc = "0.2.186"
|
||||||
x25519-dalek = "2.0.1"
|
x25519-dalek = "2.0.1"
|
||||||
chacha20poly1305.workspace = true
|
chacha20poly1305.workspace = true
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt"] }
|
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ pub struct Bridge {
|
||||||
pub reality_enabled: bool,
|
pub reality_enabled: bool,
|
||||||
pub reality_pbk: String,
|
pub reality_pbk: String,
|
||||||
pub reality_sid: String,
|
pub reality_sid: String,
|
||||||
|
pub kill_switch: bool,
|
||||||
|
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||||
|
|
||||||
metrics: Arc<BridgeMetrics>,
|
metrics: Arc<BridgeMetrics>,
|
||||||
sample_sent: u64,
|
sample_sent: u64,
|
||||||
|
|
@ -107,6 +109,8 @@ impl Bridge {
|
||||||
reality_enabled: config.reality.enabled,
|
reality_enabled: config.reality.enabled,
|
||||||
reality_pbk: config.reality.pbk.clone(),
|
reality_pbk: config.reality.pbk.clone(),
|
||||||
reality_sid: config.reality.sid.clone(),
|
reality_sid: config.reality.sid.clone(),
|
||||||
|
kill_switch: config.kill_switch,
|
||||||
|
reload_tx: None,
|
||||||
|
|
||||||
metrics,
|
metrics,
|
||||||
sample_sent: 0,
|
sample_sent: 0,
|
||||||
|
|
@ -465,8 +469,23 @@ impl Bridge {
|
||||||
Some(BridgeCommand::ReloadConfig) => {
|
Some(BridgeCommand::ReloadConfig) => {
|
||||||
match ClientConfig::reload_from_json_near_binary() {
|
match ClientConfig::reload_from_json_near_binary() {
|
||||||
Ok(cfg) => {
|
Ok(cfg) => {
|
||||||
|
let old_server = self.server_addr.clone();
|
||||||
|
let old_mode = self.mode.clone();
|
||||||
|
let old_transport = self.transport_mode.clone();
|
||||||
|
|
||||||
self.apply_runtime_config(&cfg);
|
self.apply_runtime_config(&cfg);
|
||||||
tx.send(UiEvent::Log("Runtime config reloaded".to_string())).await.ok();
|
|
||||||
|
let requires_restart = self.server_addr != old_server ||
|
||||||
|
self.mode != old_mode ||
|
||||||
|
self.transport_mode != old_transport;
|
||||||
|
|
||||||
|
if !requires_restart {
|
||||||
|
if let Some(tx_watch) = &self.reload_tx {
|
||||||
|
let _ = tx_watch.send(cfg.exclusions.clone());
|
||||||
|
}
|
||||||
|
tx.send(UiEvent::Log("Exclusions updated in real-time (hot reload)".to_string())).await.ok();
|
||||||
|
} else {
|
||||||
|
tx.send(UiEvent::Log("Runtime config reloaded. Restarting tunnel due to critical parameter changes.".to_string())).await.ok();
|
||||||
if self.running {
|
if self.running {
|
||||||
self.running = false;
|
self.running = false;
|
||||||
self.metrics.connection_state.store(0, Ordering::Relaxed);
|
self.metrics.connection_state.store(0, Ordering::Relaxed);
|
||||||
|
|
@ -477,6 +496,7 @@ impl Bridge {
|
||||||
let _ = tx.send(UiEvent::TunnelStopped).await;
|
let _ = tx.send(UiEvent::TunnelStopped).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = tx.send(UiEvent::Log(format!("Config reload failed: {err}"))).await;
|
let _ = tx.send(UiEvent::Log(format!("Config reload failed: {err}"))).await;
|
||||||
}
|
}
|
||||||
|
|
@ -504,6 +524,9 @@ impl Bridge {
|
||||||
if self.last_valid_recv.elapsed().as_secs() > 25 {
|
if self.last_valid_recv.elapsed().as_secs() > 25 {
|
||||||
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
||||||
if elapsed > 180 {
|
if elapsed > 180 {
|
||||||
|
if self.kill_switch {
|
||||||
|
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
|
||||||
|
} else {
|
||||||
let _ = tx.send(UiEvent::Log("Connection permanently lost (3-minute hard timeout). Stopping tunnel.".into())).await;
|
let _ = tx.send(UiEvent::Log("Connection permanently lost (3-minute hard timeout). Stopping tunnel.".into())).await;
|
||||||
self.running = false;
|
self.running = false;
|
||||||
*proxy_guard = None;
|
*proxy_guard = None;
|
||||||
|
|
@ -514,8 +537,10 @@ impl Bridge {
|
||||||
self.metrics.connection_state.store(0, Ordering::Relaxed);
|
self.metrics.connection_state.store(0, Ordering::Relaxed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
let _ = tx.send(UiEvent::Log(format!("Connection stall detected ({}s silence). Attempting background reconnect...", elapsed))).await;
|
let _ = tx.send(UiEvent::Log(format!("Connection stall detected ({}s silence). Attempting background reconnect...", elapsed))).await;
|
||||||
|
}
|
||||||
|
|
||||||
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
||||||
|
|
||||||
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
|
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
|
||||||
|
|
@ -970,8 +995,9 @@ impl Bridge {
|
||||||
self.reality_enabled = cfg.reality.enabled;
|
self.reality_enabled = cfg.reality.enabled;
|
||||||
self.reality_pbk = cfg.reality.pbk.clone();
|
self.reality_pbk = cfg.reality.pbk.clone();
|
||||||
self.reality_sid = cfg.reality.sid.clone();
|
self.reality_sid = cfg.reality.sid.clone();
|
||||||
self.mtu = cfg.ostp.mtu; // Fix: mtu was never updated on hot-reload
|
self.mtu = cfg.ostp.mtu;
|
||||||
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; // Fix: keepalive was never updated on hot-reload
|
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
||||||
|
self.kill_switch = cfg.kill_switch;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_connect_transport(
|
async fn try_connect_transport(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ pub struct ClientConfig {
|
||||||
pub dns_server: Option<String>,
|
pub dns_server: Option<String>,
|
||||||
#[serde(default = "default_tun_stack")]
|
#[serde(default = "default_tun_stack")]
|
||||||
pub tun_stack: String,
|
pub tun_stack: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kill_switch: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tun_stack() -> String { "system".to_string() }
|
fn default_tun_stack() -> String { "system".to_string() }
|
||||||
|
|
@ -153,6 +155,7 @@ impl Default for ClientConfig {
|
||||||
multiplex: MultiplexConfig::default(),
|
multiplex: MultiplexConfig::default(),
|
||||||
dns_server: None,
|
dns_server: None,
|
||||||
tun_stack: "system".to_string(),
|
tun_stack: "system".to_string(),
|
||||||
|
kill_switch: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +200,7 @@ struct RawTunSection {
|
||||||
enable: Option<bool>,
|
enable: Option<bool>,
|
||||||
dns: Option<String>,
|
dns: Option<String>,
|
||||||
stack: Option<String>,
|
stack: Option<String>,
|
||||||
|
kill_switch: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -292,7 +296,7 @@ impl ClientConfig {
|
||||||
},
|
},
|
||||||
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
||||||
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
||||||
|
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,13 +180,14 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
run_client_core(config, metrics, shutdown_rx).await
|
run_client_core(config, metrics, shutdown_rx, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_client_core(
|
pub async fn run_client_core(
|
||||||
mut config: crate::config::ClientConfig,
|
mut config: crate::config::ClientConfig,
|
||||||
metrics: Arc<BridgeMetrics>,
|
metrics: Arc<BridgeMetrics>,
|
||||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||||
|
mut config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
if config.mode == "tun" && !is_admin() {
|
if config.mode == "tun" && !is_admin() {
|
||||||
|
|
@ -250,7 +251,11 @@ pub async fn run_client_core(
|
||||||
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256);
|
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256);
|
||||||
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
let bridge = Bridge::new(&config, metrics)?;
|
// Setup exclusions hot-reload channel
|
||||||
|
let (reload_tx, reload_rx) = watch::channel(config.exclusions.clone());
|
||||||
|
|
||||||
|
let mut bridge = Bridge::new(&config, metrics)?;
|
||||||
|
bridge.reload_tx = Some(reload_tx.clone());
|
||||||
|
|
||||||
let (ui_tx, mut ui_rx) = mpsc::channel(512);
|
let (ui_tx, mut ui_rx) = mpsc::channel(512);
|
||||||
let (cmd_tx, cmd_rx) = mpsc::channel(128);
|
let (cmd_tx, cmd_rx) = mpsc::channel(128);
|
||||||
|
|
@ -305,11 +310,12 @@ pub async fn run_client_core(
|
||||||
});
|
});
|
||||||
|
|
||||||
let config_clone = config.clone();
|
let config_clone = config.clone();
|
||||||
|
let proxy_exclusions_rx = reload_rx.clone();
|
||||||
let mut proxy_task = tokio::spawn(async move {
|
let mut proxy_task = tokio::spawn(async move {
|
||||||
tunnel::run_local_proxy(
|
tunnel::run_local_proxy(
|
||||||
config.local_proxy,
|
config.local_proxy,
|
||||||
config.ostp,
|
config.ostp,
|
||||||
config.exclusions,
|
proxy_exclusions_rx,
|
||||||
config.debug,
|
config.debug,
|
||||||
proxy_shutdown_rx,
|
proxy_shutdown_rx,
|
||||||
proxy_events_tx,
|
proxy_events_tx,
|
||||||
|
|
@ -319,14 +325,43 @@ pub async fn run_client_core(
|
||||||
});
|
});
|
||||||
|
|
||||||
let wintun_shutdown_rx = shutdown_tx.subscribe();
|
let wintun_shutdown_rx = shutdown_tx.subscribe();
|
||||||
|
let wintun_exclusions_rx = reload_rx.clone();
|
||||||
let mut wintun_task = if config_clone.mode == "tun" {
|
let mut wintun_task = if config_clone.mode == "tun" {
|
||||||
Some(tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx).await
|
tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx, wintun_exclusions_rx).await
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wait for local_shutdown
|
||||||
|
let mut local_shutdown = shutdown_rx_ext.clone();
|
||||||
|
let cmd_tx_loop = cmd_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = local_shutdown.changed() => {
|
||||||
|
if *local_shutdown.borrow() {
|
||||||
|
let _ = cmd_tx_loop.send(BridgeCommand::Shutdown).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(_)) = async {
|
||||||
|
if let Some(ref mut rx) = config_rx {
|
||||||
|
Some(rx.changed().await)
|
||||||
|
} else {
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
if let Some(ref rx) = config_rx {
|
||||||
|
let new_cfg = rx.borrow().clone();
|
||||||
|
let _ = reload_tx.send(new_cfg.exclusions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for either external shutdown OR any task to fail
|
// Wait for either external shutdown OR any task to fail
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = shutdown_rx_ext.changed() => {
|
_ = shutdown_rx_ext.changed() => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
mod proxy;
|
mod proxy;
|
||||||
pub mod native_handler;
|
pub mod native_handler;
|
||||||
|
pub mod windows_route;
|
||||||
mod udp_nat;
|
mod udp_nat;
|
||||||
|
|
||||||
pub async fn run_tun_tunnel(
|
pub async fn run_tun_tunnel(
|
||||||
config: crate::config::ClientConfig,
|
config: crate::config::ClientConfig,
|
||||||
shutdown: watch::Receiver<bool>,
|
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
|
exclusions_rx: tokio::sync::watch::Receiver<crate::config::ExclusionConfig>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
native_handler::run_native_tunnel(config, shutdown).await
|
native_handler::run_native_tunnel(config, shutdown, exclusions_rx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
@ -51,17 +53,15 @@ pub enum ProxyToClientMsg {
|
||||||
pub async fn run_local_proxy(
|
pub async fn run_local_proxy(
|
||||||
cfg: LocalProxyConfig,
|
cfg: LocalProxyConfig,
|
||||||
ostp: OstpConfig,
|
ostp: OstpConfig,
|
||||||
exclusions: ExclusionConfig,
|
exclusions_rx: watch::Receiver<ExclusionConfig>,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
shutdown: watch::Receiver<bool>,
|
shutdown: watch::Receiver<bool>,
|
||||||
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
||||||
client_msgs_rx: mpsc::UnboundedReceiver<(u16, ProxyToClientMsg)>,
|
client_msgs_rx: mpsc::UnboundedReceiver<(u16, ProxyToClientMsg)>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
run_local_socks5_proxy(cfg, ostp, exclusions, debug, shutdown, proxy_events_tx, client_msgs_rx).await
|
run_local_socks5_proxy(cfg, ostp, exclusions_rx, debug, shutdown, proxy_events_tx, client_msgs_rx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub mod exclusion;
|
pub mod exclusion;
|
||||||
pub mod process_lookup;
|
pub mod process_lookup;
|
||||||
pub mod sni_sniff;
|
pub mod sni_sniff;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Windows / Linux desktop TUN
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
pub async fn run_native_tunnel(
|
pub async fn run_native_tunnel(
|
||||||
config: crate::config::ClientConfig,
|
config: crate::config::ClientConfig,
|
||||||
mut shutdown: watch::Receiver<bool>,
|
mut shutdown: watch::Receiver<bool>,
|
||||||
|
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::net::ToSocketAddrs;
|
use std::net::ToSocketAddrs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
@ -31,7 +36,7 @@ pub async fn run_native_tunnel(
|
||||||
io::stdin().read_line(&mut input).unwrap();
|
io::stdin().read_line(&mut input).unwrap();
|
||||||
let ans = input.trim().to_lowercase();
|
let ans = input.trim().to_lowercase();
|
||||||
if ans != "y" && ans != "yes" {
|
if ans != "y" && ans != "yes" {
|
||||||
return Err(anyhow!("TUN initialization aborted by user. Run without TUN to use as a local proxy."));
|
return Err(anyhow!("TUN initialization aborted by user."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,16 +44,91 @@ pub async fn run_native_tunnel(
|
||||||
let debug = config.debug;
|
let debug = config.debug;
|
||||||
tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)...");
|
tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)...");
|
||||||
|
|
||||||
let server_ip = config.ostp.server_addr.to_socket_addrs()
|
// ── 1. Resolve server IP ──────────────────────────────────────────────────
|
||||||
.map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))?
|
let server_ip = config
|
||||||
|
.ostp
|
||||||
|
.server_addr
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| anyhow!("Failed to resolve server IP: {}", e))?
|
||||||
.next()
|
.next()
|
||||||
.map(|addr| addr.ip())
|
.map(|a| a.ip())
|
||||||
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
|
.ok_or_else(|| anyhow!("Could not resolve server host"))?;
|
||||||
|
let _server_ip_str = server_ip.to_string();
|
||||||
|
|
||||||
let server_ip_str = server_ip.to_string();
|
// ── 2. Windows: grab physical gateway BEFORE we touch any routes ──────────
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let (phys_gw, phys_if) = super::windows_route::sys::get_default_ipv4_route()
|
||||||
|
.ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?;
|
||||||
|
|
||||||
|
// ── 3. Resolve excluded domains → IPv4 addresses for bypass routing ───────
|
||||||
|
//
|
||||||
|
// Strategy identical to sing-box / v2rayN:
|
||||||
|
// • IP exclusions → add /32 host routes via physical gateway right now
|
||||||
|
// • Domain exclusions → resolve them NOW, add /32 routes for the IPs
|
||||||
|
// • Process exclusions → NOT possible via pure routing on Windows without
|
||||||
|
// WFP; we log a warning and skip them at the routing level
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
// Will be populated after TUN is up; tracks /32 routes added for cleanup.
|
||||||
|
let bypass_routes: Vec<(std::net::Ipv4Addr, std::net::Ipv4Addr, u32)>;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Collect all IPs to bypass: server IP + configured IPs + resolved domains
|
||||||
|
let mut bypass_v4: Vec<std::net::Ipv4Addr> = Vec::new();
|
||||||
|
|
||||||
|
// Server IP always bypasses TUN
|
||||||
|
if let std::net::IpAddr::V4(v4) = server_ip {
|
||||||
|
bypass_v4.push(v4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly configured IPs / CIDRs
|
||||||
|
for ip_str in &config.exclusions.ips {
|
||||||
|
// Accept single IPs ("1.2.3.4") or CIDR ("1.2.3.0/24")
|
||||||
|
let host = ip_str.split('/').next().unwrap_or(ip_str);
|
||||||
|
if let Ok(std::net::IpAddr::V4(v4)) = host.parse() {
|
||||||
|
bypass_v4.push(v4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve configured excluded domains (best-effort, DNS at startup).
|
||||||
|
// Use (host, port) tuple so lookup_host does NOT borrow a temporary string.
|
||||||
|
for domain in &config.exclusions.domains {
|
||||||
|
match tokio::net::lookup_host((domain.as_str(), 443u16)).await {
|
||||||
|
Ok(addrs) => {
|
||||||
|
for addr in addrs {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
bypass_v4.push(v4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to pre-resolve excluded domain {domain}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.exclusions.processes.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"Process-based split tunneling is not supported in TUN mode on Windows \
|
||||||
|
without WFP. Processes in the exclusion list will still be tunneled. \
|
||||||
|
Use IP or domain exclusions instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add /32 bypass routes via physical gateway BEFORE setting up TUN default route
|
||||||
|
bypass_routes = super::windows_route::sys::add_bypass_routes(&bypass_v4, phys_gw, phys_if, 1);
|
||||||
|
tracing::info!(
|
||||||
|
"Added {} bypass routes via {} (if_index={})",
|
||||||
|
bypass_routes.len(),
|
||||||
|
phys_gw,
|
||||||
|
phys_if
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Create TUN device ──────────────────────────────────────────────────
|
||||||
let mut tun_cfg = tun::Configuration::default();
|
let mut tun_cfg = tun::Configuration::default();
|
||||||
tun_cfg.tun_name("ostp_tun")
|
tun_cfg
|
||||||
|
.tun_name("ostp_tun")
|
||||||
.address((10, 1, 0, 2))
|
.address((10, 1, 0, 2))
|
||||||
.netmask((255, 255, 255, 0))
|
.netmask((255, 255, 255, 0))
|
||||||
.destination((10, 1, 0, 1))
|
.destination((10, 1, 0, 1))
|
||||||
|
|
@ -56,67 +136,97 @@ pub async fn run_native_tunnel(
|
||||||
.up();
|
.up();
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
tun_cfg.platform_config(|config| {
|
tun_cfg.platform_config(|cfg| {
|
||||||
config.packet_information(false);
|
cfg.packet_information(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
let dev = tun::create(&tun_cfg)
|
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
|
||||||
.map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
|
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
|
||||||
let dev = tun::AsyncDevice::new(dev)
|
tracing::info!("TUN device 'ostp_tun' created.");
|
||||||
.map_err(|e| anyhow!("Failed to make TUN device async: {}", e))?;
|
|
||||||
|
|
||||||
tracing::info!("TUN device created natively.");
|
|
||||||
|
|
||||||
|
// ── 5. Windows: set default route through TUN + miscellaneous setup ───────
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||||
|
|
||||||
let setup_script = format!(
|
// Wait for ostp_tun to be visible in the routing table
|
||||||
"$remote_ip = '{}'\n\
|
let mut tun_index = None;
|
||||||
$exe_path = '{}'\n\
|
for _ in 0..20 {
|
||||||
$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object {{ $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' }} | Sort-Object RouteMetric | Select-Object -First 1\n\
|
if let Some(idx) = super::windows_route::sys::get_interface_index("ostp_tun") {
|
||||||
if ($route) {{\n\
|
tun_index = Some(idx);
|
||||||
$gw = $route.NextHop\n\
|
break;
|
||||||
$ifIndex = $route.InterfaceIndex\n\
|
}
|
||||||
if ($gw -eq '0.0.0.0' -or $gw -eq '::') {{\n\
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
New-NetRoute -DestinationPrefix \"$remote_ip/32\" -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
}
|
||||||
}} else {{\n\
|
|
||||||
New-NetRoute -DestinationPrefix \"$remote_ip/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
if let Some(idx) = tun_index {
|
||||||
}}\n\
|
// Default route through TUN with metric=5 — higher than bypass routes (metric=1)
|
||||||
if ($gw -ne '0.0.0.0') {{\n\
|
// so that non-excluded traffic is captured but excluded IPs go via real NIC.
|
||||||
New-NetRoute -DestinationPrefix \"$gw/32\" -NextHop '0.0.0.0' -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
let _ = super::windows_route::sys::add_ipv4_route(
|
||||||
}}\n\
|
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||||
}}\n\
|
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||||
New-NetFirewallRule -DisplayName 'OSTP Tunnel In' -Direction Inbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\
|
std::net::Ipv4Addr::new(10, 1, 0, 1),
|
||||||
New-NetFirewallRule -DisplayName 'OSTP Tunnel Out' -Direction Outbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\
|
idx,
|
||||||
netsh interface ipv4 set interface name=\"ostp_tun\" metric=1\n\
|
5,
|
||||||
New-NetRoute -DestinationPrefix '0.0.0.0/0' -InterfaceAlias 'ostp_tun' -NextHop '10.1.0.1' -RouteMetric 1 -ErrorAction SilentlyContinue\n",
|
|
||||||
server_ip_str, current_exe
|
|
||||||
);
|
);
|
||||||
|
tracing::info!("Default route via TUN (if_index={idx}, metric=5) added.");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Could not find ostp_tun index in routing table — traffic may not be captured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe1 = current_exe.clone();
|
||||||
|
let exe2 = current_exe.clone();
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
Command::new("powershell")
|
// Firewall allow-rules for OSTP binary
|
||||||
|
let _ = Command::new("netsh")
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.args(["-NoProfile", "-Command", &setup_script])
|
.args(["advfirewall", "firewall", "add", "rule",
|
||||||
.output()
|
"name=OSTP Tunnel In", "dir=in", "action=allow",
|
||||||
}).await.unwrap()?;
|
&format!("program={}", exe1)])
|
||||||
|
.output();
|
||||||
|
let _ = Command::new("netsh")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["advfirewall", "firewall", "add", "rule",
|
||||||
|
"name=OSTP Tunnel Out", "dir=out", "action=allow",
|
||||||
|
&format!("program={}", exe2)])
|
||||||
|
.output();
|
||||||
|
// Disable DAD / Router Discovery to avoid 15s delay
|
||||||
|
let _ = Command::new("netsh")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["interface", "ipv4", "set", "interface", "name=ostp_tun",
|
||||||
|
"routerdiscovery=disabled", "dadtransmits=0",
|
||||||
|
"managedaddress=disabled", "otherstateful=disabled"])
|
||||||
|
.output();
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(ref dns) = config.dns_server {
|
if let Some(ref dns) = config.dns_server {
|
||||||
if !dns.is_empty() {
|
if !dns.is_empty() {
|
||||||
let net_setup = format!("netsh interface ipv4 set dnsservers name=\"ostp_tun\" static {} primary\n", dns);
|
let dns_clone = dns.clone();
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
Command::new("powershell")
|
let _ = Command::new("netsh")
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.args(["-NoProfile", "-Command", &net_setup])
|
.args(["interface", "ipv4", "set", "dnsservers",
|
||||||
.output()
|
"name=ostp_tun", "static", &dns_clone, "primary"])
|
||||||
}).await.unwrap()?;
|
.output();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.kill_switch {
|
||||||
|
tracing::info!("Kill Switch enabled: Adding metric 10 blackhole route to prevent leakage");
|
||||||
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
|
let _ = Command::new("route")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["add", "0.0.0.0", "mask", "0.0.0.0", "127.0.0.1", "metric", "10", "if", "1"])
|
||||||
|
.output();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Linux: exclusion routes via real gateway ───────────────────────────
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
// Get real gateway before routing through TUN
|
|
||||||
let gw_out = Command::new("ip")
|
let gw_out = Command::new("ip")
|
||||||
.args(["route", "show", "default"])
|
.args(["route", "show", "default"])
|
||||||
.output()
|
.output()
|
||||||
|
|
@ -124,26 +234,44 @@ pub async fn run_native_tunnel(
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok());
|
.and_then(|o| String::from_utf8(o.stdout).ok());
|
||||||
|
|
||||||
let real_gw = gw_out.as_deref().and_then(|s| {
|
let real_gw = gw_out.as_deref().and_then(|s| {
|
||||||
// "default via 192.168.1.1 dev eth0" -> "192.168.1.1"
|
s.split_whitespace()
|
||||||
s.split_whitespace().skip_while(|w| *w != "via").nth(1).map(|s| s.to_string())
|
.skip_while(|w| *w != "via")
|
||||||
|
.nth(1)
|
||||||
|
.map(|s| s.to_string())
|
||||||
});
|
});
|
||||||
let real_dev = gw_out.as_deref().and_then(|s| {
|
let real_dev = gw_out.as_deref().and_then(|s| {
|
||||||
s.split_whitespace().skip_while(|w| *w != "dev").nth(1).map(|s| s.to_string())
|
s.split_whitespace()
|
||||||
|
.skip_while(|w| *w != "dev")
|
||||||
|
.nth(1)
|
||||||
|
.map(|s| s.to_string())
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add exclusion route for server IP via real gateway (bypass TUN)
|
|
||||||
if let (Some(ref gw), Some(ref dev)) = (&real_gw, &real_dev) {
|
if let (Some(ref gw), Some(ref dev)) = (&real_gw, &real_dev) {
|
||||||
let _ = Command::new("ip").args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev]).output();
|
// Server IP bypass
|
||||||
|
let _ = Command::new("ip")
|
||||||
|
.args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev])
|
||||||
|
.output();
|
||||||
|
// Configured IP exclusions
|
||||||
|
for ip_str in &config.exclusions.ips {
|
||||||
|
let host = ip_str.split('/').next().unwrap_or(ip_str);
|
||||||
|
let route = if ip_str.contains('/') { ip_str.as_str() } else { &format!("{}/32", host) };
|
||||||
|
let _ = Command::new("ip")
|
||||||
|
.args(["route", "add", route, "via", gw, "dev", dev])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default route through TUN (lower metric to take priority)
|
// Default route through TUN
|
||||||
let _ = Command::new("ip").args(["route", "add", "default", "via", "10.1.0.1", "dev", "ostp_tun", "metric", "10"]).output();
|
let _ = Command::new("ip")
|
||||||
|
.args(["route", "add", "default", "via", "10.1.0.1", "dev", "ostp_tun", "metric", "10"])
|
||||||
|
.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 7. Build smoltcp network stack ────────────────────────────────────────
|
||||||
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
||||||
.stack_buffer_size(100000)
|
.stack_buffer_size(100_000)
|
||||||
.tcp_buffer_size(100000)
|
.tcp_buffer_size(100_000)
|
||||||
.udp_buffer_size(100000)
|
.udp_buffer_size(100_000)
|
||||||
.enable_tcp(true)
|
.enable_tcp(true)
|
||||||
.enable_udp(true)
|
.enable_udp(true)
|
||||||
.mtu(config.ostp.mtu)
|
.mtu(config.ostp.mtu)
|
||||||
|
|
@ -155,6 +283,7 @@ pub async fn run_native_tunnel(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 8. Wire TUN ↔ smoltcp stack ───────────────────────────────────────────
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
let (mut stack_sink, mut stack_stream) = stack.split();
|
||||||
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
||||||
|
|
||||||
|
|
@ -172,8 +301,7 @@ pub async fn run_native_tunnel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("tun_read error: {}", e);
|
tracing::debug!("tun_read error: {e}");
|
||||||
// continue reading
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,36 +310,193 @@ pub async fn run_native_tunnel(
|
||||||
let mut stack_to_tun = tokio::spawn(async move {
|
let mut stack_to_tun = tokio::spawn(async move {
|
||||||
while let Some(Ok(frame)) = stack_stream.next().await {
|
while let Some(Ok(frame)) = stack_stream.next().await {
|
||||||
if let Err(e) = tun_write.write(&frame).await {
|
if let Err(e) = tun_write.write(&frame).await {
|
||||||
tracing::debug!("tun_write error: {}", e);
|
tracing::debug!("tun_write error: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let udp_proxy_addr = config.local_proxy.bind_addr.clone();
|
// ── 9. UDP: forward everything through OSTP proxy ─────────────────────────
|
||||||
let debug_udp = config.debug;
|
// UDP exclusions are handled at the routing table level (step 5), so
|
||||||
|
// UDP packets for excluded IPs never reach smoltcp at all.
|
||||||
|
let udp_proxy_addr = {
|
||||||
|
let mut a = config.local_proxy.bind_addr.clone();
|
||||||
|
if a.starts_with("0.0.0.0:") {
|
||||||
|
a = a.replace("0.0.0.0:", "127.0.0.1:");
|
||||||
|
}
|
||||||
|
a
|
||||||
|
};
|
||||||
|
let debug_udp = debug;
|
||||||
let mut udp_proxy_task = tokio::spawn(async move {
|
let mut udp_proxy_task = tokio::spawn(async move {
|
||||||
if let Some(udp_sock) = udp_socket {
|
if let Some(udp_sock) = udp_socket {
|
||||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
|
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let proxy_addr = config.local_proxy.bind_addr.clone();
|
// ── 10. TCP: forward to OSTP proxy (with domain-level bypass via SNI) ─────
|
||||||
let mut tcp_accept_task = tokio::spawn(async move {
|
//
|
||||||
if let Some(mut listener) = tcp_listener {
|
// For IP-based exclusions: handled by routing table → packets never arrive here.
|
||||||
while let Some((mut stream, _local, remote)) = listener.next().await {
|
// For domain-based exclusions: The IP is already in routing table (pre-resolved in
|
||||||
let proxy_addr = proxy_addr.clone();
|
// step 3), so most traffic won't arrive. As a belt-and-suspenders fallback,
|
||||||
tokio::spawn(async move {
|
// we also sniff TLS SNI and bypass if it matches — this covers CDN cases where
|
||||||
if debug { tracing::info!("Native TUN intercepted TCP to {}", remote); }
|
// the IP wasn't known at startup.
|
||||||
if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await {
|
//
|
||||||
// SOCKS5 bypass handshake locally (loopback)
|
// For bypassed connections we bind the outgoing socket to the physical interface
|
||||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
// (IP_UNICAST_IF) so it goes out via the real NIC, not TUN.
|
||||||
let mut buf = [0u8; 2];
|
|
||||||
if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; }
|
|
||||||
|
|
||||||
let ip = remote.ip();
|
let proxy_addr_tcp = {
|
||||||
let port = remote.port();
|
let mut a = config.local_proxy.bind_addr.clone();
|
||||||
let mut req = vec![5, 1, 0];
|
if a.starts_with("0.0.0.0:") {
|
||||||
match ip {
|
a = a.replace("0.0.0.0:", "127.0.0.1:");
|
||||||
|
}
|
||||||
|
a
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build exclusion matcher for SNI-based domain bypass (fallback / CDN handling)
|
||||||
|
let current_exclusions = exclusions_rx.borrow().clone();
|
||||||
|
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None);
|
||||||
|
let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher));
|
||||||
|
|
||||||
|
let matcher_clone = matcher_arc.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(_) = exclusions_rx.changed().await {
|
||||||
|
let current = exclusions_rx.borrow().clone();
|
||||||
|
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||||
|
*matcher_clone.write().await = new_matcher;
|
||||||
|
if debug {
|
||||||
|
tracing::info!("Desktop TUN exclusions hot-reloaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Physical interface index — Some on Windows, None everywhere else
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let phys_if_for_bypass: Option<u32> = Some(phys_if);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let phys_if_for_bypass: Option<u32> = None;
|
||||||
|
|
||||||
|
// Linux: physical interface name for SO_BINDTODEVICE
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let linux_phys_name = crate::tunnel::proxy::get_linux_physical_if_name();
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let linux_phys_name: Option<String> = None;
|
||||||
|
let _ = &linux_phys_name; // suppress unused warning on Windows
|
||||||
|
|
||||||
|
let mut tcp_accept_task = tokio::spawn(async move {
|
||||||
|
let Some(mut listener) = tcp_listener else { return; };
|
||||||
|
|
||||||
|
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||||
|
let proxy_addr = proxy_addr_tcp.clone();
|
||||||
|
let matcher_arc = matcher_arc.clone();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let lin_name = linux_phys_name.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let matcher = matcher_arc.read().await.clone();
|
||||||
|
if debug {
|
||||||
|
tracing::info!("TUN TCP {local} → {remote}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sniff TLS ClientHello for SNI ─────────────────────────────
|
||||||
|
let mut sniff_buf = [0u8; 2048];
|
||||||
|
let sniff_len =
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
stream.read(&mut sniff_buf),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(n)) => n,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Decide: bypass or tunnel? ─────────────────────────────────
|
||||||
|
let mut should_bypass = false;
|
||||||
|
|
||||||
|
// 1. SNI domain check (belt-and-suspenders for CDNs / late-resolved IPs)
|
||||||
|
if sniff_len > 0 {
|
||||||
|
if let Some(sni) =
|
||||||
|
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||||
|
{
|
||||||
|
if debug {
|
||||||
|
tracing::info!("TUN SNI: {sni}");
|
||||||
|
}
|
||||||
|
if matcher.match_domain(&sni) {
|
||||||
|
if debug {
|
||||||
|
tracing::info!("TUN BYPASS (SNI domain): {sni} → {remote}");
|
||||||
|
}
|
||||||
|
should_bypass = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Destination IP CIDR check (for IPs not in routing table / IPv6)
|
||||||
|
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||||
|
if debug {
|
||||||
|
tracing::info!("TUN BYPASS (IP match): {remote}");
|
||||||
|
}
|
||||||
|
should_bypass = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bypass path: direct TCP bypassing TUN ─────────────────────
|
||||||
|
if should_bypass {
|
||||||
|
let socket = match remote {
|
||||||
|
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||||
|
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
|
||||||
|
};
|
||||||
|
let Ok(socket) = socket else { return; };
|
||||||
|
|
||||||
|
// Bind to physical interface so packets don't loop back into TUN
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(idx) = phys_if_for_bypass {
|
||||||
|
if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface(
|
||||||
|
&socket,
|
||||||
|
remote.is_ipv6(),
|
||||||
|
idx,
|
||||||
|
) {
|
||||||
|
tracing::warn!("bind_socket_to_interface failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(ref name) = lin_name {
|
||||||
|
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
socket.connect(remote),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(mut direct)) => {
|
||||||
|
if sniff_len > 0 {
|
||||||
|
if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!("Direct bypass connect to {remote} failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tunnel path: forward via local OSTP SOCKS5 proxy ──────────
|
||||||
|
let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SOCKS5 handshake (no auth)
|
||||||
|
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||||
|
let mut buf2 = [0u8; 2];
|
||||||
|
if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONNECT request
|
||||||
|
let mut req = vec![5u8, 1, 0];
|
||||||
|
match remote.ip() {
|
||||||
std::net::IpAddr::V4(v4) => {
|
std::net::IpAddr::V4(v4) => {
|
||||||
req.push(1);
|
req.push(1);
|
||||||
req.extend_from_slice(&v4.octets());
|
req.extend_from_slice(&v4.octets());
|
||||||
|
|
@ -221,17 +506,20 @@ pub async fn run_native_tunnel(
|
||||||
req.extend_from_slice(&v6.octets());
|
req.extend_from_slice(&v6.octets());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.extend_from_slice(&port.to_be_bytes());
|
req.extend_from_slice(&remote.port().to_be_bytes());
|
||||||
if socks.write_all(&req).await.is_err() { return; }
|
if socks.write_all(&req).await.is_err() { return; }
|
||||||
|
|
||||||
let mut rep = [0u8; 10];
|
let mut rep = [0u8; 10];
|
||||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||||
|
|
||||||
|
// Replay sniffed bytes
|
||||||
|
if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("NATIVE TUN tunnel active.");
|
tracing::info!("NATIVE TUN tunnel active.");
|
||||||
|
|
@ -246,45 +534,81 @@ pub async fn run_native_tunnel(
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Deactivating NATIVE TUN tunnel...");
|
tracing::info!("Deactivating NATIVE TUN tunnel...");
|
||||||
// Cleanup routes
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
let cleanup_script = format!(
|
|
||||||
"$remote_ip = '{}'\n\
|
// Remove all bypass /32 host routes we added
|
||||||
Remove-NetRoute -DestinationPrefix \"$remote_ip/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\
|
super::windows_route::sys::remove_bypass_routes(&bypass_routes);
|
||||||
Remove-NetFirewallRule -DisplayName 'OSTP Tunnel*' -ErrorAction SilentlyContinue\n\
|
tracing::info!("Removed {} bypass routes.", bypass_routes.len());
|
||||||
netsh interface ipv4 set dnsservers name=\"ostp_tun\" source=dhcp 2>$null\n",
|
|
||||||
server_ip_str
|
let is_kill_switch = config.kill_switch;
|
||||||
);
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
let _ = Command::new("powershell")
|
let _ = Command::new("netsh")
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.args(["-NoProfile", "-Command", &cleanup_script])
|
.args(["advfirewall", "firewall", "delete", "rule", "name=OSTP Tunnel In"])
|
||||||
.output();
|
.output();
|
||||||
|
let _ = Command::new("netsh")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["advfirewall", "firewall", "delete", "rule", "name=OSTP Tunnel Out"])
|
||||||
|
.output();
|
||||||
|
let _ = Command::new("netsh")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["interface", "ipv4", "set", "dnsservers",
|
||||||
|
"name=ostp_tun", "source=dhcp"])
|
||||||
|
.output();
|
||||||
|
if is_kill_switch {
|
||||||
|
let _ = Command::new("route")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["delete", "0.0.0.0", "mask", "0.0.0.0", "127.0.0.1"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
// Remove default route via TUN and server exclusion route
|
|
||||||
let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output();
|
let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output();
|
||||||
let _ = Command::new("ip").args(["route", "del", &format!("{}/32", server_ip_str)]).output();
|
let _ = Command::new("ip")
|
||||||
|
.args(["route", "del", &format!("{}/32", server_ip_str)])
|
||||||
|
.output();
|
||||||
|
for ip_str in &config.exclusions.ips {
|
||||||
|
let host = ip_str.split('/').next().unwrap_or(ip_str);
|
||||||
|
let route = if ip_str.contains('/') {
|
||||||
|
ip_str.as_str().to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/32", host)
|
||||||
|
};
|
||||||
|
let _ = Command::new("ip").args(["route", "del", &route]).output();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Stub for unsupported platforms
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
pub async fn run_native_tunnel(
|
pub async fn run_native_tunnel(
|
||||||
_config: crate::config::ClientConfig,
|
_config: crate::config::ClientConfig,
|
||||||
_shutdown: watch::Receiver<bool>,
|
_shutdown: watch::Receiver<bool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux currently"))
|
Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Android: TUN from file-descriptor (opened by VpnService)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub async fn run_native_tunnel_from_fd(
|
pub async fn run_native_tunnel_from_fd(
|
||||||
config: crate::config::ClientConfig,
|
config: crate::config::ClientConfig,
|
||||||
mut shutdown: watch::Receiver<bool>,
|
mut shutdown: watch::Receiver<bool>,
|
||||||
|
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||||
fd: i32,
|
fd: i32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use netstack_smoltcp::StackBuilder;
|
use netstack_smoltcp::StackBuilder;
|
||||||
|
|
@ -304,16 +628,16 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
|
|
||||||
let read_fd = unsafe { libc::dup(fd) };
|
let read_fd = unsafe { libc::dup(fd) };
|
||||||
if read_fd < 0 {
|
if read_fd < 0 {
|
||||||
return Err(anyhow::anyhow!("Failed to dup tun fd for reading"));
|
return Err(anyhow!("Failed to dup tun fd for reading"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
|
let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
|
||||||
let tun_stream = tokio::io::unix::AsyncFd::new(file)?;
|
let tun_stream = tokio::io::unix::AsyncFd::new(file)?;
|
||||||
|
|
||||||
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
||||||
.stack_buffer_size(100000)
|
.stack_buffer_size(100_000)
|
||||||
.tcp_buffer_size(100000)
|
.tcp_buffer_size(100_000)
|
||||||
.udp_buffer_size(100000)
|
.udp_buffer_size(100_000)
|
||||||
.enable_tcp(true)
|
.enable_tcp(true)
|
||||||
.enable_udp(true)
|
.enable_udp(true)
|
||||||
.mtu(config.ostp.mtu)
|
.mtu(config.ostp.mtu)
|
||||||
|
|
@ -334,25 +658,29 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
Ok(g) => g,
|
Ok(g) => g,
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
};
|
};
|
||||||
|
|
||||||
let n = match guard.try_io(|inner| {
|
let n = match guard.try_io(|inner| {
|
||||||
let res = unsafe { libc::read(inner.as_raw_fd(), buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
let res = unsafe {
|
||||||
|
libc::read(
|
||||||
|
inner.as_raw_fd(),
|
||||||
|
buf.as_mut_ptr() as *mut libc::c_void,
|
||||||
|
buf.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
if res < 0 {
|
if res < 0 {
|
||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||||
Err(err)
|
Err(err)
|
||||||
} else {
|
} else {
|
||||||
// EINTR or other transient error — treat as zero (will continue)
|
|
||||||
Ok(0_isize)
|
Ok(0_isize)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Ok(res as isize)
|
Ok(res)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Ok(Ok(n)) if n > 0 => n as usize,
|
Ok(Ok(n)) if n > 0 => n as usize,
|
||||||
Ok(Ok(_)) => continue, // 0 = EINTR or transient error, try again
|
Ok(Ok(_)) => continue,
|
||||||
Ok(Err(_)) => continue, // WouldBlock retry
|
Ok(Err(_)) => continue,
|
||||||
Err(_would_block) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame = buf[..n].to_vec();
|
let frame = buf[..n].to_vec();
|
||||||
|
|
@ -366,7 +694,7 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
|
|
||||||
let write_fd = unsafe { libc::dup(fd) };
|
let write_fd = unsafe { libc::dup(fd) };
|
||||||
if write_fd < 0 {
|
if write_fd < 0 {
|
||||||
return Err(anyhow!("Failed to dup tun fd"));
|
return Err(anyhow!("Failed to dup tun fd for writing"));
|
||||||
}
|
}
|
||||||
unsafe {
|
unsafe {
|
||||||
let flags = libc::fcntl(write_fd, libc::F_GETFL);
|
let flags = libc::fcntl(write_fd, libc::F_GETFL);
|
||||||
|
|
@ -385,9 +713,14 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
Ok(g) => g,
|
Ok(g) => g,
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = guard.try_io(|inner| {
|
let res = guard.try_io(|inner| {
|
||||||
let res = unsafe { libc::write(inner.as_raw_fd(), frame[written..].as_ptr() as *const libc::c_void, frame.len() - written) };
|
let res = unsafe {
|
||||||
|
libc::write(
|
||||||
|
inner.as_raw_fd(),
|
||||||
|
frame[written..].as_ptr() as *const libc::c_void,
|
||||||
|
frame.len() - written,
|
||||||
|
)
|
||||||
|
};
|
||||||
if res < 0 {
|
if res < 0 {
|
||||||
let err = std::io::Error::last_os_error();
|
let err = std::io::Error::last_os_error();
|
||||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||||
|
|
@ -399,11 +732,9 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(Ok(n)) if n > 0 => written += n as usize,
|
Ok(Ok(n)) if n > 0 => written += n as usize,
|
||||||
Ok(Ok(n)) if n == 0 => break,
|
Ok(Ok(_)) => break,
|
||||||
Ok(Ok(_)) => break, // n < 0, error writing, drop this frame
|
|
||||||
Ok(Err(_)) => break,
|
Ok(Err(_)) => break,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
}
|
}
|
||||||
|
|
@ -411,87 +742,139 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let mut proxy_addr = config.local_proxy.bind_addr.clone();
|
let mut proxy_addr = config.local_proxy.bind_addr.clone();
|
||||||
if proxy_addr.starts_with("0.0.0.0:") {
|
if proxy_addr.starts_with("0.0.0.0:") {
|
||||||
proxy_addr = proxy_addr.replace("0.0.0.0:", "127.0.0.1:");
|
proxy_addr = proxy_addr.replace("0.0.0.0:", "127.0.0.1:");
|
||||||
}
|
}
|
||||||
|
|
||||||
let udp_proxy_addr = proxy_addr.clone();
|
let udp_proxy_addr = proxy_addr.clone();
|
||||||
let debug_udp = config.debug;
|
let debug_udp = debug;
|
||||||
let mut udp_proxy_task = tokio::spawn(async move {
|
let mut udp_proxy_task = tokio::spawn(async move {
|
||||||
if let Some(udp_sock) = udp_socket {
|
if let Some(udp_sock) = udp_socket {
|
||||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
|
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(&config.exclusions, None, None);
|
let current_exclusions = exclusions_rx.borrow().clone();
|
||||||
|
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None);
|
||||||
|
let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher));
|
||||||
|
|
||||||
|
let matcher_clone = matcher_arc.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(_) = exclusions_rx.changed().await {
|
||||||
|
let current = exclusions_rx.borrow().clone();
|
||||||
|
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||||
|
*matcher_clone.write().await = new_matcher;
|
||||||
|
if debug {
|
||||||
|
tracing::info!("Android TUN exclusions hot-reloaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mut tcp_accept_task = tokio::spawn(async move {
|
let mut tcp_accept_task = tokio::spawn(async move {
|
||||||
if let Some(mut listener) = tcp_listener {
|
let Some(mut listener) = tcp_listener else { return; };
|
||||||
|
|
||||||
while let Some((mut stream, local, remote)) = listener.next().await {
|
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||||
let proxy_addr = proxy_addr.clone();
|
let proxy_addr = proxy_addr.clone();
|
||||||
let matcher = matcher.clone();
|
let matcher_arc = matcher_arc.clone();
|
||||||
tokio::spawn(async move {
|
|
||||||
if debug { tracing::info!("Native TUN intercepted TCP {local} -> {remote}"); }
|
|
||||||
|
|
||||||
// Peak first chunk to see SNI
|
tokio::spawn(async move {
|
||||||
let mut sniff_buf = [0u8; 1500];
|
let matcher = matcher_arc.read().await.clone();
|
||||||
let sniff_len = match tokio::time::timeout(std::time::Duration::from_millis(50), stream.read(&mut sniff_buf)).await {
|
|
||||||
|
if debug {
|
||||||
|
tracing::info!("Android TUN TCP {local} → {remote}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sniff SNI
|
||||||
|
let mut sniff_buf = [0u8; 2048];
|
||||||
|
let sniff_len =
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
stream.read(&mut sniff_buf),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Ok(n)) => n,
|
Ok(Ok(n)) => n,
|
||||||
_ => 0, // Timeout or error
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut should_bypass = false;
|
let mut should_bypass = false;
|
||||||
|
|
||||||
// 1. Check SNI
|
// 1. SNI domain
|
||||||
if sniff_len > 0 {
|
if sniff_len > 0 {
|
||||||
if let Some(sni) = crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len]) {
|
if let Some(sni) =
|
||||||
if debug { tracing::info!("Native TUN sniffed SNI: {}", sni); }
|
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||||
|
{
|
||||||
|
if debug { tracing::info!("Android TUN SNI: {sni}"); }
|
||||||
if matcher.match_domain(&sni) {
|
if matcher.match_domain(&sni) {
|
||||||
should_bypass = true;
|
should_bypass = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Process
|
// 2. Process (Android: /proc/net lookup)
|
||||||
if !should_bypass {
|
if !should_bypass {
|
||||||
if let Some(exe) = crate::tunnel::process_lookup::get_process_name_from_port(local.port()) {
|
if let Some(exe) =
|
||||||
if debug { tracing::info!("Native TUN source port {} maps to EXE: {}", local.port(), exe); }
|
crate::tunnel::process_lookup::get_process_name_from_port(local.port())
|
||||||
|
{
|
||||||
|
if debug {
|
||||||
|
tracing::info!("Android TUN port {} → EXE: {}", local.port(), exe);
|
||||||
|
}
|
||||||
if matcher.match_process(&exe) {
|
if matcher.match_process(&exe) {
|
||||||
should_bypass = true;
|
should_bypass = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check Target IP
|
// 3. IP CIDR
|
||||||
if !should_bypass {
|
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||||
if matcher.match_ip(&remote.ip()) {
|
|
||||||
should_bypass = true;
|
should_bypass = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// Bypass: connect directly (Android VPN service already protects the socket
|
||||||
|
// from re-entering the TUN through VpnService.protect())
|
||||||
if should_bypass {
|
if should_bypass {
|
||||||
if debug { tracing::info!("Native TUN BYPASS matched for {}", remote); }
|
if debug {
|
||||||
if let Ok(mut direct) = tokio::time::timeout(std::time::Duration::from_secs(5), tokio::net::TcpStream::connect(remote)).await.unwrap_or(Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "Direct connect timeout"))) {
|
tracing::info!("Android TUN BYPASS: {remote}");
|
||||||
|
}
|
||||||
|
let socket = match remote {
|
||||||
|
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||||
|
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
|
||||||
|
};
|
||||||
|
let Ok(socket) = socket else { return; };
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
socket.connect(remote),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(mut direct)) => {
|
||||||
if sniff_len > 0 {
|
if sniff_len > 0 {
|
||||||
let _ = direct.write_all(&sniff_buf[..sniff_len]).await;
|
if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!("Android bypass connect to {remote} failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await {
|
// Tunnel via SOCKS5 proxy
|
||||||
|
let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||||
let mut buf = [0u8; 2];
|
let mut buf2 = [0u8; 2];
|
||||||
if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; }
|
if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 {
|
||||||
|
return;
|
||||||
let ip = remote.ip();
|
}
|
||||||
let port = remote.port();
|
let mut req = vec![5u8, 1, 0];
|
||||||
let mut req = vec![5, 1, 0];
|
match remote.ip() {
|
||||||
match ip {
|
|
||||||
std::net::IpAddr::V4(v4) => {
|
std::net::IpAddr::V4(v4) => {
|
||||||
req.push(1);
|
req.push(1);
|
||||||
req.extend_from_slice(&v4.octets());
|
req.extend_from_slice(&v4.octets());
|
||||||
|
|
@ -501,22 +884,16 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
req.extend_from_slice(&v6.octets());
|
req.extend_from_slice(&v6.octets());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req.extend_from_slice(&port.to_be_bytes());
|
req.extend_from_slice(&remote.port().to_be_bytes());
|
||||||
if socks.write_all(&req).await.is_err() { return; }
|
if socks.write_all(&req).await.is_err() { return; }
|
||||||
|
|
||||||
let mut rep = [0u8; 10];
|
let mut rep = [0u8; 10];
|
||||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||||
|
if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||||
// Write sniffed buffer to socks
|
return;
|
||||||
if sniff_len > 0 {
|
|
||||||
if socks.write_all(&sniff_buf[..sniff_len]).await.is_err() { return; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("NATIVE TUN (Android) tunnel active.");
|
tracing::info!("NATIVE TUN (Android) tunnel active.");
|
||||||
|
|
@ -530,7 +907,7 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
_ = &mut tcp_accept_task => {}
|
_ = &mut tcp_accept_task => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Deactivating NATIVE TUN tunnel...");
|
tracing::info!("NATIVE TUN (Android) deactivated.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -542,4 +919,3 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Err(anyhow!("Native TUN from FD is only supported on Android"))
|
Err(anyhow!("Native TUN from FD is only supported on Android"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||||
use winapi::shared::minwindef::{DWORD, ULONG};
|
use winapi::shared::minwindef::ULONG;
|
||||||
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
||||||
use winapi::um::iphlpapi::GetExtendedTcpTable;
|
use winapi::um::iphlpapi::GetExtendedTcpTable;
|
||||||
use winapi::shared::tcpmib::{MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID};
|
use winapi::shared::tcpmib::{MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID};
|
||||||
|
|
@ -47,6 +47,54 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
|
||||||
|
use winapi::shared::minwindef::ULONG;
|
||||||
|
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
||||||
|
use winapi::um::iphlpapi::GetExtendedUdpTable;
|
||||||
|
use winapi::shared::udpmib::{MIB_UDPTABLE_OWNER_PID, MIB_UDPROW_OWNER_PID};
|
||||||
|
|
||||||
|
let mut size: ULONG = 0;
|
||||||
|
let table_class = 1; // UDP_TABLE_OWNER_PID
|
||||||
|
let mut table = vec![0u8; 1024];
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut ret = GetExtendedUdpTable(
|
||||||
|
table.as_mut_ptr() as *mut _,
|
||||||
|
&mut size,
|
||||||
|
0,
|
||||||
|
2, // AF_INET
|
||||||
|
table_class,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ret == ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
table.resize(size as usize, 0);
|
||||||
|
ret = GetExtendedUdpTable(
|
||||||
|
table.as_mut_ptr() as *mut _,
|
||||||
|
&mut size,
|
||||||
|
0,
|
||||||
|
2, // AF_INET
|
||||||
|
table_class,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ret == 0 {
|
||||||
|
let udp_table = &*(table.as_ptr() as *const MIB_UDPTABLE_OWNER_PID);
|
||||||
|
let row_ptr = &udp_table.table[0] as *const MIB_UDPROW_OWNER_PID;
|
||||||
|
for i in 0..udp_table.dwNumEntries {
|
||||||
|
let row = &*row_ptr.add(i as usize);
|
||||||
|
let local_port = u16::from_be(row.dwLocalPort as u16);
|
||||||
|
if local_port == port {
|
||||||
|
return get_process_name_from_pid(row.dwOwningPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn get_process_name_from_pid(pid: u32) -> Option<String> {
|
fn get_process_name_from_pid(pid: u32) -> Option<String> {
|
||||||
use winapi::um::processthreadsapi::OpenProcess;
|
use winapi::um::processthreadsapi::OpenProcess;
|
||||||
|
|
@ -140,3 +188,8 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||||
pub fn get_process_name_from_port(_port: u16) -> Option<String> {
|
pub fn get_process_name_from_port(_port: u16) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
|
||||||
|
get_process_name_from_port(port)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::tunnel::exclusion::{ExclusionMatcher, Cidr};
|
use crate::tunnel::exclusion::ExclusionMatcher;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
||||||
|
|
@ -29,7 +29,7 @@ extern "system" {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
|
pub fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
|
||||||
let s = socket.as_raw_socket() as usize;
|
let s = socket.as_raw_socket() as usize;
|
||||||
if is_ipv6 {
|
if is_ipv6 {
|
||||||
let optval = if_index;
|
let optval = if_index;
|
||||||
|
|
@ -64,7 +64,7 @@ fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index:
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> {
|
pub fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> {
|
||||||
let fd = socket.as_raw_fd();
|
let fd = socket.as_raw_fd();
|
||||||
let mut if_name_bytes = if_name.as_bytes().to_vec();
|
let mut if_name_bytes = if_name.as_bytes().to_vec();
|
||||||
if_name_bytes.push(0);
|
if_name_bytes.push(0);
|
||||||
|
|
@ -83,31 +83,18 @@ fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Re
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_windows_physical_if_index() -> Option<u32> {
|
pub fn get_windows_physical_if_index() -> Option<u32> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
return super::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
let output = std::process::Command::new("powershell")
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.args([
|
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object { $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' } | Sort-Object RouteMetric | Select-Object -ExpandProperty InterfaceIndex -First 1"
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if output.status.success() {
|
|
||||||
let s = String::from_utf8_lossy(&output.stdout);
|
|
||||||
if let Ok(index) = s.trim().parse::<u32>() {
|
|
||||||
return Some(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_linux_physical_if_name() -> Option<String> {
|
pub fn get_linux_physical_if_name() -> Option<String> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
let output = std::process::Command::new("ip")
|
let output = std::process::Command::new("ip")
|
||||||
|
|
@ -208,7 +195,7 @@ async fn create_udp_socket_bypassing_tun(
|
||||||
pub async fn run_local_socks5_proxy(
|
pub async fn run_local_socks5_proxy(
|
||||||
cfg: LocalProxyConfig,
|
cfg: LocalProxyConfig,
|
||||||
ostp: OstpConfig,
|
ostp: OstpConfig,
|
||||||
exclusions: ExclusionConfig,
|
mut exclusions_rx: watch::Receiver<ExclusionConfig>,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
mut shutdown: watch::Receiver<bool>,
|
mut shutdown: watch::Receiver<bool>,
|
||||||
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
||||||
|
|
@ -234,7 +221,8 @@ pub async fn run_local_socks5_proxy(
|
||||||
tracing::info!("Local proxy physical interface name: {:?}", physical_if_name);
|
tracing::info!("Local proxy physical interface name: {:?}", physical_if_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let matcher = ExclusionMatcher::new(&exclusions, physical_if_index, physical_if_name.clone());
|
let mut current_exclusions = exclusions_rx.borrow().clone();
|
||||||
|
let mut matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone());
|
||||||
let (connect_tx, mut connect_rx) = mpsc::channel(128);
|
let (connect_tx, mut connect_rx) = mpsc::channel(128);
|
||||||
let max_chunk = ostp.mtu.saturating_sub(150).max(512);
|
let max_chunk = ostp.mtu.saturating_sub(150).max(512);
|
||||||
|
|
||||||
|
|
@ -248,6 +236,13 @@ pub async fn run_local_socks5_proxy(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(_) = exclusions_rx.changed() => {
|
||||||
|
current_exclusions = exclusions_rx.borrow().clone();
|
||||||
|
matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone());
|
||||||
|
if debug {
|
||||||
|
tracing::info!("Local proxy exclusions hot-reloaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
accepted = listener.accept() => {
|
accepted = listener.accept() => {
|
||||||
let (socket, _) = accepted?;
|
let (socket, _) = accepted?;
|
||||||
let stream_id = next_stream_id;
|
let stream_id = next_stream_id;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
/// Windows routing table utilities for OSTP split tunneling.
|
||||||
|
///
|
||||||
|
/// The approach used here matches how sing-box/v2rayN implement split tunneling on Windows:
|
||||||
|
/// - A high-priority default route (metric=1) via ostp_tun captures ALL traffic.
|
||||||
|
/// - Per-host /32 routes via the REAL gateway with an even lower metric (=0, auto-managed by OS)
|
||||||
|
/// force excluded IPs to bypass the TUN.
|
||||||
|
/// - Process-based exclusions are NOT supported via pure routing — they would require WFP.
|
||||||
|
/// Instead, we surface a diagnostic warning in logs.
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub mod sys {
|
||||||
|
use std::mem;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
use winapi::shared::ipmib::{MIB_IPFORWARDROW, MIB_IPFORWARDTABLE};
|
||||||
|
use winapi::shared::minwindef::{DWORD, ULONG};
|
||||||
|
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
||||||
|
use winapi::um::iphlpapi::{
|
||||||
|
CreateIpForwardEntry, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
|
||||||
|
};
|
||||||
|
use winapi::um::iptypes::{
|
||||||
|
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
|
||||||
|
};
|
||||||
|
use winapi::shared::ws2def::AF_INET;
|
||||||
|
|
||||||
|
fn ipv4_to_dword(ip: Ipv4Addr) -> DWORD {
|
||||||
|
u32::from_ne_bytes(ip.octets())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dword_to_ipv4(dw: DWORD) -> Ipv4Addr {
|
||||||
|
Ipv4Addr::from(dw.to_ne_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the (gateway_ip, interface_index) of the physical default IPv4 route,
|
||||||
|
/// excluding any route that goes through an interface named "ostp_tun".
|
||||||
|
pub fn get_default_ipv4_route() -> Option<(Ipv4Addr, u32)> {
|
||||||
|
// Enumerate adapters to find the ostp_tun interface index, so we can skip it.
|
||||||
|
let tun_index = get_interface_index("ostp_tun");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut size: ULONG = 0;
|
||||||
|
let mut ret = GetIpForwardTable(ptr::null_mut(), &mut size, 0);
|
||||||
|
if ret != ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf: Vec<u8> = vec![0; size as usize];
|
||||||
|
let table = buf.as_mut_ptr() as *mut MIB_IPFORWARDTABLE;
|
||||||
|
|
||||||
|
ret = GetIpForwardTable(table, &mut size, 0);
|
||||||
|
if ret != NO_ERROR {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = std::slice::from_raw_parts((*table).table.as_ptr(), (*table).dwNumEntries as usize);
|
||||||
|
|
||||||
|
let mut best_gw = None;
|
||||||
|
let mut best_metric = u32::MAX;
|
||||||
|
let mut best_ifindex = 0u32;
|
||||||
|
|
||||||
|
for row in entries {
|
||||||
|
// Only consider default routes (0.0.0.0/0)
|
||||||
|
if row.dwForwardDest == 0 && row.dwForwardMask == 0 {
|
||||||
|
// Skip the TUN interface
|
||||||
|
if let Some(ti) = tun_index {
|
||||||
|
if row.dwForwardIfIndex == ti {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let metric = row.dwForwardMetric1;
|
||||||
|
if metric < best_metric {
|
||||||
|
best_metric = metric;
|
||||||
|
best_gw = Some(dword_to_ipv4(row.dwForwardNextHop));
|
||||||
|
best_ifindex = row.dwForwardIfIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_gw.map(|gw| (gw, best_ifindex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_ipv4_route(
|
||||||
|
dest: Ipv4Addr,
|
||||||
|
mask: Ipv4Addr,
|
||||||
|
nexthop: Ipv4Addr,
|
||||||
|
if_index: u32,
|
||||||
|
metric: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() };
|
||||||
|
row.dwForwardDest = ipv4_to_dword(dest);
|
||||||
|
row.dwForwardMask = ipv4_to_dword(mask);
|
||||||
|
row.dwForwardNextHop = ipv4_to_dword(nexthop);
|
||||||
|
row.dwForwardIfIndex = if_index;
|
||||||
|
row.ForwardType = if nexthop == Ipv4Addr::UNSPECIFIED || dest == nexthop { 3 } else { 4 };
|
||||||
|
row.ForwardProto = 3; // MIB_IPPROTO_NETMGMT
|
||||||
|
row.dwForwardMetric1 = metric;
|
||||||
|
|
||||||
|
let ret = unsafe { CreateIpForwardEntry(&mut row) };
|
||||||
|
if ret == NO_ERROR {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("CreateIpForwardEntry failed: {}", ret))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_ipv4_route(
|
||||||
|
dest: Ipv4Addr,
|
||||||
|
mask: Ipv4Addr,
|
||||||
|
nexthop: Ipv4Addr,
|
||||||
|
if_index: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() };
|
||||||
|
row.dwForwardDest = ipv4_to_dword(dest);
|
||||||
|
row.dwForwardMask = ipv4_to_dword(mask);
|
||||||
|
row.dwForwardNextHop = ipv4_to_dword(nexthop);
|
||||||
|
row.dwForwardIfIndex = if_index;
|
||||||
|
|
||||||
|
let ret = unsafe { DeleteIpForwardEntry(&mut row) };
|
||||||
|
if ret == NO_ERROR || ret == 2 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("DeleteIpForwardEntry failed: {}", ret))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_interface_index(name: &str) -> Option<u32> {
|
||||||
|
unsafe {
|
||||||
|
let mut size: ULONG = 0;
|
||||||
|
let flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER;
|
||||||
|
let mut ret = GetAdaptersAddresses(
|
||||||
|
AF_INET as u32,
|
||||||
|
flags,
|
||||||
|
ptr::null_mut(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
&mut size,
|
||||||
|
);
|
||||||
|
if ret != ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf: Vec<u8> = vec![0; size as usize];
|
||||||
|
let addresses = buf.as_mut_ptr() as *mut IP_ADAPTER_ADDRESSES;
|
||||||
|
|
||||||
|
ret = GetAdaptersAddresses(AF_INET as u32, flags, ptr::null_mut(), addresses, &mut size);
|
||||||
|
if ret != NO_ERROR {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut curr = addresses;
|
||||||
|
while !curr.is_null() {
|
||||||
|
let friendly_name_ptr = (*curr).FriendlyName;
|
||||||
|
if !friendly_name_ptr.is_null() {
|
||||||
|
let mut len = 0;
|
||||||
|
while *friendly_name_ptr.offset(len) != 0 {
|
||||||
|
len += 1;
|
||||||
|
}
|
||||||
|
let slice = std::slice::from_raw_parts(friendly_name_ptr, len as usize);
|
||||||
|
let friendly_name = String::from_utf16_lossy(slice);
|
||||||
|
if friendly_name == name {
|
||||||
|
return Some((*(*curr).u.s()).IfIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curr = (*curr).Next;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add bypass routes for a list of resolved IP addresses (typically from exclusion config).
|
||||||
|
/// Each IP gets a /32 host route via the physical gateway so it bypasses the TUN.
|
||||||
|
/// Returns list of (ip, gw, if_index) that were successfully added, for later cleanup.
|
||||||
|
pub fn add_bypass_routes(
|
||||||
|
ips: &[Ipv4Addr],
|
||||||
|
gw: Ipv4Addr,
|
||||||
|
if_index: u32,
|
||||||
|
metric: u32,
|
||||||
|
) -> Vec<(Ipv4Addr, Ipv4Addr, u32)> {
|
||||||
|
let mut added = Vec::new();
|
||||||
|
for &ip in ips {
|
||||||
|
let mask = Ipv4Addr::new(255, 255, 255, 255);
|
||||||
|
match add_ipv4_route(ip, mask, gw, if_index, metric) {
|
||||||
|
Ok(()) => {
|
||||||
|
added.push((ip, gw, if_index));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// 87 = ERROR_INVALID_PARAMETER (route may already exist)
|
||||||
|
tracing::debug!("bypass route add {ip}/32 via {gw}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all bypass routes previously added by add_bypass_routes.
|
||||||
|
pub fn remove_bypass_routes(routes: &[(Ipv4Addr, Ipv4Addr, u32)]) {
|
||||||
|
for &(ip, gw, if_index) in routes {
|
||||||
|
let mask = Ipv4Addr::new(255, 255, 255, 255);
|
||||||
|
let _ = delete_ipv4_route(ip, mask, gw, if_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ android {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-keep class net.ostp.client.OstpClientSdk { *; }
|
||||||
|
-keep class com.ospab.ostp_client.OstpVpnService { *; }
|
||||||
|
-keep class com.ospab.ostp_client.MainActivity { *; }
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<application
|
<application
|
||||||
android:label="ostp_client"
|
android:label="ostp_client"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/launcher_icon"
|
||||||
android:extractNativeLibs="true">
|
android:extractNativeLibs="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
<!-- Quick Settings Tile -->
|
<!-- Quick Settings Tile -->
|
||||||
<service
|
<service
|
||||||
android:name=".OstpTileService"
|
android:name=".OstpTileService"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/launcher_icon"
|
||||||
android:label="OSTP VPN"
|
android:label="OSTP VPN"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
||||||
|
enum ConnectionStateEnum { disconnected, connecting, connected }
|
||||||
|
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
class AppRoutingScreen extends StatefulWidget {
|
||||||
|
final SharedPreferences prefs;
|
||||||
|
const AppRoutingScreen({super.key, required this.prefs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _allApps = [];
|
||||||
|
List<Map<String, dynamic>> _filteredApps = [];
|
||||||
|
Set<String> _selectedPackages = {};
|
||||||
|
String _routingMode = 'bypass';
|
||||||
|
bool _hideSystemApps = true;
|
||||||
|
bool _isLoading = true;
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
final TextEditingController _searchCtrl = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSavedConfig();
|
||||||
|
_fetchInstalledApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadSavedConfig() {
|
||||||
|
setState(() {
|
||||||
|
_routingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
|
_selectedPackages = (widget.prefs.getStringList('app_routing_packages') ?? []).toSet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchInstalledApps() async {
|
||||||
|
try {
|
||||||
|
final List<dynamic>? rawApps = await platform.invokeMethod('getInstalledApps');
|
||||||
|
if (rawApps != null) {
|
||||||
|
final List<Map<String, dynamic>> apps = rawApps.map((e) {
|
||||||
|
final Map<dynamic, dynamic> m = e as Map<dynamic, dynamic>;
|
||||||
|
return {
|
||||||
|
"name": m["name"] as String? ?? "Unknown",
|
||||||
|
"package": m["package"] as String? ?? "",
|
||||||
|
"isSystem": m["isSystem"] as bool? ?? false,
|
||||||
|
"icon": m["icon"] as String? ?? "",
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
apps.sort((a, b) => (a["name"] as String).toLowerCase().compareTo((b["name"] as String).toLowerCase()));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_allApps = apps;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
_filterApps();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error fetching apps: $e");
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterApps() {
|
||||||
|
setState(() {
|
||||||
|
_filteredApps = _allApps.where((app) {
|
||||||
|
final name = (app["name"] as String).toLowerCase();
|
||||||
|
final package = (app["package"] as String).toLowerCase();
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
|
||||||
|
final matchesSearch = name.contains(query) || package.contains(query);
|
||||||
|
final matchesSystemFilter = !_hideSystemApps || !(app["isSystem"] as bool);
|
||||||
|
|
||||||
|
return matchesSearch && matchesSystemFilter;
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveConfig() {
|
||||||
|
widget.prefs.setString('app_routing_mode', _routingMode);
|
||||||
|
widget.prefs.setStringList('app_routing_packages', _selectedPackages.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetConfig() {
|
||||||
|
setState(() {
|
||||||
|
_selectedPackages.clear();
|
||||||
|
_routingMode = 'bypass';
|
||||||
|
_hideSystemApps = true;
|
||||||
|
_searchCtrl.clear();
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
_saveConfig();
|
||||||
|
_filterApps();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('App routing rules reset successfully')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('App Routing Rules', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
tooltip: 'Reset Rules',
|
||||||
|
onPressed: _resetConfig,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: theme.colorScheme.surface.withOpacity(0.5),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_routingMode = 'bypass';
|
||||||
|
});
|
||||||
|
_saveConfig();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Bypass Mode',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_routingMode = 'proxy';
|
||||||
|
});
|
||||||
|
_saveConfig();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Proxy Mode',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_routingMode == 'bypass'
|
||||||
|
? 'Selected apps bypass the VPN (direct connection).'
|
||||||
|
: 'Only selected apps are routed through the VPN.',
|
||||||
|
style: const TextStyle(fontSize: 13, color: Colors.white54),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = val;
|
||||||
|
});
|
||||||
|
_filterApps();
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search apps...',
|
||||||
|
prefixIcon: const Icon(Icons.search_rounded, color: Colors.white54),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty ? IconButton(
|
||||||
|
icon: const Icon(Icons.clear_rounded, color: Colors.white54),
|
||||||
|
onPressed: () {
|
||||||
|
_searchCtrl.clear();
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
_filterApps();
|
||||||
|
},
|
||||||
|
) : null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white.withOpacity(0.05),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_hideSystemApps = !_hideSystemApps;
|
||||||
|
});
|
||||||
|
_filterApps();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.15) : Colors.white.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: _hideSystemApps ? theme.colorScheme.primary.withOpacity(0.4) : Colors.white.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_hideSystemApps ? Icons.visibility_off_rounded : Icons.visibility_rounded,
|
||||||
|
color: _hideSystemApps ? theme.colorScheme.primary : Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _filteredApps.isEmpty
|
||||||
|
? const Center(child: Text('No applications found', style: TextStyle(color: Colors.white54)))
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: _filteredApps.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final app = _filteredApps[index];
|
||||||
|
final pkg = app["package"] as String;
|
||||||
|
final name = app["name"] as String;
|
||||||
|
final isSystem = app["isSystem"] as bool;
|
||||||
|
final isSelected = _selectedPackages.contains(pkg);
|
||||||
|
final String? iconBase64 = app["icon"] as String?;
|
||||||
|
|
||||||
|
final String initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||||||
|
final int colorHash = pkg.hashCode.abs();
|
||||||
|
final double hue = (colorHash % 360).toDouble();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? (_routingMode == 'bypass'
|
||||||
|
? theme.colorScheme.primary.withOpacity(0.08)
|
||||||
|
: theme.colorScheme.secondary.withOpacity(0.08))
|
||||||
|
: Colors.white.withOpacity(0.02),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? (_routingMode == 'bypass'
|
||||||
|
? theme.colorScheme.primary.withOpacity(0.3)
|
||||||
|
: theme.colorScheme.secondary.withOpacity(0.3))
|
||||||
|
: Colors.white.withOpacity(0.05),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
leading: iconBase64 != null && iconBase64.isNotEmpty
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Image.memory(
|
||||||
|
base64Decode(iconBase64),
|
||||||
|
width: 40, height: 40,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) => Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(),
|
||||||
|
HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
HSVColor.fromAHSV(1.0, hue, 0.7, 0.8).toColor(),
|
||||||
|
HSVColor.fromAHSV(1.0, (hue + 40) % 360, 0.8, 0.9).toColor(),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSystem) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'SYS',
|
||||||
|
style: TextStyle(fontSize: 9, color: Colors.white60, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
pkg,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11, color: Colors.white38),
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: Switch(
|
||||||
|
value: isSelected,
|
||||||
|
activeColor: _routingMode == 'bypass' ? theme.colorScheme.primary : theme.colorScheme.secondary,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
if (val) {
|
||||||
|
_selectedPackages.add(pkg);
|
||||||
|
} else {
|
||||||
|
_selectedPackages.remove(pkg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_saveConfig();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,922 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import '../models/connection_state_enum.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
import 'logs_screen.dart';
|
||||||
|
import 'app_routing_screen.dart';
|
||||||
|
import 'qr_scanner_screen.dart';
|
||||||
|
|
||||||
|
class HomeScreen extends StatefulWidget {
|
||||||
|
final SharedPreferences prefs;
|
||||||
|
const HomeScreen({super.key, required this.prefs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
|
||||||
|
ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
|
||||||
|
Timer? _pollTimer;
|
||||||
|
Timer? _uptimeTimer;
|
||||||
|
int _uptimeSecs = 0;
|
||||||
|
|
||||||
|
String _serverAddr = '127.0.0.1:443';
|
||||||
|
String _accessKey = 'default_key';
|
||||||
|
|
||||||
|
String _download = '0 B';
|
||||||
|
String _upload = '0 B';
|
||||||
|
|
||||||
|
late AnimationController _pulseController;
|
||||||
|
late AnimationController _spinController;
|
||||||
|
|
||||||
|
bool _isCheckingPing = false;
|
||||||
|
String _pingText = 'Target Ping: -- ms';
|
||||||
|
Color _pingColor = Colors.white54;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
_pulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
_spinController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
);
|
||||||
|
_checkInitialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkInitialState() async {
|
||||||
|
try {
|
||||||
|
final isRunning = await platform.invokeMethod('isRunning');
|
||||||
|
if (isRunning == true && mounted) {
|
||||||
|
_setConnected();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to check initial state: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadSettings() {
|
||||||
|
setState(() {
|
||||||
|
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
|
||||||
|
_accessKey = widget.prefs.getString('access_key') ?? '';
|
||||||
|
});
|
||||||
|
_updateLatestConfigJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateLatestConfigJson() {
|
||||||
|
|
||||||
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
|
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||||
|
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
|
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||||
|
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||||
|
final stealthPort = widget.prefs.getString('stealth_port') ?? '443';
|
||||||
|
final wss = widget.prefs.getBool('wss') ?? false;
|
||||||
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
|
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||||
|
final dnsServer = widget.prefs.getString('dns_server');
|
||||||
|
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||||
|
final tunStack = 'ostp';
|
||||||
|
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
|
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||||
|
|
||||||
|
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||||
|
final configMap = {
|
||||||
|
"mode": "client",
|
||||||
|
"debug": debugMode,
|
||||||
|
"ostp": {
|
||||||
|
"server_addr": _serverAddr,
|
||||||
|
"local_bind_addr": "0.0.0.0:0",
|
||||||
|
"access_key": _accessKey,
|
||||||
|
"handshake_timeout_ms": 10000,
|
||||||
|
"io_timeout_ms": 5000,
|
||||||
|
"mtu": int.tryParse(mtu) ?? 1140,
|
||||||
|
},
|
||||||
|
"local_proxy": {
|
||||||
|
"bind_addr": localBind,
|
||||||
|
"connect_timeout_ms": 15000,
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"mode": transportMode,
|
||||||
|
"stealth_sni": stealthSni,
|
||||||
|
"stealth_port": int.tryParse(stealthPort) ?? 443,
|
||||||
|
"wss": wss,
|
||||||
|
},
|
||||||
|
"multiplex": {
|
||||||
|
"enabled": muxEnabled,
|
||||||
|
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||||
|
},
|
||||||
|
"reality": {
|
||||||
|
"enabled": widget.prefs.getBool('reality_enabled') ?? false,
|
||||||
|
"dest": "",
|
||||||
|
"private_key": "",
|
||||||
|
"pbk": widget.prefs.getString('pbk') ?? "",
|
||||||
|
"sid": widget.prefs.getString('sid') ?? "",
|
||||||
|
"sni_list": []
|
||||||
|
},
|
||||||
|
"tun": {
|
||||||
|
"enable": true,
|
||||||
|
"stack": tunStack
|
||||||
|
},
|
||||||
|
"exclusions": {
|
||||||
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
},
|
||||||
|
"app_rules": {
|
||||||
|
"mode": appRoutingMode,
|
||||||
|
"packages": appRoutingPackages,
|
||||||
|
},
|
||||||
|
"dns_server": effectiveDnsServer,
|
||||||
|
"tun_stack": tunStack
|
||||||
|
};
|
||||||
|
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||||
|
platform.invokeMethod('saveConfig', {
|
||||||
|
"configJson": jsonEncode(configMap)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_uptimeTimer?.cancel();
|
||||||
|
_pulseController.dispose();
|
||||||
|
_spinController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleConnection() async {
|
||||||
|
if (_state == ConnectionStateEnum.disconnected) {
|
||||||
|
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please configure Server and Key in Settings')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_state = ConnectionStateEnum.connecting;
|
||||||
|
});
|
||||||
|
_pulseController.repeat(reverse: true);
|
||||||
|
_spinController.repeat();
|
||||||
|
|
||||||
|
final dnsServer = widget.prefs.getString('dns_server');
|
||||||
|
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||||
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
|
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||||
|
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
|
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||||
|
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||||
|
final stealthPort = widget.prefs.getString('stealth_port') ?? '443';
|
||||||
|
final wss = widget.prefs.getBool('wss') ?? false;
|
||||||
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
|
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||||
|
final tunStack = 'ostp';
|
||||||
|
|
||||||
|
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
|
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||||
|
|
||||||
|
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||||
|
final configMap = {
|
||||||
|
"mode": "client",
|
||||||
|
"debug": debugMode,
|
||||||
|
"ostp": {
|
||||||
|
"server_addr": _serverAddr,
|
||||||
|
"local_bind_addr": "0.0.0.0:0",
|
||||||
|
"access_key": _accessKey,
|
||||||
|
"handshake_timeout_ms": 10000,
|
||||||
|
"io_timeout_ms": 5000,
|
||||||
|
"mtu": int.tryParse(mtu) ?? 1140,
|
||||||
|
},
|
||||||
|
"local_proxy": {
|
||||||
|
"bind_addr": localBind,
|
||||||
|
"connect_timeout_ms": 15000,
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"mode": transportMode,
|
||||||
|
"stealth_sni": stealthSni,
|
||||||
|
"stealth_port": int.tryParse(stealthPort) ?? 443,
|
||||||
|
"wss": wss,
|
||||||
|
},
|
||||||
|
"multiplex": {
|
||||||
|
"enabled": muxEnabled,
|
||||||
|
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||||
|
},
|
||||||
|
"reality": {
|
||||||
|
"enabled": widget.prefs.getBool('reality_enabled') ?? false,
|
||||||
|
"dest": "",
|
||||||
|
"private_key": "",
|
||||||
|
"pbk": widget.prefs.getString('pbk') ?? "",
|
||||||
|
"sid": widget.prefs.getString('sid') ?? "",
|
||||||
|
"sni_list": []
|
||||||
|
},
|
||||||
|
"tun": {
|
||||||
|
"enable": true,
|
||||||
|
"stack": tunStack
|
||||||
|
},
|
||||||
|
"exclusions": {
|
||||||
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
|
},
|
||||||
|
"app_rules": {
|
||||||
|
"mode": appRoutingMode,
|
||||||
|
"packages": appRoutingPackages,
|
||||||
|
},
|
||||||
|
"dns_server": dnsServer,
|
||||||
|
"tun_stack": tunStack
|
||||||
|
};
|
||||||
|
|
||||||
|
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
await platform.invokeMethod('saveConfig', {
|
||||||
|
"configJson": jsonEncode(configMap)
|
||||||
|
});
|
||||||
|
await platform.invokeMethod('startTunnel', {
|
||||||
|
"configJson": jsonEncode(configMap)
|
||||||
|
});
|
||||||
|
|
||||||
|
bool started = false;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
final isRunning = await platform.invokeMethod('isRunning');
|
||||||
|
if (isRunning == true) {
|
||||||
|
started = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (started) {
|
||||||
|
_setConnected();
|
||||||
|
} else {
|
||||||
|
_setDisconnected();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Failed to connect. Check logs for details.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint("Failed to start tunnel: $e\n$stackTrace");
|
||||||
|
_setDisconnected();
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Error', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: e.toString()));
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!')));
|
||||||
|
},
|
||||||
|
child: const Text('Copy'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await platform.invokeMethod('stopTunnel');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Stop error: $e");
|
||||||
|
}
|
||||||
|
_setDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runAutoMode() async {
|
||||||
|
final mtus = [1500, 1350, 1280, 1140];
|
||||||
|
final modes = [
|
||||||
|
{'t': 'udp', 'w': false, 'r': false},
|
||||||
|
{'t': 'uot', 'w': false, 'r': false},
|
||||||
|
{'t': 'uot', 'w': true, 'r': false},
|
||||||
|
{'t': 'uot', 'w': false, 'r': true},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please configure Server and Key first')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var mode in modes) {
|
||||||
|
for (var mtu in mtus) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update prefs
|
||||||
|
await widget.prefs.setString('mtu', mtu.toString());
|
||||||
|
await widget.prefs.setString('transport_mode', mode['t'] as String);
|
||||||
|
await widget.prefs.setBool('wss', mode['w'] as bool);
|
||||||
|
await widget.prefs.setBool('reality_enabled', mode['r'] as bool);
|
||||||
|
_updateLatestConfigJson();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_state = ConnectionStateEnum.connecting;
|
||||||
|
});
|
||||||
|
_pulseController.repeat(reverse: true);
|
||||||
|
_spinController.repeat();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final configJson = widget.prefs.getString('latest_config_json') ?? '{}';
|
||||||
|
await platform.invokeMethod('startTunnel', {"configJson": configJson});
|
||||||
|
|
||||||
|
bool started = false;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
final isRunning = await platform.invokeMethod('isRunning');
|
||||||
|
if (isRunning == true) {
|
||||||
|
started = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (started) {
|
||||||
|
_setConnected();
|
||||||
|
// Wait to see if connection is stable and ping is successful
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
try {
|
||||||
|
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||||
|
if (metricsJson != null && metricsJson.isNotEmpty) {
|
||||||
|
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
||||||
|
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||||
|
if (rttMs > 0) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return; // Stop on first working config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore metrics error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection seems unstable or no ping, stop and try next
|
||||||
|
await platform.invokeMethod('stopTunnel');
|
||||||
|
_setDisconnected();
|
||||||
|
} else {
|
||||||
|
_setDisconnected();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_setDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Auto search finished. No working config found.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setConnected() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_state = ConnectionStateEnum.connected;
|
||||||
|
});
|
||||||
|
_pulseController.stop();
|
||||||
|
_pulseController.value = 1.0;
|
||||||
|
|
||||||
|
_uptimeSecs = 0;
|
||||||
|
_uptimeTimer?.cancel();
|
||||||
|
_uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _uptimeSecs++);
|
||||||
|
});
|
||||||
|
|
||||||
|
_startPollingMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPollingMetrics() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
try {
|
||||||
|
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||||
|
if (metricsJson != null && metricsJson.isNotEmpty) {
|
||||||
|
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
||||||
|
final bytesSent = parsed['bytes_sent'] as int? ?? 0;
|
||||||
|
final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
|
||||||
|
final connState = parsed['connection_state'] as int? ?? 2;
|
||||||
|
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||||
|
|
||||||
|
if (connState == 0 && _state != ConnectionStateEnum.disconnected) {
|
||||||
|
try {
|
||||||
|
await platform.invokeMethod('stopTunnel');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to stop background tunnel: $e");
|
||||||
|
}
|
||||||
|
_setDisconnected();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Connection failed. Check logs for details.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_download = _formatBytes(bytesRecv);
|
||||||
|
_upload = _formatBytes(bytesSent);
|
||||||
|
if (rttMs > 0 && !_isCheckingPing) {
|
||||||
|
_pingText = 'Server Ping: $rttMs ms';
|
||||||
|
if (rttMs < 100) {
|
||||||
|
_pingColor = const Color(0xFF22D3A5);
|
||||||
|
} else if (rttMs < 250) {
|
||||||
|
_pingColor = Colors.amberAccent;
|
||||||
|
} else {
|
||||||
|
_pingColor = Colors.redAccent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to get metrics: $e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkConnectionLatency() async {
|
||||||
|
if (_state != ConnectionStateEnum.connected) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isCheckingPing = true;
|
||||||
|
_pingText = 'Updating...';
|
||||||
|
_pingColor = Colors.white70;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isCheckingPing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setDisconnected() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_state = ConnectionStateEnum.disconnected;
|
||||||
|
_download = '0 B';
|
||||||
|
_upload = '0 B';
|
||||||
|
_pingText = 'Target Ping: -- ms';
|
||||||
|
_pingColor = Colors.white54;
|
||||||
|
_isCheckingPing = false;
|
||||||
|
});
|
||||||
|
_pulseController.stop();
|
||||||
|
_pulseController.value = 0.0;
|
||||||
|
_spinController.stop();
|
||||||
|
_uptimeTimer?.cancel();
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int s) {
|
||||||
|
final h = s ~/ 3600;
|
||||||
|
final m = (s % 3600) ~/ 60;
|
||||||
|
final sec = s % 60;
|
||||||
|
final pad = (int n) => n.toString().padLeft(2, '0');
|
||||||
|
return h > 0 ? '$h:${pad(m)}:${pad(sec)}' : '${pad(m)}:${pad(sec)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: -150, right: -100,
|
||||||
|
child: Container(
|
||||||
|
width: 400, height: 400,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: theme.colorScheme.primary.withOpacity(0.15),
|
||||||
|
),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -100, left: -100,
|
||||||
|
child: Container(
|
||||||
|
width: 350, height: 350,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: theme.colorScheme.secondary.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildTopBar(theme),
|
||||||
|
Expanded(child: _buildStage(theme)),
|
||||||
|
_buildMetricsBar(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopBar(ThemeData theme) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
width: 12, height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: _state == ConnectionStateEnum.connected
|
||||||
|
? theme.colorScheme.secondary
|
||||||
|
: theme.colorScheme.primary,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: _state == ConnectionStateEnum.connected
|
||||||
|
? theme.colorScheme.secondary.withOpacity(0.5)
|
||||||
|
: theme.colorScheme.primary.withOpacity(0.5),
|
||||||
|
blurRadius: 10,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'OSTP',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
iconSize: 30,
|
||||||
|
icon: const Icon(Icons.auto_mode_rounded, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
if (_state != ConnectionStateEnum.disconnected) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Disconnect first to run Auto mode')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_runAutoMode();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
iconSize: 30,
|
||||||
|
icon: const Icon(Icons.settings_outlined, color: Colors.white),
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => SettingsScreen(prefs: widget.prefs)),
|
||||||
|
);
|
||||||
|
_loadSettings();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStage(ThemeData theme) {
|
||||||
|
Color getAccentColor() {
|
||||||
|
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
|
||||||
|
return theme.colorScheme.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 260, height: 260,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
if (_state != ConnectionStateEnum.disconnected)
|
||||||
|
RotationTransition(
|
||||||
|
turns: _spinController,
|
||||||
|
child: Container(
|
||||||
|
width: 240, height: 240,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: getAccentColor().withOpacity(0.25),
|
||||||
|
width: 2.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_state != ConnectionStateEnum.disconnected)
|
||||||
|
RotationTransition(
|
||||||
|
turns: ReverseAnimation(_spinController),
|
||||||
|
child: Container(
|
||||||
|
width: 200, height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: getAccentColor().withOpacity(0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _pulseController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
width: 140, height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
border: Border.all(
|
||||||
|
color: _state == ConnectionStateEnum.disconnected
|
||||||
|
? Colors.white.withOpacity(0.15)
|
||||||
|
: getAccentColor(),
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
if (_state != ConnectionStateEnum.disconnected)
|
||||||
|
BoxShadow(
|
||||||
|
color: getAccentColor().withOpacity(0.4 * (_state == ConnectionStateEnum.connected ? 1.0 : _pulseController.value)),
|
||||||
|
blurRadius: 40,
|
||||||
|
spreadRadius: 8,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: _toggleConnection,
|
||||||
|
child: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
size: 60,
|
||||||
|
color: _state == ConnectionStateEnum.disconnected
|
||||||
|
? Colors.white54
|
||||||
|
: getAccentColor(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
_state == ConnectionStateEnum.disconnected ? 'Disconnected' :
|
||||||
|
_state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _state == ConnectionStateEnum.disconnected ? Colors.white70 : getAccentColor(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_state == ConnectionStateEnum.connected ? _formatTime(_uptimeSecs) : 'Tap to protect your traffic',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.white54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.15)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
_serverAddr,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.03),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'CONNECTION TEST',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white38,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_pingText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _pingColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_isCheckingPing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20, height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
||||||
|
)
|
||||||
|
: TextButton.icon(
|
||||||
|
onPressed: _checkConnectionLatency,
|
||||||
|
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
||||||
|
label: Text(
|
||||||
|
'Test Ping',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricsBar(ThemeData theme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.04),
|
||||||
|
border: Border(top: BorderSide(color: Colors.white.withOpacity(0.08))),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary),
|
||||||
|
Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)),
|
||||||
|
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricItem(IconData icon, String label, String value, Color color) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 20, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label.toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white54,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
class LogsScreen extends StatefulWidget {
|
||||||
|
const LogsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LogsScreen> createState() => _LogsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogsScreenState extends State<LogsScreen> {
|
||||||
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
Timer? _pollTimer;
|
||||||
|
final List<String> _logs = [];
|
||||||
|
final ScrollController _scrollCtrl = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchLogs();
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _fetchLogs());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_scrollCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchLogs() async {
|
||||||
|
try {
|
||||||
|
final String logsJson = await platform.invokeMethod('getLogs');
|
||||||
|
if (logsJson.isNotEmpty && logsJson != "[]") {
|
||||||
|
final List<dynamic> parsed = jsonDecode(logsJson);
|
||||||
|
if (parsed.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_logs.addAll(parsed.map((e) => e.toString()));
|
||||||
|
});
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
if (_scrollCtrl.hasClients) {
|
||||||
|
_scrollCtrl.animateTo(_scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint("Failed to fetch logs: $e\n$stackTrace");
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Logs Error', style: TextStyle(color: Colors.redAccent)),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: e.toString()));
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!')));
|
||||||
|
},
|
||||||
|
child: const Text('Copy'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearLogs() async {
|
||||||
|
await platform.invokeMethod('clearLogs');
|
||||||
|
setState(() {
|
||||||
|
_logs.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copyLogs() async {
|
||||||
|
final text = _logs.join('\n');
|
||||||
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Logs copied to clipboard')));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('System Logs', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _clearLogs, tooltip: 'Clear'),
|
||||||
|
IconButton(icon: const Icon(Icons.copy_rounded), onPressed: _copyLogs, tooltip: 'Copy All'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollCtrl,
|
||||||
|
itemCount: _logs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||||
|
child: Text(
|
||||||
|
_logs[index],
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
class QRScannerScreen extends StatefulWidget {
|
||||||
|
const QRScannerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QRScannerScreen> createState() => _QRScannerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QRScannerScreenState extends State<QRScannerScreen> {
|
||||||
|
final MobileScannerController controller = MobileScannerController(
|
||||||
|
detectionSpeed: DetectionSpeed.normal,
|
||||||
|
facing: CameraFacing.back,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? lastErrorTime;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Scan QR Code'),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: controller,
|
||||||
|
onDetect: (capture) {
|
||||||
|
final List<Barcode> barcodes = capture.barcodes;
|
||||||
|
for (final barcode in barcodes) {
|
||||||
|
if (barcode.rawValue != null) {
|
||||||
|
if (barcode.rawValue!.startsWith('ostp://')) {
|
||||||
|
controller.stop();
|
||||||
|
Navigator.pop(context, barcode.rawValue);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (lastErrorTime == null || now.difference(lastErrorTime!) > const Duration(seconds: 3)) {
|
||||||
|
lastErrorTime = now;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Invalid QR Code. Must be an OSTP connection link.'),
|
||||||
|
backgroundColor: Colors.redAccent,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: ShapeDecoration(
|
||||||
|
shape: QrScannerOverlayShape(
|
||||||
|
borderColor: Theme.of(context).colorScheme.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderLength: 30,
|
||||||
|
borderWidth: 10,
|
||||||
|
cutOutSize: 300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrScannerOverlayShape extends ShapeBorder {
|
||||||
|
final Color borderColor;
|
||||||
|
final double borderWidth;
|
||||||
|
final double borderRadius;
|
||||||
|
final double borderLength;
|
||||||
|
final double cutOutSize;
|
||||||
|
|
||||||
|
const QrScannerOverlayShape({
|
||||||
|
this.borderColor = Colors.red,
|
||||||
|
this.borderWidth = 3.0,
|
||||||
|
this.borderRadius = 0.0,
|
||||||
|
this.borderLength = 20.0,
|
||||||
|
this.cutOutSize = 250.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
EdgeInsetsGeometry get dimensions => const EdgeInsets.all(10);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
|
||||||
|
return Path()
|
||||||
|
..fillType = PathFillType.evenOdd
|
||||||
|
..addPath(getOuterPath(rect), Offset.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
|
||||||
|
Path path = Path()..addRect(rect);
|
||||||
|
rect = Rect.fromCenter(
|
||||||
|
center: rect.center,
|
||||||
|
width: cutOutSize,
|
||||||
|
height: cutOutSize,
|
||||||
|
);
|
||||||
|
path.addRect(rect);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
|
||||||
|
final borderPaint = Paint()
|
||||||
|
..color = borderColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = borderWidth;
|
||||||
|
|
||||||
|
final backgroundPaint = Paint()
|
||||||
|
..color = Colors.black54
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final cutOutRect = Rect.fromCenter(
|
||||||
|
center: rect.center,
|
||||||
|
width: cutOutSize,
|
||||||
|
height: cutOutSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
final backgroundPath = Path()
|
||||||
|
..addRect(rect)
|
||||||
|
..addRect(cutOutRect)
|
||||||
|
..fillType = PathFillType.evenOdd;
|
||||||
|
|
||||||
|
canvas.drawPath(backgroundPath, backgroundPaint);
|
||||||
|
|
||||||
|
final path = Path();
|
||||||
|
// Top left
|
||||||
|
path.moveTo(cutOutRect.left, cutOutRect.top + borderLength);
|
||||||
|
path.lineTo(cutOutRect.left, cutOutRect.top + borderRadius);
|
||||||
|
path.arcToPoint(
|
||||||
|
Offset(cutOutRect.left + borderRadius, cutOutRect.top),
|
||||||
|
radius: Radius.circular(borderRadius),
|
||||||
|
);
|
||||||
|
path.lineTo(cutOutRect.left + borderLength, cutOutRect.top);
|
||||||
|
|
||||||
|
// Top right
|
||||||
|
path.moveTo(cutOutRect.right - borderLength, cutOutRect.top);
|
||||||
|
path.lineTo(cutOutRect.right - borderRadius, cutOutRect.top);
|
||||||
|
path.arcToPoint(
|
||||||
|
Offset(cutOutRect.right, cutOutRect.top + borderRadius),
|
||||||
|
radius: Radius.circular(borderRadius),
|
||||||
|
);
|
||||||
|
path.lineTo(cutOutRect.right, cutOutRect.top + borderLength);
|
||||||
|
|
||||||
|
// Bottom left
|
||||||
|
path.moveTo(cutOutRect.left, cutOutRect.bottom - borderLength);
|
||||||
|
path.lineTo(cutOutRect.left, cutOutRect.bottom - borderRadius);
|
||||||
|
path.arcToPoint(
|
||||||
|
Offset(cutOutRect.left + borderRadius, cutOutRect.bottom),
|
||||||
|
radius: Radius.circular(borderRadius),
|
||||||
|
clockwise: false,
|
||||||
|
);
|
||||||
|
path.lineTo(cutOutRect.left + borderLength, cutOutRect.bottom);
|
||||||
|
|
||||||
|
// Bottom right
|
||||||
|
path.moveTo(cutOutRect.right - borderLength, cutOutRect.bottom);
|
||||||
|
path.lineTo(cutOutRect.right - borderRadius, cutOutRect.bottom);
|
||||||
|
path.arcToPoint(
|
||||||
|
Offset(cutOutRect.right, cutOutRect.bottom - borderRadius),
|
||||||
|
radius: Radius.circular(borderRadius),
|
||||||
|
clockwise: false,
|
||||||
|
);
|
||||||
|
path.lineTo(cutOutRect.right, cutOutRect.bottom - borderLength);
|
||||||
|
|
||||||
|
canvas.drawPath(path, borderPaint);
|
||||||
|
|
||||||
|
// Line in the middle
|
||||||
|
final linePaint = Paint()
|
||||||
|
..color = borderColor.withOpacity(0.8)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.0;
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(cutOutRect.left + 20, cutOutRect.center.dy),
|
||||||
|
Offset(cutOutRect.right - 20, cutOutRect.center.dy),
|
||||||
|
linePaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ShapeBorder scale(double t) {
|
||||||
|
return QrScannerOverlayShape(
|
||||||
|
borderColor: borderColor,
|
||||||
|
borderWidth: borderWidth * t,
|
||||||
|
borderRadius: borderRadius * t,
|
||||||
|
borderLength: borderLength * t,
|
||||||
|
cutOutSize: cutOutSize * t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,516 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'app_routing_screen.dart';
|
||||||
|
import 'logs_screen.dart';
|
||||||
|
import 'qr_scanner_screen.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
final SharedPreferences prefs;
|
||||||
|
const SettingsScreen({super.key, required this.prefs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
late TextEditingController _importCtrl;
|
||||||
|
late TextEditingController _serverCtrl;
|
||||||
|
late TextEditingController _localBindCtrl;
|
||||||
|
late TextEditingController _keyCtrl;
|
||||||
|
late TextEditingController _dnsCtrl;
|
||||||
|
late TextEditingController _mtuCtrl;
|
||||||
|
late TextEditingController _domainsCtrl;
|
||||||
|
late TextEditingController _ipsCtrl;
|
||||||
|
late TextEditingController _processesCtrl;
|
||||||
|
late TextEditingController _stealthSniCtrl;
|
||||||
|
late TextEditingController _stealthPortCtrl;
|
||||||
|
late TextEditingController _pbkCtrl;
|
||||||
|
late TextEditingController _sidCtrl;
|
||||||
|
|
||||||
|
bool _obscureKey = true;
|
||||||
|
bool _debugMode = false;
|
||||||
|
bool _wss = false;
|
||||||
|
bool _realityEnabled = false;
|
||||||
|
String _transportMode = 'udp'; // 'udp' | 'uot'
|
||||||
|
String _tunStack = 'ostp'; // 'system' | 'ostp'
|
||||||
|
bool _muxEnabled = false;
|
||||||
|
late TextEditingController _muxSessionsCtrl;
|
||||||
|
bool _owndns = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_importCtrl = TextEditingController();
|
||||||
|
_serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443');
|
||||||
|
_localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088');
|
||||||
|
_keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? '');
|
||||||
|
_dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1');
|
||||||
|
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
|
||||||
|
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
|
||||||
|
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
|
||||||
|
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
|
||||||
|
_stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? '');
|
||||||
|
_stealthPortCtrl = TextEditingController(text: widget.prefs.getString('stealth_port') ?? '443');
|
||||||
|
_pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? '');
|
||||||
|
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
|
||||||
|
_wss = widget.prefs.getBool('wss') ?? false;
|
||||||
|
_realityEnabled = widget.prefs.getBool('reality_enabled') ?? false;
|
||||||
|
_transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||||
|
_tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
|
||||||
|
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
|
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
|
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
|
||||||
|
_owndns = widget.prefs.getBool('owndns') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveSettings();
|
||||||
|
_importCtrl.dispose();
|
||||||
|
_serverCtrl.dispose();
|
||||||
|
_localBindCtrl.dispose();
|
||||||
|
_keyCtrl.dispose();
|
||||||
|
_dnsCtrl.dispose();
|
||||||
|
_mtuCtrl.dispose();
|
||||||
|
_domainsCtrl.dispose();
|
||||||
|
_ipsCtrl.dispose();
|
||||||
|
_processesCtrl.dispose();
|
||||||
|
_stealthSniCtrl.dispose();
|
||||||
|
_stealthPortCtrl.dispose();
|
||||||
|
_pbkCtrl.dispose();
|
||||||
|
_sidCtrl.dispose();
|
||||||
|
_muxSessionsCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveSettings() {
|
||||||
|
widget.prefs.setString('server_addr', _serverCtrl.text.trim());
|
||||||
|
widget.prefs.setString('local_bind', _localBindCtrl.text.trim());
|
||||||
|
widget.prefs.setString('access_key', _keyCtrl.text.trim());
|
||||||
|
widget.prefs.setString('dns_server', _dnsCtrl.text.trim());
|
||||||
|
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
|
||||||
|
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
|
||||||
|
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
|
||||||
|
widget.prefs.setString('ex_processes', _processesCtrl.text.trim());
|
||||||
|
widget.prefs.setBool('debug_mode', _debugMode);
|
||||||
|
widget.prefs.setBool('wss', _wss);
|
||||||
|
widget.prefs.setBool('reality_enabled', _realityEnabled);
|
||||||
|
widget.prefs.setString('transport_mode', _transportMode);
|
||||||
|
widget.prefs.setString('tun_stack', _tunStack);
|
||||||
|
widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim());
|
||||||
|
widget.prefs.setString('stealth_port', _stealthPortCtrl.text.trim());
|
||||||
|
widget.prefs.setString('pbk', _pbkCtrl.text.trim());
|
||||||
|
widget.prefs.setString('sid', _sidCtrl.text.trim());
|
||||||
|
widget.prefs.setBool('mux_enabled', _muxEnabled);
|
||||||
|
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
|
||||||
|
widget.prefs.setBool('owndns', _owndns);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: isPassword && _obscureKey,
|
||||||
|
maxLines: maxLines,
|
||||||
|
style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(color: Colors.white30),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Theme.of(context).colorScheme.surface,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
suffixIcon: isPassword ? IconButton(
|
||||||
|
icon: Icon(_obscureKey ? Icons.visibility : Icons.visibility_off, color: Colors.white54),
|
||||||
|
onPressed: () => setState(() => _obscureKey = !_obscureKey),
|
||||||
|
) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildToggle(String title, String subtitle, bool value, ValueChanged<bool> onChanged) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(subtitle, style: const TextStyle(fontSize: 13, color: Colors.white54)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: value,
|
||||||
|
onChanged: (v) {
|
||||||
|
onChanged(v);
|
||||||
|
_saveSettings();
|
||||||
|
},
|
||||||
|
activeColor: Theme.of(context).colorScheme.secondary,
|
||||||
|
activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3),
|
||||||
|
inactiveTrackColor: Colors.white10,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Configuration', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.qr_code_scanner_rounded),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const QRScannerScreen()),
|
||||||
|
);
|
||||||
|
if (result != null && result is String && result.startsWith('ostp://')) {
|
||||||
|
setState(() {
|
||||||
|
_importCtrl.text = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
children: [
|
||||||
|
// Quick Import Row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _importCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Paste ostp:// share link...',
|
||||||
|
hintStyle: const TextStyle(color: Colors.white30, fontSize: 14),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white.withOpacity(0.05),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final raw = _importCtrl.text.trim();
|
||||||
|
if (raw.isEmpty) return;
|
||||||
|
try {
|
||||||
|
if (!raw.startsWith('ostp://')) {
|
||||||
|
throw Exception('Link must start with ostp://');
|
||||||
|
}
|
||||||
|
final uri = Uri.parse(raw);
|
||||||
|
final key = Uri.decodeComponent(uri.userInfo);
|
||||||
|
final host = uri.authority.replaceFirst(uri.userInfo + '@', '');
|
||||||
|
if (key.isEmpty || host.isEmpty) {
|
||||||
|
throw Exception('Incomplete link parameters');
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_serverCtrl.text = host;
|
||||||
|
_keyCtrl.text = key;
|
||||||
|
_stealthSniCtrl.text = uri.queryParameters['sni'] ?? '';
|
||||||
|
_pbkCtrl.text = uri.queryParameters['pbk'] ?? '';
|
||||||
|
_sidCtrl.text = uri.queryParameters['sid'] ?? '';
|
||||||
|
_wss = uri.queryParameters['wss'] == 'true';
|
||||||
|
_realityEnabled = uri.queryParameters['reality'] == 'true';
|
||||||
|
final type = uri.queryParameters['type'] ?? 'udp';
|
||||||
|
_transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp';
|
||||||
|
_owndns = uri.queryParameters['owndns'] == 'true';
|
||||||
|
_importCtrl.clear();
|
||||||
|
_saveSettings();
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully')));
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
),
|
||||||
|
child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.02),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.05)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTextField('Server Address', _serverCtrl, hint: 'host:port'),
|
||||||
|
_buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'),
|
||||||
|
_buildTextField('Access Key', _keyCtrl, hint: 'Secure access key', isPassword: true),
|
||||||
|
_buildTextField('Custom DNS Server', _dnsCtrl, hint: '1.1.1.1 (e.g. 8.8.8.8)'),
|
||||||
|
_buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1140 (decrease if connection drops)'),
|
||||||
|
|
||||||
|
// ── Transport Mode ───────────────────────────────────────
|
||||||
|
const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: 'udp',
|
||||||
|
groupValue: _transportMode,
|
||||||
|
title: const Text('UDP (по умолчанию)', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: const Text('Быстро, работает через Wi-Fi и большинство сетей', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||||
|
activeColor: Theme.of(context).colorScheme.secondary,
|
||||||
|
onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
|
||||||
|
),
|
||||||
|
Divider(color: Colors.white.withOpacity(0.05), height: 1),
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: 'uot',
|
||||||
|
groupValue: _transportMode,
|
||||||
|
title: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6C72FF).withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Text('xHTTP Стелс', style: TextStyle(fontSize: 10, color: Color(0xFF6C72FF), fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: const Text('Маскировка под HTTP-поток, обходит белые списки (уровень 1)', style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||||
|
activeColor: Theme.of(context).colorScheme.primary,
|
||||||
|
onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildToggle('WebSocket (WSS)', 'Инкапсулировать транспорт в RFC 6455 (для строгого DPI)', _wss, (val) {
|
||||||
|
setState(() {
|
||||||
|
_wss = val;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Stealth parameters
|
||||||
|
AnimatedCrossFade(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
crossFadeState: _transportMode == 'uot' ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||||
|
firstChild: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6C72FF).withOpacity(0.06),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFF6C72FF).withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.security, size: 16, color: Color(0xFF6C72FF)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Стелс параметры', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF6C72FF), fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Укажи домен из белого списка. OSTP подключится к серверу и подделает SNI / HTTP Host.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.white38),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Builder(builder: (context) {
|
||||||
|
final List<String> domains = [
|
||||||
|
'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me',
|
||||||
|
'top-fwz1.mail.ru', 'sso.passport.yandex.ru',
|
||||||
|
'sberbank.ru', 'ad.mail.ru', 'ads.vk.com',
|
||||||
|
'login.vk.com', 'api.sberbank.ru', 'ok.ru',
|
||||||
|
'rostelecom.ru', 'rt.ru', 'tinkoff.ru',
|
||||||
|
'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com'
|
||||||
|
];
|
||||||
|
String currentVal = _stealthSniCtrl.text.trim();
|
||||||
|
if (currentVal.isEmpty) currentVal = 'vk.com';
|
||||||
|
if (!domains.contains(currentVal)) {
|
||||||
|
domains.add(currentVal);
|
||||||
|
}
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
value: currentVal,
|
||||||
|
dropdownColor: const Color(0xFF1E1E2C),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Стелс Домен (Автоподставление)',
|
||||||
|
labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
items: domains.map((String domain) {
|
||||||
|
return DropdownMenuItem<String>(
|
||||||
|
value: domain,
|
||||||
|
child: Text(domain),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (String? newValue) {
|
||||||
|
if (newValue != null) {
|
||||||
|
setState(() {
|
||||||
|
_stealthSniCtrl.text = newValue;
|
||||||
|
_stealthPortCtrl.text = '443';
|
||||||
|
_saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildToggle('XTLS-Reality', 'Подделка TLS-сессии (Stealth-домен должен быть TLS 1.3)', _realityEnabled, (val) {
|
||||||
|
setState(() {
|
||||||
|
_realityEnabled = val;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTextField('Reality PublicKey (pbk)', _pbkCtrl, hint: 'Публичный ключ сервера'),
|
||||||
|
_buildTextField('Reality ShortId (sid)', _sidCtrl, hint: 'Опционально (необязательно)'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
secondChild: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)),
|
||||||
|
AnimatedCrossFade(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
crossFadeState: _muxEnabled ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||||
|
firstChild: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12.0),
|
||||||
|
child: _buildTextField('Mux Sessions', _muxSessionsCtrl, hint: '4'),
|
||||||
|
),
|
||||||
|
secondChild: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildToggle('Debug Logs', 'Verbose output', _debugMode, (v) => setState(() => _debugMode = v))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24.0, left: 10),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.receipt_long_rounded),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
tooltip: 'View Logs',
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
_buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3, isMono: true),
|
||||||
|
_buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3, isMono: true),
|
||||||
|
|
||||||
|
// Premium app routing trigger button
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.apps_rounded, color: Theme.of(context).colorScheme.primary, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Per-App Connection Rules',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Choose which apps bypass or use VPN',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.white54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Binary file not shown.
|
|
@ -1,6 +1,22 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -25,6 +41,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -78,6 +110,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.1"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -96,6 +136,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.1"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -208,6 +256,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -224,6 +280,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
screen_retriever:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -429,6 +493,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.4 <4.0.0"
|
dart: ">=3.11.4 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.35.0"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,12 @@ dev_dependencies:
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
flutter_launcher_icons: "^0.13.1"
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: "launcher_icon"
|
||||||
|
ios: false
|
||||||
|
image_path: "../ostp-gui/src-tauri/icons/icon.png"
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2641,7 +2641,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.2.79"
|
version = "0.2.83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2672,7 +2672,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.2.79"
|
version = "0.2.83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ struct TunConfig {
|
||||||
ipv4_address: Option<String>,
|
ipv4_address: Option<String>,
|
||||||
dns: Option<String>,
|
dns: Option<String>,
|
||||||
stack: Option<String>,
|
stack: Option<String>,
|
||||||
|
kill_switch: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
@ -186,6 +187,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
|
||||||
},
|
},
|
||||||
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
||||||
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
||||||
|
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,7 +216,8 @@ async fn get_config() -> Result<String, String> {
|
||||||
"enable": false,
|
"enable": false,
|
||||||
"wintun_path": "./wintun.dll",
|
"wintun_path": "./wintun.dll",
|
||||||
"ipv4_address": "10.1.0.2/24",
|
"ipv4_address": "10.1.0.2/24",
|
||||||
"dns": "1.1.1.1"
|
"dns": "1.1.1.1",
|
||||||
|
"kill_switch": false
|
||||||
},
|
},
|
||||||
|
|
||||||
"_comment_exclude": "Bypass tunnel for these domains/IPs (only works in proxy mode)",
|
"_comment_exclude": "Bypass tunnel for these domains/IPs (only works in proxy mode)",
|
||||||
|
|
@ -290,9 +293,19 @@ async fn reload_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let config_path = get_config_path();
|
let path = get_config_path();
|
||||||
let config_str = std::fs::read_to_string(&config_path)
|
let content = std::fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("Read config error: {}", e))?;
|
.map_err(|e| format!("Read config error: {}", e))?;
|
||||||
|
let mut stripped = json_comments::StripComments::new(content.as_bytes());
|
||||||
|
let unified: UnifiedConfig = serde_json::from_reader(&mut stripped)
|
||||||
|
.map_err(|e| format!("Parse config error: {}", e))?;
|
||||||
|
let client_cfg = match unified.mode {
|
||||||
|
AppMode::Client(c) => c,
|
||||||
|
AppMode::Server(_) => return Err("GUI only supports Client mode.".into()),
|
||||||
|
};
|
||||||
|
let mode_str = if client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false) { "tun" } else { "proxy" };
|
||||||
|
let core_cfg = map_to_client_config(&client_cfg, mode_str);
|
||||||
|
let config_str = serde_json::to_string(&core_cfg).unwrap();
|
||||||
|
|
||||||
match &guard.tunnel {
|
match &guard.tunnel {
|
||||||
Some(TunnelHandle::Helper(h)) => {
|
Some(TunnelHandle::Helper(h)) => {
|
||||||
|
|
@ -389,7 +402,7 @@ async fn start_proxy_in_process(
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
let metrics_clone = metrics.clone();
|
let metrics_clone = metrics.clone();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx).await {
|
match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx, None).await {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +540,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
||||||
|
|
||||||
let exe_wstr: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
let exe_wstr: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||||
let verb_wstr: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
|
let verb_wstr: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
|
||||||
let params_str = format!("--token {} --port {}", token, port);
|
let params_str = format!("--port {} --token {}", port, token);
|
||||||
let params_wstr: Vec<u16> = OsStr::new(¶ms_str).encode_wide().chain(Some(0)).collect();
|
let params_wstr: Vec<u16> = OsStr::new(¶ms_str).encode_wide().chain(Some(0)).collect();
|
||||||
#[link(name = "shell32")] extern "system" { fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize; }
|
#[link(name = "shell32")] extern "system" { fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize; }
|
||||||
|
|
||||||
|
|
@ -536,6 +549,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
||||||
let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
|
let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
|
||||||
|
|
||||||
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 0) };
|
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 0) };
|
||||||
|
|
||||||
if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); }
|
if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -543,8 +557,31 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); }
|
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); }
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn show_error_dialog(msg: &str) {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
let msg_w: Vec<u16> = std::ffi::OsStr::new(msg).encode_wide().chain(Some(0)).collect();
|
||||||
|
let title_w: Vec<u16> = std::ffi::OsStr::new("OSTP GUI Error").encode_wide().chain(Some(0)).collect();
|
||||||
|
#[link(name = "user32")] extern "system" { fn MessageBoxW(hWnd: *mut std::ffi::c_void, lpText: *const u16, lpCaption: *const u16, uType: u32) -> i32; }
|
||||||
|
unsafe { MessageBoxW(std::ptr::null_mut(), msg_w.as_ptr(), title_w.as_ptr(), 0x10); } // 0x10 is MB_ICONERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn show_error_dialog(msg: &str) {
|
||||||
|
println!("ERROR: {}", msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SINGLE_INSTANCE_LOCK: std::sync::OnceLock<std::net::TcpListener> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
if let Ok(listener) = std::net::TcpListener::bind("127.0.0.1:49153") {
|
||||||
|
let _ = SINGLE_INSTANCE_LOCK.set(listener);
|
||||||
|
} else {
|
||||||
|
show_error_dialog("Приложение OSTP GUI уже запущено!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let state = AppState(Mutex::new(AppStateInner { tunnel: None }));
|
let state = AppState(Mutex::new(AppStateInner { tunnel: None }));
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ const translations = {
|
||||||
owndns_hint: 'Route DNS queries through the VPN server (10.1.0.1)',
|
owndns_hint: 'Route DNS queries through the VPN server (10.1.0.1)',
|
||||||
label_tun: 'TUN Tunnel Mode',
|
label_tun: 'TUN Tunnel Mode',
|
||||||
tun_hint: 'Route all system traffic (Admin req.)',
|
tun_hint: 'Route all system traffic (Admin req.)',
|
||||||
|
label_kill_switch: 'Kill Switch',
|
||||||
|
kill_switch_hint: 'Block non-VPN traffic when tunnel drops',
|
||||||
label_transport: 'Transport Protocol',
|
label_transport: 'Transport Protocol',
|
||||||
label_mtu: 'MTU Size',
|
label_mtu: 'MTU Size',
|
||||||
label_transport: 'Transport Protocol',
|
label_transport: 'Transport Protocol',
|
||||||
|
|
@ -72,6 +74,8 @@ const translations = {
|
||||||
owndns_hint: 'Направлять DNS-запросы через VPN сервер (10.1.0.1)',
|
owndns_hint: 'Направлять DNS-запросы через VPN сервер (10.1.0.1)',
|
||||||
label_tun: 'Режим TUN-туннеля',
|
label_tun: 'Режим TUN-туннеля',
|
||||||
tun_hint: 'Направить весь трафик (нужны права администратора)',
|
tun_hint: 'Направить весь трафик (нужны права администратора)',
|
||||||
|
label_kill_switch: 'Kill Switch',
|
||||||
|
kill_switch_hint: 'Блокировать трафик вне VPN при обрыве связи',
|
||||||
label_transport: 'Транспортный протокол',
|
label_transport: 'Транспортный протокол',
|
||||||
label_mtu: 'Размер MTU',
|
label_mtu: 'Размер MTU',
|
||||||
label_transport: 'Транспортный протокол',
|
label_transport: 'Транспортный протокол',
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,19 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row" id="group-kill-switch" style="display: none;">
|
||||||
|
<div class="toggle-text">
|
||||||
|
<span class="toggle-name" data-i18n="label_kill_switch">Kill Switch</span>
|
||||||
|
<span class="toggle-hint" data-i18n="kill_switch_hint">Block traffic if connection drops</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="in-kill-switch" />
|
||||||
|
<span class="toggle-track">
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<div class="toggle-text">
|
<div class="toggle-text">
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const inPbk = $('in-pbk');
|
||||||
const inSid = $('in-sid');
|
const inSid = $('in-sid');
|
||||||
const inMtu = $('in-mtu');
|
const inMtu = $('in-mtu');
|
||||||
const inTun = $('in-tun-mode');
|
const inTun = $('in-tun-mode');
|
||||||
|
const inKillSwitch = $('in-kill-switch');
|
||||||
const inMux = $('in-mux-mode');
|
const inMux = $('in-mux-mode');
|
||||||
const inMuxSessions = $('in-mux-sessions');
|
const inMuxSessions = $('in-mux-sessions');
|
||||||
const inDebug = $('in-debug');
|
const inDebug = $('in-debug');
|
||||||
|
|
@ -91,12 +92,18 @@ function showToast(msg, variant = '') {
|
||||||
}, 2400);
|
}, 2400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DNS visibility ────────────────────────────────────────────────────────────
|
// ── DNS & Kill Switch visibility ──────────────────────────────────────────────
|
||||||
function updateDnsVisibility() {
|
function updateDnsVisibility() {
|
||||||
if (!groupCustomDns || !inOwndns) return;
|
if (!groupCustomDns || !inOwndns) return;
|
||||||
groupCustomDns.style.display = inOwndns.checked ? 'none' : 'block';
|
groupCustomDns.style.display = inOwndns.checked ? 'none' : 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateKillSwitchVisibility() {
|
||||||
|
const group = $('group-kill-switch');
|
||||||
|
if (!group || !inTun) return;
|
||||||
|
group.style.display = inTun.checked ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── State machine ────────────────────────────────────────────────────────────
|
// ── State machine ────────────────────────────────────────────────────────────
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
|
|
@ -162,19 +169,22 @@ function setState(next) {
|
||||||
|
|
||||||
// ── Polling ──────────────────────────────────────────────────────────────────
|
// ── Polling ──────────────────────────────────────────────────────────────────
|
||||||
async function poll() {
|
async function poll() {
|
||||||
|
if (!pollTimer) return;
|
||||||
try {
|
try {
|
||||||
const code = await invoke('get_tunnel_status');
|
const code = await invoke('get_tunnel_status');
|
||||||
|
if (!pollTimer) return; // Prevent race condition if disconnected during await
|
||||||
|
|
||||||
if (code === 0) { setState('disconnected'); return; }
|
if (code === 0) { setState('disconnected'); return; }
|
||||||
else if (code === 1) setState('connecting');
|
else if (code === 1) setState('connecting');
|
||||||
else if (code === 2) setState('connected');
|
else if (code === 2) setState('connected');
|
||||||
|
|
||||||
const metrics = await invoke('get_metrics');
|
const metrics = await invoke('get_metrics');
|
||||||
if (metrics) {
|
if (metrics && pollTimer) {
|
||||||
metricDown.textContent = fmtBytes(metrics.bytes_recv);
|
metricDown.textContent = fmtBytes(metrics.bytes_recv);
|
||||||
metricUp.textContent = fmtBytes(metrics.bytes_sent);
|
metricUp.textContent = fmtBytes(metrics.bytes_sent);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setState('disconnected');
|
if (pollTimer) setState('disconnected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,10 +212,12 @@ async function handleToggle() {
|
||||||
} else {
|
} else {
|
||||||
setState('disconnected');
|
setState('disconnected');
|
||||||
showToast(t('toast_error') || 'Failed to connect', 'error');
|
showToast(t('toast_error') || 'Failed to connect', 'error');
|
||||||
|
alert(t('toast_error') || 'Failed to connect');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState('disconnected');
|
setState('disconnected');
|
||||||
showToast(String(err), 'error');
|
showToast(String(err), 'error');
|
||||||
|
alert(String(err));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
|
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
|
||||||
|
|
@ -243,7 +255,8 @@ async function loadConfigIntoForm() {
|
||||||
inPbk.value = c.reality?.pbk || '';
|
inPbk.value = c.reality?.pbk || '';
|
||||||
inSid.value = c.reality?.sid || '';
|
inSid.value = c.reality?.sid || '';
|
||||||
inMtu.value = c.mtu || '';
|
inMtu.value = c.mtu || '';
|
||||||
inTun.checked = !!c.tun?.enabled;
|
inTun.checked = !!c.tun?.enable;
|
||||||
|
if (inKillSwitch) inKillSwitch.checked = !!c.tun?.kill_switch;
|
||||||
inMux.checked = !!c.mux?.enabled;
|
inMux.checked = !!c.mux?.enabled;
|
||||||
inMuxSessions.value = c.mux?.sessions || '';
|
inMuxSessions.value = c.mux?.sessions || '';
|
||||||
|
|
||||||
|
|
@ -253,6 +266,7 @@ async function loadConfigIntoForm() {
|
||||||
inOwndns.checked = isOwndns;
|
inOwndns.checked = isOwndns;
|
||||||
inDns.value = isOwndns ? '' : savedDns;
|
inDns.value = isOwndns ? '' : savedDns;
|
||||||
updateDnsVisibility();
|
updateDnsVisibility();
|
||||||
|
updateKillSwitchVisibility();
|
||||||
|
|
||||||
inDebug.checked = !!c.debug;
|
inDebug.checked = !!c.debug;
|
||||||
|
|
||||||
|
|
@ -307,8 +321,8 @@ async function handleSave(silent = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mtuStr = inMtu.value.trim();
|
const mtuStr = inMtu.value.trim();
|
||||||
if (mtuStr) rawConfig.ostp.mtu = parseInt(mtuStr, 10);
|
if (mtuStr) rawConfig.mtu = parseInt(mtuStr, 10);
|
||||||
else delete rawConfig.ostp.mtu;
|
else delete rawConfig.mtu;
|
||||||
|
|
||||||
if (inMux.checked) {
|
if (inMux.checked) {
|
||||||
const s = parseInt(inMuxSessions.value.trim(), 10);
|
const s = parseInt(inMuxSessions.value.trim(), 10);
|
||||||
|
|
@ -319,6 +333,7 @@ async function handleSave(silent = false) {
|
||||||
|
|
||||||
rawConfig.tun = rawConfig.tun || {};
|
rawConfig.tun = rawConfig.tun || {};
|
||||||
rawConfig.tun.enable = inTun.checked;
|
rawConfig.tun.enable = inTun.checked;
|
||||||
|
rawConfig.tun.kill_switch = inKillSwitch ? inKillSwitch.checked : false;
|
||||||
rawConfig.tun.wintun_path = rawConfig.tun.wintun_path || './wintun.dll';
|
rawConfig.tun.wintun_path = rawConfig.tun.wintun_path || './wintun.dll';
|
||||||
rawConfig.tun.ipv4_address = rawConfig.tun.ipv4_address || '10.1.0.2/24';
|
rawConfig.tun.ipv4_address = rawConfig.tun.ipv4_address || '10.1.0.2/24';
|
||||||
rawConfig.tun.stack = 'ostp';
|
rawConfig.tun.stack = 'ostp';
|
||||||
|
|
@ -384,12 +399,14 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||||
applyTranslations();
|
applyTranslations();
|
||||||
setState('disconnected');
|
setState('disconnected');
|
||||||
updateDnsVisibility(); // initialise field visibility from current checkbox state
|
updateDnsVisibility(); // initialise field visibility from current checkbox state
|
||||||
|
updateKillSwitchVisibility();
|
||||||
|
|
||||||
// Event wiring
|
// Event wiring
|
||||||
if (window.__TAURI__ && window.__TAURI__.event) {
|
if (window.__TAURI__ && window.__TAURI__.event) {
|
||||||
window.__TAURI__.event.listen('tunnel-error', (evt) => {
|
window.__TAURI__.event.listen('tunnel-error', (evt) => {
|
||||||
setState('disconnected');
|
setState('disconnected');
|
||||||
showToast(String(evt.payload), 'error');
|
showToast(String(evt.payload), 'error');
|
||||||
|
alert(String(evt.payload));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,6 +491,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||||
updateDnsVisibility();
|
updateDnsVisibility();
|
||||||
scheduleAutoSave();
|
scheduleAutoSave();
|
||||||
});
|
});
|
||||||
|
inTun.addEventListener('change', () => {
|
||||||
|
updateKillSwitchVisibility();
|
||||||
|
scheduleAutoSave();
|
||||||
|
});
|
||||||
importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); });
|
importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); });
|
||||||
|
|
||||||
// Auto-save wiring
|
// Auto-save wiring
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,9 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient(
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
let proxy_shutdown_rx = shutdown_tx.subscribe();
|
let proxy_shutdown_rx = shutdown_tx.subscribe();
|
||||||
|
|
||||||
|
// Create exclusions channel
|
||||||
|
let (_, exclusions_rx) = watch::channel(config.exclusions.clone());
|
||||||
|
|
||||||
let metrics_clone = Arc::clone(&metrics);
|
let metrics_clone = Arc::clone(&metrics);
|
||||||
|
|
||||||
// Spawn async tasks inside runtime
|
// Spawn async tasks inside runtime
|
||||||
|
|
@ -225,7 +228,7 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient(
|
||||||
tunnel::run_local_proxy(
|
tunnel::run_local_proxy(
|
||||||
config_proxy.local_proxy,
|
config_proxy.local_proxy,
|
||||||
config_proxy.ostp,
|
config_proxy.ostp,
|
||||||
config_proxy.exclusions,
|
exclusions_rx,
|
||||||
config_proxy.debug,
|
config_proxy.debug,
|
||||||
proxy_shutdown_rx,
|
proxy_shutdown_rx,
|
||||||
proxy_events_tx,
|
proxy_events_tx,
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,4 @@ simple-dns = "0.11.3"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
chacha20poly1305.workspace = true
|
chacha20poly1305.workspace = true
|
||||||
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
|
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
|
||||||
|
chrono = "0.4.44"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
use crate::dispatcher::{UserStats, UserStatsSnapshot};
|
use crate::dispatcher::{UserStats, UserStatsSnapshot};
|
||||||
|
use crate::outbound::OutboundRule;
|
||||||
|
|
||||||
// ── Shared state for API handlers ────────────────────────────────────────────
|
// ── Shared state for API handlers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -52,6 +53,28 @@ pub struct ApiState {
|
||||||
pub reality_query: String,
|
pub reality_query: String,
|
||||||
pub config_path: Option<std::path::PathBuf>,
|
pub config_path: Option<std::path::PathBuf>,
|
||||||
pub dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
pub dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
||||||
|
pub audit_logs: Arc<RwLock<Vec<AuditLogEntry>>>,
|
||||||
|
pub router: std::sync::Arc<crate::router::Router>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditLogEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub time: String,
|
||||||
|
#[serde(rename = "eventEn")]
|
||||||
|
pub event_en: String,
|
||||||
|
#[serde(rename = "eventRu")]
|
||||||
|
pub event_ru: String,
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateAuditLogRequest {
|
||||||
|
#[serde(rename = "eventEn")]
|
||||||
|
pub event_en: String,
|
||||||
|
#[serde(rename = "eventRu")]
|
||||||
|
pub event_ru: String,
|
||||||
|
pub success: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API configuration ────────────────────────────────────────────────────────
|
// ── API configuration ────────────────────────────────────────────────────────
|
||||||
|
|
@ -223,7 +246,15 @@ pub fn create_api_router(state: ApiState) -> Router {
|
||||||
.route("/login", post(handle_login))
|
.route("/login", post(handle_login))
|
||||||
.route("/dns/config", get(handle_get_dns_config).post(handle_post_dns_config))
|
.route("/dns/config", get(handle_get_dns_config).post(handle_post_dns_config))
|
||||||
.route("/dns/queries", get(handle_get_dns_queries))
|
.route("/dns/queries", get(handle_get_dns_queries))
|
||||||
.route("/dns/blocklists/refresh", post(handle_refresh_blocklists));
|
.route("/dns/blocklists/refresh", post(handle_refresh_blocklists))
|
||||||
|
.route(
|
||||||
|
"/audit",
|
||||||
|
get(handle_get_audit)
|
||||||
|
.post(handle_create_audit)
|
||||||
|
.delete(handle_clear_audit),
|
||||||
|
)
|
||||||
|
.route("/users/bulk", post(handle_bulk_create_users))
|
||||||
|
.route("/router/rules", get(handle_get_rules).put(handle_put_rules));
|
||||||
|
|
||||||
let webpath = state.webpath.clone();
|
let webpath = state.webpath.clone();
|
||||||
let webpath = webpath.trim_matches('/');
|
let webpath = webpath.trim_matches('/');
|
||||||
|
|
@ -262,6 +293,7 @@ pub async fn start_api_server(
|
||||||
reality_query: String,
|
reality_query: String,
|
||||||
config_path: Option<std::path::PathBuf>,
|
config_path: Option<std::path::PathBuf>,
|
||||||
dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
||||||
|
router: std::sync::Arc<crate::router::Router>,
|
||||||
) {
|
) {
|
||||||
let state = ApiState {
|
let state = ApiState {
|
||||||
access_keys,
|
access_keys,
|
||||||
|
|
@ -277,6 +309,8 @@ pub async fn start_api_server(
|
||||||
reality_query,
|
reality_query,
|
||||||
config_path,
|
config_path,
|
||||||
dns_server,
|
dns_server,
|
||||||
|
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
router,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = create_api_router(state);
|
let app = create_api_router(state);
|
||||||
|
|
@ -542,15 +576,16 @@ async fn handle_list_users(
|
||||||
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
let mut users: Vec<UserStatsSnapshot> = keys.iter().map(|(key, meta)| {
|
let mut users: Vec<UserStatsSnapshot> = keys.iter().map(|(key, meta)| {
|
||||||
if let Some(us) = stats.get(key) {
|
if let Some(st) = stats.get(key) {
|
||||||
UserStatsSnapshot {
|
UserStatsSnapshot {
|
||||||
access_key: key.clone(),
|
access_key: key.clone(),
|
||||||
name: meta.name.clone(),
|
name: meta.name.clone(),
|
||||||
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
bytes_up: st.bytes_up.load(Ordering::Relaxed),
|
||||||
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
bytes_down: st.bytes_down.load(Ordering::Relaxed),
|
||||||
connections: us.connections.load(Ordering::Relaxed),
|
connections: st.connections.load(Ordering::Relaxed),
|
||||||
limit_bytes: us.limit_bytes,
|
limit_bytes: st.limit_bytes,
|
||||||
online: true,
|
online: st.connections.load(Ordering::Relaxed) > 0,
|
||||||
|
last_seen: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
UserStatsSnapshot {
|
UserStatsSnapshot {
|
||||||
|
|
@ -561,6 +596,7 @@ async fn handle_list_users(
|
||||||
connections: 0,
|
connections: 0,
|
||||||
limit_bytes: meta.limit_bytes,
|
limit_bytes: meta.limit_bytes,
|
||||||
online: false,
|
online: false,
|
||||||
|
last_seen: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
@ -586,15 +622,16 @@ async fn handle_get_user(
|
||||||
};
|
};
|
||||||
|
|
||||||
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
let stats = state.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
||||||
let snapshot = if let Some(us) = stats.get(&key) {
|
let snapshot = if let Some(st) = stats.get(&key) {
|
||||||
UserStatsSnapshot {
|
UserStatsSnapshot {
|
||||||
access_key: key.clone(),
|
access_key: key.clone(),
|
||||||
name: meta.name.clone(),
|
name: meta.name.clone(),
|
||||||
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
bytes_up: st.bytes_up.load(Ordering::Relaxed),
|
||||||
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
bytes_down: st.bytes_down.load(Ordering::Relaxed),
|
||||||
connections: us.connections.load(Ordering::Relaxed),
|
connections: st.connections.load(Ordering::Relaxed),
|
||||||
limit_bytes: us.limit_bytes,
|
limit_bytes: st.limit_bytes,
|
||||||
online: true,
|
online: st.connections.load(Ordering::Relaxed) > 0,
|
||||||
|
last_seen: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
UserStatsSnapshot {
|
UserStatsSnapshot {
|
||||||
|
|
@ -605,6 +642,7 @@ async fn handle_get_user(
|
||||||
connections: 0,
|
connections: 0,
|
||||||
limit_bytes: meta.limit_bytes,
|
limit_bytes: meta.limit_bytes,
|
||||||
online: false,
|
online: false,
|
||||||
|
last_seen: None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -939,6 +977,8 @@ mod tests {
|
||||||
reality_query: "".to_string(),
|
reality_query: "".to_string(),
|
||||||
config_path: None,
|
config_path: None,
|
||||||
dns_server: crate::dns::DnsServer::new(Default::default()),
|
dns_server: crate::dns::DnsServer::new(Default::default()),
|
||||||
|
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
router: Arc::new(crate::router::Router::new(None, crate::dns::DnsServer::new(Default::default()), false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -955,3 +995,126 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_get_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
|
let logs = state.audit_logs.read().unwrap();
|
||||||
|
ApiResponse::success(logs.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse {
|
||||||
|
let mut logs = state.audit_logs.write().unwrap();
|
||||||
|
let id = format!("{:x}", rand::random::<u64>());
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let entry = AuditLogEntry {
|
||||||
|
id,
|
||||||
|
time: now.format("%H:%M:%S").to_string(),
|
||||||
|
event_en: req.event_en,
|
||||||
|
event_ru: req.event_ru,
|
||||||
|
success: req.success,
|
||||||
|
};
|
||||||
|
logs.insert(0, entry);
|
||||||
|
if logs.len() > 100 {
|
||||||
|
logs.truncate(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse::success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk keys & Router Rules ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BulkCreateRequest {
|
||||||
|
pub count: usize,
|
||||||
|
pub limit_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_bulk_create_users(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(payload): Json<BulkCreateRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) { return api_unauthorized(); }
|
||||||
|
if payload.count > 1000 {
|
||||||
|
return api_error("Count too large, max 1000");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_keys = Vec::new();
|
||||||
|
let mut keys = match state.access_keys.write() {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => e.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for _ in 0..payload.count {
|
||||||
|
let key = uuid::Uuid::new_v4().to_string().replace("-", "")[..16].to_string();
|
||||||
|
keys.insert(key.clone(), UserMeta {
|
||||||
|
name: None,
|
||||||
|
limit_bytes: payload.limit_bytes,
|
||||||
|
});
|
||||||
|
new_keys.push(key);
|
||||||
|
}
|
||||||
|
// Save keys by dropping lock, then saving to config
|
||||||
|
drop(keys);
|
||||||
|
let _ = save_config_keys(&state);
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(new_keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_get_rules(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) { return api_unauthorized(); }
|
||||||
|
|
||||||
|
let lock = state.router.outbound_cfg.read().unwrap();
|
||||||
|
let rules = if let Some(cfg) = lock.as_ref() {
|
||||||
|
cfg.rules.clone()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_put_rules(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(new_rules): Json<Vec<OutboundRule>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) { return api_unauthorized(); }
|
||||||
|
|
||||||
|
// Update memory
|
||||||
|
{
|
||||||
|
let mut lock = state.router.outbound_cfg.write().unwrap();
|
||||||
|
if let Some(cfg) = lock.as_mut() {
|
||||||
|
cfg.rules = new_rules.clone();
|
||||||
|
} else {
|
||||||
|
return api_error("Outbound routing is not enabled in config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to config.json
|
||||||
|
if let Some(path) = &state.config_path {
|
||||||
|
if let Ok(content) = std::fs::read_to_string(path) {
|
||||||
|
let mut cfg: serde_json::Value = serde_json::from_str(&content).unwrap_or_default();
|
||||||
|
if let Some(obj) = cfg.as_object_mut() {
|
||||||
|
if let Some(outbound) = obj.get_mut("outbound") {
|
||||||
|
if let Some(outbound_obj) = outbound.as_object_mut() {
|
||||||
|
if let Ok(rules_json) = serde_json::to_value(&new_rules) {
|
||||||
|
outbound_obj.insert("rules".to_string(), rules_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::write(path, serde_json::to_string_pretty(&cfg).unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_clear_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
||||||
|
let mut logs = state.audit_logs.write().unwrap();
|
||||||
|
logs.clear();
|
||||||
|
ApiResponse::success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ pub struct UserStatsSnapshot {
|
||||||
pub connections: u64,
|
pub connections: u64,
|
||||||
pub limit_bytes: Option<u64>,
|
pub limit_bytes: Option<u64>,
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
|
pub last_seen: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PeerState {
|
pub struct PeerState {
|
||||||
|
|
@ -109,17 +110,37 @@ impl Dispatcher {
|
||||||
/// Snapshot all user stats for API responses.
|
/// Snapshot all user stats for API responses.
|
||||||
pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> {
|
pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> {
|
||||||
let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner());
|
||||||
let online_keys: std::collections::HashSet<String> = self.peer_machines.values()
|
let mut online_keys: HashMap<String, std::time::Instant> = HashMap::new();
|
||||||
.map(|ps| ps.access_key.clone())
|
for ps in self.peer_machines.values() {
|
||||||
.collect();
|
let key = ps.access_key.clone();
|
||||||
stats.iter().map(|(key, us)| UserStatsSnapshot {
|
if let Some(existing) = online_keys.get(&key) {
|
||||||
|
if ps.last_seen > *existing {
|
||||||
|
online_keys.insert(key, ps.last_seen);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
online_keys.insert(key, ps.last_seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let current_sys_time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
|
||||||
|
stats.iter().map(|(key, us)| {
|
||||||
|
let last_seen_unix = online_keys.get(key).map(|&instant| {
|
||||||
|
let diff = now.duration_since(instant).as_secs();
|
||||||
|
current_sys_time.saturating_sub(diff)
|
||||||
|
});
|
||||||
|
|
||||||
|
UserStatsSnapshot {
|
||||||
access_key: key.clone(),
|
access_key: key.clone(),
|
||||||
name: None,
|
name: None,
|
||||||
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
||||||
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
||||||
connections: us.connections.load(Ordering::Relaxed),
|
connections: us.connections.load(Ordering::Relaxed),
|
||||||
limit_bytes: us.limit_bytes,
|
limit_bytes: us.limit_bytes,
|
||||||
online: online_keys.contains(key),
|
online: online_keys.contains_key(key),
|
||||||
|
last_seen: last_seen_unix,
|
||||||
|
}
|
||||||
}).collect()
|
}).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,29 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DnsConfig {
|
pub struct DnsConfig {
|
||||||
|
/// Включить полный DNS: кастомные домены + AdBlock списки + DoH форвардинг
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// Перехватывать весь UDP-трафик к порту :53 и резолвить через DoH,
|
||||||
|
/// даже если `enabled = false`. Это предотвращает DNS-утечки через сервер.
|
||||||
|
#[serde(default)]
|
||||||
|
pub intercept_all_port53: bool,
|
||||||
|
/// Порт на котором встроенный DNS-сервер слушает UDP-запросы (по умолчанию 50053).
|
||||||
|
/// Клиенты могут указать <server_ip>:50053 в качестве DNS-сервера.
|
||||||
|
#[serde(default = "default_dns_local_port")]
|
||||||
|
pub local_port: u16,
|
||||||
pub doh_upstream: String,
|
pub doh_upstream: String,
|
||||||
pub adblock_urls: Vec<String>,
|
pub adblock_urls: Vec<String>,
|
||||||
pub custom_domains: HashMap<String, String>,
|
pub custom_domains: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_dns_local_port() -> u16 { 50053 }
|
||||||
|
|
||||||
impl Default for DnsConfig {
|
impl Default for DnsConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
intercept_all_port53: false,
|
||||||
|
local_port: 50053,
|
||||||
doh_upstream: "https://cloudflare-dns.com/dns-query".to_string(),
|
doh_upstream: "https://cloudflare-dns.com/dns-query".to_string(),
|
||||||
adblock_urls: vec![],
|
adblock_urls: vec![],
|
||||||
custom_domains: HashMap::new(),
|
custom_domains: HashMap::new(),
|
||||||
|
|
@ -33,7 +46,7 @@ pub struct DnsQueryLog {
|
||||||
|
|
||||||
pub struct DnsServer {
|
pub struct DnsServer {
|
||||||
pub config: RwLock<DnsConfig>,
|
pub config: RwLock<DnsConfig>,
|
||||||
adblock_trie: RwLock<HashSet<String>>, // Simplified to HashSet for now, or maybe a suffix tree
|
adblock_trie: RwLock<HashSet<String>>,
|
||||||
query_log: Mutex<VecDeque<DnsQueryLog>>,
|
query_log: Mutex<VecDeque<DnsQueryLog>>,
|
||||||
reqwest_client: reqwest::Client,
|
reqwest_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +62,7 @@ impl DnsServer {
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn a background task to download blocklists
|
// Загружаем блок-листы при старте если DNS включён
|
||||||
if config.enabled && !config.adblock_urls.is_empty() {
|
if config.enabled && !config.adblock_urls.is_empty() {
|
||||||
let server_clone = server.clone();
|
let server_clone = server.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -60,6 +73,7 @@ impl DnsServer {
|
||||||
server
|
server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Скачать и обновить все AdBlock-листы.
|
||||||
pub async fn update_blocklists(&self) {
|
pub async fn update_blocklists(&self) {
|
||||||
let urls = {
|
let urls = {
|
||||||
let cfg = self.config.read().await;
|
let cfg = self.config.read().await;
|
||||||
|
|
@ -68,43 +82,73 @@ impl DnsServer {
|
||||||
|
|
||||||
let mut new_blocked = HashSet::new();
|
let mut new_blocked = HashSet::new();
|
||||||
|
|
||||||
for url in urls {
|
for url in &urls {
|
||||||
if let Ok(resp) = self.reqwest_client.get(&url).send().await {
|
tracing::info!("DNS: downloading AdBlock list from {url}");
|
||||||
if let Ok(text) = resp.text().await {
|
match self.reqwest_client.get(url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
match resp.text().await {
|
||||||
|
Ok(text) => {
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.is_empty() || line.starts_with('#') {
|
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Support standard hosts format: "0.0.0.0 ads.google.com" or just "ads.google.com"
|
// Формат hosts: "0.0.0.0 ads.google.com" или просто "ads.google.com"
|
||||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
// Формат adblock: "||ads.google.com^" или "ads.google.com"
|
||||||
let domain = if parts.len() >= 2 && (parts[0] == "0.0.0.0" || parts[0] == "127.0.0.1") {
|
let domain = if line.starts_with("||") && line.ends_with('^') {
|
||||||
parts[1]
|
line.trim_start_matches("||").trim_end_matches('^')
|
||||||
} else {
|
} else {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 2 && (parts[0] == "0.0.0.0" || parts[0] == "127.0.0.1") {
|
||||||
|
parts[1]
|
||||||
|
} else if parts.len() == 1 {
|
||||||
parts[0]
|
parts[0]
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
// Пропускаем localhost и wildcard-мусор
|
||||||
|
if domain == "localhost" || domain.contains('*') || domain.contains(' ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
new_blocked.insert(domain.to_lowercase());
|
new_blocked.insert(domain.to_lowercase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => tracing::warn!("DNS: failed to read AdBlock list {url}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("DNS: failed to fetch AdBlock list {url}: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Loaded {} domains into AdBlock engine", new_blocked.len());
|
tracing::info!("DNS: loaded {} domains into AdBlock engine from {} lists", new_blocked.len(), urls.len());
|
||||||
*self.adblock_trie.write().await = new_blocked;
|
*self.adblock_trie.write().await = new_blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Резолвить DNS-запрос.
|
||||||
|
///
|
||||||
|
/// Поведение зависит от конфигурации:
|
||||||
|
/// - `enabled=true`: кастомные домены → AdBlock → DoH
|
||||||
|
/// - `intercept_all_port53=true`: минуя AdBlock/custom, всегда форвардит через DoH
|
||||||
|
/// - оба `false`: возвращает `None` (трафик идёт напрямую к целевому DNS-серверу)
|
||||||
pub async fn resolve(&self, payload: &[u8], client_ip: std::net::IpAddr) -> Option<Vec<u8>> {
|
pub async fn resolve(&self, payload: &[u8], client_ip: std::net::IpAddr) -> Option<Vec<u8>> {
|
||||||
let cfg = self.config.read().await;
|
let cfg = self.config.read().await;
|
||||||
if !cfg.enabled {
|
|
||||||
return None; // If DNS is disabled, fallback to standard UDP proxying
|
// Если оба флага выключены — не вмешиваемся
|
||||||
|
if !cfg.enabled && !cfg.intercept_all_port53 {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse DNS packet
|
let enabled = cfg.enabled;
|
||||||
|
let intercept = cfg.intercept_all_port53;
|
||||||
|
let doh_url = cfg.doh_upstream.clone();
|
||||||
|
drop(cfg); // Освобождаем блокировку до IO
|
||||||
|
|
||||||
|
// Парсим DNS-пакет
|
||||||
let packet = match Packet::parse(payload) {
|
let packet = match Packet::parse(payload) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(_) => return None,
|
Err(_) => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if packet.questions.is_empty() {
|
if packet.questions.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +156,11 @@ impl DnsServer {
|
||||||
let question = &packet.questions[0];
|
let question = &packet.questions[0];
|
||||||
let qname = question.qname.to_string().to_lowercase();
|
let qname = question.qname.to_string().to_lowercase();
|
||||||
|
|
||||||
// Check Custom Domains
|
// ── Полный DNS-режим (enabled=true) ───────────────────────────────────
|
||||||
|
if enabled {
|
||||||
|
// 1. Кастомные домены (прямой ответ из конфига)
|
||||||
|
{
|
||||||
|
let cfg = self.config.read().await;
|
||||||
if let Some(ip_str) = cfg.custom_domains.get(&qname) {
|
if let Some(ip_str) = cfg.custom_domains.get(&qname) {
|
||||||
if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
|
if let Ok(ip) = ip_str.parse::<std::net::Ipv4Addr>() {
|
||||||
if question.qtype == QTYPE::TYPE(TYPE::A) {
|
if question.qtype == QTYPE::TYPE(TYPE::A) {
|
||||||
|
|
@ -129,9 +177,9 @@ impl DnsServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check AdBlock (Suffix matching not implemented in this simple hashset, for full pi-hole we need suffix match)
|
// 2. AdBlock (suffix matching)
|
||||||
// Let's do a simple suffix check
|
|
||||||
let blocked = {
|
let blocked = {
|
||||||
let blocked_domains = self.adblock_trie.read().await;
|
let blocked_domains = self.adblock_trie.read().await;
|
||||||
let mut parts: Vec<&str> = qname.split('.').collect();
|
let mut parts: Vec<&str> = qname.split('.').collect();
|
||||||
|
|
@ -148,41 +196,106 @@ impl DnsServer {
|
||||||
};
|
};
|
||||||
|
|
||||||
if blocked {
|
if blocked {
|
||||||
|
// Возвращаем пустой NXDOMAIN-ответ
|
||||||
let mut response = Packet::new_reply(packet.id());
|
let mut response = Packet::new_reply(packet.id());
|
||||||
response.questions.push(question.clone());
|
response.questions.push(question.clone());
|
||||||
self.log_query(qname, client_ip.to_string(), true).await;
|
self.log_query(qname.clone(), client_ip.to_string(), true).await;
|
||||||
|
tracing::debug!("DNS AdBlock: blocked {qname} for {client_ip}");
|
||||||
return response.build_bytes_vec().ok();
|
return response.build_bytes_vec().ok();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Forward to DoH
|
// ── Форвардинг через DoH ──────────────────────────────────────────────
|
||||||
let doh_url = cfg.doh_upstream.clone();
|
// Работает и при enabled=true и при intercept_all_port53=true
|
||||||
drop(cfg); // Release config lock before making network request
|
tracing::debug!("DNS: resolving {qname} via DoH for {client_ip}");
|
||||||
|
match self.reqwest_client
|
||||||
if let Ok(resp) = self.reqwest_client.post(&doh_url)
|
.post(&doh_url)
|
||||||
.header("Content-Type", "application/dns-message")
|
.header("Content-Type", "application/dns-message")
|
||||||
.header("Accept", "application/dns-message")
|
.header("Accept", "application/dns-message")
|
||||||
.body(payload.to_vec())
|
.body(payload.to_vec())
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if resp.status().is_success() {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
if let Ok(bytes) = resp.bytes().await {
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
self.log_query(qname, client_ip.to_string(), false).await;
|
self.log_query(qname, client_ip.to_string(), false).await;
|
||||||
return Some(bytes.to_vec());
|
return Some(bytes.to_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
tracing::warn!("DNS DoH upstream returned {}: {qname}", resp.status());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("DNS DoH upstream error for {qname}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если DoH упал и мы в режиме перехвата — возвращаем SERVFAIL
|
||||||
|
// чтобы не пустить запрос напрямую к 8.8.8.8 с IP сервера
|
||||||
|
if intercept && !enabled {
|
||||||
|
let mut response = Packet::new_reply(packet.id());
|
||||||
|
response.questions.push(question.clone());
|
||||||
|
// Устанавливаем RCODE=2 (SERVFAIL) вручную в raw байтах
|
||||||
|
if let Ok(mut bytes) = response.build_bytes_vec() {
|
||||||
|
if bytes.len() >= 4 {
|
||||||
|
bytes[3] = (bytes[3] & 0xF0) | 0x02; // RCODE=SERVFAIL
|
||||||
|
}
|
||||||
|
return Some(bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запустить встроенный UDP DNS-сервер на порту `config.local_port`.
|
||||||
|
///
|
||||||
|
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
|
||||||
|
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
|
||||||
|
pub async fn run_local_udp_listener(self: Arc<Self>) {
|
||||||
|
let port = self.config.read().await.local_port;
|
||||||
|
let bind_addr = format!("0.0.0.0:{port}");
|
||||||
|
|
||||||
|
let socket = match tokio::net::UdpSocket::bind(&bind_addr).await {
|
||||||
|
Ok(s) => Arc::new(s),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Built-in DNS server failed to bind on {bind_addr}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!("Built-in DNS server listening on UDP {bind_addr}");
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match socket.recv_from(&mut buf).await {
|
||||||
|
Ok((n, peer)) => {
|
||||||
|
let query = buf[..n].to_vec();
|
||||||
|
let srv = self.clone();
|
||||||
|
let sock = socket.clone();
|
||||||
|
let client_ip = peer.ip();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Some(response) = srv.resolve(&query, client_ip).await {
|
||||||
|
let _ = sock.send_to(&response, peer).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Built-in DNS listener recv error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn log_query(&self, domain: String, client_ip: String, blocked: bool) {
|
async fn log_query(&self, domain: String, client_ip: String, blocked: bool) {
|
||||||
let mut log = self.query_log.lock().await;
|
let mut log = self.query_log.lock().await;
|
||||||
if log.len() >= 1000 {
|
if log.len() >= 1000 {
|
||||||
log.pop_front();
|
log.pop_front();
|
||||||
}
|
}
|
||||||
log.push_back(DnsQueryLog {
|
log.push_back(DnsQueryLog {
|
||||||
timestamp: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
|
timestamp: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
domain,
|
domain,
|
||||||
client_ip,
|
client_ip,
|
||||||
blocked,
|
blocked,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ pub mod relay_node;
|
||||||
mod relay;
|
mod relay;
|
||||||
mod signal;
|
mod signal;
|
||||||
pub mod dns;
|
pub mod dns;
|
||||||
|
pub mod router;
|
||||||
|
|
||||||
pub use outbound::{OutboundAction, OutboundConfig, OutboundRule};
|
pub use outbound::{OutboundAction, OutboundConfig, OutboundRule};
|
||||||
pub use api::ApiConfig;
|
pub use api::ApiConfig;
|
||||||
|
|
@ -245,8 +246,22 @@ pub async fn run_server(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize DNS server
|
// Инициализируем DNS-сервер
|
||||||
let dns_server = dns::DnsServer::new(dns_config.unwrap_or_default());
|
let dns_cfg = dns_config.unwrap_or_default();
|
||||||
|
// Запускаем UDP listener если dns.enabled=true (полный режим) или intercept_all_port53=true
|
||||||
|
let start_dns_listener = dns_cfg.enabled || dns_cfg.intercept_all_port53;
|
||||||
|
let dns_server = dns::DnsServer::new(dns_cfg);
|
||||||
|
if start_dns_listener {
|
||||||
|
let dns_srv = dns_server.clone();
|
||||||
|
tokio::spawn(async move { dns_srv.run_local_udp_listener().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Router
|
||||||
|
let router = std::sync::Arc::new(router::Router::new(
|
||||||
|
outbound.clone(),
|
||||||
|
dns_server.clone(),
|
||||||
|
debug,
|
||||||
|
));
|
||||||
|
|
||||||
// Spawn Management API if configured
|
// Spawn Management API if configured
|
||||||
if let Some(api_cfg) = api_config {
|
if let Some(api_cfg) = api_config {
|
||||||
|
|
@ -261,8 +276,9 @@ pub async fn run_server(
|
||||||
let rq = reality_query.clone().unwrap_or_default();
|
let rq = reality_query.clone().unwrap_or_default();
|
||||||
let config_path_api = config_path.clone();
|
let config_path_api = config_path.clone();
|
||||||
let dns_server_api = dns_server.clone();
|
let dns_server_api = dns_server.clone();
|
||||||
|
let router_api = router.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api).await;
|
api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api, router_api).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +330,7 @@ pub async fn run_server(
|
||||||
let reality_config_arc = reality_config.map(std::sync::Arc::new);
|
let reality_config_arc = reality_config.map(std::sync::Arc::new);
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug, reality_config_arc, dns_server) => {
|
res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router, reality_config_arc) => {
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
tracing::error!("Server error: {e}");
|
tracing::error!("Server error: {e}");
|
||||||
}
|
}
|
||||||
|
|
@ -337,10 +353,8 @@ async fn run_server_loop(
|
||||||
mut ui_cmd_rx: mpsc::UnboundedReceiver<UiCommand>,
|
mut ui_cmd_rx: mpsc::UnboundedReceiver<UiCommand>,
|
||||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||||
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
|
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
|
||||||
outbound: Option<OutboundConfig>,
|
router: std::sync::Arc<crate::router::Router>,
|
||||||
debug: bool,
|
|
||||||
reality_config: Option<std::sync::Arc<RealityServerConfig>>,
|
reality_config: Option<std::sync::Arc<RealityServerConfig>>,
|
||||||
dns_server: std::sync::Arc<dns::DnsServer>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
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>)>();
|
||||||
|
|
@ -432,7 +446,7 @@ async fn run_server_loop(
|
||||||
|
|
||||||
drop(udp_tx); // Drop the original sender so the channel closes when all tasks end
|
drop(udp_tx); // Drop the original sender so the channel closes when all tasks end
|
||||||
|
|
||||||
if debug {
|
if router.debug {
|
||||||
let _ = ui_event_tx.send(UiEvent::Log("Server loop started".to_string()));
|
let _ = ui_event_tx.send(UiEvent::Log("Server loop started".to_string()));
|
||||||
let _ = ui_event_tx.send(UiEvent::KeyCount(shared_keys.read().unwrap_or_else(|e| e.into_inner()).len()));
|
let _ = ui_event_tx.send(UiEvent::KeyCount(shared_keys.read().unwrap_or_else(|e| e.into_inner()).len()));
|
||||||
}
|
}
|
||||||
|
|
@ -462,7 +476,7 @@ async fn run_server_loop(
|
||||||
if let Err(e) = handle_udp_packet(
|
if let Err(e) = handle_udp_packet(
|
||||||
packet, peer, &mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx,
|
packet, peer, &mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx,
|
||||||
stream_tx.clone(), udp_reply_tx.clone(), connect_tx.clone(),
|
stream_tx.clone(), udp_reply_tx.clone(), connect_tx.clone(),
|
||||||
outbound.clone(), dns_server.clone(), debug,
|
router.clone(),
|
||||||
&mut peer_last_seen, &mut peer_available, &mut last_empty_app_log
|
&mut peer_last_seen, &mut peer_available, &mut last_empty_app_log
|
||||||
).await {
|
).await {
|
||||||
tracing::error!("handle_udp_packet error: {}", e);
|
tracing::error!("handle_udp_packet error: {}", e);
|
||||||
|
|
@ -529,9 +543,7 @@ async fn handle_udp_packet(
|
||||||
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<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
|
||||||
outbound: Option<OutboundConfig>,
|
router: std::sync::Arc<crate::router::Router>,
|
||||||
dns_server: std::sync::Arc<dns::DnsServer>,
|
|
||||||
debug: bool,
|
|
||||||
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>,
|
||||||
last_empty_app_log: &mut Instant,
|
last_empty_app_log: &mut Instant,
|
||||||
|
|
@ -594,9 +606,7 @@ async fn handle_udp_packet(
|
||||||
stream_tx.clone(),
|
stream_tx.clone(),
|
||||||
udp_reply_tx.clone(),
|
udp_reply_tx.clone(),
|
||||||
connect_tx.clone(),
|
connect_tx.clone(),
|
||||||
outbound.clone(),
|
router.clone(),
|
||||||
dns_server.clone(),
|
|
||||||
debug,
|
|
||||||
tcp_map,
|
tcp_map,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum OutboundAction {
|
pub enum OutboundAction {
|
||||||
Proxy,
|
Proxy,
|
||||||
Direct,
|
Direct,
|
||||||
|
Block,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OutboundRule {
|
pub struct OutboundRule {
|
||||||
|
#[serde(default)]
|
||||||
pub domain_suffix: Vec<String>,
|
pub domain_suffix: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub ip_cidr: Vec<String>,
|
pub ip_cidr: Vec<String>,
|
||||||
pub action: OutboundAction,
|
pub action: OutboundAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OutboundConfig {
|
pub struct OutboundConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub protocol: String,
|
pub protocol: String,
|
||||||
|
|
@ -36,6 +41,9 @@ pub async fn connect_target(
|
||||||
if let Some(outbound) = outbound {
|
if let Some(outbound) = outbound {
|
||||||
if outbound.enabled {
|
if outbound.enabled {
|
||||||
let action = select_outbound_action(target, outbound, debug).await;
|
let action = select_outbound_action(target, outbound, debug).await;
|
||||||
|
if action == OutboundAction::Block {
|
||||||
|
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
|
||||||
|
}
|
||||||
if action == OutboundAction::Proxy {
|
if action == OutboundAction::Proxy {
|
||||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||||
return match outbound.protocol.as_str() {
|
return match outbound.protocol.as_str() {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ use tokio::net::UdpSocket;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::dispatcher::Dispatcher;
|
use crate::dispatcher::Dispatcher;
|
||||||
use crate::outbound::{self, OutboundConfig};
|
|
||||||
use crate::{RemoteState, UiEvent};
|
use crate::{RemoteState, UiEvent};
|
||||||
|
|
||||||
pub async fn handle_relay_message(
|
pub async fn handle_relay_message(
|
||||||
|
|
@ -23,15 +22,13 @@ pub async fn handle_relay_message(
|
||||||
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<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
|
||||||
outbound_cfg: Option<OutboundConfig>,
|
router: std::sync::Arc<crate::router::Router>,
|
||||||
dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
|
||||||
debug: bool,
|
|
||||||
tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>,
|
tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match RelayMessage::decode(&payload)? {
|
match RelayMessage::decode(&payload)? {
|
||||||
RelayMessage::Connect(target) => {
|
RelayMessage::Connect(target) => {
|
||||||
// DNS interception disabled for stability
|
// DNS interception disabled for stability
|
||||||
let is_internal_dns = false;
|
let _is_internal_dns = false;
|
||||||
|
|
||||||
let mut connect_target = target.clone();
|
let mut connect_target = target.clone();
|
||||||
if connect_target.starts_with("10.1.0.1:") {
|
if connect_target.starts_with("10.1.0.1:") {
|
||||||
|
|
@ -41,9 +38,9 @@ pub async fn handle_relay_message(
|
||||||
let target_clone = connect_target.clone();
|
let target_clone = connect_target.clone();
|
||||||
let connect_tx_clone = connect_tx.clone();
|
let connect_tx_clone = connect_tx.clone();
|
||||||
let stream_tx_clone = stream_tx.clone();
|
let stream_tx_clone = stream_tx.clone();
|
||||||
let outbound_clone = outbound_cfg.clone();
|
let router_clone = router.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let stream_res = outbound::connect_target(&target_clone, outbound_clone.as_ref(), debug).await;
|
let stream_res = router_clone.route_tcp(&target_clone).await;
|
||||||
match stream_res {
|
match stream_res {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
let (mut reader, writer) = stream.into_split();
|
let (mut reader, writer) = stream.into_split();
|
||||||
|
|
@ -100,7 +97,7 @@ pub async fn handle_relay_message(
|
||||||
}
|
}
|
||||||
RelayMessage::Pong(_) => {}
|
RelayMessage::Pong(_) => {}
|
||||||
RelayMessage::UdpAssociate => {
|
RelayMessage::UdpAssociate => {
|
||||||
if debug {
|
if router.debug {
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}")));
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}")));
|
||||||
}
|
}
|
||||||
let udp_bind_result = match UdpSocket::bind("[::]:0").await {
|
let udp_bind_result = match UdpSocket::bind("[::]:0").await {
|
||||||
|
|
@ -121,9 +118,9 @@ pub async fn handle_relay_message(
|
||||||
|
|
||||||
// Outbound UDP loop (tunnel -> target)
|
// Outbound UDP loop (tunnel -> target)
|
||||||
let tx_sock = server_udp.clone();
|
let tx_sock = server_udp.clone();
|
||||||
let dns_srv = dns_server.clone();
|
let _dns_srv = router.dns_server.clone();
|
||||||
let udp_reply_clone_dns = udp_reply_tx.clone();
|
let _udp_reply_clone_dns = udp_reply_tx.clone();
|
||||||
let client_ip = peer_addr.ip();
|
let _client_ip = peer_addr.ip();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some((target, data)) = udp_rx.recv().await {
|
while let Some((target, data)) = udp_rx.recv().await {
|
||||||
let mut forward_target = target.clone();
|
let mut forward_target = target.clone();
|
||||||
|
|
@ -175,6 +172,41 @@ pub async fn handle_relay_message(
|
||||||
}
|
}
|
||||||
RelayMessage::UdpData(target, data) => {
|
RelayMessage::UdpData(target, data) => {
|
||||||
if let Some(remote) = remotes.get_mut(&(session_id, stream_id)) {
|
if let Some(remote) = remotes.get_mut(&(session_id, stream_id)) {
|
||||||
|
// Если целевой порт 53 — пробуем перехватить через встроенный DNS
|
||||||
|
if target.ends_with(":53") {
|
||||||
|
let should_intercept = {
|
||||||
|
let cfg = router.dns_server.config.read().await;
|
||||||
|
cfg.enabled || cfg.intercept_all_port53
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_intercept {
|
||||||
|
match router.route_dns(peer_addr.ip(), &data).await {
|
||||||
|
Some(response) => {
|
||||||
|
let _ = udp_reply_tx.send((session_id, stream_id, target, response));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// route_dns вернул None — значит DoH упал и enabled=true
|
||||||
|
// в режиме перехвата уже вернул SERVFAIL
|
||||||
|
// просто блокируем, не пускаем к 8.8.8.8 с IP сервера
|
||||||
|
if router.debug {
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Log(format!(
|
||||||
|
"DNS [{session_id}:{stream_id}] DoH failed for {target}, dropping (intercept=true)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// intercept отключён: forward как обычный UDP
|
||||||
|
if router.debug {
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Log(format!(
|
||||||
|
"DNS [{session_id}:{stream_id}] passthrough to {target} (intercept disabled)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref udp_tx) = remote.udp_tx {
|
if let Some(ref udp_tx) = remote.udp_tx {
|
||||||
let _ = udp_tx.send((target, Bytes::from(data)));
|
let _ = udp_tx.send((target, Bytes::from(data)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crate::outbound::{OutboundConfig, connect_target};
|
||||||
|
use crate::dns::DnsServer;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Router {
|
||||||
|
pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>,
|
||||||
|
pub dns_server: Arc<DnsServer>,
|
||||||
|
pub debug: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Router {
|
||||||
|
pub fn new(outbound_cfg: Option<OutboundConfig>, dns_server: Arc<DnsServer>, debug: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
outbound_cfg: Arc::new(RwLock::new(outbound_cfg)),
|
||||||
|
dns_server,
|
||||||
|
debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TCP Target Routing
|
||||||
|
pub async fn route_tcp(&self, target: &str) -> Result<TcpStream> {
|
||||||
|
let cfg = {
|
||||||
|
let lock = self.outbound_cfg.read().unwrap();
|
||||||
|
lock.clone() // Clone config to avoid holding lock across await point
|
||||||
|
};
|
||||||
|
connect_target(target, cfg.as_ref(), self.debug).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified DNS Routing and Resolution (AdBlock / Custom Domains / DoH)
|
||||||
|
pub async fn route_dns(&self, client_ip: std::net::IpAddr, payload: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
self.dns_server.resolve(payload, client_ip).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ enum HelperMsg {
|
||||||
|
|
||||||
struct TunnelState {
|
struct TunnelState {
|
||||||
shutdown_tx: Option<watch::Sender<bool>>,
|
shutdown_tx: Option<watch::Sender<bool>>,
|
||||||
|
config_tx: Option<watch::Sender<ostp_client::config::ClientConfig>>,
|
||||||
metrics: Option<Arc<ostp_client::bridge::BridgeMetrics>>,
|
metrics: Option<Arc<ostp_client::bridge::BridgeMetrics>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,18 +62,19 @@ async fn main() -> Result<()> {
|
||||||
let mut port = 53211u16;
|
let mut port = 53211u16;
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
for i in 1..args.len() {
|
for i in 1..args.len() {
|
||||||
|
if args[i] == "--port" && i + 1 < args.len() {
|
||||||
|
port = args[i + 1].parse().unwrap_or(53211);
|
||||||
|
}
|
||||||
if args[i] == "--token" && i + 1 < args.len() {
|
if args[i] == "--token" && i + 1 < args.len() {
|
||||||
expected_token = args[i + 1].clone();
|
expected_token = args[i + 1].clone();
|
||||||
} else if args[i] == "--port" && i + 1 < args.len() {
|
|
||||||
port = args[i + 1].parse().unwrap_or(53211);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log_to_file("Helper started (TCP mode)");
|
log_to_file("Helper started (TCP mode)");
|
||||||
|
|
||||||
if expected_token.is_empty() {
|
if expected_token.is_empty() {
|
||||||
log_to_file("FATAL: --token argument is required for security. Unauthorized access denied.");
|
log_to_file("FATAL: OSTP_TUN_TOKEN environment variable is required for security. Unauthorized access denied.");
|
||||||
return Err(anyhow::anyhow!("--token argument is required"));
|
return Err(anyhow::anyhow!("OSTP_TUN_TOKEN environment variable is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = run_server(expected_token, port).await {
|
if let Err(e) = run_server(expected_token, port).await {
|
||||||
|
|
@ -85,6 +87,7 @@ async fn main() -> Result<()> {
|
||||||
async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
||||||
let state = Arc::new(Mutex::new(TunnelState {
|
let state = Arc::new(Mutex::new(TunnelState {
|
||||||
shutdown_tx: None,
|
shutdown_tx: None,
|
||||||
|
config_tx: None,
|
||||||
metrics: None,
|
metrics: None,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -180,10 +183,12 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
||||||
});
|
});
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
let (config_tx, config_rx) = watch::channel(cfg.clone());
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut st = state.lock().await;
|
let mut st = state.lock().await;
|
||||||
st.shutdown_tx = Some(shutdown_tx);
|
st.shutdown_tx = Some(shutdown_tx);
|
||||||
|
st.config_tx = Some(config_tx);
|
||||||
st.metrics = Some(metrics.clone());
|
st.metrics = Some(metrics.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +197,7 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
||||||
let shutdown_rx_for_core = shutdown_rx.clone();
|
let shutdown_rx_for_core = shutdown_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_to_file("Starting tunnel core...");
|
log_to_file("Starting tunnel core...");
|
||||||
match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core).await {
|
match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core, Some(config_rx)).await {
|
||||||
Ok(_) => { log_to_file("Tunnel core stopped normally"); }
|
Ok(_) => { log_to_file("Tunnel core stopped normally"); }
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_to_file(&format!("Tunnel core error: {}", e));
|
log_to_file(&format!("Tunnel core error: {}", e));
|
||||||
|
|
@ -243,15 +248,6 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
||||||
}
|
}
|
||||||
log_to_file("Received RELOAD command");
|
log_to_file("Received RELOAD command");
|
||||||
|
|
||||||
// Signal shutdown to current core
|
|
||||||
{
|
|
||||||
let mut st = state.lock().await;
|
|
||||||
if let Some(tx) = st.shutdown_tx.take() {
|
|
||||||
let _ = tx.send(true);
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await; // give it time to shutdown cleanly
|
|
||||||
}
|
|
||||||
|
|
||||||
let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) {
|
let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -260,68 +256,13 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let metrics = Arc::new(ostp_client::bridge::BridgeMetrics {
|
|
||||||
bytes_sent: portable_atomic::AtomicU64::new(0),
|
|
||||||
bytes_recv: portable_atomic::AtomicU64::new(0),
|
|
||||||
connection_state: portable_atomic::AtomicU8::new(0),
|
|
||||||
rtt_ms: portable_atomic::AtomicU32::new(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut st = state.lock().await;
|
let st = state.lock().await;
|
||||||
st.shutdown_tx = Some(shutdown_tx);
|
if let Some(tx) = &st.config_tx {
|
||||||
st.metrics = Some(metrics.clone());
|
let _ = tx.send(cfg);
|
||||||
}
|
log_to_file("Config sent to running core for seamless hot-reload");
|
||||||
|
|
||||||
let metrics_for_runner = metrics.clone();
|
|
||||||
let writer_for_err = writer.clone();
|
|
||||||
let shutdown_rx_for_core = shutdown_rx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
log_to_file("Restarting tunnel core for reload...");
|
|
||||||
match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core).await {
|
|
||||||
Ok(_) => { log_to_file("Reloaded core stopped normally"); }
|
|
||||||
Err(e) => {
|
|
||||||
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }).unwrap_or_default();
|
|
||||||
let mut w = writer_for_err.lock().await;
|
|
||||||
let _ = w.write_all(format!("{}\n", json).as_bytes()).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Status tick loop is already running and using old metrics?
|
|
||||||
// Wait! We re-created metrics, so the old tick loop will continue reporting old metrics (which are disconnected)!
|
|
||||||
// We should probably share the tick loop or spawn a new one and let the old one die.
|
|
||||||
// It's easier if `metrics` in state is a generic watcher, but since we re-spawned it:
|
|
||||||
let writer_tick = writer.clone();
|
|
||||||
let metrics_tick = metrics.clone();
|
|
||||||
let mut shutdown_rx_tick = shutdown_rx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut last_state = 99u8;
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {}
|
|
||||||
_ = shutdown_rx_tick.changed() => {
|
|
||||||
if *shutdown_rx_tick.borrow() { break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let cs = metrics_tick.connection_state.load(Ordering::Relaxed);
|
|
||||||
let sent = metrics_tick.bytes_sent.load(Ordering::Relaxed);
|
|
||||||
let recv = metrics_tick.bytes_recv.load(Ordering::Relaxed);
|
|
||||||
let rtt = metrics_tick.rtt_ms.load(Ordering::Relaxed);
|
|
||||||
|
|
||||||
let mut w = writer_tick.lock().await;
|
|
||||||
if cs != last_state {
|
|
||||||
last_state = cs;
|
|
||||||
let json = serde_json::to_string(&HelperMsg::Status { value: cs }).unwrap_or_default();
|
|
||||||
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; }
|
|
||||||
}
|
|
||||||
let json = serde_json::to_string(&HelperMsg::Metrics { bytes_sent: sent, bytes_recv: recv, rtt_ms: rtt }).unwrap_or_default();
|
|
||||||
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; }
|
|
||||||
drop(w);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
send_msg(HelperMsg::Status { value: 1 });
|
send_msg(HelperMsg::Status { value: 1 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,4 @@ url = "2.5"
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
ostp-core = { version = "0.2.68", path = "../ostp-core" }
|
ostp-core = { version = "0.2.68", path = "../ostp-core" }
|
||||||
|
colored = "2.1"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use clap::Parser;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use colored::Colorize;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)]
|
#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)]
|
||||||
|
|
@ -117,6 +118,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
||||||
wintun_path: Some("./wintun.dll".to_string()),
|
wintun_path: Some("./wintun.dll".to_string()),
|
||||||
ipv4_address: Some("10.1.0.2/24".to_string()),
|
ipv4_address: Some("10.1.0.2/24".to_string()),
|
||||||
dns: tun_dns,
|
dns: tun_dns,
|
||||||
|
kill_switch: Some(false),
|
||||||
}),
|
}),
|
||||||
reality: Some(RealityConfigRaw {
|
reality: Some(RealityConfigRaw {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -355,6 +357,7 @@ struct TunConfig {
|
||||||
wintun_path: Option<String>,
|
wintun_path: Option<String>,
|
||||||
ipv4_address: Option<String>,
|
ipv4_address: Option<String>,
|
||||||
dns: Option<String>,
|
dns: Option<String>,
|
||||||
|
kill_switch: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
@ -426,7 +429,7 @@ async fn main() -> Result<()> {
|
||||||
let res = run_app().await;
|
let res = run_app().await;
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("[ostp] Fatal error: {}", e);
|
eprintln!("{} {}", "[FATAL ERROR]".red().bold(), e);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|
@ -590,7 +593,7 @@ async fn run_app() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(import_url) = args.import {
|
if let Some(import_url) = args.import {
|
||||||
println!("[ostp] Importing configuration from share link...");
|
println!("{} Importing configuration from share link...", "[ostp]".cyan().bold());
|
||||||
let client_cfg = parse_ostp_link(&import_url)
|
let client_cfg = parse_ostp_link(&import_url)
|
||||||
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
||||||
let unified = UnifiedConfig {
|
let unified = UnifiedConfig {
|
||||||
|
|
@ -604,19 +607,19 @@ async fn run_app() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs::write(&args.config, content)?;
|
fs::write(&args.config, content)?;
|
||||||
println!("[ostp] Configuration successfully imported and saved to {:?}", args.config);
|
println!("{} Configuration successfully imported and saved to {:?}", "[ostp]".green().bold(), args.config);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = args.url {
|
if let Some(url) = args.url {
|
||||||
println!("[ostp] Connecting via share link...");
|
println!("{} Connecting via share link...", "[ostp]".cyan().bold());
|
||||||
let mut client_cfg = parse_ostp_link(&url)
|
let mut client_cfg = parse_ostp_link(&url)
|
||||||
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
||||||
|
|
||||||
// Interactive prompt for URL launch
|
// Interactive prompt for URL launch
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
print!("Enable TUN (VPN) mode? [y/N]: ");
|
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
|
||||||
std::io::stdout().flush().unwrap();
|
std::io::stdout().flush().unwrap();
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
std::io::stdin().read_line(&mut input).unwrap();
|
std::io::stdin().read_line(&mut input).unwrap();
|
||||||
|
|
@ -626,7 +629,7 @@ async fn run_app() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print!("Enable connection multiplexing (mux)? [y/N]: ");
|
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
|
||||||
std::io::stdout().flush().unwrap();
|
std::io::stdout().flush().unwrap();
|
||||||
input.clear();
|
input.clear();
|
||||||
std::io::stdin().read_line(&mut input).unwrap();
|
std::io::stdin().read_line(&mut input).unwrap();
|
||||||
|
|
@ -675,9 +678,9 @@ async fn run_app() -> Result<()> {
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
match &config.mode {
|
match &config.mode {
|
||||||
AppMode::Server(s) => {
|
AppMode::Server(s) => {
|
||||||
println!("[ostp] Config OK: server mode");
|
println!("{} Config OK: server mode", "[ostp]".green().bold());
|
||||||
println!(" Listen: {:?}", s.listen.primary());
|
println!(" Listen: {:?}", s.listen.primary().as_str().cyan());
|
||||||
println!(" Access keys: {}", s.access_keys.len());
|
println!(" Access keys: {}", s.access_keys.len().to_string().yellow());
|
||||||
if let Some(api) = &s.api {
|
if let Some(api) = &s.api {
|
||||||
println!(" API: {} (bind: {})",
|
println!(" API: {} (bind: {})",
|
||||||
if api.enabled.unwrap_or(false) { "enabled" } else { "disabled" },
|
if api.enabled.unwrap_or(false) { "enabled" } else { "disabled" },
|
||||||
|
|
@ -696,16 +699,16 @@ async fn run_app() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMode::Client(c) => {
|
AppMode::Client(c) => {
|
||||||
println!("[ostp] Config OK: client mode");
|
println!("{} Config OK: client mode", "[ostp]".green().bold());
|
||||||
println!(" Server: {}", c.server);
|
println!(" Server: {}", c.server.cyan());
|
||||||
println!(" Key: {}...", &c.access_key[..8.min(c.access_key.len())]);
|
println!(" Key: {}...", &c.access_key[..8.min(c.access_key.len())].yellow());
|
||||||
}
|
}
|
||||||
AppMode::Relay(r) => {
|
AppMode::Relay(r) => {
|
||||||
println!("[ostp] Config OK: relay mode");
|
println!("{} Config OK: relay mode", "[ostp]".green().bold());
|
||||||
println!(" Listen: {:?}", r.listen.primary());
|
println!(" Listen: {:?}", r.listen.primary().cyan());
|
||||||
println!(" Upstream TCP: {}", r.upstream_tcp);
|
println!(" Upstream TCP: {}", r.upstream_tcp.cyan());
|
||||||
println!(" Upstream UDP: {}", r.upstream_udp);
|
println!(" Upstream UDP: {}", r.upstream_udp.cyan());
|
||||||
println!(" API sync: {}", r.upstream_api_url);
|
println!(" API sync: {}", r.upstream_api_url.yellow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -781,6 +784,29 @@ async fn run_app() -> Result<()> {
|
||||||
"sid": "{}",
|
"sid": "{}",
|
||||||
"sni_list": ["www.microsoft.com"]
|
"sni_list": ["www.microsoft.com"]
|
||||||
}},
|
}},
|
||||||
|
|
||||||
|
// Built-in DNS server
|
||||||
|
"dns": {{
|
||||||
|
// Full mode: custom domains + AdBlock lists + DoH forwarding
|
||||||
|
"enabled": false,
|
||||||
|
// Intercept ALL UDP port 53 traffic and resolve via DoH (prevents DNS leaks through the server)
|
||||||
|
// Works even if enabled=false — just strips AdBlock/custom domains logic
|
||||||
|
"intercept_all_port53": false,
|
||||||
|
// UDP port the built-in DNS server listens on (clients can use <server_ip>:50053 as DNS)
|
||||||
|
"local_port": 50053,
|
||||||
|
// DoH upstream: Cloudflare, Google, NextDNS, etc.
|
||||||
|
"doh_upstream": "https://cloudflare-dns.com/dns-query",
|
||||||
|
// AdBlock lists (hosts format, ||domain^, or one domain per line)
|
||||||
|
"adblock_urls": [
|
||||||
|
// "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
|
||||||
|
// "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt"
|
||||||
|
],
|
||||||
|
// Custom domains: respond with A record directly (bypasses DoH)
|
||||||
|
"custom_domains": {{
|
||||||
|
// "myserver.internal": "10.0.0.1",
|
||||||
|
// "home.local": "192.168.1.100"
|
||||||
|
}}
|
||||||
|
}},
|
||||||
"debug": false
|
"debug": false
|
||||||
}}"#, key, priv_key, pub_key, sid)
|
}}"#, key, priv_key, pub_key, sid)
|
||||||
} else if mode_str == "relay" {
|
} else if mode_str == "relay" {
|
||||||
|
|
@ -1001,11 +1027,13 @@ async fn run_app() -> Result<()> {
|
||||||
|
|
||||||
match config.mode {
|
match config.mode {
|
||||||
AppMode::Server(server_cfg) => {
|
AppMode::Server(server_cfg) => {
|
||||||
|
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
||||||
|
|
||||||
let listen_addrs = server_cfg.listen.addresses();
|
let listen_addrs = server_cfg.listen.addresses();
|
||||||
println!("[ostp] Starting server on {:?}", listen_addrs);
|
println!("{} Starting server on {:?}", "[ostp]".cyan().bold(), listen_addrs);
|
||||||
if let Some(ref reality) = server_cfg.reality {
|
if let Some(ref reality) = server_cfg.reality {
|
||||||
if reality.enabled {
|
if reality.enabled {
|
||||||
println!("[ostp] Reality mode enabled (dest: {})", reality.dest);
|
println!("{} Reality mode enabled (dest: {})", "[ostp]".cyan().bold(), reality.dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let debug = server_cfg.debug.unwrap_or(false);
|
let debug = server_cfg.debug.unwrap_or(false);
|
||||||
|
|
@ -1065,14 +1093,16 @@ async fn run_app() -> Result<()> {
|
||||||
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, dns_cfg, Some(args.config)).await?;
|
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, dns_cfg, Some(args.config)).await?;
|
||||||
}
|
}
|
||||||
AppMode::Client(client_cfg) => {
|
AppMode::Client(client_cfg) => {
|
||||||
|
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
||||||
run_client_directly(client_cfg).await?;
|
run_client_directly(client_cfg).await?;
|
||||||
}
|
}
|
||||||
AppMode::Relay(relay_cfg) => {
|
AppMode::Relay(relay_cfg) => {
|
||||||
|
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
||||||
let listen_addrs = relay_cfg.listen.addresses();
|
let listen_addrs = relay_cfg.listen.addresses();
|
||||||
println!("[ostp] Starting relay node on {:?}", listen_addrs);
|
println!("{} Starting relay node on {:?}", "[ostp]".cyan().bold(), listen_addrs);
|
||||||
println!("[ostp] Upstream TCP: {}", relay_cfg.upstream_tcp);
|
println!("{} Upstream TCP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_tcp);
|
||||||
println!("[ostp] Upstream UDP: {}", relay_cfg.upstream_udp);
|
println!("{} Upstream UDP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_udp);
|
||||||
println!("[ostp] Key sync API: {}", relay_cfg.upstream_api_url);
|
println!("{} Key sync API: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_api_url);
|
||||||
let relay_config = ostp_server::RelayConfig {
|
let relay_config = ostp_server::RelayConfig {
|
||||||
listen_addrs,
|
listen_addrs,
|
||||||
upstream_tcp: relay_cfg.upstream_tcp,
|
upstream_tcp: relay_cfg.upstream_tcp,
|
||||||
|
|
@ -1171,7 +1201,7 @@ fn cmd_update() -> Result<()> {
|
||||||
async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||||
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
|
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
|
||||||
let mode_str = if is_tun_enabled { "tun" } else { "proxy" };
|
let mode_str = if is_tun_enabled { "tun" } else { "proxy" };
|
||||||
println!("[ostp] Starting client (mode={}, server={})", mode_str, client_cfg.server);
|
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan());
|
||||||
let reality_cfg = client_cfg.reality.as_ref();
|
let reality_cfg = client_cfg.reality.as_ref();
|
||||||
let client_conf = ostp_client::config::ClientConfig {
|
let client_conf = ostp_client::config::ClientConfig {
|
||||||
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() },
|
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() },
|
||||||
|
|
@ -1214,6 +1244,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||||
wss: client_cfg.transport.as_ref().and_then(|t| t.wss).unwrap_or(false),
|
wss: client_cfg.transport.as_ref().and_then(|t| t.wss).unwrap_or(false),
|
||||||
},
|
},
|
||||||
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
|
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
|
||||||
|
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run the client implementation
|
// Run the client implementation
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,9 @@ if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
ARCHIVE_NAME="ostp-linux-${ARCH}.tar.gz"
|
ARCHIVE_NAME="ostp-linux-${ARCH}.tar.gz"
|
||||||
|
GUI_ARCHIVE_NAME="ostp-gui-linux-${ARCH}.tar.gz"
|
||||||
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${ARCHIVE_NAME}"
|
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${ARCHIVE_NAME}"
|
||||||
|
GUI_DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${GUI_ARCHIVE_NAME}"
|
||||||
echo "Downloading: $ARCHIVE_NAME ($LATEST_RELEASE)"
|
echo "Downloading: $ARCHIVE_NAME ($LATEST_RELEASE)"
|
||||||
|
|
||||||
TEMP_TAR="/tmp/ostp_temp.tar.gz"
|
TEMP_TAR="/tmp/ostp_temp.tar.gz"
|
||||||
|
|
@ -132,6 +134,9 @@ else
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# We don't download GUI binary immediately, we will do it if the user selects Client + GUI mode
|
||||||
|
|
||||||
|
|
||||||
# ── Create global symlink ────────────────────────────────────────────
|
# ── Create global symlink ────────────────────────────────────────────
|
||||||
|
|
||||||
ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK"
|
ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK"
|
||||||
|
|
@ -317,8 +322,9 @@ echo " 1) Server"
|
||||||
echo " 2) Client"
|
echo " 2) Client"
|
||||||
echo " 3) Relay"
|
echo " 3) Relay"
|
||||||
echo " 4) Server + Web Panel"
|
echo " 4) Server + Web Panel"
|
||||||
|
echo " 5) Client + GUI"
|
||||||
echo "--------------------------------------------------------"
|
echo "--------------------------------------------------------"
|
||||||
read -p "Choice [1-4]: " NODE_MODE
|
read -p "Choice [1-5]: " NODE_MODE
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
|
@ -413,7 +419,7 @@ with open('$CONFIG_FILE', 'w') as f:
|
||||||
echo "Password: $PASSWORD"
|
echo "Password: $PASSWORD"
|
||||||
echo "========================================================"
|
echo "========================================================"
|
||||||
|
|
||||||
elif [ "$NODE_MODE" == "2" ]; then
|
elif [ "$NODE_MODE" == "2" ] || [ "$NODE_MODE" == "5" ]; then
|
||||||
echo "Initializing client configuration..."
|
echo "Initializing client configuration..."
|
||||||
./ostp --init client --config "$CONFIG_FILE"
|
./ostp --init client --config "$CONFIG_FILE"
|
||||||
|
|
||||||
|
|
@ -437,6 +443,45 @@ elif [ "$NODE_MODE" == "2" ]; then
|
||||||
fi
|
fi
|
||||||
echo "Client configuration saved: $CONFIG_FILE"
|
echo "Client configuration saved: $CONFIG_FILE"
|
||||||
|
|
||||||
|
if [ "$NODE_MODE" == "5" ]; then
|
||||||
|
echo "Installing GUI..."
|
||||||
|
if [ -n "$LATEST_RELEASE" ]; then
|
||||||
|
TEMP_GUI_TAR="/tmp/ostp_gui_temp.tar.gz"
|
||||||
|
echo "Downloading GUI: $GUI_ARCHIVE_NAME ($LATEST_RELEASE)"
|
||||||
|
HTTP_CODE_GUI=$(curl -sL -w "%{http_code}" "$GUI_DOWNLOAD_URL" -o "$TEMP_GUI_TAR")
|
||||||
|
if [ "$HTTP_CODE_GUI" -eq 200 ]; then
|
||||||
|
tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR" ostp-gui 2>/dev/null || tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR"
|
||||||
|
rm -f "$TEMP_GUI_TAR"
|
||||||
|
if [ -f "$INSTALL_DIR/ostp-gui" ]; then
|
||||||
|
chmod +x "$INSTALL_DIR/ostp-gui"
|
||||||
|
ln -sf "$INSTALL_DIR/ostp-gui" "/usr/local/bin/ostp-gui"
|
||||||
|
echo "GUI binary installed at $INSTALL_DIR/ostp-gui"
|
||||||
|
|
||||||
|
# Create desktop entry
|
||||||
|
DESKTOP_FILE="/usr/share/applications/ostp-gui.desktop"
|
||||||
|
cat <<EOF > "$DESKTOP_FILE"
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=OSTP Client
|
||||||
|
Comment=Ospab Stealth Transport Protocol Client
|
||||||
|
Exec=/usr/local/bin/ostp-gui
|
||||||
|
Icon=utilities-terminal
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Network;Utility;
|
||||||
|
EOF
|
||||||
|
echo "Desktop entry created at $DESKTOP_FILE"
|
||||||
|
else
|
||||||
|
echo "[error] GUI binary not found in archive."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[error] Download failed for GUI (HTTP $HTTP_CODE_GUI)."
|
||||||
|
rm -f "$TEMP_GUI_TAR"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[notice] Automatic download not possible. Install GUI manually."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
elif [ "$NODE_MODE" == "3" ]; then
|
elif [ "$NODE_MODE" == "3" ]; then
|
||||||
echo "Initializing relay configuration..."
|
echo "Initializing relay configuration..."
|
||||||
./ostp --init relay --config "$CONFIG_FILE"
|
./ostp --init relay --config "$CONFIG_FILE"
|
||||||
|
|
|
||||||
27
server.json
27
server.json
|
|
@ -57,5 +57,30 @@
|
||||||
"sid": "960223edfa174fc5",
|
"sid": "960223edfa174fc5",
|
||||||
"sni_list": ["www.microsoft.com"]
|
"sni_list": ["www.microsoft.com"]
|
||||||
},
|
},
|
||||||
"debug": false
|
"debug": false,
|
||||||
|
|
||||||
|
// Встроенный DNS-сервер
|
||||||
|
"dns": {
|
||||||
|
// Полный режим: кастомные домены + AdBlock списки + DoH форвардинг
|
||||||
|
"enabled": false,
|
||||||
|
// Перехватывать весь UDP-трафик к порту 53 и резолвить через DoH
|
||||||
|
// (работает даже если enabled=false, предотвращает DNS-утечки через сервер)
|
||||||
|
"intercept_all_port53": false,
|
||||||
|
// УДП порт встроенного DNS (клиент может указать <server_ip>:50053 как DNS)
|
||||||
|
"local_port": 50053,
|
||||||
|
// DoH вверх: Cloudflare, Google, NextDNS и др.
|
||||||
|
"doh_upstream": "https://cloudflare-dns.com/dns-query",
|
||||||
|
// "doh_upstream": "https://dns.google/dns-query",
|
||||||
|
// "doh_upstream": "https://dns10.quad9.net/dns-query",
|
||||||
|
// Списки доменов для блокировки (формат: hosts-файл, ||domain^, один домен на строку)
|
||||||
|
"adblock_urls": [
|
||||||
|
// "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
|
||||||
|
// "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt"
|
||||||
|
],
|
||||||
|
// Кастомные домены: отвечаем A-записью напрямую (не через DoH)
|
||||||
|
"custom_domains": {
|
||||||
|
// "myserver.internal": "10.0.0.1",
|
||||||
|
// "home.local": "192.168.1.100"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue