feat: Built-in DNS Server with AdBlock and DoH proxy

This commit is contained in:
ospab 2026-05-27 22:23:06 +03:00
parent 7c88115bb5
commit f69f194923
13 changed files with 1165 additions and 34 deletions

14
Cargo.lock generated
View File

@ -559,7 +559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -1455,6 +1455,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"simple-dns",
"socket2", "socket2",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@ -2047,6 +2048,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -2709,7 +2719,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

@ -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<bool>,
) -> 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<bool>,
) -> 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<bool>,
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<bool>,
_fd: i32,
) -> Result<()> {
Err(anyhow!("Native TUN from FD is only supported on Android"))
}

View File

@ -11,6 +11,7 @@ import Wiki from './pages/Wiki';
import Tools from './pages/Tools'; import Tools from './pages/Tools';
import AuditLogs from './pages/AuditLogs'; import AuditLogs from './pages/AuditLogs';
import Login from './pages/Login'; import Login from './pages/Login';
import Dns from './pages/Dns';
// State and Context // State and Context
import { api } from './lib/api'; import { api } from './lib/api';
@ -77,6 +78,10 @@ function MainLayout() {
<BookOpen className="w-5 h-5 text-blue-400" /> <BookOpen className="w-5 h-5 text-blue-400" />
{isSidebarOpen && <span>{t('sidebar_wiki')}</span>} {isSidebarOpen && <span>{t('sidebar_wiki')}</span>}
</Link> </Link>
<Link to="/dns" className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-white/5 transition-colors text-text-muted hover:text-white">
<Globe className="w-5 h-5 text-emerald-400" />
{isSidebarOpen && <span>{t('sidebar_dns')}</span>}
</Link>
<Link to="/logs" className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-white/5 transition-colors text-text-muted hover:text-white"> <Link to="/logs" className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-white/5 transition-colors text-text-muted hover:text-white">
<History className="w-5 h-5 text-yellow-400" /> <History className="w-5 h-5 text-yellow-400" />
{isSidebarOpen && <span>{t('sidebar_history')}</span>} {isSidebarOpen && <span>{t('sidebar_history')}</span>}
@ -143,6 +148,7 @@ function MainLayout() {
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/tools" element={<Tools />} /> <Route path="/tools" element={<Tools />} />
<Route path="/dns" element={<Dns />} />
<Route path="/logs" element={<AuditLogs />} /> <Route path="/logs" element={<AuditLogs />} />
</Routes> </Routes>
</div> </div>

View File

@ -21,6 +21,20 @@ export interface ApiResponse<T> {
error?: string; error?: string;
} }
export interface DnsConfig {
enabled: boolean;
doh_upstream: string;
adblock_urls: string[];
custom_domains: Record<string, string>;
}
export interface DnsQueryLog {
timestamp: number;
domain: string;
client_ip: string;
blocked: boolean;
}
const API_TOKEN_KEY = 'ostp_api_token'; const API_TOKEN_KEY = 'ostp_api_token';
export function getApiSettings() { export function getApiSettings() {
@ -126,5 +140,18 @@ export const api = {
return json.data; return json.data;
} }
throw new Error(json.error || 'Failed to fetch subscription link'); throw new Error(json.error || 'Failed to fetch subscription link');
} },
getDnsConfig: () => request<DnsConfig>('/api/dns/config'),
updateDnsConfig: (config: DnsConfig) => request<boolean>('/api/dns/config', {
method: 'POST',
body: JSON.stringify(config),
}),
getDnsQueries: () => request<DnsQueryLog[]>('/api/dns/queries'),
refreshDnsBlocklists: () => request<boolean>('/api/dns/blocklists/refresh', {
method: 'POST',
}),
}; };

View File

