Skip to content

Commit f07f454

Browse files
authored
Merge pull request #2 from nnemirovsky/feat/hostname-recovery
feat(proxy): recover hostnames from IP-only SOCKS5 requests via DNS cache
2 parents ad800e3 + 90e8bd0 commit f07f454

3 files changed

Lines changed: 195 additions & 13 deletions

File tree

internal/proxy/dns.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type DNSInterceptor struct {
2121
engine *atomic.Pointer[policy.Engine]
2222
audit *audit.FileLogger
2323
resolver string // upstream DNS resolver address (host:port)
24+
reverse *ReverseDNSCache
2425
}
2526

2627
// NewDNSInterceptor creates a DNS interceptor that forwards allowed queries
@@ -37,9 +38,16 @@ func NewDNSInterceptor(engine *atomic.Pointer[policy.Engine], audit *audit.FileL
3738
engine: engine,
3839
audit: audit,
3940
resolver: resolver,
41+
reverse: NewReverseDNSCache(),
4042
}
4143
}
4244

45+
// ReverseLookup returns the hostname for an IP if it was recently resolved
46+
// through this interceptor. Returns empty string if not found.
47+
func (d *DNSInterceptor) ReverseLookup(ip string) string {
48+
return d.reverse.Lookup(ip)
49+
}
50+
4351
// dnsTimeout bounds how long a single upstream DNS query can block.
4452
const dnsQueryTimeout = 5 * time.Second
4553

@@ -253,7 +261,18 @@ func (d *DNSInterceptor) HandleQuery(query []byte) ([]byte, error) {
253261
return BuildNXDOMAIN(query)
254262
}
255263

256-
return d.forwardToResolver(query)
264+
resp, fwdErr := d.forwardToResolver(query)
265+
if fwdErr != nil {
266+
return nil, fwdErr
267+
}
268+
269+
// Populate reverse DNS cache from A/AAAA responses so the SOCKS5
270+
// handler can recover hostnames from IP-only CONNECT requests.
271+
if questions[0].Type == dnsTypeA || questions[0].Type == dnsTypeAAAA {
272+
d.reverse.PopulateFromResponse(domain, resp)
273+
}
274+
275+
return resp, nil
257276
}
258277

259278
// evaluate checks the DNS domain against the policy engine. Uses

internal/proxy/dns_reverse.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package proxy
2+
3+
import (
4+
"encoding/binary"
5+
"net"
6+
"sync"
7+
"time"
8+
)
9+
10+
// ReverseDNSCache maps IP addresses to hostnames based on observed DNS
11+
// responses. This enables the SOCKS5 handler to recover the original hostname
12+
// when tun2proxy sends IP-only CONNECT requests (tun2proxy operates at the
13+
// network level and only sees resolved IPs, not hostnames).
14+
//
15+
// Entries expire after 10 minutes. The cache is bounded to 10000 entries.
16+
type ReverseDNSCache struct {
17+
mu sync.RWMutex
18+
entries map[string]reverseDNSEntry
19+
}
20+
21+
type reverseDNSEntry struct {
22+
hostname string
23+
addedAt time.Time
24+
}
25+
26+
const (
27+
reverseDNSTTL = 10 * time.Minute
28+
reverseDNSMaxSize = 10000
29+
)
30+
31+
// NewReverseDNSCache creates a new reverse DNS cache.
32+
func NewReverseDNSCache() *ReverseDNSCache {
33+
return &ReverseDNSCache{
34+
entries: make(map[string]reverseDNSEntry, 256),
35+
}
36+
}
37+
38+
// Store adds an IP -> hostname mapping to the cache.
39+
func (c *ReverseDNSCache) Store(ip, hostname string) {
40+
c.mu.Lock()
41+
defer c.mu.Unlock()
42+
43+
if len(c.entries) >= reverseDNSMaxSize {
44+
c.evictExpiredLocked()
45+
}
46+
if len(c.entries) >= reverseDNSMaxSize {
47+
return
48+
}
49+
50+
c.entries[ip] = reverseDNSEntry{
51+
hostname: hostname,
52+
addedAt: time.Now(),
53+
}
54+
}
55+
56+
// Lookup returns the hostname for an IP, or empty string if not found or expired.
57+
func (c *ReverseDNSCache) Lookup(ip string) string {
58+
c.mu.RLock()
59+
defer c.mu.RUnlock()
60+
61+
entry, ok := c.entries[ip]
62+
if !ok {
63+
return ""
64+
}
65+
if time.Since(entry.addedAt) > reverseDNSTTL {
66+
return ""
67+
}
68+
return entry.hostname
69+
}
70+
71+
// PopulateFromResponse parses a DNS response and extracts A/AAAA records,
72+
// storing the IP -> hostname mappings.
73+
func (c *ReverseDNSCache) PopulateFromResponse(hostname string, resp []byte) {
74+
if len(resp) < dnsHeaderLen {
75+
return
76+
}
77+
78+
flags := binary.BigEndian.Uint16(resp[2:4])
79+
if flags&dnsFlagQR == 0 {
80+
return // not a response
81+
}
82+
if flags&0x000F != 0 {
83+
return // error response
84+
}
85+
86+
qdcount := int(binary.BigEndian.Uint16(resp[4:6]))
87+
ancount := int(binary.BigEndian.Uint16(resp[6:8]))
88+
if ancount == 0 {
89+
return
90+
}
91+
92+
// Skip question section.
93+
offset := dnsHeaderLen
94+
for i := 0; i < qdcount; i++ {
95+
_, newOffset, err := parseDNSName(resp, offset)
96+
if err != nil {
97+
return
98+
}
99+
offset = newOffset + 4
100+
if offset > len(resp) {
101+
return
102+
}
103+
}
104+
105+
// Parse answer records.
106+
for i := 0; i < ancount; i++ {
107+
_, newOffset, err := parseDNSName(resp, offset)
108+
if err != nil {
109+
return
110+
}
111+
offset = newOffset
112+
if offset+10 > len(resp) {
113+
return
114+
}
115+
116+
rrType := binary.BigEndian.Uint16(resp[offset : offset+2])
117+
rdLength := binary.BigEndian.Uint16(resp[offset+8 : offset+10])
118+
offset += 10
119+
120+
if offset+int(rdLength) > len(resp) {
121+
return
122+
}
123+
124+
switch rrType {
125+
case dnsTypeA:
126+
if rdLength == 4 {
127+
ip := net.IPv4(resp[offset], resp[offset+1], resp[offset+2], resp[offset+3])
128+
c.Store(ip.String(), hostname)
129+
}
130+
case dnsTypeAAAA:
131+
if rdLength == 16 {
132+
ip := net.IP(resp[offset : offset+16])
133+
c.Store(ip.String(), hostname)
134+
}
135+
}
136+
137+
offset += int(rdLength)
138+
}
139+
}
140+
141+
func (c *ReverseDNSCache) evictExpiredLocked() {
142+
now := time.Now()
143+
for ip, entry := range c.entries {
144+
if now.Sub(entry.addedAt) > reverseDNSTTL {
145+
delete(c.entries, ip)
146+
}
147+
}
148+
}

