|
| 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 } |
0 commit comments