From 4462610cb207c497828c1a9064600260fa3876fe Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 19 Nov 2025 12:03:16 -0800 Subject: [PATCH 1/9] update comments --- go.mod | 4 ++-- radiance.go | 31 ++++++++++++++++++++++++++++--- vpn/boxoptions.go | 38 +++++++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 10a7fd3c..d2e4b9aa 100644 --- a/go.mod +++ b/go.mod @@ -199,7 +199,7 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/metacubex/tfo-go v0.0.0-20241231083714-66613d49c422 // indirect - github.com/miekg/dns v1.1.63 // indirect + github.com/miekg/dns v1.1.63 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 @@ -219,7 +219,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.0 // indirect - github.com/sagernet/sing-tun v0.6.9 // indirect + github.com/sagernet/sing-tun v0.6.9 github.com/sagernet/sing-vmess v0.2.3 // indirect github.com/sagernet/smux v1.5.34-mod.2 // indirect github.com/sagernet/utls v1.6.7 // indirect diff --git a/radiance.go b/radiance.go index 9cc4512d..a07e2ba1 100644 --- a/radiance.go +++ b/radiance.go @@ -28,6 +28,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" @@ -61,8 +62,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 @@ -154,9 +156,16 @@ func NewRadiance(opts Options) (*Radiance, error) { } }, ) + + adBlocker, err := vpn.NewAdBlockerHandler() + if err != nil { + slog.Error("Unable to create ad blocker", "error", err) + } + r := &Radiance{ confHandler: confHandler, issueReporter: issueReporter, + adBlocker: adBlocker, apiHandler: apiHandler, srvManager: svrMgr, userInfo: userInfo, @@ -293,12 +302,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/boxoptions.go b/vpn/boxoptions.go index 404ab542..3e6403ab 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -48,6 +48,7 @@ const ( func baseOpts(basePath string) O.Options { splitTunnelPath := filepath.Join(basePath, splitTunnelFile) directPath := filepath.Join(basePath, directFile) + adBlockPath := filepath.Join(basePath, adBlockFile) // Write the domains to access directly to a file to disk. if err := os.WriteFile(directPath, []byte(inlineDirectRuleSet), 0644); err != nil { @@ -61,7 +62,11 @@ func baseOpts(basePath string) O.Options { if common.IsWindows() { splitTunnelPath = splitTunnelFile directPath = directFile - slog.Info("Adjusted split tunnel and direct paths for Windows", "splitTunnelPath", splitTunnelPath, "directPath", directPath) + adBlockPath = adBlockFile + slog.Info("Adjusted split tunnel and direct paths for Windows", + "splitTunnelPath", splitTunnelPath, + "directPath", directPath, + "adBlockPath", adBlockPath) } return O.Options{ @@ -158,6 +163,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{ @@ -183,10 +196,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 @@ -250,6 +264,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{ From 69d27e7673d5432981966b2a72d7bb866bf37af9 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 19 Nov 2025 12:03:23 -0800 Subject: [PATCH 2/9] add adblocker --- vpn/adblocker.go | 160 ++++++++++++++++++++++++++++++++++++++++++ vpn/adblocker_test.go | 112 +++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 vpn/adblocker.go create mode 100644 vpn/adblocker_test.go diff --git a/vpn/adblocker.go b/vpn/adblocker.go new file mode 100644 index 00000000..17da2dab --- /dev/null +++ b/vpn/adblocker.go @@ -0,0 +1,160 @@ +package vpn + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync/atomic" + + C "github.com/sagernet/sing-box/constant" + O "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json" + + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/internal" +) + +const ( + adBlockTag = "adblock" + // remote list (updated by sing-box) + adBlockListTag = "adblock-list" + adBlockFile = adBlockTag + ".json" +) + +// For headless ruleset JSON: +// +// { +// "version": 3, +// "rules": [ +// { "type": "logical", "logical": { "mode": "...", "rules": [...] } }, +// { "type": "rule_set", "rule_set": ["adblock-list"] } +// ] +// } + +// adblockHeadlessRule is a minimal wrapper around the structures we need. +// We reuse sing-box's LogicalHeadlessRule for the logical gate and use a string array +// for the rule set reference +type adblockHeadlessRule struct { + Type string `json:"type"` + Logical *O.LogicalHeadlessRule `json:"logical,omitempty"` + RuleSet []string `json:"rule_set,omitempty"` +} + +// adblockRuleSet is the top-level struct for persisting ad blocking rules +type adblockRuleSet struct { + Version int `json:"version"` + Rules []adblockHeadlessRule `json:"rules"` +} + +// AdBlocker tracks whether ad blocking is on and where its rules live +type AdBlocker struct { + mode string + ruleFile string + enabled *atomic.Bool +} + +// NewAdBlockerHandler wires ad blocking up to the data directory and loads +// or creates the rule file +func NewAdBlockerHandler() (*AdBlocker, error) { + a := newAdBlocker(common.DataPath()) + if _, err := os.Stat(a.ruleFile); os.IsNotExist(err) { + if err := a.save(); err != nil { + return nil, fmt.Errorf("write 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), + enabled: &atomic.Bool{}, + } +} + +// IsEnabled checks if ad blocking is currently turned on +func (a *AdBlocker) IsEnabled() bool { return a.enabled.Load() } + +// SetEnabled flips ad blocking on or off +func (a *AdBlocker) SetEnabled(enabled bool) error { + prev := a.mode + if enabled { + a.mode = C.LogicalTypeOr + } else { + a.mode = C.LogicalTypeAnd + } + if err := a.save(); err != nil { + a.mode = prev + return err + } + a.enabled.Store(enabled) + slog.Log(context.Background(), internal.LevelTrace, "updated adblock", "enabled", enabled) + return nil +} + +// save rewrites the adblock ruleset JSON on disk based on the current mode +func (a *AdBlocker) save() error { + rs := adblockRuleSet{ + Version: 3, + Rules: []adblockHeadlessRule{ + { + Type: "logical", + Logical: &O.LogicalHeadlessRule{ + Mode: a.mode, + Rules: []O.HeadlessRule{ + { + Type: "default", + DefaultOptions: O.DefaultHeadlessRule{ + Domain: []string{"disable.rule"}, + }, + }, + { + Type: "default", + DefaultOptions: O.DefaultHeadlessRule{ + Domain: []string{"disable.rule"}, + Invert: true, + }, + }, + }, + }, + }, + { + Type: "rule_set", + RuleSet: []string{adBlockListTag}, + }, + }, + } + + buf, err := json.Marshal(rs) + if err != nil { + return fmt.Errorf("marshal adblock ruleset: %w", err) + } + return os.WriteFile(a.ruleFile, buf, 0o644) +} + +// load reads the adblock ruleset from disk and updates the mode +func (a *AdBlocker) load() error { + content, err := os.ReadFile(a.ruleFile) + if err != nil { + return fmt.Errorf("read adblock file: %w", err) + } + + var rs adblockRuleSet + if err := json.Unmarshal(content, &rs); err != nil { + return fmt.Errorf("unmarshal adblock: %w", err) + } + + if len(rs.Rules) == 0 || rs.Rules[0].Logical == nil { + return fmt.Errorf("adblock file missing logical rule") + } + + a.mode = rs.Rules[0].Logical.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..d993f50d --- /dev/null +++ b/vpn/adblocker_test.go @@ -0,0 +1,112 @@ +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 := NewAdBlockerHandler() + require.NoError(t, err, "NewAdBlockerHandler") + require.NotEmpty(t, a.ruleFile, "ruleFile must be set") + return a +} + +func loadAdblockRuleSet(t *testing.T, a *AdBlocker) adblockRuleSet { + t.Helper() + + content, err := os.ReadFile(a.ruleFile) + require.NoError(t, err, "read rule file") + + var rs adblockRuleSet + 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") + + // Load ad block rule set + rs := loadAdblockRuleSet(t, a) + + require.Equal(t, 3, rs.Version, "version should be 3") + require.Len(t, rs.Rules, 2, "should have two rules (logical + rule_set)") + + // First rule: logical gate + logicalRule := rs.Rules[0] + assert.Equal(t, "logical", logicalRule.Type, "first rule should be logical") + require.NotNil(t, logicalRule.Logical, "logical must not be nil") + assert.Equal(t, C.LogicalTypeAnd, logicalRule.Logical.Mode, "logical mode should be AND by default") + + require.Len(t, logicalRule.Logical.Rules, 2, "logical should have 2 inner rules") + + // Inner rule where domain == disable.rule + r1 := logicalRule.Logical.Rules[0] + assert.Equal(t, "default", r1.Type) + assert.Equal(t, []string{"disable.rule"}, []string(r1.DefaultOptions.Domain)) + assert.False(t, r1.DefaultOptions.Invert, "should not invert") + + // Inner rule where domain == disable.rule, invert == true + r2 := logicalRule.Logical.Rules[1] + assert.Equal(t, "default", r2.Type) + assert.Equal(t, []string{"disable.rule"}, []string(r2.DefaultOptions.Domain)) + assert.True(t, r2.DefaultOptions.Invert, "should invert") + + // Second rule: rule_set -> adblock-list + rsRule := rs.Rules[1] + assert.Equal(t, "rule_set", rsRule.Type, "second rule should be rule_set") + assert.Equal(t, []string{adBlockListTag}, rsRule.RuleSet, "rule_set should reference adblock list tag") +} + +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.NotNil(t, rs.Rules[0].Logical) + assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Logical.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.NotNil(t, rs.Rules[0].Logical) + assert.Equal(t, C.LogicalTypeAnd, rs.Rules[0].Logical.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 := NewAdBlockerHandler() + require.NoError(t, err, "NewAdBlockerHandler (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.NotNil(t, rs.Rules[0].Logical) + assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Logical.Mode, "file mode should stay OR after reload") +} From 41ed47c544846714db56509a638ccc71b48851b2 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 19 Nov 2025 12:09:19 -0800 Subject: [PATCH 3/9] clean-ups --- radiance.go | 2 +- vpn/adblocker.go | 9 +++++---- vpn/adblocker_test.go | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/radiance.go b/radiance.go index a07e2ba1..b418b98c 100644 --- a/radiance.go +++ b/radiance.go @@ -157,7 +157,7 @@ func NewRadiance(opts Options) (*Radiance, error) { }, ) - adBlocker, err := vpn.NewAdBlockerHandler() + adBlocker, err := vpn.NewAdBlocker() if err != nil { slog.Error("Unable to create ad blocker", "error", err) } diff --git a/vpn/adblocker.go b/vpn/adblocker.go index 17da2dab..1bca27cf 100644 --- a/vpn/adblocker.go +++ b/vpn/adblocker.go @@ -55,9 +55,9 @@ type AdBlocker struct { enabled *atomic.Bool } -// NewAdBlockerHandler wires ad blocking up to the data directory and loads +// NewAdBlocker wires ad blocking up to the data directory and loads // or creates the rule file -func NewAdBlockerHandler() (*AdBlocker, error) { +func NewAdBlocker() (*AdBlocker, error) { a := newAdBlocker(common.DataPath()) if _, err := os.Stat(a.ruleFile); os.IsNotExist(err) { if err := a.save(); err != nil { @@ -78,7 +78,7 @@ func newAdBlocker(path string) *AdBlocker { } } -// IsEnabled checks if ad blocking is currently turned on +// IsEnabled returns whether or not ad blocking currently turned on func (a *AdBlocker) IsEnabled() bool { return a.enabled.Load() } // SetEnabled flips ad blocking on or off @@ -98,7 +98,8 @@ func (a *AdBlocker) SetEnabled(enabled bool) error { return nil } -// save rewrites the adblock ruleset JSON on disk based on the current mode +// save rewrites the adblock ruleset JSON with the current mode +// and saves it to disk func (a *AdBlocker) save() error { rs := adblockRuleSet{ Version: 3, diff --git a/vpn/adblocker_test.go b/vpn/adblocker_test.go index d993f50d..e4ffb010 100644 --- a/vpn/adblocker_test.go +++ b/vpn/adblocker_test.go @@ -100,8 +100,8 @@ func TestAdBlockerPersistence(t *testing.T) { require.NoError(t, a.SetEnabled(true), "enable adblock") assert.True(t, a.IsEnabled(), "adblock should be enabled before reload") - b, err := NewAdBlockerHandler() - require.NoError(t, err, "NewAdBlockerHandler (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") From 190d513a3a8bad51fabe2756fb984e01abc98c64 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 19 Nov 2025 12:11:57 -0800 Subject: [PATCH 4/9] update comments --- vpn/adblocker.go | 8 +++----- vpn/adblocker_test.go | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/vpn/adblocker.go b/vpn/adblocker.go index 1bca27cf..2eb1e4f3 100644 --- a/vpn/adblocker.go +++ b/vpn/adblocker.go @@ -33,9 +33,7 @@ const ( // ] // } -// adblockHeadlessRule is a minimal wrapper around the structures we need. -// We reuse sing-box's LogicalHeadlessRule for the logical gate and use a string array -// for the rule set reference +// adblockHeadlessRule is a minimal wrapper for ad blocking around O.LogicalHeadlessRule type adblockHeadlessRule struct { Type string `json:"type"` Logical *O.LogicalHeadlessRule `json:"logical,omitempty"` @@ -55,8 +53,8 @@ type AdBlocker struct { enabled *atomic.Bool } -// NewAdBlocker wires ad blocking up to the data directory and loads -// or creates the rule file +// NewAdBlocker creates a new instance of ad blocker, with ad blocking wired +// up to the data directory and loads (or creates) the adblock rule file func NewAdBlocker() (*AdBlocker, error) { a := newAdBlocker(common.DataPath()) if _, err := os.Stat(a.ruleFile); os.IsNotExist(err) { diff --git a/vpn/adblocker_test.go b/vpn/adblocker_test.go index e4ffb010..c3896f69 100644 --- a/vpn/adblocker_test.go +++ b/vpn/adblocker_test.go @@ -16,7 +16,7 @@ func setupTestAdBlocker(t *testing.T) *AdBlocker { t.Helper() common.SetPathsForTesting(t) - a, err := NewAdBlockerHandler() + a, err := NewAdBlocker() require.NoError(t, err, "NewAdBlockerHandler") require.NotEmpty(t, a.ruleFile, "ruleFile must be set") return a From 22cd517fc2343b7844ada10b93483dd92635bc86 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 19 Nov 2025 12:17:21 -0800 Subject: [PATCH 5/9] update comments --- vpn/adblocker.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vpn/adblocker.go b/vpn/adblocker.go index 2eb1e4f3..53220f53 100644 --- a/vpn/adblocker.go +++ b/vpn/adblocker.go @@ -96,8 +96,7 @@ func (a *AdBlocker) SetEnabled(enabled bool) error { return nil } -// save rewrites the adblock ruleset JSON with the current mode -// and saves it to disk +// save updates the current mode in the adblock ruleset JSON and saves it to disk func (a *AdBlocker) save() error { rs := adblockRuleSet{ Version: 3, From 341f39a271f65a6a83f781b61681fb9ae0368618 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 19 Nov 2025 12:24:26 -0800 Subject: [PATCH 6/9] Fix tests and save default rule set --- vpn/boxoptions.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index 3e6403ab..6b6d2f14 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -57,6 +57,10 @@ func baseOpts(basePath string) O.Options { slog.Info("Wrote inline direct rule set to file", "path", directPath) } + if err := newAdBlocker(basePath).save(); err != nil { + slog.Warn("Failed to save default adblock rule set", "path", adBlockPath, "error", err) + } + // For whatever reason, sing-box seems to append the path to the base path on Windows, so we // just use the file names directly. if common.IsWindows() { From b28fea2ff968ee8d85b88623f8738cf382edeca2 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 17 Dec 2025 13:06:52 -0800 Subject: [PATCH 7/9] clean-ups --- vpn/adblocker.go | 104 ++++++++++++++++++++++++++++++++++++---------- vpn/boxoptions.go | 31 +++++--------- vpn/vpn.go | 5 ++- 3 files changed, 95 insertions(+), 45 deletions(-) diff --git a/vpn/adblocker.go b/vpn/adblocker.go index 53220f53..b15eb5bd 100644 --- a/vpn/adblocker.go +++ b/vpn/adblocker.go @@ -1,11 +1,15 @@ +// 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" @@ -13,6 +17,7 @@ import ( "github.com/sagernet/sing/common/json" "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/atomicfile" "github.com/getlantern/radiance/internal" ) @@ -23,16 +28,6 @@ const ( adBlockFile = adBlockTag + ".json" ) -// For headless ruleset JSON: -// -// { -// "version": 3, -// "rules": [ -// { "type": "logical", "logical": { "mode": "...", "rules": [...] } }, -// { "type": "rule_set", "rule_set": ["adblock-list"] } -// ] -// } - // adblockHeadlessRule is a minimal wrapper for ad blocking around O.LogicalHeadlessRule type adblockHeadlessRule struct { Type string `json:"type"` @@ -50,18 +45,29 @@ type adblockRuleSet struct { type AdBlocker struct { mode string ruleFile string - enabled *atomic.Bool + + enabled atomic.Bool + access sync.Mutex } -// NewAdBlocker creates a new instance of ad blocker, with ad blocking wired -// up to the data directory and loads (or creates) the adblock rule file +// 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()) - if _, err := os.Stat(a.ruleFile); os.IsNotExist(err) { + + // 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) } @@ -72,32 +78,74 @@ func newAdBlocker(path string) *AdBlocker { return &AdBlocker{ mode: C.LogicalTypeAnd, ruleFile: filepath.Join(path, adBlockFile), - enabled: &atomic.Bool{}, } } -// IsEnabled returns whether or not ad blocking currently turned on +// 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 +// SetEnabled flips ad blocking on or off. func (a *AdBlocker) SetEnabled(enabled bool) error { - prev := a.mode + 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.save(); err != nil { - a.mode = prev + + 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 +// 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() +} + +// saveLocked assumes a.access is already held. +func (a *AdBlocker) saveLocked() error { rs := adblockRuleSet{ Version: 3, Rules: []adblockHeadlessRule{ @@ -133,12 +181,22 @@ func (a *AdBlocker) save() error { if err != nil { return fmt.Errorf("marshal adblock ruleset: %w", err) } - return os.WriteFile(a.ruleFile, buf, 0o644) + + 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 { - content, err := os.ReadFile(a.ruleFile) + 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) } diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index 6d9d4e20..fda44f9a 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -46,9 +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) - adBlockPath := filepath.Join(basePath, adBlockFile) + 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 { @@ -57,22 +57,6 @@ func baseOpts(basePath string) O.Options { slog.Info("Wrote inline direct rule set to file", "path", directPath) } - if err := newAdBlocker(basePath).save(); err != nil { - slog.Warn("Failed to save default adblock rule set", "path", adBlockPath, "error", err) - } - - // For whatever reason, sing-box seems to append the path to the base path on Windows, so we - // just use the file names directly. - if common.IsWindows() { - splitTunnelPath = splitTunnelFile - directPath = directFile - adBlockPath = adBlockFile - slog.Info("Adjusted split tunnel and direct paths for Windows", - "splitTunnelPath", splitTunnelPath, - "directPath", directPath, - "adBlockPath", adBlockPath) - } - return O.Options{ Log: &O.LogOptions{ Level: "debug", @@ -169,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. @@ -287,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/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) From ed7dca13fb8be04427ba80cbf43f58183a0cc839 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 17 Dec 2025 13:29:11 -0800 Subject: [PATCH 8/9] fix test --- vpn/tunnel_test.go | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) 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") From ee0014eb0e7943744d46455f7bc2026ebea48320 Mon Sep 17 00:00:00 2001 From: atavism Date: Wed, 17 Dec 2025 14:01:26 -0800 Subject: [PATCH 9/9] fix test --- vpn/adblocker.go | 64 +++++++++++++++++++------------------------ vpn/adblocker_test.go | 53 +++++++++++++++++------------------ 2 files changed, 53 insertions(+), 64 deletions(-) diff --git a/vpn/adblocker.go b/vpn/adblocker.go index b15eb5bd..8197ff43 100644 --- a/vpn/adblocker.go +++ b/vpn/adblocker.go @@ -13,7 +13,6 @@ import ( "sync/atomic" C "github.com/sagernet/sing-box/constant" - O "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/json" "github.com/getlantern/radiance/common" @@ -28,17 +27,21 @@ const ( adBlockFile = adBlockTag + ".json" ) -// adblockHeadlessRule is a minimal wrapper for ad blocking around O.LogicalHeadlessRule -type adblockHeadlessRule struct { - Type string `json:"type"` - Logical *O.LogicalHeadlessRule `json:"logical,omitempty"` - RuleSet []string `json:"rule_set,omitempty"` +type adblockRuleSetFile struct { + Version int `json:"version"` + Rules []adblockRule `json:"rules"` } -// adblockRuleSet is the top-level struct for persisting ad blocking rules -type adblockRuleSet struct { - Version int `json:"version"` - Rules []adblockHeadlessRule `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 @@ -144,36 +147,26 @@ func (a *AdBlocker) save() error { return a.saveLocked() } -// saveLocked assumes a.access is already held. func (a *AdBlocker) saveLocked() error { - rs := adblockRuleSet{ + rs := adblockRuleSetFile{ Version: 3, - Rules: []adblockHeadlessRule{ + Rules: []adblockRule{ { Type: "logical", - Logical: &O.LogicalHeadlessRule{ - Mode: a.mode, - Rules: []O.HeadlessRule{ - { - Type: "default", - DefaultOptions: O.DefaultHeadlessRule{ - Domain: []string{"disable.rule"}, - }, - }, - { - Type: "default", - DefaultOptions: O.DefaultHeadlessRule{ - Domain: []string{"disable.rule"}, - Invert: true, - }, + 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}, }, }, - { - Type: "rule_set", - RuleSet: []string{adBlockListTag}, - }, }, } @@ -181,7 +174,6 @@ func (a *AdBlocker) saveLocked() error { 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) } @@ -201,16 +193,16 @@ func (a *AdBlocker) loadLocked() error { return fmt.Errorf("read adblock file: %w", err) } - var rs adblockRuleSet + 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].Logical == nil { + if len(rs.Rules) == 0 || rs.Rules[0].Type != "logical" { return fmt.Errorf("adblock file missing logical rule") } - a.mode = rs.Rules[0].Logical.Mode + 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 index c3896f69..24e97cf5 100644 --- a/vpn/adblocker_test.go +++ b/vpn/adblocker_test.go @@ -22,13 +22,13 @@ func setupTestAdBlocker(t *testing.T) *AdBlocker { return a } -func loadAdblockRuleSet(t *testing.T, a *AdBlocker) adblockRuleSet { +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 adblockRuleSet + var rs adblockRuleSetFile require.NoError(t, stdjson.Unmarshal(content, &rs), "unmarshal rule file") return rs } @@ -40,36 +40,33 @@ func TestAdBlockerInitialState(t *testing.T) { assert.False(t, a.IsEnabled(), "adblock should be disabled by default") assert.Equal(t, C.LogicalTypeAnd, a.mode, "default mode should be AND") - // Load ad block rule set rs := loadAdblockRuleSet(t, a) require.Equal(t, 3, rs.Version, "version should be 3") - require.Len(t, rs.Rules, 2, "should have two rules (logical + rule_set)") + require.Len(t, rs.Rules, 1, "should have one top-level rule (outer logical)") - // First rule: logical gate - logicalRule := rs.Rules[0] - assert.Equal(t, "logical", logicalRule.Type, "first rule should be logical") - require.NotNil(t, logicalRule.Logical, "logical must not be nil") - assert.Equal(t, C.LogicalTypeAnd, logicalRule.Logical.Mode, "logical mode should be AND by default") + 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)") - require.Len(t, logicalRule.Logical.Rules, 2, "logical should have 2 inner rules") + 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") - // Inner rule where domain == disable.rule - r1 := logicalRule.Logical.Rules[0] + r1 := disableGate.Rules[0] assert.Equal(t, "default", r1.Type) - assert.Equal(t, []string{"disable.rule"}, []string(r1.DefaultOptions.Domain)) - assert.False(t, r1.DefaultOptions.Invert, "should not invert") + assert.Equal(t, []string{"disable.rule"}, r1.Domain) + assert.False(t, r1.Invert, "should not invert") - // Inner rule where domain == disable.rule, invert == true - r2 := logicalRule.Logical.Rules[1] + r2 := disableGate.Rules[1] assert.Equal(t, "default", r2.Type) - assert.Equal(t, []string{"disable.rule"}, []string(r2.DefaultOptions.Domain)) - assert.True(t, r2.DefaultOptions.Invert, "should invert") + assert.Equal(t, []string{"disable.rule"}, r2.Domain) + assert.True(t, r2.Invert, "should invert") - // Second rule: rule_set -> adblock-list - rsRule := rs.Rules[1] - assert.Equal(t, "rule_set", rsRule.Type, "second rule should be rule_set") - assert.Equal(t, []string{adBlockListTag}, rsRule.RuleSet, "rule_set should reference adblock list tag") + ref := outer.Rules[1] + assert.Equal(t, "default", ref.Type) } func TestAdBlockerEnableDisable(t *testing.T) { @@ -81,8 +78,8 @@ func TestAdBlockerEnableDisable(t *testing.T) { assert.Equal(t, C.LogicalTypeOr, a.mode, "mode should be OR when enabled") rs := loadAdblockRuleSet(t, a) - require.NotNil(t, rs.Rules[0].Logical) - assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Logical.Mode, "file mode should be OR when enabled") + 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") @@ -90,8 +87,8 @@ func TestAdBlockerEnableDisable(t *testing.T) { assert.Equal(t, C.LogicalTypeAnd, a.mode, "mode should be AND when disabled") rs = loadAdblockRuleSet(t, a) - require.NotNil(t, rs.Rules[0].Logical) - assert.Equal(t, C.LogicalTypeAnd, rs.Rules[0].Logical.Mode, "file mode should be AND when disabled") + 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) { @@ -107,6 +104,6 @@ func TestAdBlockerPersistence(t *testing.T) { assert.Equal(t, C.LogicalTypeOr, b.mode, "mode should still be OR after reload") rs := loadAdblockRuleSet(t, b) - require.NotNil(t, rs.Rules[0].Logical) - assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Logical.Mode, "file mode should stay OR after reload") + require.Len(t, rs.Rules, 1) + assert.Equal(t, C.LogicalTypeOr, rs.Rules[0].Mode, "file mode should stay OR after reload") }