Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
33 changes: 30 additions & 3 deletions radiance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Comment on lines +167 to +170
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If vpn.NewAdBlocker() fails, the error is logged but execution continues with a nil adBlocker. This means r.adBlocker will be nil, but the Radiance instance is still created and returned successfully. While the public methods AdBlockEnabled() and SetAdBlockEnabled() do handle nil checks, it would be clearer to either:

  1. Return an error from NewRadiance() if ad blocker initialization is critical, or
  2. Document this behavior explicitly that ad blocking is optional and may be unavailable

Copilot uses AI. Check for mistakes.

confHandler := config.NewConfigHandler(cOpts)

r := &Radiance{
confHandler: confHandler,
issueReporter: issueReporter,
adBlocker: adBlocker,
apiHandler: apiHandler,
srvManager: svrMgr,
userInfo: userInfo,
Expand Down Expand Up @@ -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)
}
208 changes: 208 additions & 0 deletions vpn/adblocker.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading