Skip to content

Add peer module + portforward for Share My Connection (PR 1/4)#458

Open
myleshorton wants to merge 4 commits into
mainfrom
fisk/peer-module
Open

Add peer module + portforward for Share My Connection (PR 1/4)#458
myleshorton wants to merge 4 commits into
mainfrom
fisk/peer-module

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

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 LocalBackend wiring, 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 gateway
  • MapPort(ctx, internalPort, description) (*Mapping, error)
  • UnmapPort(ctx) error
  • StartRenewal(ctx) — re-issues AddPortMapping at 50% lease (min 1 min)
  • ExternalIP(ctx) (string, error) — queries the gateway directly
  • LocalIP() (string, error) — UDP-dial trick

Every blocking goupnp call is wrapped in a ctx-respecting helper so an unresponsive gateway cannot block the caller past its deadline.

radiance/peer/

peer.Client orchestrates one session:

Start: NewForwarder → MapPort → ExternalIP → API.Register
       → BuildBoxService(serverConfig) → box.Start
       → StartRenewal + heartbeatLoop
Stop:  cancel runCtx → wait for loop → Deregister → box.Close → UnmapPort
  • Box lifetime: runCtx is derived from context.Background() (not the Start caller's ctx) so a short-lived Start ctx doesn't kill the box.
  • 404 auto-stop: heartbeat sees a 404 (deprecated/reaped/wrong-owner) → spawns a goroutine that calls Stop() to avoid the cyclic Stop → cancelRun → loop-exit deadlock.
  • Transient errors: non-404 heartbeat failures are logged and the loop keeps trying; the server-side reaper will eventually deprecate the row and we'll observe that on a later heartbeat as a 404.
  • Stop semantics: idempotent; continues past individual failures so partial state never lingers.

peer.API is the thin HTTP client for /v1/peer/{register,heartbeat,deregister}. X-Lantern-Device-Id is sent on every request so the server can owner-gate.

Tests

go test -race ./peer/... ./portforward/... and golangci-lint run are both clean.

Package Coverage
peer Happy path; every Start failure phase (port-forward, external-IP, register, build sing-box, start sing-box); Stop idempotency; Stop continues past individual errors; 404 auto-stop drives loop end-to-end; transient-error stays-running
portforward Map happy path; double-Map rejected; gateway-error propagation; ctx cancellation; Unmap no-op + remove + re-Map; renewal re-issues AddPortMapping; renewal cancels cleanly; ExternalIP happy/empty/error

Config.HeartbeatInterval exists so the 404 auto-stop test can drive the loop at 50ms instead of waiting a full minute.

Out of scope (PR 2-4)

  • PR 2: wire peer.Client into LocalBackend via a new PeerShareEnabledKey setting and emit lifecycle events.
  • PR 3: setPeerProxyEnabled FFI export in lantern-core/ffi/.
  • PR 4: Dart setPeerProxy rewrite in app_setting_notifier.dart (mirror setBlockAds rollback pattern) + optional status indicator.

Test plan

  • go test -race ./peer/... ./portforward/...
  • golangci-lint run --timeout 3m ./peer/... ./portforward/...
  • go vet ./peer/... ./portforward/...
  • End-to-end manual test deferred to PR 2 (when the toggle lands)

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 4, 2026 21:01
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 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 portforward to discover IGD gateways, create/remove TCP mappings, renew leases, and query the external IP.
  • Adds peer session orchestration and a thin HTTP client for /v1/peer/{register,heartbeat,deregister}.
  • Adds focused unit tests for both packages and promotes github.com/huin/goupnp to 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.

Comment thread peer/peer.go
Comment thread peer/peer.go
Comment thread peer/peer.go Outdated
Comment thread portforward/portforward.go
Comment thread portforward/portforward.go Outdated
Adam Fisk and others added 2 commits May 5, 2026 08:57
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>
@myleshorton myleshorton requested a review from Copilot May 5, 2026 23:02
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 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 thread peer/peer.go
Comment on lines +246 to +250
c.mu.Lock()
if !c.active {
c.mu.Unlock()
return nil
}
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