Skip to content

Commit 4ece939

Browse files
Adam Fiskclaude
andcommitted
qa: add RADIANCE_OUTBOUND_SOCKS_ADDRESS for whole-process residential egress
When this env var is set, every outbound connection radiance opens is routed through that SOCKS5 server. Intended for censorship-circumvention QA: pair with `pinger bridge --country ru` so the API request, sing-box tunnel dials, and the bypass dialer all egress through a residential proxy in the chosen country, simulating a real client there. * common/env: new key OutboundSocksAddress. * kindling/client: when set, swap kindling's stacked transport for a plain http.Transport whose DialContext goes via SOCKS5. Kindling's fronted/AMP/dnstt circumvention paths are skipped (kindling lacks a generic dialer override) — see comment. * vpn/boxoptions: append a SOCKS5 outbound and walk every leaf outbound setting DialerOptions.Detour to it; selector/urltest/block/dns are left alone (they don't dial directly). * bypass/bypass: route DialContext through SOCKS5 instead of the local-bypass-proxy / direct-fallback chain. * telemetry/otel: skip OTLP gRPC init in this mode — the gRPC exporter can't be routed via http.Transport.DialContext, so leaving it would leak the test process's real IP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 94792aa commit 4ece939

5 files changed

Lines changed: 159 additions & 0 deletions

File tree

bypass/bypass.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import (
88
"net"
99
"net/http"
1010
"net/url"
11+
"sync"
1112
"time"
1213

14+
"golang.org/x/net/proxy"
15+
16+
"github.com/getlantern/radiance/common/env"
1317
"github.com/getlantern/radiance/log"
1418
)
1519

@@ -36,7 +40,14 @@ const (
3640

3741
// DialContext tries to connect through the local bypass proxy. If the proxy is
3842
// not reachable (VPN not running), it falls back to a direct dial.
43+
//
44+
// QA: when env.OutboundSocksAddress is set, both the bypass-proxy path and the
45+
// direct-fallback path are replaced by a dial through that upstream SOCKS5,
46+
// so every dial out of radiance goes via the same residential egress.
3947
func DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
48+
if d, ok := outboundSocksDialer(); ok {
49+
return d.DialContext(ctx, network, addr)
50+
}
4051
dialer := &net.Dialer{
4152
Timeout: dialTimeout,
4253
KeepAlive: dialKeepAlive,
@@ -54,6 +65,31 @@ func DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
5465
return tunnelConn, nil
5566
}
5667

68+
var (
69+
outboundSocksOnce sync.Once
70+
outboundSocksDialFn proxy.ContextDialer
71+
)
72+
73+
// outboundSocksDialer returns a SOCKS5 ContextDialer for env.OutboundSocksAddress
74+
// if set, cached after the first successful build.
75+
func outboundSocksDialer() (proxy.ContextDialer, bool) {
76+
outboundSocksOnce.Do(func() {
77+
addr, ok := env.Get(env.OutboundSocksAddress)
78+
if !ok || addr == "" {
79+
return
80+
}
81+
d, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
82+
if err != nil {
83+
slog.Error("invalid RADIANCE_OUTBOUND_SOCKS_ADDRESS for bypass dialer", slog.Any("error", err), slog.String("addr", addr))
84+
return
85+
}
86+
if cd, ok := d.(proxy.ContextDialer); ok {
87+
outboundSocksDialFn = cd
88+
}
89+
})
90+
return outboundSocksDialFn, outboundSocksDialFn != nil
91+
}
92+
5793
// Dial is a convenience wrapper without context, suitable for use with
5894
// amp.WithDialer which expects func(network, addr string) (net.Conn, error).
5995
func Dial(network, addr string) (net.Conn, error) {

common/env/env.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ var (
2626
ENV _key = "RADIANCE_ENV"
2727
UseSocks _key = "RADIANCE_USE_SOCKS_PROXY"
2828
SocksAddress _key = "RADIANCE_SOCKS_ADDRESS"
29+
// OutboundSocksAddress, when set to host:port of a SOCKS5 server, routes
30+
// every outbound connection that radiance opens (kindling HTTP client,
31+
// sing-box outbound tunnel dials, the bypass dialer) through that server.
32+
// Distinct from SocksAddress, which sets up an inbound listener for other
33+
// apps to use radiance as a SOCKS proxy. Intended for censorship-
34+
// circumvention QA — point it at a SOCKS server that egresses through a
35+
// residential proxy in the country we want to simulate.
36+
OutboundSocksAddress _key = "RADIANCE_OUTBOUND_SOCKS_ADDRESS"
2937
Country _key = "RADIANCE_COUNTRY"
3038
FeatureOverrides _key = "RADIANCE_FEATURE_OVERRIDES"
3139
AppVersion _key = "RADIANCE_VERSION"

kindling/client.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ package kindling
44

55
import (
66
"context"
7+
"fmt"
78
"log/slog"
9+
"net"
810
"net/http"
911
"path/filepath"
1012
"strings"
@@ -14,8 +16,10 @@ import (
1416
"go.opentelemetry.io/otel"
1517
"go.opentelemetry.io/otel/attribute"
1618
"go.opentelemetry.io/otel/trace"
19+
"golang.org/x/net/proxy"
1720

1821
"github.com/getlantern/radiance/common"
22+
"github.com/getlantern/radiance/common/env"
1923
"github.com/getlantern/radiance/common/reporting"
2024
"github.com/getlantern/radiance/common/settings"
2125
"github.com/getlantern/radiance/kindling/dnstt"
@@ -44,6 +48,24 @@ var (
4448
// initKindling initializes the package-level kindling instance and shared
4549
// transport.
4650
func initKindling() {
51+
// Censorship-circumvention QA path: when OutboundSocksAddress is set,
52+
// every outbound HTTP dial goes through that SOCKS5 server. Kindling's
53+
// stacked transports (fronted/AMP/dnstt/proxyless) are skipped — the
54+
// SOCKS5 is providing egress, and kindling's per-transport internal
55+
// dialers don't expose an override hook today. As a result, when this
56+
// var is set we are testing "does the bandit/tunnel path work given a
57+
// reachable API channel" rather than the full anti-censorship stack.
58+
if addr, ok := env.Get(env.OutboundSocksAddress); ok && addr != "" {
59+
t, err := socksOnlyTransport(addr)
60+
if err != nil {
61+
slog.Error("invalid RADIANCE_OUTBOUND_SOCKS_ADDRESS, falling back to default transport", slog.Any("error", err))
62+
transport = traces.NewRoundTripper(traces.NewHeaderAnnotatingRoundTripper(defaultTransportClone))
63+
return
64+
}
65+
slog.Info("RADIANCE_OUTBOUND_SOCKS_ADDRESS set — routing all radiance HTTP through upstream SOCKS5", slog.String("addr", addr))
66+
transport = traces.NewRoundTripper(traces.NewHeaderAnnotatingRoundTripper(t))
67+
return
68+
}
4769
newK, err := NewKindling(settings.GetString(settings.DataPathKey))
4870
if err != nil {
4971
slog.Error("failed to create kindling client", slog.Any("error", err))
@@ -57,6 +79,28 @@ func initKindling() {
5779
}
5880
}
5981

82+
// socksOnlyTransport returns an http.Transport that dials through the given
83+
// SOCKS5 server for every connection.
84+
func socksOnlyTransport(socksAddr string) (*http.Transport, error) {
85+
d, err := proxy.SOCKS5("tcp", socksAddr, nil, proxy.Direct)
86+
if err != nil {
87+
return nil, fmt.Errorf("building SOCKS5 dialer for %s: %w", socksAddr, err)
88+
}
89+
ctxDialer, ok := d.(proxy.ContextDialer)
90+
if !ok {
91+
return nil, fmt.Errorf("SOCKS5 dialer does not support context")
92+
}
93+
t := defaultTransportClone.Clone()
94+
t.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
95+
return ctxDialer.DialContext(ctx, network, address)
96+
}
97+
// Disable HTTP_PROXY env-based proxying — we route via DialContext instead.
98+
// (x/net/proxy's SOCKS5 sends the hostname to the upstream as ATYP=domain,
99+
// so DNS resolution also happens at the SOCKS5 server, no local leak.)
100+
t.Proxy = nil
101+
return t, nil
102+
}
103+
60104
func Init() {
61105
go initOnce.Do(initKindling)
62106
}

telemetry/otel.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ func Initialize(deviceID string, configResponse config.Config, pro bool) error {
7878
return nil
7979
}
8080

81+
// QA: when env.OutboundSocksAddress is set, the OTLP gRPC exporters do
82+
// NOT honor the radiance dialer override and would phone home directly,
83+
// leaking the test process's real IP and bypassing the SOCKS5 egress.
84+
// Skip telemetry init in that mode.
85+
if addr, ok := env.Get(env.OutboundSocksAddress); ok && addr != "" {
86+
slog.Info("RADIANCE_OUTBOUND_SOCKS_ADDRESS set — skipping OpenTelemetry init (gRPC exporters cannot be routed via SOCKS5)", "addr", addr)
87+
return nil
88+
}
89+
8190
if shutdownOTEL != nil {
8291
slog.Info("Shutting down existing OpenTelemetry SDK")
8392
if err := shutdownOTEL(context.Background()); err != nil {

vpn/boxoptions.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"log/slog"
10+
"net"
1011
"net/netip"
1112
"path/filepath"
1213
"slices"
@@ -331,6 +332,13 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) {
331332

332333
tags := mergeAndCollectTags(&opts, &bOptions.Options)
333334

335+
// QA: route every leaf outbound through an upstream SOCKS5 (e.g. one that
336+
// egresses through a residential proxy in the country we want to simulate)
337+
// before reaching its real destination. See env.OutboundSocksAddress.
338+
if err := applyOutboundSocksDetour(&opts); err != nil {
339+
return O.Options{}, err
340+
}
341+
334342
// add mode selector outbounds and rules
335343
opts.Outbounds = append(opts.Outbounds, urlTestOutbound(AutoSelectTag, tags, bOptions.BanditURLOverrides))
336344
opts.Outbounds = append(opts.Outbounds, selectorOutbound(ManualSelectTag, tags))
@@ -372,6 +380,60 @@ func writeBoxOptions(path string, opts O.Options) []byte {
372380
// Helper functions //
373381
//////////////////////
374382

383+
// devOutboundSocksTag is the tag of the synthetic SOCKS5 outbound injected
384+
// when env.OutboundSocksAddress is set. Other outbounds get DialerOptions.Detour
385+
// pointing at this tag, so every real dial is wrapped in a SOCKS5 connection.
386+
const devOutboundSocksTag = "_dev_outbound_socks"
387+
388+
// applyOutboundSocksDetour appends a SOCKS5 outbound to opts and rewrites every
389+
// other leaf outbound to dial through it, when env.OutboundSocksAddress is set.
390+
// Selector / urltest / block / dns outbounds are skipped — they don't dial
391+
// directly. No-op when the env var is unset.
392+
func applyOutboundSocksDetour(opts *O.Options) error {
393+
addr, ok := env.Get(env.OutboundSocksAddress)
394+
if !ok || addr == "" {
395+
return nil
396+
}
397+
host, portStr, err := net.SplitHostPort(addr)
398+
if err != nil {
399+
return fmt.Errorf("invalid RADIANCE_OUTBOUND_SOCKS_ADDRESS %q: %w", addr, err)
400+
}
401+
port, err := strconv.ParseUint(portStr, 10, 16)
402+
if err != nil {
403+
return fmt.Errorf("invalid RADIANCE_OUTBOUND_SOCKS_ADDRESS port %q: %w", portStr, err)
404+
}
405+
406+
for i := range opts.Outbounds {
407+
out := &opts.Outbounds[i]
408+
switch out.Type {
409+
case C.TypeSelector, C.TypeURLTest, C.TypeBlock, C.TypeDNS:
410+
continue
411+
}
412+
if w, ok := out.Options.(O.DialerOptionsWrapper); ok {
413+
d := w.TakeDialerOptions()
414+
d.Detour = devOutboundSocksTag
415+
w.ReplaceDialerOptions(d)
416+
}
417+
}
418+
419+
opts.Outbounds = append(opts.Outbounds, O.Outbound{
420+
Type: C.TypeSOCKS,
421+
Tag: devOutboundSocksTag,
422+
Options: &O.SOCKSOutboundOptions{
423+
ServerOptions: O.ServerOptions{
424+
Server: host,
425+
ServerPort: uint16(port),
426+
},
427+
Version: "5",
428+
},
429+
})
430+
431+
slog.Info("RADIANCE_OUTBOUND_SOCKS_ADDRESS set — every sing-box outbound will dial via this SOCKS5",
432+
slog.String("addr", addr),
433+
slog.Int("rewritten_outbounds", len(opts.Outbounds)-1))
434+
return nil
435+
}
436+
375437
// mergeAndCollectTags merges src into dst and returns all outbound/endpoint tags from src.
376438
func mergeAndCollectTags(dst, src *O.Options) []string {
377439
dst.Outbounds = append(dst.Outbounds, src.Outbounds...)

0 commit comments

Comments
 (0)