Repository: Watfaq/clash-rs
Pull request #775: fix(tun): use session's original dst addr as the src addr during udp NAT
Author: login: VendettaReborn
Created: 2025-04-13T08:42:41Z | Merged: 2025-04-14T07:33:34Z

<!--
First of all, thank you for your contribution! 😄
For requesting to pull a new feature or bugfix, please send it from a feature/bugfix branch based on the `master` branch.
Before submitting your pull request, please make sure the checklist below is confirmed.
Your pull requests will be merged after one of the collaborators approve.
Thank you!
-->

### 🤔 This is a ...

- [x] Bug fix
- [x] Doc

### 🔗 Related issue link

https://github.com/Watfaq/clash-rs/issues/773

### 💡 Background and solution

1. add docs to the rather complicated implementation in `dispatch_datagram`
2. use session's original dst addr as the src addr during NAT when receiving the remote packet(needed only for UDP)

### 📝 Changelog


### ☑️ Self-Check before Merge

⚠️ Please check all items below before requesting a reviewing. ⚠️

- [ ] Doc is updated/provided or not needed
- [ ] Changelog is provided or not needed



Diff:
diff --git a/clash_lib/src/app/dispatcher/dispatcher_impl.rs b/clash_lib/src/app/dispatcher/dispatcher_impl.rs
index 09e098d46..0dc1d2b1d 100644
--- a/clash_lib/src/app/dispatcher/dispatcher_impl.rs
+++ b/clash_lib/src/app/dispatcher/dispatcher_impl.rs
@@ -1,6 +1,7 @@
 use crate::{
     app::{
         dispatcher::tracked::{TrackedDatagram, TrackedStream},
+        dns::ClashResolver,
         outbound::manager::ThreadSafeOutboundManager,
         router::ThreadSafeRouter,
     },
@@ -79,41 +80,14 @@ impl Dispatcher {
         mut sess: Session,
         mut lhs: Box<dyn ClientStream>,
     ) {
-        let dest: SocksAddr = match &sess.destination {
-            crate::session::SocksAddr::Ip(socket_addr) => {
-                if self.resolver.fake_ip_enabled() {
-                    trace!("looking up fake ip: {}", socket_addr.ip());
-                    let ip = socket_addr.ip();
-                    if self.resolver.is_fake_ip(ip).await {
-                        let host = self.resolver.reverse_lookup(ip).await;
-                        match host {
-                            Some(host) => (host, socket_addr.port())
-                                .try_into()
-                                .expect("must be valid domain"),
-                            None => {
-                                error!("failed to reverse lookup fake ip: {}", ip);
-                                return;
-                            }
-                        }
-                    } else {
-                        (*socket_addr).into()
-                    }
-                } else {
-                    trace!("looking up resolve cache ip: {}", socket_addr.ip());
-                    match self.resolver.cached_for(socket_addr.ip()).await {
-                        Some(resolved) => (resolved, socket_addr.port())
-                            .try_into()
-                            .expect("must be valid domain"),
-                        _ => (*socket_addr).into(),
-                    }
+        let dest: SocksAddr =
+            match reverse_lookup(&self.resolver, &sess.destination).await {
+                Some(dest) => dest,
+                None => {
+                    warn!("failed to resolve destination {}", sess);
+                    return;
                 }
-            }
-            crate::session::SocksAddr::Domain(host, port) => {
-                (host.to_owned(), *port)
-                    .try_into()
-                    .expect("must be valid domain")
-            }
-        };
+            };
 
         sess.destination = dest.clone();
 
@@ -252,6 +226,24 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
+         * 
+         *  data structure:
+         *    local_r, local_w: stream/sink pair
+         *    remote_r, remote_w: stream/sink pair
+         *    remote_receiver_r, remote_receiver_w: channel pair
+         *    remote_sender, remote_forwarder: channel pair
+         *
+         *  data flow:
+         *    => local_r => init packet => connect_datagram => remote_sender     => remote_forwarder         => remote_w
+         *    => local_w                                    <= remote_receiver_r <= NAT + remote_receiver_w  <= remote_r
+         *  
+         *  notice:
+         *    the NAT is binded to the session in the dispatch_datagram function arg and the closure
+         *    so we need not to add a global NAT table and do the translation
+         */
         let (mut local_w, mut local_r) = udp_inbound.split();
         let (remote_receiver_w, mut remote_receiver_r) =
             tokio::sync::mpsc::channel(32);
@@ -259,50 +251,23 @@ impl Dispatcher {
         let s = sess.clone();
         let ss = sess.clone();
         let t1 = tokio::spawn(async move {
-            while let Some(packet) = local_r.next().await {
+            while let Some(mut packet) = local_r.next().await {
                 let mut sess = sess.clone();
-                sess.source = packet.src_addr.clone().must_into_socket_addr();
 
-                let dest: SocksAddr = match &packet.dst_addr {
-                    crate::session::SocksAddr::Ip(socket_addr) => {
-                        if resolver.fake_ip_enabled() {
-                            let ip = socket_addr.ip();
-                            if resolver.is_fake_ip(ip).await {
-                                let host = resolver.reverse_lookup(ip).await;
-                                match host {
-                                    Some(host) => (host, socket_addr.port())
-                                        .try_into()
-                                        .expect("must be valid domain"),
-                                    None => {
-                                        error!(
-                                            "failed to reverse lookup fake ip: {}",
-                                            ip
-                                        );
-                                        continue;
-                                    }
-                                }
-                            } else {
-                                (*socket_addr).into()
-                            }
-                        } else {
-                            match resolver.cached_for(socket_addr.ip()).await {
-                                Some(resolved) => (resolved, socket_addr.port())
-                                    .try_into()
-                                    .expect("must be valid domain"),
-                                _ => (*socket_addr).into(),
-                            }
-                        }
-                    }
-                    crate::session::SocksAddr::Domain(host, port) => {
-                        (host.to_owned(), *port)
-                            .try_into()
-                            .expect("must be valid domain")
+                let dest = match reverse_lookup(&resolver, &packet.dst_addr).await {
+                    Some(dest) => dest,
+                    None => {
+                        warn!("failed to resolve destination {}", sess);
+                        continue;
                     }
                 };
+
+                // for TUN or Tproxy, we need the original destination address
+                let orig_dest = packet.dst_addr.clone();
+                sess.source = packet.src_addr.clone().must_into_socket_addr();
                 sess.destination = dest.clone();
 
                 // mutate packet for fake ip
-                let mut packet = packet;
                 // resolve is done in OutboundDatagramImpl so it's fine to have
                 // (Domain, port) here. ideally the OutboundDatagramImpl should only
                 // do Ip though?
@@ -375,7 +340,7 @@ impl Dispatcher {
                             while let Some(packet) = remote_r.next().await {
                                 // NAT
                                 let mut packet = packet;
-                                packet.src_addr = sess.destination.clone();
+                                packet.src_addr = orig_dest.clone();
                                 packet.dst_addr = sess.source.into();
 
                                 debug!(
@@ -472,6 +437,50 @@ impl Dispatcher {
     }
 }
 
+// helper function to resolve the destination address
+// if the destination is an IP address, check if it's a fake IP
+// or look for cached IP
+// if the destination is a domain name, don't resolve
+async fn reverse_lookup(
+    resolver: &Arc<dyn ClashResolver>,
+    dst: &SocksAddr,
+) -> Option<SocksAddr> {
+    let dst = match dst {
+        crate::session::SocksAddr::Ip(socket_addr) => {
+            if resolver.fake_ip_enabled() {
+                let ip = socket_addr.ip();
+                if resolver.is_fake_ip(ip).await {
+                    trace!("looking up fake ip: {}", socket_addr.ip());
+                    let host = resolver.reverse_lookup(ip).await;
+                    match host {
+                        Some(host) => (host, socket_addr.port())
+                            .try_into()
+                            .expect("must be valid domain"),
+                        None => {
+                            error!("failed to reverse lookup fake ip: {}", ip);
+                            return None;
+                        }
+                    }
+                } else {
+                    (*socket_addr).into()
+                }
+            } else {
+                trace!("looking up resolve cache ip: {}", socket_addr.ip());
+                match resolver.cached_for(socket_addr.ip()).await {
+                    Some(resolved) => (resolved, socket_addr.port())
+                        .try_into()
+                        .expect("must be valid domain"),
+                    _ => (*socket_addr).into(),
+                }
+            }
+        }
+        crate::session::SocksAddr::Domain(host, port) => (host.to_owned(), *port)
+            .try_into()
+            .expect("must be valid domain"),
+    };
+    Some(dst)
+}
+
 type OutboundPacketSender = tokio::sync::mpsc::Sender<UdpPacket>; // outbound packet sender
 
 struct TimeoutUdpSessionManager {
diff --git a/clash_lib/src/app/router/rules/geodata/mod.rs b/clash_lib/src/app/router/rules/geodata/mod.rs
index 062276d13..9046fb6b5 100644
--- a/clash_lib/src/app/router/rules/geodata/mod.rs
+++ b/clash_lib/src/app/router/rules/geodata/mod.rs
@@ -127,7 +127,7 @@ mod tests {
     };
 
     const GEOSITE_URL: &str =
-        "https://github.com/Watfaq/v2ray-rules-dat/releases/download/test/geosite.dat";
+        "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat";
 
     struct TestSuite<'a> {
         country_code: &'a str,


Reviews:
user: login: ibigbug
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T15:14:32Z
user: login: ibigbug
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T15:15:19Z
user: login: ibigbug
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T15:16:40Z
user: login: VendettaReborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T18:41:04Z
user: login: VendettaReborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T18:41:52Z
user: login: ibigbug
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T19:02:48Z
user: login: ibigbug
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T19:03:12Z
user: login: VendettaReborn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T19:32:54Z
user: login: ibigbug
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T01:39:04Z
user: login: ibigbug
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-14T01:41:19Z
user: login: ibigbug
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-14T07:04:08Z

Review comments:
diff_hunk: @@ -472,6 +438,50 @@ impl Dispatcher {
     }
 }
 
+// helper function to resolve the destination address
+// if the destination is an IP address, check if it's a fake IP
+// or look for cached IP
+// if the destination is a domain name, don't resolve
+async fn resolve(
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: ibigbug
body: maybe we call this reverse_resolve or something?
created_at: 2025-04-13T15:14:31Z
author_association: MEMBER
diff_hunk: @@ -252,57 +226,49 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: ibigbug
body: thanks
created_at: 2025-04-13T15:15:18Z
author_association: MEMBER
diff_hunk: @@ -252,57 +226,49 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
+         * 
+         *  data structure:
+         *    local_r, local_w: stream/sink pair
+         *    remote_r, remote_w: stream/sink pair
+         *    remote_receiver_r, remote_receiver_w: channel pair
+         *    remote_sender, remote_forwarder: channel pair
+         *
+         *  data flow:
+         *    => local_r => init packet => connect_datagram => remote_sender     => remote_forwarder         => remote_w
+         *    => local_w                                    <= remote_receiver_r <= NAT + remote_receiver_w  <= remote_r
+         *  
+         *  notice:
+         *    the NAT is binded to the session in the dispatch_datagram function arg and the closure
+         *    so we need not to add a global NAT table and do the translation
+         *    but the price is, we have to spawn two tasks for each session
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: ibigbug
body: not quite following this - we need two tasks for bi-directional transfer anyways?
created_at: 2025-04-13T15:16:39Z
author_association: MEMBER
diff_hunk: @@ -472,6 +438,50 @@ impl Dispatcher {
     }
 }
 
+// helper function to resolve the destination address
+// if the destination is an IP address, check if it's a fake IP
+// or look for cached IP
+// if the destination is a domain name, don't resolve
+async fn resolve(
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: VendettaReborn
body: does `reverse_lookup` sounds better?
created_at: 2025-04-13T18:41:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -252,57 +226,49 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
+         * 
+         *  data structure:
+         *    local_r, local_w: stream/sink pair
+         *    remote_r, remote_w: stream/sink pair
+         *    remote_receiver_r, remote_receiver_w: channel pair
+         *    remote_sender, remote_forwarder: channel pair
+         *
+         *  data flow:
+         *    => local_r => init packet => connect_datagram => remote_sender     => remote_forwarder         => remote_w
+         *    => local_w                                    <= remote_receiver_r <= NAT + remote_receiver_w  <= remote_r
+         *  
+         *  notice:
+         *    the NAT is binded to the session in the dispatch_datagram function arg and the closure
+         *    so we need not to add a global NAT table and do the translation
+         *    but the price is, we have to spawn two tasks for each session
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: VendettaReborn
body: it's just comments for the current implementation... since it's rather confusing each time reading this function
created_at: 2025-04-13T18:41:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -252,57 +226,49 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
+         * 
+         *  data structure:
+         *    local_r, local_w: stream/sink pair
+         *    remote_r, remote_w: stream/sink pair
+         *    remote_receiver_r, remote_receiver_w: channel pair
+         *    remote_sender, remote_forwarder: channel pair
+         *
+         *  data flow:
+         *    => local_r => init packet => connect_datagram => remote_sender     => remote_forwarder         => remote_w
+         *    => local_w                                    <= remote_receiver_r <= NAT + remote_receiver_w  <= remote_r
+         *  
+         *  notice:
+         *    the NAT is binded to the session in the dispatch_datagram function arg and the closure
+         *    so we need not to add a global NAT table and do the translation
+         *    but the price is, we have to spawn two tasks for each session
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: ibigbug
body: Yeah what I wasn't sure is you seem to indicate a global nat table would "save the price of spawning two tasks"?
created_at: 2025-04-13T19:02:48Z
author_association: MEMBER
diff_hunk: @@ -472,6 +438,50 @@ impl Dispatcher {
     }
 }
 
+// helper function to resolve the destination address
+// if the destination is an IP address, check if it's a fake IP
+// or look for cached IP
+// if the destination is a domain name, don't resolve
+async fn resolve(
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: ibigbug
body: Yeah sg 
created_at: 2025-04-13T19:03:12Z
author_association: MEMBER
diff_hunk: @@ -252,57 +226,49 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
+         * 
+         *  data structure:
+         *    local_r, local_w: stream/sink pair
+         *    remote_r, remote_w: stream/sink pair
+         *    remote_receiver_r, remote_receiver_w: channel pair
+         *    remote_sender, remote_forwarder: channel pair
+         *
+         *  data flow:
+         *    => local_r => init packet => connect_datagram => remote_sender     => remote_forwarder         => remote_w
+         *    => local_w                                    <= remote_receiver_r <= NAT + remote_receiver_w  <= remote_r
+         *  
+         *  notice:
+         *    the NAT is binded to the session in the dispatch_datagram function arg and the closure
+         *    so we need not to add a global NAT table and do the translation
+         *    but the price is, we have to spawn two tasks for each session
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: VendettaReborn
body: oh, sorry, that's not what i mean. i'd better make it clear:
a global nat table is a different approach, which would perhaps save some costs of task spawning, but might introduce downsides of more lock contention. 
personally, i prefer the current one, of course. but i'm not sure the global nat table might be better(it's not full measured, after all)
created_at: 2025-04-13T19:32:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -252,57 +226,49 @@ impl Dispatcher {
         let mode = self.mode.clone();
         let manager = self.manager.clone();
 
+        #[rustfmt::skip]
+        /*
+         *  implement details
+         * 
+         *  data structure:
+         *    local_r, local_w: stream/sink pair
+         *    remote_r, remote_w: stream/sink pair
+         *    remote_receiver_r, remote_receiver_w: channel pair
+         *    remote_sender, remote_forwarder: channel pair
+         *
+         *  data flow:
+         *    => local_r => init packet => connect_datagram => remote_sender     => remote_forwarder         => remote_w
+         *    => local_w                                    <= remote_receiver_r <= NAT + remote_receiver_w  <= remote_r
+         *  
+         *  notice:
+         *    the NAT is binded to the session in the dispatch_datagram function arg and the closure
+         *    so we need not to add a global NAT table and do the translation
+         *    but the price is, we have to spawn two tasks for each session
path: clash_lib/src/app/dispatcher/dispatcher_impl.rs
user: login: ibigbug
body: i see. maybe we could verify that NAT type we support first if we going to change that. I _think_ the current implementation should support full cone, but not very sure.
created_at: 2025-04-14T01:39:03Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-13T08:50:00Z
body: ## [Codecov](https://app.codecov.io/gh/Watfaq/clash-rs/pull/775?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Watfaq) Report
Attention: Patch coverage is `0%` with `37 lines` in your changes missing coverage. Please review.
| [Files with missing lines](https://app.codecov.io/gh/Watfaq/clash-rs/pull/775?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Watfaq) | Patch % | Lines |
|---|---|---|
| [clash\_lib/src/app/dispatcher/dispatcher\_impl.rs](https://app.codecov.io/gh/Watfaq/clash-rs/pull/775?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Watfaq#diff-Y2xhc2hfbGliL3NyYy9hcHAvZGlzcGF0Y2hlci9kaXNwYXRjaGVyX2ltcGwucnM=) | 0.00% | [37 Missing :warning: ](https://app.codecov.io/gh/Watfaq/clash-rs/pull/775?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Watfaq) |

:loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)
author_association: NONE
user: login: ibigbug
created_at: 2025-04-13T15:12:26Z
body: thanks. I'm going to merge this and https://github.com/Watfaq/clash-rs/compare/master...fix-udp-nat-fakeip together to add some tests for this
author_association: MEMBER
user: login: ibigbug
created_at: 2025-04-13T15:12:50Z
body: or you can take it over if you don't mind
author_association: MEMBER
user: login: ibigbug
created_at: 2025-04-14T01:41:45Z
body: plz fmt the code, thanks!
author_association: MEMBER