Remove built-in DNS server and owndns features

This commit is contained in:
ospab 2026-06-10 22:52:35 +03:00
parent 6bf7b06b43
commit 2b0d85b530
14 changed files with 391 additions and 568 deletions

View File

@ -24,6 +24,8 @@ pub struct ClientConfig {
pub tun_stack: String, pub tun_stack: String,
#[serde(default)] #[serde(default)]
pub kill_switch: bool, pub kill_switch: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gui: Option<serde_json::Value>,
} }
fn default_tun_stack() -> String { "system".to_string() } fn default_tun_stack() -> String { "system".to_string() }
@ -151,6 +153,7 @@ impl Default for ClientConfig {
dns_server: None, dns_server: None,
tun_stack: "system".to_string(), tun_stack: "system".to_string(),
kill_switch: false, kill_switch: false,
gui: None,
} }
} }
} }
@ -180,6 +183,7 @@ struct RawUnifiedConfig {
mux: Option<RawMuxSection>, mux: Option<RawMuxSection>,
reality: Option<RawRealitySection>, reality: Option<RawRealitySection>,
transport: Option<RawTransportSection>, transport: Option<RawTransportSection>,
gui: Option<serde_json::Value>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -290,6 +294,7 @@ impl ClientConfig {
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()), dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()), tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
gui: raw.gui,
}) })
} }
} }

View File

@ -11,7 +11,6 @@ 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';
import Routing from './pages/Routing'; import Routing from './pages/Routing';
// State and Context // State and Context
@ -83,10 +82,7 @@ function MainLayout() {
<RouteIcon className="w-5 h-5 text-orange-400" /> <RouteIcon className="w-5 h-5 text-orange-400" />
{isSidebarOpen && <span>Routing</span>} {isSidebarOpen && <span>Routing</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>}
@ -154,7 +150,6 @@ function MainLayout() {
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/routing" element={<Routing />} /> <Route path="/routing" element={<Routing />} />
<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

@ -1,327 +0,0 @@
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

@ -40,7 +40,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
String _tunStack = 'ostp'; // 'system' | 'ostp' String _tunStack = 'ostp'; // 'system' | 'ostp'
bool _muxEnabled = false; bool _muxEnabled = false;
late TextEditingController _muxSessionsCtrl; late TextEditingController _muxSessionsCtrl;
bool _owndns = false;
@override @override
void initState() { void initState() {
@ -64,8 +64,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_debugMode = widget.prefs.getBool('debug_mode') ?? false; _debugMode = widget.prefs.getBool('debug_mode') ?? false;
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false; _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2'); _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
_owndns = widget.prefs.getBool('owndns') ?? false;
}
@override @override
void dispose() { void dispose() {
@ -105,8 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
widget.prefs.setString('sid', _sidCtrl.text.trim()); widget.prefs.setString('sid', _sidCtrl.text.trim());
widget.prefs.setBool('mux_enabled', _muxEnabled); widget.prefs.setBool('mux_enabled', _muxEnabled);
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim()); 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}) { Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) {
return Column( return Column(
@ -240,8 +238,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_realityEnabled = uri.queryParameters['reality'] == 'true'; _realityEnabled = uri.queryParameters['reality'] == 'true';
final type = uri.queryParameters['type'] ?? 'udp'; final type = uri.queryParameters['type'] ?? 'udp';
_transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp'; _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp';
_owndns = uri.queryParameters['owndns'] == 'true';
_importCtrl.clear(); _importCtrl.clear();
_saveSettings(); _saveSettings();
}); });
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully'))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully')));

View File

@ -188,22 +188,7 @@
<input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" /> <input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" />
</div> </div>
<!-- Built-in DNS toggle --> <div class="field-group">
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-name" data-i18n="label_owndns">Built-in Server DNS</span>
<span class="toggle-hint" data-i18n="owndns_hint">Route DNS through the VPN server (10.1.0.1)</span>
</div>
<label class="toggle">
<input type="checkbox" id="in-owndns" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<!-- Custom DNS (hidden when built-in is ON) -->
<div class="field-group" id="group-custom-dns">
<label class="field-label" for="in-dns" data-i18n="label_dns">Custom DNS Server</label> <label class="field-label" for="in-dns" data-i18n="label_dns">Custom DNS Server</label>
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" /> <input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
</div> </div>

