Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7a57ea7
refactor(client): modularize DNS interception logic and address remap…
fortuna Apr 15, 2026
01891a2
refactor(client): simplify dns interceptor lazy initialization
fortuna Apr 15, 2026
a5a5ec5
Remove helpers.go
fortuna Apr 15, 2026
6c038e9
Merge branch 'master' into fortuna/dns-resource-leak
fortuna Apr 27, 2026
a10b32d
Add notes
fortuna Apr 28, 2026
4e45a16
Update notes
fortuna Apr 28, 2026
d0cf3b3
More notes
fortuna Apr 28, 2026
8b43841
Error note
fortuna Apr 29, 2026
b783562
Shorter timeout for DNS
fortuna May 1, 2026
76af39e
Introduce lazy packet proxy
fortuna May 1, 2026
e285a46
Update TODO
fortuna May 1, 2026
1af2b8c
client/go/dnsintercept: close receiver after single response for sing…
fortuna May 1, 2026
4341285
docs(dnsintercept): update README to fix PR comments
fortuna May 1, 2026
7da7026
test(configregistry): add integration tests and benchmarks for DNS in…
fortuna May 1, 2026
e0655fd
test(configregistry): fix data race in DNS interceptor tests
fortuna May 1, 2026
d0cd4bc
docs(dnsintercept): add comments to NewSession and request sender
fortuna May 1, 2026
fa3cc17
test(configregistry): implement deadlines in mock to fix truncation test
fortuna May 1, 2026
e17452b
test(configregistry): add assertion to timeout test
fortuna May 1, 2026
a98384d
fix(configregistry): make error messages distinct in outline_dns_inte…
fortuna May 1, 2026
5b7923f
test(configregistry): fix remaining data races in DNS interceptor tests
fortuna May 1, 2026
c92d671
fix(dnsintercept): add sync.Once to prevent double-close in singleRes…
fortuna May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions client/go/outline/configregistry/outline_dns_intercept.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import (
"log/slog"
"math/rand/v2"
"net/netip"
"time"

"localhost/client/go/outline/connectivity"
"localhost/client/go/outline/dnsintercept"

"golang.getoutline.org/sdk/network"
"golang.getoutline.org/sdk/network/dnstruncate"
"golang.getoutline.org/sdk/transport"
)

Expand Down Expand Up @@ -57,33 +59,43 @@ func wrapTransportPairWithOutlineDNS(sd *Dialer[transport.StreamConn], pl *Packe
}

// Intercept DNS for PacketProxy
ppBase, err := network.NewPacketProxyFromPacketListener(pl)

// PacketProxy for connecting to remote servers.
// Uses the 5m timeout as recommended in https://www.rfc-editor.org/rfc/rfc4787.html#section-4.3
ppBase, err := network.NewPacketProxyFromPacketListener(pl, network.WithPacketListenerWriteIdleTimeout(5 * time.Minute))
if err != nil {
return nil, fmt.Errorf("failed to create PacketProxy: %w", err)
}
// Forwards everything including DNS. For DNS it translates between the link-local and remote addresses for the DNS resolver.
ppForward, err := dnsintercept.NewDNSRedirectPacketProxy(ppBase, linkLocalDNS, remoteDNS)
// PacketProxy for DNS. Uses a shorter timeout, as recommended in https://www.rfc-editor.org/rfc/rfc5452.html#section-10.
ppDNSBase, err := network.NewPacketProxyFromPacketListener(pl, network.WithPacketListenerWriteIdleTimeout(10 * time.Second))
if err != nil {
return nil, fmt.Errorf("failed to create DNS redirect PacketProxy: %w", err)
return nil, fmt.Errorf("failed to create PacketProxy: %w", err)
}
Comment thread
fortuna marked this conversation as resolved.
// Forwards everything except DNS. For DNS it returns a truncated response.
ppTrunc, err := dnsintercept.NewDNSTruncatePacketProxy(ppBase, linkLocalDNS)
// Returns a truncated response for DNS packets to force a retry over TCP.
ppDNSTrunc, err := dnstruncate.NewPacketProxy()
if err != nil {
return nil, fmt.Errorf("failed to create always-truncate DNS PacketProxy: %w", err)
}
ppMain, err := network.NewDelegatePacketProxy(ppTrunc)
// Delegate for DNS traffic: selects between forwarding and truncation based on connectivity.
ppDNSDelegate, err := network.NewDelegatePacketProxy(ppDNSTrunc)
if err != nil {
return nil, fmt.Errorf("failed to create indirect DNS PacketProxy: %w", err)
}
// Interceptor: Forwards everything except DNS to ppBase. DNS is redirected to ppDNS and
// translated between the link-local and remote addresses.
ppMain, err := dnsintercept.NewDNSInterceptor(ppBase, ppDNSDelegate, linkLocalDNS, remoteDNS)
if err != nil {
return nil, fmt.Errorf("failed to create indirect PacketProxy: %w", err)
return nil, fmt.Errorf("failed to create DNS interceptor PacketProxy: %w", err)
}

