diff --git a/go.mod b/go.mod index 5ae019ad..b1724b99 100644 --- a/go.mod +++ b/go.mod @@ -239,7 +239,7 @@ require ( github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 // indirect - github.com/miekg/dns v1.1.67 // indirect + github.com/miekg/dns v1.1.67 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -257,7 +257,7 @@ require ( github.com/sagernet/sing-shadowsocks v0.2.8 // indirect github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect - github.com/sagernet/sing-tun v0.7.4-0.20251217114513-e6c219a61ef0 // indirect + github.com/sagernet/sing-tun v0.7.4-0.20251217114513-e6c219a61ef0 github.com/sagernet/sing-vmess v0.2.7 // indirect github.com/sagernet/smux v1.5.34-mod.2 // indirect github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect diff --git a/radiance.go b/radiance.go index fa3014cd..b097295d 100644 --- a/radiance.go +++ b/radiance.go @@ -29,6 +29,7 @@ import ( "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/telemetry" "github.com/getlantern/radiance/traces" + "github.com/getlantern/radiance/vpn" "github.com/getlantern/radiance/config" "github.com/getlantern/radiance/issue" @@ -62,8 +63,9 @@ type Radiance struct { srvManager *servers.Manager // user config is the user config object that contains the device ID and other user data - userInfo common.UserInfo - locale string + userInfo common.UserInfo + locale string + adBlocker *vpn.AdBlocker shutdownFuncs []func(context.Context) error closeOnce sync.Once @@ -159,11 +161,20 @@ func NewRadiance(opts Options) (*Radiance, error) { if err := telemetry.OnNewConfig(evt.Old, evt.New, platformDeviceID, userInfo); err != nil { slog.Error("Failed to handle new config for telemetry", "error", err) } + }) + + adBlocker, err := vpn.NewAdBlocker() + if err != nil { + slog.Error("Unable to create ad blocker", "error", err) + } + confHandler := config.NewConfigHandler(cOpts) + r := &Radiance{ confHandler: confHandler, issueReporter: issueReporter, + adBlocker: adBlocker, apiHandler: apiHandler, srvManager: svrMgr, userInfo: userInfo, @@ -293,12 +304,28 @@ func (r *Radiance) ServerLocations() ([]lcommon.ServerLocation, error) { return cfg.ConfigResponse.Servers, nil } +// slogWriter is used to bridge kindling/fronted logs into slog type slogWriter struct { *slog.Logger } func (w *slogWriter) Write(p []byte) (n int, err error) { - // Convert the byte slice to a string and log it w.Info(string(p)) return len(p), nil } + +// AdBlockEnabled returns whether or not ad blocking is currently enabled +func (r *Radiance) AdBlockEnabled() bool { + if r == nil || r.adBlocker == nil { + return false + } + return r.adBlocker.IsEnabled() +} + +// SetAdBlockEnabled toggles ad blocking by updating the underlying sing-box ruleset +func (r *Radiance) SetAdBlockEnabled(enabled bool) error { + if r == nil || r.adBlocker == nil { + return fmt.Errorf("adblocker not initialized") + } + return r.adBlocker.SetEnabled(enabled) +} diff --git a/vpn/adblocker.go b/vpn/adblocker.go new file mode 100644 index 00000000..8197ff43 --- /dev/null +++ b/vpn/adblocker.go @@ -0,0 +1,208 @@ +// file: vpn/adblocker.go +package vpn + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sync" + "sync/atomic" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json" + + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/atomicfile" + "github.com/getlantern/radiance/internal" +) + +const ( + adBlockTag = "adblock" + // remote list (updated by sing-box) + adBlockListTag = "adblock-list" + adBlockFile = adBlockTag + ".json" +) + +type adblockRuleSetFile struct { + Version int `json:"version"` + Rules []adblockRule `json:"rules"` +} + +type adblockRule struct { + Type string `json:"type,omitempty"` + + // logical + Mode string `json:"mode,omitempty"` + Rules []adblockRule `json:"rules,omitempty"` + + // default match fields we need + Domain []string `json:"domain,omitempty"` + Invert bool `json:"invert,omitempty"` +} + +// AdBlocker tracks whether ad blocking is on and where its rules live +type AdBlocker struct { + mode string + ruleFile string + + enabled atomic.Bool + access sync.Mutex +} + +// NewAdBlocker creates a new instance of ad blocker, wired to the data directory +// and loads (or creates) the adblock rule file +func NewAdBlocker() (*AdBlocker, error) { + a := newAdBlocker(common.DataPath()) + + // Create parent dir if needed (defensive for early startup paths) + if err := os.MkdirAll(filepath.Dir(a.ruleFile), 0o755); err != nil { + return nil, fmt.Errorf("create adblock dir: %w", err) + } + + if _, err := os.Stat(a.ruleFile); errors.Is(err, fs.ErrNotExist) { + if err := a.save(); err != nil { + return nil, fmt.Errorf("write adblock file: %w", err) + } + } else if err != nil { + return nil, fmt.Errorf("stat adblock file: %w", err) + } + + if err := a.load(); err != nil { + return nil, fmt.Errorf("load adblock file: %w", err) + } + return a, nil +} + +func newAdBlocker(path string) *AdBlocker { + return &AdBlocker{ + mode: C.LogicalTypeAnd, + ruleFile: filepath.Join(path, adBlockFile), + } +} + +// createAdBlockRuleFile creates the adblock rules file if it does not exist +func createAdBlockRuleFile(basePath string) error { + if basePath == "" { + return fmt.Errorf("basePath is empty") + } + if err := os.MkdirAll(basePath, 0o755); err != nil { + return fmt.Errorf("create basePath: %w", err) + } + + a := newAdBlocker(basePath) + + _, err := os.Stat(a.ruleFile) + switch { + case err == nil: + return nil + case errors.Is(err, fs.ErrNotExist): + if err := a.save(); err != nil { + slog.Warn("Failed to save default adblock rule set", "path", a.ruleFile, "error", err) + return err + } + return nil + default: + slog.Warn("Failed to stat adblock rule set", "path", a.ruleFile, "error", err) + return err + } +} + +// IsEnabled returns whether or not ad blocking is currently on. +func (a *AdBlocker) IsEnabled() bool { return a.enabled.Load() } + +// SetEnabled flips ad blocking on or off. +func (a *AdBlocker) SetEnabled(enabled bool) error { + a.access.Lock() + defer a.access.Unlock() + + if a.enabled.Load() == enabled { + return nil + } + + prevMode := a.mode + if enabled { + a.mode = C.LogicalTypeOr + } else { + a.mode = C.LogicalTypeAnd + } + + if err := a.saveLocked(); err != nil { + a.mode = prevMode + return err + } + + a.enabled.Store(enabled) + slog.Log(context.Background(), internal.LevelTrace, "updated adblock", "enabled", enabled) + return nil +} + +// save updates the current mode in the adblock ruleset JSON and saves it to disk. +func (a *AdBlocker) save() error { + a.access.Lock() + defer a.access.Unlock() + return a.saveLocked() +} + +func (a *AdBlocker) saveLocked() error { + rs := adblockRuleSetFile{ + Version: 3, + Rules: []adblockRule{ + { + Type: "logical", + Mode: a.mode, // AND disables, OR enables + Rules: []adblockRule{ + // always-false “disable” gate + { + Type: "logical", + Mode: C.LogicalTypeAnd, + Rules: []adblockRule{ + {Type: "default", Domain: []string{"disable.rule"}}, + {Type: "default", Domain: []string{"disable.rule"}, Invert: true}, + }, + }, + {Type: "default", Domain: []string{"disable.rule"}, Invert: true}, + }, + }, + }, + } + + buf, err := json.Marshal(rs) + if err != nil { + return fmt.Errorf("marshal adblock ruleset: %w", err) + } + if err := atomicfile.WriteFile(a.ruleFile, buf, 0o644); err != nil { + return fmt.Errorf("write adblock file: %w", err) + } + return nil +} + +// load reads the adblock ruleset from disk and updates the mode +func (a *AdBlocker) load() error { + a.access.Lock() + defer a.access.Unlock() + return a.loadLocked() +} + +func (a *AdBlocker) loadLocked() error { + content, err := atomicfile.ReadFile(a.ruleFile) + if err != nil { + return fmt.Errorf("read adblock file: %w", err) + } + + var rs adblockRuleSetFile + if err := json.Unmarshal(content, &rs); err != nil { + return fmt.Errorf("unmarshal adblock: %w", err) + } + + if len(rs.Rules) == 0 || rs.Rules[0].Type != "logical" { + return fmt.Errorf("adblock file missing logical rule") + } + + a.mode = rs.Rules[0].Mode + a.enabled.Store(a.mode == C.LogicalTypeOr) + return nil +} diff --git a/vpn/adblocker_test.go b/vpn/adblocker_test.go new file mode 100644 index 00000000..24e97cf5 --- /dev/null +++ b/vpn/adblocker_test.go @@ -0,0 +1,109 @@ +package vpn + +import ( + stdjson "encoding/json" + "os" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getlantern/radiance/common" +) + +func setupTestAdBlocker(t *testing.T) *AdBlocker { + t.Helper() + common.SetPathsForTesting(t) + + a, err := NewAdBlocker() + require.NoError(t, err, "NewAdBlockerHandler") + require.NotEmpty(t, a.ruleFile, "ruleFile must be set") + return a +} + +func loadAdblockRuleSet(t *testing.T, a *AdBlocker) adblockRuleSetFile { + t.Helper() + + content, err := os.ReadFile(a.ruleFile) + require.NoError(t, err, "read rule file") + + var rs adblockRuleSetFile + require.NoError(t, stdjson.Unmarshal(content, &rs), "unmarshal rule file") + return rs +} + +func TestAdBlockerInitialState(t *testing.T) { + a := setupTestAdBlocker(t) + + // Default should be disabled + assert.False(t, a.IsEnabled(), "adblock should be disabled by default") + assert.Equal(t, C.LogicalTypeAnd, a.mode, "default mode should be AND") + + rs := loadAdblockRuleSet(t, a) + + require.Equal(t, 3, rs.Version, "version should be 3") + require.Len(t, rs.Rules, 1, "should have one top-level rule (outer logical)") + + outer := rs.Rules[0] + assert.Equal(t, "logical", outer.Type, "top-level rule should be logical") + assert.Equal(t, C.LogicalTypeAnd, outer.Mode, "outer mode should be AND by default") + require.Len(t, outer.Rules, 2, "outer logical should have 2 inner rules (disable gate + ruleset ref)") + + disableGate := outer.Rules[0] + assert.Equal(t, "logical", disableGate.Type, "first inner rule should be logical (disable gate)") + assert.Equal(t, C.LogicalTypeAnd, disableGate.Mode, "disable gate mode should be AND") + require.Len(t, disableGate.Rules, 2, "disable gate should have 2 inner default rules") + + r1 := disableGate.Rules[0] + assert.Equal(t, "default", r1.Type) + assert.Equal(t, []string{"disable.rule"}, r1.Domain) + assert.False(t, r1.Invert, "should not invert") + + r2 := disableGate.Rules[1] + assert.Equal(t, "default", r2.Type) + assert.Equal(t, []string{"disable.rule"}, r2.Domain) + assert.True(t, r2.Invert, "should invert") + + ref := outer.Rules[1] + assert.Equal(t, "default", ref.Type) +} + +func TestAdBlockerEnableDisable(t *testing.T) { + a := setupTestAdBlocker(t) + + // Enable + require.NoError(t, a.SetEnabled(true), "enable adblock") + assert.True(t, a.IsEnabled(), "adblock should be enabled") + assert.Equal(t, C.LogicalTypeOr, a.mode, "mode should be OR when enabled") + + rs := loadAdblockRuleSet(t, a) + require.Len(t, rs.Rules, 1) + assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Mode, "file mode should be OR when enabled") + + // Disable + require.NoError(t, a.SetEnabled(false), "disable adblock") + assert.False(t, a.IsEnabled(), "adblock should be disabled") + assert.Equal(t, C.LogicalTypeAnd, a.mode, "mode should be AND when disabled") + + rs = loadAdblockRuleSet(t, a) + require.Len(t, rs.Rules, 1) + assert.Equal(t, C.LogicalTypeAnd, rs.Rules[0].Mode, "file mode should be AND when disabled") +} + +func TestAdBlockerPersistence(t *testing.T) { + a := setupTestAdBlocker(t) + + require.NoError(t, a.SetEnabled(true), "enable adblock") + assert.True(t, a.IsEnabled(), "adblock should be enabled before reload") + + b, err := NewAdBlocker() + require.NoError(t, err, "NewAdBlocker (reload)") + + assert.True(t, b.IsEnabled(), "adblock should stay enabled after reload") + assert.Equal(t, C.LogicalTypeOr, b.mode, "mode should still be OR after reload") + + rs := loadAdblockRuleSet(t, b) + require.Len(t, rs.Rules, 1) + assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Mode, "file mode should stay OR after reload") +} diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index 7a936be0..fda44f9a 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -46,8 +46,9 @@ const ( // this is the base options that is need for everything to work correctly. this should not be // changed unless you know what you're doing. func baseOpts(basePath string) O.Options { - splitTunnelPath := filepath.Join(basePath, splitTunnelFile) - directPath := filepath.Join(basePath, directFile) + splitTunnelPath := localBoxPath(basePath, splitTunnelFile) + directPath := localBoxPath(basePath, directFile) + adBlockPath := localBoxPath(basePath, adBlockFile) // Write the domains to access directly to a file to disk. if err := os.WriteFile(directPath, []byte(inlineDirectRuleSet), 0644); err != nil { @@ -127,6 +128,14 @@ func baseOpts(basePath string) O.Options { }, Format: C.RuleSetFormatSource, }, + { + Type: C.RuleSetTypeLocal, + Tag: adBlockTag, + LocalOptions: O.LocalRuleSet{ + Path: adBlockPath, + }, + Format: C.RuleSetFormatSource, + }, }, }, Experimental: &O.ExperimentalOptions{ @@ -144,6 +153,13 @@ func baseOpts(basePath string) O.Options { } } +func localBoxPath(basePath, name string) string { + if common.IsWindows() { + return name + } + return filepath.Join(basePath, name) +} + func baseRoutingRules() []O.Rule { // routing rules are evaluated in the order they are defined and the first matching rule // is applied. So order is important here. @@ -152,10 +168,11 @@ func baseRoutingRules() []O.Rule { // 2. Hijack DNS to allow sing-box to handle DNS requests // 3. Route private IPs to direct outbound // 4. Split tunnel rule - // 5. Bypass Lantern process traffic (not on mobile) - // 6. rules from config file (added in buildOptions) - // 7-9. Group rules for auto, lantern, and user (added in buildOptions) - // 10. Catch-all blocking rule (added in buildOptions). This ensures that any traffic not covered + // 5. Ad-block rule + // 6. Bypass Lantern process traffic (not on mobile) + // 7. rules from config file (added in buildOptions) + // 8-10. Group rules for auto, lantern, and user (added in buildOptions) + // 11. Catch-all blocking rule (added in buildOptions). This ensures that any traffic not covered // by previous rules does not automatically bypass the VPN. // // * DO NOT change the order of these rules unless you know what you're doing. Changing these @@ -219,6 +236,20 @@ func baseRoutingRules() []O.Rule { }, }, }, + { // ad-block rule + Type: C.RuleTypeDefault, + DefaultOptions: O.DefaultRule{ + RawDefaultRule: O.RawDefaultRule{ + RuleSet: []string{adBlockTag}, + }, + RuleAction: O.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: O.RouteActionOptions{ + Outbound: "block", + }, + }, + }, + }, } if !common.IsAndroid() && !common.IsIOS() { rules = append(rules, O.Rule{ @@ -247,7 +278,7 @@ func buildOptions(group, path string) (O.Options, error) { slog.Debug("Base options initialized") // update default options and paths - opts.Experimental.CacheFile.Path = filepath.Join(path, cacheFileName) + opts.Experimental.CacheFile.Path = localBoxPath(path, cacheFileName) opts.Experimental.ClashAPI.DefaultMode = group slog.Log(nil, internal.LevelTrace, "Updated default options and paths", diff --git a/vpn/tunnel_test.go b/vpn/tunnel_test.go index 6dc43b76..da5a4515 100644 --- a/vpn/tunnel_test.go +++ b/vpn/tunnel_test.go @@ -1,6 +1,7 @@ package vpn import ( + "os" "path/filepath" "testing" "time" @@ -12,8 +13,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/getlantern/lantern-box/adapter" - "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/vpn/ipc" @@ -107,24 +106,24 @@ func TestUpdateServers(t *testing.T) { } } -func getGroups(outboundMgr sbA.OutboundManager) []adapter.MutableOutboundGroup { - outbounds := outboundMgr.Outbounds() - var iGroups []adapter.MutableOutboundGroup - for _, it := range outbounds { - if group, isGroup := it.(adapter.MutableOutboundGroup); isGroup { - iGroups = append(iGroups, group) - } - } - return iGroups -} - func testEstablishConnection(t *testing.T, opts sbO.Options) { tmp := common.DataPath() - opts.Route.RuleSet = baseOpts(common.DataPath()).Route.RuleSet + base := baseOpts(tmp) + opts.Route.RuleSet = base.Route.RuleSet opts.Route.RuleSet[0].LocalOptions.Path = filepath.Join(tmp, splitTunnelFile) - opts.Route.Rules = append([]sbO.Rule{baseOpts(common.DataPath()).Route.Rules[2]}, opts.Route.Rules...) + opts.Route.RuleSet[1].LocalOptions.Path = filepath.Join(tmp, directFile) + opts.Route.RuleSet[2].LocalOptions.Path = filepath.Join(tmp, adBlockFile) + newSplitTunnel(tmp) + require.NoError(t, createAdBlockRuleFile(tmp)) + require.NoError(t, os.WriteFile( + opts.Route.RuleSet[1].LocalOptions.Path, + []byte(inlineDirectRuleSet), + 0644, + )) + + opts.Route.Rules = append([]sbO.Rule{base.Route.Rules[2]}, opts.Route.Rules...) err := establishConnection("", "", opts, tmp, nil) require.NoError(t, err, "failed to establish connection") diff --git a/vpn/vpn.go b/vpn/vpn.go index ec0a2df8..87323990 100644 --- a/vpn/vpn.go +++ b/vpn/vpn.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log/slog" - "path/filepath" "slices" "time" @@ -88,6 +87,8 @@ func ConnectToServer(group, tag string, platIfce libbox.PlatformInterface) error func connect(group, tag string, platIfce libbox.PlatformInterface) error { path := common.DataPath() _ = newSplitTunnel(path) // ensure split tunnel rule file exists to prevent sing-box from complaining + createAdBlockRuleFile(path) + opts, err := buildOptions(group, path) if err != nil { return fmt.Errorf("failed to build options: %w", err) @@ -187,7 +188,7 @@ func selectedServer(ctx context.Context) (string, string, error) { } slog.Log(nil, internal.LevelTrace, "Tunnel not running, reading from cache file") opts := baseOpts(common.DataPath()).Experimental.CacheFile - opts.Path = filepath.Join(common.DataPath(), cacheFileName) + opts.Path = localBoxPath(common.DataPath(), cacheFileName) cacheFile := cachefile.New(context.Background(), *opts) if err := cacheFile.Start(adapter.StartStateInitialize); err != nil { return "", "", fmt.Errorf("failed to start cache file: %w", err)