Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
87 changes: 5 additions & 82 deletions vpn/boxoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,22 +169,8 @@ func hasGlobalIPv6Using(getSnapshots func() ([]ifaceSnapshot, error)) bool {
// baseOpts returns the minimum sing-box options required for the tunnel to
// function. Do not modify without understanding the downstream effects.
func baseOpts(basePath string) O.Options {
// ensure split tunnel file exists
splitTunnelPath := newSplitTunnel(basePath, slog.Default()).ruleFile

cacheFile := cacheFilePath(basePath)
loopbackAddr := badoption.Addr(netip.MustParseAddr("127.0.0.1"))

// v6 ULA conditional on system v6 — see hasGlobalIPv6.
tunAddress := []netip.Prefix{
netip.MustParsePrefix("10.10.1.1/30"),
}
if hasGlobalIPv6() {
tunAddress = append(tunAddress, netip.MustParsePrefix("fdfe:dcba:9876::1/126"))
slog.Info("vpn: TUN with IPv6 ULA (system has global v6)")
} else {
slog.Info("vpn: TUN IPv4-only (no global v6 detected)")
}
return O.Options{
Log: &O.LogOptions{
Level: "debug",
Expand All @@ -200,30 +186,7 @@ func baseOpts(basePath string) O.Options {
Final: "dns_local",
},
},
Inbounds: []O.Inbound{
{
Type: "tun",
Tag: "tun-in",
Options: &O.TunInboundOptions{
InterfaceName: "utun225",
Address: tunAddress,
AutoRoute: true,
StrictRoute: true,
EndpointIndependentNat: true, // needed for QUIC migration and hole-punching
Stack: "system", // fallback to gvisor on older Android kernels in buildOptions
},
},
{
Type: C.TypeMixed,
Tag: bypass.BypassInboundTag,
Options: &O.HTTPMixedInboundOptions{
ListenOptions: O.ListenOptions{
Listen: &loopbackAddr,
ListenPort: bypass.ProxyPort,
},
},
},
},
Inbounds: baseInbounds(),
Outbounds: []O.Outbound{
{
Type: C.TypeDirect,
Expand All @@ -239,16 +202,7 @@ func baseOpts(basePath string) O.Options {
Route: &O.RouteOptions{
AutoDetectInterface: true,
Rules: baseRoutingRules(),
RuleSet: []O.RuleSet{
{
Type: C.RuleSetTypeLocal,
Tag: splitTunnelTag,
LocalOptions: O.LocalRuleSet{
Path: splitTunnelPath,
},
Format: C.RuleSetFormatSource,
},
},
RuleSet: splitTunnelRuleSet(basePath),
DefaultDomainResolver: &O.DomainResolveOptions{
Server: "dns_local",
},
Expand Down Expand Up @@ -351,21 +305,8 @@ func baseRoutingRules() []O.Rule {
},
},
},
{ // split tunnel rule
Type: C.RuleTypeDefault,
DefaultOptions: O.DefaultRule{
RawDefaultRule: O.RawDefaultRule{
RuleSet: []string{splitTunnelTag},
},
RuleAction: O.RuleAction{
Action: C.RuleActionTypeRoute,
RouteOptions: O.RouteActionOptions{
Outbound: "direct",
},
},
},
},
}
rules = append(rules, splitTunnelRoutingRules()...)
Comment thread
reflog marked this conversation as resolved.
return rules
}

Expand Down Expand Up @@ -489,7 +430,7 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) {
opts := baseOpts(bOptions.BasePath)
slog.Debug("Base options initialized")

if env.GetBool(env.UseSocks) {
if socksOnlyEnforced() || env.GetBool(env.UseSocks) {
socksAddr, _ := env.Get(env.SocksAddress)
slog.Info("Using SOCKS proxy for inbound as per environment variable", "socksAddr", socksAddr)
Comment thread
Copilot marked this conversation as resolved.
Outdated
addrPort, err := netip.ParseAddrPort(socksAddr)
Expand All @@ -509,25 +450,7 @@ func buildOptions(bOptions BoxOptions) (O.Options, error) {
}
opts.Inbounds = []O.Inbound{socksIn}
} else {
switch common.Platform {
case "android":
opts.Route.OverrideAndroidVPN = true
kv := kernelVersion()
slog.Debug("detected kernel version", "kernel", kv)
if kv == "" {
slog.Warn("kernel version unknown, keeping default TUN stack")
} else if kernelBelow(kv, minAndroidSystemStackKernel) {
opts.Inbounds[0].Options.(*O.TunInboundOptions).Stack = "gvisor"
slog.Info("kernel below 5.10, using gvisor TUN stack", "kernel", kv)
}
slog.Debug("Android platform detected, OverrideAndroidVPN set to true")
case "ios":
opts.Inbounds[0].Options.(*O.TunInboundOptions).Stack = ""
slog.Debug("iOS platform detected, using default TUN stack with no override")
case "linux":
opts.Inbounds[0].Options.(*O.TunInboundOptions).AutoRedirect = true
slog.Debug("Linux platform detected, AutoRedirect set to true")
}
applyPlatformTunnelOptions(&opts)
}

// add smart routing and ad block rules
Expand Down
68 changes: 0 additions & 68 deletions vpn/boxoptions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,74 +480,6 @@ func TestHasGlobalIPv6Using(t *testing.T) {
// reject: it must follow the split-tunnel and smart-routing rules (so a
// direct-routed domain keeps its QUIC) and precede the selector rules (so
// QUIC bound for the proxy is rejected).
Comment thread
reflog marked this conversation as resolved.
func TestBuildOptions_RejectsQUICAfterDirectRules(t *testing.T) {
cfg := testConfig(t)
opts, err := buildOptions(BoxOptions{
BasePath: t.TempDir(),
Options: cfg.Options,
SmartRouting: cfg.SmartRouting,
AdBlock: cfg.AdBlock,
})
require.NoError(t, err)

isQUICReject := func(r O.Rule) bool {
opts := r.DefaultOptions
return opts.RuleAction.Action == "reject" &&
slices.Contains(opts.RawDefaultRule.Network, "udp") &&
slices.Contains(opts.RawDefaultRule.Port, uint16(443))
}
isSplitTunnel := func(r O.Rule) bool {
return slices.Contains(r.DefaultOptions.RawDefaultRule.RuleSet, splitTunnelTag)
}
isSelector := func(r O.Rule) bool {
mode := r.DefaultOptions.RawDefaultRule.ClashMode
return mode == AutoSelectTag || mode == ManualSelectTag
}

quicIdx, splitIdx, selectorIdx := -1, -1, -1
for i, r := range opts.Route.Rules {
switch {
case isQUICReject(r):
quicIdx = i
case isSplitTunnel(r):
splitIdx = i
case isSelector(r) && selectorIdx == -1:
selectorIdx = i
}
}
require.NotEqual(t, -1, quicIdx, "expected UDP/443 reject rule in built options")
require.NotEqual(t, -1, splitIdx, "expected split-tunnel rule in built options")
require.NotEqual(t, -1, selectorIdx, "expected at least one selector mode rule in built options")
assert.Greater(t, quicIdx, splitIdx, "QUIC reject must come after split-tunnel rule so split-direct domains keep QUIC")
assert.Less(t, quicIdx, selectorIdx, "QUIC reject must come before selector rules so proxied QUIC is rejected")
}

func TestBaseOpts_TunIPv6Address(t *testing.T) {
opts := baseOpts(t.TempDir())
require.NotEmpty(t, opts.Inbounds, "expected inbounds in baseOpts output")

var tunOpts *O.TunInboundOptions
for _, in := range opts.Inbounds {
if in.Type == "tun" {
var ok bool
tunOpts, ok = in.Options.(*O.TunInboundOptions)
require.True(t, ok, "expected *TunInboundOptions for tun inbound")
break
}
}
require.NotNil(t, tunOpts, "expected a tun inbound")
require.NotEmpty(t, tunOpts.Address, "expected at least the v4 TUN address")
assert.Equal(t, "10.10.1.1/30", tunOpts.Address[0].String(), "first TUN address should be the v4 prefix")

if hasGlobalIPv6() {
require.Len(t, tunOpts.Address, 2, "expected v4 + v6 ULA on TUN when system has global v6")
assert.Equal(t, "fdfe:dcba:9876::1/126", tunOpts.Address[1].String(),
"v6 ULA should be appended after the v4 address")
} else {
require.Len(t, tunOpts.Address, 1, "expected v4-only TUN when system has no global v6")
}
}

func TestBuildOptions_RejectsIPv6WhenCaptured(t *testing.T) {
cfg := testConfig(t)
opts, err := buildOptions(BoxOptions{
Expand Down
80 changes: 80 additions & 0 deletions vpn/boxoptions_tun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//go:build !novpn

package vpn

import (
"slices"
"testing"

O "github.com/sagernet/sing-box/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildOptions_RejectsQUICAfterDirectRules(t *testing.T) {
cfg := testConfig(t)
opts, err := buildOptions(BoxOptions{
BasePath: t.TempDir(),
Options: cfg.Options,
SmartRouting: cfg.SmartRouting,
AdBlock: cfg.AdBlock,
})
require.NoError(t, err)

isQUICReject := func(r O.Rule) bool {
opts := r.DefaultOptions
return opts.RuleAction.Action == "reject" &&
slices.Contains(opts.RawDefaultRule.Network, "udp") &&
slices.Contains(opts.RawDefaultRule.Port, uint16(443))
}
isSplitTunnel := func(r O.Rule) bool {
return slices.Contains(r.DefaultOptions.RawDefaultRule.RuleSet, splitTunnelTag)
}
isSelector := func(r O.Rule) bool {
mode := r.DefaultOptions.RawDefaultRule.ClashMode
return mode == AutoSelectTag || mode == ManualSelectTag
}

quicIdx, splitIdx, selectorIdx := -1, -1, -1
for i, r := range opts.Route.Rules {
switch {
case isQUICReject(r):
quicIdx = i
case isSplitTunnel(r):
splitIdx = i
case isSelector(r) && selectorIdx == -1:
selectorIdx = i
}
}
require.NotEqual(t, -1, quicIdx, "expected UDP/443 reject rule in built options")
require.NotEqual(t, -1, splitIdx, "expected split-tunnel rule in built options")
require.NotEqual(t, -1, selectorIdx, "expected at least one selector mode rule in built options")
assert.Greater(t, quicIdx, splitIdx, "QUIC reject must come after split-tunnel rule so split-direct domains keep QUIC")
assert.Less(t, quicIdx, selectorIdx, "QUIC reject must come before selector rules so proxied QUIC is rejected")
}

func TestBaseOpts_TunIPv6Address(t *testing.T) {
opts := baseOpts(t.TempDir())
require.NotEmpty(t, opts.Inbounds, "expected inbounds in baseOpts output")

var tunOpts *O.TunInboundOptions
for _, in := range opts.Inbounds {
if in.Type == "tun" {
var ok bool
tunOpts, ok = in.Options.(*O.TunInboundOptions)
require.True(t, ok, "expected *TunInboundOptions for tun inbound")
break
}
}
require.NotNil(t, tunOpts, "expected a tun inbound")
require.NotEmpty(t, tunOpts.Address, "expected at least the v4 TUN address")
assert.Equal(t, "10.10.1.1/30", tunOpts.Address[0].String(), "first TUN address should be the v4 prefix")

if hasGlobalIPv6() {
require.Len(t, tunOpts.Address, 2, "expected v4 + v6 ULA on TUN when system has global v6")
assert.Equal(t, "fdfe:dcba:9876::1/126", tunOpts.Address[1].String(),
"v6 ULA should be appended after the v4 address")
} else {
require.Len(t, tunOpts.Address, 1, "expected v4-only TUN when system has no global v6")
}
}
83 changes: 83 additions & 0 deletions vpn/inbounds_default.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build !novpn

package vpn

import (
"log/slog"
"net/netip"

C "github.com/sagernet/sing-box/constant"
O "github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/json/badoption"

"github.com/getlantern/radiance/bypass"
"github.com/getlantern/radiance/common"
)

// baseInbounds returns the inbounds for a tunnel build: the TUN device plus the
// loopback bypass proxy used for kindling connections. The TUN inbound is at
// index 0 so applyPlatformTunnelOptions can adjust it per platform.
func baseInbounds() []O.Inbound {
loopbackAddr := badoption.Addr(netip.MustParseAddr("127.0.0.1"))

// ULA-only interfaces don't indicate real public v6, so gate the TUN v6 address
// on genuine global v6 connectivity.
tunAddress := []netip.Prefix{
netip.MustParsePrefix("10.10.1.1/30"),
}
if hasGlobalIPv6() {
tunAddress = append(tunAddress, netip.MustParsePrefix("fdfe:dcba:9876::1/126"))
slog.Info("vpn: TUN with IPv6 ULA (system has global v6)")
} else {
slog.Info("vpn: TUN IPv4-only (no global v6 detected)")
}

return []O.Inbound{
{
Type: "tun",
Tag: "tun-in",
Options: &O.TunInboundOptions{
InterfaceName: "utun225",
Address: tunAddress,
AutoRoute: true,
StrictRoute: true,
EndpointIndependentNat: true, // needed for QUIC migration and hole-punching
Stack: "system", // fallback to gvisor on older Android kernels in applyPlatformTunnelOptions
},
},
{
Type: C.TypeMixed,
Tag: bypass.BypassInboundTag,
Options: &O.HTTPMixedInboundOptions{
ListenOptions: O.ListenOptions{
Listen: &loopbackAddr,
ListenPort: bypass.ProxyPort,
},
},
},
}
}

// applyPlatformTunnelOptions indexes opts.Inbounds[0] as the TUN device, per the
// invariant established by baseInbounds. Safe only in tunnel mode.
func applyPlatformTunnelOptions(opts *O.Options) {
switch common.Platform {
case "android":
opts.Route.OverrideAndroidVPN = true
kv := kernelVersion()
slog.Debug("detected kernel version", "kernel", kv)
if kv == "" {
slog.Warn("kernel version unknown, keeping default TUN stack")
} else if kernelBelow(kv, minAndroidSystemStackKernel) {
opts.Inbounds[0].Options.(*O.TunInboundOptions).Stack = "gvisor"
slog.Info("kernel below 5.10, using gvisor TUN stack", "kernel", kv)
}
slog.Debug("Android platform detected, OverrideAndroidVPN set to true")
case "ios":
opts.Inbounds[0].Options.(*O.TunInboundOptions).Stack = ""
slog.Debug("iOS platform detected, using default TUN stack with no override")
case "linux":
opts.Inbounds[0].Options.(*O.TunInboundOptions).AutoRedirect = true
slog.Debug("Linux platform detected, AutoRedirect set to true")
}
}
Comment thread
reflog marked this conversation as resolved.
Outdated
Loading
Loading