onNetworkChanged := func() {
go func() {
if err := connectivity.CheckUDPConnectivity(pl); err == nil {
slog.Info("remote device UDP is healthy")
ppMain.SetProxy(ppForward)
ppDNSDelegate.SetProxy(ppDNSBase)
} else {
slog.Warn("remote device UDP is not healthy", "err", err)
ppMain.SetProxy(ppTrunc)
ppDNSDelegate.SetProxy(ppDNSTrunc)
}
}()
}
Expand Down
67 changes: 35 additions & 32 deletions client/go/outline/dnsintercept/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,27 +63,47 @@ DNS can travel over both TCP and UDP:

UDP connectivity is not guaranteed, so the package uses two strategies and switches between them dynamically.

### Dynamic switching

The two modes are wired together by the caller (`configregistry.wrapTransportPairWithOutlineDNS`) using a `DelegatePacketProxy` and a `DNSInterceptor`.

```mermaid
flowchart TD
OS["OS (UDP traffic)"] --> ppMain
ppMain["DNSInterceptor<br/>(Address remapping and lazy dispatching)"]
ppMain -->|Non-DNS| ppBase["base PacketProxy<br/>(transport)"]
ppMain -->|DNS| ppDNS["DelegatePacketProxy<br/>(DNS traffic only)"]

check["UDP connectivity check<br/>(on network change)"] -->|pass| ppDNS
check -->|fail| ppDNS

ppDNS -->|UDP available| ppBase
ppDNS -->|UDP blocked| ppTrunc["dnstruncate.PacketProxy<br/>(TC response locally)"]
```

The `DNSInterceptor` acts as the primary dispatcher. It routes non-DNS traffic directly to the transport and DNS traffic to a dedicated delegate proxy. This delegate proxy switches between forwarding and truncation based on the result of a periodic UDP connectivity check. Transport sessions are opened lazily upon receiving the first packet, ensuring resources are only used when needed.


### Forward mode (UDP available)

DNS queries are forwarded over UDP to a public resolver (Cloudflare, Quad9, or OpenDNS, chosen randomly per session) through the proxy transport. Responses are rewritten to appear to come from the original fake address.
DNS queries are forwarded over UDP to a public resolver (Cloudflare, Quad9, or OpenDNS, chosen randomly per session) through the proxy transport. Addresses are rewritten between the fake link-local address and the real resolver address.

