Skip to content

Unbounded censored client outbound#76

Draft
noahlevenson wants to merge 22 commits intomainfrom
nelson/unbounded-integration
Draft

Unbounded censored client outbound#76
noahlevenson wants to merge 22 commits intomainfrom
nelson/unbounded-integration

Conversation

@noahlevenson
Copy link

@noahlevenson noahlevenson commented Dec 17, 2025

TODO:

  • TLS
  • STUN batch
  • plumb through remaining config options, including injected HTTP client
  • How does this outbound make its bootstrap requests?

@myleshorton
Copy link
Contributor

Nice! Generally looking good to me.

myleshorton and others added 4 commits February 27, 2026 05:23
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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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/unbounded outbound adapter, including WebRTC networking integration and QUIC/TLS setup.
  • Adds UnboundedOutboundOptions for 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.

Comment on lines +277 to +297
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,
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

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)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
}

func (d *rtcDialer) Dial(network, address string) (net.Conn, error) {
return d.dialer.Dial(network, address)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
}
}

// maybe well implement these later. might be useful
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

Typo/grammar in comment: maybe well implement these later. might be usefulmaybe we'll implement these later; might be useful.

Suggested change
// maybe well implement these later. might be useful
// maybe we'll implement these later; might be useful

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +112
if int(size) >= len(servers) {
return servers, nil
}
return servers[:size], nil
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +201 to +214
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())
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
myleshorton and others added 2 commits February 27, 2026 07:43
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>
Comment on lines +31 to +32
STUNBatch func(size uint32) (batch []string, err error)
HTTPClient *http.Client
Copy link
Collaborator

Choose a reason for hiding this comment

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

@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.

Copy link
Author

Choose a reason for hiding this comment

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

@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.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can use this suggestion no? #76 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

Just added that change along with a test to make sure it works.

Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Some things you won't be able to use Claude for 😆

myleshorton and others added 4 commits February 27, 2026 11:35
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>
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.

4 participants