Conversation
|
Nice! Generally looking good to me. |
Deduplicate algeneva and water imports that were added twice in register.go (merge conflict artifact). Fix spaces-to-tab indentation for TypeSamizdat in proxy.go. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow callers to inject an http.Client and a static list of STUN servers into UnboundedOutboundOptions. When HTTPClient is set, it is used directly instead of building one from the sing-box dialer. When STUNServers is set (and STUNBatch is not), a batch function is created that returns from the provided list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new custom Unbounded outbound protocol implementation (backed by github.com/getlantern/broflake) and wires it into the protocol registry so it can be selected via configuration.
Changes:
- Introduces
protocol/unboundedoutbound adapter, including WebRTC networking integration and QUIC/TLS setup. - Adds
UnboundedOutboundOptionsfor configuring broflake/WebRTC/egress parameters. - Registers the new outbound type and updates constants; updates Go module dependencies to include broflake + pion transport v3.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| protocol/unbounded/outbound.go | New Unbounded outbound adapter implementation (options plumbing, dialing, startup, TLS config). |
| protocol/unbounded/net.go | Adds a transport.Net wrapper to route pion/WebRTC networking through the sing-box dialer. |
| protocol/register.go | Registers the Unbounded outbound in the outbound registry. |
| option/unbounded.go | Adds configuration struct for the Unbounded outbound. |
| constant/proxy.go | Adds TypeUnbounded constant. |
| go.mod | Adds broflake + pion transport v3 and related indirect dependencies. |
| go.sum | Updates checksums for newly added/updated dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
protocol/unbounded/outbound.go
Outdated
| caCertPool := x509.NewCertPool() | ||
|
|
||
| if egressCA != "" { | ||
| ok := caCertPool.AppendCertsFromPEM([]byte(egressCA)) | ||
| if !ok { | ||
| panic("an egress CA cert was configured, but it could not be appended") | ||
| } | ||
| } | ||
|
|
||
| clientAuth := tls.RequireAndVerifyClientCert | ||
|
|
||
| if insecureDoNotVerifyClientCert { | ||
| clientAuth = tls.NoClientCert | ||
| } | ||
|
|
||
| return &tls.Config{ | ||
| Certificates: []tls.Certificate{tlsCert}, | ||
| NextProtos: []string{"broflake"}, | ||
| ClientAuth: clientAuth, | ||
| ClientCAs: caCertPool, | ||
| } |
There was a problem hiding this comment.
TLS client-certificate verification is effectively enabled by default (RequireAndVerifyClientCert), but when egressCA is empty the ClientCAs pool is empty as well, which will cause all client-cert handshakes to fail. Either make client cert verification opt-in (only require/verify when a CA is configured), or load system roots / validate that egressCA is provided and return a clear error.
There was a problem hiding this comment.
I don't think Copilot is making sense here. We should not load the system cert pool under any circumstances for our special mTLS setup. Last time I did an e2e test, I was able to specify InsecureDoNotVerifyClientCert in my config.json without providing a CA cert and everything worked exactly as intended.
If we somehow manage to fail to serve the client a CA cert, the handshake failing is exactly what should happen.
| outline.RegisterOutbound(registry) | ||
| samizdat.RegisterOutbound(registry) | ||
| water.RegisterOutbound(registry) | ||
| unbounded.RegisterOutbound(registry) |
There was a problem hiding this comment.
SupportedProtocols() is backed by supportedProtocols, but unbounded isn’t included there. Since this PR registers the Unbounded outbound, callers that use SupportedProtocols() will still think it’s unsupported. Add "unbounded" to the supportedProtocols slice.
| } | ||
|
|
||
| func (d *rtcDialer) Dial(network, address string) (net.Conn, error) { | ||
| return d.dialer.Dial(network, address) |
There was a problem hiding this comment.
rtcNet.CreateDialer returns a dialer whose Dial method calls the provided net.Dialer directly, bypassing the sing-box N.Dialer that this type is supposed to enforce. That likely defeats the purpose of routing/bind_interface/detour for any pion code paths that use CreateDialer. Consider implementing rtcDialer.Dial in terms of rtcNet (or removing CreateDialer if it’s not needed) so all dials consistently go through n.dialer.
| return d.dialer.Dial(network, address) | |
| destination, err := metadata.ParseSocksaddr(address) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return d.net.dialer.DialContext(d.net.ctx, network, destination) |
| } | ||
| } | ||
|
|
||
| // maybe well implement these later. might be useful |
There was a problem hiding this comment.
Typo/grammar in comment: maybe well implement these later. might be useful → maybe we'll implement these later; might be useful.
| // maybe well implement these later. might be useful | |
| // maybe we'll implement these later; might be useful |
protocol/unbounded/outbound.go
Outdated
| if int(size) >= len(servers) { | ||
| return servers, nil | ||
| } | ||
| return servers[:size], nil |
There was a problem hiding this comment.
rtcOpt.STUNBatch closure returns servers[:size], but slice indices must be int in Go. As written this won’t compile because size is uint32. Convert size to int consistently in both the bounds check and the slice expression (and consider guarding against overflow/negative values from config before casting).
| if int(size) >= len(servers) { | |
| return servers, nil | |
| } | |
| return servers[:size], nil | |
| // If requested size is greater than or equal to the number of servers, | |
| // return all servers. | |
| if size >= uint32(len(servers)) { | |
| return servers, nil | |
| } | |
| // Safe to convert to int after bounding by len(servers). | |
| n := int(size) | |
| return servers[:n], nil |
protocol/unbounded/outbound.go
Outdated
| func (h *Outbound) DialContext( | ||
| ctx context.Context, | ||
| network string, | ||
| destination M.Socksaddr, | ||
| ) (net.Conn, error) { | ||
| // XXX: this is the log pattern for N.NetworkTCP | ||
| h.logger.InfoContext(ctx, "outbound connection to ", destination) | ||
|
|
||
| if h.dial == nil { | ||
| return nil, fmt.Errorf("unbounded not ready") | ||
| } | ||
|
|
||
| // XXX: network is ignored by Unbounded's SOCKS5 dialer | ||
| return h.dial(ctx, network, destination.String()) |
There was a problem hiding this comment.
This outbound’s DialContext doesn’t call adapter.ExtendContext to populate outbound/destination metadata (unlike other outbounds in this repo). That can break per-outbound routing/logging features that rely on the metadata in context. Consider extending the context here and setting metadata.Outbound and metadata.Destination before dialing.
Use sing's uot.Client to tunnel UDP traffic over Unbounded's TCP-only SOCKS5 connection, following the same pattern as the samizdat outbound. The outbound now advertises both TCP and UDP network support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
option/unbounded.go
Outdated
| STUNBatch func(size uint32) (batch []string, err error) | ||
| HTTPClient *http.Client |
There was a problem hiding this comment.
@myleshorton the options have to be able to marshaled to JSON. We won't be able to provide a http.Client.
@noahlevenson sorry, I guess I missed this before, but same thing for STUNBatch.
There was a problem hiding this comment.
@garmr-ulfr Whoops, I think I committed that STUNBatch change by accident -- it was in my local changes so that I could use a localhost STUN server while we were debugging direct dials. It definitely should not be there.
There was a problem hiding this comment.
I think we can use this suggestion no? #76 (comment)
There was a problem hiding this comment.
Just added that change along with a test to make sure it works.
There was a problem hiding this comment.
Unfortunately not, for a couple of reasons, the sing-box marshaler still marshals those fields. That's where it does all the custom marshaling that prevents us from using the std lib JSON marshaler. And libbox accepts the options as a string.
This also breaks the sing-box convention making it require extra steps for users to use this with standard sing-box.
There was a problem hiding this comment.
Some things you won't be able to use Claude for 😆
Replace 1024-bit RSA (insecure) with ECDSA P-256 for self-signed TLS cert generation. Return errors to caller instead of panicking so the outbound can fail gracefully during init. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mark STUNBatch (func) and HTTPClient (*http.Client) with json:"-" so they are excluded from JSON marshalling. These are injection-only fields that cannot be represented in JSON config. Adds a round-trip marshalling test to verify the options struct serializes and deserializes correctly with these fields excluded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… working STUN batch function
TODO: