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