Skip to content

peer: stream samizdat ConnectionEvents through radiance to consumers#499

Open
myleshorton wants to merge 12 commits into
fisk/peer-call-verifyfrom
fisk/peer-connection-events-A
Open

peer: stream samizdat ConnectionEvents through radiance to consumers#499
myleshorton wants to merge 12 commits into
fisk/peer-call-verifyfrom
fisk/peer-connection-events-A

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

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/peerconn listener registry (getlantern/lantern-box#255) calls peerconn.Notify(+1, "ip:port") on accept and Notify(-1, ...) on close. This PR subscribes to that, normalizes into peer.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:

  • Manual port-forward override → #fisk/peer-manual-portforward (471-B)
  • Unbounded broflake integration → #fisk/peer-unbounded-integration (471-C)

What's new

  • events/events.gopeer.ConnectionEvent{State, Source, Timestamp} event type and Emit subscriber count logging.
  • ipc/types.go, ipc/client_events_*.go, ipc/server.go — IPC marshaling so the FlutterEvent bridge can stream ConnectionEvents out-of-process.
  • peer/peer.go — subscription to lantern-box's peerconn.Notify callbacks; phase-granular StatusEvents through Start/Stop lifecycle; listener-draining shield during box.Close to prevent disconnect-cascade flooding the Flutter event bus.
  • Local replace github.com/getlantern/lantern-box => ../lantern-box in go.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.
  • macOS end-to-end (waits on lantern-box#255 tagged release):
    • Run radiance with peer module enabled; bring up a samizdat listener on a UPnP port.
    • Connect a test client through the public port.
    • Verify ConnectionEvent{+1, ...} is emitted on accept and -1 on close.
    • Subscribe via the FlutterEvent IPC and confirm events flow.

Dependencies

🤖 Generated with Claude Code

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

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, source ip:port) and wires lantern-box tracker/peerconn notifications to events.Emit.
  • Adds phase-granular peer.Status updates (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.

Comment thread go.mod Outdated
Comment thread peer/peer.go
Comment thread peer/peer.go Outdated
Comment thread events/events.go
Comment thread peer/peer_test.go Outdated
@myleshorton myleshorton force-pushed the fisk/peer-call-verify branch from 024337d to c7b4a75 Compare May 29, 2026 01:58
@myleshorton myleshorton force-pushed the fisk/peer-connection-events-A branch from 4960eb5 to 5e36933 Compare May 29, 2026 02:02
@myleshorton myleshorton force-pushed the fisk/peer-call-verify branch from c7b4a75 to 9bb7d4b Compare May 29, 2026 19:51
@myleshorton myleshorton force-pushed the fisk/peer-connection-events-A branch from 5e36933 to 854b2b1 Compare May 29, 2026 19:55
@myleshorton myleshorton force-pushed the fisk/peer-call-verify branch from 9bb7d4b to 5614c02 Compare May 29, 2026 20:08
@myleshorton myleshorton force-pushed the fisk/peer-connection-events-A branch from 854b2b1 to c2bfbd2 Compare May 29, 2026 20:09
@myleshorton myleshorton force-pushed the fisk/peer-call-verify branch from 5614c02 to dff7a12 Compare May 29, 2026 20:17
@myleshorton myleshorton force-pushed the fisk/peer-connection-events-A branch 2 times, most recently from 915e95b to 67311e7 Compare May 29, 2026 20:31
@myleshorton myleshorton force-pushed the fisk/peer-call-verify branch from 343e765 to 7768eca Compare May 29, 2026 20:44
@myleshorton myleshorton force-pushed the fisk/peer-connection-events-A branch from 67311e7 to 3ac9497 Compare May 29, 2026 20:45
Adam Fisk and others added 10 commits May 29, 2026 14:58
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)
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>
Copy link
Copy Markdown
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

Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.

Comment thread events/events.go
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>
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.

2 participants