From 224e7f347e284c864002b827f61e1762e7fed1aa Mon Sep 17 00:00:00 2001 From: Stanislav Povolotsky <19610545+Stanislav-Povolotsky@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:54:43 +0300 Subject: [PATCH 1/4] Error handling fix --- engine/engine.go | 19 +++++++++---------- log/level.go | 10 +++++++--- main.go | 5 +++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/engine/engine.go b/engine/engine.go index 3843eada..b02ac20a 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -39,18 +39,14 @@ var ( _defaultStack *stack.Stack ) -// Start starts the default engine up. -func Start() { - if err := start(); err != nil { - log.Fatalf("[ENGINE] failed to start: %v", err) - } +// Start starts the default engine up (returns error) +func Start() error { + return start() } // Stop shuts the default engine down. -func Stop() { - if err := stop(); err != nil { - log.Fatalf("[ENGINE] failed to stop: %v", err) - } +func Stop() error { + return stop() } // Insert loads *Key to the default engine. @@ -108,9 +104,12 @@ func execCommand(cmd string) error { func general(k *Key) error { level, err := log.ParseLevel(k.LogLevel) if err != nil { - return err + level = log.InfoLevel } log.SetLogger(log.Must(log.NewLeveled(level))) + if err != nil { + log.Warnf("[ENGINE] invalid log level %q, falling back to info", k.LogLevel) + } if k.Interface != "" { iface, err := net.InterfaceByName(k.Interface) diff --git a/log/level.go b/log/level.go index 0123cdad..f20d7927 100644 --- a/log/level.go +++ b/log/level.go @@ -1,6 +1,8 @@ package log import ( + "strings" + "go.uber.org/zap/zapcore" ) @@ -20,11 +22,13 @@ const ( SilentLevel = InvalidLevel + 1 ) -// ParseLevel is a thin wrapper for zapcore.ParseLevel. +// ParseLevel is a wrapper for zapcore.ParseLevel func ParseLevel(text string) (Level, error) { - switch text { - case "silent", "SILENT": + switch strings.ToLower(strings.TrimSpace(text)) { + case "silent", "none", "off", "disable", "disabled": return SilentLevel, nil + case "warning": + return WarnLevel, nil default: return zapcore.ParseLevel(text) } diff --git a/main.go b/main.go index 201c396d..88bd5580 100644 --- a/main.go +++ b/main.go @@ -63,8 +63,9 @@ func main() { } engine.Insert(key) - - engine.Start() + if err := engine.Start(); err != nil { + log.Fatalf("[ENGINE] failed to start: %v", err) + } defer engine.Stop() sigCh := make(chan os.Signal, 1) From 1e10916985658e72a4a3d4a7b36896d2ee084138 Mon Sep 17 00:00:00 2001 From: "B. Blechschmidt" Date: Mon, 15 Jul 2024 20:24:26 +0200 Subject: [PATCH 2/4] Implement fake DNS This commit implements fake DNS. Fake DNS implements a UDP listener DNS A record queries on port 53. It replies with an unused IP address from an address pool, 198.18.0.0/15 by default. When obtaining a new address from the pool, tun2socks needs to memorize which name the address belongs to, so that when a client connects to the address, it can instruct the proxy to connect to the FQDN. To implement this IP to name mapping, the FakeIP module from clash is used. --- common/cache/lrucache.go | 223 ++++++++++++++++++++++++++++++ common/cache/lrucache_test.go | 183 ++++++++++++++++++++++++ common/sockopt/reuseaddr_linux.go | 19 +++ common/sockopt/reuseaddr_other.go | 11 ++ component/fakeip/memory.go | 69 +++++++++ component/fakeip/pool.go | 169 ++++++++++++++++++++++ component/fakeip/pool_test.go | 196 ++++++++++++++++++++++++++ component/trie/domain.go | 129 +++++++++++++++++ component/trie/domain_test.go | 107 ++++++++++++++ component/trie/node.go | 26 ++++ dns/fakedns.go | 77 +++++++++++ dns/server.go | 95 +++++++++++++ engine/engine.go | 51 ++++++- engine/key.go | 3 + engine/parse.go | 10 +- go.mod | 4 + go.sum | 10 ++ main.go | 3 + metadata/metadata.go | 5 + proxy/internal/utils/utils.go | 2 +- tunnel/tcp.go | 3 + tunnel/udp.go | 8 +- 22 files changed, 1397 insertions(+), 6 deletions(-) create mode 100644 common/cache/lrucache.go create mode 100644 common/cache/lrucache_test.go create mode 100644 common/sockopt/reuseaddr_linux.go create mode 100644 common/sockopt/reuseaddr_other.go create mode 100644 component/fakeip/memory.go create mode 100644 component/fakeip/pool.go create mode 100644 component/fakeip/pool_test.go create mode 100644 component/trie/domain.go create mode 100644 component/trie/domain_test.go create mode 100644 component/trie/node.go create mode 100644 dns/fakedns.go create mode 100644 dns/server.go diff --git a/common/cache/lrucache.go b/common/cache/lrucache.go new file mode 100644 index 00000000..e0f5d20c --- /dev/null +++ b/common/cache/lrucache.go @@ -0,0 +1,223 @@ +package cache + +// Modified by https://github.com/die-net/lrucache + +import ( + "container/list" + "sync" + "time" +) + +// Option is part of Functional Options Pattern +type Option func(*LruCache) + +// EvictCallback is used to get a callback when a cache entry is evicted +type EvictCallback = func(key any, value any) + +// WithEvict set the evict callback +func WithEvict(cb EvictCallback) Option { + return func(l *LruCache) { + l.onEvict = cb + } +} + +// WithUpdateAgeOnGet update expires when Get element +func WithUpdateAgeOnGet() Option { + return func(l *LruCache) { + l.updateAgeOnGet = true + } +} + +// WithAge defined element max age (second) +func WithAge(maxAge int64) Option { + return func(l *LruCache) { + l.maxAge = maxAge + } +} + +// WithSize defined max length of LruCache +func WithSize(maxSize int) Option { + return func(l *LruCache) { + l.maxSize = maxSize + } +} + +// WithStale decide whether Stale return is enabled. +// If this feature is enabled, element will not get Evicted according to `WithAge`. +func WithStale(stale bool) Option { + return func(l *LruCache) { + l.staleReturn = stale + } +} + +// LruCache is a thread-safe, in-memory lru-cache that evicts the +// least recently used entries from memory when (if set) the entries are +// older than maxAge (in seconds). Use the New constructor to create one. +type LruCache struct { + maxAge int64 + maxSize int + mu sync.Mutex + cache map[any]*list.Element + lru *list.List // Front is least-recent + updateAgeOnGet bool + staleReturn bool + onEvict EvictCallback +} + +// New creates an LruCache +func New(options ...Option) *LruCache { + lc := &LruCache{ + lru: list.New(), + cache: make(map[any]*list.Element), + } + + for _, option := range options { + option(lc) + } + + return lc +} + +// Get returns the any representation of a cached response and a bool +// set to true if the key was found. +func (c *LruCache) Get(key any) (any, bool) { + entry := c.get(key) + if entry == nil { + return nil, false + } + value := entry.value + + return value, true +} + +// GetWithExpire returns the any representation of a cached response, +// a time.Time Give expected expires, +// and a bool set to true if the key was found. +// This method will NOT check the maxAge of element and will NOT update the expires. +func (c *LruCache) GetWithExpire(key any) (any, time.Time, bool) { + entry := c.get(key) + if entry == nil { + return nil, time.Time{}, false + } + + return entry.value, time.Unix(entry.expires, 0), true +} + +// Exist returns if key exist in cache but not put item to the head of linked list +func (c *LruCache) Exist(key any) bool { + c.mu.Lock() + defer c.mu.Unlock() + + _, ok := c.cache[key] + return ok +} + +// Set stores the any representation of a response for a given key. +func (c *LruCache) Set(key any, value any) { + expires := int64(0) + if c.maxAge > 0 { + expires = time.Now().Unix() + c.maxAge + } + c.SetWithExpire(key, value, time.Unix(expires, 0)) +} + +// SetWithExpire stores the any representation of a response for a given key and given expires. +// The expires time will round to second. +func (c *LruCache) SetWithExpire(key any, value any, expires time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + + if le, ok := c.cache[key]; ok { + c.lru.MoveToBack(le) + e := le.Value.(*entry) + e.value = value + e.expires = expires.Unix() + } else { + e := &entry{key: key, value: value, expires: expires.Unix()} + c.cache[key] = c.lru.PushBack(e) + + if c.maxSize > 0 { + if len := c.lru.Len(); len > c.maxSize { + c.deleteElement(c.lru.Front()) + } + } + } + + c.maybeDeleteOldest() +} + +// CloneTo clone and overwrite elements to another LruCache +func (c *LruCache) CloneTo(n *LruCache) { + c.mu.Lock() + defer c.mu.Unlock() + + n.mu.Lock() + defer n.mu.Unlock() + + n.lru = list.New() + n.cache = make(map[any]*list.Element) + + for e := c.lru.Front(); e != nil; e = e.Next() { + elm := e.Value.(*entry) + n.cache[elm.key] = n.lru.PushBack(elm) + } +} + +func (c *LruCache) get(key any) *entry { + c.mu.Lock() + defer c.mu.Unlock() + + le, ok := c.cache[key] + if !ok { + return nil + } + + if !c.staleReturn && c.maxAge > 0 && le.Value.(*entry).expires <= time.Now().Unix() { + c.deleteElement(le) + c.maybeDeleteOldest() + + return nil + } + + c.lru.MoveToBack(le) + entry := le.Value.(*entry) + if c.maxAge > 0 && c.updateAgeOnGet { + entry.expires = time.Now().Unix() + c.maxAge + } + return entry +} + +// Delete removes the value associated with a key. +func (c *LruCache) Delete(key any) { + c.mu.Lock() + + if le, ok := c.cache[key]; ok { + c.deleteElement(le) + } + + c.mu.Unlock() +} + +func (c *LruCache) maybeDeleteOldest() { + if !c.staleReturn && c.maxAge > 0 { + now := time.Now().Unix() + for le := c.lru.Front(); le != nil && le.Value.(*entry).expires <= now; le = c.lru.Front() { + c.deleteElement(le) + } + } +} + +func (c *LruCache) deleteElement(le *list.Element) { + c.lru.Remove(le) + e := le.Value.(*entry) + delete(c.cache, e.key) + if c.onEvict != nil { + c.onEvict(e.key, e.value) + } +} + +type entry struct { + key any + value any + expires int64 +} diff --git a/common/cache/lrucache_test.go b/common/cache/lrucache_test.go new file mode 100644 index 00000000..1a09975d --- /dev/null +++ b/common/cache/lrucache_test.go @@ -0,0 +1,183 @@ +package cache + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var entries = []struct { + key string + value string +}{ + {"1", "one"}, + {"2", "two"}, + {"3", "three"}, + {"4", "four"}, + {"5", "five"}, +} + +func TestLRUCache(t *testing.T) { + c := New() + + for _, e := range entries { + c.Set(e.key, e.value) + } + + c.Delete("missing") + _, ok := c.Get("missing") + assert.False(t, ok) + + for _, e := range entries { + value, ok := c.Get(e.key) + if assert.True(t, ok) { + assert.Equal(t, e.value, value.(string)) + } + } + + for _, e := range entries { + c.Delete(e.key) + + _, ok := c.Get(e.key) + assert.False(t, ok) + } +} + +func TestLRUMaxAge(t *testing.T) { + c := New(WithAge(86400)) + + now := time.Now().Unix() + expected := now + 86400 + + // Add one expired entry + c.Set("foo", "bar") + c.lru.Back().Value.(*entry).expires = now + + // Reset + c.Set("foo", "bar") + e := c.lru.Back().Value.(*entry) + assert.True(t, e.expires >= now) + c.lru.Back().Value.(*entry).expires = now + + // Set a few and verify expiration times + for _, s := range entries { + c.Set(s.key, s.value) + e := c.lru.Back().Value.(*entry) + assert.True(t, e.expires >= expected && e.expires <= expected+10) + } + + // Make sure we can get them all + for _, s := range entries { + _, ok := c.Get(s.key) + assert.True(t, ok) + } + + // Expire all entries + for _, s := range entries { + le, ok := c.cache[s.key] + if assert.True(t, ok) { + le.Value.(*entry).expires = now + } + } + + // Get one expired entry, which should clear all expired entries + _, ok := c.Get("3") + assert.False(t, ok) + assert.Equal(t, c.lru.Len(), 0) +} + +func TestLRUpdateOnGet(t *testing.T) { + c := New(WithAge(86400), WithUpdateAgeOnGet()) + + now := time.Now().Unix() + expires := now + 86400/2 + + // Add one expired entry + c.Set("foo", "bar") + c.lru.Back().Value.(*entry).expires = expires + + _, ok := c.Get("foo") + assert.True(t, ok) + assert.True(t, c.lru.Back().Value.(*entry).expires > expires) +} + +func TestMaxSize(t *testing.T) { + c := New(WithSize(2)) + // Add one expired entry + c.Set("foo", "bar") + _, ok := c.Get("foo") + assert.True(t, ok) + + c.Set("bar", "foo") + c.Set("baz", "foo") + + _, ok = c.Get("foo") + assert.False(t, ok) +} + +func TestExist(t *testing.T) { + c := New(WithSize(1)) + c.Set(1, 2) + assert.True(t, c.Exist(1)) + c.Set(2, 3) + assert.False(t, c.Exist(1)) +} + +func TestEvict(t *testing.T) { + temp := 0 + evict := func(key any, value any) { + temp = key.(int) + value.(int) + } + + c := New(WithEvict(evict), WithSize(1)) + c.Set(1, 2) + c.Set(2, 3) + + assert.Equal(t, temp, 3) +} + +func TestSetWithExpire(t *testing.T) { + c := New(WithAge(1)) + now := time.Now().Unix() + + tenSecBefore := time.Unix(now-10, 0) + c.SetWithExpire(1, 2, tenSecBefore) + + // res is expected not to exist, and expires should be empty time.Time + res, expires, exist := c.GetWithExpire(1) + assert.Equal(t, nil, res) + assert.Equal(t, time.Time{}, expires) + assert.Equal(t, false, exist) +} + +func TestStale(t *testing.T) { + c := New(WithAge(1), WithStale(true)) + now := time.Now().Unix() + + tenSecBefore := time.Unix(now-10, 0) + c.SetWithExpire(1, 2, tenSecBefore) + + res, expires, exist := c.GetWithExpire(1) + assert.Equal(t, 2, res) + assert.Equal(t, tenSecBefore, expires) + assert.Equal(t, true, exist) +} + +func TestCloneTo(t *testing.T) { + o := New(WithSize(10)) + o.Set("1", 1) + o.Set("2", 2) + + n := New(WithSize(2)) + n.Set("3", 3) + n.Set("4", 4) + + o.CloneTo(n) + + assert.False(t, n.Exist("3")) + assert.True(t, n.Exist("1")) + + n.Set("5", 5) + assert.False(t, n.Exist("1")) +} diff --git a/common/sockopt/reuseaddr_linux.go b/common/sockopt/reuseaddr_linux.go new file mode 100644 index 00000000..a1d19bfd --- /dev/null +++ b/common/sockopt/reuseaddr_linux.go @@ -0,0 +1,19 @@ +package sockopt + +import ( + "net" + "syscall" +) + +func UDPReuseaddr(c *net.UDPConn) (err error) { + rc, err := c.SyscallConn() + if err != nil { + return + } + + rc.Control(func(fd uintptr) { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + }) + + return +} diff --git a/common/sockopt/reuseaddr_other.go b/common/sockopt/reuseaddr_other.go new file mode 100644 index 00000000..04fc8ed7 --- /dev/null +++ b/common/sockopt/reuseaddr_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package sockopt + +import ( + "net" +) + +func UDPReuseaddr(c *net.UDPConn) (err error) { + return +} diff --git a/component/fakeip/memory.go b/component/fakeip/memory.go new file mode 100644 index 00000000..69ba70e4 --- /dev/null +++ b/component/fakeip/memory.go @@ -0,0 +1,69 @@ +package fakeip + +import ( + "net" + + "github.com/xjasonlyu/tun2socks/v2/common/cache" +) + +type memoryStore struct { + cache *cache.LruCache +} + +// GetByHost implements store.GetByHost +func (m *memoryStore) GetByHost(host string) (net.IP, bool) { + if elm, exist := m.cache.Get(host); exist { + ip := elm.(net.IP) + + // ensure ip --> host on head of linked list + m.cache.Get(ipToUint(ip.To4())) + return ip, true + } + + return nil, false +} + +// PutByHost implements store.PutByHost +func (m *memoryStore) PutByHost(host string, ip net.IP) { + m.cache.Set(host, ip) +} + +// GetByIP implements store.GetByIP +func (m *memoryStore) GetByIP(ip net.IP) (string, bool) { + if elm, exist := m.cache.Get(ipToUint(ip.To4())); exist { + host := elm.(string) + + // ensure host --> ip on head of linked list + m.cache.Get(host) + return host, true + } + + return "", false +} + +// PutByIP implements store.PutByIP +func (m *memoryStore) PutByIP(ip net.IP, host string) { + m.cache.Set(ipToUint(ip.To4()), host) +} + +// DelByIP implements store.DelByIP +func (m *memoryStore) DelByIP(ip net.IP) { + ipNum := ipToUint(ip.To4()) + if elm, exist := m.cache.Get(ipNum); exist { + m.cache.Delete(elm.(string)) + } + m.cache.Delete(ipNum) +} + +// Exist implements store.Exist +func (m *memoryStore) Exist(ip net.IP) bool { + return m.cache.Exist(ipToUint(ip.To4())) +} + +// CloneTo implements store.CloneTo +// only for memoryStore to memoryStore +func (m *memoryStore) CloneTo(store store) { + if ms, ok := store.(*memoryStore); ok { + m.cache.CloneTo(ms.cache) + } +} diff --git a/component/fakeip/pool.go b/component/fakeip/pool.go new file mode 100644 index 00000000..134f6eb1 --- /dev/null +++ b/component/fakeip/pool.go @@ -0,0 +1,169 @@ +package fakeip + +import ( + "errors" + "net" + "strings" + "sync" + + "github.com/xjasonlyu/tun2socks/v2/common/cache" + "github.com/xjasonlyu/tun2socks/v2/component/trie" +) + +type store interface { + GetByHost(host string) (net.IP, bool) + PutByHost(host string, ip net.IP) + GetByIP(ip net.IP) (string, bool) + PutByIP(ip net.IP, host string) + DelByIP(ip net.IP) + Exist(ip net.IP) bool + CloneTo(store) +} + +// Pool is an implementation about fake ip generator without storage +type Pool struct { + max uint32 + min uint32 + gateway uint32 + offset uint32 + mux sync.Mutex + host *trie.DomainTrie + ipnet *net.IPNet + store store +} + +// Lookup return a fake ip with host +func (p *Pool) Lookup(host string) net.IP { + p.mux.Lock() + defer p.mux.Unlock() + + // RFC4343: DNS Case Insensitive, we SHOULD return result with all cases. + host = strings.ToLower(host) + if ip, exist := p.store.GetByHost(host); exist { + return ip + } + + ip := p.get(host) + p.store.PutByHost(host, ip) + return ip +} + +// LookBack return host with the fake ip +func (p *Pool) LookBack(ip net.IP) (string, bool) { + p.mux.Lock() + defer p.mux.Unlock() + + if ip = ip.To4(); ip == nil { + return "", false + } + + return p.store.GetByIP(ip) +} + +// ShouldSkipped return if domain should be skipped +func (p *Pool) ShouldSkipped(domain string) bool { + if p.host == nil { + return false + } + return p.host.Search(domain) != nil +} + +// Exist returns if given ip exists in fake-ip pool +func (p *Pool) Exist(ip net.IP) bool { + p.mux.Lock() + defer p.mux.Unlock() + + if ip = ip.To4(); ip == nil { + return false + } + + return p.store.Exist(ip) +} + +// Gateway return gateway ip +func (p *Pool) Gateway() net.IP { + return uintToIP(p.gateway) +} + +// IPNet return raw ipnet +func (p *Pool) IPNet() *net.IPNet { + return p.ipnet +} + +// CloneFrom clone cache from old pool +func (p *Pool) CloneFrom(o *Pool) { + o.store.CloneTo(p.store) +} + +func (p *Pool) get(host string) net.IP { + current := p.offset + for { + ip := uintToIP(p.min + p.offset) + if !p.store.Exist(ip) { + break + } + + p.offset = (p.offset + 1) % (p.max - p.min) + // Avoid infinite loops + if p.offset == current { + p.offset = (p.offset + 1) % (p.max - p.min) + ip := uintToIP(p.min + p.offset) + p.store.DelByIP(ip) + break + } + } + ip := uintToIP(p.min + p.offset) + p.store.PutByIP(ip, host) + return ip +} + +func ipToUint(ip net.IP) uint32 { + v := uint32(ip[0]) << 24 + v += uint32(ip[1]) << 16 + v += uint32(ip[2]) << 8 + v += uint32(ip[3]) + return v +} + +func uintToIP(v uint32) net.IP { + return net.IP{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} +} + +type Options struct { + IPNet *net.IPNet + Host *trie.DomainTrie + + // Size sets the maximum number of entries in memory + // and does not work if Persistence is true + Size int + + // Persistence will save the data to disk. + // Size will not work and record will be fully stored. + Persistence bool +} + +// New return Pool instance +func New(options Options) (*Pool, error) { + minIP := ipToUint(options.IPNet.IP) + 2 + + ones, bits := options.IPNet.Mask.Size() + total := 1<= 0; i-- { + part := parts[i] + if !node.hasChild(part) { + node.addChild(part, newNode(nil)) + } + + node = node.getChild(part) + } + + node.Data = data +} + +// Search is the most important part of the Trie. +// Priority as: +// 1. static part +// 2. wildcard domain +// 2. dot wildcard domain +func (t *DomainTrie) Search(domain string) *Node { + parts, valid := ValidAndSplitDomain(domain) + if !valid || parts[0] == "" { + return nil + } + + n := t.search(t.root, parts) + + if n == nil || n.Data == nil { + return nil + } + + return n +} + +func (t *DomainTrie) search(node *Node, parts []string) *Node { + if len(parts) == 0 { + return node + } + + if c := node.getChild(parts[len(parts)-1]); c != nil { + if n := t.search(c, parts[:len(parts)-1]); n != nil && n.Data != nil { + return n + } + } + + if c := node.getChild(wildcard); c != nil { + if n := t.search(c, parts[:len(parts)-1]); n != nil && n.Data != nil { + return n + } + } + + return node.getChild(dotWildcard) +} + +// New returns a new, empty Trie. +func New() *DomainTrie { + return &DomainTrie{root: newNode(nil)} +} diff --git a/component/trie/domain_test.go b/component/trie/domain_test.go new file mode 100644 index 00000000..4322699a --- /dev/null +++ b/component/trie/domain_test.go @@ -0,0 +1,107 @@ +package trie + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +var localIP = net.IP{127, 0, 0, 1} + +func TestTrie_Basic(t *testing.T) { + tree := New() + domains := []string{ + "example.com", + "google.com", + "localhost", + } + + for _, domain := range domains { + tree.Insert(domain, localIP) + } + + node := tree.Search("example.com") + assert.NotNil(t, node) + assert.True(t, node.Data.(net.IP).Equal(localIP)) + assert.NotNil(t, tree.Insert("", localIP)) + assert.Nil(t, tree.Search("")) + assert.NotNil(t, tree.Search("localhost")) + assert.Nil(t, tree.Search("www.google.com")) +} + +func TestTrie_Wildcard(t *testing.T) { + tree := New() + domains := []string{ + "*.example.com", + "sub.*.example.com", + "*.dev", + ".org", + ".example.net", + ".apple.*", + "+.foo.com", + "+.stun.*.*", + "+.stun.*.*.*", + "+.stun.*.*.*.*", + "stun.l.google.com", + } + + for _, domain := range domains { + tree.Insert(domain, localIP) + } + + assert.NotNil(t, tree.Search("sub.example.com")) + assert.NotNil(t, tree.Search("sub.foo.example.com")) + assert.NotNil(t, tree.Search("test.org")) + assert.NotNil(t, tree.Search("test.example.net")) + assert.NotNil(t, tree.Search("test.apple.com")) + assert.NotNil(t, tree.Search("test.foo.com")) + assert.NotNil(t, tree.Search("foo.com")) + assert.NotNil(t, tree.Search("global.stun.website.com")) + assert.Nil(t, tree.Search("foo.sub.example.com")) + assert.Nil(t, tree.Search("foo.example.dev")) + assert.Nil(t, tree.Search("example.com")) +} + +func TestTrie_Priority(t *testing.T) { + tree := New() + domains := []string{ + ".dev", + "example.dev", + "*.example.dev", + "test.example.dev", + } + + assertFn := func(domain string, data int) { + node := tree.Search(domain) + assert.NotNil(t, node) + assert.Equal(t, data, node.Data) + } + + for idx, domain := range domains { + tree.Insert(domain, idx) + } + + assertFn("test.dev", 0) + assertFn("foo.bar.dev", 0) + assertFn("example.dev", 1) + assertFn("foo.example.dev", 2) + assertFn("test.example.dev", 3) +} + +func TestTrie_Boundary(t *testing.T) { + tree := New() + tree.Insert("*.dev", localIP) + + assert.NotNil(t, tree.Insert(".", localIP)) + assert.NotNil(t, tree.Insert("..dev", localIP)) + assert.Nil(t, tree.Search("dev")) +} + +func TestTrie_WildcardBoundary(t *testing.T) { + tree := New() + tree.Insert("+.*", localIP) + tree.Insert("stun.*.*.*", localIP) + + assert.NotNil(t, tree.Search("example.com")) +} diff --git a/component/trie/node.go b/component/trie/node.go new file mode 100644 index 00000000..67ef64a4 --- /dev/null +++ b/component/trie/node.go @@ -0,0 +1,26 @@ +package trie + +// Node is the trie's node +type Node struct { + children map[string]*Node + Data any +} + +func (n *Node) getChild(s string) *Node { + return n.children[s] +} + +func (n *Node) hasChild(s string) bool { + return n.getChild(s) != nil +} + +func (n *Node) addChild(s string, child *Node) { + n.children[s] = child +} + +func newNode(data any) *Node { + return &Node{ + Data: data, + children: map[string]*Node{}, + } +} diff --git a/dns/fakedns.go b/dns/fakedns.go new file mode 100644 index 00000000..be87edc4 --- /dev/null +++ b/dns/fakedns.go @@ -0,0 +1,77 @@ +package dns + +import ( + "errors" + "net" + "net/netip" + "strings" + + D "github.com/miekg/dns" + + "github.com/xjasonlyu/tun2socks/v2/component/fakeip" + M "github.com/xjasonlyu/tun2socks/v2/metadata" +) + +var ( + fakePool *fakeip.Pool + fakeDNSenabled = false +) + +func setMsgTTL(msg *D.Msg, ttl uint32) { + for _, answer := range msg.Answer { + answer.Header().Ttl = ttl + } + + for _, ns := range msg.Ns { + ns.Header().Ttl = ttl + } + + for _, extra := range msg.Extra { + extra.Header().Ttl = ttl + } +} + +func EnableFakeDNS() { + fakeDNSenabled = true +} + +func ProcessMetadata(metadata *M.Metadata) bool { + if !fakeDNSenabled { + return false + } + dstName, found := fakePool.LookBack(net.IP(metadata.DstIP.AsSlice())) + if !found { + return false + } + metadata.DstIP = netip.Addr{} + metadata.DstName = dstName + return true +} + +func fakeipHandler(fakePool *fakeip.Pool) handler { + return func(r *D.Msg) (*D.Msg, error) { + if len(r.Question) == 0 { + return nil, errors.New("at least one question is required") + } + + q := r.Question[0] + + host := strings.TrimRight(q.Name, ".") + msg := r.Copy() + + if q.Qtype == D.TypeA { + rr := &D.A{} + rr.Hdr = D.RR_Header{Name: q.Name, Rrtype: D.TypeA, Class: D.ClassINET, Ttl: dnsDefaultTTL} + ip := fakePool.Lookup(host) + rr.A = ip + msg.Answer = []D.RR{rr} + } + + setMsgTTL(msg, 1) + msg.SetRcode(r, D.RcodeSuccess) + msg.RecursionAvailable = true + msg.Response = true + + return msg, nil + } +} diff --git a/dns/server.go b/dns/server.go new file mode 100644 index 00000000..f996101a --- /dev/null +++ b/dns/server.go @@ -0,0 +1,95 @@ +package dns + +import ( + "errors" + "net" + + D "github.com/miekg/dns" + + "github.com/xjasonlyu/tun2socks/v2/common/sockopt" + "github.com/xjasonlyu/tun2socks/v2/component/fakeip" + "github.com/xjasonlyu/tun2socks/v2/log" +) + +var ( + server = &Server{} + dnsDefaultTTL uint32 = 600 +) + +type ( + handler func(r *D.Msg) (*D.Msg, error) +) + +type Server struct { + *D.Server + handler handler +} + +// ServeDNS implement D.Handler ServeDNS +func (s *Server) ServeDNS(w D.ResponseWriter, r *D.Msg) { + msg, err := handlerWithContext(s.handler, r) + if err != nil { + D.HandleFailed(w, r) + return + } + msg.Compress = true + w.WriteMsg(msg) +} + +func handlerWithContext(handler handler, msg *D.Msg) (*D.Msg, error) { + if len(msg.Question) == 0 { + return nil, errors.New("at least one question is required") + } + + return handler(msg) +} + +func ReCreateServer(addr string, pool *fakeip.Pool) { + fakePool = pool + if server.Server != nil { + server.Shutdown() + server = &Server{} + } + + if addr == "" { + return + } + + var err error + defer func() { + if err != nil { + log.Errorf("Start DNS server error: %s", err.Error()) + } + }() + + _, port, err := net.SplitHostPort(addr) + if port == "0" || port == "" || err != nil { + return + } + + udpAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return + } + + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return + } + + err = sockopt.UDPReuseaddr(udpConn) + if err != nil { + log.Warnf("Failed to Reuse UDP Address: %s", err) + + err = nil + } + + server = &Server{handler: fakeipHandler(fakePool)} + server.Server = &D.Server{Addr: addr, PacketConn: udpConn, Handler: server} + + go func() { + server.ActivateAndServe() + }() + + log.Infof("DNS server listening at: %s", udpConn.LocalAddr().String()) +} diff --git a/engine/engine.go b/engine/engine.go index 25904708..90debe60 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -2,6 +2,7 @@ package engine import ( "errors" + "fmt" "net" "os/exec" "sync" @@ -12,11 +13,14 @@ import ( "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/stack" + "github.com/xjasonlyu/tun2socks/v2/component/fakeip" + "github.com/xjasonlyu/tun2socks/v2/component/trie" "github.com/xjasonlyu/tun2socks/v2/core" "github.com/xjasonlyu/tun2socks/v2/core/adapter" "github.com/xjasonlyu/tun2socks/v2/core/device" "github.com/xjasonlyu/tun2socks/v2/core/option" "github.com/xjasonlyu/tun2socks/v2/dialer" + "github.com/xjasonlyu/tun2socks/v2/dns" "github.com/xjasonlyu/tun2socks/v2/log" "github.com/xjasonlyu/tun2socks/v2/proxy" "github.com/xjasonlyu/tun2socks/v2/restapi" @@ -176,6 +180,45 @@ func restAPI(k *Key) error { return nil } +// remoteDNSProtocols lists the proxy protocols whose upstream can resolve +// FQDNs itself, so that fake DNS can hand off the original hostname. +var remoteDNSProtocols = map[string]bool{ + "socks5": true, + "socks4": true, + "http": true, + "ss": true, +} + +func fakeDNS(k *Key, proxyScheme string) (err error) { + if !k.FakeDNS { + return + } + + if !remoteDNSProtocols[proxyScheme] { + return fmt.Errorf("remote DNS not supported with proxy protocol %q", proxyScheme) + } + + _, ipnet, err := net.ParseCIDR(k.FakeDNSNetIPv4) + if err != nil { + return err + } + + pool, err := fakeip.New(fakeip.Options{ + IPNet: ipnet, + Size: 1000, + Host: trie.New(), + }) + if err != nil { + return err + } + + dns.EnableFakeDNS() + dns.ReCreateServer(k.FakeDNSListenAddress, pool) + + log.Infof("[DNS] fake DNS enabled") + return nil +} + func netstack(k *Key) (err error) { if k.Proxy == "" { return errors.New("empty proxy") @@ -206,7 +249,8 @@ func netstack(k *Key) (err error) { return err } - if _defaultProxy, err = parseProxy(k.Proxy); err != nil { + var proxyScheme string + if _defaultProxy, proxyScheme, err = parseProxy(k.Proxy); err != nil { return err } tunnel.T().SetProxy(_defaultProxy) @@ -247,5 +291,10 @@ func netstack(k *Key) (err error) { } log.Infof("[STACK] %s <-> %s", k.Device, k.Proxy) + + if err = fakeDNS(k, proxyScheme); err != nil { + return err + } + return nil } diff --git a/engine/key.go b/engine/key.go index a8fb1dd6..389efe6a 100644 --- a/engine/key.go +++ b/engine/key.go @@ -17,4 +17,7 @@ type Key struct { TUNPreUp string `yaml:"tun-pre-up"` TUNPostUp string `yaml:"tun-post-up"` UDPTimeout time.Duration `yaml:"udp-timeout"` + FakeDNS bool `yaml:"fakedns"` + FakeDNSNetIPv4 string `yaml:"fakedns-net-ipv4"` + FakeDNSListenAddress string `yaml:"fakedns-listen-addr"` } diff --git a/engine/parse.go b/engine/parse.go index c859b9f1..34bb9e4d 100644 --- a/engine/parse.go +++ b/engine/parse.go @@ -73,16 +73,20 @@ func parseFD(u *url.URL, mtu uint32) (device.Device, error) { return fdbased.Open(u.Host, mtu, offset) } -func parseProxy(s string) (proxy.Proxy, error) { +func parseProxy(s string) (proxy.Proxy, string, error) { if !strings.Contains(s, "://") { s = fmt.Sprintf("%s://%s", "socks5" /* default */, s) } u, err := url.Parse(s) if err != nil { - return nil, err + return nil, "", err + } + p, err := proxy.Parse(u) + if err != nil { + return nil, "", err } - return proxy.Parse(u) + return p, u.Scheme, nil } func parseMulticastGroups(v []string) ([]netip.Addr, error) { diff --git a/go.mod b/go.mod index 5ed9c275..de511f24 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/schema v1.4.1 github.com/gorilla/websocket v1.5.3 + github.com/miekg/dns v1.1.52 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 go.uber.org/atomic v1.11.0 @@ -33,7 +34,10 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/tools v0.46.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index d328aae7..f83b3a1e 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/go-gost/relay v0.6.1 h1:7SqnHFbY8x/DzvjpK03a5zcVH9+TbJAcnW/RT6s1ecc= github.com/go-gost/relay v0.6.1/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -31,6 +33,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/miekg/dns v1.1.52 h1:Bmlc/qsNNULOe6bpXcUTsuOajd0DzRHwup6D9k1An0c= +github.com/miekg/dns v1.1.52/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -55,14 +59,20 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= diff --git a/main.go b/main.go index f1672b93..0e9777a0 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,9 @@ func init() { flag.StringVar(&key.TUNPreUp, "tun-pre-up", "", "Execute a command before TUN device setup") flag.StringVar(&key.TUNPostUp, "tun-post-up", "", "Execute a command after TUN device setup") flag.BoolVarP(&versionFlag, "version", "v", false, "Show version and then quit") + flag.BoolVar(&key.FakeDNS, "fakedns", false, "Enable fake DNS (HTTP, Shadowsocks, SOCKS)") + flag.StringVar(&key.FakeDNSNetIPv4, "fakedns-net-ipv4", "198.18.0.0/15", "IPv4 network for fake DNS A records") + flag.StringVar(&key.FakeDNSListenAddress, "fakedns-listen-addr", "127.0.0.1:53", "IP:port to listen on for DNS requests") flag.Parse() } diff --git a/metadata/metadata.go b/metadata/metadata.go index 0e2a0da8..af8daed5 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -3,6 +3,7 @@ package metadata import ( "net" "net/netip" + "strconv" ) // Metadata contains metadata of transport protocol sessions. @@ -11,6 +12,7 @@ type Metadata struct { SrcIP netip.Addr `json:"sourceIP"` MidIP netip.Addr `json:"dialerIP"` DstIP netip.Addr `json:"destinationIP"` + DstName string `json:"destinationName"` SrcPort uint16 `json:"sourcePort"` MidPort uint16 `json:"dialerPort"` DstPort uint16 `json:"destinationPort"` @@ -21,6 +23,9 @@ func (m *Metadata) DestinationAddrPort() netip.AddrPort { } func (m *Metadata) DestinationAddress() string { + if !m.DstIP.IsValid() { + return net.JoinHostPort(m.DstName, strconv.FormatUint(uint64(m.DstPort), 10)) + } return m.DestinationAddrPort().String() } diff --git a/proxy/internal/utils/utils.go b/proxy/internal/utils/utils.go index 4a1e1799..5093f4e9 100644 --- a/proxy/internal/utils/utils.go +++ b/proxy/internal/utils/utils.go @@ -30,5 +30,5 @@ func SafeConnClose(c net.Conn, err error) { // SerializeSocksAddr serializes metadata to SOCKSv5 address. func SerializeSocksAddr(m *M.Metadata) socks5.Addr { - return socks5.SerializeAddr("", m.DstIP, m.DstPort) + return socks5.SerializeAddr(m.DstName, m.DstIP, m.DstPort) } diff --git a/tunnel/tcp.go b/tunnel/tcp.go index 93ac8c70..5bafd338 100644 --- a/tunnel/tcp.go +++ b/tunnel/tcp.go @@ -9,6 +9,7 @@ import ( "github.com/xjasonlyu/tun2socks/v2/buffer" "github.com/xjasonlyu/tun2socks/v2/core/adapter" + "github.com/xjasonlyu/tun2socks/v2/dns" "github.com/xjasonlyu/tun2socks/v2/log" M "github.com/xjasonlyu/tun2socks/v2/metadata" "github.com/xjasonlyu/tun2socks/v2/tunnel/statistic" @@ -26,6 +27,8 @@ func (t *Tunnel) handleTCPConn(originConn adapter.TCPConn) { DstPort: id.LocalPort, } + dns.ProcessMetadata(metadata) + ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout) defer cancel() diff --git a/tunnel/udp.go b/tunnel/udp.go index 80c60419..e1058ed8 100644 --- a/tunnel/udp.go +++ b/tunnel/udp.go @@ -8,6 +8,7 @@ import ( "github.com/xjasonlyu/tun2socks/v2/buffer" "github.com/xjasonlyu/tun2socks/v2/core/adapter" + "github.com/xjasonlyu/tun2socks/v2/dns" "github.com/xjasonlyu/tun2socks/v2/log" M "github.com/xjasonlyu/tun2socks/v2/metadata" "github.com/xjasonlyu/tun2socks/v2/tunnel/statistic" @@ -26,6 +27,8 @@ func (t *Tunnel) handleUDPConn(uc adapter.UDPConn) { DstPort: id.LocalPort, } + dns.ProcessMetadata(metadata) + pc, err := t.Proxy().DialUDP(metadata) if err != nil { log.Warnf("[UDP] dial %s: %v", metadata.DestinationAddress(), err) @@ -105,7 +108,10 @@ func (pc *symmetricNATPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { for { n, from, err := pc.PacketConn.ReadFrom(p) - if from != nil && from.String() != pc.dst { + // If pc.dst is not an IP address, it is a hostname. In that case, we + // do not know the source IP which packets should originate from and + // cannot drop them accordingly. + if from != nil && from.String() != pc.dst && net.ParseIP(pc.dst) != nil { log.Warnf("[UDP] symmetric NAT %s->%s: drop packet from %s", pc.src, pc.dst, from) continue } From a0cd3d33e929efc35fbb0c6f5e04914905049771 Mon Sep 17 00:00:00 2001 From: Stanislav Povolotsky <19610545+Stanislav-Povolotsky@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:30:47 +0300 Subject: [PATCH 3/4] Fix fake DNS bugs found in post-rebase review - fakeip.Pool.get(): fix off-by-one in the address-cycling modulo that made the pool's top address unreachable (collapsed a /30 pool to a single address for every hostname). - tunnel/udp.go: fix symmetric-NAT source check that was permanently disabled for every UDP flow, not just fake-DNS ones, because net.ParseIP() was called on a "host:port" string and always failed. - dns package: guard fakePool/fakeDNSenabled with a mutex instead of unsynchronized globals, close the nil-pool panic window on startup, and drop the dead TTL=600 record header immediately overwritten by TTL=1. - engine: validate fake DNS config and build its pool before bringing up the TUN device/stack, so a bad config can't leave a half-started stack running; wire dns.DisableFakeDNS() into engine.Stop(). --- component/fakeip/pool.go | 4 ++-- component/fakeip/pool_test.go | 3 +++ dns/fakedns.go | 41 +++++++++++++++++++++++++++++------ dns/server.go | 20 +++++++++++------ engine/engine.go | 37 +++++++++++++++++-------------- tunnel/udp.go | 14 +++++++----- 6 files changed, 81 insertions(+), 38 deletions(-) diff --git a/component/fakeip/pool.go b/component/fakeip/pool.go index 134f6eb1..250d0cf7 100644 --- a/component/fakeip/pool.go +++ b/component/fakeip/pool.go @@ -103,10 +103,10 @@ func (p *Pool) get(host string) net.IP { break } - p.offset = (p.offset + 1) % (p.max - p.min) + p.offset = (p.offset + 1) % (p.max - p.min + 1) // Avoid infinite loops if p.offset == current { - p.offset = (p.offset + 1) % (p.max - p.min) + p.offset = (p.offset + 1) % (p.max - p.min + 1) ip := uintToIP(p.min + p.offset) p.store.DelByIP(ip) break diff --git a/component/fakeip/pool_test.go b/component/fakeip/pool_test.go index c2bb593e..b4ecb4ba 100644 --- a/component/fakeip/pool_test.go +++ b/component/fakeip/pool_test.go @@ -95,6 +95,9 @@ func TestPool_CycleUsed(t *testing.T) { assert.Equal(t, net.IP{192, 168, 0, 4}, pool.Lookup("4.com")) assert.Equal(t, net.IP{192, 168, 0, 5}, pool.Lookup("5.com")) assert.Equal(t, net.IP{192, 168, 0, 6}, pool.Lookup("6.com")) + // The pool for a /29 network spans .2-.7 (6 addresses); the last + // address must be reachable too, not just the first max-min of them. + assert.Equal(t, net.IP{192, 168, 0, 7}, pool.Lookup("7.com")) assert.Equal(t, net.IP{192, 168, 0, 2}, pool.Lookup("12.com")) assert.Equal(t, net.IP{192, 168, 0, 3}, pool.Lookup("3.com")) } diff --git a/dns/fakedns.go b/dns/fakedns.go index be87edc4..e48a8b81 100644 --- a/dns/fakedns.go +++ b/dns/fakedns.go @@ -5,6 +5,7 @@ import ( "net" "net/netip" "strings" + "sync" D "github.com/miekg/dns" @@ -13,8 +14,12 @@ import ( ) var ( + // fakeMu guards fakePool and fakeDNSenabled, which are read from every + // TCP/UDP connection goroutine via ProcessMetadata and written once from + // the engine goroutine via EnableFakeDNS/DisableFakeDNS/ReCreateServer. + fakeMu sync.RWMutex fakePool *fakeip.Pool - fakeDNSenabled = false + fakeDNSenabled bool ) func setMsgTTL(msg *D.Msg, ttl uint32) { @@ -32,14 +37,28 @@ func setMsgTTL(msg *D.Msg, ttl uint32) { } func EnableFakeDNS() { + fakeMu.Lock() fakeDNSenabled = true + fakeMu.Unlock() +} + +// DisableFakeDNS turns fake DNS off and stops the DNS listener, if any. +func DisableFakeDNS() { + fakeMu.Lock() + fakeDNSenabled = false + fakeMu.Unlock() + ReCreateServer("", nil) } func ProcessMetadata(metadata *M.Metadata) bool { - if !fakeDNSenabled { + fakeMu.RLock() + enabled, pool := fakeDNSenabled, fakePool + fakeMu.RUnlock() + + if !enabled || pool == nil { return false } - dstName, found := fakePool.LookBack(net.IP(metadata.DstIP.AsSlice())) + dstName, found := pool.LookBack(net.IP(metadata.DstIP.AsSlice())) if !found { return false } @@ -48,12 +67,19 @@ func ProcessMetadata(metadata *M.Metadata) bool { return true } -func fakeipHandler(fakePool *fakeip.Pool) handler { +func fakeipHandler() handler { return func(r *D.Msg) (*D.Msg, error) { if len(r.Question) == 0 { return nil, errors.New("at least one question is required") } + fakeMu.RLock() + pool := fakePool + fakeMu.RUnlock() + if pool == nil { + return nil, errors.New("fake DNS pool not initialized") + } + q := r.Question[0] host := strings.TrimRight(q.Name, ".") @@ -61,12 +87,13 @@ func fakeipHandler(fakePool *fakeip.Pool) handler { if q.Qtype == D.TypeA { rr := &D.A{} - rr.Hdr = D.RR_Header{Name: q.Name, Rrtype: D.TypeA, Class: D.ClassINET, Ttl: dnsDefaultTTL} - ip := fakePool.Lookup(host) - rr.A = ip + rr.Hdr = D.RR_Header{Name: q.Name, Rrtype: D.TypeA, Class: D.ClassINET} + rr.A = pool.Lookup(host) msg.Answer = []D.RR{rr} } + // Fake IPs can be recycled by the pool at any time, so clients must + // not cache them for long. setMsgTTL(msg, 1) msg.SetRcode(r, D.RcodeSuccess) msg.RecursionAvailable = true diff --git a/dns/server.go b/dns/server.go index f996101a..8cd2dc3f 100644 --- a/dns/server.go +++ b/dns/server.go @@ -11,10 +11,7 @@ import ( "github.com/xjasonlyu/tun2socks/v2/log" ) -var ( - server = &Server{} - dnsDefaultTTL uint32 = 600 -) +var server = &Server{} type ( handler func(r *D.Msg) (*D.Msg, error) @@ -44,14 +41,19 @@ func handlerWithContext(handler handler, msg *D.Msg) (*D.Msg, error) { return handler(msg) } +// ReCreateServer (re)starts the fake DNS UDP listener at addr using pool. +// Passing an empty addr or a nil pool stops any running server. func ReCreateServer(addr string, pool *fakeip.Pool) { + fakeMu.Lock() + defer fakeMu.Unlock() + fakePool = pool if server.Server != nil { server.Shutdown() server = &Server{} } - if addr == "" { + if addr == "" || pool == nil { return } @@ -84,11 +86,15 @@ func ReCreateServer(addr string, pool *fakeip.Pool) { err = nil } - server = &Server{handler: fakeipHandler(fakePool)} + server = &Server{handler: fakeipHandler()} server.Server = &D.Server{Addr: addr, PacketConn: udpConn, Handler: server} + // Capture the current *Server so a concurrent ReCreateServer call + // swapping the package-level `server` var can't redirect this goroutine + // to serve on the wrong instance. + srv := server go func() { - server.ActivateAndServe() + srv.ActivateAndServe() }() log.Infof("DNS server listening at: %s", udpConn.LocalAddr().String()) diff --git a/engine/engine.go b/engine/engine.go index 90debe60..1370899e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -99,6 +99,7 @@ func stop() (err error) { _defaultStack.Close() _defaultStack.Wait() } + dns.DisableFakeDNS() _engineMu.Unlock() return nil } @@ -189,34 +190,29 @@ var remoteDNSProtocols = map[string]bool{ "ss": true, } -func fakeDNS(k *Key, proxyScheme string) (err error) { +// newFakeDNSPool validates the fake DNS configuration and builds its address +// pool. It has no side effects, so it is safe to call before the netstack is +// brought up: a configuration error here must not leave a half-started stack +// behind. +func newFakeDNSPool(k *Key, proxyScheme string) (*fakeip.Pool, error) { if !k.FakeDNS { - return + return nil, nil } if !remoteDNSProtocols[proxyScheme] { - return fmt.Errorf("remote DNS not supported with proxy protocol %q", proxyScheme) + return nil, fmt.Errorf("remote DNS not supported with proxy protocol %q", proxyScheme) } _, ipnet, err := net.ParseCIDR(k.FakeDNSNetIPv4) if err != nil { - return err + return nil, err } - pool, err := fakeip.New(fakeip.Options{ + return fakeip.New(fakeip.Options{ IPNet: ipnet, Size: 1000, Host: trie.New(), }) - if err != nil { - return err - } - - dns.EnableFakeDNS() - dns.ReCreateServer(k.FakeDNSListenAddress, pool) - - log.Infof("[DNS] fake DNS enabled") - return nil } func netstack(k *Key) (err error) { @@ -255,6 +251,13 @@ func netstack(k *Key) (err error) { } tunnel.T().SetProxy(_defaultProxy) + // Validate fake DNS config and build its pool up front: an error here + // must not leave a half-started stack behind. + fakeDNSPool, err := newFakeDNSPool(k, proxyScheme) + if err != nil { + return err + } + if _defaultDevice, err = parseDevice(k.Device, uint32(k.MTU)); err != nil { return err } @@ -292,8 +295,10 @@ func netstack(k *Key) (err error) { log.Infof("[STACK] %s <-> %s", k.Device, k.Proxy) - if err = fakeDNS(k, proxyScheme); err != nil { - return err + if fakeDNSPool != nil { + dns.ReCreateServer(k.FakeDNSListenAddress, fakeDNSPool) + dns.EnableFakeDNS() + log.Infof("[DNS] fake DNS enabled") } return nil diff --git a/tunnel/udp.go b/tunnel/udp.go index e1058ed8..9630395c 100644 --- a/tunnel/udp.go +++ b/tunnel/udp.go @@ -92,8 +92,9 @@ func copyPacketData(dst, src net.PacketConn, to net.Addr, timeout time.Duration) type symmetricNATPacketConn struct { net.PacketConn - src string - dst string + src string + dst string + dstIsHost bool } func newSymmetricNATPacketConn(pc net.PacketConn, metadata *M.Metadata) *symmetricNATPacketConn { @@ -101,6 +102,10 @@ func newSymmetricNATPacketConn(pc net.PacketConn, metadata *M.Metadata) *symmetr PacketConn: pc, src: metadata.SourceAddress(), dst: metadata.DestinationAddress(), + // If the destination is a hostname (fake DNS rewrote it), we do not + // know the source IP which packets should originate from and cannot + // drop them accordingly. + dstIsHost: !metadata.DstIP.IsValid(), } } @@ -108,10 +113,7 @@ func (pc *symmetricNATPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { for { n, from, err := pc.PacketConn.ReadFrom(p) - // If pc.dst is not an IP address, it is a hostname. In that case, we - // do not know the source IP which packets should originate from and - // cannot drop them accordingly. - if from != nil && from.String() != pc.dst && net.ParseIP(pc.dst) != nil { + if from != nil && from.String() != pc.dst && !pc.dstIsHost { log.Warnf("[UDP] symmetric NAT %s->%s: drop packet from %s", pc.src, pc.dst, from) continue } From 9ae939b819c47aa3c3e663e341603fe1d28d9233 Mon Sep 17 00:00:00 2001 From: Stanislav Povolotsky <19610545+Stanislav-Povolotsky@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:02:14 +0300 Subject: [PATCH 4/4] Answer fake DNS queries directly from the tunnel path ReCreateServer's fake DNS listener binds a real OS-level UDP socket, which only receives traffic on platforms where DNS queries reach it outside the VPN tunnel (e.g. a desktop system resolver pointed at 127.0.0.1). On platforms like Android, VpnService captures all app traffic -- including DNS -- into the TUN device, so it never reaches that socket. Recognize UDP/TCP flows addressed to the configured fake DNS listen address in tunnel/udp.go and tunnel/tcp.go, and answer them locally via the same fake IP pool (dns.HandleQuery), independent of whether the real socket bind succeeded. This mirrors how ordinary TCP/UDP flows are already intercepted in the tunnel, and lets fake DNS work wherever the TUN device captures the traffic, not just when a real loopback socket is reachable. --- dns/fakedns.go | 37 ++++++++++++++++++-- dns/fakedns_test.go | 85 +++++++++++++++++++++++++++++++++++++++++++++ dns/server.go | 10 ++++++ tunnel/tcp.go | 40 +++++++++++++++++++++ tunnel/udp.go | 33 ++++++++++++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 dns/fakedns_test.go diff --git a/dns/fakedns.go b/dns/fakedns.go index e48a8b81..0548836a 100644 --- a/dns/fakedns.go +++ b/dns/fakedns.go @@ -14,12 +14,19 @@ import ( ) var ( - // fakeMu guards fakePool and fakeDNSenabled, which are read from every - // TCP/UDP connection goroutine via ProcessMetadata and written once from - // the engine goroutine via EnableFakeDNS/DisableFakeDNS/ReCreateServer. + // fakeMu guards fakePool, fakeDNSenabled and fakeListenAddr, which are + // read from every TCP/UDP connection goroutine via ProcessMetadata and + // IsFakeDNSQuery, and written once from the engine goroutine via + // EnableFakeDNS/DisableFakeDNS/ReCreateServer. fakeMu sync.RWMutex fakePool *fakeip.Pool fakeDNSenabled bool + // fakeListenAddr is the address fake DNS queries are expected on. It is + // used to recognize fake DNS traffic that arrives through the tunnel + // (e.g. on platforms like Android, where all traffic including DNS is + // captured by the TUN device and never reaches a real OS-level socket), + // independent of whether ReCreateServer managed to bind a real listener. + fakeListenAddr netip.AddrPort ) func setMsgTTL(msg *D.Msg, ttl uint32) { @@ -50,6 +57,30 @@ func DisableFakeDNS() { ReCreateServer("", nil) } +// IsFakeDNSQuery reports whether dst is the configured fake DNS listen +// address, i.e. a UDP/TCP flow to dst should be answered locally via +// HandleQuery instead of being dialed out through the proxy. +func IsFakeDNSQuery(dst netip.AddrPort) bool { + fakeMu.RLock() + defer fakeMu.RUnlock() + return fakeDNSenabled && fakeListenAddr.IsValid() && dst == fakeListenAddr +} + +// HandleQuery answers a raw DNS query message using the fake IP pool, for +// callers that intercept DNS traffic directly from the tunnel rather than +// through the real OS-level socket managed by ReCreateServer. +func HandleQuery(data []byte) ([]byte, error) { + var msg D.Msg + if err := msg.Unpack(data); err != nil { + return nil, err + } + resp, err := fakeipHandler()(&msg) + if err != nil { + return nil, err + } + return resp.Pack() +} + func ProcessMetadata(metadata *M.Metadata) bool { fakeMu.RLock() enabled, pool := fakeDNSenabled, fakePool diff --git a/dns/fakedns_test.go b/dns/fakedns_test.go new file mode 100644 index 00000000..1b17993f --- /dev/null +++ b/dns/fakedns_test.go @@ -0,0 +1,85 @@ +package dns + +import ( + "net" + "net/netip" + "testing" + + D "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + + "github.com/xjasonlyu/tun2socks/v2/component/fakeip" + "github.com/xjasonlyu/tun2socks/v2/component/trie" +) + +func newTestPool(t *testing.T) *fakeip.Pool { + t.Helper() + _, cidr, err := net.ParseCIDR("198.18.0.0/16") + if err != nil { + t.Fatal(err) + } + pool, err := fakeip.New(fakeip.Options{ + IPNet: cidr, + Size: 10, + Host: trie.New(), + }) + if err != nil { + t.Fatal(err) + } + return pool +} + +// TestIsFakeDNSQuery_TunnelHijack verifies that a query addressed to the +// configured fake DNS listen address is recognized, independent of whether +// ReCreateServer's real OS-level socket bind succeeds. This is what allows +// platforms like Android — where all traffic, including DNS, is captured by +// the TUN device and never reaches a real socket — to answer fake DNS +// queries via the in-tunnel path instead. +func TestIsFakeDNSQuery_TunnelHijack(t *testing.T) { + t.Cleanup(func() { DisableFakeDNS() }) + + pool := newTestPool(t) + + want := netip.MustParseAddrPort("127.0.0.1:15353") + assert.False(t, IsFakeDNSQuery(want), "must be false before fake DNS is configured") + + ReCreateServer("127.0.0.1:15353", pool) + EnableFakeDNS() + + assert.True(t, IsFakeDNSQuery(want)) + assert.False(t, IsFakeDNSQuery(netip.MustParseAddrPort("127.0.0.1:9999"))) +} + +// TestHandleQuery_TunnelPath verifies that a raw DNS query byte slice, as +// would be read directly from a UDP/TCP flow intercepted in the tunnel, is +// answered with a fake IP from the pool, and that IP resolves back to the +// queried hostname via the pool's LookBack — exactly the round trip +// tunnel/udp.go's handleFakeDNSUDP and tunnel/tcp.go's handleFakeDNSTCP rely +// on. +func TestHandleQuery_TunnelPath(t *testing.T) { + t.Cleanup(func() { DisableFakeDNS() }) + + pool := newTestPool(t) + ReCreateServer("127.0.0.1:0", pool) + EnableFakeDNS() + + query := new(D.Msg) + query.SetQuestion(D.Fqdn("example.com"), D.TypeA) + + raw, err := query.Pack() + assert.NoError(t, err) + + respRaw, err := HandleQuery(raw) + assert.NoError(t, err) + + resp := new(D.Msg) + assert.NoError(t, resp.Unpack(respRaw)) + assert.Len(t, resp.Answer, 1) + + a, ok := resp.Answer[0].(*D.A) + assert.True(t, ok) + + host, found := pool.LookBack(a.A) + assert.True(t, found) + assert.Equal(t, "example.com", host) +} diff --git a/dns/server.go b/dns/server.go index 8cd2dc3f..839fd156 100644 --- a/dns/server.go +++ b/dns/server.go @@ -3,6 +3,7 @@ package dns import ( "errors" "net" + "net/netip" D "github.com/miekg/dns" @@ -48,6 +49,7 @@ func ReCreateServer(addr string, pool *fakeip.Pool) { defer fakeMu.Unlock() fakePool = pool + fakeListenAddr = netip.AddrPort{} if server.Server != nil { server.Shutdown() server = &Server{} @@ -73,6 +75,14 @@ func ReCreateServer(addr string, pool *fakeip.Pool) { if err != nil { return } + // Recorded regardless of whether the OS-level bind below succeeds, so + // that fake DNS queries arriving through the tunnel (see + // IsFakeDNSQuery/HandleQuery) are still answered even on platforms + // where binding this address as a real local socket isn't possible. + // Unmap in case ResolveUDPAddr returned an IPv4-in-6 form: addresses + // coming from the tunnel (gVisor) are always plain IPv4, so comparing + // against a mapped address in IsFakeDNSQuery would never match. + fakeListenAddr = netip.AddrPortFrom(udpAddr.AddrPort().Addr().Unmap(), uint16(udpAddr.Port)) udpConn, err := net.ListenUDP("udp", udpAddr) if err != nil { diff --git a/tunnel/tcp.go b/tunnel/tcp.go index 5bafd338..728c9022 100644 --- a/tunnel/tcp.go +++ b/tunnel/tcp.go @@ -2,6 +2,7 @@ package tunnel import ( "context" + "encoding/binary" "io" "net" "sync" @@ -27,6 +28,11 @@ func (t *Tunnel) handleTCPConn(originConn adapter.TCPConn) { DstPort: id.LocalPort, } + if dns.IsFakeDNSQuery(metadata.DestinationAddrPort()) { + t.handleFakeDNSTCP(originConn) + return + } + dns.ProcessMetadata(metadata) ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout) @@ -46,6 +52,40 @@ func (t *Tunnel) handleTCPConn(originConn adapter.TCPConn) { pipe(originConn, remoteConn) } +// handleFakeDNSTCP answers DNS-over-TCP queries (RFC 1035 4.2.2: each +// message is prefixed with a 2-byte big-endian length) sent to the fake DNS +// listen address locally via the fake IP pool, without dialing out through +// the proxy. +func (t *Tunnel) handleFakeDNSTCP(originConn adapter.TCPConn) { + for { + originConn.SetReadDeadline(time.Now().Add(tcpConnectTimeout)) + + var lenBuf [2]byte + if _, err := io.ReadFull(originConn, lenBuf[:]); err != nil { + return + } + + msgBuf := make([]byte, binary.BigEndian.Uint16(lenBuf[:])) + if _, err := io.ReadFull(originConn, msgBuf); err != nil { + return + } + + resp, err := dns.HandleQuery(msgBuf) + if err != nil { + log.Debugf("[DNS] fake DNS query: %v", err) + return + } + + binary.BigEndian.PutUint16(lenBuf[:], uint16(len(resp))) + if _, err := originConn.Write(lenBuf[:]); err != nil { + return + } + if _, err := originConn.Write(resp); err != nil { + return + } + } +} + // pipe copies data to & from provided net.Conn(s) bidirectionally. func pipe(origin, remote net.Conn) { wg := sync.WaitGroup{} diff --git a/tunnel/udp.go b/tunnel/udp.go index 9630395c..dff1d68e 100644 --- a/tunnel/udp.go +++ b/tunnel/udp.go @@ -27,6 +27,11 @@ func (t *Tunnel) handleUDPConn(uc adapter.UDPConn) { DstPort: id.LocalPort, } + if dns.IsFakeDNSQuery(metadata.DestinationAddrPort()) { + t.handleFakeDNSUDP(uc) + return + } + dns.ProcessMetadata(metadata) pc, err := t.Proxy().DialUDP(metadata) @@ -51,6 +56,34 @@ func (t *Tunnel) handleUDPConn(uc adapter.UDPConn) { pipePacket(uc, pc, remote, t.udpTimeout.Load()) } +// handleFakeDNSUDP answers DNS queries sent to the fake DNS listen address +// locally via the fake IP pool, without dialing out through the proxy. This +// is what makes fake DNS work on platforms (e.g. Android) where all traffic, +// including DNS, is captured by the TUN device and never reaches the real +// OS-level socket ReCreateServer tries to bind. +func (t *Tunnel) handleFakeDNSUDP(uc adapter.UDPConn) { + buf := buffer.Get(buffer.MaxSegmentSize) + defer buffer.Put(buf) + + timeout := t.udpTimeout.Load() + for { + uc.SetReadDeadline(time.Now().Add(timeout)) + n, from, err := uc.ReadFrom(buf) + if err != nil { + return + } + + resp, err := dns.HandleQuery(buf[:n]) + if err != nil { + log.Debugf("[DNS] fake DNS query: %v", err) + continue + } + if _, err := uc.WriteTo(resp, from); err != nil { + return + } + } +} + func pipePacket(origin, remote net.PacketConn, to net.Addr, timeout time.Duration) { wg := sync.WaitGroup{} wg.Add(2)