Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
9 changes: 6 additions & 3 deletions client/go/outline/configregistry/outline_dns_intercept.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

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

"golang.getoutline.org/sdk/network"
"golang.getoutline.org/sdk/transport"
)
Expand Down Expand Up @@ -50,7 +51,7 @@ func wrapTransportPairWithOutlineDNS(sd *Dialer[transport.StreamConn], pl *Packe
remoteDNS := outlineDNSResolvers[rand.IntN(len(outlineDNSResolvers))]

// Intercept DNS for StreamDialer
sdForward, err := dnsintercept.WrapForwardStreamDialer(transport.FuncStreamDialer(sd.Dial), linkLocalDNS, remoteDNS)
sdForward, err := dnsintercept.NewDNSRedirectStreamDialer(transport.FuncStreamDialer(sd.Dial), linkLocalDNS, remoteDNS)
if err != nil {
return nil, fmt.Errorf("failed to create DNS redirect StreamDialer: %w", err)
}
Expand All @@ -60,11 +61,13 @@ func wrapTransportPairWithOutlineDNS(sd *Dialer[transport.StreamConn], pl *Packe
if err != nil {
return nil, fmt.Errorf("failed to create PacketProxy: %w", err)
}
ppForward, err := dnsintercept.WrapForwardPacketProxy(ppBase, linkLocalDNS, remoteDNS)
// 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)
if err != nil {
return nil, fmt.Errorf("failed to create DNS redirect PacketProxy: %w", err)
}
ppTrunc, err := dnsintercept.WrapTruncatePacketProxy(ppBase, linkLocalDNS)
// Forwards everything except DNS. For DNS it returns a truncated response.
ppTrunc, err := dnsintercept.NewDNSTruncatePacketProxy(ppBase, linkLocalDNS)
if err != nil {
return nil, fmt.Errorf("failed to create always-truncate DNS PacketProxy: %w", err)
}
Expand Down
134 changes: 134 additions & 0 deletions client/go/outline/dnsintercept/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# dnsintercept

This package intercepts DNS traffic inside the VPN tunnel and routes it reliably through the proxy, even when UDP is blocked by the network.

## Key abstractions

This package works in terms of three interfaces from the `golang.getoutline.org/sdk/network` package that model UDP packet flow through the proxy.

**`PacketProxy`** represents anything that can handle UDP sessions. It has a single method:

```go
NewSession(resp PacketResponseReceiver) (PacketRequestSender, error)
```

Calling `NewSession` tells the proxy that a new UDP flow has started. The caller supplies a `PacketResponseReceiver` (where incoming packets will be delivered) and gets back a `PacketRequestSender` (where it will send outgoing packets).

**`PacketRequestSender`** is the outbound half of a session — the handle the network stack uses to send packets *into* the proxy:

```go
WriteTo(p []byte, destination netip.AddrPort) (int, error)
Close() error
```

**`PacketResponseReceiver`** is the inbound half — a callback the proxy calls to deliver packets *back* to the network stack:

```go
WriteFrom(p []byte, source net.Addr) (int, error)
Close() error
```

Put together, a session looks like this:

```mermaid
sequenceDiagram
participant Stack as Network stack
participant Proxy as PacketProxy

Stack->>Proxy: NewSession(responseReceiver) → requestSender
loop per outgoing packet
Stack->>Proxy: requestSender.WriteTo(packet, dst)
end
loop per incoming packet
Proxy->>Stack: responseReceiver.WriteFrom(packet, src)
end
Stack->>Proxy: requestSender.Close()
Proxy->>Stack: responseReceiver.Close()
```

The two halves are independent: outgoing packets flow through `WriteTo`, incoming packets are pushed back via `WriteFrom`. Either side can close independently.

The wrappers in this package implement `PacketProxy` and intercept `WriteTo` / `WriteFrom` calls to rewrite addresses or generate synthetic responses, then delegate to an inner proxy for everything else.

## Background

When the Outline VPN is active, the OS is configured to send all DNS queries to a fake link-local address (`169.254.113.53:53`). This address is served by the VPN tunnel itself — no real server listens there. The `dnsintercept` package sits at the boundary between the OS and the proxy transport, intercepting those queries and handling them appropriately.

DNS can travel over both TCP and UDP:

- **TCP** is simple: queries always get through via the proxy's stream dialer.
- **UDP** is conditional: queries can be forwarded via UDP only if the proxy supports it. On some networks, UDP is blocked entirely.

## How UDP DNS is handled

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

### 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.

```mermaid
sequenceDiagram
participant OS
participant forwardPacketProxy
participant Transport
participant Resolver as Public DNS resolver

OS->>forwardPacketProxy: UDP query to 169.254.113.53:53
forwardPacketProxy->>Transport: UDP query to 1.1.1.1:53 (remapped)
Transport->>Resolver: query
Resolver->>Transport: response
Transport->>forwardPacketProxy: UDP response from 1.1.1.1:53
forwardPacketProxy->>OS: response from 169.254.113.53:53 (remapped back)
Note over forwardPacketProxy: session closed immediately after response
```

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.

### Truncate mode (UDP unavailable)

If UDP is blocked, forwarding silently fails and DNS stops working. To handle this, the package falls back to *truncate mode*: it responds immediately to every UDP DNS query with a [truncated DNS response](https://www.rfc-editor.org/rfc/rfc1035#section-4.1.1) (the TC bit set). This is a standard DNS signal telling the OS to retry the same query over TCP, which goes through the stream dialer and always works.

```mermaid
sequenceDiagram
participant OS
participant truncatePacketProxy
participant StreamDialer
participant Resolver as Public DNS resolver

OS->>truncatePacketProxy: UDP query to 169.254.113.53:53
truncatePacketProxy->>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.
Comment thread
fortuna marked this conversation as resolved.

```mermaid
flowchart LR
OS["OS (UDP traffic)"] --> ppMain
check["UDP connectivity check<br/>(on network change)"] -->|pass| ppMain
check -->|fail| ppMain

ppMain{{"DelegatePacketProxy<br/>(ppMain)"}}
ppMain -->|UDP available| ppForward["forwardPacketProxy<br/>(DNS → resolver via transport)"]
ppMain -->|UDP blocked| ppTrunc["truncatePacketProxy<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 |
Comment thread
fortuna marked this conversation as resolved.
| `helpers.go` | `isEquivalentAddrPort` — address comparison ignoring IPv4-in-IPv6 mapping |
91 changes: 57 additions & 34 deletions client/go/outline/dnsintercept/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,79 +19,102 @@ import (
"errors"
"net"
"net/netip"
"sync"

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

// WrapForwardStreamDialer creates a StreamDialer to intercept and redirect TCP based DNS connections.
// It intercepts all TCP connection for `localIP:53` and redirects them to `resolverAddr` via the `base` StreamDialer.
func WrapForwardStreamDialer(base transport.StreamDialer, localAddr, resolverAddr netip.AddrPort) (transport.StreamDialer, error) {
// NewDNSRedirectStreamDialer creates a StreamDialer to intercept and redirect TCP based DNS connections.
// It intercepts all TCP connection for `resolverLinkLocalAddr:53` and redirects them to `resolverRemoteAddr` via the `base` StreamDialer.
func NewDNSRedirectStreamDialer(base transport.StreamDialer, resolverLinkLocalAddr, resolverRemoteAddr netip.AddrPort) (transport.StreamDialer, error) {
if base == nil {
return nil, errors.New("base StreamDialer must be provided")
}
return transport.FuncStreamDialer(func(ctx context.Context, addr string) (transport.StreamConn, error) {
if dst, err := netip.ParseAddrPort(addr); err == nil && isEquivalentAddrPort(dst, localAddr) {
addr = resolverAddr.String()
return transport.FuncStreamDialer(func(ctx context.Context, targetAddr string) (transport.StreamConn, error) {
if dst, err := netip.ParseAddrPort(targetAddr); err == nil && isEquivalentAddrPort(dst, resolverLinkLocalAddr) {
targetAddr = resolverRemoteAddr.String()
}
return base.DialStream(ctx, addr)
return base.DialStream(ctx, targetAddr)
}), nil
}

// forwardPacketProxy wraps another PacketProxy to intercept and redirect DNS packets.
type forwardPacketProxy struct {
base network.PacketProxy
local, resolv netip.AddrPort
// dnsRedirectPacketProxy wraps another PacketProxy to intercept and redirect DNS packets.
type dnsRedirectPacketProxy struct {
base network.PacketProxy
resolverLinkLocalAddr, resolverRemoteAddr netip.AddrPort
}

type forwardPacketReqSender struct {
type dnsRedirectPacketReqSender struct {
network.PacketRequestSender
fpp *forwardPacketProxy
fpp *dnsRedirectPacketProxy
}

type forwardPacketRespReceiver struct {
// dnsRedirectPacketRespReceiver intercepts incoming packets from the remote DNS resolver.
// It remaps the source address from the remote resolver back to the local DNS address,
// and closes the underlying session after delivering the first DNS response to free the
// transport session immediately rather than waiting for the idle timeout.
type dnsRedirectPacketRespReceiver struct {
network.PacketResponseReceiver
fpp *forwardPacketProxy
fpp *dnsRedirectPacketProxy
once sync.Once // ensures the session is closed at most once
mu sync.Mutex // protects sender; required for Go memory model correctness
sender network.PacketRequestSender // the request sender to close after first DNS response
}

var _ network.PacketProxy = (*forwardPacketProxy)(nil)
var _ network.PacketProxy = (*dnsRedirectPacketProxy)(nil)

// WrapForwardPacketProxy creates a PacketProxy to intercept and redirect UDP based DNS packets.
// It intercepts all packets to `localAddr` and redirecrs them to `resolverAddr` via the `base` PacketProxy.
func WrapForwardPacketProxy(base network.PacketProxy, localAddr, resolverAddr netip.AddrPort) (network.PacketProxy, error) {
// NewDNSRedirectPacketProxy creates a PacketProxy to intercept and redirect UDP based DNS packets.
// It intercepts all packets to `resolverLinkLocalAddr` and redirecrs them to `resolverRemoteAddr` via the `base` PacketProxy.
Comment thread
fortuna marked this conversation as resolved.
Outdated
func NewDNSRedirectPacketProxy(base network.PacketProxy, resolverLinkLocalAddr, resolverRemoteAddr netip.AddrPort) (network.PacketProxy, error) {
if base == nil {
return nil, errors.New("base PacketProxy must be provided")
}
return &forwardPacketProxy{
base: base,
local: localAddr,
resolv: resolverAddr,
return &dnsRedirectPacketProxy{
base: base,
resolverLinkLocalAddr: resolverLinkLocalAddr,
resolverRemoteAddr: resolverRemoteAddr,
}, nil
}

// NewSession implements PacketProxy.NewSession.
func (fpp *forwardPacketProxy) NewSession(resp network.PacketResponseReceiver) (_ network.PacketRequestSender, err error) {
base, err := fpp.base.NewSession(&forwardPacketRespReceiver{resp, fpp})
func (fpp *dnsRedirectPacketProxy) NewSession(resp network.PacketResponseReceiver) (_ network.PacketRequestSender, err error) {
wrapper := &dnsRedirectPacketRespReceiver{PacketResponseReceiver: resp, fpp: fpp}
base, err := fpp.base.NewSession(wrapper)
if err != nil {
return nil, err
}
return &forwardPacketReqSender{base, fpp}, nil
wrapper.mu.Lock()
wrapper.sender = base
wrapper.mu.Unlock()
return &dnsRedirectPacketReqSender{base, fpp}, nil
}

// WriteTo intercepts outgoing DNS request packets.
// If a packet is destined for the local resolver, it remaps the destination to the remote resolver.
func (req *forwardPacketReqSender) WriteTo(p []byte, destination netip.AddrPort) (int, error) {
if isEquivalentAddrPort(destination, req.fpp.local) {
destination = req.fpp.resolv
func (req *dnsRedirectPacketReqSender) WriteTo(p []byte, destination netip.AddrPort) (int, error) {
if isEquivalentAddrPort(destination, req.fpp.resolverLinkLocalAddr) {
destination = req.fpp.resolverRemoteAddr
}
return req.PacketRequestSender.WriteTo(p, destination)
}

// ReadFrom intercepts incoming DNS response packets.
// If a packet is received from the remote resolver, it remaps the source address to be the local resolver.
func (resp *forwardPacketRespReceiver) WriteFrom(p []byte, source net.Addr) (int, error) {
if addr, ok := source.(*net.UDPAddr); ok && isEquivalentAddrPort(addr.AddrPort(), resp.fpp.resolv) {
source = net.UDPAddrFromAddrPort(resp.fpp.local)
// WriteFrom intercepts incoming DNS response packets.
// If a packet is received from the remote resolver, it remaps the source address to the local
// resolver and then closes the underlying session. DNS is one-shot (one query, one response),
// so closing immediately frees the transport session rather than holding it open until the 30-second
// write-idle timeout, preventing resource exhaustion under sustained DNS load.
func (resp *dnsRedirectPacketRespReceiver) WriteFrom(p []byte, source net.Addr) (int, error) {
if addr, ok := source.(*net.UDPAddr); ok && isEquivalentAddrPort(addr.AddrPort(), resp.fpp.resolverRemoteAddr) {
source = net.UDPAddrFromAddrPort(resp.fpp.resolverLinkLocalAddr)
n, err := resp.PacketResponseReceiver.WriteFrom(p, source)
resp.once.Do(func() {
resp.mu.Lock()
s := resp.sender
resp.mu.Unlock()
s.Close()
})
Comment on lines +107 to +116
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

In dnsRedirectPacketRespReceiver.WriteFrom, resp.sender can still be nil (e.g., if the underlying PacketProxy starts delivering packets before NewSession assigns wrapper.sender). In that case s.Close() will panic. Consider ensuring sender is set before any callbacks can run (or make the close path tolerate nil / defer closing until sender becomes available).

Copilot uses AI. Check for mistakes.
return n, err
}
return resp.PacketResponseReceiver.WriteFrom(p, source)
}
41 changes: 36 additions & 5 deletions client/go/outline/dnsintercept/forward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import (
"net/netip"
"testing"

"github.com/stretchr/testify/require"
"golang.getoutline.org/sdk/network"
"golang.getoutline.org/sdk/transport"
"github.com/stretchr/testify/require"
)

// ----- forward StreamDialer tests -----
Expand All @@ -43,10 +43,10 @@ func TestWrapForwardStreamDialer(t *testing.T) {
local := netip.MustParseAddrPort("192.0.2.1:53")
resolver := netip.MustParseAddrPort("8.8.8.8:53")

_, err := WrapForwardStreamDialer(nil, local, resolver)
_, err := NewDNSRedirectStreamDialer(nil, local, resolver)
require.Error(t, err)

dialer, err := WrapForwardStreamDialer(sd, local, resolver)
dialer, err := NewDNSRedirectStreamDialer(sd, local, resolver)
require.NoError(t, err)

_, err = dialer.DialStream(context.TODO(), "192.0.2.1:53")
Expand Down Expand Up @@ -112,10 +112,10 @@ func TestWrapForwardPacketProxy(t *testing.T) {
nonResolver := netip.MustParseAddrPort("203.0.113.10:123")
nonResolverUDPAddr := net.UDPAddrFromAddrPort(nonResolver)

_, err := WrapForwardPacketProxy(nil, local, resolver)
_, err := NewDNSRedirectPacketProxy(nil, local, resolver)
require.Error(t, err)

fpp, err := WrapForwardPacketProxy(pp, local, resolver)
fpp, err := NewDNSRedirectPacketProxy(pp, local, resolver)
require.NoError(t, err)

req, err := fpp.NewSession(resp)
Expand All @@ -137,11 +137,42 @@ func TestWrapForwardPacketProxy(t *testing.T) {
require.Equal(t, 8, n)
require.Equal(t, net.UDPAddrFromAddrPort(local), resp.lastSrc)

// After the first DNS response, the underlying session must be closed immediately
// to free the transport session instead of waiting for the write-idle timeout.
require.True(t, pp.req.closed, "session must be closed after first DNS response")

n, err = pp.resp.WriteFrom([]byte("response"), nonResolverUDPAddr)
require.NoError(t, err)
require.Equal(t, 8, n)
require.Equal(t, nonResolverUDPAddr, resp.lastSrc)

// Explicit Close must be safe even though the session was already closed.
require.NoError(t, req.Close())
}

// TestWrapForwardPacketProxy_NonDNSResponseDoesNotClose verifies that a non-DNS
// response (not from the resolver) does not trigger an early session close.
func TestWrapForwardPacketProxy_NonDNSResponseDoesNotClose(t *testing.T) {
pp := &packetProxyWithGivenRequestSender{req: &lastDestPacketRequestSender{}}
resp := &lastSourcePacketResponseReceiver{}

local := netip.MustParseAddrPort("192.0.2.2:53")
resolver := netip.MustParseAddrPort("8.8.4.4:53")
nonResolver := netip.MustParseAddrPort("203.0.113.10:123")
nonResolverUDPAddr := net.UDPAddrFromAddrPort(nonResolver)

fpp, err := NewDNSRedirectPacketProxy(pp, local, resolver)
require.NoError(t, err)

req, err := fpp.NewSession(resp)
require.NoError(t, err)

n, err := pp.resp.WriteFrom([]byte("response"), nonResolverUDPAddr)
require.NoError(t, err)
require.Equal(t, 8, n)

require.False(t, pp.req.closed, "session must not be closed for non-DNS responses")

require.NoError(t, req.Close())
require.True(t, pp.req.closed)
}
Loading
Loading