Skip to content

Commit a877927

Browse files
Adam Fiskclaude
authored andcommitted
unbounded: integrate broflake widget-proxy lifecycle manager
Adds the radiance side of the Unbounded ("Basic mode" in the SmC UI) WebRTC donor-mode integration. Self-contained package under radiance/unbounded; drops cleanly onto the current branch without needing the larger vpn/ refactor that adam/unbounded-widget-proxy ships with. unbounded.SetEnabled(bool) toggles the local opt-in (settings.UnboundedKey). InitSubscription wires the manager to config.NewConfigEvent — the broflake widget actually runs only when: 1. settings.UnboundedKey is true (local opt-in) 2. server cfg.Features[UNBOUNDED] is on 3. server provides cfg.Unbounded discovery + egress URLs Each consumer connection change emits unbounded.ConnectionEvent on the radiance event bus, mirroring the shape of peer.ConnectionEvent so lantern-core subscribers can feed both into one Flutter event stream. Wired into LocalBackend.Start so the manager is live for the process lifetime; sync.Once-guarded against double-subscribe. Mostly a port of the unbounded.go file from adam/unbounded-widget-proxy (#336), with the package moved out of vpn/ since that branch's vpn/ is undergoing a separate refactor and we want the unbounded code to land independently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 217afc9 commit a877927

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

backend/radiance.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"github.com/getlantern/radiance/servers"
3939
"github.com/getlantern/radiance/telemetry"
4040
"github.com/getlantern/radiance/traces"
41+
"github.com/getlantern/radiance/unbounded"
4142
"github.com/getlantern/radiance/vpn"
4243

4344
"github.com/sagernet/sing-box/adapter"
@@ -232,6 +233,13 @@ func (r *LocalBackend) Start() {
232233

233234
r.resumePeerShareIfEnabled()
234235

236+
// Wire the broflake / Unbounded widget proxy lifecycle to config
237+
// updates. This single subscription handles all three start/stop
238+
// triggers (local toggle, server feature flag, server-supplied
239+
// config); InitSubscription is sync.Once-guarded so a future Start
240+
// retry after Close won't double-subscribe.
241+
unbounded.InitSubscription()
242+
235243
// set country code in settings when new config is received so it can be included in issue reports
236244
events.SubscribeOnce(func(evt config.NewConfigEvent) {
237245
if env.GetString(env.Country) != "" {

common/settings/settings.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ const (
6565
// Advanced setting in the Share My Connection UI for users on
6666
// networks where UPnP is disabled or unavailable.
6767
PeerManualPortKey _key = "peer_manual_port" // int (0 = unset; 1..65535 = manual port)
68+
// UnboundedKey is the local opt-in for the broflake / Unbounded
69+
// widget proxy. When true AND the server-side Features[unbounded]
70+
// flag is on AND the server provides UnboundedConfig (discovery
71+
// + egress URLs), the widget proxy starts. Surfaced as a "Basic
72+
// mode" option in the Share My Connection UI for networks where
73+
// UPnP isn't workable but the user still wants to contribute via
74+
// the WebRTC-based donor path.
75+
UnboundedKey _key = "unbounded" // bool
6876
SelectedServerKey _key = "selected_server" // [servers.Server] Server.Options is not stored
6977

7078
PreferredLocationKey _key = "preferred_location" // [common.PreferredLocation]

unbounded/unbounded.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Package unbounded manages the broflake / Unbounded widget-proxy lifecycle.
2+
//
3+
// Unbounded is the WebRTC-based donor mode for Lantern's Share My Connection
4+
// feature: the local user contributes bandwidth to censored users via short-
5+
// lived WebRTC sessions brokered through a discovery server, without exposing
6+
// a long-lived inbound port the way the samizdat-over-UPnP "Share My
7+
// Connection" mode does. It's the lower-bandwidth, lower-risk, universally-
8+
// applicable alternative to SmC — works on networks where UPnP is disabled
9+
// or unavailable, and the peer's residential IP isn't tied to a single
10+
// long-lived inbound listener.
11+
//
12+
// Three conditions must all hold for the widget proxy to actually run:
13+
//
14+
// 1. settings.UnboundedKey is true (local opt-in via the UI toggle)
15+
// 2. server-side cfg.Features[UNBOUNDED] is enabled (server says go)
16+
// 3. server-side cfg.Unbounded provides discovery + egress URLs
17+
//
18+
// The manager subscribes to config.NewConfigEvent and recomputes the
19+
// running state on every config update; it also re-evaluates when
20+
// SetEnabled flips the local toggle. Each consumer connection change
21+
// (accept / disconnect) emits a ConnectionEvent on the radiance event
22+
// bus so the same Flutter globe used for SmC can render arcs without
23+
// caring which protocol produced them.
24+
package unbounded
25+
26+
import (
27+
"context"
28+
"log/slog"
29+
"net"
30+
"sync"
31+
32+
C "github.com/getlantern/common"
33+
34+
"github.com/getlantern/broflake/clientcore"
35+
36+
"github.com/getlantern/radiance/common/settings"
37+
"github.com/getlantern/radiance/config"
38+
"github.com/getlantern/radiance/events"
39+
)
40+
41+
// ConnectionEvent fires every time a consumer (i.e. a censored client
42+
// being routed through this widget proxy) connects or disconnects via
43+
// the broflake mesh. State: +1 on accept, -1 on close. WorkerIdx is
44+
// broflake's internal worker slot identifier — used by the Flutter
45+
// globe to pair connect/disconnect events for the same arc. Addr is
46+
// the remote consumer's IP if broflake exposes it, otherwise empty.
47+
//
48+
// Shape mirrors radiance/peer.ConnectionEvent so consumers (lantern-
49+
// core's listenPeerConnectionEvents in particular) can subscribe with
50+
// a single discriminator and feed both the SmC and Unbounded streams
51+
// into the same globe view.
52+
type ConnectionEvent struct {
53+
events.Event
54+
State int `json:"state"`
55+
WorkerIdx int `json:"workerIdx"`
56+
Addr string `json:"addr"`
57+
}
58+
59+
var manager = &unboundedManager{}
60+
61+
type unboundedManager struct {
62+
mu sync.Mutex
63+
cancel context.CancelFunc
64+
lastCfg *C.UnboundedConfig // most recent server-supplied config
65+
}
66+
67+
// Enabled reports whether the local opt-in is set. Doesn't say whether
68+
// the proxy is currently running (server flag and config can override).
69+
func Enabled() bool {
70+
return settings.GetBool(settings.UnboundedKey)
71+
}
72+
73+
// SetEnabled flips the local opt-in. When enabling, the proxy starts
74+
// immediately if a server config is already cached; otherwise it
75+
// starts on the next config event. When disabling, the proxy stops.
76+
// Idempotent — calling with the current value is a no-op.
77+
func SetEnabled(enable bool) error {
78+
if Enabled() == enable {
79+
return nil
80+
}
81+
if err := settings.Set(settings.UnboundedKey, enable); err != nil {
82+
return err
83+
}
84+
slog.Info("Unbounded widget proxy local opt-in changed", "enabled", enable)
85+
if enable {
86+
manager.mu.Lock()
87+
cfg := manager.lastCfg
88+
manager.mu.Unlock()
89+
if cfg != nil {
90+
manager.start(cfg)
91+
} else {
92+
slog.Info("Unbounded: enabled locally, will start when server config arrives")
93+
}
94+
} else {
95+
manager.stop()
96+
}
97+
return nil
98+
}
99+
100+
// InitSubscription wires the manager into radiance's config event bus.
101+
// Called once at LocalBackend startup; the subscription lives for the
102+
// process lifetime, so repeated calls would leak goroutines — hence
103+
// the package-level guard.
104+
func InitSubscription() {
105+
initOnce.Do(func() {
106+
events.Subscribe(func(evt config.NewConfigEvent) {
107+
if evt.New == nil {
108+
return
109+
}
110+
// config.Config is a type alias for C.ConfigResponse on
111+
// the current radiance branch — no nested .ConfigResponse
112+
// field, just dereference and use directly.
113+
cfg := *evt.New
114+
manager.mu.Lock()
115+
manager.lastCfg = cfg.Unbounded
116+
running := manager.cancel != nil
117+
manager.mu.Unlock()
118+
119+
shouldRun := shouldRunUnbounded(cfg)
120+
switch {
121+
case shouldRun && !running:
122+
manager.start(cfg.Unbounded)
123+
case !shouldRun && running:
124+
manager.stop()
125+
}
126+
})
127+
})
128+
}
129+
130+
var initOnce sync.Once
131+
132+
// Stop tears down a running widget proxy. Idempotent. Used as a
133+
// LocalBackend shutdown hook so the broflake goroutines don't outlive
134+
// the radiance process during a graceful exit.
135+
func Stop(_ context.Context) error {
136+
manager.stop()
137+
return nil
138+
}
139+
140+
func shouldRunUnbounded(cfg C.ConfigResponse) bool {
141+
if !settings.GetBool(settings.UnboundedKey) {
142+
return false
143+
}
144+
if !cfg.Features[C.UNBOUNDED] {
145+
return false
146+
}
147+
if cfg.Unbounded == nil {
148+
return false
149+
}
150+
return true
151+
}
152+
153+
func (m *unboundedManager) start(ucfg *C.UnboundedConfig) {
154+
m.mu.Lock()
155+
defer m.mu.Unlock()
156+
if m.cancel != nil {
157+
return // already running
158+
}
159+
160+
ctx, cancel := context.WithCancel(context.Background())
161+
m.cancel = cancel
162+
163+
go func() {
164+
slog.Info("Unbounded: starting broflake widget proxy")
165+
166+
bfOpt := clientcore.NewDefaultBroflakeOptions()
167+
bfOpt.ClientType = "widget"
168+
if ucfg != nil {
169+
if ucfg.CTableSize > 0 {
170+
bfOpt.CTableSize = ucfg.CTableSize
171+
}
172+
if ucfg.PTableSize > 0 {
173+
bfOpt.PTableSize = ucfg.PTableSize
174+
}
175+
}
176+
177+
// Wire the broflake connection callback into the radiance event
178+
// bus so the Flutter globe (and any future abuse aggregation)
179+
// sees consumer connect/disconnect.
180+
bfOpt.OnConnectionChangeFunc = func(state int, workerIdx int, addr net.IP) {
181+
addrStr := ""
182+
if addr != nil {
183+
addrStr = addr.String()
184+
}
185+
slog.Debug("Unbounded: consumer connection change",
186+
"state", state, "workerIdx", workerIdx, "addr", addrStr)
187+
events.Emit(ConnectionEvent{
188+
State: state,
189+
WorkerIdx: workerIdx,
190+
Addr: addrStr,
191+
})
192+
}
193+
194+
rtcOpt := clientcore.NewDefaultWebRTCOptions()
195+
if ucfg != nil {
196+
if ucfg.DiscoverySrv != "" {
197+
rtcOpt.DiscoverySrv = ucfg.DiscoverySrv
198+
}
199+
if ucfg.DiscoveryEndpoint != "" {
200+
rtcOpt.Endpoint = ucfg.DiscoveryEndpoint
201+
}
202+
}
203+
204+
egOpt := clientcore.NewDefaultEgressOptions()
205+
if ucfg != nil {
206+
if ucfg.EgressAddr != "" {
207+
egOpt.Addr = ucfg.EgressAddr
208+
}
209+
if ucfg.EgressEndpoint != "" {
210+
egOpt.Endpoint = ucfg.EgressEndpoint
211+
}
212+
}
213+
214+
// BroflakeConn is for clients routing traffic *through* the
215+
// mesh. A widget proxy only donates bandwidth, so the conn
216+
// is unused — discard it.
217+
_, ui, err := clientcore.NewBroflake(bfOpt, rtcOpt, egOpt)
218+
if err != nil {
219+
slog.Error("Unbounded: failed to create broflake widget", "error", err)
220+
cancel()
221+
m.mu.Lock()
222+
m.cancel = nil
223+
m.mu.Unlock()
224+
return
225+
}
226+
227+
slog.Info("Unbounded: broflake widget proxy started")
228+
<-ctx.Done()
229+
slog.Info("Unbounded: stopping broflake widget proxy")
230+
ui.Stop()
231+
m.mu.Lock()
232+
m.cancel = nil
233+
m.mu.Unlock()
234+
slog.Info("Unbounded: broflake widget proxy stopped")
235+
}()
236+
}
237+
238+
func (m *unboundedManager) stop() {
239+
m.mu.Lock()
240+
defer m.mu.Unlock()
241+
if m.cancel != nil {
242+
m.cancel()
243+
m.cancel = nil
244+
}
245+
}

0 commit comments

Comments
 (0)