diff --git a/Cargo.lock b/Cargo.lock index 70f1e37..7b2c7c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,7 +559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1455,6 +1455,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "simple-dns", "socket2", "tokio", "tokio-rustls", @@ -2047,6 +2048,15 @@ dependencies = [ "libc", ] +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "slab" version = "0.4.12" @@ -2709,7 +2719,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/ostp-client/src/tunnel/native_handler.rs b/ostp-client/src/tunnel/native_handler.rs new file mode 100644 index 0000000..dd91d8c --- /dev/null +++ b/ostp-client/src/tunnel/native_handler.rs @@ -0,0 +1,393 @@ +use anyhow::{anyhow, Result}; +use tokio::sync::watch; + +#[cfg(any(target_os = "windows", target_os = "linux"))] +pub async fn run_native_tunnel( + config: crate::config::ClientConfig, + mut shutdown: watch::Receiver, +) -> Result<()> { + use std::net::ToSocketAddrs; + use std::process::Command; + use netstack_smoltcp::StackBuilder; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use futures::{StreamExt, SinkExt}; + + #[cfg(target_os = "windows")] + use std::os::windows::process::CommandExt; + + let debug = config.debug; + tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)..."); + + let server_ip = config.ostp.server_addr.to_socket_addrs() + .map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))? + .next() + .map(|addr| addr.ip()) + .ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?; + + let server_ip_str = server_ip.to_string(); + + let mut tun_cfg = tun::Configuration::default(); + tun_cfg.tun_name("ostp_tun") + .address((10, 1, 0, 2)) + .netmask((255, 255, 255, 0)) + .destination((10, 1, 0, 1)) + .mtu(config.ostp.mtu as u16) + .up(); + + #[cfg(target_os = "linux")] + tun_cfg.platform_config(|config| { + config.packet_information(false); + }); + + let dev = tun::create(&tun_cfg) + .map_err(|e| anyhow!("Failed to create TUN device: {}", e))?; + let dev = tun::AsyncDevice::new(dev) + .map_err(|e| anyhow!("Failed to make TUN device async: {}", e))?; + + tracing::info!("TUN device created natively."); + + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + let current_exe = std::env::current_exe()?.to_string_lossy().into_owned(); + + let setup_script = format!( + "$remote_ip = '{}'\n\ + $exe_path = '{}'\n\ + $route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object {{ $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' }} | Sort-Object RouteMetric | Select-Object -First 1\n\ + if ($route) {{\n\ + $gw = $route.NextHop\n\ + $ifIndex = $route.InterfaceIndex\n\ + New-NetRoute -DestinationPrefix \"$remote_ip/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\ + $dns_ips = Get-DnsClientServerAddress -InterfaceIndex $ifIndex | Select-Object -ExpandProperty ServerAddresses\n\ + foreach ($dns in $dns_ips) {{\n\ + if ($dns -match '^\\d+\\.\\d+\\.\\d+\\.\\d+$') {{\n\ + New-NetRoute -DestinationPrefix \"$dns/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\ + }}\n\ + }}\n\ + New-NetRoute -DestinationPrefix \"1.1.1.1/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\ + }}\n\ + New-NetFirewallRule -DisplayName 'OSTP Tunnel In' -Direction Inbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\ + New-NetFirewallRule -DisplayName 'OSTP Tunnel Out' -Direction Outbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\ + netsh interface ipv4 set interface name=\"ostp_tun\" metric=1\n\ + 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 + ); + let _ = Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .args(["-NoProfile", "-Command", &setup_script]) + .output()?; + + if let Some(ref dns) = config.dns_server { + if !dns.is_empty() { + let net_setup = format!("netsh interface ipv4 set dnsservers name=\"ostp_tun\" static {} primary\n", dns); + let _ = Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .args(["-NoProfile", "-Command", &net_setup]) + .output()?; + } + } + } + + #[cfg(target_os = "linux")] + { + // Add default route to tun, bypassing server IP + let _ = Command::new("ip").args(["route", "add", &format!("{}/32", server_ip_str), "via", "10.1.0.1"]).output(); + } + + let (stack, tcp_runner, _udp_socket, tcp_listener) = StackBuilder::default() + .enable_tcp(true) + .enable_udp(true) + .mtu(config.ostp.mtu) + .build()?; + + let mut runner_task = tokio::spawn(async move { + if let Some(mut runner) = tcp_runner { + let _ = runner.await; + } + }); + + let (mut stack_sink, mut stack_stream) = stack.split(); + let (mut tun_read, mut tun_write) = tokio::io::split(dev); + + let mut tun_to_stack = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + match tun_read.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + let frame = buf[..n].to_vec(); + if stack_sink.send(frame).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut stack_to_tun = tokio::spawn(async move { + while let Some(Ok(frame)) = stack_stream.next().await { + if tun_write.write_all(frame.as_slice()).await.is_err() { + break; + } + } + }); + + let proxy_addr = config.local_proxy.bind_addr.clone(); + let mut tcp_accept_task = tokio::spawn(async move { + if let Some(mut listener) = tcp_listener { + while let Some((mut stream, _local, remote)) = listener.next().await { + let proxy_addr = proxy_addr.clone(); + tokio::spawn(async move { + if debug { tracing::info!("Native TUN intercepted TCP to {}", remote); } + if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await { + // SOCKS5 bypass handshake locally (loopback) + if socks.write_all(&[5, 1, 0]).await.is_err() { return; } + 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 port = remote.port(); + let mut req = vec![5, 1, 0]; + match ip { + std::net::IpAddr::V4(v4) => { + req.push(1); + req.extend_from_slice(&v4.octets()); + } + std::net::IpAddr::V6(v6) => { + req.push(4); + req.extend_from_slice(&v6.octets()); + } + } + req.extend_from_slice(&port.to_be_bytes()); + if socks.write_all(&req).await.is_err() { return; } + + let mut rep = [0u8; 10]; + if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; } + + let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await; + } + }); + } + } + }); + + tracing::info!("NATIVE TUN tunnel active."); + + tokio::select! { + _ = shutdown.changed() => {} + _ = &mut runner_task => {} + _ = &mut tun_to_stack => {} + _ = &mut stack_to_tun => {} + _ = &mut tcp_accept_task => {} + } + + tracing::info!("Deactivating NATIVE TUN tunnel..."); + // Cleanup routes + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + let cleanup_script = format!( + "$remote_ip = '{}'\n\ + Remove-NetRoute -DestinationPrefix \"$remote_ip/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\ + Remove-NetRoute -DestinationPrefix \"1.1.1.1/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\ + Remove-NetFirewallRule -DisplayName 'OSTP Tunnel*' -ErrorAction SilentlyContinue\n\ + netsh interface ipv4 set dnsservers name=\"ostp_tun\" source=dhcp 2>$null\n", + server_ip_str + ); + let _ = Command::new("powershell") + .creation_flags(CREATE_NO_WINDOW) + .args(["-NoProfile", "-Command", &cleanup_script]) + .output(); + } + + Ok(()) +} + +#[cfg(not(any(target_os = "windows", target_os = "linux")))] +pub async fn run_native_tunnel( + _config: crate::config::ClientConfig, + _shutdown: watch::Receiver, +) -> Result<()> { + Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux currently")) +} + +#[cfg(target_os = "android")] +pub async fn run_native_tunnel_from_fd( + config: crate::config::ClientConfig, + mut shutdown: watch::Receiver, + fd: i32, +) -> Result<()> { + use netstack_smoltcp::StackBuilder; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use futures::{StreamExt, SinkExt}; + use std::os::unix::io::{FromRawFd, AsRawFd}; + + let debug = config.debug; + tracing::info!("Initializing NATIVE TUN tunnel on Android (FD {})", fd); + + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + + let file = unsafe { std::fs::File::from_raw_fd(fd) }; + let tun_stream = tokio::io::unix::AsyncFd::new(file)?; + + let (stack, tcp_runner, _udp_socket, tcp_listener) = StackBuilder::default() + .enable_tcp(true) + .enable_udp(true) + .mtu(config.ostp.mtu) + .build()?; + + let mut runner_task = tokio::spawn(async move { + if let Some(mut runner) = tcp_runner { + let _ = runner.await; + } + }); + + let (mut stack_sink, mut stack_stream) = stack.split(); + + let mut tun_to_stack = tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + loop { + let mut guard = match tun_stream.readable().await { + Ok(g) => g, + Err(_) => break, + }; + + 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()) }; + if res < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + Err(err) + } else { + Ok(res) // Return error as success to break gracefully + } + } else { + Ok(res) + } + }) { + Ok(Ok(n)) if n > 0 => n as usize, + Ok(Ok(_)) => break, // EOF or Error + Ok(Err(_)) => continue, // Should not happen with try_io + Err(_would_block) => continue, + }; + + let frame = buf[..n].to_vec(); + if stack_sink.send(frame).await.is_err() { + break; + } + } + }); + + let write_fd = unsafe { libc::dup(fd) }; + if write_fd < 0 { + return Err(anyhow!("Failed to dup tun fd")); + } + unsafe { + let flags = libc::fcntl(write_fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + let write_file = unsafe { std::fs::File::from_raw_fd(write_fd) }; + let tun_write_stream = tokio::io::unix::AsyncFd::new(write_file)?; + + let mut stack_to_tun = tokio::spawn(async move { + while let Some(Ok(frame)) = stack_stream.next().await { + let mut written = 0; + while written < frame.len() { + let mut guard = match tun_write_stream.writable().await { + Ok(g) => g, + Err(_) => break, + }; + + 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) }; + if res < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + Err(err) + } else { + Ok(res) + } + } else { + Ok(res) + } + }); + + match res { + Ok(Ok(n)) if n > 0 => written += n as usize, + Ok(Ok(_)) => break, + Ok(Err(_)) => continue, + Err(_) => continue, + } + } + } + }); + + let proxy_addr = config.local_proxy.bind_addr.clone(); + let mut tcp_accept_task = tokio::spawn(async move { + if let Some(mut listener) = tcp_listener { + while let Some((mut stream, _local, remote)) = listener.next().await { + let proxy_addr = proxy_addr.clone(); + tokio::spawn(async move { + if debug { tracing::info!("Native TUN intercepted TCP to {}", remote); } + if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await { + if socks.write_all(&[5, 1, 0]).await.is_err() { return; } + 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 port = remote.port(); + let mut req = vec![5, 1, 0]; + match ip { + std::net::IpAddr::V4(v4) => { + req.push(1); + req.extend_from_slice(&v4.octets()); + } + std::net::IpAddr::V6(v6) => { + req.push(4); + req.extend_from_slice(&v6.octets()); + } + } + req.extend_from_slice(&port.to_be_bytes()); + if socks.write_all(&req).await.is_err() { return; } + + let mut rep = [0u8; 10]; + if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; } + + let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await; + } + }); + } + } + }); + + tracing::info!("NATIVE TUN (Android) tunnel active."); + + tokio::select! { + _ = shutdown.changed() => {} + _ = &mut runner_task => {} + _ = &mut tun_to_stack => {} + _ = &mut stack_to_tun => {} + _ = &mut tcp_accept_task => {} + } + + tracing::info!("Deactivating NATIVE TUN tunnel..."); + Ok(()) +} + +#[cfg(not(target_os = "android"))] +pub async fn run_native_tunnel_from_fd( + _config: crate::config::ClientConfig, + _shutdown: watch::Receiver, + _fd: i32, +) -> Result<()> { + Err(anyhow!("Native TUN from FD is only supported on Android")) +} diff --git a/ostp-control/src/App.tsx b/ostp-control/src/App.tsx index e606f8b..25f23ed 100644 --- a/ostp-control/src/App.tsx +++ b/ostp-control/src/App.tsx @@ -11,6 +11,7 @@ import Wiki from './pages/Wiki'; import Tools from './pages/Tools'; import AuditLogs from './pages/AuditLogs'; import Login from './pages/Login'; +import Dns from './pages/Dns'; // State and Context import { api } from './lib/api'; @@ -77,6 +78,10 @@ function MainLayout() { {isSidebarOpen && {t('sidebar_wiki')}} + + + {isSidebarOpen && {t('sidebar_dns')}} + {isSidebarOpen && {t('sidebar_history')}} @@ -143,6 +148,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> diff --git a/ostp-control/src/lib/api.ts b/ostp-control/src/lib/api.ts index 9c1d10b..061e7c3 100644 --- a/ostp-control/src/lib/api.ts +++ b/ostp-control/src/lib/api.ts @@ -21,6 +21,20 @@ export interface ApiResponse { error?: string; } +export interface DnsConfig { + enabled: boolean; + doh_upstream: string; + adblock_urls: string[]; + custom_domains: Record; +} + +export interface DnsQueryLog { + timestamp: number; + domain: string; + client_ip: string; + blocked: boolean; +} + const API_TOKEN_KEY = 'ostp_api_token'; export function getApiSettings() { @@ -126,5 +140,18 @@ export const api = { return json.data; } throw new Error(json.error || 'Failed to fetch subscription link'); - } + }, + + getDnsConfig: () => request('/api/dns/config'), + + updateDnsConfig: (config: DnsConfig) => request('/api/dns/config', { + method: 'POST', + body: JSON.stringify(config), + }), + + getDnsQueries: () => request('/api/dns/queries'), + + refreshDnsBlocklists: () => request('/api/dns/blocklists/refresh', { + method: 'POST', + }), }; diff --git a/ostp-control/src/lib/i18n.ts b/ostp-control/src/lib/i18n.ts index d38613e..8e5c73a 100644 --- a/ostp-control/src/lib/i18n.ts +++ b/ostp-control/src/lib/i18n.ts @@ -4,6 +4,7 @@ export const translations = { sidebar_clients: 'Клиенты', sidebar_settings: 'Настройки', sidebar_tools: 'Инструменты', + sidebar_dns: 'DNS Фильтрация', sidebar_wiki: 'Документация', sidebar_history: 'История', @@ -169,12 +170,35 @@ export const translations = { au_time: 'Время', au_event: 'Событие', au_status: 'Результат', + + dns_title: 'DNS Фильтрация', + dns_subtitle: 'Встроенный DNS сервер с AdBlock и поддержкой DoH', + dns_enable: 'Включить встроенный DNS сервер', + dns_upstream: 'DoH Upstream сервер', + dns_upstream_sub: 'Куда будут отправляться разрешенные DNS запросы (поверх HTTPS)', + dns_custom_domains: 'Пользовательские домены', + dns_domain: 'Домен', + dns_ip: 'IP адрес', + dns_add_custom: 'Добавить', + dns_adblock_lists: 'Списки блокировки AdBlock', + dns_list_url: 'URL списка (формат hosts)', + dns_add_list: 'Добавить список', + dns_save: 'Сохранить настройки', + dns_refresh: 'Обновить списки блокировки', + dns_query_log: 'Журнал DNS запросов', + dns_q_time: 'Время', + dns_q_domain: 'Домен', + dns_q_client: 'Клиент', + dns_q_status: 'Статус', + dns_q_allowed: 'Разрешен', + dns_q_blocked: 'Заблокирован', }, en: { sidebar_dashboard: 'Dashboard', sidebar_clients: 'Clients', sidebar_settings: 'Settings', sidebar_tools: 'Tools', + sidebar_dns: 'DNS Filter', sidebar_wiki: 'Wiki', sidebar_history: 'Audit Logs', @@ -340,6 +364,28 @@ export const translations = { au_time: 'Time', au_event: 'Event', au_status: 'Result', + + dns_title: 'DNS Filtering', + dns_subtitle: 'Built-in DNS server with AdBlock and DoH support', + dns_enable: 'Enable built-in DNS server', + dns_upstream: 'DoH Upstream Server', + dns_upstream_sub: 'Where allowed DNS queries will be forwarded (DNS over HTTPS)', + dns_custom_domains: 'Custom Domains', + dns_domain: 'Domain', + dns_ip: 'IP Address', + dns_add_custom: 'Add Domain', + dns_adblock_lists: 'AdBlock Lists', + dns_list_url: 'List URL (hosts format)', + dns_add_list: 'Add List', + dns_save: 'Save Settings', + dns_refresh: 'Refresh Blocklists', + dns_query_log: 'DNS Query Log', + dns_q_time: 'Time', + dns_q_domain: 'Domain', + dns_q_client: 'Client IP', + dns_q_status: 'Status', + dns_q_allowed: 'Allowed', + dns_q_blocked: 'Blocked', } } as const; diff --git a/ostp-control/src/pages/Dns.tsx b/ostp-control/src/pages/Dns.tsx new file mode 100644 index 0000000..9770c4c --- /dev/null +++ b/ostp-control/src/pages/Dns.tsx @@ -0,0 +1,327 @@ +import { useState, useEffect } from 'react'; +import { Globe, Plus, Trash2, Save, RefreshCw, AlertCircle, CheckCircle, XCircle } from 'lucide-react'; +import { api } from '../lib/api'; +import type { DnsConfig, DnsQueryLog } from '../lib/api'; +import { useLanguage } from '../lib/LanguageContext'; + +export default function Dns() { + const { t } = useLanguage(); + + const [config, setConfig] = useState(null); + const [queries, setQueries] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + // Forms state + const [newDomain, setNewDomain] = useState(''); + const [newIp, setNewIp] = useState(''); + const [newUrl, setNewUrl] = useState(''); + + const fetchConfig = async () => { + try { + const data = await api.getDnsConfig(); + setConfig(data); + } catch (err: any) { + setError(err.message); + } + }; + + const fetchQueries = async () => { + try { + const data = await api.getDnsQueries(); + setQueries(data.reverse()); // Show newest first + } catch (err: any) { + console.error('Failed to load DNS queries', err); + } + }; + + const loadData = async () => { + setLoading(true); + await fetchConfig(); + await fetchQueries(); + setLoading(false); + }; + + useEffect(() => { + loadData(); + const interval = setInterval(fetchQueries, 5000); + return () => clearInterval(interval); + }, []); + + const handleSave = async () => { + if (!config) return; + setSaving(true); + setError(null); + try { + await api.updateDnsConfig(config); + // Wait a moment for backend to potentially fetch blocklists + setTimeout(loadData, 1000); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const handleRefreshBlocklists = async () => { + setRefreshing(true); + try { + await api.refreshDnsBlocklists(); + } catch (err: any) { + setError(err.message); + } finally { + setRefreshing(false); + } + }; + + const addCustomDomain = () => { + if (!newDomain || !newIp || !config) return; + setConfig({ + ...config, + custom_domains: { + ...config.custom_domains, + [newDomain.toLowerCase()]: newIp + } + }); + setNewDomain(''); + setNewIp(''); + }; + + const removeCustomDomain = (domain: string) => { + if (!config) return; + const newDomains = { ...config.custom_domains }; + delete newDomains[domain]; + setConfig({ ...config, custom_domains: newDomains }); + }; + + const addAdblockUrl = () => { + if (!newUrl || !config) return; + setConfig({ + ...config, + adblock_urls: [...config.adblock_urls, newUrl] + }); + setNewUrl(''); + }; + + const removeAdblockUrl = (index: number) => { + if (!config) return; + const newUrls = [...config.adblock_urls]; + newUrls.splice(index, 1); + setConfig({ ...config, adblock_urls: newUrls }); + }; + + if (loading && !config) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +
+
+