internal/proxy/server.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,13 @@ func (r *policyResolver) Resolve(ctx context.Context, name string) (context.Cont
190190
}
191191

192192
type policyRuleSet struct {
193-
engine *atomic.Pointer[policy.Engine]
194-
reloadMu *sync.Mutex // serializes engine swaps and dynamic rule mutations
195-
audit *audit.FileLogger
196-
broker *channel.Broker
197-
store *store.Store
198-
selfBypass map[string]bool // host:port addresses that bypass policy (sluice's own listeners)
193+
engine *atomic.Pointer[policy.Engine]
194+
reloadMu *sync.Mutex // serializes engine swaps and dynamic rule mutations
195+
audit *audit.FileLogger
196+
broker *channel.Broker
197+
store *store.Store
198+
selfBypass map[string]bool // host:port addresses that bypass policy (sluice's own listeners)
199+
dnsInterceptor *DNSInterceptor // reverse DNS cache for IP -> hostname recovery
199200
}
200201

201202
func (r *policyRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
@@ -210,7 +211,21 @@ func (r *policyRuleSet) Allow(ctx context.Context, req *socks5.Request) (context
210211
dest := req.DestAddr.FQDN
211212
if dest == "" {
212213
if req.DestAddr.IP != nil {
213-
dest = req.DestAddr.IP.String()
214+
ipStr := req.DestAddr.IP.String()
215+
// Recover hostname from DNS reverse cache. tun2proxy operates
216+
// at the network level and sends IP-only CONNECT requests.
217+
// The DNS interceptor caches IP -> hostname mappings from
218+
// responses, so we can show hostnames in approval messages
219+
// and evaluate hostname-based policy rules.
220+
if r.dnsInterceptor != nil {
221+
if hostname := r.dnsInterceptor.ReverseLookup(ipStr); hostname != "" {
222+
dest = hostname
223+
} else {
224+
dest = ipStr
225+
}
226+
} else {
227+
dest = ipStr
228+
}
214229
} else {
215230
return ctx, false
216231
}
@@ -412,15 +427,15 @@ func New(cfg Config) (*Server, error) {
412427
}
413428
}
414429

415-
rules := &policyRuleSet{engine: enginePtr, reloadMu: reloadMu, audit: cfg.Audit, broker: cfg.Broker, store: cfg.Store, selfBypass: bypassSet}
416-
dnsRes := &policyResolver{engine: enginePtr, audit: cfg.Audit, broker: cfg.Broker}
417-
srv.rules = rules
418-
srv.dnsResolver = dnsRes
419-
420430
// Create UDP relay and DNS interceptor for UDP ASSOCIATE sessions.
421431
srv.udpRelay = NewUDPRelay(enginePtr, cfg.Audit)
422432
srv.dnsInterceptor = NewDNSInterceptor(enginePtr, cfg.Audit, cfg.DNSResolver)
423433

434+
rules := &policyRuleSet{engine: enginePtr, reloadMu: reloadMu, audit: cfg.Audit, broker: cfg.Broker, store: cfg.Store, selfBypass: bypassSet, dnsInterceptor: srv.dnsInterceptor}
435+
dnsRes := &policyResolver{engine: enginePtr, audit: cfg.Audit, broker: cfg.Broker}
436+
srv.rules = rules
437+
srv.dnsResolver = dnsRes
438+
424439
srv.socks = socks5.NewServer(
425440
socks5.WithRule(rules),
426441
socks5.WithResolver(dnsRes),

0 commit comments

Comments
 (0)