Skip to content
Closed
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
8 changes: 8 additions & 0 deletions bypass/bypass.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
// Package bypass provides a local HTTP CONNECT proxy dialer that routes
// kindling traffic outside the VPN tunnel via the daemon's sing-box instance.
//
// On desktop, this is the only mechanism for kindling to bypass the tunnel
// because the main app runs in a separate process from the daemon. On mobile
// (single process), this is redundant with the direct transport registered in
// the sing-box context (see vpn/direct_transport.go), but is kept for
// simplicity since kindling initializes before the tunnel starts.
package bypass

import (
Expand Down
77 changes: 77 additions & 0 deletions vpn/direct_transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package vpn

import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"sync"

"github.com/sagernet/sing-box/adapter"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
)

// lazyDirectTransport is an http.RoundTripper registered in the sing-box
// context before the service is created. It defers to an inner RoundTripper
// that is set via Resolve() after the direct outbound exists.
//
// This allows unbounded (which is constructed during NewServiceWithContext)
// to hold a reference to this transport, while the actual dialer is wired
// later — before PostStart, when unbounded first uses it.
type lazyDirectTransport struct {
mu sync.RWMutex
inner http.RoundTripper
resolved bool
}

func (t *lazyDirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.RLock()
inner := t.inner
resolved := t.resolved
t.mu.RUnlock()

if !resolved || inner == nil {
return nil, fmt.Errorf("direct transport not yet resolved")
}
return inner.RoundTrip(req)
}

// Resolve builds the underlying http.Transport using the direct outbound's
// DialContext. Must be called after libbox.NewServiceWithContext but before
// the sing-box service Start (so that it's ready by PostStart when unbounded
// first makes HTTP calls).
func (t *lazyDirectTransport) Resolve(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()

outboundMgr := service.FromContext[adapter.OutboundManager](ctx)
if outboundMgr == nil {
return fmt.Errorf("outbound manager not found in context")
}

directOutbound, found := outboundMgr.Outbound("direct")
if !found {
return fmt.Errorf("direct outbound not found")
}

dialer, ok := directOutbound.(N.Dialer)
if !ok {
return fmt.Errorf("direct outbound does not implement N.Dialer")
}

baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return fmt.Errorf("default HTTP transport has unexpected type %T", http.DefaultTransport)
}
cloned := baseTransport.Clone()
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
t.inner = cloned
t.resolved = true
slog.Debug("Direct transport resolved using sing-box direct outbound")
return nil
Comment thread
myleshorton marked this conversation as resolved.
}
76 changes: 76 additions & 0 deletions vpn/direct_transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package vpn

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLazyDirectTransport_UnresolvedRoundTrip(t *testing.T) {
transport := &lazyDirectTransport{}
req, _ := http.NewRequest("GET", "http://example.com", nil)
_, err := transport.RoundTrip(req)
require.Error(t, err)
assert.Contains(t, err.Error(), "not yet resolved")
}

func TestLazyDirectTransport_ResolvedRoundTrip(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()

transport := &lazyDirectTransport{}

// Manually resolve with a working transport (simulates what Resolve does)
transport.mu.Lock()
transport.inner = http.DefaultTransport
transport.resolved = true
transport.mu.Unlock()

req, _ := http.NewRequest("GET", ts.URL, nil)
resp, err := transport.RoundTrip(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
}

func TestLazyDirectTransport_ResolveNoOutboundManager(t *testing.T) {
transport := &lazyDirectTransport{}
err := transport.Resolve(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "outbound manager not found")
}

func TestLazyDirectTransport_ConcurrentAccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()

transport := &lazyDirectTransport{}
transport.mu.Lock()
transport.inner = http.DefaultTransport
transport.resolved = true
transport.mu.Unlock()

// Fire several concurrent requests to verify no data race
done := make(chan struct{}, 10)
for i := 0; i < 10; i++ {
go func() {
defer func() { done <- struct{}{} }()
req, _ := http.NewRequest("GET", ts.URL, nil)
resp, err := transport.RoundTrip(req)
assert.NoError(t, err)
if resp != nil {
resp.Body.Close()
}
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
11 changes: 11 additions & 0 deletions vpn/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,23 @@
t.logFactory = lblog.NewFactory(slog.Default().Handler())
service.MustRegister[sblog.Factory](t.ctx, t.logFactory)

// Register a lazy direct transport in the context so that outbounds like
// unbounded can retrieve it during construction. The transport is resolved
// after the service is created (once the direct outbound exists).
directTransport := &lazyDirectTransport{}
t.ctx = lbA.ContextWithDirectTransport(t.ctx, directTransport)

Check failure on line 108 in vpn/tunnel.go

View workflow job for this annotation

GitHub Actions / build

undefined: lbA.ContextWithDirectTransport

Comment thread
myleshorton marked this conversation as resolved.
slog.Log(nil, internal.LevelTrace, "Creating libbox service")
lb, err := libbox.NewServiceWithContext(t.ctx, options, platformIfce)
if err != nil {
return fmt.Errorf("create libbox service: %w", err)
}

// Now that the service exists, resolve the direct transport.
if err := directTransport.Resolve(t.ctx); err != nil {
return fmt.Errorf("resolve direct transport: %w", err)
}

// setup client info tracker
outboundMgr := service.FromContext[adapter.OutboundManager](t.ctx)
clientContextInjector := newClientContextInjector(outboundMgr, dataPath)
Expand Down
Loading