mirror of https://github.com/ospab/ostp.git
Remove built-in DNS server and owndns features
This commit is contained in:
parent
6bf7b06b43
commit
2b0d85b530
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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')));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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('?');
|
||||||
|
|
|
||||||
24
server.json
24
server.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue