Skip to content

refactor(client): modularize DNS interception logic and address remapping#2751

Closed
fortuna wants to merge 21 commits into
masterfrom
fortuna/dns-resource-leak
Closed

refactor(client): modularize DNS interception logic and address remapping#2751
fortuna wants to merge 21 commits into
masterfrom
fortuna/dns-resource-leak

Conversation

@fortuna

@fortuna fortuna commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR refactors the DNS interception logic to be more modular and easier to maintain, while also addressing resource exhaustion issues.

Key changes:

  • Centralized Dispatching: Extracted dispatching and address remapping into a new DNSInterceptor (interceptor.go). This replaces the previous logic in forward.go (which has been deleted).
  • Lazy Initialization: Introduced lazyPacketProxy (lazy_packet_proxy.go) to defer session creation until the first packet is actually written, reducing idle resource usage.
  • Single Response Enforcement: Added singleResponseReceiver in interceptor.go to automatically close the response receiver after the first response is received (assuming a single request-response pattern for DNS), preventing long-lived idle sessions.
  • SDK Truncation: Removed the custom truncation proxy (truncate.go) in favor of the Outline SDK's dnstruncate.NewPacketProxy().
  • Dynamic Switching: Updated the integration in outline_dns_intercept.go to use the DNSInterceptor combined with a DelegatePacketProxy for dynamically switching between UDP forwarding and local truncation based on connectivity checks.
  • DNS Timeout: Configured a shorter write idle timeout (10 seconds) for DNS traffic compared to base traffic (5 minutes) in outline_dns_intercept.go.
  • Documentation: Updated client/go/outline/dnsintercept/README.md with new Mermaid diagrams reflecting the updated architecture.

These changes resolve the resource exhaustion issues while making the individual proxy components single-purpose.

@github-actions github-actions Bot added size/XXL and removed size/XL labels May 1, 2026
@fortuna

fortuna commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR refactors the DNS interception layer into composable, single-purpose components: a DNSInterceptor for address remapping and routing, a lazyPacketProxy for on-demand session creation, and separate PacketProxy instances with purpose-tuned idle timeouts (5 min for general UDP, 10 s for DNS). The custom truncation proxy is replaced by the SDK's dnstruncate.NewPacketProxy().

  • singleResponseReceiver.WriteFrom omits sync.Once for the auto-close call, regressing from the protection present in the old dnsRedirectPacketRespReceiver. Concurrent DNS response delivery could double-close resp.
  • TestWrapTransportPairWithOutlineDNS_Timeout sleeps 15 seconds but uses t.Logf instead of require.True, making the timeout assertion a no-op that always passes.

Confidence Score: 4/5

Safe to merge after addressing the missing sync.Once in singleResponseReceiver and fixing the no-op timeout test assertion.

Two P1 findings: singleResponseReceiver regresses from explicit sync.Once protection to an unguarded Close() (real concurrent risk under duplicate UDP responses), and the timeout integration test asserts nothing. Both are targeted fixes. The core architecture, lazy initialization, and address-remapping logic are well-structured and well-tested.

client/go/outline/dnsintercept/interceptor.go (singleResponseReceiver double-close) and client/go/outline/configregistry/outline_dns_intercept_test.go (timeout test missing assertion)

Important Files Changed

Filename Overview
client/go/outline/dnsintercept/interceptor.go New central dispatcher: routes DNS→dnsProxy with address NAT, non-DNS→baseProxy; singleResponseReceiver regresses from sync.Once to unprotected Close(), risking concurrent double-close
client/go/outline/dnsintercept/lazy_packet_proxy.go Clean lazy-initialization wrapper; mutex correctly guards sender creation and close; edge cases tested
client/go/outline/configregistry/outline_dns_intercept.go Cleaner wiring: separate PacketProxy instances for base (5 min) and DNS (10 s) timeouts, delegate starts in safe truncate mode, SDK dnstruncate replaces custom implementation
client/go/outline/configregistry/outline_dns_intercept_test.go Good integration-test coverage for DNS redirect, truncation, and non-DNS paths; TestWrapTransportPairWithOutlineDNS_Timeout is a no-op: sleeps but never asserts on resp.closed
client/go/outline/dnsintercept/interceptor_test.go Unit tests for DNSInterceptor, natResponseReceiver, singleResponseReceiver auto-close, and StreamDialer redirect; coverage is thorough
client/go/outline/dnsintercept/lazy_packet_proxy_test.go Comprehensive tests for lazy proxy: session deferred until first write, retry on error, correct close semantics including double-close

Sequence Diagram

sequenceDiagram
    participant OS
    participant DNSInterceptor
    participant lazyBase as lazyPacketProxy(ppBase)
    participant lazyDNS as lazyPacketProxy(ppDNSDelegate)
    participant ppDNSBase as ppDNSBase (10s timeout)
    participant ppDNSTrunc as ppDNSTrunc (local TC)
    participant Transport

    note over lazyDNS: Starts as ppDNSTrunc (safe default)
    OS->>DNSInterceptor: WriteTo(p, 169.254.113.53:53) [DNS]
    DNSInterceptor->>lazyDNS: WriteTo(p, 1.1.1.1:53) [remapped]
    lazyDNS->>ppDNSTrunc: NewSession + WriteTo (UDP blocked path)
    ppDNSTrunc-->>OS: WriteFrom(TC response, 169.254.113.53:53)

    note over lazyDNS: After connectivity check passes → SetProxy(ppDNSBase)
    OS->>DNSInterceptor: WriteTo(p, 169.254.113.53:53) [DNS]
    DNSInterceptor->>lazyDNS: WriteTo(p, 1.1.1.1:53) [remapped]
    lazyDNS->>ppDNSBase: NewSession + WriteTo
    ppDNSBase->>Transport: UDP query to 1.1.1.1:53
    Transport-->>ppDNSBase: UDP response from 1.1.1.1:53
    ppDNSBase-->>OS: WriteFrom(response, 169.254.113.53:53) [remapped back]

    OS->>DNSInterceptor: WriteTo(p, 1.2.3.4:443) [non-DNS]
    DNSInterceptor->>lazyBase: WriteTo(p, 1.2.3.4:443)
    lazyBase->>Transport: UDP packet to 1.2.3.4:443
Loading

Reviews (2): Last reviewed commit: "test(configregistry): fix data race in D..." | Re-trigger Greptile

Comment thread client/go/outline/dnsintercept/README.md
Comment on lines +179 to +184
func (r *singleResponseReceiver) WriteFrom(p []byte, source net.Addr) (int, error) {
n, err := r.PacketResponseReceiver.WriteFrom(p, source)
if atomic.LoadInt32(r.sentCount) == 1 {
r.PacketResponseReceiver.Close()
}
return n, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 singleResponseReceiver closes the shared outer resp

r.PacketResponseReceiver here is dnsResp (a natResponseReceiver), which embeds the same resp that was also passed to i.baseProxy.NewSession(resp). Calling r.PacketResponseReceiver.Close() therefore calls resp.Close(), signalling to the OS that the entire session is done. If any non-DNS traffic was sent on the same session, future responses from the base transport session would be delivered to an already-closed receiver.

In the expected VPN architecture each OS UDP socket is DNS-only, so this is unlikely to matter in practice — but the assumption isn't enforced anywhere. A comment noting that the auto-close is intentional and that it relies on DNS sessions being single-purpose would help future readers understand the invariant.

@fortuna fortuna marked this pull request as ready for review May 1, 2026 21:59
@fortuna fortuna requested a review from a team as a code owner May 1, 2026 21:59
@fortuna fortuna requested review from Copilot, jyyi1 and ohnorobo May 1, 2026 21:59
Comment thread client/go/outline/dnsintercept/interceptor.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the Outline client’s DNS interception stack by splitting DNS dispatch/remapping into a dedicated DNSInterceptor, introducing lazy session creation to reduce idle resource usage, and updating the configregistry wiring (including separate DNS vs non-DNS UDP idle timeouts).

Changes:

  • Replace the old forward.go/truncate.go approach with DNSInterceptor (DNS vs non-DNS dispatch + address remapping) plus lazyPacketProxy.
  • Switch truncation to the SDK’s dnstruncate.NewPacketProxy() and update config wiring to dynamically select forwarding vs truncation for DNS only.
  • Add new tests and update dnsintercept/README.md diagrams to reflect the new architecture.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
client/go/outline/dnsintercept/truncate_test.go Removed tests for the deleted custom truncate proxy.
client/go/outline/dnsintercept/truncate.go Removed custom truncation proxy in favor of SDK truncation.
client/go/outline/dnsintercept/helpers.go Moved isEquivalentAddrPort into interceptor.go.
client/go/outline/dnsintercept/forward_test.go Removed tests for the deleted forwarding proxy.
client/go/outline/dnsintercept/forward.go Deleted legacy DNS forwarding proxy implementation.
client/go/outline/dnsintercept/lazy_packet_proxy.go Added lazy session creation wrapper to defer base session allocation until first write.
client/go/outline/dnsintercept/lazy_packet_proxy_test.go Added unit tests for lazy session creation/close behavior.
client/go/outline/dnsintercept/interceptor.go Added DNSInterceptor for DNS dispatch/remapping plus response receiver wrappers.
client/go/outline/dnsintercept/interceptor_test.go Added tests for DNS interception/remapping and receiver auto-close behavior.
client/go/outline/dnsintercept/README.md Updated architecture diagrams and descriptions for the new modular design.
client/go/outline/configregistry/outline_dns_intercept.go Updated integration to use DNSInterceptor, delegate DNS proxy switching, and per-traffic idle timeouts.
client/go/outline/configregistry/outline_dns_intercept_test.go Added integration tests/benchmark for DNS interception, switching, and timeout behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +179 to +184
func (r *singleResponseReceiver) WriteFrom(p []byte, source net.Addr) (int, error) {
n, err := r.PacketResponseReceiver.WriteFrom(p, source)
if atomic.LoadInt32(r.sentCount) == 1 {
r.PacketResponseReceiver.Close()
}
return n, err

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I should count all writes, not just the DNS ones.

Comment thread client/go/outline/configregistry/outline_dns_intercept_test.go
Comment thread client/go/outline/configregistry/outline_dns_intercept_test.go Outdated
Comment thread client/go/outline/configregistry/outline_dns_intercept.go
@fortuna fortuna marked this pull request as draft May 2, 2026 15:32
@fortuna

fortuna commented May 2, 2026

Copy link
Copy Markdown
Collaborator Author

This needs more work. The closing of the receiver needs to be cleaned up, and I realized that we are not setting read deadlines on the external socket, which is bad. That fix will go on the sdk

@fortuna

fortuna commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #2777 and the SDK changes

@fortuna fortuna closed this Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants