peer: stream samizdat ConnectionEvents through radiance to consumers#499
Open
myleshorton wants to merge 12 commits into
Open
peer: stream samizdat ConnectionEvents through radiance to consumers#499myleshorton wants to merge 12 commits into
myleshorton wants to merge 12 commits into
Conversation
This was referenced May 28, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end plumbing to propagate inbound samizdat connection accept/close events from lantern-box into Radiance’s in-process event bus and out to external consumers via IPC SSE, alongside more granular peer-share lifecycle phase updates.
Changes:
- Introduces
peer.ConnectionEvent(+1/-1, sourceip:port) and wires lantern-boxtracker/peerconnnotifications toevents.Emit. - Adds phase-granular
peer.Statusupdates (mapping/detecting/registering/starting/verifying/serving/stopping/error) and expands peer status event tests. - Exposes a new IPC SSE stream for peer connection events and adds corresponding client helpers.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
peer/peer.go |
Adds ConnectionEvent, phase-based status reporting, and peerconn listener registration/draining during start/stop. |
peer/peer_test.go |
Updates lifecycle event test to expect phased emits; adds Start-error phase reporting test. |
ipc/server.go |
Adds /peer/connection/events SSE endpoint streaming peer.ConnectionEvent. |
ipc/client_events_nonmobile.go |
Adds client helpers to consume peer status + connection SSE streams on non-mobile. |
ipc/client_events_mobile.go |
Adds dual-path (in-proc subscribe vs SSE) helpers for peer status + connection events on mobile. |
events/events.go |
Adds per-emit subscriber-count diagnostic logging hook. |
go.mod |
Adds a local replace for lantern-box and bumps lantern-box version requirement. |
go.sum |
Updates dependency sums for bumped modules. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
024337d to
c7b4a75
Compare
4960eb5 to
5e36933
Compare
c7b4a75 to
9bb7d4b
Compare
5e36933 to
854b2b1
Compare
9bb7d4b to
5614c02
Compare
854b2b1 to
c2bfbd2
Compare
5614c02 to
dff7a12
Compare
915e95b to
67311e7
Compare
343e765 to
7768eca
Compare
67311e7 to
3ac9497
Compare
Plumb lantern-box's peerconn listener registry through to the radiance event bus so consumers (Flutter globe view, future abuse aggregation) can subscribe to a per-connection accept/close stream. Listener is registered after libbox.Start so the box's accept loop is already serving when notifications start flowing; cleared on Stop and in the Start rollback path so post-teardown callbacks land on a no-op rather than emitting events to a torn-down consumer. Source field carries the remote "ip:port" string verbatim from M.Socksaddr.String(); consumers extract the IP for geo-lookup or rate-limit attribution. Pinned to local lantern-box via a replace directive while the peerconn package is in flight; remove once lantern-box tags a release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit d4fc0cb)
Adds a localhost HTTP endpoint exposing the active samizdat connection set as JSON, fed by the lantern-box peerconn listener registered when peer.Client.Start succeeds. Replaces the planned full Go→FFI→Dart event channel for the prototype with poll-driven Dart consumption — much smaller surface, same data shape, swap with a streaming FFI events path later without changing the Dart side. Loopback-only: net.Listen 127.0.0.1 enforces it at the kernel level, plus a defense-in-depth host check on each request in case someone later misconfigures RADIANCE_PEER_STATS_ADDR to a non-loopback bind. The endpoint reveals connected client IPs which we don't want surfaced beyond the local machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 48e0f6f)
The HTTP endpoint at 127.0.0.1:17099/peer/connections was added to bridge
peer connection lifecycle to Flutter without writing FFI plumbing, but
two problems with that approach:
1. Detectability — a fixed loopback port is a Lantern-specific
fingerprint any local process (incl. malware) can probe. Sandboxed
adversary on the user's machine could detect Lantern is running.
2. Local server adds attack surface for free.
Reverting to ConnectionEvent emission only; Flutter consumption rides
on the existing FlutterEventEmitter / Dart api_dl bridge in lantern-core
(separate commit) which has no port footprint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit a1c10cf)
defaultBuildBoxService used to call libbox.NewServiceWithContext with
the caller's bare ctx, which has no lantern-box protocol registries
plumbed in. The samizdat inbound type ServerConfig sends back from
/peer/register isn't a built-in sing-box protocol, so libbox's JSON
decoder couldn't resolve inbounds[0].type="samizdat" and returned
"missing inbound fields registry in context". The integration tests
stub BuildBoxService entirely, so this layer was never exercised in
CI — only surfaced live during the eero end-to-end test.
Two pieces:
1. Use box.BaseContext() (from getlantern/lantern-box) when calling
libbox.NewServiceWithContext. That ctx has the InboundOptionsRegistry
populated with samizdat / reflex / etc. so the decode succeeds.
Coexists with the user's VPN tunnel (vpn/tunnel.go) — libbox.Setup
is process-global, the ctx registries are per-box.
2. TestDefaultBuildBoxService_DecodesSamizdatInbound walks the actual
decode path with a minimal samizdat-inbound JSON. Verified to fail
with the exact production error message under the pre-fix code,
pass under the fix. Cuts the diagnostic loop from a 5-minute
rebuild+redeploy+toggle cycle to a 0.5s test failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit b25b01b)
When the user toggles SmC off while real client traffic is flowing, box.Close fires per-connection disconnect callbacks for every in-flight inbound. peerconn.Notify reads its registered listener under an RLock and releases the lock before invoking — SetListener(nil) alone races against goroutines that have already snapshotted the listener (one per live connection). Each surviving callback hits events.Emit, which spawns yet another goroutine per subscriber. The Flutter-side subscriber posts main-thread tasks per event, and a hundred-task flood against an engine that's simultaneously handling the SmC-off state change reproduced as a Flutter mutex abort on the main thread. Add a sync/atomic flag the listener wrapper checks inline. Flip it before box.Close in both Stop and the Start-rollback defer; re-arm it at the top of Start so a Stop→Start cycle doesn't leave the wrapper muted. SetListener(nil) still runs for cleanliness, but the flag is what actually halts the cascade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6774c6)
The UI today sees a single active/inactive flip — toggling SmC on looks
"hung" through the multi-second sequence of port-forwarding, registering,
starting the local box, and verifying. This adds a Phase field to Status
and emits one StatusEvent per stage:
Start: mapping_port → detecting_ip → registering → starting_proxy →
verifying → serving
Stop: stopping → idle
on err: error (Status.Error populated with the wrapped fmt.Errorf
message, e.g. "map port 33445: upnp gateway refused mapping")
Phase is a stable string so Flutter / web consumers can switch on it
without depending on Go enum ordering. Active stays as a derived bool
(true only on PhaseServing) for subscribers that just want the binary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 39b6b45)
The "no globe arcs despite 200+ samizdat connections" pattern is
unobservable from current logs: peerconn.SetListener and events.Emit
don't log, so when the chain breaks between samizdat-in's Notify and
the Flutter bridge, there's no trace. This adds three breadcrumbs to
make the failure mode diagnosable on the next rebuild:
- "peer listener: registered with peerconn" — one line per Start that
confirms the listener actually got installed
- "peer listener: forwarding connection event" — one line per accept
AND per close; pairs with the lantern-core subscriber breadcrumb
so we can see if events bus delivers what the listener emits
- "peer listener: dropping post-Stop Notify" — DEBUG-level for the
race window the listenerDraining flag silences; makes that bucket
countable instead of silently discarding events
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit bf26ce2)
The radiance peer listener fires (42 ConnectionEvents observed) but lantern-core's subscriber breadcrumb never fires, suggesting either Subscribe never ran or Emit is looking at a different subscriptions map. Logs the type key + subscriber count at every Emit so we can distinguish "no subscribers registered" (init bug) from "subscribers registered but callback panics" (rare, but possible). Uses stdlib log to avoid pulling slog into the events package (and a possible import cycle with slog-forwarding handlers that subscribe to events). Temporary diagnostic — should be downgraded to Debug or removed once the chain works end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 810ef9b)
The events package's globals are process-scoped — events.Emit in
lanternd (where radiance/peer runs) doesn't reach events.Subscribe in
Liblantern. Diagnostic at events.go showed subscribers=0 for every
peer.ConnectionEvent emit despite Subscribe being called.
Adds the cross-process bridge:
- New /peer/connection/events SSE endpoint (mirrors /peer/status/events).
peerConnectionEventsHandler buffers 64 events to absorb slow consumers
without backpressuring events.Emit; drops on overflow rather than
growing unbounded.
- Client.PeerStatusEvents(ctx, handler) and Client.PeerConnectionEvents(
ctx, handler) in both mobile and nonmobile client variants. Mobile
keeps the events.SubscribeContext path so in-process delivery still
works for builds that bundle radiance with the consumer; otherwise
falls through to SSE.
The peer-status SSE endpoint and handler were already there; this PR
just adds the matching client method so lantern-core can actually
consume it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 29a4b7e)
lantern-box bumps samizdat to plumb the underlying TLS conn's RemoteAddr through serverStreamConn. With this, peer.ConnectionEvent emitted from the peerconn listener carries a real peer ip:port instead of the "client:0" placeholder, so the Dart Share My Connection UI can key globe arcs per actual peer (and arcs persist through real connection lifetimes instead of flickering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 0b72cd4)
3ac9497 to
3debe39
Compare
2 tasks
Four substantive findings (the fifth — local replace directive in go.mod — was already addressed in the cascade rebase to 3debe39): 1. ConnectionEvent now carries a Timestamp field (Unix millis at emit time). Consumers that need ordering across the async dispatch path, or that aggregate over a time window, can compare it directly without snapshotting wall time at receive. 2. Per-connection forwarding log dropped from Info to Debug. Two reasons: under real traffic the Info-level breadcrumb floods logs, and the remote ip:port doesn't belong in routinely-collected client logs for a censorship-circumvention tool. Operators investigating 'no globe arcs despite samizdat traffic' can flip the level. The once-per-session listener-registration line stays at Info — that's a lifecycle event, not a per-connection breadcrumb. 3. events.Emit's diagnostic hook (emitDebugLogger) now defaults to a no-op instead of a synchronous log.Printf, and is called AFTER releasing subscriptionsMu rather than under the RLock. The old default both spammed prod logs and let the logger amplify subscriptionsMu contention on hot event types. Added SetEmitDebugLogger so callers (tests, diagnostic builds) can swap in a real logger when investigating a specific path; nil restores the no-op. The 'log' import is no longer needed. 4. TestClient_StatusEventEmittedOnStartAndStop rewritten to assert set-membership of expected phases + the final-state contract (PhaseServing carries Active=true, RouteID set; all others carry Active=false). events.Emit dispatches each subscriber's callback in its own goroutine, so the channel-arrival order of multiple sequential Emits is non-deterministic and the previous strict- ordered receive was inherently flaky. New drainPhases helper collects N events keyed by Phase (last-write-per-phase wins). Tests pass 5/5 times under -race -count=1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
b2b3938 to
06ceb1f
Compare
2 tasks
Round-1's fix to move emitDebugLogger out from under subscriptionsMu.RLock (so a blocking logger couldn't amplify lock contention) accidentally moved the per-subscriber iteration outside the lock too. Iterating the subscriptions[key] map after RUnlock races against Unsubscribe's write-locked mutation — guaranteed 'concurrent map iteration and map write' panic under load. Fix: snapshot the callbacks into a slice while holding the RLock, then drop the lock and run emitDebugLogger + the per-callback goroutine spawns over the slice. Slice iteration is race-free because the slice itself is unshared. The original code (pre-round-1) was correct because it held the RLock for the whole function via defer — that's still safe but it forces logger calls to run under the lock. The snapshot pattern gets both properties: no iteration race + no blocking under the lock. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The peer module on
fisk/peer-module(#458) brings up samizdat as an inbound proxy on a UPnP-mapped port. This PR wires accept/close lifecycle events from samizdat through to anything that wants to react to them — primarily Flutter's globe widget, which renders an arc per remote client connection.Concretely: lantern-box's new
tracker/peerconnlistener registry (getlantern/lantern-box#255) callspeerconn.Notify(+1, "ip:port")on accept andNotify(-1, ...)on close. This PR subscribes to that, normalizes intopeer.ConnectionEvent, and emits on the radiance event bus. Consumers subscribe via the existing IPC events bridge.Scope intentionally narrow — this PR is only the event plumbing. Two follow-ups land on top:
What's new
events/events.go—peer.ConnectionEvent{State, Source, Timestamp}event type andEmitsubscriber count logging.ipc/types.go,ipc/client_events_*.go,ipc/server.go— IPC marshaling so the FlutterEvent bridge can streamConnectionEvents out-of-process.peer/peer.go— subscription to lantern-box'speerconn.Notifycallbacks; phase-granularStatusEvents through Start/Stop lifecycle; listener-draining shield during box.Close to prevent disconnect-cascade flooding the Flutter event bus.replace github.com/getlantern/lantern-box => ../lantern-boxingo.mod— needs to come out before merge once lantern-box#255 ships.How this was sliced
Split out of #471, which originally bundled events + manual port forward + Unbounded integration into one 4.8k-LOC PR. This is the events + IPC streaming slice (~386 LOC). See #471 for the full original context.
Test plan
go test ./peer/... ./events/... ./ipc/...clean on the branch.ConnectionEvent{+1, ...}is emitted on accept and-1on close.Dependencies
🤖 Generated with Claude Code