Remove built-in DNS server and owndns features

This commit is contained in:
ospab 2026-06-10 22:52:35 +03:00
parent 7bb7d211fa
commit 9f35caf4ca
12 changed files with 390 additions and 235 deletions

View File

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

View File

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

View File

@ -188,22 +188,7 @@
<input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" />
</div>
<!-- Built-in DNS toggle -->
<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">
<div class="field-group">
<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" />
</div>

View File

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

View File

@ -244,9 +244,6 @@ pub fn create_api_router(state: ApiState) -> Router {
.route("/users/{key}/reset", post(handle_reset_stats))
.route("/subscribe/{key}", get(handle_subscribe))
.route("/login", post(handle_login))
.route("/dns/config", get(handle_get_dns_config).post(handle_post_dns_config))
.route("/dns/queries", get(handle_get_dns_queries))
.route("/dns/blocklists/refresh", post(handle_refresh_blocklists))
.route(
"/audit",
get(handle_get_audit)
@ -444,38 +441,6 @@ fn save_config_keys(state: &ApiState) -> Result<(), String> {
Ok(())
}
fn save_dns_config(state: &ApiState, cfg: &crate::dns::DnsConfig) -> Result<(), String> {
let Some(ref path) = state.config_path else {
return Ok(());
};
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read config file: {}", e))?;
let mut stripped = json_comments::StripComments::new(content.as_bytes());
let mut content_str = String::new();
use std::io::Read;
stripped.read_to_string(&mut content_str)
.map_err(|e| format!("failed to strip comments: {}", e))?;
let mut json_val: serde_json::Value = serde_json::from_str(&content_str)
.map_err(|e| format!("failed to parse config JSON: {}", e))?;
if let Some(obj) = json_val.as_object_mut() {
let dns_val = serde_json::to_value(cfg).map_err(|e| e.to_string())?;
obj.insert("dns".to_string(), dns_val);
} else {
return Err("config root is not an object".to_string());
}
let new_content = serde_json::to_string_pretty(&json_val)
.map_err(|e| format!("failed to serialize config JSON: {}", e))?;
std::fs::write(path, new_content)
.map_err(|e| format!("failed to write config file: {}", e))?;
Ok(())
}
// ── Handlers ─────────────────────────────────────────────────────────────────
@ -895,68 +860,6 @@ async fn handle_subscribe(
}))).into_response()
}
// ── DNS API Handlers ──────────────────────────────────────────────────────────
async fn handle_get_dns_config(
State(state): State<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)]
mod tests {
@ -1082,15 +985,21 @@ async fn handle_put_rules(
if !check_token(&state, &headers) { return api_unauthorized(); }
// Update memory
let mut updated_outbound = None;
{
let mut lock = state.router.outbound_cfg.write().unwrap();
if let Some(cfg) = lock.as_mut() {
cfg.rules = new_rules.clone();
updated_outbound = Some(cfg.clone());
} else {
return api_error("Outbound routing is not enabled in config");
}
}
if let Some(cfg) = updated_outbound {
state.dns_server.update_proxy(Some(&cfg)).await;
}
// Save to config.json
if let Some(path) = &state.config_path {
if let Ok(content) = std::fs::read_to_string(path) {

View File

@ -48,7 +48,7 @@ pub struct DnsServer {
pub config: RwLock<DnsConfig>,
adblock_trie: RwLock<HashSet<String>>,
query_log: Mutex<VecDeque<DnsQueryLog>>,
reqwest_client: reqwest::Client,
reqwest_client: RwLock<reqwest::Client>,
}
impl DnsServer {
@ -57,9 +57,7 @@ impl DnsServer {
config: RwLock::new(config.clone()),
adblock_trie: RwLock::new(HashSet::new()),
query_log: Mutex::new(VecDeque::with_capacity(1000)),
reqwest_client: reqwest::Client::builder()
.build()
.unwrap_or_default(),
reqwest_client: RwLock::new(reqwest::Client::builder().build().unwrap_or_default()),
});
// Загружаем блок-листы при старте если DNS включён
@ -73,6 +71,33 @@ impl DnsServer {
server
}
pub async fn update_proxy(&self, outbound: Option<&crate::outbound::OutboundConfig>) {
let mut builder = reqwest::Client::builder();
if let Some(outbound) = outbound {
if outbound.enabled {
// Determine if DoH upstream domain matches any proxy rules
// We simplify by just setting the proxy for the client if outbound is globally enabled
// But we should check if the DoH URL domain matches Proxy.
// Since DoH usually goes to 1.1.1.1 or cloudflare-dns.com, if proxy is enabled, we route it.
// Better: just route if proxy is enabled and protocol is socks5/http.
let proxy_url = match outbound.protocol.as_str() {
"socks5" => Some(format!("socks5h://{}:{}", outbound.address, outbound.port)),
"http" => Some(format!("http://{}:{}", outbound.address, outbound.port)),
_ => None,
};
if let Some(url) = proxy_url {
if let Ok(proxy) = reqwest::Proxy::all(&url) {
builder = builder.proxy(proxy);
}
}
}
}
if let Ok(client) = builder.build() {
*self.reqwest_client.write().await = client;
}
}
/// Скачать и обновить все AdBlock-листы.
pub async fn update_blocklists(&self) {
let urls = {
@ -84,7 +109,8 @@ impl DnsServer {
for url in &urls {
tracing::info!("DNS: downloading AdBlock list from {url}");
match self.reqwest_client.get(url).send().await {
let client = self.reqwest_client.read().await.clone();
match client.get(url).send().await {
Ok(resp) => {
match resp.text().await {
Ok(text) => {
@ -172,7 +198,6 @@ impl DnsServer {
60,
RData::A(ip.into()),
));
self.log_query(qname, client_ip.to_string(), false).await;
return response.build_bytes_vec().ok();
}
}
@ -199,7 +224,6 @@ impl DnsServer {
// Возвращаем пустой NXDOMAIN-ответ
let mut response = Packet::new_reply(packet.id());
response.questions.push(question.clone());
self.log_query(qname.clone(), client_ip.to_string(), true).await;
tracing::debug!("DNS AdBlock: blocked {qname} for {client_ip}");
return response.build_bytes_vec().ok();
}
@ -208,7 +232,8 @@ impl DnsServer {
// ── Форвардинг через DoH ──────────────────────────────────────────────
// Работает и при enabled=true и при intercept_all_port53=true
tracing::debug!("DNS: resolving {qname} via DoH for {client_ip}");
match self.reqwest_client
let client = self.reqwest_client.read().await.clone();
match client
.post(&doh_url)
.header("Content-Type", "application/dns-message")
.header("Accept", "application/dns-message")
@ -219,7 +244,6 @@ impl DnsServer {
{
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
self.log_query(qname, client_ip.to_string(), false).await;
return Some(bytes.to_vec());
}
}

View File

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

View File

@ -17,6 +17,8 @@ pub struct OutboundRule {
pub domain_suffix: Vec<String>,
#[serde(default)]
pub ip_cidr: Vec<String>,
#[serde(default)]
pub protocol: Option<String>,
pub action: OutboundAction,
}
@ -40,7 +42,7 @@ pub async fn connect_target(
let connect_timeout = Duration::from_secs(10);
if let Some(outbound) = outbound {
if outbound.enabled {
let action = select_outbound_action(target, outbound, debug).await;
let action = select_outbound_action(target, "tcp", outbound, debug).await;
if action == OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
}
@ -66,8 +68,9 @@ pub async fn connect_target(
// ── Rule matching ────────────────────────────────────────────────────────────
async fn select_outbound_action(
pub async fn select_outbound_action(
target: &str,
protocol: &str,
outbound: &OutboundConfig,
debug: bool,
) -> OutboundAction {
@ -78,8 +81,15 @@ async fn select_outbound_action(
let mut matched = None;
for rule in &outbound.rules {
if let Some(ref rule_proto) = rule.protocol {
if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol {
continue;
}
}
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() {
continue;
// Protocol-only rule match
matched = Some(rule.action);
break;
}
if match_domain_rule(&host, &rule.domain_suffix) {
matched = Some(rule.action);
@ -121,10 +131,7 @@ async fn match_ip_rule(host: &str, port: u16, cidrs: &[String]) -> bool {
return parsed.iter().any(|cidr| cidr.contains(&ip));
}
match tokio::net::lookup_host((host, port)).await {
Ok(addrs) => addrs.into_iter().any(|addr| parsed.iter().any(|cidr| cidr.contains(&addr.ip()))),
Err(_) => false,
}
false
}
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
@ -202,8 +209,213 @@ async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
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 ───────────────────────────────────────────────────────────
enum Cidr {
V4(u32, u8),
V6(u128, u8),

View File

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

View File

@ -24,13 +24,80 @@ impl Router {
pub async fn route_tcp(&self, target: &str) -> Result<TcpStream> {
let cfg = {
let lock = self.outbound_cfg.read().unwrap();
lock.clone() // Clone config to avoid holding lock across await point
lock.clone()
};
connect_target(target, cfg.as_ref(), self.debug).await
}
/// UDP Target Routing
pub async fn route_udp(&self, target: &str, server_udp: std::sync::Arc<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)
pub async fn route_dns(&self, client_ip: std::net::IpAddr, payload: &[u8]) -> Option<Vec<u8>> {
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"]
}},
// 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
}}"#, key, priv_key, pub_key, sid)
} else if mode_str == "relay" {
@ -991,11 +970,7 @@ async fn run_app() -> Result<()> {
query_params.push("type=udp".to_string());
}
if let Some(dns) = &server_cfg.dns {
if dns.enabled {
query_params.push("owndns=true".to_string());
}
}
if !query_params.is_empty() {
link.push('?');

View File

@ -59,28 +59,4 @@
},
"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"
}
}
}