diff --git a/README.md b/README.md index 0499e10f..ce688091 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Available variables: * `RADIANCE_USE_SOCKS_PROXY`: If set to `true`, replace the TUN with a SOCKS proxy for inbound connections. * `RADIANCE_SOCKS_ADDRESS`: Specifies the address (`host:port`) for the SOCKS proxy to use for inbound connections. * `RADIANCE_ENV`: Sets whether we're running in production or development mode. Set to `dev` for additional debugging output, such as the sing-box config actually in use. `prod` is the default. +* `RADIANCE_FEATURE_OVERRIDE`: Comma-separated list of feature flags to force-enable on the server side. If set, the value is sent as the `X-Lantern-Feature-Override` header on config requests in any environment, and it is recommended for testing/non-production use. For example, `RADIANCE_FEATURE_OVERRIDE=bandit_assignment` enables bandit-based proxy assignment during testing. ## Packages diff --git a/config/config.go b/config/config.go index 20fb84df..183612af 100644 --- a/config/config.go +++ b/config/config.go @@ -234,9 +234,17 @@ func (ch *ConfigHandler) fetchConfig() error { locs[k] = *v } opts := servers.Options{ - Outbounds: cfg.Options.Outbounds, - Endpoints: cfg.Options.Endpoints, - Locations: locs, + Outbounds: cfg.Options.Outbounds, + Endpoints: cfg.Options.Endpoints, + Locations: locs, + URLOverrides: cfg.BanditURLOverrides, + } + if len(cfg.BanditURLOverrides) > 0 { + slog.Info("Config includes bandit URL overrides", + "override_count", len(cfg.BanditURLOverrides), + "outbound_count", len(cfg.Options.Outbounds), + "endpoint_count", len(cfg.Options.Endpoints), + ) } if err := ch.svrManager.SetServers(servers.SGLantern, opts); err != nil { slog.Error("setting servers in manager", "error", err) diff --git a/config/fetcher.go b/config/fetcher.go index 2fc5f932..b9419b75 100644 --- a/config/fetcher.go +++ b/config/fetcher.go @@ -153,6 +153,10 @@ func (f *fetcher) send(ctx context.Context, body io.Reader) ([]byte, error) { slog.Info("Setting x-lantern-client-country header", "country", val) req.Header.Set("x-lantern-client-country", val) } + if val, exists := os.LookupEnv("RADIANCE_FEATURE_OVERRIDE"); exists && val != "" { + slog.Info("Setting X-Lantern-Feature-Override header", "features", val) + req.Header.Set("X-Lantern-Feature-Override", val) + } // Add If-Modified-Since header to the request // Note that on the first run, lastModified is zero, so the server will return the latest config. diff --git a/go.mod b/go.mod index 3baed4a6..6f4444e7 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/getlantern/fronted v0.0.0-20260319225233-cf2160f85053 github.com/getlantern/keepcurrent v0.0.0-20260304213122-017d542145ae github.com/getlantern/kindling v0.0.0-20260319225424-4736208dd171 - github.com/getlantern/lantern-box v0.0.49 + github.com/getlantern/lantern-box v0.0.50-0.20260323160457-f763bd127d8c github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/timezone v0.0.0-20210901200113-3f9de9d360c9 github.com/go-resty/resty/v2 v2.16.5 diff --git a/go.sum b/go.sum index d137cb9c..ec647fbc 100644 --- a/go.sum +++ b/go.sum @@ -244,8 +244,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260304213122-017d542145ae h1:NMq3K7h3 github.com/getlantern/keepcurrent v0.0.0-20260304213122-017d542145ae/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260319225424-4736208dd171 h1:UEjX+Gg+T6oGVUbzHJ4JfLhlsIh8Wl8PmTXZYWGS43A= github.com/getlantern/kindling v0.0.0-20260319225424-4736208dd171/go.mod h1:c5cFjpNrqX8wQ0PUE2blHrO7knAlRCVx3j1/G6zaVlY= -github.com/getlantern/lantern-box v0.0.49 h1:ZEurOeyceCkrNbWptrbEhjS5xVphZm1v8XFnISRo8C4= -github.com/getlantern/lantern-box v0.0.49/go.mod h1:Luj0rLyuokADHg2B+eXlAdxVXYO+T5Reeds+hKuQkZA= +github.com/getlantern/lantern-box v0.0.50-0.20260323160457-f763bd127d8c h1:4OhNZ95H/5EqGmuiMD/iDXnHgEsvbe4TafL94hl6YUo= +github.com/getlantern/lantern-box v0.0.50-0.20260323160457-f763bd127d8c/go.mod h1:Luj0rLyuokADHg2B+eXlAdxVXYO+T5Reeds+hKuQkZA= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= diff --git a/servers/manager.go b/servers/manager.go index d12ca6b0..16690ae4 100644 --- a/servers/manager.go +++ b/servers/manager.go @@ -59,10 +59,11 @@ type ServerCredentials struct { } type Options struct { - Outbounds []option.Outbound `json:"outbounds,omitempty"` - Endpoints []option.Endpoint `json:"endpoints,omitempty"` - Locations map[string]C.ServerLocation `json:"locations,omitempty"` - Credentials map[string]ServerCredentials `json:"credentials,omitempty"` + Outbounds []option.Outbound `json:"outbounds,omitempty"` + Endpoints []option.Endpoint `json:"endpoints,omitempty"` + Locations map[string]C.ServerLocation `json:"locations,omitempty"` + URLOverrides map[string]string `json:"url_overrides,omitempty"` + Credentials map[string]ServerCredentials `json:"credentials,omitempty"` } // MarshalJSON encodes Options using the sing-box context so that type-specific outbound/endpoint @@ -164,10 +165,11 @@ func (m *Manager) Servers() Servers { result := make(Servers, len(m.servers)) for group, opts := range m.servers { result[group] = Options{ - Outbounds: append([]option.Outbound{}, opts.Outbounds...), - Endpoints: append([]option.Endpoint{}, opts.Endpoints...), - Locations: maps.Clone(opts.Locations), - Credentials: maps.Clone(opts.Credentials), + Outbounds: append([]option.Outbound{}, opts.Outbounds...), + Endpoints: append([]option.Endpoint{}, opts.Endpoints...), + Locations: maps.Clone(opts.Locations), + URLOverrides: maps.Clone(opts.URLOverrides), + Credentials: maps.Clone(opts.Credentials), } } return result @@ -302,10 +304,11 @@ func (m *Manager) setServers(group ServerGroup, options Options) error { slog.Log(nil, internal.LevelTrace, "Setting servers", "group", group, "options", options) opts := Options{ - Outbounds: append([]option.Outbound{}, options.Outbounds...), - Endpoints: append([]option.Endpoint{}, options.Endpoints...), - Locations: make(map[string]C.ServerLocation, len(options.Locations)), - Credentials: make(map[string]ServerCredentials, len(options.Credentials)), + Outbounds: append([]option.Outbound{}, options.Outbounds...), + Endpoints: append([]option.Endpoint{}, options.Endpoints...), + Locations: make(map[string]C.ServerLocation, len(options.Locations)), + URLOverrides: maps.Clone(options.URLOverrides), + Credentials: make(map[string]ServerCredentials, len(options.Credentials)), } maps.Copy(opts.Locations, options.Locations) maps.Copy(opts.Credentials, options.Credentials) @@ -387,6 +390,12 @@ func (m *Manager) merge(group ServerGroup, options Options) []string { servers.Credentials[out.Tag] = creds } } + for k, v := range options.URLOverrides { + if servers.URLOverrides == nil { + servers.URLOverrides = make(map[string]string) + } + servers.URLOverrides[k] = v + } m.servers[group] = servers return existingTags } diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index c9a6e211..ff0e1ed1 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -308,7 +308,7 @@ func buildOptions(ctx context.Context, path string) (O.Options, error) { lanternTags = mergeAndCollectTags(&opts, &configOpts) slog.Debug("Merged config options", "tags", lanternTags) - appendGroupOutbounds(&opts, servers.SGLantern, autoLanternTag, lanternTags) + appendGroupOutbounds(&opts, servers.SGLantern, autoLanternTag, lanternTags, cfg.BanditURLOverrides) // Load user servers slog.Debug("Loading user servers") @@ -324,14 +324,14 @@ func buildOptions(ctx context.Context, path string) (O.Options, error) { userTags = mergeAndCollectTags(&opts, &userOpts) slog.Debug("Merged user server options", "tags", userTags) } - appendGroupOutbounds(&opts, servers.SGUser, autoUserTag, userTags) + appendGroupOutbounds(&opts, servers.SGUser, autoUserTag, userTags, nil) if len(lanternTags) == 0 && len(userTags) == 0 { return O.Options{}, errors.New("no outbounds or endpoints found in config or user servers") } // Add auto all outbound - opts.Outbounds = append(opts.Outbounds, urlTestOutbound(autoAllTag, []string{autoLanternTag, autoUserTag})) + opts.Outbounds = append(opts.Outbounds, urlTestOutbound(autoAllTag, []string{autoLanternTag, autoUserTag}, nil)) // Add routing rules for the groups opts.Route.Rules = append(opts.Route.Rules, groupRule(autoAllTag)) @@ -429,8 +429,8 @@ func useIfNotZero[T comparable](newVal, oldVal T) T { return oldVal } -func appendGroupOutbounds(opts *O.Options, serverGroup, autoTag string, tags []string) { - opts.Outbounds = append(opts.Outbounds, urlTestOutbound(autoTag, tags)) +func appendGroupOutbounds(opts *O.Options, serverGroup, autoTag string, tags []string, urlOverrides map[string]string) { + opts.Outbounds = append(opts.Outbounds, urlTestOutbound(autoTag, tags, urlOverrides)) opts.Outbounds = append(opts.Outbounds, selectorOutbound(serverGroup, append([]string{autoTag}, tags...))) slog.Log( nil, internal.LevelTrace, "Added group outbounds", @@ -453,15 +453,16 @@ func groupAutoTag(group string) string { } } -func urlTestOutbound(tag string, outbounds []string) O.Outbound { +func urlTestOutbound(tag string, outbounds []string, urlOverrides map[string]string) O.Outbound { return O.Outbound{ Type: lbC.TypeMutableURLTest, Tag: tag, Options: &lbO.MutableURLTestOutboundOptions{ - Outbounds: outbounds, - URL: "https://google.com/generate_204", - Interval: badoption.Duration(urlTestInterval), - IdleTimeout: badoption.Duration(urlTestIdleTimeout), + Outbounds: outbounds, + URL: "https://google.com/generate_204", + URLOverrides: urlOverrides, + Interval: badoption.Duration(urlTestInterval), + IdleTimeout: badoption.Duration(urlTestIdleTimeout), }, } } diff --git a/vpn/boxoptions_test.go b/vpn/boxoptions_test.go index cacd91d1..edcc0571 100644 --- a/vpn/boxoptions_test.go +++ b/vpn/boxoptions_test.go @@ -223,6 +223,36 @@ func TestBuildOptions_Rulesets(t *testing.T) { }) } +func TestBuildOptions_BanditURLOverrides(t *testing.T) { + testOpts, _, err := testBoxOptions("") + require.NoError(t, err) + lanternTags, lanternOuts := filterOutbounds(*testOpts, constant.TypeHTTP) + require.NotEmpty(t, lanternTags, "need at least one HTTP outbound for test") + + overrides := map[string]string{ + lanternTags[0]: "https://example.com/callback?token=abc", + } + cfg := config.Config{ + ConfigResponse: LC.ConfigResponse{ + Options: O.Options{Outbounds: lanternOuts}, + BanditURLOverrides: overrides, + }, + } + + path := t.TempDir() + testOptsToFile(t, cfg, filepath.Join(path, common.ConfigFileName)) + + opts, err := buildOptions(context.Background(), path) + require.NoError(t, err) + + out := findOutbound(opts.Outbounds, autoLanternTag) + require.NotNil(t, out, "auto-lantern outbound not found") + + mutOpts, ok := out.Options.(*lbO.MutableURLTestOutboundOptions) + require.True(t, ok, "auto-lantern outbound should be MutableURLTestOutboundOptions") + assert.Equal(t, overrides, mutOpts.URLOverrides, "URLOverrides should be wired from config") +} + func contains[S ~[]E, E any](t *testing.T, s S, e E) bool { for _, v := range s { if optsEqual(t, v, e) { diff --git a/vpn/tunnel.go b/vpn/tunnel.go index 3dec359e..6bb6cfc2 100644 --- a/vpn/tunnel.go +++ b/vpn/tunnel.go @@ -340,6 +340,26 @@ func (t *tunnel) addOutbounds(group string, options servers.Options) (err error) added++ } } + + if len(options.URLOverrides) > 0 { + slog.Info("Applying bandit URL overrides to URL test group", + "group", autoTag, + "override_count", len(options.URLOverrides), + ) + } + if err := t.mutGrpMgr.SetURLOverrides(autoTag, options.URLOverrides); err != nil { + slog.Warn("Failed to set URL overrides", "group", autoTag, "error", err) + } else if len(options.URLOverrides) > 0 { + // Trigger an immediate URL test cycle when we have bandit overrides so + // callback probes are hit within seconds of config receipt rather than + // waiting for the next scheduled interval (3 min). + if err := t.mutGrpMgr.CheckOutbounds(autoTag); err != nil { + slog.Warn("Failed to trigger immediate URL test after bandit overrides", "group", autoTag, "error", err) + } else { + slog.Info("Triggered immediate URL test for bandit callbacks", "group", autoTag) + } + } + slog.Debug("Added servers to group", "group", group, "added", added) return errors.Join(errs...) } @@ -451,9 +471,11 @@ func (t *tunnel) updateOutbounds(new servers.Servers) error { func removeDuplicates(ctx context.Context, curr *lsync.TypedMap[string, []byte], new servers.Options, group string) servers.Options { slog.Log(nil, internal.LevelTrace, "Removing duplicate outbounds/endpoints", "group", group) deduped := servers.Options{ - Outbounds: []O.Outbound{}, - Endpoints: []O.Endpoint{}, - Locations: map[string]lcommon.ServerLocation{}, + Outbounds: []O.Outbound{}, + Endpoints: []O.Endpoint{}, + Locations: map[string]lcommon.ServerLocation{}, + URLOverrides: new.URLOverrides, + Credentials: new.Credentials, } var dropped []string for _, out := range new.Outbounds { diff --git a/vpn/tunnel_test.go b/vpn/tunnel_test.go index c16c552a..03dd03b2 100644 --- a/vpn/tunnel_test.go +++ b/vpn/tunnel_test.go @@ -72,10 +72,10 @@ func TestUpdateServers(t *testing.T) { outs := []sbO.Outbound{ allOutbounds["direct"], allOutbounds["block"], allOutbounds["http1-out"], allOutbounds["http2-out"], allOutbounds["socks1-out"], - urlTestOutbound(autoLanternTag, lanternTags), urlTestOutbound(autoUserTag, userTags), + urlTestOutbound(autoLanternTag, lanternTags, nil), urlTestOutbound(autoUserTag, userTags, nil), selectorOutbound(servers.SGLantern, append(lanternTags, autoLanternTag)), selectorOutbound(servers.SGUser, append(userTags, autoUserTag)), - urlTestOutbound(autoAllTag, []string{autoLanternTag, autoUserTag}), + urlTestOutbound(autoAllTag, []string{autoLanternTag, autoUserTag}, nil), } testOpts.Outbounds = outs diff --git a/vpn/vpn.go b/vpn/vpn.go index bdb73504..e0e8cfcc 100644 --- a/vpn/vpn.go +++ b/vpn/vpn.go @@ -495,7 +495,7 @@ func preTest(path string) (map[string]uint16, error) { for _, ob := range outbounds { tags = append(tags, ob.Tag) } - outbounds = append(outbounds, urlTestOutbound("preTest", tags)) + outbounds = append(outbounds, urlTestOutbound("preTest", tags, cfg.BanditURLOverrides)) options := option.Options{ Log: &option.LogOptions{Disabled: true}, Outbounds: outbounds,