```mermaid
sequenceDiagram
participant OS
participant dnsRedirectPacketProxy
participant Interceptor as DNSInterceptor
participant Transport
participant Resolver as Public DNS resolver

Comment thread
fortuna marked this conversation as resolved.
OS->>dnsRedirectPacketProxy: UDP query to 169.254.113.53:53
dnsRedirectPacketProxy->>Transport: UDP query to 1.1.1.1:53 (remapped)
OS->>Interceptor: UDP query to 169.254.113.53:53
Interceptor->>Transport: UDP query to 1.1.1.1:53 (remapped)
Transport->>Resolver: query
Resolver->>Transport: response
Transport->>dnsRedirectPacketProxy: UDP response from 1.1.1.1:53
dnsRedirectPacketProxy->>OS: response from 169.254.113.53:53 (remapped back)
Note over dnsRedirectPacketProxy: session closed immediately after response
Transport->>Interceptor: UDP response from 1.1.1.1:53
Interceptor->>OS: response from 169.254.113.53:53 (remapped back)
```

Each DNS session (one query/response pair) opens a transport session for the duration of the exchange and closes it as soon as the response is delivered. This keeps resource usage proportional to in-flight queries rather than to recent query rate.
Each DNS session uses a standard transport session. The transport handles the lifecycle, usually timing out after standard UDP idle timeouts.

### Truncate mode (UDP unavailable)

Expand All @@ -92,43 +112,26 @@ If UDP is blocked, forwarding silently fails and DNS stops working. To handle t
```mermaid
sequenceDiagram
participant OS
participant dnsTruncatePacketProxy
participant Trunc as dnstruncate.PacketProxy
participant StreamDialer
participant Resolver as Public DNS resolver

OS->>dnsTruncatePacketProxy: UDP query to 169.254.113.53:53
dnsTruncatePacketProxy->>OS: truncated response (TC=1), no transport used
OS->>Trunc: UDP query to 169.254.113.53:53
Trunc->>OS: truncated response (TC=1), no transport used
Note over OS: retries over TCP automatically
OS->>StreamDialer: TCP query to 169.254.113.53:53
StreamDialer->>Resolver: TCP query to 1.1.1.1:53 (remapped)
Resolver->>StreamDialer: TCP response
StreamDialer->>OS: TCP response
```

In truncate mode, no transport session is opened for DNS at all — the truncated response is generated locally. Non-DNS UDP traffic still flows through the transport normally (a base transport session is opened lazily on the first non-DNS packet).

## Dynamic switching

The two modes are wired together by the caller (`configregistry.wrapTransportPairWithOutlineDNS`) using a `DelegatePacketProxy`. The VPN starts in truncate mode (safe default) and switches to forward mode once UDP connectivity is confirmed. It switches back to truncate mode if connectivity is lost.

```mermaid
flowchart LR
OS["OS (UDP traffic)"] --> ppMain
check["UDP connectivity check<br/>(on network change)"] -->|pass| ppMain
check -->|fail| ppMain
In truncate mode, no transport session is opened for DNS at all — the truncated response is generated locally.

ppMain{{"DelegatePacketProxy<br/>(ppMain)"}}
ppMain -->|UDP available| ppForward["dnsRedirectPacketProxy<br/>(DNS → resolver via transport)"]
ppMain -->|UDP blocked| ppTrunc["dnsTruncatePacketProxy<br/>(DNS → TC response locally)"]

ppForward --> ppBase["base PacketProxy<br/>(transport)"]
ppTrunc --> ppBase
```

## Package contents

| File | Description |
|------|-------------|
| `forward.go` | `NewDNSRedirectStreamDialer` and `NewDNSRedirectPacketProxy` — redirect DNS to a real resolver |
| `truncate.go` | `NewDNSTruncatePacketProxy` — respond with TC=1 to force TCP retry |
| `helpers.go` | `isEquivalentAddrPort` — address comparison ignoring IPv4-in-IPv6 mapping |
| `interceptor.go` | `NewDNSInterceptor` — Dispatches DNS traffic, handles address translation, and creates sessions lazily |
| `interceptor.go` | `NewDNSRedirectStreamDialer` — Redirects TCP DNS to a real resolver |
| `helpers.go` | `isEquivalentAddrPort` — Address comparison ignoring IPv4-in-IPv6 mapping |
120 changes: 0 additions & 120 deletions client/go/outline/dnsintercept/forward.go

This file was deleted.

Loading
Loading