Skip to content

Commit b9ed811

Browse files
Adam Fiskclaude
andcommitted
qa-bandit: Android-shaped bandit probe + complete the egress override
cmd/qa-bandit is a focused QA driver that boots a radiance backend impersonating an Android client (Platform=android, version, locale=ru_RU, TZ=Europe/Moscow), captures the first /v1/config-new response, dumps the bandit assignment (country/IP the API saw, assigned outbounds and locations), then optionally ConnectVPN(AutoSelect) and probes a target URL through the local SOCKS5 inbound to verify the full egress path. Pair with `pinger bridge --country ru` running on 127.0.0.1:1080: RADIANCE_OUTBOUND_SOCKS_ADDRESS=127.0.0.1:1080 \ go run -tags 'with_quic,with_gvisor,with_wireguard,with_utls' \ ./cmd/qa-bandit Plumbing required to make the API actually see us as a Russia client: * common.Platform: const → var, plus RADIANCE_PLATFORM env override in common.Init(). Lets the QA driver impersonate Android while running on macOS. * backend.Start: skip publicip.Detect() when OutboundSocksAddress is set. Otherwise it talks directly to AWS/ifconfig.me, gets the host's real IP, and stuffs it into X-Lantern-Config-Client-IP — which the API trusts over the actual TCP source for bandit lookups. * vpn/boxoptions: add C.TypeDirect to the Detour skip-list. Sing-box rejects "detour is not supported in direct context" at runtime otherwise. * Spoof TZ + locale (--tz Europe/Moscow, --locale ru_RU) so the request's X-Lantern-Time-Zone / locale don't trigger the API's "GeoIP says X but timezone says Y, must be VPN" override path (cmd/api/maxmind.go:LookupCountryASNState). Known limitation: the bridge SOCKS5 listener only implements TCP CONNECT, not UDP ASSOCIATE. So UDP outbounds (hysteria/hysteria2/ wireguard/tuic) fail with code=7 when chained through the detour; TCP-based outbounds (samizdat/reflex/vmess/vless/trojan/shadowsocks) work. URLTest will fall back to a working outbound on retry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ece939 commit b9ed811

6 files changed

Lines changed: 326 additions & 16 deletions

File tree

