Skip to content

twiddle: support UDP tunneling through UoT - #321

Merged
myleshorton merged 4 commits into
mainfrom
fisk/twiddle-udp
Sep 5, 2026
Merged

twiddle: support UDP tunneling through UoT#321
myleshorton merged 4 commits into
mainfrom
fisk/twiddle-udp

Conversation

@myleshorton

@myleshorton myleshorton commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Add UDP tunneling to Twiddle using the same sing-box UDP-over-TCP (UoT v2) path as Samizdat.

  • Advertise TCP and UDP on the outbound.
  • Support connected UDP through DialContext and multi-destination UDP through ListenPacket.
  • Wrap the inbound router with sing-box's UoT router so the reserved destination becomes packet routing, not an ordinary TCP destination.
  • Keep the outer transport TCP and leave Twiddle's handshake, credentials, and ordinary TCP framing unchanged.
  • Fix make test: declare the target phony (the existing test/ directory made CI skip it), and remove the obsolete GOEXPERIMENT=synctest flag rejected by the module's Go 1.25 toolchain. Existing experiment-tagged legacy tests are not migrated in this PR.

Flow

sequenceDiagram
    autonumber
    participant C as Caller
    participant O as Outbound<br/>outbound.go
    participant I as Inbound<br/>inbound.go
    participant R as UoT router

    C->>O: outbound.go:183 / 256<br/>DialContext(udp) or ListenPacket
    Note over O: outbound.go:176<br/>UoT uses this outbound's TCP path ⚠️
    O->>I: outbound.go:199<br/>authenticated Twiddle session over TCP
    O->>I: outbound.go:212<br/>reserved UoT destination, then request and datagrams
    rect rgba(255, 200, 200, 0.3)
        Note over I: inbound.go:83<br/>UoT wrapper fixes missing packet dispatch 🐛
    end
    I->>R: inbound.go:142<br/>RouteConnectionEx recognizes UoT destination
    R-->>C: inbound.go:83<br/>packet routing preserves datagram boundaries and destinations
Loading

Verification

  • go test -race -count=20 -timeout=3m ./protocol/twiddle passed.
  • Full go test ./... with CI's e2e build tags passed.
  • go vet for the Twiddle package, including the full tag set, passed.
  • New loopback integration test exercises the real Twiddle client/server handshake and both adapter paths: TCP, connected UDP, multi-destination packets, IPv4/IPv6 return addresses, and 4 KB payloads.
  • Repaired make test passes locally.
  • CI runs the standard test target and existing live e2e workflow.

Rollout / scope

Both client and egress need this adapter update for UDP. No dependency bump, new configuration option, or UDP listening port is required.

This PR does not revive closed #319 or fix the separate credential-reuse/concurrency issue documented in getlantern/lantern-cloud#3291. Full-handshake profile activation is also unchanged.

Summary by CodeRabbit

  • New Features

    • Twiddle outbound connections now support UDP alongside TCP.
    • UDP packet traffic can be sent through the Twiddle transport.
  • Bug Fixes

    • Improved inbound routing so connection and packet traffic are handled consistently.
  • Tests

    • Added end-to-end coverage for TCP, UDP, and packet-mode traffic using local loopback routing.
    • Test commands now run with the default Go configuration.

Copilot AI lite review requested due to automatic review settings September 5, 2026 10:41
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 45 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

This review ran on the open-source allowance, not this organization's plan, because the pull request author doesn't have an assigned seat. Waiting won't change this — ask an organization admin to assign them a seat, or add seats in Billing if every seat is already assigned, then retry.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: a93af02d-a247-4a06-be8f-0caa62899c0b

📥 Commits

Reviewing files that changed from the base of the PR and between 9a68e34 and c5e7cc1.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • go.mod
  • protocol/twiddle/outbound.go
  • protocol/twiddle/twiddle_test.go

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 0b3fa0b5-8f63-41cc-b267-a7bc16fb0ebd

📥 Commits

Reviewing files that changed from the base of the PR and between 468be38 and 9a68e34.

📒 Files selected for processing (4)
  • Makefile
  • protocol/twiddle/inbound.go
  • protocol/twiddle/outbound.go
  • protocol/twiddle/twiddle_test.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Twiddle now supports UDP over TCP through UoT. The inbound wraps its router for UoT-aware handling. End-to-end tests cover TCP, UDP, and packet-mode traffic. The Makefile runs tests with the default Go configuration.

Changes

Twiddle UoT transport

Layer / File(s) Summary
Outbound UDP-over-TCP support
protocol/twiddle/outbound.go
The outbound creates a UoT client, routes UDP dials and packet listeners through it, and advertises TCP and UDP support.
Inbound UoT router integration
protocol/twiddle/inbound.go
The inbound wraps the router with uot.NewRouter and stores it as a ConnectionRouterEx.
TCP and UDP end-to-end validation
protocol/twiddle/twiddle_test.go, Makefile
The tests validate TCP, UDP, and packet-mode echo routing. The test target is phony and no longer sets GOEXPERIMENT=synctest.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 9a68e

Twiddle now carries connected and packet-mode UDP traffic over its existing TCP transport while retaining TCP behavior. The supplied coverage exercises the new routing paths, with no remaining merge-blocking risk identified.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant TwiddleOutbound
  participant UoTClient
  participant TwiddleInbound
  participant EchoRouter
  Caller->>TwiddleOutbound: Dial or listen for TCP/UDP traffic
  TwiddleOutbound->>UoTClient: Route UDP through UoT
  UoTClient->>TwiddleInbound: Carry traffic over TCP
  TwiddleInbound->>EchoRouter: Route connection or packet
  EchoRouter-->>Caller: Echo payload and routing metadata
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements the UDP/UoT adapter changes. However, linked issue #319 identifies a Twiddle core write/close race caused by the new yamux concurrency and requires a fix before merge. The changed fi… Fix the Twiddle core race before merge. Serialize close-time and data writes through the existing write lock, or make writeSized respect the flushing state. Add or retain a race test that verifies safe sequencing of sendSeq and AEAD record …
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files. (1 skipped: 1 … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The changes remain within scope. They add UDP-over-TCP support, update the inbound UoT router, add integration coverage, and update the test target as described in the PR objectives.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding UDP tunneling to Twiddle through UoT.
Full details: Linked Issues check

Explanation

The PR implements the UDP/UoT adapter changes. However, linked issue #319 identifies a Twiddle core write/close race caused by the new yamux concurrency and requires a fix before merge. The changed files contain no fix for that race.

Resolution

Fix the Twiddle core race before merge. Serialize close-time and data writes through the existing write lock, or make writeSized respect the flushing state. Add or retain a race test that verifies safe sequencing of sendSeq and AEAD record writes.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fisk/twiddle-udp

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new test router embeds a nil adapter.Router, which can panic at runtime if non-Ex router methods are invoked (particularly via the UoT router wrapper).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds UDP tunneling support to the Twiddle protocol adapter by using sing-box’s UDP-over-TCP (UoT v2) path, aligning Twiddle’s capabilities with other UoT-enabled outbounds (e.g., Samizdat), and updates local tooling/tests accordingly.

Changes:

  • Advertise UDP support in the Twiddle outbound and implement UDP via uot.Client for DialContext and ListenPacket.
  • Wrap the Twiddle inbound router with sing-box’s UoT router to enable packet dispatch for UoT connections.
  • Add a loopback integration test covering TCP, connected UDP, and multi-destination packet routing; fix make test target behavior.
File summaries
File Description
protocol/twiddle/twiddle_test.go Adds integration test that exercises Twiddle TCP + UDP-over-TCP paths and router dispatch.
protocol/twiddle/outbound.go Enables UDP on outbound and routes UDP + packet APIs through uot.Client.
protocol/twiddle/inbound.go Wraps router with UoT router and updates inbound router type to support packet routing.
Makefile Declares test as phony and removes obsolete GOEXPERIMENT=synctest.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread protocol/twiddle/outbound.go Outdated
Comment thread protocol/twiddle/twiddle_test.go
… half

uot.Client was wired with the Outbound as its own Dialer, which made
DialContext(udp) and ListenPacket self-referential. Neither recurses against
sing v0.8.13 -- uot.Client dials TCP for the tunnel and never calls
Dialer.ListenPacket -- but both of those are upstream details rather than
properties of this package, and if a bump changes either the symptom is an
unbounded recursion in production, not a compile error. uotDialer opens the
outer tunnel and refuses anything else, which is how unbounded, samizdat and
water each wire theirs. dialTunnel is the shared body, so the TCP path and
UoT's inner dial cannot drift.

echoRouter left RouteConnection and RoutePacketConnection on a nil embedded
adapter.Router. uot.Router.RouteConnection forwards to the wrapped router's
RoutePacketConnection -- the non-Ex one -- so that path was a nil-interface
panic waiting for whatever first took it. Only the Ex path runs today, which is
why the test passed. Both are defined now, matching the mockRouters in samizdat
and meek.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gn6KuHUL766qQNn8m1fu1

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The implementation follows established in-repo UoT patterns and includes an integration test that exercises the new TCP/UDP/packet flows end-to-end.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

getlantern/twiddle#2 is merged. Conn.writeRecord now holds a dedicated mutex
across the seal, the sequence increment and the socket write, which it did not
before: Write releases wmu around each record so a slow socket cannot block
other writers, and Close reached writeRecord through writeSized on that freed
lock. Two sealers could take one sendSeq, and the sequence number is the AEAD
nonce, so that was nonce reuse under a single key rather than a decrypt
failure -- and records could reach the wire out of the order they were sealed
in, which the peer's monotonic counter cannot authenticate either.

It matters here because UoT gives a twiddle Conn the ordinary sing-box shape of
a bidirectional copy: one direction writing while the other closes on error is
exactly the overlap involved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gn6KuHUL766qQNn8m1fu1

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The implementation appears complete and is backed by a new end-to-end test; the only feedback is a minor test robustness nit.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

protocol/twiddle/twiddle_test.go:100

  • In the accept goroutine, conn.SetDeadline(...) ignores its returned error. If SetDeadline fails (e.g., unusual net.Conn implementations), the test can hang longer than intended or behave inconsistently; it’s safer to handle the error and abort the goroutine early.
  • Files reviewed: 5/6 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@myleshorton
myleshorton merged commit 561c9e4 into main Sep 5, 2026
5 of 6 checks passed
@myleshorton
myleshorton deleted the fisk/twiddle-udp branch September 5, 2026 11:25
myleshorton added a commit that referenced this pull request Sep 5, 2026
#321 made dialTunnel mean "open one outer twiddle connection" and pointed UoT's
inner dial at it. This branch replaces that model with one session and a stream
per dial, so the two had to be reconciled rather than merged textually: taken
naively, every UDP association would have opened a twiddle tunnel of its own,
putting a fresh TLS-shaped opening on the wire for every DNS lookup. That is the
pattern muxing exists to remove.

dialTunnel now means "open a stream on the shared session", and uotDialer still
points at it, so UDP rides the mux with everything else. Network() keeps both
protocols, Close() keeps the session teardown, and the GO_AWAY handling moves
with the retry loop into dialTunnel.

Two things in #321's test assumed the pre-mux lifetime. NewConnectionEx blocked
there for the life of the connection; here it hands the session to a goroutine
and returns, so the test's `defer conn.Close()` tore the tunnel down before the
client could open a stream, and its SetDeadline bounded the whole session rather
than one connection. The test now holds the tunnel open explicitly.

And echoRouter called onClose unconditionally, which routeStream's nil argument
turned into a panic. nil is legitimate: sing-box's own router funnels onClose
through N.CloseOnHandshakeFailure, which tolerates it, and a stream has no
per-stream close bookkeeping to do. The double now guards, like the production
path does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gn6KuHUL766qQNn8m1fu1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants