Skip to content

Commit 9d796d3

Browse files
myleshortonclaude
andcommitted
pkg/lanturn: wire destination forwarding via M.SocksaddrSerializer
Replaces the planned-SOCKS5-handshake design with the sing-box-native address-prefix pattern that trojan / anytls / vmess / shadowsocks all use. Lighter than SOCKS5 (no version-negotiation / auth-method / CONNECT-request ceremony) and the natural fit since we control both ends of the wire. API changes: - Dial(ctx, ClientConfig) → Dial(ctx, ClientConfig, M.Socksaddr). Caller passes the destination they want the egress to forward to. The first inner-stream bytes the egress receives are M.SocksaddrSerializer.WriteAddrPort(destination) (1B addr-type + addr + 2B port). After the prefix is consumed, the conn is a transparent byte pipe. - Listen(ServerConfig) (net.Listener, error) → Listen(ServerConfig) (*Listener, error) with Accept() (net.Conn, M.Socksaddr, error). The egress reads the destination prefix in Accept (before returning to caller) so the egress process can immediately dial(destination) and io.Copy bidirectionally between the dial and the lanturn conn. Why not net.Listener? sing-box-style egresses aren't pattern-matchable to net.Listener anyway — the destination has to come out of the inner protocol, not as a fixed bind address. Returning it as a third Accept value is cleaner than wrapping the net.Conn in an interface. Implementation: - clientConn gains destinationPrefix []byte; txLoop seeds its `pending` buffer with these bytes before draining any caller writes, so the serialized destination is the very first SRTP-payload bytes the egress sees. - *Listener.Accept now does the inner DTLS-SRTP handshake, then reads M.SocksaddrSerializer.ReadAddrPort from the resulting serverConn with a 10s deadline (malformed sessions fail fast rather than hang). - New helpers: encodeDestination + bytesWriter for one-shot serialization without dragging buf.Buffer. Pattern matches what we found surveying sing-box-minimal: /transport/trojan/protocol.go:184 — same prefix /vendor/sing-anytls/client.go:52 — same prefix Unbounded uses full SOCKS5 only because broflake's egress was built as a generic QUIC→SOCKS5 bridge using off-the-shelf armon/go-socks5. We don't have that constraint. Phase 5 follow-up still needed for production: covert-dtls fingerprint randomization, session rotation, TURNS-on-5349 fallback, multi-profile, recency-weighted fleet selection. All have working code in the cmd/lanturn-phase* spike binaries and port over cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa20d8c commit 9d796d3

3 files changed

Lines changed: 113 additions & 35 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/pion/transport/v2 v2.2.4 // indirect
2020
github.com/pion/transport/v3 v3.0.2 // indirect
2121
github.com/pion/transport/v4 v4.0.1 // indirect
22+
github.com/sagernet/sing v0.8.9 // indirect
2223
github.com/theodorsm/covert-dtls v1.5.0 // indirect
2324
golang.org/x/crypto v0.45.0 // indirect
2425
golang.org/x/net v0.47.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ github.com/pion/turn/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE=
3535
github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc=
3636
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3737
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
38+
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
39+
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
3840
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
3941
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
4042
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

pkg/lanturn/lanturn.go

Lines changed: 110 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import (
4646
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
4747
"github.com/pion/rtp"
4848
"github.com/pion/srtp/v3"
49+
M "github.com/sagernet/sing/common/metadata"
4950
)
5051

5152
// ============================================================================
@@ -152,14 +153,27 @@ const (
152153
// Dial — client-side
153154
// ============================================================================
154155

155-
// Dial opens a lanturn client connection. The returned net.Conn carries
156-
// caller bytes through the full lanturn stack: caller bytes →
157-
// SRTP-paced chunks (Opus profile, 50pps) → AES-128-CM-HMAC-SHA1-80
158-
// encryption → DTLS-derived keying → TURN ChannelData → plain UDP/3478.
156+
// Dial opens a lanturn client connection toward `destination`. The
157+
// returned net.Conn carries caller bytes through the full lanturn
158+
// stack: caller bytes → SRTP-paced chunks (Opus profile, 50pps) →
159+
// AES-128-CM-HMAC-SHA1-80 encryption → DTLS-derived keying → TURN
160+
// ChannelData → plain UDP/3478.
161+
//
162+
// Destination forwarding: the first bytes the egress receives are
163+
// `M.SocksaddrSerializer.WriteAddrPort(destination)` — the sing-box-
164+
// native address-prefix pattern used by trojan / anytls / vmess /
165+
// shadowsocks (NOT a full SOCKS5 handshake, which is heavier and only
166+
// used when the egress is a generic SOCKS5 server like in Unbounded).
167+
// The egress reads the prefix in `Listener.Accept` and dials the
168+
// destination itself; from the caller's perspective the returned conn
169+
// is a transparent byte pipe to `destination`.
159170
//
160171
// MVP: single session, UDP-only, Opus-only profile. Caller redials
161172
// to start a fresh session.
162-
func Dial(ctx context.Context, cfg ClientConfig) (net.Conn, error) {
173+
func Dial(ctx context.Context, cfg ClientConfig, destination M.Socksaddr) (net.Conn, error) {
174+
if !destination.IsValid() {
175+
return nil, fmt.Errorf("lanturn: invalid destination %s", destination)
176+
}
163177
if len(cfg.CoturnEndpoints) == 0 {
164178
return nil, fmt.Errorf("lanturn: no coturn endpoints configured")
165179
}
@@ -270,22 +284,47 @@ func Dial(ctx context.Context, cfg ClientConfig) (net.Conn, error) {
270284
return nil, fmt.Errorf("lanturn: SRTP TX context: %w", err)
271285
}
272286

287+
// Encode the destination prefix that the egress will read first.
288+
// Sing-box-native serializer matches what trojan / anytls / vmess /
289+
// shadowsocks use — 1B address-type + addr + 2B port, no SOCKS5
290+
// ceremony.
291+
prefix := encodeDestination(destination)
292+
273293
// Build the streaming-chunker on top of the SRTP context.
274-
conn := newClientConn(ctx, mux, txCtx, alloc, dtlsConn, logf)
294+
conn := newClientConn(ctx, mux, txCtx, alloc, dtlsConn, logf, prefix)
275295
return conn, nil
276296
}
277297

298+
// encodeDestination produces the M.SocksaddrSerializer wire format for
299+
// the given destination — the prefix the egress reads first.
300+
func encodeDestination(dst M.Socksaddr) []byte {
301+
buf := make([]byte, 0, M.SocksaddrSerializer.AddrPortLen(dst))
302+
w := bytesWriter{buf: &buf}
303+
_ = M.SocksaddrSerializer.WriteAddrPort(&w, dst)
304+
return buf
305+
}
306+
307+
type bytesWriter struct{ buf *[]byte }
308+
309+
func (w *bytesWriter) Write(p []byte) (int, error) {
310+
*w.buf = append(*w.buf, p...)
311+
return len(p), nil
312+
}
313+
278314
// ============================================================================
279315
// Listen — server-side (Lantern egress)
280316
// ============================================================================
281317

282-
// Listen opens a lanturn server listener. Each Accept returns a
283-
// net.Conn for one inbound client session.
318+
// Listen opens a lanturn server listener. The returned *Listener's
319+
// Accept blocks until a new client session arrives and returns the
320+
// per-session net.Conn along with the destination the client wants
321+
// traffic forwarded to (read from the M.SocksaddrSerializer prefix
322+
// the client sends as the first inner-stream bytes).
284323
//
285324
// MVP: accepts one session at a time (sequential). Production would
286325
// concurrently accept multiple via the egressDemuxer pattern from
287326
// cmd/lanturn-phase2/main.go.
288-
func Listen(cfg ServerConfig) (net.Listener, error) {
327+
func Listen(cfg ServerConfig) (*Listener, error) {
289328
if cfg.ListenUDP == "" {
290329
return nil, fmt.Errorf("lanturn: ListenUDP required")
291330
}
@@ -297,22 +336,35 @@ func Listen(cfg ServerConfig) (net.Listener, error) {
297336
if err != nil {
298337
return nil, fmt.Errorf("lanturn: ListenPacket: %w", err)
299338
}
300-
return &listener{pc: pc, logf: logf, closed: make(chan struct{})}, nil
339+
return &Listener{pc: pc, logf: logf, closed: make(chan struct{})}, nil
301340
}
302341

303-
type listener struct {
342+
// Listener is the egress-side accept loop for lanturn client sessions.
343+
// Note that this is NOT a net.Listener — Accept returns three values
344+
// (conn, destination, err) so the egress process can dial the
345+
// destination itself before bridging bytes. lanturn's egress is not a
346+
// generic listener-pattern consumer; it's specifically the
347+
// "address-prefix + io.Copy" SOCKS-shaped proxy described in the
348+
// design doc §4.
349+
type Listener struct {
304350
pc net.PacketConn
305351
logf func(format string, args ...any)
306352
closeOnce sync.Once
307353
closed chan struct{}
308354
}
309355

310-
func (l *listener) Accept() (net.Conn, error) {
356+
// Accept blocks until a new lanturn client session arrives, runs the
357+
// inner DTLS-SRTP handshake, reads the destination prefix the client
358+
// sends per M.SocksaddrSerializer, and returns the session conn + the
359+
// destination + nil error. The egress is expected to immediately dial
360+
// `destination` (via whatever direct dialer it's configured with) and
361+
// io.Copy bidirectionally between that dial and the returned conn.
362+
func (l *Listener) Accept() (net.Conn, M.Socksaddr, error) {
311363
// Wait for first packet from a new source — this initiates a session.
312364
buf := make([]byte, 4096)
313365
n, srcAddr, err := l.pc.ReadFrom(buf)
314366
if err != nil {
315-
return nil, err
367+
return nil, M.Socksaddr{}, err
316368
}
317369
l.logf("lanturn: accepting session from %s (first pkt %dB leading=%#02x)", srcAddr, n, buf[0])
318370

@@ -324,7 +376,7 @@ func (l *listener) Accept() (net.Conn, error) {
324376
cert, err := selfsign.GenerateSelfSigned()
325377
if err != nil {
326378
mux.Close()
327-
return nil, fmt.Errorf("lanturn: cert: %w", err)
379+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: cert: %w", err)
328380
}
329381
dtlsCfg := &dtls.Config{
330382
Certificates: []tls.Certificate{cert},
@@ -335,48 +387,61 @@ func (l *listener) Accept() (net.Conn, error) {
335387
dtlsConn, err := dtls.Server(mux.dtlsPacketConn(srcAddr), srcAddr, dtlsCfg)
336388
if err != nil {
337389
mux.Close()
338-
return nil, fmt.Errorf("lanturn: DTLS server setup: %w", err)
390+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: DTLS server setup: %w", err)
339391
}
340392
if err := dtlsConn.Handshake(); err != nil {
341393
dtlsConn.Close()
342394
mux.Close()
343-
return nil, fmt.Errorf("lanturn: DTLS handshake: %w", err)
395+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: DTLS handshake: %w", err)
344396
}
345397
l.logf("lanturn: inner DTLS handshake OK")
346398

347399
state, ok := dtlsConn.ConnectionState()
348400
if !ok {
349401
dtlsConn.Close()
350402
mux.Close()
351-
return nil, fmt.Errorf("lanturn: no DTLS connection state")
403+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: no DTLS connection state")
352404
}
353405
keyMat, err := state.ExportKeyingMaterial("EXTRACTOR-dtls_srtp", nil, srtpKeyMatLen)
354406
if err != nil {
355407
dtlsConn.Close()
356408
mux.Close()
357-
return nil, fmt.Errorf("lanturn: ExportKeyingMaterial: %w", err)
409+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: ExportKeyingMaterial: %w", err)
358410
}
359411
clientKey, clientSalt, _, _ := splitSRTPKeys(keyMat)
360412
rxCtx, err := srtp.CreateContext(clientKey, clientSalt, srtpProfile)
361413
if err != nil {
362414
dtlsConn.Close()
363415
mux.Close()
364-
return nil, fmt.Errorf("lanturn: SRTP RX context: %w", err)
416+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: SRTP RX context: %w", err)
365417
}
366418

367419
conn := newServerConn(mux, rxCtx, dtlsConn, l.logf)
368-
return conn, nil
420+
421+
// Read the destination prefix the client sent as the first bytes
422+
// of the inner stream (M.SocksaddrSerializer). Times out if the
423+
// client doesn't send it promptly — that's a malformed session.
424+
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
425+
destination, err := M.SocksaddrSerializer.ReadAddrPort(conn)
426+
if err != nil {
427+
conn.Close()
428+
return nil, M.Socksaddr{}, fmt.Errorf("lanturn: read destination prefix: %w", err)
429+
}
430+
_ = conn.SetReadDeadline(time.Time{})
431+
l.logf("lanturn: session destination = %s", destination)
432+
433+
return conn, destination, nil
369434
}
370435

371-
func (l *listener) Close() error {
436+
func (l *Listener) Close() error {
372437
l.closeOnce.Do(func() {
373438
close(l.closed)
374439
l.pc.Close()
375440
})
376441
return nil
377442
}
378443

379-
func (l *listener) Addr() net.Addr {
444+
func (l *Listener) Addr() net.Addr {
380445
return l.pc.LocalAddr()
381446
}
382447

@@ -423,6 +488,12 @@ type clientConn struct {
423488

424489
writeBuf chan []byte // bounded buffer of pending caller bytes
425490

491+
// destinationPrefix is the M.SocksaddrSerializer wire-encoded
492+
// destination address that the egress reads first. The txLoop
493+
// seeds its `pending` buffer with these bytes before any caller
494+
// writes drain in.
495+
destinationPrefix []byte
496+
426497
// rxPipe is the byte stream the caller reads from. Filled by a
427498
// goroutine reading SRTP packets, draining real-data payloads.
428499
rxPipe *bytesPipe
@@ -432,7 +503,7 @@ type clientConn struct {
432503
rxCtx *srtp.Context
433504
}
434505

435-
func newClientConn(ctx context.Context, mux *packetMux, txCtx *srtp.Context, alloc *turn.Allocation, dtlsConn *dtls.Conn, logf func(format string, args ...any)) *clientConn {
506+
func newClientConn(ctx context.Context, mux *packetMux, txCtx *srtp.Context, alloc *turn.Allocation, dtlsConn *dtls.Conn, logf func(format string, args ...any), destinationPrefix []byte) *clientConn {
436507
// We'll need an RX SRTP context to decrypt the egress's responses.
437508
// Egress side derives keys with the SAME EXTRACTOR layout; the
438509
// "server write" half of our extracted keying material is the
@@ -444,17 +515,18 @@ func newClientConn(ctx context.Context, mux *packetMux, txCtx *srtp.Context, all
444515

445516
cctx, cancel := context.WithCancel(ctx)
446517
c := &clientConn{
447-
ctx: cctx,
448-
cancel: cancel,
449-
mux: mux,
450-
txCtx: txCtx,
451-
rxCtx: rxCtx,
452-
alloc: alloc,
453-
dtlsConn: dtlsConn,
454-
logf: logf,
455-
writeBuf: make(chan []byte, 64),
456-
rxPipe: newBytesPipe(),
457-
closed: make(chan struct{}),
518+
ctx: cctx,
519+
cancel: cancel,
520+
mux: mux,
521+
txCtx: txCtx,
522+
rxCtx: rxCtx,
523+
alloc: alloc,
524+
dtlsConn: dtlsConn,
525+
logf: logf,
526+
writeBuf: make(chan []byte, 64),
527+
rxPipe: newBytesPipe(),
528+
closed: make(chan struct{}),
529+
destinationPrefix: destinationPrefix,
458530
}
459531
go c.txLoop()
460532
go c.rxLoop()
@@ -467,7 +539,10 @@ func (c *clientConn) txLoop() {
467539
ts := randUint32()
468540
seq := uint16(randUint32() & 0xFFFF)
469541

470-
pending := make([]byte, 0, 4096)
542+
// Seed pending with the destination prefix so the very first
543+
// real-data bytes the egress receives are the serialized address.
544+
pending := make([]byte, 0, 4096+len(c.destinationPrefix))
545+
pending = append(pending, c.destinationPrefix...)
471546
ticker := time.NewTicker(opusFrameMs * time.Millisecond)
472547
defer ticker.Stop()
473548

0 commit comments

Comments
 (0)