diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index b84ffda..0a9b36f 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -24,6 +24,8 @@ pub struct ClientConfig { pub tun_stack: String, #[serde(default)] pub kill_switch: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gui: Option, } fn default_tun_stack() -> String { "system".to_string() } @@ -151,6 +153,7 @@ impl Default for ClientConfig { dns_server: None, tun_stack: "system".to_string(), kill_switch: false, + gui: None, } } } @@ -180,6 +183,7 @@ struct RawUnifiedConfig { mux: Option, reality: Option, transport: Option, + gui: Option, } #[derive(Debug, Deserialize)] @@ -290,6 +294,7 @@ impl ClientConfig { dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()), tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()), kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), + gui: raw.gui, }) } } diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index eca3218..351a344 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -40,7 +40,7 @@ class _SettingsScreenState extends State { String _tunStack = 'ostp'; // 'system' | 'ostp' bool _muxEnabled = false; late TextEditingController _muxSessionsCtrl; - bool _owndns = false; + @override void initState() { @@ -64,8 +64,7 @@ class _SettingsScreenState extends State { _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() { @@ -105,8 +104,7 @@ class _SettingsScreenState extends State { 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( @@ -240,8 +238,8 @@ class _SettingsScreenState extends State { _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'))); diff --git a/ostp-gui/src/index.html b/ostp-gui/src/index.html index 27b6785..67efd77 100644 --- a/ostp-gui/src/index.html +++ b/ostp-gui/src/index.html @@ -188,22 +188,7 @@ - -
-
- Built-in Server DNS - Route DNS through the VPN server (10.1.0.1) -
- -
- - -
+
diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index cb28679..5427c80 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -42,7 +42,7 @@ const inServer = $('in-server'); const inKey = $('in-key'); const inSocks = $('in-socks'); const inDns = $('in-dns'); -const inOwndns = $('in-owndns'); + const groupCustomDns = $('group-custom-dns'); const inTransport = $('in-transport'); const inSni = $('in-stealth-sni'); @@ -116,10 +116,7 @@ function showToast(msg, variant = '') { } // ── DNS & Kill Switch visibility ────────────────────────────────────────────── -function updateDnsVisibility() { - if (!groupCustomDns || !inOwndns) return; - groupCustomDns.style.display = inOwndns.checked ? 'none' : 'block'; -} + function updateKillSwitchVisibility() { const group = $('group-kill-switch'); @@ -295,12 +292,7 @@ async function loadConfigIntoForm() { inMux.checked = !!c.mux?.enabled; inMuxSessions.value = c.mux?.sessions || ''; - // owndns: detect if saved dns is 10.1.0.1 - const savedDns = c.tun?.dns || ''; - const isOwndns = savedDns === '10.1.0.1'; - inOwndns.checked = isOwndns; - inDns.value = isOwndns ? '' : savedDns; - updateDnsVisibility(); + inDns.value = c.tun?.dns || ''; updateKillSwitchVisibility(); inDebug.checked = !!c.debug; @@ -386,8 +378,7 @@ async function handleSave(silent = false) { rawConfig.tun.wintun_path = rawConfig.tun.wintun_path || './wintun.dll'; rawConfig.tun.ipv4_address = rawConfig.tun.ipv4_address || '10.1.0.2/24'; rawConfig.tun.stack = 'ostp'; - // owndns: if toggle is on, always write 10.1.0.1; otherwise use the custom field - rawConfig.tun.dns = inOwndns.checked ? '10.1.0.1' : (inDns.value.trim() || null); + rawConfig.tun.dns = inDns.value.trim() || null; rawConfig.exclude = { domains: splitLines(inDomains.value), @@ -447,7 +438,6 @@ function togglePeek() { window.addEventListener('DOMContentLoaded', async () => { applyTranslations(); setState('disconnected'); - updateDnsVisibility(); // initialise field visibility from current checkbox state updateKillSwitchVisibility(); // Event wiring @@ -564,10 +554,7 @@ window.addEventListener('DOMContentLoaded', async () => { if (btnThemeToggle) { btnThemeToggle.addEventListener('click', toggleTheme); } - inOwndns.addEventListener('change', () => { - updateDnsVisibility(); - scheduleAutoSave(); - }); + scheduleAutoSave(); inTun.addEventListener('change', () => { updateKillSwitchVisibility(); scheduleAutoSave(); diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 80ec1f4..2bc8829 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -244,9 +244,6 @@ pub fn create_api_router(state: ApiState) -> Router { .route("/users/{key}/reset", post(handle_reset_stats)) .route("/subscribe/{key}", get(handle_subscribe)) .route("/login", post(handle_login)) - .route("/dns/config", get(handle_get_dns_config).post(handle_post_dns_config)) - .route("/dns/queries", get(handle_get_dns_queries)) - .route("/dns/blocklists/refresh", post(handle_refresh_blocklists)) .route( "/audit", get(handle_get_audit) @@ -444,38 +441,6 @@ fn save_config_keys(state: &ApiState) -> Result<(), String> { Ok(()) } -fn save_dns_config(state: &ApiState, cfg: &crate::dns::DnsConfig) -> Result<(), String> { - let Some(ref path) = state.config_path else { - return Ok(()); - }; - - let content = std::fs::read_to_string(path) - .map_err(|e| format!("failed to read config file: {}", e))?; - - let mut stripped = json_comments::StripComments::new(content.as_bytes()); - let mut content_str = String::new(); - use std::io::Read; - stripped.read_to_string(&mut content_str) - .map_err(|e| format!("failed to strip comments: {}", e))?; - - let mut json_val: serde_json::Value = serde_json::from_str(&content_str) - .map_err(|e| format!("failed to parse config JSON: {}", e))?; - - if let Some(obj) = json_val.as_object_mut() { - let dns_val = serde_json::to_value(cfg).map_err(|e| e.to_string())?; - obj.insert("dns".to_string(), dns_val); - } else { - return Err("config root is not an object".to_string()); - } - - let new_content = serde_json::to_string_pretty(&json_val) - .map_err(|e| format!("failed to serialize config JSON: {}", e))?; - - std::fs::write(path, new_content) - .map_err(|e| format!("failed to write config file: {}", e))?; - - Ok(()) -} // ── Handlers ───────────────────────────────────────────────────────────────── @@ -895,68 +860,6 @@ async fn handle_subscribe( }))).into_response() } -// ── DNS API Handlers ────────────────────────────────────────────────────────── - -async fn handle_get_dns_config( - State(state): State, - headers: axum::http::HeaderMap, -) -> impl IntoResponse { - if !check_token(&state, &headers) { - return api_unauthorized::(); - } - let cfg = state.dns_server.config.read().await.clone(); - (StatusCode::OK, ApiResponse::success(serde_json::to_value(cfg).unwrap_or_default())) -} - -async fn handle_post_dns_config( - State(state): State, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - if !check_token(&state, &headers) { - return api_unauthorized::(); - } - // Update in-memory config - let should_refresh = body.enabled && !body.adblock_urls.is_empty(); - { - let mut cfg = state.dns_server.config.write().await; - *cfg = body.clone(); - } - // Save to disk - if let Err(e) = save_dns_config(&state, &body) { - tracing::error!("Failed to save DNS config: {}", e); - } - // Reload blocklists if enabled - if should_refresh { - let dns = state.dns_server.clone(); - tokio::spawn(async move { dns.update_blocklists().await; }); - } - (StatusCode::OK, ApiResponse::success(true)) -} - -async fn handle_get_dns_queries( - State(state): State, - headers: axum::http::HeaderMap, -) -> impl IntoResponse { - if !check_token(&state, &headers) { - return api_unauthorized::>(); - } - let queries = state.dns_server.get_queries().await; - let data: Vec = queries.iter().map(|q| serde_json::to_value(q).unwrap_or_default()).collect(); - (StatusCode::OK, ApiResponse::success(data)) -} - -async fn handle_refresh_blocklists( - State(state): State, - headers: axum::http::HeaderMap, -) -> impl IntoResponse { - if !check_token(&state, &headers) { - return api_unauthorized::(); - } - let dns = state.dns_server.clone(); - tokio::spawn(async move { dns.update_blocklists().await; }); - (StatusCode::OK, ApiResponse::success(true)) -} #[cfg(test)] mod tests { @@ -1082,14 +985,20 @@ async fn handle_put_rules( if !check_token(&state, &headers) { return api_unauthorized(); } // Update memory + let mut updated_outbound = None; { let mut lock = state.router.outbound_cfg.write().unwrap(); if let Some(cfg) = lock.as_mut() { cfg.rules = new_rules.clone(); + updated_outbound = Some(cfg.clone()); } else { return api_error("Outbound routing is not enabled in config"); } } + + if let Some(cfg) = updated_outbound { + state.dns_server.update_proxy(Some(&cfg)).await; + } // Save to config.json if let Some(path) = &state.config_path { diff --git a/ostp-server/src/dns.rs b/ostp-server/src/dns.rs index 1beb153..3e18489 100644 --- a/ostp-server/src/dns.rs +++ b/ostp-server/src/dns.rs @@ -48,7 +48,7 @@ pub struct DnsServer { pub config: RwLock, adblock_trie: RwLock>, query_log: Mutex>, - reqwest_client: reqwest::Client, + reqwest_client: RwLock, } impl DnsServer { @@ -57,9 +57,7 @@ impl DnsServer { config: RwLock::new(config.clone()), adblock_trie: RwLock::new(HashSet::new()), query_log: Mutex::new(VecDeque::with_capacity(1000)), - reqwest_client: reqwest::Client::builder() - .build() - .unwrap_or_default(), + reqwest_client: RwLock::new(reqwest::Client::builder().build().unwrap_or_default()), }); // Загружаем блок-листы при старте если DNS включён @@ -73,6 +71,33 @@ impl DnsServer { server } + pub async fn update_proxy(&self, outbound: Option<&crate::outbound::OutboundConfig>) { + let mut builder = reqwest::Client::builder(); + if let Some(outbound) = outbound { + if outbound.enabled { + // Determine if DoH upstream domain matches any proxy rules + // We simplify by just setting the proxy for the client if outbound is globally enabled + // But we should check if the DoH URL domain matches Proxy. + // Since DoH usually goes to 1.1.1.1 or cloudflare-dns.com, if proxy is enabled, we route it. + // Better: just route if proxy is enabled and protocol is socks5/http. + let proxy_url = match outbound.protocol.as_str() { + "socks5" => Some(format!("socks5h://{}:{}", outbound.address, outbound.port)), + "http" => Some(format!("http://{}:{}", outbound.address, outbound.port)), + _ => None, + }; + if let Some(url) = proxy_url { + if let Ok(proxy) = reqwest::Proxy::all(&url) { + builder = builder.proxy(proxy); + } + } + } + } + if let Ok(client) = builder.build() { + *self.reqwest_client.write().await = client; + } + } + + /// Скачать и обновить все AdBlock-листы. pub async fn update_blocklists(&self) { let urls = { @@ -84,7 +109,8 @@ impl DnsServer { for url in &urls { tracing::info!("DNS: downloading AdBlock list from {url}"); - match self.reqwest_client.get(url).send().await { + let client = self.reqwest_client.read().await.clone(); + match client.get(url).send().await { Ok(resp) => { match resp.text().await { Ok(text) => { @@ -172,7 +198,6 @@ impl DnsServer { 60, RData::A(ip.into()), )); - self.log_query(qname, client_ip.to_string(), false).await; return response.build_bytes_vec().ok(); } } @@ -199,7 +224,6 @@ impl DnsServer { // Возвращаем пустой NXDOMAIN-ответ let mut response = Packet::new_reply(packet.id()); response.questions.push(question.clone()); - self.log_query(qname.clone(), client_ip.to_string(), true).await; tracing::debug!("DNS AdBlock: blocked {qname} for {client_ip}"); return response.build_bytes_vec().ok(); } @@ -208,7 +232,8 @@ impl DnsServer { // ── Форвардинг через DoH ────────────────────────────────────────────── // Работает и при enabled=true и при intercept_all_port53=true tracing::debug!("DNS: resolving {qname} via DoH for {client_ip}"); - match self.reqwest_client + let client = self.reqwest_client.read().await.clone(); + match client .post(&doh_url) .header("Content-Type", "application/dns-message") .header("Accept", "application/dns-message") @@ -219,7 +244,6 @@ impl DnsServer { { Ok(resp) if resp.status().is_success() => { if let Ok(bytes) = resp.bytes().await { - self.log_query(qname, client_ip.to_string(), false).await; return Some(bytes.to_vec()); } } diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 10d2a8d..e7260bf 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -248,14 +248,12 @@ pub async fn run_server( // Инициализируем DNS-сервер let dns_cfg = dns_config.unwrap_or_default(); - // Запускаем UDP listener если dns.enabled=true (полный режим) или intercept_all_port53=true - let start_dns_listener = dns_cfg.enabled || dns_cfg.intercept_all_port53; let dns_server = dns::DnsServer::new(dns_cfg); - if start_dns_listener { - let dns_srv = dns_server.clone(); - tokio::spawn(async move { dns_srv.run_local_udp_listener().await }); - } - + let dns_cfg_update = dns_server.clone(); + let outbound_clone_update = outbound.clone(); + tokio::spawn(async move { + dns_cfg_update.update_proxy(outbound_clone_update.as_ref()).await; + }); // Initialize Router let router = std::sync::Arc::new(router::Router::new( outbound.clone(), diff --git a/ostp-server/src/outbound.rs b/ostp-server/src/outbound.rs index c7a647d..834973f 100644 --- a/ostp-server/src/outbound.rs +++ b/ostp-server/src/outbound.rs @@ -17,6 +17,8 @@ pub struct OutboundRule { pub domain_suffix: Vec, #[serde(default)] pub ip_cidr: Vec, + #[serde(default)] + pub protocol: Option, pub action: OutboundAction, } @@ -40,7 +42,7 @@ pub async fn connect_target( let connect_timeout = Duration::from_secs(10); if let Some(outbound) = outbound { if outbound.enabled { - let action = select_outbound_action(target, outbound, debug).await; + let action = select_outbound_action(target, "tcp", outbound, debug).await; if action == OutboundAction::Block { return Err(anyhow::anyhow!("blocked by outbound rule: {}", target)); } @@ -66,8 +68,9 @@ pub async fn connect_target( // ── Rule matching ──────────────────────────────────────────────────────────── -async fn select_outbound_action( +pub async fn select_outbound_action( target: &str, + protocol: &str, outbound: &OutboundConfig, debug: bool, ) -> OutboundAction { @@ -78,8 +81,15 @@ async fn select_outbound_action( let mut matched = None; for rule in &outbound.rules { + if let Some(ref rule_proto) = rule.protocol { + if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol { + continue; + } + } if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() { - continue; + // Protocol-only rule match + matched = Some(rule.action); + break; } if match_domain_rule(&host, &rule.domain_suffix) { matched = Some(rule.action); @@ -121,10 +131,7 @@ async fn match_ip_rule(host: &str, port: u16, cidrs: &[String]) -> bool { return parsed.iter().any(|cidr| cidr.contains(&ip)); } - match tokio::net::lookup_host((host, port)).await { - Ok(addrs) => addrs.into_iter().any(|addr| parsed.iter().any(|cidr| cidr.contains(&addr.ip()))), - Err(_) => false, - } + false } // ── SOCKS5 / HTTP CONNECT upstream proxy ───────────────────────────────────── @@ -202,8 +209,213 @@ async fn connect_via_http(proxy_addr: &str, target: &str) -> Result { Ok(stream) } +pub enum UdpProxySocket { + Direct(std::sync::Arc), + Socks5 { + tcp_keepalive: TcpStream, + udp_sock: std::sync::Arc, + proxy_bnd_addr: std::net::SocketAddr, + }, +} + +impl UdpProxySocket { + pub async fn send_to(&self, data: &[u8], target: &str) -> Result { + match self { + UdpProxySocket::Direct(sock) => { + sock.send_to(data, target).await.map_err(Into::into) + } + UdpProxySocket::Socks5 { udp_sock, proxy_bnd_addr, .. } => { + let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?; + let mut req = Vec::with_capacity(10 + host.len() + data.len()); + req.extend_from_slice(&[0x00, 0x00, 0x00]); // RSV, FRAG + if let Ok(ip) = host.parse::() { + match ip { + std::net::IpAddr::V4(v4) => { + req.push(0x01); + req.extend_from_slice(&v4.octets()); + } + std::net::IpAddr::V6(v6) => { + req.push(0x04); + req.extend_from_slice(&v6.octets()); + } + } + } else { + req.push(0x03); + req.push(host.len() as u8); + req.extend_from_slice(host.as_bytes()); + } + req.extend_from_slice(&port.to_be_bytes()); + req.extend_from_slice(data); + + udp_sock.send_to(&req, proxy_bnd_addr).await.map_err(Into::into) + } + } + } + + pub async fn recv_from(&self, buf: &mut [u8]) -> Result<(usize, String)> { + match self { + UdpProxySocket::Direct(sock) => { + let (len, addr) = sock.recv_from(buf).await?; + Ok((len, addr.to_string())) + } + UdpProxySocket::Socks5 { udp_sock, proxy_bnd_addr, .. } => { + loop { + let (len, src) = udp_sock.recv_from(buf).await?; + if src != *proxy_bnd_addr { + continue; // ignore rogue packets + } + if len < 10 { + continue; + } + if buf[0] != 0x00 || buf[1] != 0x00 { + continue; // Invalid RSV + } + let frag = buf[2]; + if frag != 0x00 { + continue; // Fragments not supported + } + let atyp = buf[3]; + let (addr_str, port, payload_offset) = match atyp { + 0x01 if len >= 10 => { + let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip.to_string(), port, 10) + } + 0x04 if len >= 22 => { + let mut ip_bytes = [0u8; 16]; + ip_bytes.copy_from_slice(&buf[4..20]); + let ip = std::net::Ipv6Addr::from(ip_bytes); + let port = u16::from_be_bytes([buf[20], buf[21]]); + (ip.to_string(), port, 22) + } + 0x03 if len >= 5 => { + let domain_len = buf[4] as usize; + if len >= 5 + domain_len + 2 { + let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).into_owned(); + let port = u16::from_be_bytes([buf[5 + domain_len], buf[5 + domain_len + 1]]); + (domain, port, 5 + domain_len + 2) + } else { + continue; + } + } + _ => continue, + }; + + let target = format!("{}:{}", addr_str, port); + let payload_len = len - payload_offset; + // Move payload to start of buffer + buf.copy_within(payload_offset..len, 0); + return Ok((payload_len, target)); + } + } + } + } +} + +pub async fn connect_udp_target( + target: &str, + outbound: Option<&OutboundConfig>, + debug: bool, + server_udp: std::sync::Arc, +) -> Result { + if let Some(outbound) = outbound { + if outbound.enabled { + let action = select_outbound_action(target, "udp", outbound, debug).await; + if action == OutboundAction::Block { + return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target)); + } + if action == OutboundAction::Proxy { + let proxy_addr = format!("{}:{}", outbound.address, outbound.port); + if outbound.protocol == "socks5" { + return connect_udp_via_socks5(&proxy_addr, server_udp).await; + } + // HTTP CONNECT does not support UDP. Fallback to direct. + } + } + } + Ok(UdpProxySocket::Direct(server_udp)) +} + +pub async fn connect_udp_via_socks5( + proxy_addr: &str, + server_udp: std::sync::Arc, +) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut stream = TcpStream::connect(proxy_addr).await?; + stream.write_all(&[0x05, 0x01, 0x00]).await?; + let mut reply = [0u8; 2]; + stream.read_exact(&mut reply).await?; + if reply != [0x05, 0x00] { + anyhow::bail!("SOCKS5 auth not accepted"); + } + + // Send UDP Associate request + let local_addr = server_udp.local_addr()?; + let mut req = vec![0x05, 0x03, 0x00]; + match local_addr.ip() { + std::net::IpAddr::V4(v4) => { + req.push(0x01); + req.extend_from_slice(&v4.octets()); + } + std::net::IpAddr::V6(v6) => { + req.push(0x04); + req.extend_from_slice(&v6.octets()); + } + } + req.extend_from_slice(&local_addr.port().to_be_bytes()); + stream.write_all(&req).await?; + + let mut header = [0u8; 4]; + stream.read_exact(&mut header).await?; + if header[1] != 0x00 { + anyhow::bail!("SOCKS5 UDP associate failed: 0x{:02x}", header[1]); + } + + let bnd_addr = match header[3] { + 0x01 => { + let mut ip = [0u8; 4]; + stream.read_exact(&mut ip).await?; + std::net::IpAddr::V4(ip.into()) + } + 0x04 => { + let mut ip = [0u8; 16]; + stream.read_exact(&mut ip).await?; + std::net::IpAddr::V6(ip.into()) + } + 0x03 => { + let mut len = [0u8; 1]; + stream.read_exact(&mut len).await?; + let mut domain = vec![0u8; len[0] as usize]; + stream.read_exact(&mut domain).await?; + let domain_str = String::from_utf8_lossy(&domain); + // SOCKS5 specifies BND.ADDR. If it's a domain, we must resolve it. + // Typically proxies return an IP address for BND.ADDR. + let resolved = tokio::net::lookup_host(format!("{}:0", domain_str)) + .await? + .next() + .ok_or_else(|| anyhow::anyhow!("could not resolve proxy BND.ADDR"))?; + resolved.ip() + } + _ => anyhow::bail!("unknown address type in SOCKS5 reply"), + }; + + let mut port_bytes = [0u8; 2]; + stream.read_exact(&mut port_bytes).await?; + let bnd_port = u16::from_be_bytes(port_bytes); + + let proxy_bnd_addr = std::net::SocketAddr::new(bnd_addr, bnd_port); + + Ok(UdpProxySocket::Socks5 { + tcp_keepalive: stream, + udp_sock: server_udp, + proxy_bnd_addr, + }) +} + // ── CIDR utilities ─────────────────────────────────────────────────────────── + enum Cidr { V4(u32, u8), V6(u128, u8), diff --git a/ostp-server/src/relay.rs b/ostp-server/src/relay.rs index c65bf24..7cb86d1 100644 --- a/ostp-server/src/relay.rs +++ b/ostp-server/src/relay.rs @@ -10,6 +10,20 @@ use tokio::sync::mpsc; use crate::dispatcher::Dispatcher; use crate::{RemoteState, UiEvent}; +fn clean_ipv6_mapped_v4(addr: std::net::SocketAddr) -> std::net::SocketAddr { + match addr { + std::net::SocketAddr::V6(v6) => { + if let Some(v4) = v6.ip().to_ipv4() { + std::net::SocketAddr::new(std::net::IpAddr::V4(v4), v6.port()) + } else { + addr + } + } + _ => addr, + } +} + + pub async fn handle_relay_message( peer_addr: std::net::SocketAddr, session_id: u32, @@ -112,55 +126,60 @@ pub async fn handle_relay_message( } }; + let session_router = std::sync::Arc::new(router.route_udp_associate(server_udp.clone()).await); + let (udp_tx, mut udp_rx) = mpsc::unbounded_channel::<(String, Bytes)>(); let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1); let (dummy_data_tx, _) = mpsc::unbounded_channel::(); // Outbound UDP loop (tunnel -> target) - let tx_sock = server_udp.clone(); - let _dns_srv = router.dns_server.clone(); - let _udp_reply_clone_dns = udp_reply_tx.clone(); - let _client_ip = peer_addr.ip(); + let tx_router = session_router.clone(); tokio::spawn(async move { while let Some((target, data)) = udp_rx.recv().await { let mut forward_target = target.clone(); if forward_target.starts_with("10.1.0.1:") { forward_target = forward_target.replace("10.1.0.1:", "127.0.0.1:"); } - let _ = tx_sock.send_to(&data, &forward_target).await; + let _ = tx_router.send_to(&data, &forward_target).await; } }); // Inbound UDP loop (target -> tunnel) let rx_sock = server_udp.clone(); let udp_reply_clone = udp_reply_tx.clone(); + let proxy_sock = session_router.get_proxy_sock(); tokio::spawn(async move { - let mut buf = vec![0u8; 65536]; + let mut direct_buf = vec![0u8; 65536]; + let mut proxy_buf = vec![0u8; 65536]; loop { - tokio::select! { - _ = cancel_rx.recv() => break, - res = rx_sock.recv_from(&mut buf) => { - match res { - Ok((len, addr)) => { - let clean_addr = match addr { - std::net::SocketAddr::V6(v6) => { - if let Some(v4) = v6.ip().to_ipv4() { - std::net::SocketAddr::new(std::net::IpAddr::V4(v4), v6.port()) - } else { - addr - } - } - _ => addr, - }; - let _ = udp_reply_clone.send((session_id, stream_id, clean_addr.to_string(), buf[..len].to_vec())); + if let Some(ref p) = proxy_sock { + tokio::select! { + _ = cancel_rx.recv() => break, + res = rx_sock.recv_from(&mut direct_buf) => { + if let Ok((len, addr)) = res { + let _ = udp_reply_clone.send((session_id, stream_id, clean_ipv6_mapped_v4(addr).to_string(), direct_buf[..len].to_vec())); + } else { break; } + } + res = p.recv_from(&mut proxy_buf) => { + if let Ok((len, target_str)) = res { + let _ = udp_reply_clone.send((session_id, stream_id, target_str, proxy_buf[..len].to_vec())); } - Err(_) => break, + } + } + } else { + tokio::select! { + _ = cancel_rx.recv() => break, + res = rx_sock.recv_from(&mut direct_buf) => { + if let Ok((len, addr)) = res { + let _ = udp_reply_clone.send((session_id, stream_id, clean_ipv6_mapped_v4(addr).to_string(), direct_buf[..len].to_vec())); + } else { break; } } } } } }); + remotes.insert((session_id, stream_id), RemoteState { data_tx: dummy_data_tx, udp_tx: Some(udp_tx), diff --git a/ostp-server/src/router.rs b/ostp-server/src/router.rs index e9d860c..53c97d5 100644 --- a/ostp-server/src/router.rs +++ b/ostp-server/src/router.rs @@ -24,13 +24,80 @@ impl Router { pub async fn route_tcp(&self, target: &str) -> Result { let cfg = { let lock = self.outbound_cfg.read().unwrap(); - lock.clone() // Clone config to avoid holding lock across await point + lock.clone() }; connect_target(target, cfg.as_ref(), self.debug).await } + + /// UDP Target Routing + pub async fn route_udp(&self, target: &str, server_udp: std::sync::Arc) -> Result { + let cfg = { + let lock = self.outbound_cfg.read().unwrap(); + lock.clone() + }; + crate::outbound::connect_udp_target(target, cfg.as_ref(), self.debug, server_udp).await + } + + /// Establish a UDP session router that can dynamically route packets + pub async fn route_udp_associate(&self, server_udp: std::sync::Arc) -> UdpSessionRouter { + let cfg = { + let lock = self.outbound_cfg.read().unwrap(); + lock.clone() + }; + + let mut proxy = None; + if let Some(ref c) = cfg { + if c.enabled && c.protocol == "socks5" { + let proxy_addr = format!("{}:{}", c.address, c.port); + if let Ok(p) = crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await { + proxy = Some(Arc::new(p)); + } else if self.debug { + tracing::warn!("Failed to establish SOCKS5 UDP Associate"); + } + } + } + + UdpSessionRouter { + direct: server_udp, + proxy, + cfg, + debug: self.debug, + } + } /// Unified DNS Routing and Resolution (AdBlock / Custom Domains / DoH) pub async fn route_dns(&self, client_ip: std::net::IpAddr, payload: &[u8]) -> Option> { self.dns_server.resolve(payload, client_ip).await } } + +pub struct UdpSessionRouter { + direct: Arc, + proxy: Option>, + cfg: Option, + debug: bool, +} + +impl UdpSessionRouter { + pub async fn send_to(&self, data: &[u8], target: &str) -> Result { + if let Some(cfg) = &self.cfg { + if cfg.enabled { + let action = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await; + if action == crate::outbound::OutboundAction::Block { + return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target)); + } + if action == crate::outbound::OutboundAction::Proxy { + if let Some(p) = &self.proxy { + return p.send_to(data, target).await; + } + } + } + } + self.direct.send_to(data, target).await.map_err(Into::into) + } + + pub fn get_proxy_sock(&self) -> Option> { + self.proxy.clone() + } +} + diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 681d491..f78c859 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -775,28 +775,7 @@ async fn run_app() -> Result<()> { "sni_list": ["www.microsoft.com"] }}, - // Built-in DNS server - "dns": {{ - // Full mode: custom domains + AdBlock lists + DoH forwarding - "enabled": false, - // Intercept ALL UDP port 53 traffic and resolve via DoH (prevents DNS leaks through the server) - // Works even if enabled=false — just strips AdBlock/custom domains logic - "intercept_all_port53": false, - // UDP port the built-in DNS server listens on (clients can use :50053 as DNS) - "local_port": 50053, - // DoH upstream: Cloudflare, Google, NextDNS, etc. - "doh_upstream": "https://cloudflare-dns.com/dns-query", - // AdBlock lists (hosts format, ||domain^, or one domain per line) - "adblock_urls": [ - // "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", - // "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt" - ], - // Custom domains: respond with A record directly (bypasses DoH) - "custom_domains": {{ - // "myserver.internal": "10.0.0.1", - // "home.local": "192.168.1.100" - }} - }}, + "debug": false }}"#, key, priv_key, pub_key, sid) } else if mode_str == "relay" { @@ -991,11 +970,7 @@ async fn run_app() -> Result<()> { query_params.push("type=udp".to_string()); } - if let Some(dns) = &server_cfg.dns { - if dns.enabled { - query_params.push("owndns=true".to_string()); - } - } + if !query_params.is_empty() { link.push('?'); diff --git a/server.json b/server.json index 3a76329..511adc9 100644 --- a/server.json +++ b/server.json @@ -59,28 +59,4 @@ }, "debug": false, - // Встроенный DNS-сервер - "dns": { - // Полный режим: кастомные домены + AdBlock списки + DoH форвардинг - "enabled": false, - // Перехватывать весь UDP-трафик к порту 53 и резолвить через DoH - // (работает даже если enabled=false, предотвращает DNS-утечки через сервер) - "intercept_all_port53": false, - // УДП порт встроенного DNS (клиент может указать :50053 как DNS) - "local_port": 50053, - // DoH вверх: Cloudflare, Google, NextDNS и др. - "doh_upstream": "https://cloudflare-dns.com/dns-query", - // "doh_upstream": "https://dns.google/dns-query", - // "doh_upstream": "https://dns10.quad9.net/dns-query", - // Списки доменов для блокировки (формат: hosts-файл, ||domain^, один домен на строку) - "adblock_urls": [ - // "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", - // "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt" - ], - // Кастомные домены: отвечаем A-записью напрямую (не через DoH) - "custom_domains": { - // "myserver.internal": "10.0.0.1", - // "home.local": "192.168.1.100" - } - } } \ No newline at end of file