@ -4,6 +4,7 @@ export const translations = {
sidebar_clients: 'Клиенты', sidebar_clients: 'Клиенты',
sidebar_settings: 'Настройки', sidebar_settings: 'Настройки',
sidebar_tools: 'Инструменты', sidebar_tools: 'Инструменты',
sidebar_dns: 'DNS Фильтрация',
sidebar_wiki: 'Документация', sidebar_wiki: 'Документация',
sidebar_history: 'История', sidebar_history: 'История',
@ -169,12 +170,35 @@ export const translations = {
au_time: 'Время', au_time: 'Время',
au_event: 'Событие', au_event: 'Событие',
au_status: 'Результат', 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: { en: {
sidebar_dashboard: 'Dashboard', sidebar_dashboard: 'Dashboard',
sidebar_clients: 'Clients', sidebar_clients: 'Clients',
sidebar_settings: 'Settings', sidebar_settings: 'Settings',
sidebar_tools: 'Tools', sidebar_tools: 'Tools',
sidebar_dns: 'DNS Filter',
sidebar_wiki: 'Wiki', sidebar_wiki: 'Wiki',
sidebar_history: 'Audit Logs', sidebar_history: 'Audit Logs',
@ -340,6 +364,28 @@ export const translations = {
au_time: 'Time', au_time: 'Time',
au_event: 'Event', au_event: 'Event',
au_status: 'Result', 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; } as const;

View File

@ -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<DnsConfig | null>(null);
const [queries, setQueries] = useState<DnsQueryLog[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center gap-3 mb-8">
<div className="p-3 bg-primary/10 rounded-xl">
<Globe className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold text-white tracking-tight">{t('dns_title')}</h1>
<p className="text-text-muted mt-1">{t('dns_subtitle')}</p>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p>{error}</p>
</div>
)}
{config && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Main Settings Panel */}
<div className="space-y-6">
<div className="glass p-6 rounded-2xl border border-white/5 space-y-6">
<label className="flex items-center justify-between p-4 bg-surface rounded-xl border border-white/5 cursor-pointer hover:bg-white/5 transition-colors">
<div className="space-y-1">
<div className="text-white font-medium">{t('dns_enable')}</div>
</div>
<div className="relative">
<input
type="checkbox"
className="sr-only"
checked={config.enabled}
onChange={(e) => setConfig({ ...config, enabled: e.target.checked })}
/>
<div className={`block w-14 h-8 rounded-full transition-colors ${config.enabled ? 'bg-primary' : 'bg-surface-light border border-white/10'}`}></div>
<div className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform ${config.enabled ? 'transform translate-x-6' : ''}`}></div>
</div>
</label>
<div className="space-y-2">
<label className="block text-sm font-medium text-text-muted">{t('dns_upstream')}</label>
<input
type="text"
value={config.doh_upstream}
onChange={(e) => 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"
/>
<p className="text-xs text-text-muted mt-1">{t('dns_upstream_sub')}</p>
</div>
<div className="pt-4 border-t border-white/5 flex gap-3">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 bg-primary hover:bg-primary-hover text-background font-bold py-3 px-4 rounded-xl transition-all shadow-[0_0_20px_rgba(34,211,165,0.2)] disabled:opacity-50"
>
{saving ? <RefreshCw className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
{t('dns_save')}
</button>
</div>
</div>
{/* Custom Domains */}
<div className="glass p-6 rounded-2xl border border-white/5 space-y-4">
<h3 className="text-lg font-bold text-white">{t('dns_custom_domains')}</h3>
<div className="flex gap-2">
<input
type="text"
value={newDomain}
onChange={(e) => 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()}
/>
<input
type="text"
value={newIp}
onChange={(e) => 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()}
/>
<button onClick={addCustomDomain} className="bg-primary/20 hover:bg-primary/30 text-primary p-2 rounded-lg transition-colors">
<Plus className="w-5 h-5" />
</button>
</div>
<div className="space-y-2 mt-4 max-h-48 overflow-y-auto pr-2">
{Object.entries(config.custom_domains).map(([domain, ip]) => (
<div key={domain} className="flex items-center justify-between bg-surface p-3 rounded-lg border border-white/5">
<div>
<div className="text-white font-medium text-sm">{domain}</div>
<div className="text-text-muted text-xs font-mono mt-0.5">{ip}</div>
</div>
<button onClick={() => removeCustomDomain(domain)} className="text-red-400 hover:text-red-300 p-1">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{Object.keys(config.custom_domains).length === 0 && (
<div className="text-center text-text-muted text-sm py-4">Нет записей</div>
)}
</div>
</div>
</div>
{/* AdBlock Lists & Queries */}
<div className="space-y-6">
<div className="glass p-6 rounded-2xl border border-white/5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-white">{t('dns_adblock_lists')}</h3>
<button
onClick={handleRefreshBlocklists}
disabled={refreshing}
className="text-xs flex items-center gap-1.5 bg-white/5 hover:bg-white/10 text-white py-1.5 px-3 rounded-lg transition-colors"
>
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
{t('dns_refresh')}
</button>
</div>
<div className="flex gap-2">
<input
type="text"
value={newUrl}
onChange={(e) => 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()}
/>
<button onClick={addAdblockUrl} className="bg-primary/20 hover:bg-primary/30 text-primary p-2 rounded-lg transition-colors">
<Plus className="w-5 h-5" />
</button>
</div>
<div className="space-y-2 mt-4 max-h-48 overflow-y-auto pr-2">
{config.adblock_urls.map((url, i) => (
<div key={i} className="flex items-center justify-between bg-surface p-3 rounded-lg border border-white/5">
<div className="text-white text-sm truncate pr-4" title={url}>{url}</div>
<button onClick={() => removeAdblockUrl(i)} className="text-red-400 hover:text-red-300 p-1 flex-shrink-0">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{config.adblock_urls.length === 0 && (
<div className="text-center text-text-muted text-sm py-4">Нет списков</div>
)}
</div>
</div>
<div className="glass p-6 rounded-2xl border border-white/5 flex flex-col h-[400px]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">{t('dns_query_log')}</h3>
<div className="flex gap-3 text-xs text-text-muted">
<div className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-primary"></span> {t('dns_q_allowed')}</div>
<div className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500"></span> {t('dns_q_blocked')}</div>
</div>
</div>
<div className="flex-1 overflow-auto -mx-4 px-4">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="text-text-muted sticky top-0 bg-[#0f111a] z-10">
<tr>
<th className="pb-3 font-medium px-2">{t('dns_q_time')}</th>
<th className="pb-3 font-medium px-2">{t('dns_q_domain')}</th>
<th className="pb-3 font-medium px-2">{t('dns_q_client')}</th>
<th className="pb-3 font-medium px-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{queries.map((q, i) => (
<tr key={i} className="hover:bg-white/5 transition-colors">
<td className="py-2.5 px-2 text-text-muted">
{new Date(q.timestamp * 1000).toLocaleTimeString()}
</td>
<td className="py-2.5 px-2 text-white max-w-[150px] truncate" title={q.domain}>
{q.domain}
</td>
<td className="py-2.5 px-2 text-text-muted font-mono text-xs">
{q.client_ip}
</td>
<td className="py-2.5 px-2 text-right">
{q.blocked ? (
<XCircle className="w-4 h-4 text-red-500 inline" />
) : (
<CheckCircle className="w-4 h-4 text-primary/50 inline" />
)}
</td>
</tr>
))}
{queries.length === 0 && (
<tr>
<td colSpan={4} className="py-8 text-center text-text-muted">
Журнал пуст
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -355,7 +355,7 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_stopClient(
} }
if let Some(rt) = runtime { 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()); add_log("OSTP SDK: Client successfully stopped".to_string());

View File

@ -29,3 +29,4 @@ uuid = { version = "1", features = ["v4", "serde"] }
rcgen = "0.13" rcgen = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
futures-util = "0.3" futures-util = "0.3"
simple-dns = "0.11.3"

View File

@ -20,10 +20,9 @@ use portable_atomic::AtomicU64;
use std::time::Instant; use std::time::Instant;
use axum::{ use axum::{
body::Body,
extract::{Path, State}, extract::{Path, State},
http::{header, Request, StatusCode, Uri}, http::{header, StatusCode, Uri},
response::{IntoResponse, Response}, response::{IntoResponse},
routing::{get, post, put}, routing::{get, post, put},
Json, Router, Json, Router,
}; };
@ -51,6 +50,7 @@ pub struct ApiState {
pub server_port: u16, pub server_port: u16,
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>,
} }
// ── API configuration ──────────────────────────────────────────────────────── // ── 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}/limit", put(handle_set_limit))
.route("/users/{key}/reset", post(handle_reset_stats)) .route("/users/{key}/reset", post(handle_reset_stats))
.route("/subscribe/{key}", get(handle_subscribe)) .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 = state.webpath.clone();
let webpath = webpath.trim_matches('/'); let webpath = webpath.trim_matches('/');
@ -247,6 +250,7 @@ pub async fn start_api_server(
server_port: u16, server_port: u16,
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>,
) { ) {
let state = ApiState { let state = ApiState {
access_keys, access_keys,
@ -260,6 +264,7 @@ pub async fn start_api_server(
server_port, server_port,
reality_query, reality_query,
config_path, config_path,
dns_server,
}; };
let app = create_api_router(state); let app = create_api_router(state);
@ -740,7 +745,16 @@ async fn handle_subscribe(
// If client requests plain text, return ostp:// share link // If client requests plain text, return ostp:// share link
if accept.contains("text/plain") { 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!({ return (StatusCode::OK, Json(serde_json::json!({
"ok": true, "ok": true,
"data": link "data": link
@ -778,45 +792,95 @@ async fn handle_subscribe(
}))) })))
} }
// ── DNS API Handlers ──────────────────────────────────────────────────────────
async fn handle_get_dns_config(
State(state): State<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<serde_json::Value>();
}
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<ApiState>,
headers: axum::http::HeaderMap,
Json(body): Json<crate::dns::DnsConfig>,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<bool>();
}
// 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<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<Vec<serde_json::Value>>();
}
let queries = state.dns_server.get_queries().await;
let data: Vec<serde_json::Value> = 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<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<bool>();
}
let dns = state.dns_server.clone();
tokio::spawn(async move { dns.update_blocklists().await; });
(StatusCode::OK, ApiResponse::success(true))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] fn make_test_state(webpath: &str) -> ApiState {
fn test_router_creation() { ApiState {
let state = ApiState {
access_keys: Arc::new(RwLock::new(HashMap::new())), access_keys: Arc::new(RwLock::new(HashMap::new())),
user_stats: Arc::new(RwLock::new(HashMap::new())), user_stats: Arc::new(RwLock::new(HashMap::new())),
start_time: std::time::Instant::now(), start_time: std::time::Instant::now(),
session_token: Arc::new(RwLock::new(None)), session_token: Arc::new(RwLock::new(None)),
webpath: "bNAzr8Ss".to_string(), webpath: webpath.to_string(),
username: "admin".to_string(), username: "admin".to_string(),
password_hash: "hash".to_string(), password_hash: "hash".to_string(),
server_host: "127.0.0.1".to_string(), server_host: "127.0.0.1".to_string(),
server_port: 50000, server_port: 50000,
reality_query: "".to_string(), reality_query: "".to_string(),
config_path: None, config_path: None,
}; dns_server: crate::dns::DnsServer::new(Default::default()),
// This should not panic }
}
#[test]
fn test_router_creation() {
let state = make_test_state("bNAzr8Ss");
let _router = create_api_router(state); let _router = create_api_router(state);
} }
#[test] #[test]
fn test_router_creation_empty_webpath() { fn test_router_creation_empty_webpath() {
let state = ApiState { let state = make_test_state("");
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 _router = create_api_router(state); let _router = create_api_router(state);
} }
} }

196
ostp-server/src/dns.rs Normal file
View File

@ -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<String>,
pub custom_domains: HashMap<String, String>,
}
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<DnsConfig>,
adblock_trie: RwLock<HashSet<String>>, // Simplified to HashSet for now, or maybe a suffix tree
query_log: Mutex<VecDeque<DnsQueryLog>>,
reqwest_client: reqwest::Client,
}
impl DnsServer {
pub fn new(config: DnsConfig) -> Arc<Self> {
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<Vec<u8>> {
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::<std::net::Ipv4Addr>() {
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<DnsQueryLog> {
let log = self.query_log.lock().await;
log.iter().cloned().collect()
}
}

View File

@ -18,6 +18,7 @@ pub mod transport;
pub mod relay_node; pub mod relay_node;
mod relay; mod relay;
mod signal; mod signal;
pub mod dns;
pub use outbound::{OutboundAction, OutboundConfig, OutboundRule}; pub use outbound::{OutboundAction, OutboundConfig, OutboundRule};
pub use api::ApiConfig; pub use api::ApiConfig;
@ -59,6 +60,8 @@ pub(crate) enum UiEvent {
pub(crate) struct RemoteState { pub(crate) struct RemoteState {
pub data_tx: mpsc::UnboundedSender<Bytes>, pub data_tx: mpsc::UnboundedSender<Bytes>,
pub cancel_tx: mpsc::Sender<()>, pub cancel_tx: mpsc::Sender<()>,
#[allow(dead_code)]
pub is_dns: bool,
} }
// ── Public API ─────────────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────────────
@ -73,6 +76,7 @@ pub async fn run_server(
debug: bool, debug: bool,
reality_query: Option<String>, reality_query: Option<String>,
reality_config: Option<RealityServerConfig>, reality_config: Option<RealityServerConfig>,
dns_config: Option<dns::DnsConfig>,
config_path: Option<std::path::PathBuf>, config_path: Option<std::path::PathBuf>,
) -> Result<()> { ) -> Result<()> {
let mut keys_map = HashMap::new(); 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 // Spawn Management API if configured
if let Some(api_cfg) = api_config { if let Some(api_cfg) = api_config {
if api_cfg.enabled { 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 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 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();
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).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! { 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 { if let Err(e) = res {
tracing::error!("Server error: {e}"); tracing::error!("Server error: {e}");
} }
@ -348,6 +356,7 @@ async fn run_server_loop(
outbound: Option<OutboundConfig>, outbound: Option<OutboundConfig>,
debug: bool, debug: bool,
tls_config: Option<std::sync::Arc<rustls::ServerConfig>>, tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
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>)>();
@ -545,6 +554,7 @@ async fn run_server_loop(
stream_tx.clone(), stream_tx.clone(),
connect_tx.clone(), connect_tx.clone(),
outbound.clone(), outbound.clone(),
dns_server.clone(),
debug, debug,
).await?; ).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 _ = 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}"))); let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT ok for [{session_id}:{stream_id}] -> {target}")));
} }

View File

@ -8,11 +8,12 @@ use tokio::net::UdpSocket;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::dispatcher::Dispatcher; use crate::dispatcher::Dispatcher;
use crate::dns::DnsServer;
use crate::outbound::{self, OutboundConfig}; 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(
_peer_addr: std::net::SocketAddr, peer_addr: std::net::SocketAddr,
session_id: u32, session_id: u32,
stream_id: u16, stream_id: u16,
payload: Bytes, payload: Bytes,
@ -23,11 +24,53 @@ pub async fn handle_relay_message(
stream_tx: mpsc::UnboundedSender<(u32, u16, Vec<u8>)>, stream_tx: mpsc::UnboundedSender<(u32, u16, 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>, outbound_cfg: Option<OutboundConfig>,
dns_server: std::sync::Arc<DnsServer>,
debug: bool, debug: bool,
) -> Result<()> { ) -> Result<()> {
match RelayMessage::decode(&payload)? { match RelayMessage::decode(&payload)? {
RelayMessage::Connect(target) => { RelayMessage::Connect(target) => {
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT start for [{session_id}:{stream_id}] -> {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(<dns_query_bytes>) → we resolve & reply Data(<dns_response>) + 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::<Bytes>();
// 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 target_clone = 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();
@ -93,6 +136,11 @@ pub async fn handle_relay_message(
Ok(()) 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( pub async fn send_relay_to_stream(
session_id: u32, session_id: u32,
stream_id: u16, stream_id: u16,

View File

@ -241,6 +241,7 @@ struct ServerConfig {
api: Option<ApiConfig>, api: Option<ApiConfig>,
fallback: Option<FallbackCfg>, fallback: Option<FallbackCfg>,
transport: Option<TransportConfigRaw>, transport: Option<TransportConfigRaw>,
dns: Option<ostp_server::dns::DnsConfig>,
} }
/// Конфигурация Relay-узла в config.json /// Конфигурация Relay-узла в config.json
@ -895,8 +896,10 @@ async fn run_app() -> Result<()> {
}) })
}).collect::<Vec<_>>(); }).collect::<Vec<_>>();
let host = get_or_ask_public_ip(&args.config); 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 // 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) => { AppMode::Client(client_cfg) => {
run_client_directly(client_cfg).await?; run_client_directly(client_cfg).await?;