Add peer module + portforward for Share My Connection (PR 1/4)#458
Open
myleshorton wants to merge 4 commits into
Open
Add peer module + portforward for Share My Connection (PR 1/4)#458myleshorton wants to merge 4 commits into
myleshorton wants to merge 4 commits into
Conversation
PR 1 of 4 stacked PRs implementing the radiance side of "Share My
Connection" (peer-proxy). This PR introduces a self-contained peer
module — wiring into LocalBackend / settings / FFI lands in PRs 2-4.
* portforward: UPnP IGDv2 with IGDv1 fallback (huin/goupnp). Forwarder
exposes MapPort, UnmapPort, StartRenewal (50%-of-lease cadence,
1-min floor), and ExternalIP. Each goupnp call is wrapped in a
ctx-respecting helper so an unresponsive gateway can't block the
caller past its deadline.
* peer.Client: orchestrates one session — open UPnP port → fetch
external IP → register with lantern-cloud → start a second sing-box
instance with the server-supplied config → run the heartbeat loop.
Stop deregisters, closes the box, unmaps the port, and continues
past individual failures so partial state never lingers. The box's
lifetime ctx is derived from Background (not the Start caller's
ctx) so a short-lived Start ctx doesn't kill it.
* peer.API: thin HTTP client for /v1/peer/{register,heartbeat,
deregister}. X-Lantern-Device-Id is sent on every request so the
server can owner-gate.
* heartbeatLoop auto-stops on a 404 from the server (registration
reaped or wrong owner). Stop runs in a separate goroutine to avoid
the cyclic Stop → cancelRun → loop-exit deadlock.
Tests cover the happy path, every failure phase (port-forward,
external-IP, register, sing-box build, sing-box start), Stop
idempotency, Stop continuing past individual errors, the 404
auto-stop path, and the transient-error stays-running path. portforward
gets fake-IGD coverage including ctx-cancellation. go test -race and
golangci-lint are clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces the new client-side peer-sharing module for Radiance: a UPnP-based port forwarder plus a peer.Client that registers a route with lantern-cloud, starts a sing-box service, renews the mapping, and maintains liveness via heartbeats.
Changes:
- Adds
portforwardto discover IGD gateways, create/remove TCP mappings, renew leases, and query the external IP. - Adds
peersession orchestration and a thin HTTP client for/v1/peer/{register,heartbeat,deregister}. - Adds focused unit tests for both packages and promotes
github.com/huin/goupnpto a direct dependency.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
portforward/portforward.go |
Implements UPnP discovery, mapping lifecycle, renewal loop, external IP lookup, and context-wrapping helpers. |
portforward/portforward_test.go |
Adds unit coverage for mapping, unmapping, renewal, context cancellation, and external IP behavior. |
peer/peer.go |
Implements the peer session lifecycle, heartbeat loop, status tracking, and sing-box startup/teardown. |
peer/peer_test.go |
Adds tests for happy path, failure unwinding, idempotent stop, and heartbeat-driven shutdown behavior. |
peer/api.go |
Adds the lantern-cloud peer API client and error wrapper types. |
go.mod |
Promotes goupnp to a direct dependency for the new port forwarding package. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
3 tasks
Five Copilot comments on #458 flagged real lifecycle / concurrency bugs in the original PR 1 implementation. 1) peer.go:103 — Start checked c.active under the lock then released it before doing setup. Two concurrent Starts could both pass the check, both run MapPort/Register/box.Start, and the second's state would overwrite the first's, orphaning a registered route + open box that this Client could no longer Stop. Added a starting flag that's set under the lock alongside the active check, so any second Start while the first is in flight is rejected. 2) peer.go:127 / 145 — Rollback after MapPort, ExternalIP, Register, BuildBoxService, or box.Start failures all reused the caller's ctx. If the caller's ctx had already timed out or been cancelled, the Deregister and UnmapPort calls in the rollback would also abort immediately, leaking the registered route + router rule. Replaced the inline rollbacks with a single defer that runs against a fresh peerCleanupTimeout-bounded Background context, so cleanup always gets a live deadline. 3) portforward.go:234 — runWithCtx started fn even when the caller's ctx was already canceled, only stopping the wait. The goroutine would still run AddPortMapping or DeletePortMapping in the background, creating side effects after the caller had given up. Added a ctx.Err() check at the top so an already-canceled ctx returns immediately without spawning the goroutine. 4) portforward.go:128 — UnmapPort cleared f.mapping before DeletePortMapping succeeded. A failed delete (gateway momentarily unavailable, ctx expired, etc.) would leave the Forwarder "forgetting" about a router rule that was actually still live, so the caller couldn't retry the unmap and the user would have to wait for the UPnP lease to expire. Moved the f.mapping = nil to after the delete returns nil. Test coverage: * New TestClient_Start_ConcurrentStartsAreSerialized exercises the race fixed by issue 1: spawn two Starts, gate the first inside MapPort, release the second to observe the rejection, assert exactly one succeeds and exactly one returns "already active". * Existing tests for the rollback paths (PortForward / ExternalIP / Register / BoxStart failures) still pass — the cleanup defer takes the same shape as before but now uses a fresh ctx. go test -race ./peer/... ./portforward/... and golangci-lint --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
config/fetcher.go forwards FeatureOverridesKey (RADIANCE_FEATURE_OVERRIDES) as X-Lantern-Feature-Override on /config-new requests so QA can flip features on ahead of public rollout. peer.API.do only sent X-Lantern-Device-Id, so even with the override set the server-side gate rejected the peer register/heartbeat/deregister endpoints. Forward the same header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
myleshorton
pushed a commit
that referenced
this pull request
May 5, 2026
PR 2 of 4 stacked on PR 1 (peer module + portforward, #458). * common/settings: add PeerShareEnabledKey bool. * peer.Client: emit StatusEvent on Start success and Stop completion so subscribers (the new IPC SSE handler) can drive UI without polling. * backend.LocalBackend: own a peerController (interface seam over peer.Client) constructed in NewLocalBackend with kindling's HTTP client + the lantern-cloud base URL + the device ID. * PatchSettings dispatch: PeerShareEnabledKey changes route to applyPeerShare(enabled). Toggle calls are serialized by peerToggleMu so a fast off→on→off can't see the second call's "already active" rollback racing the third call's Stop. Start runs against a 30s deadline so a slow router can't block the IPC response indefinitely. On Start failure the persisted setting is rolled back so reads of PeerShareEnabledKey reflect runtime state and the Dart toggle can surface the error. * Auto-resume: if PeerShareEnabledKey is true at LocalBackend.Start(), kick off Start in a goroutine tracked by peerWG. Close() waits for peerWG before tearing down ctx, so an in-flight resume can't leave a registered route + open box behind on shutdown. * Close: if peerClient.IsActive() after the WG settles, Stop with a fresh context so Deregister has a live HTTP deadline. * IPC: new GET /peer/status (snapshot) and GET /peer/status/events (SSE). The SSE handler replays the current snapshot on connect. Tests cover applyPeerShare's three branches (enable, disable, Start failure rolls back the setting), the resume-if-enabled path, the Close-waits-for-resume + Stop-active-peer race, and the PatchSettings dispatch wiring (a typo on the diff key would silently break the toggle without it). peer_test adds a Subscribe-and-assert test for StatusEvent emission on both edges. go test -race ./peer/... ./backend/... ./common/settings/... ./ipc/... golangci-lint run --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the peer-share user has Lantern VPN running, its sing-box installs a TUN (utun225) with auto_route=true that captures all outbound traffic on the host. Without intervention, the peer's sing-box (a separate libbox instance) would dial destination addresses through the OS routing table — which now points at utun225 — so the censored client's traffic would egress through the local user's Lantern proxy instead of their residential connection. That defeats the whole point of peer-sharing (use the user's home IP as a circumvention exit) and double-bills bandwidth through Lantern infra. Splice route.auto_detect_interface=true into the server-supplied sing-box options before handing them to libbox.NewServiceWithContext. sing-box's interface monitor picks the underlying physical iface (en0/wlan0) rather than any TUN, and binds outbound dials directly to it — bypassing the VPN TUN entirely. The bypass is applied client-side rather than server-side because it's a property of the client's environment (whether the user has a TUN VPN running), not the proxy track config. Setting it unconditionally is safe — when no TUN is present, auto_detect just picks the same default interface the OS would have chosen anyway. Tests cover the three branches: no route block in the input, an existing route block (other fields preserved), and malformed JSON. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
myleshorton
pushed a commit
that referenced
this pull request
May 5, 2026
PR 2 of 4 stacked on PR 1 (peer module + portforward, #458). * common/settings: add PeerShareEnabledKey bool. * peer.Client: emit StatusEvent on Start success and Stop completion so subscribers (the new IPC SSE handler) can drive UI without polling. * backend.LocalBackend: own a peerController (interface seam over peer.Client) constructed in NewLocalBackend with kindling's HTTP client + the lantern-cloud base URL + the device ID. * PatchSettings dispatch: PeerShareEnabledKey changes route to applyPeerShare(enabled). Toggle calls are serialized by peerToggleMu so a fast off→on→off can't see the second call's "already active" rollback racing the third call's Stop. Start runs against a 30s deadline so a slow router can't block the IPC response indefinitely. On Start failure the persisted setting is rolled back so reads of PeerShareEnabledKey reflect runtime state and the Dart toggle can surface the error. * Auto-resume: if PeerShareEnabledKey is true at LocalBackend.Start(), kick off Start in a goroutine tracked by peerWG. Close() waits for peerWG before tearing down ctx, so an in-flight resume can't leave a registered route + open box behind on shutdown. * Close: if peerClient.IsActive() after the WG settles, Stop with a fresh context so Deregister has a live HTTP deadline. * IPC: new GET /peer/status (snapshot) and GET /peer/status/events (SSE). The SSE handler replays the current snapshot on connect. Tests cover applyPeerShare's three branches (enable, disable, Start failure rolls back the setting), the resume-if-enabled path, the Close-waits-for-resume + Stop-active-peer race, and the PatchSettings dispatch wiring (a typo on the diff key would silently break the toggle without it). peer_test adds a Subscribe-and-assert test for StatusEvent emission on both edges. go test -race ./peer/... ./backend/... ./common/settings/... ./ipc/... golangci-lint run --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+57
to
+62
| if c, err := discoverIGDv2(ctx); err == nil && c != nil { | ||
| return &Forwarder{client: c, method: "upnp-igd2"}, nil | ||
| } | ||
| if c, err := discoverIGDv1(ctx); err == nil && c != nil { | ||
| return &Forwarder{client: c, method: "upnp-igd1"}, nil | ||
| } |
| return client.AddPortMapping("", externalPort, "TCP", internalPort, internalIP, true, description, requestedLease) | ||
| }) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("add port mapping: %w", err) |
Comment on lines
+211
to
+225
| // localIP dials an external UDP "no-op" address and inspects the source IP | ||
| // the OS would have chosen — no packets are actually sent. | ||
| func localIP() (string, error) { | ||
| conn, err := net.Dial("udp", "8.8.8.8:53") | ||
| if err != nil { | ||
| return "", fmt.Errorf("dial udp for local ip: %w", err) | ||
| } | ||
| defer func() { _ = conn.Close() }() | ||
| addr, ok := conn.LocalAddr().(*net.UDPAddr) | ||
| if !ok { | ||
| return "", fmt.Errorf("unexpected local addr type %T", conn.LocalAddr()) | ||
| } | ||
| return addr.IP.String(), nil | ||
| } | ||
|
|
Comment on lines
+249
to
+268
| func discoverIGDv2(ctx context.Context) (igdClient, error) { | ||
| clients, _, err := internetgateway2.NewWANIPConnection2ClientsCtx(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if len(clients) == 0 { | ||
| return nil, nil | ||
| } | ||
| return wanIPv2Wrapper{c: clients[0]}, nil | ||
| } | ||
|
|
||
| func discoverIGDv1(ctx context.Context) (igdClient, error) { | ||
| clients, _, err := internetgateway1.NewWANIPConnection1ClientsCtx(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if len(clients) == 0 { | ||
| return nil, nil | ||
| } | ||
| return wanIPv1Wrapper{c: clients[0]}, nil |
Comment on lines
+246
to
+250
| c.mu.Lock() | ||
| if !c.active { | ||
| c.mu.Unlock() | ||
| return nil | ||
| } |
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.
First of four stacked PRs implementing the radiance client side of Share My Connection (the peer-proxy feature). Server side is shipping in lantern-cloud (#2678 schema, #2679 endpoints, #2680 verifier+reaper, #2681 tests).
This PR is the self-contained peer module. No
LocalBackendwiring, no settings dispatch, no FFI yet — those land in PRs 2-4.What's in here
radiance/portforward/UPnP IGDv2 with IGDv1 fallback via
huin/goupnp. Public API:NewForwarder(ctx) (*Forwarder, error)— discovers the local gatewayMapPort(ctx, internalPort, description) (*Mapping, error)UnmapPort(ctx) errorStartRenewal(ctx)— re-issuesAddPortMappingat 50% lease (min 1 min)ExternalIP(ctx) (string, error)— queries the gateway directlyLocalIP() (string, error)— UDP-dial trickEvery blocking goupnp call is wrapped in a ctx-respecting helper so an unresponsive gateway cannot block the caller past its deadline.
radiance/peer/peer.Clientorchestrates one session:runCtxis derived fromcontext.Background()(not the Start caller's ctx) so a short-lived Start ctx doesn't kill the box.Stop()to avoid the cyclicStop → cancelRun → loop-exitdeadlock.peer.APIis the thin HTTP client for/v1/peer/{register,heartbeat,deregister}.X-Lantern-Device-Idis sent on every request so the server can owner-gate.Tests
go test -race ./peer/... ./portforward/...andgolangci-lint runare both clean.peerportforwardConfig.HeartbeatIntervalexists so the 404 auto-stop test can drive the loop at 50ms instead of waiting a full minute.Out of scope (PR 2-4)
peer.ClientintoLocalBackendvia a newPeerShareEnabledKeysetting and emit lifecycle events.setPeerProxyEnabledFFI export inlantern-core/ffi/.setPeerProxyrewrite inapp_setting_notifier.dart(mirrorsetBlockAdsrollback pattern) + optional status indicator.Test plan
go test -race ./peer/... ./portforward/...golangci-lint run --timeout 3m ./peer/... ./portforward/...go vet ./peer/... ./portforward/...🤖 Generated with Claude Code