Skip to content
Merged
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
TAGS=with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_conntrack
TAGS=with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme

UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
Expand Down
1 change: 1 addition & 0 deletions backend/radiance.go
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,7 @@ func (r *LocalBackend) RunOfflineURLTests() error {
svrs := r.srvManager.AllServers()
slog.Debug("Running offline URL tests", "server_count", len(svrs), "url_override_count", len(cfg.BanditURLOverrides))
results, err := r.vpnClient.RunOfflineURLTests(
r.ctx,
settings.GetString(settings.DataPathKey),
servers.ServerList{Servers: svrs}.Outbounds(),
cfg.BanditURLOverrides,
Expand Down
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ func load(path string) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
ctx := box.BaseContext()
ctx := box.Context(context.Background())
cfg, err := singjson.UnmarshalExtendedContext[*Config](ctx, rawConfig)
if err == nil {
return cfg, nil
Expand Down
148 changes: 87 additions & 61 deletions go.mod

Large diffs are not rendered by default.

273 changes: 176 additions & 97 deletions go.sum

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion ipc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ func (c *Client) RunOfflineURLTests(ctx context.Context) error {
// Server selection //
///////////////////////

var boxCtx = box.BaseContext()
// boxCtx holds the sing-box registries and should not be mutated. It is used
// for JSON marshalling/unmarshalling of server options.
var boxCtx = box.Context(context.Background())

// SelectServer selects the server with the given tag.
func (c *Client) SelectServer(ctx context.Context, tag string) error {
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
base_tags := "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_conntrack"
base_tags := "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme"
Comment thread
garmr-ulfr marked this conversation as resolved.
tags := if os() == "macos" { "standalone," + base_tags } else { base_tags }
lanternd := if os() == "windows" { "lanternd.exe" } else { "lanternd" }
lantern := if os() == "windows" { "lantern.exe" } else { "lantern" }
Expand Down
94 changes: 40 additions & 54 deletions peer/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package peer

import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
Expand All @@ -13,13 +12,16 @@ import (
"sync/atomic"
"time"

"github.com/sagernet/sing-box/experimental/libbox"
sbox "github.com/sagernet/sing-box"
sblog "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/service"

box "github.com/getlantern/lantern-box"
lblog "github.com/getlantern/lantern-box/log"
"github.com/getlantern/lantern-box/tracker/peerconn"

"github.com/getlantern/radiance/common/env"
"github.com/getlantern/radiance/common/settings"
"github.com/getlantern/radiance/events"
Expand Down Expand Up @@ -424,6 +426,7 @@ func (c *Client) Start(ctx context.Context) (retErr error) {
// is fatal — the server has already deprecated the row, so the
// deferred cleanup tears the rest of the session down.
if err := c.cfg.API.Verify(ctx, regResp.RouteID); err != nil {
cancelRun()
return fmt.Errorf("verify with lantern-cloud: %w", err)
}

Expand Down Expand Up @@ -1068,17 +1071,16 @@ func startNewBoxWithRetry(ctx context.Context, newBox boxService) (retErr error)
// VPN-bypass requirement is a property of the *client's* environment, not
// the proxy track config.
func ensurePeerOutboundsBypassVPN(options string) (string, error) {
var raw map[string]any
if err := json.Unmarshal([]byte(options), &raw); err != nil {
ctx := box.Context(context.Background())
opts, err := json.UnmarshalExtendedContext[option.Options](ctx, []byte(options))
if err != nil {
return "", fmt.Errorf("decode options: %w", err)
}
route, _ := raw["route"].(map[string]any)
if route == nil {
route = map[string]any{}
raw["route"] = route
if opts.Route == nil {
opts.Route = &option.RouteOptions{}
}
route["auto_detect_interface"] = true
out, err := json.Marshal(raw)
opts.Route.AutoDetectInterface = true
out, err := json.MarshalContext(ctx, opts)
if err != nil {
return "", fmt.Errorf("encode options: %w", err)
}
Expand Down Expand Up @@ -1136,58 +1138,42 @@ func pickInternalPort() uint16 {
return uint16(internalPortMin + rand.IntN(internalPortMax-internalPortMin))
}

// We pass a nil PlatformInterface — peer-proxy inbounds don't need TUN /
// platform-VPN integration the way the main VPN tunnel does. The samizdat
// inbound is just an HTTPS server bound to a TCP port; sing-box's default
// network stack handles it.
// defaultBuildBoxService builds the sing-box service for one peer box.
//
// The registries newPeerBoxContext supplies are what let libbox decode the
// inbounds[0].type="samizdat" stanza from /peer/register; without them it
// fails with "missing inbound fields registry in context". They are scoped
// to this box instance, so the peer and the main tunnel coexist without
// stomping on each other.
// A peer-proxy box needs no TUN / platform-VPN integration the way the main
// VPN tunnel does: the samizdat inbound is just an HTTPS server bound to a
// TCP port, which sing-box's default network stack handles.
func defaultBuildBoxService(ctx context.Context, options string) (boxService, error) {
bs, err := libbox.NewServiceWithContext(newPeerBoxContext(ctx), options, nil)
ctx = newPeerBoxContext(ctx)
opts, err := json.UnmarshalExtendedContext[option.Options](ctx, []byte(options))
if err != nil {
return nil, fmt.Errorf("decode sing-box options: %w", err)
}
bs, err := sbox.New(sbox.Options{
Context: ctx,
Options: opts,
})
if err != nil {
return nil, fmt.Errorf("libbox.NewServiceWithContext: %w", err)
return nil, fmt.Errorf("build sing-box: %w", err)
}
return bs, nil
}

// newPeerBoxContext assembles the context for one peer box: cancellation
// from ctx, lantern-box's protocol registries and this box's log factory
// from a single captured base context.
// newPeerBoxContext augments ctx with everything one peer box needs: the
// sing-box and lantern-box protocol registries plus this box's own sing-box
// log factory. The caller's cancellation is preserved, so a Stop-induced
// cancel still reaches the box.
//
// The base must be captured exactly once. box.BaseContext() builds a new
// service registry on every call, and service.MustRegister mutates
// whichever registry the context hands back, so registering through a
// wrapper that rebuilds the base per lookup writes into a registry that
// is discarded before libbox ever reads it — the registration silently
// does nothing.
// The registries are what let sing-box decode custom inbounds like the
// samizdat stanza from /peer/register; without them the decode fails with
// "missing inbound fields registry in context". Registering the log factory
// routes this box's router and dial errors to lantern.log instead of
// sing-box's stderr default. Both are scoped to the fresh registry
// box.Context installs, so the peer and the main tunnel coexist without
// stomping on each other.
func newPeerBoxContext(ctx context.Context) context.Context {
base := box.BaseContext()
// The peer runs a second box beside the main tunnel's. Absent its own
// factory it keeps sing-box's stderr-only default, so this box's
// router and dial errors never reach lantern.log — the signal that
// explains why a peer-share verify failed. Mirrors the main tunnel's
// registration.
service.MustRegister[sblog.Factory](base, lblog.NewFactory(slog.Default().Handler()))
return peerBoxContext{Context: ctx, base: base}
}

// peerBoxContext resolves Deadline/Done/Err from the embedded caller
// context so a Stop-induced cancel propagates into box internals. Values
// come from the caller first and from base only as a fallback, so anything
// the caller carries shadows base — which matters because base holds the one
// captured registry instance libbox both registers into and reads back.
type peerBoxContext struct {
context.Context
base context.Context
ctx = box.Context(ctx)
service.MustRegister[sblog.Factory](ctx, lblog.NewFactory(slog.Default().Handler()))
return ctx
}

func (c peerBoxContext) Value(key any) any {
if v := c.Context.Value(key); v != nil {
return v
}
return c.base.Value(key)
}
156 changes: 72 additions & 84 deletions peer/peer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import (

sblog "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
singjson "github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

box "github.com/getlantern/lantern-box"

"github.com/getlantern/radiance/common"
"github.com/getlantern/radiance/common/settings"
"github.com/getlantern/radiance/events"
Expand Down Expand Up @@ -631,26 +634,32 @@ func TestClient_Heartbeat_TransientErrorDoesNotStop(t *testing.T) {
// "no route block at all" and "existing route block" cases get the flag set,
// and that other route-level keys are preserved.
func TestEnsurePeerOutboundsBypassVPN(t *testing.T) {
ctx := box.Context(context.Background())
parseOptions := func(t *testing.T, s string) option.Options {
t.Helper()
opts, err := singjson.UnmarshalExtendedContext[option.Options](ctx, []byte(s))
require.NoError(t, err)
return opts
}

t.Run("adds route block when missing", func(t *testing.T) {
in := `{"inbounds":[{"type":"samizdat","tag":"samizdat-in"}]}`
out, err := ensurePeerOutboundsBypassVPN(in)
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &parsed))
route := parsed["route"].(map[string]any)
assert.Equal(t, true, route["auto_detect_interface"])
assert.Contains(t, parsed, "inbounds", "must preserve other top-level fields")
opts := parseOptions(t, out)
require.NotNil(t, opts.Route)
assert.True(t, opts.Route.AutoDetectInterface)
assert.NotEmpty(t, opts.Inbounds, "must preserve other top-level fields")
})
t.Run("preserves existing route fields", func(t *testing.T) {
in := `{"route":{"rules":[{"action":"sniff"}],"final":"direct"}}`
out, err := ensurePeerOutboundsBypassVPN(in)
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal([]byte(out), &parsed))
route := parsed["route"].(map[string]any)
assert.Equal(t, true, route["auto_detect_interface"])
assert.Equal(t, "direct", route["final"])
assert.NotEmpty(t, route["rules"])
opts := parseOptions(t, out)
require.NotNil(t, opts.Route)
assert.True(t, opts.Route.AutoDetectInterface)
assert.Equal(t, "direct", opts.Route.Final)
assert.NotEmpty(t, opts.Route.Rules)
})
t.Run("rejects malformed json", func(t *testing.T) {
_, err := ensurePeerOutboundsBypassVPN(`{not json`)
Expand Down Expand Up @@ -948,20 +957,19 @@ var _ boxService = (*fakeBoxService)(nil)

// TestDefaultBuildBoxService_DecodesSamizdatInbound is the regression net
// for the "missing inbound fields registry in context" failure that bit
// us live: defaultBuildBoxService used to call libbox.NewServiceWithContext
// with a fresh ctx that didn't have the lantern-box protocol registries
// (samizdat, reflex, …) plumbed in, so the JSON decoder couldn't resolve
// inbounds[0].type="samizdat" → libbox.NewServiceWithContext returned an
// error → applyPeerShare rolled the toggle back. The integration tests
// stub BuildBoxService entirely, so neither the libbox setup nor the
// samizdat decoder were exercised in CI.
// us live: the peer box was built from a ctx that didn't have the
// lantern-box protocol registries (samizdat, reflex, …) plumbed in, so the
// JSON decoder couldn't resolve inbounds[0].type="samizdat" → the build
// failed → applyPeerShare rolled the toggle back. The integration tests
// stub BuildBoxService entirely, so the samizdat decode path was never
// exercised in CI.
//
// Calling defaultBuildBoxService directly with a minimal samizdat-inbound
// options JSON walks the actual decode path. If the registry is missing
// in the ctx that defaultBuildBoxService produces, libbox returns the
// "missing inbound fields registry" error and this test fails before any
// of the runtime cycle (rebuild, redeploy, toggle UI, dial-back) — what
// used to take a 5-minute round-trip is now a 0.1s test failure.
// options JSON walks the actual decode path. If the registries are missing
// from the ctx it produces, the decode fails with the "missing inbound
// fields registry" error and this test fails before any of the runtime
// cycle (rebuild, redeploy, toggle UI, dial-back) — what used to take a
// 5-minute round-trip is now a 0.1s test failure.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
func TestDefaultBuildBoxService_DecodesSamizdatInbound(t *testing.T) {
// Minimal but complete samizdat inbound — every field that
// option.SamizdatInboundOptions's json tags require to round-trip.
Expand All @@ -985,10 +993,50 @@ func TestDefaultBuildBoxService_DecodesSamizdatInbound(t *testing.T) {
"the lantern-box protocol registries have to be in ctx")
require.NotNil(t, bs)
// We never call Start; just verifying the decode path. Close drops
// any background structures libbox might have stood up.
// any background structures the box might have stood up.
_ = bs.Close()
}

func TestNewPeerBoxContext_LogFactoryIsRetrievable(t *testing.T) {
ctx := newPeerBoxContext(context.Background())
require.NotNil(t, service.FromContext[sblog.Factory](ctx),
"the registered log factory must be readable from the box's context, "+
"or this box logs only to stderr")
}

func TestNewPeerBoxContext_ResolvesInboundRegistry(t *testing.T) {
ctx := newPeerBoxContext(context.Background())
assert.NotNil(t, service.FromContext[option.InboundOptionsRegistry](ctx),
"registering the log factory must not displace the protocol registries "+
"sing-box needs to decode the samizdat inbound")
}

func TestNewPeerBoxContext_InheritsCallerCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
boxCtx := newPeerBoxContext(ctx)
require.NoError(t, boxCtx.Err())

cancel()

select {
case <-boxCtx.Done():
case <-time.After(2 * time.Second):
t.Fatal("peer box context did not observe the caller's cancel")
}
assert.ErrorIs(t, boxCtx.Err(), context.Canceled)
}

func TestNewPeerBoxContext_RegistryStableAcrossLookups(t *testing.T) {
// service.MustRegister mutates whichever registry the context hands back, so
// a per-lookup rebuild would write the log factory into an object discarded
// before sing-box reads it. Pin that both lookups resolve to one registry.
ctx := newPeerBoxContext(context.Background())
first := service.RegistryFromContext(ctx)
second := service.RegistryFromContext(ctx)
require.NotNil(t, first)
assert.True(t, first == second, "every lookup must resolve to one registry")
}

// All four peer endpoints must carry the same standard header set as
// /config-new (X-Lantern-Config-Client-IP in particular). The server's
// util.ClientIPWithAddr prefers that header over X-Forwarded-For and
Expand Down Expand Up @@ -1069,66 +1117,6 @@ func TestAPI_ForwardsCommonHeaders(t *testing.T) {
}
}

// The peer box's log factory has to survive the trip into libbox, and the
// bug that made it not survive was invisible: box.BaseContext() mints a
// fresh service registry per call, so a wrapper that rebuilt the base on
// every Value lookup handed out a different registry each time. Reads kept
// working (each fresh base carries the same protocol registrations), which
// is why only a registration could expose it. These four tests pin the
// properties that make the registration land.

// A registration made against the context libbox receives must be
// retrievable from that same context.
func TestPeerBoxContext_LogFactoryIsRetrievable(t *testing.T) {
boxCtx := newPeerBoxContext(context.Background())

got := service.FromContext[sblog.Factory](boxCtx)
require.NotNil(t, got, "the registered sing-box log factory must be readable "+
"from the context handed to libbox, or this box logs only to stderr")
}

// Repeated registry lookups must return the same object. This is the
// invariant the old wrapper broke: two lookups, two registries, so a write
// through one was never seen through the other.
func TestPeerBoxContext_RegistryIsStableAcrossLookups(t *testing.T) {
boxCtx := newPeerBoxContext(context.Background())

first := service.RegistryFromContext(boxCtx)
second := service.RegistryFromContext(boxCtx)
require.NotNil(t, first)
assert.True(t, first == second,
"every lookup must resolve to one registry; a per-lookup rebuild makes "+
"service.MustRegister write into an object that is immediately discarded")
}

// Cancellation still comes from the caller, so a Stop-induced cancel reaches
// box internals rather than being swallowed by the base context.
func TestPeerBoxContext_InheritsCallerCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
boxCtx := newPeerBoxContext(ctx)
require.NoError(t, boxCtx.Err())

cancel()

select {
case <-boxCtx.Done():
case <-time.After(2 * time.Second):
t.Fatal("peer box context did not observe the caller's cancel")
}
assert.ErrorIs(t, boxCtx.Err(), context.Canceled)
}

// The lantern-box protocol registries must still resolve through the
// wrapper. This is the registry libbox reports as "missing inbound fields
// registry in context" when it is absent, which is what would break
// decoding the samizdat inbound from /peer/register.
func TestPeerBoxContext_StillResolvesInboundRegistry(t *testing.T) {
boxCtx := newPeerBoxContext(context.Background())

assert.NotNil(t, service.FromContext[option.InboundOptionsRegistry](boxCtx),
"registering the log factory must not displace the protocol registries")
}

// Rotation installs a freshly fetched launch_cfg on an already-running peer,
// so it has to re-apply the same abuse-rule gate Start does. Validating only
// at Start would let a server-side regression reach every long-lived peer on
Expand Down
Loading
Loading