{t('dns_title')}

+

{t('dns_subtitle')}

+
+
+ + {error && ( +
+ +

{error}

+
+ )} + + {config && ( +
+ {/* Main Settings Panel */} +
+
+ + +
+ + setConfig({ ...config, doh_upstream: e.target.value })} + className="w-full bg-surface border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all" + placeholder="https://cloudflare-dns.com/dns-query" + /> +

{t('dns_upstream_sub')}

+
+ +
+ +
+
+ + {/* Custom Domains */} +
+

{t('dns_custom_domains')}

+
+ setNewDomain(e.target.value)} + placeholder="example.local" + className="flex-1 bg-surface border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary" + onKeyDown={(e) => e.key === 'Enter' && addCustomDomain()} + /> + setNewIp(e.target.value)} + placeholder="192.168.1.10" + className="flex-1 bg-surface border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary" + onKeyDown={(e) => e.key === 'Enter' && addCustomDomain()} + /> + +
+
+ {Object.entries(config.custom_domains).map(([domain, ip]) => ( +
+
+
{domain}
+
{ip}
+
+ +
+ ))} + {Object.keys(config.custom_domains).length === 0 && ( +
Нет записей
+ )} +
+
+
+ + {/* AdBlock Lists & Queries */} +
+
+
+

{t('dns_adblock_lists')}

+ +
+
+ setNewUrl(e.target.value)} + placeholder="https://..." + className="flex-1 bg-surface border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary" + onKeyDown={(e) => e.key === 'Enter' && addAdblockUrl()} + /> + +
+
+ {config.adblock_urls.map((url, i) => ( +
+
{url}
+ +
+ ))} + {config.adblock_urls.length === 0 && ( +
Нет списков
+ )} +
+
+ +
+
+

{t('dns_query_log')}

+
+
{t('dns_q_allowed')}
+
{t('dns_q_blocked')}
+
+
+
+ + + + + + + + + + + {queries.map((q, i) => ( + + + + + + + ))} + {queries.length === 0 && ( + + + + )} + +
{t('dns_q_time')}{t('dns_q_domain')}{t('dns_q_client')}
+ {new Date(q.timestamp * 1000).toLocaleTimeString()} + + {q.domain} + + {q.client_ip} + + {q.blocked ? ( + + ) : ( + + )} +
+ Журнал пуст +
+
+
+
+
+ )} +
+ ); +} diff --git a/ostp-jni/src/lib.rs b/ostp-jni/src/lib.rs index e4021ef..2ceda28 100644 --- a/ostp-jni/src/lib.rs +++ b/ostp-jni/src/lib.rs @@ -355,7 +355,7 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_stopClient( } if let Some(rt) = runtime { - rt.shutdown_timeout(std::time::Duration::from_secs(3)); + rt.shutdown_background(); } add_log("OSTP SDK: Client successfully stopped".to_string()); diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml index a2edb1d..069758b 100644 --- a/ostp-server/Cargo.toml +++ b/ostp-server/Cargo.toml @@ -29,3 +29,4 @@ uuid = { version = "1", features = ["v4", "serde"] } rcgen = "0.13" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } futures-util = "0.3" +simple-dns = "0.11.3" diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 9b45989..788a6a0 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -20,10 +20,9 @@ use portable_atomic::AtomicU64; use std::time::Instant; use axum::{ - body::Body, extract::{Path, State}, - http::{header, Request, StatusCode, Uri}, - response::{IntoResponse, Response}, + http::{header, StatusCode, Uri}, + response::{IntoResponse}, routing::{get, post, put}, Json, Router, }; @@ -51,6 +50,7 @@ pub struct ApiState { pub server_port: u16, pub reality_query: String, pub config_path: Option, + pub dns_server: std::sync::Arc, } // ── API configuration ──────────────────────────────────────────────────────── @@ -209,7 +209,10 @@ pub fn create_api_router(state: ApiState) -> Router { .route("/users/{key}/limit", put(handle_set_limit)) .route("/users/{key}/reset", post(handle_reset_stats)) .route("/subscribe/{key}", get(handle_subscribe)) - .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/queries", get(handle_get_dns_queries)) + .route("/dns/blocklists/refresh", post(handle_refresh_blocklists)); let webpath = state.webpath.clone(); let webpath = webpath.trim_matches('/'); @@ -247,6 +250,7 @@ pub async fn start_api_server( server_port: u16, reality_query: String, config_path: Option, + dns_server: std::sync::Arc, ) { let state = ApiState { access_keys, @@ -260,6 +264,7 @@ pub async fn start_api_server( server_port, reality_query, config_path, + dns_server, }; let app = create_api_router(state); @@ -740,7 +745,16 @@ async fn handle_subscribe( // If client requests plain text, return ostp:// share link if accept.contains("text/plain") { - let link = format!("ostp://{}@{}:{}{}", key, state.server_host, state.server_port, state.reality_query); + let dns_enabled = state.dns_server.config.read().await.enabled; + let mut rq = state.reality_query.clone(); + if dns_enabled { + if rq.is_empty() { + rq = "?owndns=true".to_string(); + } else { + rq = format!("{}&owndns=true", rq); + } + } + let link = format!("ostp://{}@{}:{}{}", key, state.server_host, state.server_port, rq); return (StatusCode::OK, Json(serde_json::json!({ "ok": true, "data": link @@ -778,45 +792,95 @@ async fn handle_subscribe( }))) } +// ── 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; + } + // 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 { use super::*; - #[test] - fn test_router_creation() { - let state = ApiState { + fn make_test_state(webpath: &str) -> ApiState { + ApiState { access_keys: Arc::new(RwLock::new(HashMap::new())), user_stats: Arc::new(RwLock::new(HashMap::new())), start_time: std::time::Instant::now(), session_token: Arc::new(RwLock::new(None)), - webpath: "bNAzr8Ss".to_string(), + webpath: webpath.to_string(), username: "admin".to_string(), password_hash: "hash".to_string(), server_host: "127.0.0.1".to_string(), server_port: 50000, reality_query: "".to_string(), config_path: None, - }; - // This should not panic + dns_server: crate::dns::DnsServer::new(Default::default()), + } + } + + #[test] + fn test_router_creation() { + let state = make_test_state("bNAzr8Ss"); let _router = create_api_router(state); } #[test] fn test_router_creation_empty_webpath() { - let state = ApiState { - access_keys: Arc::new(RwLock::new(HashMap::new())), - user_stats: Arc::new(RwLock::new(HashMap::new())), - start_time: std::time::Instant::now(), - session_token: Arc::new(RwLock::new(None)), - webpath: "".to_string(), - username: "admin".to_string(), - password_hash: "hash".to_string(), - server_host: "127.0.0.1".to_string(), - server_port: 50000, - reality_query: "".to_string(), - config_path: None, - }; - // This should not panic + let state = make_test_state(""); let _router = create_api_router(state); } } diff --git a/ostp-server/src/dns.rs b/ostp-server/src/dns.rs new file mode 100644 index 0000000..fb6c70a --- /dev/null +++ b/ostp-server/src/dns.rs @@ -0,0 +1,196 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use tokio::sync::{RwLock, Mutex}; +use simple_dns::{Packet, rdata::RData, ResourceRecord, CLASS, TYPE, QTYPE}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsConfig { + pub enabled: bool, + pub doh_upstream: String, + pub adblock_urls: Vec, + pub custom_domains: HashMap, +} + +impl Default for DnsConfig { + fn default() -> Self { + Self { + enabled: false, + doh_upstream: "https://cloudflare-dns.com/dns-query".to_string(), + adblock_urls: vec![], + custom_domains: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct DnsQueryLog { + pub timestamp: u64, + pub domain: String, + pub client_ip: String, + pub blocked: bool, +} + +pub struct DnsServer { + pub config: RwLock, + adblock_trie: RwLock>, // Simplified to HashSet for now, or maybe a suffix tree + query_log: Mutex>, + reqwest_client: reqwest::Client, +} + +impl DnsServer { + pub fn new(config: DnsConfig) -> Arc { + let server = Arc::new(Self { + 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(), + }); + + // Spawn a background task to download blocklists + if config.enabled && !config.adblock_urls.is_empty() { + let server_clone = server.clone(); + tokio::spawn(async move { + server_clone.update_blocklists().await; + }); + } + + server + } + + pub async fn update_blocklists(&self) { + let urls = { + let cfg = self.config.read().await; + cfg.adblock_urls.clone() + }; + + let mut new_blocked = HashSet::new(); + + for url in urls { + if let Ok(resp) = self.reqwest_client.get(&url).send().await { + if let Ok(text) = resp.text().await { + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // Support standard hosts format: "0.0.0.0 ads.google.com" or just "ads.google.com" + let parts: Vec<&str> = line.split_whitespace().collect(); + let domain = if parts.len() >= 2 && (parts[0] == "0.0.0.0" || parts[0] == "127.0.0.1") { + parts[1] + } else { + parts[0] + }; + new_blocked.insert(domain.to_lowercase()); + } + } + } + } + + tracing::info!("Loaded {} domains into AdBlock engine", new_blocked.len()); + *self.adblock_trie.write().await = new_blocked; + } + + pub async fn resolve(&self, payload: &[u8], client_ip: std::net::IpAddr) -> Option> { + let cfg = self.config.read().await; + if !cfg.enabled { + return None; // If DNS is disabled, fallback to standard UDP proxying + } + + // Parse DNS packet + let packet = match Packet::parse(payload) { + Ok(p) => p, + Err(_) => return None, + }; + + if packet.questions.is_empty() { + return None; + } + + let question = &packet.questions[0]; + let qname = question.qname.to_string().to_lowercase(); + + // Check Custom Domains + if let Some(ip_str) = cfg.custom_domains.get(&qname) { + if let Ok(ip) = ip_str.parse::() { + if question.qtype == QTYPE::TYPE(TYPE::A) { + let mut response = Packet::new_reply(packet.id()); + response.questions.push(question.clone()); + response.answers.push(ResourceRecord::new( + question.qname.clone(), + CLASS::IN, + 60, + RData::A(ip.into()), + )); + self.log_query(qname, client_ip.to_string(), false).await; + return response.build_bytes_vec().ok(); + } + } + } + + // Check AdBlock (Suffix matching not implemented in this simple hashset, for full pi-hole we need suffix match) + // Let's do a simple suffix check + let blocked = { + let blocked_domains = self.adblock_trie.read().await; + let mut parts: Vec<&str> = qname.split('.').collect(); + let mut is_blocked = false; + while !parts.is_empty() { + let suffix = parts.join("."); + if blocked_domains.contains(&suffix) { + is_blocked = true; + break; + } + parts.remove(0); + } + is_blocked + }; + + if blocked { + let mut response = Packet::new_reply(packet.id()); + response.questions.push(question.clone()); + self.log_query(qname, client_ip.to_string(), true).await; + return response.build_bytes_vec().ok(); + } + + // Forward to DoH + let doh_url = cfg.doh_upstream.clone(); + drop(cfg); // Release config lock before making network request + + if let Ok(resp) = self.reqwest_client.post(&doh_url) + .header("Content-Type", "application/dns-message") + .header("Accept", "application/dns-message") + .body(payload.to_vec()) + .send() + .await + { + 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()); + } + } + } + + None + } + + async fn log_query(&self, domain: String, client_ip: String, blocked: bool) { + let mut log = self.query_log.lock().await; + if log.len() >= 1000 { + log.pop_front(); + } + log.push_back(DnsQueryLog { + timestamp: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(), + domain, + client_ip, + blocked, + }); + } + + pub async fn get_queries(&self) -> Vec { + let log = self.query_log.lock().await; + log.iter().cloned().collect() + } +} diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index f0cc051..2c57726 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -18,6 +18,7 @@ pub mod transport; pub mod relay_node; mod relay; mod signal; +pub mod dns; pub use outbound::{OutboundAction, OutboundConfig, OutboundRule}; pub use api::ApiConfig; @@ -59,6 +60,8 @@ pub(crate) enum UiEvent { pub(crate) struct RemoteState { pub data_tx: mpsc::UnboundedSender, pub cancel_tx: mpsc::Sender<()>, + #[allow(dead_code)] + pub is_dns: bool, } // ── Public API ─────────────────────────────────────────────────────────────── @@ -73,6 +76,7 @@ pub async fn run_server( debug: bool, reality_query: Option, reality_config: Option, + dns_config: Option, config_path: Option, ) -> Result<()> { let mut keys_map = HashMap::new(); @@ -240,6 +244,9 @@ pub async fn run_server( } }); + // Initialize DNS server + let dns_server = dns::DnsServer::new(dns_config.unwrap_or_default()); + // Spawn Management API if configured if let Some(api_cfg) = api_config { if api_cfg.enabled { @@ -252,8 +259,9 @@ pub async fn run_server( let server_host = server_public_ip.unwrap_or_else(|| parts.get(1).unwrap_or(&"0.0.0.0").to_string()); let rq = reality_query.clone().unwrap_or_default(); let config_path_api = config_path.clone(); + let dns_server_api = dns_server.clone(); tokio::spawn(async move { - api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api).await; + api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api).await; }); } } @@ -322,7 +330,7 @@ pub async fn run_server( }; tokio::select! { - res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug, tls_config) => { + res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug, tls_config, dns_server) => { if let Err(e) = res { tracing::error!("Server error: {e}"); } @@ -348,6 +356,7 @@ async fn run_server_loop( outbound: Option, debug: bool, tls_config: Option>, + dns_server: std::sync::Arc, ) -> Result<()> { let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new(); let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec)>(); @@ -545,6 +554,7 @@ async fn run_server_loop( stream_tx.clone(), connect_tx.clone(), outbound.clone(), + dns_server.clone(), debug, ).await?; } @@ -577,7 +587,7 @@ async fn run_server_loop( } } }); - remotes.insert((session_id, stream_id), RemoteState { data_tx, cancel_tx }); + remotes.insert((session_id, stream_id), RemoteState { data_tx, cancel_tx, is_dns: false }); let _ = relay::send_relay_to_stream(session_id, stream_id, RelayMessage::ConnectOk, &mut dispatcher, &socket, &ui_event_tx).await; let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT ok for [{session_id}:{stream_id}] -> {target}"))); } diff --git a/ostp-server/src/relay.rs b/ostp-server/src/relay.rs index 166d53c..8d33841 100644 --- a/ostp-server/src/relay.rs +++ b/ostp-server/src/relay.rs @@ -8,11 +8,12 @@ use tokio::net::UdpSocket; use tokio::sync::mpsc; use crate::dispatcher::Dispatcher; +use crate::dns::DnsServer; use crate::outbound::{self, OutboundConfig}; use crate::{RemoteState, UiEvent}; pub async fn handle_relay_message( - _peer_addr: std::net::SocketAddr, + peer_addr: std::net::SocketAddr, session_id: u32, stream_id: u16, payload: Bytes, @@ -23,11 +24,53 @@ pub async fn handle_relay_message( stream_tx: mpsc::UnboundedSender<(u32, u16, Vec)>, connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>, outbound_cfg: Option, + dns_server: std::sync::Arc, debug: bool, ) -> Result<()> { match RelayMessage::decode(&payload)? { RelayMessage::Connect(target) => { let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT start for [{session_id}:{stream_id}] -> {target}"))); + + // ── DNS Interception ──────────────────────────────────────────────── + // If client is connecting to port 53 (DNS), we handle it locally + // instead of opening a real UDP/TCP socket to the destination. + // + // Protocol flow: + // 1. Client sends Connect("8.8.8.8:53") → we reply ConnectOk + // 2. Client sends Data() → we resolve & reply Data() + Close + if is_dns_target(&target) { + let client_ip = peer_addr.ip(); + let dns_srv = dns_server.clone(); + let stream_tx_dns = stream_tx.clone(); + let (cancel_tx, _) = mpsc::channel::<()>(1); + + // Channel: relay.rs Data handler → DNS resolution task + let (dns_query_tx, mut dns_query_rx) = mpsc::unbounded_channel::(); + + // Spawn task that waits for the DNS query payload and resolves it + tokio::spawn(async move { + if let Some(query_bytes) = dns_query_rx.recv().await { + if let Some(resp_bytes) = dns_srv.resolve(&query_bytes, client_ip).await { + let _ = stream_tx_dns.send((session_id, stream_id, resp_bytes)); + } + } + // Always close the stream after responding + let _ = stream_tx_dns.send((session_id, stream_id, Vec::new())); + }); + + // Store as a RemoteState — Data messages will be forwarded to dns_query_tx + remotes.insert((session_id, stream_id), RemoteState { + data_tx: dns_query_tx, + cancel_tx, + is_dns: true, + }); + + // Tell the client we are ready to receive its DNS query + send_relay_to_stream(session_id, stream_id, RelayMessage::ConnectOk, dispatcher, socket, ui_event_tx).await?; + return Ok(()); + } + + // ── Normal TCP Connect ────────────────────────────────────────────── let target_clone = target.clone(); let connect_tx_clone = connect_tx.clone(); let stream_tx_clone = stream_tx.clone(); @@ -93,6 +136,11 @@ pub async fn handle_relay_message( Ok(()) } +/// Returns true if the target address is a DNS server (port 53) +fn is_dns_target(target: &str) -> bool { + target.ends_with(":53") +} + pub async fn send_relay_to_stream( session_id: u32, stream_id: u16, diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 8fa1b19..df0c5ac 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -241,6 +241,7 @@ struct ServerConfig { api: Option, fallback: Option, transport: Option, + dns: Option, } /// Конфигурация Relay-узла в config.json @@ -895,8 +896,10 @@ async fn run_app() -> Result<()> { }) }).collect::>(); let host = get_or_ask_public_ip(&args.config); + // Build DNS config and set owndns flag in subscribe links if DNS enabled + let dns_cfg = server_cfg.dns; // Pass all listen addresses for multi-listener support - ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, 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) => { run_client_directly(client_cfg).await?;