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=