-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(client): prevent resource exhaustion from long-lived DNS sessions #2724
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
068ada7
9d5a99b
09e7489
6ae5294
8aa7aa4
3746475
02e6ca2
cef0ee5
06469cc
7ba1e1f
6821c9c
c67399d
881eaa8
5918fee
afb8aaf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| ```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 | | ||
|
fortuna marked this conversation as resolved.
|
||
| | `helpers.go` | `isEquivalentAddrPort` — address comparison ignoring IPv4-in-IPv6 mapping | | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
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
|
||
| return n, err | ||
| } | ||
| return resp.PacketResponseReceiver.WriteFrom(p, source) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.