diff --git a/client/go/outline/client.go b/client/go/outline/client.go
index 84dfad2a31..6aca5fa3a0 100644
--- a/client/go/outline/client.go
+++ b/client/go/outline/client.go
@@ -27,7 +27,7 @@ import (
"localhost/client/go/outline/configregistry"
"localhost/client/go/outline/platerrors"
"localhost/client/go/outline/reporting"
- "golang.getoutline.org/sdk/network"
+ "golang.getoutline.org/sdk/network/packetrelay"
"golang.getoutline.org/sdk/transport"
"github.com/goccy/go-yaml"
)
@@ -37,12 +37,12 @@ import (
// It's used by the connectivity test and the tun2socks handlers.
// TODO(fortuna):
// - Add connectivity test to StartSession()
-// - Add NotifyNetworkChange() method. Needs to hold a network.PacketProxy instead of configregistry.PacketListener
+// - Add NotifyNetworkChange() method. Needs to hold a packetrelay.PacketRelay instead of configregistry.PacketListener
// to handle that.
// - Refactor so that StartSession returns a Client
type Client struct {
sd *configregistry.Dialer[transport.StreamConn]
- pp *configregistry.PacketProxy
+ pr *configregistry.PacketRelay
reporter reporting.Reporter
sessionCancel context.CancelFunc
}
@@ -52,14 +52,14 @@ func (c *Client) DialStream(ctx context.Context, address string) (transport.Stre
return c.sd.Dial(ctx, address)
}
-// NewSession implements PacketProxy.NewSession.
-func (c *Client) NewSession(resp network.PacketResponseReceiver) (network.PacketRequestSender, error) {
- return c.pp.NewSession(resp)
+// NewAssociation implements packetrelay.PacketRelay.NewAssociation.
+func (c *Client) NewAssociation() (packetrelay.PacketSender, packetrelay.PacketReceiver, error) {
+ return c.pr.NewAssociation()
}
func (c *Client) NotifyNetworkChanged() {
- if c.pp.NotifyNetworkChanged != nil {
- c.pp.NotifyNetworkChanged()
+ if c.pr.NotifyNetworkChanged != nil {
+ c.pr.NotifyNetworkChanged()
}
}
@@ -155,7 +155,7 @@ func (c *ClientConfig) new(keyID string, providerClientConfigText string) (*Clie
}
}
- client := &Client{sd: transportPair.StreamDialer, pp: transportPair.PacketProxy}
+ client := &Client{sd: transportPair.StreamDialer, pr: transportPair.PacketRelay}
// TODO: figure out a better way to handle parse calls.
if providerClientConfig.Reporter != nil {
diff --git a/client/go/outline/client_test.go b/client/go/outline/client_test.go
index e20fab9abb..2989ab01a9 100644
--- a/client/go/outline/client_test.go
+++ b/client/go/outline/client_test.go
@@ -35,7 +35,7 @@ func Test_NewTransport_SS_URL(t *testing.T) {
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_Legacy_JSON(t *testing.T) {
@@ -51,7 +51,7 @@ transport: {
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_Flexible_JSON(t *testing.T) {
@@ -68,7 +68,7 @@ transport: {
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_YAML(t *testing.T) {
@@ -84,7 +84,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_Explicit_endpoint(t *testing.T) {
@@ -100,7 +100,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_Multihop_URL(t *testing.T) {
@@ -117,7 +117,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_Multihop_Explicit(t *testing.T) {
@@ -138,7 +138,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_Explicit_TCPUDP(t *testing.T) {
@@ -160,7 +160,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, "example.com:80", result.Client.sd.FirstHop)
- require.Equal(t, "example.com:53", result.Client.pp.FirstHop)
+ require.Equal(t, "example.com:53", result.Client.pr.FirstHop)
}
func Test_NewTransport_YAML_Reuse(t *testing.T) {
@@ -180,7 +180,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_YAML_Partial_Reuse(t *testing.T) {
@@ -202,7 +202,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, "example.com:80", result.Client.sd.FirstHop)
- require.Equal(t, "example.com:53", result.Client.pp.FirstHop)
+ require.Equal(t, "example.com:53", result.Client.pr.FirstHop)
}
func Test_NewTransport_Unsupported(t *testing.T) {
@@ -233,7 +233,7 @@ transport:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
- require.Equal(t, firstHop, result.Client.pp.FirstHop)
+ require.Equal(t, firstHop, result.Client.pr.FirstHop)
}
func Test_NewTransport_AllowProxyless(t *testing.T) {
@@ -246,7 +246,7 @@ transport:
require.Nil(t, result.Error, "Got %v", result.Error)
require.NotNil(t, result.Client)
require.Equal(t, configregistry.ConnTypeDirect, result.Client.sd.ConnType)
- require.Equal(t, configregistry.ConnTypeDirect, result.Client.pp.ConnType)
+ require.Equal(t, configregistry.ConnTypeDirect, result.Client.pr.ConnType)
}
func Test_NewClientFromJSON_Errors(t *testing.T) {
@@ -334,7 +334,7 @@ reporter:
result := (&ClientConfig{}).New("", config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, "example.com:80", result.Client.sd.FirstHop)
- require.Equal(t, "example.com:53", result.Client.pp.FirstHop)
+ require.Equal(t, "example.com:53", result.Client.pr.FirstHop)
require.NotNil(t, result.Client.reporter, "Reporter is nil")
request, err := result.Client.reporter.(*reporting.HTTPReporter).NewRequest()
require.NoError(t, err)
diff --git a/client/go/outline/configregistry/config_proxyless.go b/client/go/outline/configregistry/config_proxyless.go
index 0b1b38a8e7..b689c2b34b 100644
--- a/client/go/outline/configregistry/config_proxyless.go
+++ b/client/go/outline/configregistry/config_proxyless.go
@@ -19,10 +19,9 @@ import (
"fmt"
"math/rand"
- "localhost/client/go/configyaml"
- "golang.getoutline.org/sdk/network"
"golang.getoutline.org/sdk/transport"
"golang.getoutline.org/sdk/transport/tlsfrag"
+ "localhost/client/go/configyaml"
)
const (
@@ -57,22 +56,16 @@ func parseProxylessTransportPair(ctx context.Context, configMap map[string]any,
splitLength := randomSplitLength()
- sd, err := tlsfrag.NewFixedLenStreamDialer(&transport.TCPDialer{}, splitLength)
+ fragSD, err := tlsfrag.NewFixedLenStreamDialer(&transport.TCPDialer{}, splitLength)
if err != nil {
return nil, fmt.Errorf("failed to create StreamDialer: %w", err)
}
pl := &PacketListener{ConnectionProviderInfo{ConnTypeDirect, ""}, &transport.UDPListener{}}
- pp, err := network.NewPacketProxyFromPacketListener(pl)
- if err != nil {
- return nil, fmt.Errorf("failed to create PacketProxy: %w", err)
+ sd := &Dialer[transport.StreamConn]{
+ ConnectionProviderInfo: ConnectionProviderInfo{ConnType: ConnTypeDirect},
+ Dial: fragSD.DialStream,
}
- return &TransportPair{
- StreamDialer: &Dialer[transport.StreamConn]{
- ConnectionProviderInfo: ConnectionProviderInfo{ConnType: ConnTypeDirect},
- Dial: sd.DialStream,
- },
- PacketProxy: &PacketProxy{ConnectionProviderInfo{ConnTypeDirect, ""}, pp, nil},
- }, nil
+ return wrapTransportPairWithOutlineDNS(sd, pl)
}
diff --git a/client/go/outline/configregistry/config_proxyless_test.go b/client/go/outline/configregistry/config_proxyless_test.go
index 2ea553f10b..882edb8492 100644
--- a/client/go/outline/configregistry/config_proxyless_test.go
+++ b/client/go/outline/configregistry/config_proxyless_test.go
@@ -18,8 +18,8 @@ import (
"context"
"testing"
- "localhost/client/go/configyaml"
"github.com/stretchr/testify/require"
+ "localhost/client/go/configyaml"
)
func TestParseProxyless(t *testing.T) {
@@ -32,7 +32,7 @@ func TestParseProxyless(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, transportPair)
require.NotNil(t, transportPair.StreamDialer)
- require.NotNil(t, transportPair.PacketProxy)
+ require.NotNil(t, transportPair.PacketRelay)
require.Equal(t, ConnTypeDirect, transportPair.StreamDialer.ConnType)
- require.Equal(t, ConnTypeDirect, transportPair.PacketProxy.ConnType)
+ require.Equal(t, ConnTypeDirect, transportPair.PacketRelay.ConnType)
}
diff --git a/client/go/outline/configregistry/outline_dns_intercept.go b/client/go/outline/configregistry/outline_dns_intercept.go
index ae1974dae4..b92dd5cc81 100644
--- a/client/go/outline/configregistry/outline_dns_intercept.go
+++ b/client/go/outline/configregistry/outline_dns_intercept.go
@@ -15,15 +15,18 @@
package configregistry
import (
+ "context"
"fmt"
"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/dnsintercept"
+ "golang.getoutline.org/sdk/network/dnstruncate"
+ "golang.getoutline.org/sdk/network/packetrelay"
"golang.getoutline.org/sdk/transport"
)
@@ -50,46 +53,52 @@ func wrapTransportPairWithOutlineDNS(sd *Dialer[transport.StreamConn], pl *Packe
// Randomly selects a DNS resolver for the VPN session
remoteDNS := outlineDNSResolvers[rand.IntN(len(outlineDNSResolvers))]
- // Intercept DNS for StreamDialer
- 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)
+ // Intercept DNS for StreamDialer: remap TCP connections to linkLocalDNS → remoteDNS.
+ sdForward := func(ctx context.Context, addr string) (transport.StreamConn, error) {
+ if dst, err := netip.ParseAddrPort(addr); err == nil && dst.Addr().Unmap() == linkLocalDNS.Addr() && dst.Port() == linkLocalDNS.Port() {
+ addr = remoteDNS.String()
+ }
+ return sd.Dial(ctx, addr)
}
- // Intercept DNS for PacketProxy
- ppBase, err := network.NewPacketProxyFromPacketListener(pl)
+ baseListener, err := packetrelay.NewPacketRelayFromPacketListener(pl.PacketListener, 30*time.Second)
if err != nil {
- return nil, fmt.Errorf("failed to create PacketProxy: %w", err)
+ return nil, fmt.Errorf("failed to create base PacketRelay: %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)
+ // Forward relay: intercept DNS at link-local address, forward to remote resolver.
+ // DNS gets a shorter 5s timeout on its own independent listener.
+ dnsListener, err := packetrelay.NewPacketRelayFromPacketListener(pl.PacketListener, 5*time.Second)
if err != nil {
- return nil, fmt.Errorf("failed to create DNS redirect PacketProxy: %w", err)
+ return nil, fmt.Errorf("failed to create DNS PacketRelay: %w", err)
}
- // Forwards everything except DNS. For DNS it returns a truncated response.
- ppTrunc, err := dnsintercept.NewDNSTruncatePacketProxy(ppBase, linkLocalDNS)
+ relayForward := dnsintercept.NewInterceptDNSPacketRelay(dnsListener, baseListener, linkLocalDNS, remoteDNS)
+ // Truncate relay: intercept DNS at link-local address, return truncated response (forces TCP retry).
+ // Non-DNS traffic passes through to baseListener.
+ dnsTruncRelay, err := dnstruncate.NewPacketRelay()
if err != nil {
- return nil, fmt.Errorf("failed to create always-truncate DNS PacketProxy: %w", err)
+ return nil, fmt.Errorf("failed to create DNS truncate relay: %w", err)
}
- ppMain, err := network.NewDelegatePacketProxy(ppTrunc)
+ relayTrunc := dnsintercept.NewInterceptDNSPacketRelay(dnsTruncRelay, baseListener, linkLocalDNS, remoteDNS)
+ // Delegate relay starts with truncate (UDP unverified), switches to forward when UDP is healthy.
+ relayMain, err := packetrelay.NewDelegatePacketRelay(relayTrunc)
if err != nil {
- return nil, fmt.Errorf("failed to create indirect PacketProxy: %w", err)
+ return nil, fmt.Errorf("failed to create delegate PacketRelay: %w", err)
}
onNetworkChanged := func() {
go func() {
if err := connectivity.CheckUDPConnectivity(pl); err == nil {
slog.Info("remote device UDP is healthy")
- ppMain.SetProxy(ppForward)
+ relayMain.SetRelay(relayForward)
} else {
slog.Warn("remote device UDP is not healthy", "err", err)
- ppMain.SetProxy(ppTrunc)
+ relayMain.SetRelay(relayTrunc)
}
}()
}
return &TransportPair{
- &Dialer[transport.StreamConn]{sd.ConnectionProviderInfo, sdForward.DialStream},
- &PacketProxy{pl.ConnectionProviderInfo, ppMain, onNetworkChanged},
+ &Dialer[transport.StreamConn]{sd.ConnectionProviderInfo, sdForward},
+ &PacketRelay{pl.ConnectionProviderInfo, relayMain, onNetworkChanged},
}, nil
}
diff --git a/client/go/outline/configregistry/registry_test.go b/client/go/outline/configregistry/registry_test.go
index f2b603d60c..f2f3db2e6c 100644
--- a/client/go/outline/configregistry/registry_test.go
+++ b/client/go/outline/configregistry/registry_test.go
@@ -48,11 +48,11 @@ udp: *shared`)
require.NoError(t, err)
require.NotNil(t, d.StreamDialer)
- require.NotNil(t, d.PacketProxy)
+ require.NotNil(t, d.PacketRelay)
require.Equal(t, "example.com:1234", d.StreamDialer.FirstHop)
require.Equal(t, ConnTypeTunneled, d.StreamDialer.ConnType)
- require.Equal(t, "example.com:1234", d.PacketProxy.FirstHop)
- require.Equal(t, ConnTypeTunneled, d.PacketProxy.ConnType)
+ require.Equal(t, "example.com:1234", d.PacketRelay.FirstHop)
+ require.Equal(t, ConnTypeTunneled, d.PacketRelay.ConnType)
}
func TestRegisterParseURL(t *testing.T) {
@@ -65,11 +65,11 @@ func TestRegisterParseURL(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, d.StreamDialer)
- require.NotNil(t, d.PacketProxy)
+ require.NotNil(t, d.PacketRelay)
require.Equal(t, "example.com:4321", d.StreamDialer.FirstHop)
require.Equal(t, ConnTypeTunneled, d.StreamDialer.ConnType)
- require.Equal(t, "example.com:4321", d.PacketProxy.FirstHop)
- require.Equal(t, ConnTypeTunneled, d.PacketProxy.ConnType)
+ require.Equal(t, "example.com:4321", d.PacketRelay.FirstHop)
+ require.Equal(t, ConnTypeTunneled, d.PacketRelay.ConnType)
}
func TestRegisterParseURLInQuotes(t *testing.T) {
@@ -82,11 +82,11 @@ func TestRegisterParseURLInQuotes(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, d.StreamDialer)
- require.NotNil(t, d.PacketProxy)
+ require.NotNil(t, d.PacketRelay)
require.Equal(t, "example.com:4321", d.StreamDialer.FirstHop)
require.Equal(t, ConnTypeTunneled, d.StreamDialer.ConnType)
- require.Equal(t, "example.com:4321", d.PacketProxy.FirstHop)
- require.Equal(t, ConnTypeTunneled, d.PacketProxy.ConnType)
+ require.Equal(t, "example.com:4321", d.PacketRelay.FirstHop)
+ require.Equal(t, ConnTypeTunneled, d.PacketRelay.ConnType)
}
type errorStreamDialer struct {
diff --git a/client/go/outline/configregistry/types.go b/client/go/outline/configregistry/types.go
index 590df8b8ae..44585ccdd6 100644
--- a/client/go/outline/configregistry/types.go
+++ b/client/go/outline/configregistry/types.go
@@ -18,7 +18,7 @@ import (
"context"
"encoding/json"
- "golang.getoutline.org/sdk/network"
+ "golang.getoutline.org/sdk/network/packetrelay"
"golang.getoutline.org/sdk/transport"
)
@@ -72,10 +72,10 @@ type PacketListener struct {
transport.PacketListener
}
-// PacketProxy is a [network.PacketProxy] with embedded ConnectionProviderInfo.
-type PacketProxy struct {
+// PacketRelay is a [packetrelay.PacketRelay] with embedded ConnectionProviderInfo.
+type PacketRelay struct {
ConnectionProviderInfo
- network.PacketProxy
+ packetrelay.PacketRelay
NotifyNetworkChanged func()
}
@@ -102,7 +102,7 @@ type Endpoint[ConnType any] struct {
// TransportPair provides a StreamDialer and PacketListener, to use as the transport in a Tun2Socks VPN.
type TransportPair struct {
StreamDialer *Dialer[transport.StreamConn]
- PacketProxy *PacketProxy
+ PacketRelay *PacketRelay
}
var _ transport.StreamDialer = (*TransportPair)(nil)
diff --git a/client/go/outline/dnsintercept/README.md b/client/go/outline/dnsintercept/README.md
deleted file mode 100644
index 1720349d4a..0000000000
--- a/client/go/outline/dnsintercept/README.md
+++ /dev/null
@@ -1,134 +0,0 @@
-# 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 dnsRedirectPacketProxy
- participant Transport
- participant Resolver as Public DNS resolver
-
- OS->>dnsRedirectPacketProxy: UDP query to 169.254.113.53:53
- dnsRedirectPacketProxy->>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
-```
-
-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 dnsTruncatePacketProxy
- 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
- 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
(on network change)"] -->|pass| ppMain
- check -->|fail| ppMain
-
- ppMain{{"DelegatePacketProxy
(ppMain)"}}
- ppMain -->|UDP available| ppForward["dnsRedirectPacketProxy
(DNS → resolver via transport)"]
- ppMain -->|UDP blocked| ppTrunc["dnsTruncatePacketProxy
(DNS → TC response locally)"]
-
- ppForward --> ppBase["base PacketProxy
(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 |
diff --git a/client/go/outline/dnsintercept/forward.go b/client/go/outline/dnsintercept/forward.go
deleted file mode 100644
index a80d4c6235..0000000000
--- a/client/go/outline/dnsintercept/forward.go
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright 2025 The Outline Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package dnsintercept
-
-import (
- "context"
- "errors"
- "net"
- "net/netip"
- "sync"
-
- "golang.getoutline.org/sdk/network"
- "golang.getoutline.org/sdk/transport"
-)
-
-// 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, targetAddr string) (transport.StreamConn, error) {
- if dst, err := netip.ParseAddrPort(targetAddr); err == nil && isEquivalentAddrPort(dst, resolverLinkLocalAddr) {
- targetAddr = resolverRemoteAddr.String()
- }
- return base.DialStream(ctx, targetAddr)
- }), nil
-}
-
-// dnsRedirectPacketProxy wraps another PacketProxy to intercept and redirect DNS packets.
-type dnsRedirectPacketProxy struct {
- baseProxy network.PacketProxy
- resolverLinkLocalAddr, resolverRemoteAddr netip.AddrPort
-}
-
-type dnsRedirectPacketReqSender struct {
- network.PacketRequestSender
- fpp *dnsRedirectPacketProxy
-}
-
-// 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 *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 = (*dnsRedirectPacketProxy)(nil)
-
-// NewDNSRedirectPacketProxy creates a PacketProxy to intercept and redirect UDP based DNS packets.
-// It intercepts all packets to `resolverLinkLocalAddr` and redirects them to `resolverRemoteAddr` via the `base` PacketProxy.
-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 &dnsRedirectPacketProxy{
- baseProxy: base,
- resolverLinkLocalAddr: resolverLinkLocalAddr,
- resolverRemoteAddr: resolverRemoteAddr,
- }, nil
-}
-
-// NewSession implements PacketProxy.NewSession.
-func (fpp *dnsRedirectPacketProxy) NewSession(resp network.PacketResponseReceiver) (_ network.PacketRequestSender, err error) {
- wrapper := &dnsRedirectPacketRespReceiver{PacketResponseReceiver: resp, fpp: fpp}
- baseSender, err := fpp.baseProxy.NewSession(wrapper)
- if err != nil {
- return nil, err
- }
- wrapper.mu.Lock()
- wrapper.sender = baseSender
- wrapper.mu.Unlock()
- return &dnsRedirectPacketReqSender{baseSender, 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 *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)
-}
-
-// 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()
- })
- return n, err
- }
- return resp.PacketResponseReceiver.WriteFrom(p, source)
-}
diff --git a/client/go/outline/dnsintercept/forward_test.go b/client/go/outline/dnsintercept/forward_test.go
deleted file mode 100644
index 9e7a2bf683..0000000000
--- a/client/go/outline/dnsintercept/forward_test.go
+++ /dev/null
@@ -1,178 +0,0 @@
-// Copyright 2025 The Outline Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package dnsintercept
-
-import (
- "context"
- "errors"
- "net"
- "net/netip"
- "testing"
-
- "github.com/stretchr/testify/require"
- "golang.getoutline.org/sdk/network"
- "golang.getoutline.org/sdk/transport"
-)
-
-// ----- forward StreamDialer tests -----
-
-type lastAddrStreamDialer struct {
- transport.StreamDialer
- dialedAddr string
-}
-
-func (d *lastAddrStreamDialer) DialStream(ctx context.Context, addr string) (transport.StreamConn, error) {
- d.dialedAddr = addr
- return nil, errors.New("not used in test")
-}
-
-func TestWrapForwardStreamDialer(t *testing.T) {
- sd := &lastAddrStreamDialer{}
- local := netip.MustParseAddrPort("192.0.2.1:53")
- resolver := netip.MustParseAddrPort("8.8.8.8:53")
-
- _, err := NewDNSRedirectStreamDialer(nil, local, resolver)
- require.Error(t, err)
-
- dialer, err := NewDNSRedirectStreamDialer(sd, local, resolver)
- require.NoError(t, err)
-
- _, err = dialer.DialStream(context.TODO(), "192.0.2.1:53")
- require.Error(t, err)
- require.Equal(t, "8.8.8.8:53", sd.dialedAddr)
-
- _, err = dialer.DialStream(context.TODO(), "198.51.100.1:443")
- require.Error(t, err)
- require.Equal(t, "198.51.100.1:443", sd.dialedAddr)
-}
-
-// ----- forward PacketProxy tests -----
-
-type packetProxyWithGivenRequestSender struct {
- network.PacketProxy
- req *lastDestPacketRequestSender
- resp network.PacketResponseReceiver
-}
-
-func (p *packetProxyWithGivenRequestSender) NewSession(resp network.PacketResponseReceiver) (network.PacketRequestSender, error) {
- p.resp = resp
- return p.req, nil
-}
-
-type lastDestPacketRequestSender struct {
- lastDst netip.AddrPort
- closed bool
-}
-
-func (s *lastDestPacketRequestSender) WriteTo(p []byte, destination netip.AddrPort) (int, error) {
- s.lastDst = destination
- return len(p), nil
-}
-
-func (s *lastDestPacketRequestSender) Close() error {
- s.closed = true
- return nil
-}
-
-type lastSourcePacketResponseReceiver struct {
- lastSrc net.Addr
- lastPacket []byte
-}
-
-func (r *lastSourcePacketResponseReceiver) WriteFrom(p []byte, source net.Addr) (int, error) {
- r.lastSrc = source
- r.lastPacket = make([]byte, len(p))
- copy(r.lastPacket, p)
- return len(p), nil
-}
-
-func (r *lastSourcePacketResponseReceiver) Close() error {
- return nil
-}
-
-func TestWrapForwardPacketProxy(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")
- resolverUDPAddr := net.UDPAddrFromAddrPort(resolver)
- nonResolver := netip.MustParseAddrPort("203.0.113.10:123")
- nonResolverUDPAddr := net.UDPAddrFromAddrPort(nonResolver)
-
- _, err := NewDNSRedirectPacketProxy(nil, local, resolver)
- require.Error(t, err)
-
- fpp, err := NewDNSRedirectPacketProxy(pp, local, resolver)
- require.NoError(t, err)
-
- req, err := fpp.NewSession(resp)
- require.NoError(t, err)
-
- n, err := req.WriteTo([]byte("request"), local)
- require.NoError(t, err)
- require.Equal(t, 7, n)
- require.Equal(t, resolver, pp.req.lastDst)
-
- n, err = req.WriteTo([]byte("request"), nonResolver)
- require.NoError(t, err)
- require.Equal(t, 7, n)
- require.Equal(t, nonResolver, pp.req.lastDst)
-
- require.NotNil(t, pp.resp)
- n, err = pp.resp.WriteFrom([]byte("response"), resolverUDPAddr)
- require.NoError(t, err)
- 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)
-}
diff --git a/client/go/outline/dnsintercept/helpers.go b/client/go/outline/dnsintercept/helpers.go
deleted file mode 100644
index 8d12e35dfb..0000000000
--- a/client/go/outline/dnsintercept/helpers.go
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright 2025 The Outline Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package dnsintercept
-
-import "net/netip"
-
-func isEquivalentAddrPort(addr1, addr2 netip.AddrPort) bool {
- return addr1.Addr().Unmap() == addr2.Addr().Unmap() && addr1.Port() == addr2.Port()
-}
diff --git a/client/go/outline/dnsintercept/truncate.go b/client/go/outline/dnsintercept/truncate.go
deleted file mode 100644
index d360363211..0000000000
--- a/client/go/outline/dnsintercept/truncate.go
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright 2025 The Outline Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package dnsintercept
-
-import (
- "errors"
- "fmt"
- "net/netip"
- "sync"
-
- "golang.getoutline.org/sdk/network"
- "golang.getoutline.org/sdk/network/dnstruncate"
-)
-
-type dnsTruncatePacketProxy struct {
- network.PacketProxy
- truncate53PP network.PacketProxy
- resolverLinkLocalAddr netip.AddrPort
-}
-
-// dnsTruncatePacketReqSender handles packet routing for truncate sessions.
-//
-// DNS packets (destined for local) are handled by trunc and never touch the
-// base proxy. The base session is created lazily on the first non-DNS packet,
-// avoiding a wasted transport session for DNS-only flows.
-type dnsTruncatePacketReqSender struct {
- mu sync.Mutex
- baseSender network.PacketRequestSender // nil until first non-DNS packet; guarded by mu
- baseProxy network.PacketProxy // used to lazily create base
- respReceiver network.PacketResponseReceiver // passed to base when it is created
- truncate53PP network.PacketRequestSender // handles DNS packets locally without a transport session
- resolverLinkLocalAddr netip.AddrPort // the DNS address to intercept
-}
-
-// NewDNSTruncatePacketProxy creates a PacketProxy to intercept UDP-based DNS packets and force a TCP retry.
-//
-// It intercepts all packets to `resolverLinkLocalAddr` and returns an immediate truncated response,
-// prompting the OS to retry the query over TCP.
-//
-// All other UDP packets are passed through to the `base` PacketProxy.
-func NewDNSTruncatePacketProxy(base network.PacketProxy, resolverLinkLocalAddr netip.AddrPort) (network.PacketProxy, error) {
- if base == nil {
- return nil, errors.New("base PacketProxy must be provided")
- }
- // Returns truncated responses for *all* traffic on port 53.
- truncate53PP, err := dnstruncate.NewPacketProxy()
- if err != nil {
- return nil, fmt.Errorf("failed to create the underlying DNS truncate PacketProxy: %w", err)
- }
- return &dnsTruncatePacketProxy{
- PacketProxy: base,
- truncate53PP: truncate53PP,
- resolverLinkLocalAddr: resolverLinkLocalAddr,
- }, nil
-}
-
-// NewSession implements PacketProxy.NewSession.
-//
-// Only the trunc session is created eagerly. The base session is deferred
-// until the first non-DNS packet arrives.
-func (tpp *dnsTruncatePacketProxy) NewSession(respReceiver network.PacketResponseReceiver) (_ network.PacketRequestSender, err error) {
- trunc, err := tpp.truncate53PP.NewSession(respReceiver)
- if err != nil {
- return nil, err
- }
- return &dnsTruncatePacketReqSender{
- baseProxy: tpp.PacketProxy,
- respReceiver: respReceiver,
- truncate53PP: trunc,
- resolverLinkLocalAddr: tpp.resolverLinkLocalAddr,
- }, nil
-}
-
-// WriteTo checks if the packet is a DNS query to the local intercept address.
-// If so, it truncates the packet. Otherwise, it passes it to the base proxy,
-// creating the base session on demand if this is the first non-DNS packet.
-func (req *dnsTruncatePacketReqSender) WriteTo(p []byte, destination netip.AddrPort) (int, error) {
- if isEquivalentAddrPort(destination, req.resolverLinkLocalAddr) {
- return req.truncate53PP.WriteTo(p, destination)
- }
- req.mu.Lock()
- if req.baseSender == nil {
- base, err := req.baseProxy.NewSession(req.respReceiver)
- if err != nil {
- req.mu.Unlock()
- return 0, err
- }
- req.baseSender = base
- }
- sender := req.baseSender
- req.mu.Unlock()
- return sender.WriteTo(p, destination)
-}
-
-// Close ensures all underlying PacketRequestSenders are closed properly.
-func (req *dnsTruncatePacketReqSender) Close() (err error) {
- req.mu.Lock()
- defer req.mu.Unlock()
- if req.baseSender != nil {
- err = req.baseSender.Close()
- }
- return errors.Join(err, req.truncate53PP.Close())
-}
diff --git a/client/go/outline/dnsintercept/truncate_test.go b/client/go/outline/dnsintercept/truncate_test.go
deleted file mode 100644
index fe67971a6f..0000000000
--- a/client/go/outline/dnsintercept/truncate_test.go
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright 2025 The Outline Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package dnsintercept
-
-import (
- "net/netip"
- "testing"
-
- "golang.org/x/net/dns/dnsmessage"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestWrapTruncatePacketProxy(t *testing.T) {
- pp := &packetProxyWithGivenRequestSender{req: &lastDestPacketRequestSender{}}
- resp := &lastSourcePacketResponseReceiver{}
-
- local := netip.MustParseAddrPort("192.0.2.2:53")
- udpAddr := netip.MustParseAddrPort("203.0.113.10:123")
-
- _, err := NewDNSTruncatePacketProxy(nil, local)
- require.Error(t, err)
-
- tpp, err := NewDNSTruncatePacketProxy(pp, local)
- require.NoError(t, err)
-
- req, err := tpp.NewSession(resp)
- require.NoError(t, err)
-
- msg := dnsmessage.Message{
- Header: dnsmessage.Header{ID: 1234},
- Questions: []dnsmessage.Question{{
- Name: dnsmessage.MustNewName("example.com."),
- Type: dnsmessage.TypeA,
- Class: dnsmessage.ClassINET,
- }},
- }
- query, err := msg.Pack()
- require.NoError(t, err)
-
- _, err = req.WriteTo(query, local)
- require.NoError(t, err)
- require.NotNil(t, resp.lastPacket)
-
- var p dnsmessage.Parser
- header, err := p.Start(resp.lastPacket)
- require.NoError(t, err)
- require.True(t, header.Response)
- require.True(t, header.Truncated)
-
- _, err = req.WriteTo([]byte("not-a-dns-packet"), udpAddr)
- require.NoError(t, err)
- require.Equal(t, udpAddr, pp.req.lastDst)
-
- require.NoError(t, req.Close())
- require.True(t, pp.req.closed)
-}
-
-// TestWrapTruncatePacketProxy_DNSOnlyDoesNotCreateBaseSession verifies that a session
-// that only sends DNS packets never allocates a base transport session, which
-// prevents resource exhaustion under sustained DNS load.
-func TestWrapTruncatePacketProxy_DNSOnlyDoesNotCreateBaseSession(t *testing.T) {
- pp := &packetProxyWithGivenRequestSender{req: &lastDestPacketRequestSender{}}
- resp := &lastSourcePacketResponseReceiver{}
-
- local := netip.MustParseAddrPort("192.0.2.2:53")
-
- tpp, err := NewDNSTruncatePacketProxy(pp, local)
- require.NoError(t, err)
-
- req, err := tpp.NewSession(resp)
- require.NoError(t, err)
-
- // NewSession must not have called pp.NewSession
- require.Nil(t, pp.resp, "base session must not be created at NewSession time")
-
- msg := dnsmessage.Message{
- Header: dnsmessage.Header{ID: 42},
- Questions: []dnsmessage.Question{{
- Name: dnsmessage.MustNewName("example.com."),
- Type: dnsmessage.TypeA,
- Class: dnsmessage.ClassINET,
- }},
- }
- query, err := msg.Pack()
- require.NoError(t, err)
-
- _, err = req.WriteTo(query, local)
- require.NoError(t, err)
-
- // After a DNS-only send, still no base session
- require.Nil(t, pp.resp, "base session must not be created for DNS-only traffic")
-
- // Close should succeed without creating a base session
- require.NoError(t, req.Close())
- require.False(t, pp.req.closed, "base session Close must not be called if session was never created")
-}
diff --git a/client/go/outline/parse.go b/client/go/outline/parse.go
index 0abe72e66f..ec733a55fb 100644
--- a/client/go/outline/parse.go
+++ b/client/go/outline/parse.go
@@ -157,13 +157,13 @@ func doParseTunnelConfig(input string) *InvokeMethodResult {
}
streamFirstHop := result.Client.sd.ConnectionProviderInfo.FirstHop
- packetFirstHop := result.Client.pp.ConnectionProviderInfo.FirstHop
+ packetFirstHop := result.Client.pr.ConnectionProviderInfo.FirstHop
if streamFirstHop == packetFirstHop {
response.FirstHop = streamFirstHop
}
streamConnType := result.Client.sd.ConnectionProviderInfo.ConnType
- packetConnType := result.Client.pp.ConnectionProviderInfo.ConnType
+ packetConnType := result.Client.pr.ConnectionProviderInfo.ConnType
response.ConnectionType = combinedConnectionType(streamConnType, packetConnType)
responseBytes, err := json.Marshal(response)
diff --git a/client/go/outline/parse_test.go b/client/go/outline/parse_test.go
index 42c20bc1b8..fe507073a6 100644
--- a/client/go/outline/parse_test.go
+++ b/client/go/outline/parse_test.go
@@ -216,7 +216,7 @@ func TestParseConfig_SS_URL(t *testing.T) {
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -240,7 +240,7 @@ func TestParseConfig_Legacy_JSON(t *testing.T) {
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -265,7 +265,7 @@ func TestParseConfig_Legacy_JSON_WithPrefix(t *testing.T) {
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -285,7 +285,7 @@ func TestParseConfig_Legacy_JSONFlow_WithPrefix(t *testing.T) {
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -310,7 +310,7 @@ func TestParseConfig_Transport_JSON_WithPrefix(t *testing.T) {
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchClientConfig(t, userInputConfig, result.Value)
}
@@ -359,7 +359,7 @@ func TestParseConfig_Flexible_JSON(t *testing.T) {
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -385,7 +385,7 @@ prefix: "SSH-2.0\r\n"`
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -410,7 +410,7 @@ prefix: "SSH-2.0\r\n"`
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -436,7 +436,7 @@ prefix: "SSH-2.0\r\n"`
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -466,7 +466,7 @@ prefix: "SSH-2.0\r\n"`
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -500,7 +500,7 @@ udp:
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, expectedSdFirstHop, clientResult.Client.sd.FirstHop)
- require.Equal(t, expectedPlFirstHop, clientResult.Client.pp.FirstHop)
+ require.Equal(t, expectedPlFirstHop, clientResult.Client.pr.FirstHop)
matchTransportConfig(t, userInputConfig, result.Value)
}
@@ -535,7 +535,7 @@ udp:
require.Nil(t, clientResult.Error, "NewClient failed with parsed client config: %v", clientResult.Error)
require.NotNil(t, clientResult.Client)
require.Equal(t, configregistry.ConnTypeDirect, clientResult.Client.sd.ConnType)
- require.Equal(t, configregistry.ConnTypeTunneled, clientResult.Client.pp.ConnType)
+ require.Equal(t, configregistry.ConnTypeTunneled, clientResult.Client.pr.ConnType)
matchTransportConfig(t, userInputConfig, result.Value)
}
diff --git a/client/go/outline/tun2socks/relay.go b/client/go/outline/tun2socks/relay.go
index b83ec3969b..dae10153db 100644
--- a/client/go/outline/tun2socks/relay.go
+++ b/client/go/outline/tun2socks/relay.go
@@ -47,9 +47,9 @@ func GoRelayTrafficOneWay(tun TunWriter, rd *RemoteDevice) *perrs.PlatformError
rd.mu.Lock()
if rd.tun != nil {
if err := rd.tun.Close(); err != nil {
- slog.Info("successfully closed an already existing tun device")
- } else {
slog.Warn("failed to close an already existing tun device", "err", err)
+ } else {
+ slog.Info("successfully closed an already existing tun device")
}
}
rd.tun = tun
diff --git a/client/go/outline/vpn/device.go b/client/go/outline/vpn/device.go
index 5254588c89..2df475320e 100644
--- a/client/go/outline/vpn/device.go
+++ b/client/go/outline/vpn/device.go
@@ -23,8 +23,8 @@ import (
"localhost/client/go/outline/connectivity"
perrs "localhost/client/go/outline/platerrors"
- "golang.getoutline.org/sdk/network"
"golang.getoutline.org/sdk/network/lwip2transport"
+ "golang.getoutline.org/sdk/network/packetrelay"
"golang.getoutline.org/sdk/transport"
)
@@ -33,7 +33,7 @@ type RemoteDevice struct {
io.ReadWriteCloser
sd transport.StreamDialer
- pp network.PacketProxy
+ pr packetrelay.PacketRelay
// health check fields
tcpMu sync.Mutex
@@ -41,20 +41,20 @@ type RemoteDevice struct {
tcpErr error
}
-func ConnectRemoteDevice(ctx context.Context, sd transport.StreamDialer, pp network.PacketProxy) (_ *RemoteDevice, err error) {
+func ConnectRemoteDevice(ctx context.Context, sd transport.StreamDialer, pr packetrelay.PacketRelay) (_ *RemoteDevice, err error) {
if sd == nil {
return nil, errors.New("StreamDialer must be provided")
}
- if pp == nil {
- return nil, errors.New("PacketProxy must be provided")
+ if pr == nil {
+ return nil, errors.New("PacketRelay must be provided")
}
if ctx.Err() != nil {
return nil, errCancelled(ctx.Err())
}
- dev := &RemoteDevice{sd: sd, pp: pp}
+ dev := &RemoteDevice{sd: sd, pr: pr}
dev.tcpCheckDone.Go(dev.checkTCPHealthAndUpdate)
- dev.ReadWriteCloser, err = lwip2transport.ConfigureDevice(dev.sd, dev.pp)
+ dev.ReadWriteCloser, err = lwip2transport.ConfigureDeviceWithRelay(dev.sd, dev.pr)
if err != nil {
return nil, errSetupHandler("remote device failed to configure network stack", err)
}
diff --git a/client/go/outline/vpn/vpn.go b/client/go/outline/vpn/vpn.go
index 9280ace4fa..33a4273373 100644
--- a/client/go/outline/vpn/vpn.go
+++ b/client/go/outline/vpn/vpn.go
@@ -23,7 +23,7 @@ import (
"time"
"localhost/client/go/outline/callback"
- "golang.getoutline.org/sdk/network"
+ "golang.getoutline.org/sdk/network/packetrelay"
"golang.getoutline.org/sdk/transport"
)
@@ -105,7 +105,7 @@ func SetStateChangeListener(token callback.Token) {
// newly created [VPNConnection] as the currently active connection.
// It returns the new [VPNConnection], or an error if the connection fails.
func EstablishVPN(
- ctx context.Context, conf *Config, sd transport.StreamDialer, pp network.PacketProxy,
+ ctx context.Context, conf *Config, sd transport.StreamDialer, pr packetrelay.PacketRelay,
) (_ *VPNConnection, err error) {
if conf == nil {
panic("a VPN config must be provided")
@@ -113,8 +113,8 @@ func EstablishVPN(
if sd == nil {
panic("a StreamDialer must be provided")
}
- if pp == nil {
- panic("a PacketListener must be provided")
+ if pr == nil {
+ panic("a PacketRelay must be provided")
}
c := &VPNConnection{ID: conf.ID, Status: ConnectionDisconnected}
@@ -142,7 +142,7 @@ func EstablishVPN(
}
}()
- if c.proxy, err = ConnectRemoteDevice(ctx, sd, pp); err != nil {
+ if c.proxy, err = ConnectRemoteDevice(ctx, sd, pr); err != nil {
slog.Error("failed to connect to the remote device", "err", err)
return
}
diff --git a/client/src/cordova/apple/README.md b/client/src/cordova/apple/README.md
index 7252eea502..64e51a5bf4 100644
--- a/client/src/cordova/apple/README.md
+++ b/client/src/cordova/apple/README.md
@@ -124,6 +124,18 @@ log stream --info --predicate 'senderImagePath contains "Outline.app" or (proce
To see past logs use `log show` and the `--last` flag.
+Private values are redacted from Apple system logs by default. To include private debug values while developing, run:
+
+```sh
+sudo log config --mode "private_data:on"
+```
+
+Restore the default redaction behavior when you are done:
+
+```sh
+sudo log config --mode "private_data:off"
+```
+
For details on Apple logging, see [Your Friend the System Log](https://developer.apple.com/forums/thread/705868/) and [Mac Logging and the log Command: A Guide for Apple Admins](https://blog.kandji.io/mac-logging-and-the-log-command-a-guide-for-apple-admins#:~:text=The%20log%20command%20is%20built,(Press%20q%20to%20exit.)).
diff --git a/go.mod b/go.mod
index 13bdb1ed93..429c4a45b0 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module localhost
-go 1.25
+go 1.25.0
require (
github.com/Wifx/gonetworkmanager/v2 v2.1.0
@@ -9,10 +9,9 @@ require (
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
github.com/stretchr/testify v1.10.0
go.nhat.io/cookiejar v0.3.0
- golang.getoutline.org/sdk v0.0.21-alpha.1
+ golang.getoutline.org/sdk v0.1.0-rc1
golang.getoutline.org/sdk/x v0.0.9-alpha.1
golang.org/x/mobile v0.0.0-20250813145510-f12310a0cfd9
- golang.org/x/net v0.44.0
golang.org/x/sys v0.36.0
)
@@ -75,6 +74,7 @@ require (
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/mod v0.28.0 // indirect
+ golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
diff --git a/go.sum b/go.sum
index 2a89a6a2f2..a9d6cfbf40 100644
--- a/go.sum
+++ b/go.sum
@@ -233,8 +233,8 @@ go.nhat.io/cookiejar v0.3.0 h1:/SYdYfxpmdrM+pMS6wc5jEpFyD2hahRTIoGetfUM79U=
go.nhat.io/cookiejar v0.3.0/go.mod h1:k6iUMJVbeler1y9G3AfWsAm1h8eRnleyREdDNvU6u8k=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-golang.getoutline.org/sdk v0.0.21-alpha.1 h1:ikq5Kh5eqzRsxd7CSGvN5lSCkDi1Dfb/camf23A1CWI=
-golang.getoutline.org/sdk v0.0.21-alpha.1/go.mod h1:raUAs4PYbEaT/cLTK6PviiKSh7gjEj7JJczFFFr41zc=
+golang.getoutline.org/sdk v0.1.0-rc1 h1:soeTFOQCsR/2vIUFgbEGHbRRykpC3XOxL7+AlLCOdt0=
+golang.getoutline.org/sdk v0.1.0-rc1/go.mod h1:nKZlO//e/sRFk+rp8gm8EJ5RasDSyY+fGDNSd3I2iaA=
golang.getoutline.org/sdk/x v0.0.9-alpha.1 h1:9jWebNsAFQtxy4U+AZwkUT+pKG8U22SPd6eFpnHnAfI=
golang.getoutline.org/sdk/x v0.0.9-alpha.1/go.mod h1:m+uKYyk01Ack+DFCmVZkvW1iFXY7bUE0PcXVCjfGSjo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=