|
7 | 7 | "errors" |
8 | 8 | "fmt" |
9 | 9 | "log/slog" |
| 10 | + "net" |
10 | 11 | "net/netip" |
11 | 12 | "path/filepath" |
12 | 13 | "slices" |
@@ -331,6 +332,13 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) { |
331 | 332 |
|
332 | 333 | tags := mergeAndCollectTags(&opts, &bOptions.Options) |
333 | 334 |
|
| 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 | + |
334 | 342 | // add mode selector outbounds and rules |
335 | 343 | opts.Outbounds = append(opts.Outbounds, urlTestOutbound(AutoSelectTag, tags, bOptions.BanditURLOverrides)) |
336 | 344 | opts.Outbounds = append(opts.Outbounds, selectorOutbound(ManualSelectTag, tags)) |
@@ -372,6 +380,60 @@ func writeBoxOptions(path string, opts O.Options) []byte { |
372 | 380 | // Helper functions // |
373 | 381 | ////////////////////// |
374 | 382 |
|
| 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 | + |
375 | 437 | // mergeAndCollectTags merges src into dst and returns all outbound/endpoint tags from src. |
376 | 438 | func mergeAndCollectTags(dst, src *O.Options) []string { |
377 | 439 | dst.Outbounds = append(dst.Outbounds, src.Outbounds...) |
|
0 commit comments