View File

@ -42,7 +42,7 @@ const inServer = $('in-server');
const inKey = $('in-key'); const inKey = $('in-key');
const inSocks = $('in-socks'); const inSocks = $('in-socks');
const inDns = $('in-dns'); const inDns = $('in-dns');
const inOwndns = $('in-owndns');
const groupCustomDns = $('group-custom-dns'); const groupCustomDns = $('group-custom-dns');
const inTransport = $('in-transport'); const inTransport = $('in-transport');
const inSni = $('in-stealth-sni'); const inSni = $('in-stealth-sni');
@ -116,10 +116,7 @@ function showToast(msg, variant = '') {
} }
// ── DNS & Kill Switch visibility ────────────────────────────────────────────── // ── DNS & Kill Switch visibility ──────────────────────────────────────────────
function updateDnsVisibility() {
if (!groupCustomDns || !inOwndns) return;
groupCustomDns.style.display = inOwndns.checked ? 'none' : 'block';
}
function updateKillSwitchVisibility() { function updateKillSwitchVisibility() {
const group = $('group-kill-switch'); const group = $('group-kill-switch');
@ -295,12 +292,7 @@ async function loadConfigIntoForm() {
inMux.checked = !!c.mux?.enabled; inMux.checked = !!c.mux?.enabled;
inMuxSessions.value = c.mux?.sessions || ''; inMuxSessions.value = c.mux?.sessions || '';
// owndns: detect if saved dns is 10.1.0.1 inDns.value = c.tun?.dns || '';
const savedDns = c.tun?.dns || '';
const isOwndns = savedDns === '10.1.0.1';
inOwndns.checked = isOwndns;
inDns.value = isOwndns ? '' : savedDns;
updateDnsVisibility();
updateKillSwitchVisibility(); updateKillSwitchVisibility();
inDebug.checked = !!c.debug; 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.wintun_path = rawConfig.tun.wintun_path || './wintun.dll';
rawConfig.tun.ipv4_address = rawConfig.tun.ipv4_address || '10.1.0.2/24'; rawConfig.tun.ipv4_address = rawConfig.tun.ipv4_address || '10.1.0.2/24';
rawConfig.tun.stack = 'ostp'; rawConfig.tun.stack = 'ostp';
// owndns: if toggle is on, always write 10.1.0.1; otherwise use the custom field rawConfig.tun.dns = inDns.value.trim() || null;
rawConfig.tun.dns = inOwndns.checked ? '10.1.0.1' : (inDns.value.trim() || null);
rawConfig.exclude = { rawConfig.exclude = {
domains: splitLines(inDomains.value), domains: splitLines(inDomains.value),
@ -447,7 +438,6 @@ function togglePeek() {
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
applyTranslations(); applyTranslations();
setState('disconnected'); setState('disconnected');
updateDnsVisibility(); // initialise field visibility from current checkbox state
updateKillSwitchVisibility(); updateKillSwitchVisibility();
// Event wiring // Event wiring
@ -564,10 +554,7 @@ window.addEventListener('DOMContentLoaded', async () => {
if (btnThemeToggle) { if (btnThemeToggle) {
btnThemeToggle.addEventListener('click', toggleTheme); btnThemeToggle.addEventListener('click', toggleTheme);
} }
inOwndns.addEventListener('change', () => { scheduleAutoSave();
updateDnsVisibility();
scheduleAutoSave();
});
inTun.addEventListener('change', () => { inTun.addEventListener('change', () => {
updateKillSwitchVisibility(); updateKillSwitchVisibility();
scheduleAutoSave(); scheduleAutoSave();

View File

@ -244,9 +244,6 @@ pub fn create_api_router(state: ApiState) -> Router {
.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))
.route( .route(
"/audit", "/audit",
get(handle_get_audit) get(handle_get_audit)
@ -444,38 +441,6 @@ fn save_config_keys(state: &ApiState) -> Result<(), String> {
Ok(()) 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 ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
@ -895,68 +860,6 @@ async fn handle_subscribe(
}))).into_response() }))).into_response()
} }
// ── 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.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<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 {
@ -1082,15 +985,21 @@ async fn handle_put_rules(
if !check_token(&state, &headers) { return api_unauthorized(); } if !check_token(&state, &headers) { return api_unauthorized(); }
// Update memory // Update memory
let mut updated_outbound = None;
{ {
let mut lock = state.router.outbound_cfg.write().unwrap(); let mut lock = state.router.outbound_cfg.write().unwrap();
if let Some(cfg) = lock.as_mut() { if let Some(cfg) = lock.as_mut() {
cfg.rules = new_rules.clone(); cfg.rules = new_rules.clone();
updated_outbound = Some(cfg.clone());
} else { } else {
return api_error("Outbound routing is not enabled in config"); 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 // Save to config.json
if let Some(path) = &state.config_path { if let Some(path) = &state.config_path {
if let Ok(content) = std::fs::read_to_string(path) { if let Ok(content) = std::fs::read_to_string(path) {

View File

@ -48,7 +48,7 @@ pub struct DnsServer {
pub config: RwLock<DnsConfig>, pub config: RwLock<DnsConfig>,
adblock_trie: RwLock<HashSet<String>>, adblock_trie: RwLock<HashSet<String>>,
query_log: Mutex<VecDeque<DnsQueryLog>>, query_log: Mutex<VecDeque<DnsQueryLog>>,
reqwest_client: reqwest::Client, reqwest_client: RwLock<reqwest::Client>,
} }
impl DnsServer { impl DnsServer {
@ -57,9 +57,7 @@ impl DnsServer {
config: RwLock::new(config.clone()), config: RwLock::new(config.clone()),
adblock_trie: RwLock::new(HashSet::new()), adblock_trie: RwLock::new(HashSet::new()),
query_log: Mutex::new(VecDeque::with_capacity(1000)), query_log: Mutex::new(VecDeque::with_capacity(1000)),
reqwest_client: reqwest::Client::builder() reqwest_client: RwLock::new(reqwest::Client::builder().build().unwrap_or_default()),
.build()
.unwrap_or_default(),
}); });
// Загружаем блок-листы при старте если DNS включён // Загружаем блок-листы при старте если DNS включён
@ -73,6 +71,33 @@ impl DnsServer {
server 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-листы. /// Скачать и обновить все AdBlock-листы.
pub async fn update_blocklists(&self) { pub async fn update_blocklists(&self) {
let urls = { let urls = {
@ -84,7 +109,8 @@ impl DnsServer {
for url in &urls { for url in &urls {
tracing::info!("DNS: downloading AdBlock list from {url}"); 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) => { Ok(resp) => {
match resp.text().await { match resp.text().await {
Ok(text) => { Ok(text) => {
@ -172,7 +198,6 @@ impl DnsServer {
60, 60,
RData::A(ip.into()), RData::A(ip.into()),
)); ));
self.log_query(qname, client_ip.to_string(), false).await;
return response.build_bytes_vec().ok(); return response.build_bytes_vec().ok();
} }
} }
@ -199,7 +224,6 @@ impl DnsServer {
// Возвращаем пустой NXDOMAIN-ответ // Возвращаем пустой NXDOMAIN-ответ
let mut response = Packet::new_reply(packet.id()); let mut response = Packet::new_reply(packet.id());
response.questions.push(question.clone()); response.questions.push(question.clone());
self.log_query(qname.clone(), client_ip.to_string(), true).await;
tracing::debug!("DNS AdBlock: blocked {qname} for {client_ip}"); tracing::debug!("DNS AdBlock: blocked {qname} for {client_ip}");
return response.build_bytes_vec().ok(); return response.build_bytes_vec().ok();
} }
@ -208,7 +232,8 @@ impl DnsServer {
// ── Форвардинг через DoH ────────────────────────────────────────────── // ── Форвардинг через DoH ──────────────────────────────────────────────
// Работает и при enabled=true и при intercept_all_port53=true // Работает и при enabled=true и при intercept_all_port53=true
tracing::debug!("DNS: resolving {qname} via DoH for {client_ip}"); 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) .post(&doh_url)
.header("Content-Type", "application/dns-message") .header("Content-Type", "application/dns-message")
.header("Accept", "application/dns-message") .header("Accept", "application/dns-message")
@ -219,7 +244,6 @@ impl DnsServer {
{ {
Ok(resp) if resp.status().is_success() => { Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await { if let Ok(bytes) = resp.bytes().await {
self.log_query(qname, client_ip.to_string(), false).await;
return Some(bytes.to_vec()); return Some(bytes.to_vec());
} }
} }

View File

@ -248,14 +248,12 @@ pub async fn run_server(
// Инициализируем DNS-сервер // Инициализируем DNS-сервер
let dns_cfg = dns_config.unwrap_or_default(); let dns_cfg = dns_config.unwrap_or_default();
// Запускаем UDP listener если dns.enabled=true (полный режим) или intercept_all_port53=true
let start_dns_listener = dns_cfg.enabled || dns_cfg.intercept_all_port53;
let dns_server = dns::DnsServer::new(dns_cfg); let dns_server = dns::DnsServer::new(dns_cfg);
if start_dns_listener { let dns_cfg_update = dns_server.clone();
let dns_srv = dns_server.clone(); let outbound_clone_update = outbound.clone();
tokio::spawn(async move { dns_srv.run_local_udp_listener().await }); tokio::spawn(async move {
} dns_cfg_update.update_proxy(outbound_clone_update.as_ref()).await;
});
// Initialize Router // Initialize Router
let router = std::sync::Arc::new(router::Router::new( let router = std::sync::Arc::new(router::Router::new(
outbound.clone(), outbound.clone(),

View File

@ -17,6 +17,8 @@ pub struct OutboundRule {
pub domain_suffix: Vec<String>, pub domain_suffix: Vec<String>,
#[serde(default)] #[serde(default)]
pub ip_cidr: Vec<String>, pub ip_cidr: Vec<String>,
#[serde(default)]
pub protocol: Option<String>,
pub action: OutboundAction, pub action: OutboundAction,
} }
@ -40,7 +42,7 @@ pub async fn connect_target(
let connect_timeout = Duration::from_secs(10); let connect_timeout = Duration::from_secs(10);
if let Some(outbound) = outbound { if let Some(outbound) = outbound {
if outbound.enabled { if outbound.enabled {
let action = select_outbound_action(target, outbound, debug).await; let action = select_outbound_action(target, "tcp", outbound, debug).await;
if action == OutboundAction::Block { if action == OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target)); return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
} }
@ -66,8 +68,9 @@ pub async fn connect_target(
// ── Rule matching ──────────────────────────────────────────────────────────── // ── Rule matching ────────────────────────────────────────────────────────────
async fn select_outbound_action( pub async fn select_outbound_action(
target: &str, target: &str,
protocol: &str,
outbound: &OutboundConfig, outbound: &OutboundConfig,
debug: bool, debug: bool,
) -> OutboundAction { ) -> OutboundAction {
@ -78,8 +81,15 @@ async fn select_outbound_action(
let mut matched = None; let mut matched = None;
for rule in &outbound.rules { 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() { 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) { if match_domain_rule(&host, &rule.domain_suffix) {
matched = Some(rule.action); 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)); return parsed.iter().any(|cidr| cidr.contains(&ip));
} }
match tokio::net::lookup_host((host, port)).await { false
Ok(addrs) => addrs.into_iter().any(|addr| parsed.iter().any(|cidr| cidr.contains(&addr.ip()))),
Err(_) => false,
}
} }
// ── SOCKS5 / HTTP CONNECT upstream proxy ───────────────────────────────────── // ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
@ -202,8 +209,213 @@ async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
Ok(stream) Ok(stream)
} }
pub enum UdpProxySocket {
Direct(std::sync::Arc<tokio::net::UdpSocket>),
Socks5 {
tcp_keepalive: TcpStream,
udp_sock: std::sync::Arc<tokio::net::UdpSocket>,
proxy_bnd_addr: std::net::SocketAddr,
},
}
impl UdpProxySocket {
pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> {
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::<std::net::IpAddr>() {
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<tokio::net::UdpSocket>,
) -> Result<UdpProxySocket> {
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<tokio::net::UdpSocket>,
) -> Result<UdpProxySocket> {
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 ─────────────────────────────────────────────────────────── // ── CIDR utilities ───────────────────────────────────────────────────────────
enum Cidr { enum Cidr {
V4(u32, u8), V4(u32, u8),
V6(u128, u8), V6(u128, u8),

View File

@ -10,6 +10,20 @@ use tokio::sync::mpsc;
use crate::dispatcher::Dispatcher; use crate::dispatcher::Dispatcher;
use crate::{RemoteState, UiEvent}; 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( pub async fn handle_relay_message(
peer_addr: std::net::SocketAddr, peer_addr: std::net::SocketAddr,
session_id: u32, 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 (udp_tx, mut udp_rx) = mpsc::unbounded_channel::<(String, Bytes)>();
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1); let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
let (dummy_data_tx, _) = mpsc::unbounded_channel::<Bytes>(); let (dummy_data_tx, _) = mpsc::unbounded_channel::<Bytes>();
// Outbound UDP loop (tunnel -> target) // Outbound UDP loop (tunnel -> target)
let tx_sock = server_udp.clone(); let tx_router = session_router.clone();
let _dns_srv = router.dns_server.clone();
let _udp_reply_clone_dns = udp_reply_tx.clone();
let _client_ip = peer_addr.ip();
tokio::spawn(async move { tokio::spawn(async move {
while let Some((target, data)) = udp_rx.recv().await { while let Some((target, data)) = udp_rx.recv().await {
let mut forward_target = target.clone(); let mut forward_target = target.clone();
if forward_target.starts_with("10.1.0.1:") { if forward_target.starts_with("10.1.0.1:") {
forward_target = forward_target.replace("10.1.0.1:", "127.0.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) // Inbound UDP loop (target -> tunnel)
let rx_sock = server_udp.clone(); let rx_sock = server_udp.clone();
let udp_reply_clone = udp_reply_tx.clone(); let udp_reply_clone = udp_reply_tx.clone();
let proxy_sock = session_router.get_proxy_sock();
tokio::spawn(async move { 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 { loop {
tokio::select! { if let Some(ref p) = proxy_sock {
_ = cancel_rx.recv() => break, tokio::select! {
res = rx_sock.recv_from(&mut buf) => { _ = cancel_rx.recv() => break,
match res { res = rx_sock.recv_from(&mut direct_buf) => {
Ok((len, addr)) => { if let Ok((len, addr)) = res {
let clean_addr = match addr { let _ = udp_reply_clone.send((session_id, stream_id, clean_ipv6_mapped_v4(addr).to_string(), direct_buf[..len].to_vec()));
std::net::SocketAddr::V6(v6) => { } else { break; }
if let Some(v4) = v6.ip().to_ipv4() { }
std::net::SocketAddr::new(std::net::IpAddr::V4(v4), v6.port()) res = p.recv_from(&mut proxy_buf) => {
} else { if let Ok((len, target_str)) = res {
addr let _ = udp_reply_clone.send((session_id, stream_id, target_str, proxy_buf[..len].to_vec()));
}
}
_ => addr,
};
let _ = udp_reply_clone.send((session_id, stream_id, clean_addr.to_string(), 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 { remotes.insert((session_id, stream_id), RemoteState {
data_tx: dummy_data_tx, data_tx: dummy_data_tx,
udp_tx: Some(udp_tx), udp_tx: Some(udp_tx),

View File

@ -24,13 +24,80 @@ impl Router {
pub async fn route_tcp(&self, target: &str) -> Result<TcpStream> { pub async fn route_tcp(&self, target: &str) -> Result<TcpStream> {
let cfg = { let cfg = {
let lock = self.outbound_cfg.read().unwrap(); 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 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<tokio::net::UdpSocket>) -> Result<crate::outbound::UdpProxySocket> {
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<tokio::net::UdpSocket>) -> 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) /// Unified DNS Routing and Resolution (AdBlock / Custom Domains / DoH)
pub async fn route_dns(&self, client_ip: std::net::IpAddr, payload: &[u8]) -> Option<Vec<u8>> { pub async fn route_dns(&self, client_ip: std::net::IpAddr, payload: &[u8]) -> Option<Vec<u8>> {
self.dns_server.resolve(payload, client_ip).await self.dns_server.resolve(payload, client_ip).await
} }
} }
pub struct UdpSessionRouter {
direct: Arc<tokio::net::UdpSocket>,
proxy: Option<Arc<crate::outbound::UdpProxySocket>>,
cfg: Option<OutboundConfig>,
debug: bool,
}
impl UdpSessionRouter {
pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> {
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<Arc<crate::outbound::UdpProxySocket>> {
self.proxy.clone()
}
}

View File

@ -775,28 +775,7 @@ async fn run_app() -> Result<()> {
"sni_list": ["www.microsoft.com"] "sni_list": ["www.microsoft.com"]
}}, }},
// Built-in DNS server
"dns": {{
// Full mode: custom domains + AdBlock lists + DoH forwarding
"enabled": false,
// Intercept ALL UDP port 53 traffic and resolve via DoH (prevents DNS leaks through the server)
// Works even if enabled=false — just strips AdBlock/custom domains logic
"intercept_all_port53": false,
// UDP port the built-in DNS server listens on (clients can use <server_ip>:50053 as DNS)
"local_port": 50053,
// DoH upstream: Cloudflare, Google, NextDNS, etc.
"doh_upstream": "https://cloudflare-dns.com/dns-query",
// AdBlock lists (hosts format, ||domain^, or one domain per line)
"adblock_urls": [
// "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
// "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt"
],
// Custom domains: respond with A record directly (bypasses DoH)
"custom_domains": {{
// "myserver.internal": "10.0.0.1",
// "home.local": "192.168.1.100"
}}
}},
"debug": false "debug": false
}}"#, key, priv_key, pub_key, sid) }}"#, key, priv_key, pub_key, sid)
} else if mode_str == "relay" { } else if mode_str == "relay" {
@ -991,11 +970,7 @@ async fn run_app() -> Result<()> {
query_params.push("type=udp".to_string()); 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() { if !query_params.is_empty() {
link.push('?'); link.push('?');

View File

@ -59,28 +59,4 @@
}, },
"debug": false, "debug": false,
// Встроенный DNS-сервер
"dns": {
// Полный режим: кастомные домены + AdBlock списки + DoH форвардинг
"enabled": false,
// Перехватывать весь UDP-трафик к порту 53 и резолвить через DoH
// (работает даже если enabled=false, предотвращает DNS-утечки через сервер)
"intercept_all_port53": false,
// УДП порт встроенного DNS (клиент может указать <server_ip>:50053 как DNS)
"local_port": 50053,
// DoH вверх: Cloudflare, Google, NextDNS и др.
"doh_upstream": "https://cloudflare-dns.com/dns-query",
// "doh_upstream": "https://dns.google/dns-query",
// "doh_upstream": "https://dns10.quad9.net/dns-query",
// Списки доменов для блокировки (формат: hosts-файл, ||domain^, один домен на строку)
"adblock_urls": [
// "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
// "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt"
],
// Кастомные домены: отвечаем A-записью напрямую (не через DoH)
"custom_domains": {
// "myserver.internal": "10.0.0.1",
// "home.local": "192.168.1.100"
}
}
} }