backend/radiance.go

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -185,20 +185,28 @@ func NewLocalBackend(ctx context.Context, opts Options) (*LocalBackend, error) {
185185
func (r *LocalBackend) Start() {
186186
// eagerly start kindling so it's ready by the time we need to make network requests
187187
kindling.Init()
188-
go func() {
189-
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
190-
result, err := publicip.Detect(ctx, &publicip.Config{
191-
Timeout: 2 * time.Second,
192-
MinConsensus: 1,
193-
})
194-
cancel()
195-
if err != nil {
196-
slog.Warn("Failed to get public IP", "error", err)
197-
} else {
198-
common.SetPublicIP(result.IP.String())
199-
slog.Debug("Detected public IP", "confidence", result.Confidence, "sources", result.Sources)
200-
}
201-
}()
188+
// QA: when an upstream outbound SOCKS5 is set, publicip.Detect would
189+
// leak the host's real IP via direct calls to AWS/ifconfig.me, and the
190+
// resulting X-Lantern-Config-Client-IP header would override our Russia
191+
// egress for the API's bandit lookup. Skip detection in that mode.
192+
if addr, _ := env.Get(env.OutboundSocksAddress); addr == "" {
193+
go func() {
194+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
195+
result, err := publicip.Detect(ctx, &publicip.Config{
196+
Timeout: 2 * time.Second,
197+
MinConsensus: 1,
198+
})
199+
cancel()
200+
if err != nil {
201+
slog.Warn("Failed to get public IP", "error", err)
202+
} else {
203+
common.SetPublicIP(result.IP.String())
204+
slog.Debug("Detected public IP", "confidence", result.Confidence, "sources", result.Sources)
205+
}
206+
}()
207+
} else {
208+
slog.Info("Skipping publicip.Detect because RADIANCE_OUTBOUND_SOCKS_ADDRESS is set", "addr", addr)
209+
}
202210

203211
if settings.GetBool(settings.TelemetryKey) {
204212
if err := r.startTelemetry(); err != nil {

cmd/qa-bandit/main.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Command qa-bandit is a focused QA driver for the bandit assignment path.
2+
// It boots a radiance backend that impersonates an Android client, captures
3+
// the first /v1/config-new response from the bandit, prints the assignment,
4+
// then optionally connects the VPN and probes a target URL through the
5+
// resulting tunnel to confirm both the API view of the client and the
6+
// outbound dials originate from the country we're simulating.
7+
//
8+
// Pair with `pinger bridge --country ru`:
9+
//
10+
// # in lantern-cloud-bridge:
11+
// ./cmd/pinger/bridge.sh
12+
// # in radiance:
13+
// RADIANCE_OUTBOUND_SOCKS_ADDRESS=127.0.0.1:1080 \
14+
// go run -tags 'with_quic,with_gvisor,with_wireguard,with_utls' ./cmd/qa-bandit
15+
//
16+
// The build tags are needed by sing-box outbounds (hysteria2 needs QUIC,
17+
// etc.) — without them ConnectVPN fails with "X is not included in this
18+
// build, rebuild with -tags with_X".
19+
package main
20+
21+
import (
22+
"context"
23+
"encoding/json"
24+
"flag"
25+
"fmt"
26+
"io"
27+
"log/slog"
28+
"net"
29+
"net/http"
30+
"net/url"
31+
"os"
32+
"strconv"
33+
"time"
34+
35+
"golang.org/x/net/proxy"
36+
37+
"github.com/getlantern/radiance/backend"
38+
"github.com/getlantern/radiance/common"
39+
"github.com/getlantern/radiance/common/settings"
40+
"github.com/getlantern/radiance/config"
41+
"github.com/getlantern/radiance/events"
42+
"github.com/getlantern/radiance/vpn"
43+
)
44+
45+
func main() {
46+
var (
47+
outboundSocks = flag.String("outbound-socks", os.Getenv("RADIANCE_OUTBOUND_SOCKS_ADDRESS"),
48+
"upstream SOCKS5 to route ALL radiance egress through (e.g. 127.0.0.1:1080 — pinger bridge)")
49+
platform = flag.String("platform", "android", "platform to advertise to the API (sent in the body and X-Lantern-Platform)")
50+
version = flag.String("version", "9.0.30-qa-bandit",
51+
"app version to advertise (X-Lantern-App-Version / X-Lantern-Version)")
52+
deviceID = flag.String("device-id", "qa-bandit-android-0001", "device ID to advertise")
53+
userID = flag.String("user-id", "0", "user ID to advertise (string; 0 = no specific user)")
54+
token = flag.String("token", "", "pro token (optional — empty = free tier)")
55+
probeURL = flag.String("probe-url", "https://api.ipify.org",
56+
"URL to fetch through the bandit-assigned tunnel to verify egress IP")
57+
doConnect = flag.Bool("connect", true,
58+
"actually ConnectVPN(AutoSelect) and probe — false = just dump the bandit response and exit")
59+
socksIn = flag.String("socks-inbound", "127.0.0.1:46666",
60+
"local SOCKS5 inbound that radiance exposes for the probe (avoids needing a TUN / root)")
61+
// The API's GeoIP→country logic overrides the IP-derived country
62+
// with the timezone-derived one (treats mismatches as VPN users).
63+
// Without spoofing these to Russia equivalents, the bandit will
64+
// keep serving US-tier outbounds even though the TCP egress is
65+
// Russia. See cmd/api/maxmind.go LookupCountryASNState.
66+
tz = flag.String("tz", "Europe/Moscow", "TZ env var sent as the request's X-Lantern-Time-Zone")
67+
locale = flag.String("locale", "ru_RU", "locale to pass to the radiance backend (X-Lantern-Locale)")
68+
timeout = flag.Duration("timeout", 90*time.Second, "overall timeout")
69+
)
70+
flag.Parse()
71+
72+
// Plumb the QA env vars BEFORE common.Init runs (i.e. before NewLocalBackend).
73+
// All three of these are honored by code on qa/outbound-socks-egress branch.
74+
if *outboundSocks != "" {
75+
os.Setenv("RADIANCE_OUTBOUND_SOCKS_ADDRESS", *outboundSocks)
76+
}
77+
os.Setenv("RADIANCE_PLATFORM", *platform)
78+
os.Setenv("RADIANCE_VERSION", *version)
79+
// Use a SOCKS5 inbound listener instead of a TUN device — no root/sudo
80+
// needed, and gives us a clean address to probe through.
81+
os.Setenv("RADIANCE_USE_SOCKS_PROXY", "true")
82+
os.Setenv("RADIANCE_SOCKS_ADDRESS", *socksIn)
83+
// Spoof TZ so the X-Lantern-Time-Zone radiance sends matches the country
84+
// we're impersonating. The API's MaxMind logic overrides the GeoIP-derived
85+
// country with the timezone-derived one when they disagree, so without
86+
// this the bandit thinks "user behind a VPN, return their real country".
87+
if *tz != "" {
88+
os.Setenv("TZ", *tz)
89+
}
90+
91+
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
92+
defer cancel()
93+
94+
dataDir, err := os.MkdirTemp("", "qa-bandit-")
95+
if err != nil {
96+
fatal("mktempdir", err)
97+
}
98+
defer os.RemoveAll(dataDir)
99+
100+
banner(*outboundSocks, *platform, *version, dataDir, *socksIn)
101+
102+
be, err := backend.NewLocalBackend(ctx, backend.Options{
103+
DataDir: dataDir,
104+
LogDir: dataDir,
105+
Locale: *locale,
106+
})
107+
if err != nil {
108+
fatal("NewLocalBackend", err)
109+
}
110+
defer be.Close()
111+
112+
uid, err := strconv.ParseInt(*userID, 10, 64)
113+
if err != nil {
114+
fatal("parse user-id", err)
115+
}
116+
settings.Set(settings.UserIDKey, uid)
117+
settings.Set(settings.TokenKey, *token)
118+
settings.Set(settings.UserLevelKey, "")
119+
settings.Set(settings.EmailKey, "qa-bandit@local")
120+
// Need both: DeviceIDKey is what common.NewRequestWithHeaders pulls for
121+
// the X-Lantern-DeviceID header (and the user-create body field), while
122+
// DevicesKey is the canonical list used elsewhere.
123+
settings.Set(settings.DeviceIDKey, *deviceID)
124+
settings.Set(settings.DevicesKey, []settings.Device{{ID: *deviceID, Name: *deviceID}})
125+
126+
// Subscribe BEFORE Start() so we don't race the first config event.
127+
cfgCh := make(chan *config.Config, 1)
128+
go events.SubscribeOnce(func(evt config.NewConfigEvent) {
129+
cfgCh <- evt.New
130+
})
131+
132+
be.Start()
133+
134+
// Note: we deliberately do NOT bring up the IPC server here. It's there
135+
// for client UIs (Lantern Flutter, etc.) to talk to the backend — we're
136+
// calling backend methods directly, and on macOS its default Unix-socket
137+
// path (/var/run/lantern/lanternd.sock) requires root.
138+
139+
fmt.Println("[qa-bandit] waiting for first /v1/config-new response (bandit assignment)...")
140+
var cfg *config.Config
141+
select {
142+
case cfg = <-cfgCh:
143+
case <-ctx.Done():
144+
fatal("waiting for config", ctx.Err())
145+
}
146+
147+
dumpAssignment(cfg)
148+
149+
if !*doConnect {
150+
return
151+
}
152+
153+
fmt.Println("\n[qa-bandit] connecting VPN with bandit auto-pick...")
154+
if err := be.ConnectVPN(vpn.AutoSelectTag); err != nil {
155+
fmt.Printf("[qa-bandit] ConnectVPN FAILED: %v\n", err)
156+
os.Exit(1)
157+
}
158+
defer be.DisconnectVPN()
159+
160+
fmt.Printf("[qa-bandit] VPN connected; probing %s through %s...\n", *probeURL, *socksIn)
161+
body, dur, err := probeViaSocks(ctx, *socksIn, *probeURL)
162+
if err != nil {
163+
fmt.Printf("[qa-bandit] probe FAILED: %v (%.2fs)\n", err, dur.Seconds())
164+
os.Exit(1)
165+
}
166+
fmt.Printf("[qa-bandit] probe OK in %.2fs — egress IP: %s\n", dur.Seconds(), body)
167+
}
168+
169+
func banner(outboundSocks, platform, version, dataDir, socksIn string) {
170+
fmt.Println()
171+
fmt.Println("======================================================================")
172+
fmt.Println(" qa-bandit — radiance bandit-assignment probe")
173+
fmt.Println("======================================================================")
174+
fmt.Printf(" Platform : %s\n", platform)
175+
fmt.Printf(" App version : %s\n", version)
176+
fmt.Printf(" Time zone : %s\n", os.Getenv("TZ"))
177+
if outboundSocks == "" {
178+
fmt.Println(" Outbound SOCKS5 : (unset — radiance will dial DIRECTLY, NOT through any country)")
179+
} else {
180+
fmt.Printf(" Outbound SOCKS5 : %s (every radiance dial goes here)\n", outboundSocks)
181+
}
182+
fmt.Printf(" Probe inbound SOCKS: %s\n", socksIn)
183+
fmt.Printf(" Data dir : %s\n", dataDir)
184+
fmt.Println()
185+
}
186+
187+
// dumpAssignment prints the parts of the config response the bandit decided.
188+
func dumpAssignment(cfg *config.Config) {
189+
fmt.Println("=========================== bandit assignment ===========================")
190+
fmt.Printf(" API saw client as : country=%s ip=%s\n", cfg.Country, cfg.IP)
191+
fmt.Printf(" Servers (%d) :\n", len(cfg.Servers))
192+
for _, s := range cfg.Servers {
193+
fmt.Printf(" %-2s %s / %s\n", s.CountryCode, s.Country, s.City)
194+
}
195+
fmt.Printf(" Outbounds (%d):\n", len(cfg.Options.Outbounds))
196+
for _, o := range cfg.Options.Outbounds {
197+
loc := cfg.OutboundLocations[o.Tag]
198+
fmt.Printf(" %-12s %s (%s / %s)\n", o.Type, o.Tag, loc.CountryCode, loc.City)
199+
}
200+
if len(cfg.BanditURLOverrides) > 0 {
201+
fmt.Printf(" Bandit callback URLs : %d outbounds tagged with per-proxy callbacks\n", len(cfg.BanditURLOverrides))
202+
}
203+
if cfg.PollIntervalSeconds > 0 {
204+
fmt.Printf(" Server-suggested poll: %ds\n", cfg.PollIntervalSeconds)
205+
}
206+
if raw, err := json.MarshalIndent(struct {
207+
Country string `json:"country"`
208+
IP string `json:"ip"`
209+
Outbounds int `json:"outbounds"`
210+
Servers int `json:"servers"`
211+
BanditURLOverrides int `json:"bandit_url_overrides"`
212+
PollIntervalSeconds int `json:"poll_interval_seconds"`
213+
OutboundLocations map[string]string `json:"outbound_locations,omitempty"`
214+
}{
215+
Country: cfg.Country,
216+
IP: cfg.IP,
217+
Outbounds: len(cfg.Options.Outbounds),
218+
Servers: len(cfg.Servers),
219+
BanditURLOverrides: len(cfg.BanditURLOverrides),
220+
PollIntervalSeconds: cfg.PollIntervalSeconds,
221+
OutboundLocations: shortOutboundLocations(cfg),
222+
}, "", " "); err == nil {
223+
fmt.Printf(" Summary JSON :\n%s\n", raw)
224+
}
225+
fmt.Println("==========================================================================")
226+
}
227+
228+
func shortOutboundLocations(cfg *config.Config) map[string]string {
229+
out := make(map[string]string, len(cfg.OutboundLocations))
230+
for tag, loc := range cfg.OutboundLocations {
231+
out[tag] = fmt.Sprintf("%s / %s", loc.CountryCode, loc.City)
232+
}
233+
return out
234+
}
235+
236+
func probeViaSocks(ctx context.Context, socksAddr, target string) (string, time.Duration, error) {
237+
d, err := proxy.SOCKS5("tcp", socksAddr, nil, proxy.Direct)
238+
if err != nil {
239+
return "", 0, fmt.Errorf("building SOCKS5 dialer to %s: %w", socksAddr, err)
240+
}
241+
cd := d.(proxy.ContextDialer)
242+
tr := &http.Transport{
243+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
244+
return cd.DialContext(ctx, network, addr)
245+
},
246+
}
247+
defer tr.CloseIdleConnections()
248+
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
249+
250+
parsed, err := url.Parse(target)
251+
if err != nil {
252+
return "", 0, fmt.Errorf("parsing %q: %w", target, err)
253+
}
254+
if parsed.Scheme == "" {
255+
return "", 0, fmt.Errorf("probe URL must include scheme (https://...): %q", target)
256+
}
257+
258+
t0 := time.Now()
259+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
260+
if err != nil {
261+
return "", 0, err
262+
}
263+
resp, err := client.Do(req)
264+
if err != nil {
265+
return "", time.Since(t0), err
266+
}
267+
defer resp.Body.Close()
268+
if resp.StatusCode != http.StatusOK {
269+
return "", time.Since(t0), fmt.Errorf("probe returned status %d", resp.StatusCode)
270+
}
271+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
272+
if err != nil {
273+
return "", time.Since(t0), err
274+
}
275+
return string(body), time.Since(t0), nil
276+
}
277+
278+
func fatal(stage string, err error) {
279+
slog.Error(stage, "error", err)
280+
fmt.Fprintf(os.Stderr, "[qa-bandit] FAILED at %s: %v\n", stage, err)
281+
os.Exit(1)
282+
}
283+
284+
// Compile-time check that common.Platform is a var (not const) — see
285+
// common/platform.go. If this stops compiling, the override env var
286+
// (RADIANCE_PLATFORM, set in main()) won't take effect.
287+
var _ = func() string { return common.Platform }

common/env/env.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ var (
3434
// circumvention QA — point it at a SOCKS server that egresses through a
3535
// residential proxy in the country we want to simulate.
3636
OutboundSocksAddress _key = "RADIANCE_OUTBOUND_SOCKS_ADDRESS"
37+
// Platform overrides common.Platform for QA scenarios that want to
38+
// impersonate a different OS (e.g. test the Android bandit path from a
39+
// Linux/macOS process). Honored in common.Init().
40+
Platform _key = "RADIANCE_PLATFORM"
3741
Country _key = "RADIANCE_COUNTRY"
3842
FeatureOverrides _key = "RADIANCE_FEATURE_OVERRIDES"
3943
AppVersion _key = "RADIANCE_VERSION"

common/init.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ func Init(dataDir, logDir, logLevel string) (err error) {
6969
Version = v
7070
slog.Info("Version overridden via RADIANCE_VERSION", "version", Version)
7171
}
72+
if v, ok := env.Get(env.Platform); ok && v != "" {
73+
Platform = v
74+
slog.Info("Platform overridden via RADIANCE_PLATFORM", "platform", Platform)
75+
}
7276
reporting.Init(GetVersion())
7377
data, logs, err := setupDirectories(dataDir, logDir)
7478
if err != nil {

common/platform.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package common
22

33
import "runtime"
44

5-
const Platform = runtime.GOOS
5+
// Platform is the runtime platform string, defaulting to runtime.GOOS but
6+
// overridable via RADIANCE_PLATFORM (handled in common.Init) for QA scenarios
7+
// that need to impersonate a different platform — e.g. running radiance as a
8+
// Go process on macOS while making the API see us as an Android client.
9+
var Platform = runtime.GOOS
610

711
func IsAndroid() bool {
812
return Platform == "android"

vpn/boxoptions.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,10 @@ func applyOutboundSocksDetour(opts *O.Options) error {
406406
for i := range opts.Outbounds {
407407
out := &opts.Outbounds[i]
408408
switch out.Type {
409-
case C.TypeSelector, C.TypeURLTest, C.TypeBlock, C.TypeDNS:
409+
case C.TypeSelector, C.TypeURLTest, C.TypeBlock, C.TypeDNS, C.TypeDirect:
410+
// selector/urltest wrap others; block/dns/direct don't dial
411+
// real upstream proxies. `direct` in particular rejects Detour
412+
// at runtime ("detour is not supported in direct context").
410413
continue
411414
}
412415
if w, ok := out.Options.(O.DialerOptionsWrapper); ok {

0 commit comments

Comments
 (0)