Skip to content

Share My Connection: peer-core integration (collapses #458, #460, #466, #472, #484, #499, #503) - #589

Open
myleshorton wants to merge 51 commits into
mainfrom
smc/peer-core
Open

Share My Connection: peer-core integration (collapses #458, #460, #466, #472, #484, #499, #503)#589
myleshorton wants to merge 51 commits into
mainfrom
smc/peer-core

Conversation

@myleshorton

@myleshorton myleshorton commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Collapses the Share My Connection peer-core stack onto current main.

Supersedes #458 (1/4), #460 (2/4), #466, #472, #484, #499, #503 — all now closed.

The originals were cut ~2 months ago and sat 81 commits behind main. Each reported
MERGEABLE/CLEAN only because GitHub measures a PR against its own parent in the stack,
not against main — the stack tip actually conflicted in 6 files. Per the agreed plan the
stack is collapsed into one integration PR rather than 8 sequential rebase-and-merge cycles.

Dependency

go.mod pins github.com/getlantern/lantern-box v0.0.109 — the released tag, no longer a
staging pseudo-version. lantern-box #255 and #256 have merged; that repo auto-tags per merge,
so #255 produced v0.0.108 and #256 produced v0.0.109. v0.0.109 is required, not
v0.0.108
: peerconn.Event arrives with #256, and peer/peer.go calls
SetListener(func(evt peerconn.Event) {...}).

Conflict resolutions worth reviewing

Site Resolution
events.Emit Combined, not picked. main added panic recovery around callbacks; the peer branch replaced whole-loop RLock with snapshot-then-release, fixing a real concurrent map iteration and map write race against Unsubscribe. Taking either side alone silently drops a fix and still compiles. Now: snapshot under RLock → release → dispatch each callback in a goroutine with recovery.
LocalBackend.stopChan Gone on main; Close now drains via shutdownFuncs + closePeerClient's peerWG.Wait(). The peer shutdown test helper drops the field instead of reintroducing it.
peerconn.SetListener Signature is func(peerconn.Event) as of lantern-box #256. Call site adapted.
Client.Stop state reset Clears #472's rotation fields while keeping #503's phase-aware PhaseStopping status — the emitted stoppingSnapshot depends on it.
backend/radiance_test.go Union of both sides' tests (5 from main + 8 peer tests); git could not align the files so the whole body conflicted. All 13 present and passing.
#484 × #472 interaction #484's validator rejects any launch_cfg without a route block, which #472's rotation test did not supply. It now uses the shared minimalValidLaunchCfg fixture. Only visible once both siblings were in one tree.

Review fixes (Copilot + CodeRabbit, 3 rounds — 681f5eb, a4913ef)

Two of these are open-port leaks worth reading closely.

  • portforward: mappings could outlive the Forwarder. runWithCtx returns the moment its
    ctx is cancelled but its goroutine keeps running, so an AddPortMapping could reach the
    gateway after we'd given up — leaving an inbound forward to the user's host that nothing
    removes (permanent on routers that ignore the requested lease). This affected both
    renewLoop (racing UnmapPort) and MapPort (racing caller cancellation; worse there,
    since f.mapping was never recorded so UnmapPort short-circuited). Both now wait for the
    call's outcome, bounded by a timeout treated as "may have landed", and delete the mapping if
    teardown or cancellation happened meanwhile. UnmapPort marks teardown under the lock so a
    blocked renewal is ordered strictly after it.
  • peer: rotation bypassed the abuse-rule gate. validateAbuseRules ran only in Start,
    so a server-side regression would have reached every long-lived peer on its next hourly
    rotation — precisely what the gate exists to prevent. Rotation now re-validates; a rejected
    config deregisters the orphan route and leaves the current, already validated box serving.
  • events: emitDebugLogger data race. A plain global read by Emit from arbitrary
    goroutines and written by SetEmitDebugLogger. Now an atomic.Pointer. The package had no
    test file; the new one reproduces the race and fails under -race without the fix
    (WARNING: DATA RACE).
  • backend: construction no longer fatal on peer-client failure. Restores the invariant
    documented on NewLocalBackend — only common.Init is fatal, so a user can always come up
    and report an issue. PeerStatus and applyPeerShare guard nil, and applyPeerShare rolls
    PeerShareEnabledKey back so a persisted "on" can't outlive an unavailable client.
  • backend: PatchSettings applies both handlers and joins their errors. settings.Patch
    has already persisted the whole diff by then, so an early return on a VPN-restart failure
    left PeerShareEnabledKey persisted but unapplied.
  • peer: isUnconditionalReject tolerates "type":"default" on inlined rules.
    launch_cfg is authored server-side rather than round-tripped through sing-box's marshaller
    (which omits type for default rules), so the discriminator can legitimately appear and was
    making the peer refuse a config that does reject unconditionally. Non-default types are still
    rejected — they carry their own matching fields.
  • MapPort also returns early on an already-cancelled ctx instead of enumerating interfaces first.

Every fix above has a test that was verified to fail with that fix reverted. Two added tests
pass either way and are labelled as guards rather than proofs: TestForwarder_RenewalAfterTeardown_DoesNotReAdd
and TestValidateAbuseRules_RejectsNonDefaultRuleType.

Not included

Verification

  • go build -tags "with_clash_api standalone" ./... — clean
  • go test -tags "with_clash_api standalone" ./... — all 21 packages pass
  • go test -race on events, portforward, backend, peer — clean
  • gofmt clean across all changed files

standalone is needed locally on darwin: ipc/client_nonmobile.go is built for
(!android && !ios && !darwin) || (darwin && standalone), so without it darwin selects the
mobile NewClient and cmd/lantern fails to compile. CI runs on ubuntu and takes the
nonmobile path.

Known pre-existing issue (not introduced here)

go vet ./peer/... reports a lostcancel finding on cancelRun in peer/peer.go. It
reproduces on the unmodified fisk/peer-manual-portforward branch, so it predates this
integration, and it is not CI-gating (lostcancel is outside go test's default vet subset).
Left as-is rather than widening scope.

Residual, disclosed

A UPnP call that exceeds its timeout has an unknowable outcome. We assume it landed and delete,
so the leak direction is closed, but a gateway that both hangs past the timeout and rejects
the delete would still keep the forward. Fully removing that needs cancellable UPnP calls,
which goupnp's blocking SOAP API doesn't offer — a change to runWithCtx's contract affecting
every call site, not this PR.

Summary by CodeRabbit

  • New Features
    • Added peer sharing with lifecycle management, connection status, and live activity updates.
    • Added mobile and desktop event streams for peer status and connection changes.
    • Added automatic UPnP port forwarding with support for manually configured ports.
    • Added settings for peer sharing, manual ports, smart routing, split tunneling, ad blocking, and auto-connect.
  • Bug Fixes
    • Improved startup rollback, shutdown cleanup, timeout handling, and resilience to unavailable networking components.
    • Added validation to prevent unsafe or incomplete peer routing configurations.
  • Reliability
    • Improved event delivery safety and diagnostic logging.

Adam Fisk and others added 30 commits May 29, 2026 13:51
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>
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>
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>
Five fixes from #458 (comment):

1. portforward.NewForwarder: when ctx is canceled/expired during discovery,
   propagate the ctx error instead of masking it as ErrNoPortForwarding.
   Callers can now distinguish 'this network can't host a peer' from
   'we ran out of time, retry later'.

2. portforward.MapPort: when the gateway refuses a mapping (non-ctx error),
   wrap with errors.Join(ErrNoPortForwarding, err) so callers can detect
   the documented case via errors.Is while keeping the underlying router-
   specific error for diagnostics.

3. portforward.localIP: fall back to enumerating interfaces if the
   net.Dial("udp", "8.8.8.8:53") trick fails. Covers IPv6-only hosts
   and networks that block outbound to 8.8.8.8.

4. portforward.discoverIGDv1: also probe WANPPPConnection (PPPoE/DSL
   routers), not just WANIPConnection. Many consumer DSL CPEs only
   expose UPnP via WANPPPConnection.

5. peer.Stop: wait for any in-flight Start to finish before checking
   active. Without this, a Stop arriving while starting=true returns nil
   and the racing Start leaves the client active afterward — the exact
   orphaned-session shape Start's rollback path is designed to prevent.
   Wait honors ctx so a cancellable caller still has an exit door.

Tests added:
- TestForwarder_MapPort_GatewayErrorWrapsErrNoPortForwarding
- TestLocalIPByInterfaceScan
- TestClient_Stop_WaitsForInflightStart
- TestClient_Stop_RespectsCtxWhileWaitingForStart

All pre-existing tests pass under -race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three honest-comment / honest-behavior fixes from
#458 (comment) :

1. peer.go: heartbeat interval. The previous clamp bumped any server-
   supplied value below 1 minute up to 5 minutes, which would defeat the
   server's intent if it deliberately picked a short interval to reap
   stale registrations faster. Now: honor any positive value verbatim,
   only fall back to 5m when the field is non-positive (unset / older
   server / JSON omitted).

2. portforward.go: rewrote the StartRenewal comment. It used to claim
   the goroutine 'prevents routers from dropping the mapping when they
   silently assign a shorter TTL', but the renewal cadence is keyed off
   the *requested* lease (the only value we know) — UPnP IGD has no API
   to query the router-assigned lease. A router that silently shortens
   the TTL can still drop the mapping; the peer heartbeat path catches
   that and auto-Stops. The comment now describes what actually happens.

3. peer.go: rewrote the port-range comment. The old wording claimed
   '30000–50000 avoids well-known/registered ports and the OS ephemeral
   range' — but 30000–50000 overlaps both the IANA registered range
   (1024–49151) AND the Linux ephemeral range (default starts at 32768).
   The new comment is honest about that: the range minimizes collisions
   on the typical home network but doesn't guarantee zero, and the
   AddPortMapping conflict path is the safety net.

No behavior change in #2 or #3 — only #1 actually changes runtime
behavior, and only for short-interval server responses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Two fixes from review on #460.

1) applyPeerShare's disable branch was calling peer.Stop with r.ctx
   (no deadline). Deregister or UnmapPort stalling on a slow gateway
   would hang the IPC /settings PATCH and leave the UI toggle without
   a response. Wrap both branches in a single peerToggleTimeout-bounded
   context (renamed from peerStartTimeout to reflect that it now
   covers both directions).

2) The SSE handler streamed the StatusEvent's captured snapshot, but
   events.Emit dispatches each subscriber callback in its own goroutine
   so a quick start→stop pair could land in the channel out of order
   — the consumer would briefly see "active" *after* "inactive".
   Reworked the handler to use the event purely as a wake-up trigger
   and read the live snapshot from PeerStatus() before each send.
   Out-of-order trigger goroutines now just produce duplicate reads
   of the same final state instead of stale-state flicker.

go test -race ./peer/... ./backend/... ./ipc/... and golangci-lint
both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes to make the failure-on-discover path actually surface its
underlying error instead of crashing the IPC handler.

  1. peer/peer.go's default cfg.NewForwarder wrapped
     portforward.NewForwarder with a bare `return portforward.NewForwarder(ctx)`.
     When discovery failed, that collapsed the `(*Forwarder)(nil), err`
     pair into a typed-nil interface — `if fwd != nil` in the deferred
     cleanup passed (the interface has a type), `fwd.UnmapPort(...)`
     dispatched to a nil receiver, and `f.mu.Lock()` panicked. The
     wrapper now returns a clean `nil, err` so the caller sees
     ErrNoPortForwarding (or whatever the discoverer returned) and the
     deferred cleanup short-circuits on the interface nil-check.

  2. portforward.UnmapPort grew a defensive `if f == nil { return nil }`
     at the top. Belt-and-suspenders for any future caller that lands
     here through an interface and bypasses the inline nil-check —
     teardown should be idempotent on a nil receiver, not a panic.

Reproduced live on macOS 26.x with `Share My Connection` toggled on
when UPnP discovery returned ErrNoPortForwarding; the http2 IPC
goroutine panicked instead of rolling back the toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The conflict-resolution from the May 28 rebase brought the new `r.DisconnectVPN()`
call (added on radiance/main since this PR's original branch point) into
Close(). Peer-focused unit tests that construct partial LocalBackends
without a vpnClient (TestClose_WaitsForResumeAndStopsActivePeer in
particular) now hit a nil-pointer panic because Close calls through to
`r.vpnClient.Disconnect()` unconditionally.

Mirror the existing `r.peerClient != nil` guard immediately above —
production NewLocalBackend always sets vpnClient, but defensive-null in
the shutdown path costs nothing and keeps the peer test scaffolding
viable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pulls the peer.Client wiring out of backend/radiance.go (was 1262 lines)
into a new same-package file so the dispatch / lifecycle / IPC accessor
all live together rather than scattered across 80+ lines of the main
file.

Moved to backend/peer_share.go:
  - peerController interface (the test-friendly subset of *peer.Client)
  - peerToggleTimeout constant
  - newPeerClient: peer.Client construction helper (used by NewLocalBackend)
  - applyPeerShare method
  - resumePeerShareIfEnabled method
  - closePeerClient method (used by Close; absorbs the nil-vpnClient
    guard pattern and the in-flight-resume Wait that were inline before)
  - PeerStatus method

backend/radiance.go now holds only the struct-field declarations and
six call sites (NewLocalBackend, Start, Close, PatchSettings dispatch).
No "github.com/getlantern/radiance/peer" import in radiance.go anymore.

Net diff: -84 lines in radiance.go (1262 → 1181), +121 lines in
peer_share.go. Tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two changes that pair with the lantern-cloud /peer/verify split:

  1. peer/api.go: drop the leading /v1 from peer endpoint paths.
     baseURL already ends with /api/v1 (from common.GetBaseURL), so
     /v1/peer/register was hitting /api/v1/v1/peer/register on prod
     and 404'ing. Every other radiance API caller appends without
     /v1 (config/fetcher.go, issue/issue.go); peer/api.go was the
     odd one out. Updated NewAPI's docstring to spell out the
     convention.

  2. peer/peer.go: after box.Start succeeds, call API.Verify(routeID).
     The server's verifier dials back through the peer's external
     port using the just-built creds, so the inbound has to be
     listening before verify runs. Splitting verify out of register
     resolves the chicken-and-egg where register-time verify could
     never see a peer that didn't yet have its cert. Verify failure
     here is fatal — the server has already deprecated the row, so
     the deferred cleanup tears the rest of the session down.

  3. peer/api.go: new API.Verify(ctx, routeID) wrapping POST
     /peer/verify.

Tests: stubServer's mux handles the new /peer/verify route plus
verifyCount / verifyDeviceID / verifyStatus knobs. Existing tests
exercise the new step transparently because they use the default
verifyStatus=200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
…every peer endpoint

peer/api.go was building requests with bare http.NewRequestWithContext,
skipping the X-Lantern-Config-Client-IP / X-Lantern-User-Id / version
header set that /config-new sends via common.NewRequestWithHeaders.

That mattered for /peer/register specifically: the server's
util.ClientIPWithAddr (lantern-cloud cmd/api/util/header.go:155-184)
prefers X-Lantern-Config-Client-IP over X-Forwarded-For and RemoteAddr
when resolving clientIP. With the header missing, the server fell back
to whatever its X-Forwarded-For chain produced — potentially a
different IP than the radiance-detected publicIP, leading the verifier
to dial back to an address the peer's listener wasn't bound to.

Switching to common.NewRequestWithHeaders makes peer endpoints
consistent with /config-new's header set:

  - X-Lantern-Config-Client-IP (the key one for verify-dial targeting)
  - X-Lantern-App-Version, X-Lantern-Version, X-Lantern-Platform,
    X-Lantern-App, X-Lantern-User-Id, X-Lantern-Time-Zone, X-Lantern-Rand

DeviceIDHeader is set by NewRequestWithHeaders from settings; we
explicitly re-set it to a.deviceID afterward for parity with the
prior behavior in case the two ever diverge.

Adds TestAPI_ForwardsCommonHeaders which hits all four peer endpoints
against a stub server and asserts each carries the expected headers
(uses common.SetPublicIP / Cleanup to avoid leaking into other tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uters

UPnP / NAT-PMP / PCP discovery is silent or absent on a meaningful
chunk of consumer routers — eero in particular ignores all three.
For peers behind such routers, peer.Client.Start currently fails at
the MapPort step with portforward.ErrNoPortForwarding even though
the operator has perfectly valid manual port-forward rules in their
router admin UI.

Add an env-var escape hatch: when RADIANCE_PEER_EXTERNAL_PORT is set
to a 1..65535 value, NewClient's default forwarder substitutes a
manualPortForwarder that:

  - Returns the manual port unchanged for both internal and external
    sides of the Mapping (operator is responsible for matching the
    sing-box bind to the same port).
  - Returns "" from ExternalIP, letting peer_handler's "external_ip
    empty -> use observed" fall-through resolve the IP server-side.
  - Is a no-op for UnmapPort and StartRenewal (nothing to release;
    the manual rule is operator-managed).

Invalid values (non-numeric, <1, >65535) log a warning and fall back
to the default UPnP path so a typo doesn't silently disable peer
share entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… log to Info

Two operator-visibility tweaks that helped during peer-share testing
and are worth keeping:

1. applyPeerShare(true) now logs the underlying Start error at Error
   level (and a paired success log at Info). Without this, a peer
   share toggle that fails server-side (4xx, UPnP miss, samizdat
   verify timeout) only surfaces via the IPC HTTP response — a
   layer the daemon log never sees, making post-hoc triage from
   the user's local logs much harder than necessary.

2. The "Detected public IP" log goes from Debug to Info, with the
   resolved IP added to the structured fields. publicIP is fetched
   exactly once per daemon lifetime; emitting it at Info gives
   operators a single line to compare against what lantern-cloud
   observed for that same client (visible in SigNoz traces) without
   needing to flip the global log level.

No behavior change beyond the log lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six clusters of fixes from the round-1 + round-2 Copilot passes:

1. peer/api.go: NewAPI doc now states the baseURL contract honestly —
   common.GetBaseURL returns either '.../v1' (stage) or '.../api/v1'
   (prod); the previous wording hard-coded the prod form and would
   mislead a future caller writing a test.

2. peer/peer_test.go: stub server registers under /v1/peer/* and
   newTestClient passes srv.server.URL+"/v1" as the baseURL. The bare
   URL the test had before would've masked a peer/api.go regression
   that double-prefixes the version segment.

3. peer/peer_test.go: /peer/verify handler decodes LifecycleRequest
   into srv.lastVerifyReq so tests can assert the route_id round-trips
   correctly.

4. peer/peer.go: defaultBuildBoxService no longer discards the caller's
   ctx. New boxRegistryCtx wraps the caller's ctx and falls back to
   box.BaseContext() on Value() lookups — preserves cancellation while
   keeping libbox's protocol-registry resolution working.

5. backend/radiance.go: 'Detected public IP' Info log no longer
   includes the IP itself. Lantern users in censored regions can't
   safely have their public IP in routinely-collected client logs;
   confidence + sources are enough for operator-side 'detection
   succeeded' triage and the IP is correlated server-side via traces.

6. backend/peer_share.go: slog calls use 'error' / 'start_error' /
   'rollback_error' keys to match the backend-package convention
   (backend/radiance.go uses 'error' exclusively; the 'err' keys came
   from the peer package's own convention and don't fit here).

New tests:
- TestClient_Start_HappyPath now asserts verifyCount==1 and the
  route_id round-trips through /peer/verify.
- TestClient_Start_VerifyFailureUnwinds: when verify returns 500,
  Start must unmap, close box, deregister, and return an error.

All existing tests still pass under -race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Currently peer.Client builds the libbox inbound exactly once per Start
and holds the same X25519 keypair / shortID / masquerade for the
entire peer process lifetime — a leaked credential (logs, telemetry,
support bundles, the route_id leakage in engineering#3440) remains
usable for hours or days.

This adds a credRotationLoop goroutine on a 1h tick. On each tick:

  1. Re-register with lantern-cloud against the same (address, port)
     tuple — same router-side mapping, fresh server-side row, fresh
     samizdat creds.
  2. Patch the new options for VPN bypass.
  3. Build a new libbox service.
  4. Close the old box (releases the listening port).
  5. Start the new box (re-binds the same port with new creds).
  6. Atomic swap of c.box, c.routeID.
  7. Best-effort deregister of the prior route_id so the bandit
     stops handing the old (now-invalid) creds to clients within
     ~immediately rather than waiting up-to-TTL for the row to
     expire.

Steps 4-5 leave a brief (~hundreds of ms) window where the port is
unbound; samizdat clients see TCP RST and reconnect via the bandit.
That's the trade-off vs. the security cost of holding the same cred
for the peer process lifetime — caps blast radius from cred leakage
to ~1h regardless of how long the peer has been running.

Rotation is best-effort: a single failure logs and waits for the
next tick. The current box and creds remain serving in the failure
case so a transient register error doesn't kill the session.

Config gains CredRotationInterval (defaults to peerCredRotationInterval
= 1h) so tests drive the loop without a 1h sleep — see
TestClient_RotatesCredentialsAtInterval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 Share My Connection abuse-handling lives entirely in the
sing-box options that lantern-cloud sends back from
/v1/peer/register. The peer client trusts that JSON and hands it
straight to libbox. If a future regression in
lantern-cloud/cmd/api/pcfg/samizdat.go silently shipped a
launch_cfg without those rules, every newly-registered peer would
become an open residential proxy until someone noticed — and the
class of bug that triggers it is one missing function call in a
file most reviewers don't routinely audit.

validateAbuseRules adds defence-in-depth on the client side. After
Register returns, before BuildBoxService is called, parse the
JSON and assert:

  - route.rule_set declares all four abuse tags (geosite-malware,
    geoip-malware, geosite-phishing, geosite-cryptominers).
  - route.rules has a matching reject action for each tag —
    otherwise sing-box downloads the rule_set but never enforces it.
  - route.rules contains an RFC1918 reject (canary 10.0.0.0/8) and
    an SMTP-port reject (canary :25). One sentinel per static block
    in samizdat.go's peerEgressBlockRules, picked to detect "whole
    block was dropped" rather than fail on legitimate additions.

The check is structural-only and permissive about JSON shape (sing-
box marshals "default" rules in both inlined and nested forms;
TestValidateAbuseRules_NestedDefaultForm asserts both work). It
does NOT verify the .srs files at the rule_set URLs or that the
URLs themselves are trustworthy — those are separate supply-chain
concerns to track.

errors.Join means a thoroughly-broken config surfaces every missing
piece in one report so the deployer triaging "why won't my peer
start?" doesn't have to fix-one-thing-find-the-next.

Existing peer_test.go uses a minimal `{"inbounds":[…]}` fixture
that would now fail the check. Migrated it to minimalValidLaunchCfg
(shared with validate_test.go) — same shape as a real samizdat
launch_cfg as far as the routing layer is concerned.
AGENTS.md:13-17 forbids code-location references in comments. Round-1
fixes had reintroduced several. Rewrote each to describe the contract
or invariant directly without naming files:

- peer/peer.go:37 (manualPortForwarder.ExternalIP) — drop reference to
  the server's peer_handler.
- peer/peer.go:152 (NewClient manual-override branch) — drop the 'see
  env.PeerExternalPort' pointer; the manualPort() call site is self-
  describing.
- peer/peer.go:518 (defaultBuildBoxService) — drop the explicit
  vpn/tunnel.go path; the 'same process as the user's main VPN tunnel'
  framing carries the invariant without naming the source file.
- peer/api.go:56 (NewAPI doc) — drop the 'mirroring config/fetcher.go,
  issue/issue.go' tail and the hard-coded host names. The old comment
  also named the wrong prod host: BaseURL is df.iantem.io/api/v1, not
  api.iantem.io/api/v1, so the inaccuracy is fixed too.
- peer/peer_test.go:175 + :232 — drop the peer/api.go references; the
  'regression in URL composition' framing is what matters.

Also added test coverage for RADIANCE_PEER_EXTERNAL_PORT:

- TestManualPort exercises parsing across unset / valid mid-range /
  valid 1 + 65535 boundaries / non-numeric / 0 / negative / above-
  uint16 / way-above-uint16. All non-positive and out-of-range values
  collapse to 0 (the 'use UPnP discovery' signal).
- TestManualPortForwarder exercises the full portForwarder contract:
  MapPort returns external==internal port + 'manual-env' method,
  UnmapPort and StartRenewal are no-ops, ExternalIP returns empty
  (server substitutes observed IP).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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)
Sibling of the peer-core stack; both branched off #460 so the Client
struct and its tests needed reconciling.

Stop() now clears the rotation-loop state (externalPort, internalPort,
boxOptions, runCtx) while keeping the phase-aware PhaseStopping status
from #503 rather than #472's older Status{} reset — the emitted
stoppingSnapshot depends on it.
Third sibling off #460. The merge itself was conflict-free, but #484's
validator rejects any launch_cfg without a route block, which #472's
rotation test did not supply — it now uses the shared
minimalValidLaunchCfg fixture instead of an inline inbounds-only config.
Formatting only; gofmt's doc-comment list indentation had not been
applied to these files on the source branches.
Copilot AI lite review requested due to automatic review settings August 5, 2026 21:14
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Peer sharing now supports UPnP and manual port forwarding, remote registration, sing-box lifecycle management, validation, heartbeat recovery, credential rotation, backend settings integration, status reporting, and IPC event streams.

Changes

Peer sharing

Layer / File(s) Summary
Port forwarding and configuration
common/env/env.go, common/settings/settings.go, portforward/*, go.mod
Adds UPnP forwarding, manual port forwarding, renewal, external-IP lookup, configuration keys, and dependency updates.
Peer API and lifecycle
peer/*
Adds registration APIs, startup and shutdown sequencing, abuse-rule validation, heartbeats, credential rotation, VPN bypass handling, and lifecycle tests.
Backend peer control
backend/peer_share.go, backend/radiance.go, backend/radiance_test.go
LocalBackend creates and owns the peer client, resumes persisted sharing, applies setting changes, exposes status, and coordinates shutdown.
IPC status and connection streams
ipc/*, events/*
Adds peer status and connection streams, mobile and non-mobile clients, event snapshotting, panic recovery, and diagnostic logging.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LocalBackend
  participant peer.Client
  participant portforward.Forwarder
  participant peer.API
  participant BoxService
  LocalBackend->>peer.Client: Start(ctx)
  peer.Client->>portforward.Forwarder: MapPort()
  peer.Client->>peer.API: Register()
  peer.Client->>peer.API: Verify()
  peer.Client->>BoxService: Start()
  peer.Client->>peer.API: Heartbeat()
  peer.Client->>peer.Client: Rotate credentials or Stop()
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change as integrating the peer-core stack for connection sharing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch smc/peer-core

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Integrates the “Share My Connection” (peer-proxy) stack onto main by adding UPnP/manual port-forwarding, peer session lifecycle (register/verify/heartbeat/cred rotation), abuse-rule validation of server-provided launch_cfg, and IPC/SSE plumbing to expose peer status + connection events to consumers.

Changes:

  • Add portforward package (UPnP IGD v1/v2 + manual override) with tests, and wire it into the peer client’s Start/Stop lifecycle.
  • Add peer safety + lifecycle features: /peer/verify, abuse-rule validation gate for launch_cfg, credential rotation, and connection-event forwarding via lantern-box peerconn.
  • Expose peer status and connection events over IPC (snapshot + SSE streams) and wire toggle/resume/close handling in LocalBackend.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
portforward/portforward.go Implements UPnP IGD discovery, mapping/unmapping, renewal, and external IP lookup for peer sessions.
portforward/portforward_test.go Unit tests for UPnP forwarder behavior, ctx handling, renewal loop, and error wrapping.
portforward/manual.go Adds manual port-forward implementation + parsing for UPnP-less networks.
portforward/manual_test.go Tests manual port parsing and manual forwarder contract.
peer/validate.go Adds structural validation to ensure server launch_cfg includes required abuse-blocking rules.
peer/validate_test.go Comprehensive unit tests for abuse-rule validation (happy path + many failure modes).
peer/peer.go Implements peer session orchestration, status phases, connection event forwarding, and credential rotation.
peer/peer_test.go Extensive tests covering Start/Stop semantics, verify/unwind, header forwarding, rotation, and status events.
peer/api.go Implements peer HTTP client (register/verify/heartbeat/deregister) using common headers + feature override.
events/events.go Updates event emission to snapshot subscriber callbacks under lock + adds optional emit debug hook.
ipc/server.go Adds peer status snapshot endpoint and SSE streams for peer status + connection events.
ipc/client_events_nonmobile.go Adds client helpers to consume peer status/connection SSE streams on desktop.
ipc/client_events_mobile.go Adds mobile dual-path (in-proc subscribe vs SSE) for peer status/connection events.
common/settings/settings.go Adds PeerShareEnabledKey and PeerManualPortKey settings metadata.
common/env/env.go Adds RADIANCE_PEER_EXTERNAL_PORT env var for manual port override.
backend/peer_share.go Adds LocalBackend peer client construction, toggle apply/rollback, auto-resume, close coordination, and status accessor.
backend/radiance.go Wires peer client into backend lifecycle: init, auto-resume on Start, stop on Close, and PatchSettings dispatch.
backend/radiance_test.go Adds backend tests for peer toggle dispatch, rollback, auto-resume, and shutdown ordering.
go.mod Updates lantern-box dependency and adds direct dependency on huin/goupnp.
go.sum Updates module checksums for new/updated dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread portforward/portforward.go
Comment thread peer/validate.go
Comment thread backend/peer_share.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (7)
portforward/portforward_test.go (2)

128-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test does not cover the mid-call cancellation path.

runWithCtx returns at its ctx.Err() pre-check when ctx is already cancelled, so AddPortMapping is never called here. c.addBlock is never received on, and close(block) releases nothing. The comment on Line 128 describes a hung router, which is the mid-call case: the gateway call is in flight and ctx expires during the wait. Add a case that cancels after MapPort blocks, so the select branch in runWithCtx is exercised.

💚 Proposed additional test
// Cancelling while the gateway call is in flight must abort the wait rather
// than block until the router answers.
func TestForwarder_MapPort_CancelsMidCall(t *testing.T) {
	block := make(chan struct{})
	c := &fakeIGD{addBlock: block}
	f := newTestForwarder(t, c)

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		// Wait until AddPortMapping is actually blocked, then cancel.
		for c.addCalls.Load() == 0 {
			time.Sleep(time.Millisecond)
		}
		cancel()
	}()

	_, err := f.MapPort(ctx, 30001, "test")
	require.Error(t, err)
	assert.ErrorIs(t, err, context.Canceled)
	assert.Equal(t, int64(1), c.addCalls.Load(), "gateway call must have started")
	close(block) // release the in-flight goroutine
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward_test.go` around lines 128 - 142, Add a separate
mid-call cancellation test alongside
TestForwarder_MapPort_RespectsContextCancellation that waits until
fakeIGD.AddPortMapping has started, cancels the context while it remains
blocked, and asserts MapPort returns context.Canceled. Keep the existing
pre-cancelled test, verify the gateway call started exactly once, and close the
blocking channel afterward to release the in-flight goroutine.

169-190: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

StartRenewal itself has no test, and the loop goroutine is not joined.

This test calls f.renewLoop directly, so f.cancel is never set. The guards in StartRenewal are untested: the early return when a renewal is already running, the early return when f.mapping is nil, and the 1-minute interval floor. UnmapPort depends on f.cancel being set to stop the loop.

The test also returns immediately after cancel() without waiting for renewLoop to exit. Join the goroutine so the loop cannot call the fake after the test finishes.

💚 Proposed changes
 	ctx, cancel := context.WithCancel(context.Background())
-	go f.renewLoop(ctx, 20*time.Millisecond)
+	loopDone := make(chan struct{})
+	go func() {
+		f.renewLoop(ctx, 20*time.Millisecond)
+		close(loopDone)
+	}()
 
 	deadline := time.After(2 * time.Second)
 	for c.addCalls.Load() < 3 {
 		select {
 		case <-deadline:
 			t.Fatalf("renewal fired only %d times", c.addCalls.Load())
 		case <-time.After(10 * time.Millisecond):
 		}
 	}
 	cancel()
+	<-loopDone
 }

Add coverage for StartRenewal:

// StartRenewal must set f.cancel so UnmapPort can stop the loop, and must
// refuse to start a second loop.
func TestForwarder_StartRenewal_Guards(t *testing.T) {
	c := &fakeIGD{}
	f := newTestForwarder(t, c)

	// No mapping yet: StartRenewal must not arm anything.
	f.StartRenewal(context.Background())
	f.mu.Lock()
	assert.Nil(t, f.cancel, "no mapping means no renewal loop")
	f.mu.Unlock()

	_, err := f.MapPort(context.Background(), 30001, "test")
	require.NoError(t, err)

	f.StartRenewal(context.Background())
	f.mu.Lock()
	first := f.cancel
	f.mu.Unlock()
	require.NotNil(t, first, "StartRenewal must store a cancel func")

	f.StartRenewal(context.Background())
	f.mu.Lock()
	assert.NotNil(t, f.cancel, "second call must not clear the existing loop")
	f.mu.Unlock()

	require.NoError(t, f.UnmapPort(context.Background()))
	f.mu.Lock()
	assert.Nil(t, f.cancel, "UnmapPort must clear the renewal cancel func")
	f.mu.Unlock()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward_test.go` around lines 169 - 190, Replace the direct
renewLoop invocation in TestForwarder_StartRenewal_ReissuesAddPortMapping with
StartRenewal so the test exercises f.cancel and UnmapPort integration, and add
coverage for StartRenewal’s nil-mapping guard, duplicate-start guard, and
one-minute interval floor. Ensure the renewal goroutine is joined after
cancellation before the test returns, using the existing renewal lifecycle
symbols StartRenewal, renewLoop, and UnmapPort.
portforward/portforward.go (2)

26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add doc comments to the exported Mapping type and LocalIP function.

Mapping at Line 26 and LocalIP at Line 319 are exported and carry no doc comment. Every other exported identifier in this file is documented. LocalIP is a bare re-export of localIP, so its purpose and its fallback behavior are not discoverable from the name alone.

As per coding guidelines: "Use Go doc comments (// Foo ...) for exported identifiers" and doc comments "must start with the identifier's name and a concise summary in the format // Foo does X.".

♻️ Proposed doc comments
+// Mapping describes an active port forward on the local gateway. Protocol is
+// always "TCP". LeaseDuration is the duration that was requested, not
+// necessarily the one the router applied.
 type Mapping struct {
+// LocalIP returns the LAN IPv4 address the OS would use to reach the gateway.
+// It is the exported form of localIP: it first tries a UDP-noop dial and
+// falls back to scanning interfaces for a private IPv4.
 func LocalIP() (string, error) { return localIP() }

Also applies to: 319-319

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward.go` at line 26, Add Go doc comments for the exported
Mapping type and LocalIP function, placing each comment immediately before its
declaration and starting with the identifier name. Summarize Mapping’s purpose
and LocalIP’s behavior, including its fallback behavior when delegating to
localIP.

Source: Coding guidelines


84-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the discarded discovery errors.

NewForwarder drops the error from discoverIGDv2 and discoverIGDv1. A gateway that is present but returns a SOAP or transport error is then reported as ErrNoPortForwarding, which the doc comment reserves for "discovery completed without finding a usable gateway". UPnP failures are the main support case for this feature. A debug log of each discarded error makes them diagnosable from user logs.

♻️ Proposed refactor
-	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
-	}
+	c, err := discoverIGDv2(ctx)
+	if err == nil && c != nil {
+		return &Forwarder{client: c, method: "upnp-igd2"}, nil
+	}
+	if err != nil {
+		slog.Debug("portforward: IGDv2 discovery failed", "err", err)
+	}
+	c, err = discoverIGDv1(ctx)
+	if err == nil && c != nil {
+		return &Forwarder{client: c, method: "upnp-igd1"}, nil
+	}
+	if err != nil {
+		slog.Debug("portforward: IGDv1 discovery failed", "err", err)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward.go` around lines 84 - 89, Update NewForwarder’s
discoverIGDv2 and discoverIGDv1 fallback branches to retain and emit each
non-nil discovery error at debug level before trying the next protocol or
returning ErrNoPortForwarding. Preserve the existing successful-client selection
and fallback behavior while making both discarded errors available in user logs.
peer/peer.go (1)

96-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add Go doc comments to these exported identifiers.

Status (line 96), NewClient (line 204), IsActive (line 562), and CurrentStatus (line 568) have no doc comment. The neighbouring exported identifiers in this file already carry one.

As per coding guidelines: "Use Go doc comments (// Foo ...) for exported identifiers" and comments "must start with the identifier's name".

Also applies to: 204-204, 562-572

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@peer/peer.go` at line 96, Add Go doc comments immediately before the exported
identifiers Status, NewClient, IsActive, and CurrentStatus in peer.go. Each
comment must begin with the corresponding identifier name and briefly describe
its purpose, matching the style of neighboring exported declarations.

Source: Coding guidelines

peer/api.go (1)

17-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exported identifiers in the new peer package lack Go doc comments. The coding guidelines require a doc comment on every exported identifier, starting with the identifier's name. Both files document some exported identifiers and skip others, so the package's public surface is documented inconsistently.

  • peer/api.go#L17-L31: add doc comments to RegisterRequest, RegisterResponse, LifecycleRequest, and to API (line 45), Register (line 60), and Deregister (line 90).
  • peer/peer.go#L96-L96: add doc comments to Status, and to NewClient (line 204), IsActive (line 562), and CurrentStatus (line 568).

As per coding guidelines: "Use Go doc comments (// Foo ...) for exported identifiers" and "Go doc comments must start with the identifier's name and a concise summary in the format // Foo does X.".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@peer/api.go` around lines 17 - 31, Add concise Go doc comments beginning with
each identifier’s name for the exported types RegisterRequest, RegisterResponse,
LifecycleRequest, API, Register, Deregister, Status, NewClient, IsActive, and
CurrentStatus. Update peer/api.go (lines 17-31 and the listed API methods) and
peer/peer.go (line 96 and the listed methods); no other changes are needed.

Source: Coding guidelines

peer/peer_test.go (1)

884-898: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

drainPhases reads a fixed count, so one stray event breaks the assertions.

events uses a process-global subscription map. The subscriber in TestClient_StatusEventEmittedOnStartAndStop receives every StatusEvent emitted anywhere in the package, including events from a client that a previous test left running (the rotation and heartbeat tests both stop clients from background goroutines).

drainPhases reads exactly n events. One unrelated event consumes a slot, so a wanted phase is never collected and assert.Contains fails. Drain until the wanted set is complete instead of counting.

♻️ Proposed refactor
-// drainPhases reads up to n StatusEvents from got and returns them
-// keyed by Phase (last event per phase wins). Used by tests that need
-// set-membership semantics rather than strict ordering because
-// events.Emit's per-callback goroutines deliver out of order under
-// the runtime's scheduling.
-func drainPhases(t *testing.T, got <-chan StatusEvent, n int) map[Phase]StatusEvent {
+// drainPhases reads StatusEvents from got until every phase in want has
+// been seen, and returns them keyed by Phase (last event per phase
+// wins). Reading until the wanted set is complete — rather than reading
+// a fixed count — keeps the test stable when an unrelated client emits
+// on the process-global event bus.
+func drainPhases(t *testing.T, got <-chan StatusEvent, want map[Phase]bool) map[Phase]StatusEvent {
 	t.Helper()
-	out := make(map[Phase]StatusEvent, n)
+	out := make(map[Phase]StatusEvent, len(want))
 	deadline := time.After(2 * time.Second)
-	for i := 0; i < n; i++ {
+	for {
+		missing := 0
+		for p := range want {
+			if _, ok := out[p]; !ok {
+				missing++
+			}
+		}
+		if missing == 0 {
+			return out
+		}
 		select {
 		case evt := <-got:
 			out[evt.Status.Phase] = evt
 		case <-deadline:
-			t.Fatalf("received only %d/%d status events within 2s; got phases: %v",
-				i, n, mapKeys(out))
+			t.Fatalf("missing %d/%d status phases within 2s; got phases: %v",
+				missing, len(want), mapKeys(out))
 		}
 	}
-	return out
 }

Update both call sites to pass the phase set:

startEvents := drainPhases(t, got, wantStartPhases)
stopEvents := drainPhases(t, got, wantStopPhases)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@peer/peer_test.go` around lines 884 - 898, Update drainPhases to accept the
wanted phase set and continue receiving events until every requested phase has
been collected, ignoring unrelated events while retaining the timeout. Modify
both TestClient_StatusEventEmittedOnStartAndStop call sites to pass
wantStartPhases and wantStopPhases, respectively.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/peer_share.go`:
- Around line 122-126: Update LocalBackend.PeerStatus to guard against a nil
peerClient before calling CurrentStatus, returning the appropriate zero or
unavailable peer.Status value when absent. Preserve the existing CurrentStatus
result for initialized peer clients.

In `@backend/radiance.go`:
- Around line 189-193: Update newPeerClient in the backend construction flow to
log peer-client creation failures and continue with a nil client instead of
returning the error, preserving common.Init as the only fatal path. Add
nil-client guards to PeerStatus and applyPeerShare so peer sharing safely
returns an error when unavailable rather than panicking.
- Around line 606-614: Update PatchSettings around maybeRestartVPN and
applyPeerShare so both handlers run even when maybeRestartVPN fails, ensuring a
changed PeerShareEnabledKey is applied despite VPN restart errors. Capture each
handler error and join them before returning, preserving successful application
and the existing applyPeerShare rollback behavior.

In `@events/events.go`:
- Around line 152-167: Make the emitDebugLogger hook concurrency-safe by storing
it in an atomic pointer and loading it in Emit before invocation. Update
SetEmitDebugLogger to atomically replace the hook, including an atomic no-op
default for nil, and add the required sync/atomic support; remove the outdated
restriction that callers must avoid concurrent updates.

In `@go.mod`:
- Line 40: Update the github.com/getlantern/lantern-box dependency in go.mod
from the staging pseudo-version to the released v0.0.108 tag, preserving the
rest of the dependency declarations unchanged.

In `@peer/validate.go`:
- Around line 200-214: Update isUnconditionalReject to allow the top-level type
field in its allowed-key set, treating it as a non-match discriminator like
action and invert. Preserve the existing rejection behavior for all other
unexpected fields and the current unconditional reject checks.

In `@portforward/portforward.go`:
- Around line 211-237: Update Forwarder.renewLoop to track an explicit teardown
state, re-check that state while holding f.mu immediately before AddPortMapping,
and keep f.mu held through the renewal call so UnmapPort cannot delete
concurrently with a renewal. Set the teardown flag in UnmapPort before
cancellation/deletion, and preserve normal renewal behavior when teardown has
not started.

---

Nitpick comments:
In `@peer/api.go`:
- Around line 17-31: Add concise Go doc comments beginning with each
identifier’s name for the exported types RegisterRequest, RegisterResponse,
LifecycleRequest, API, Register, Deregister, Status, NewClient, IsActive, and
CurrentStatus. Update peer/api.go (lines 17-31 and the listed API methods) and
peer/peer.go (line 96 and the listed methods); no other changes are needed.

In `@peer/peer_test.go`:
- Around line 884-898: Update drainPhases to accept the wanted phase set and
continue receiving events until every requested phase has been collected,
ignoring unrelated events while retaining the timeout. Modify both
TestClient_StatusEventEmittedOnStartAndStop call sites to pass wantStartPhases
and wantStopPhases, respectively.

In `@peer/peer.go`:
- Line 96: Add Go doc comments immediately before the exported identifiers
Status, NewClient, IsActive, and CurrentStatus in peer.go. Each comment must
begin with the corresponding identifier name and briefly describe its purpose,
matching the style of neighboring exported declarations.

In `@portforward/portforward_test.go`:
- Around line 128-142: Add a separate mid-call cancellation test alongside
TestForwarder_MapPort_RespectsContextCancellation that waits until
fakeIGD.AddPortMapping has started, cancels the context while it remains
blocked, and asserts MapPort returns context.Canceled. Keep the existing
pre-cancelled test, verify the gateway call started exactly once, and close the
blocking channel afterward to release the in-flight goroutine.
- Around line 169-190: Replace the direct renewLoop invocation in
TestForwarder_StartRenewal_ReissuesAddPortMapping with StartRenewal so the test
exercises f.cancel and UnmapPort integration, and add coverage for
StartRenewal’s nil-mapping guard, duplicate-start guard, and one-minute interval
floor. Ensure the renewal goroutine is joined after cancellation before the test
returns, using the existing renewal lifecycle symbols StartRenewal, renewLoop,
and UnmapPort.

In `@portforward/portforward.go`:
- Line 26: Add Go doc comments for the exported Mapping type and LocalIP
function, placing each comment immediately before its declaration and starting
with the identifier name. Summarize Mapping’s purpose and LocalIP’s behavior,
including its fallback behavior when delegating to localIP.
- Around line 84-89: Update NewForwarder’s discoverIGDv2 and discoverIGDv1
fallback branches to retain and emit each non-nil discovery error at debug level
before trying the next protocol or returning ErrNoPortForwarding. Preserve the
existing successful-client selection and fallback behavior while making both
discarded errors available in user logs.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7136f722-5b36-4ead-bf06-38528b79f076

📥 Commits

Reviewing files that changed from the base of the PR and between 0aebcab and 03dc83f.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (19)
  • backend/peer_share.go
  • backend/radiance.go
  • backend/radiance_test.go
  • common/env/env.go
  • common/settings/settings.go
  • events/events.go
  • go.mod
  • ipc/client_events_mobile.go
  • ipc/client_events_nonmobile.go
  • ipc/server.go
  • peer/api.go
  • peer/peer.go
  • peer/peer_test.go
  • peer/validate.go
  • peer/validate_test.go
  • portforward/manual.go
  • portforward/manual_test.go
  • portforward/portforward.go
  • portforward/portforward_test.go

Comment thread backend/peer_share.go
Comment thread backend/radiance.go
Comment thread backend/radiance.go
Comment thread events/events.go Outdated
Comment thread go.mod Outdated
Comment thread peer/validate.go
Comment thread portforward/portforward.go
Replaces the fisk/peerconn-on-main staging pseudo-version now that
lantern-box #255 and #256 have merged. v0.0.109 is the auto-tagged
release containing peerconn.Event, which peer.go's SetListener callback
requires — v0.0.108 predates it and would not compile here.
events: emitDebugLogger was a plain global read by Emit from arbitrary
goroutines and written by SetEmitDebugLogger. Held in an atomic.Pointer
now; the new events test reproduces the race and fails under -race
without the change.

portforward: a renewal already past its teardown pre-check could re-add
the router mapping after UnmapPort deleted it, leaving an inbound forward
to the user's host that nothing removes — permanent on routers that
ignore the requested lease. UnmapPort marks teardown under the lock and
the renewal re-checks afterwards, deleting what it re-added. The renewal
no longer goes through runWithCtx: that returns on cancellation while its
goroutine keeps running, so the compensating delete could otherwise fire
before the call it exists to undo. A wedged gateway is bounded by
renewCallTimeout and treated as "may have landed".

portforward: MapPort returns early on an already-cancelled ctx rather
than enumerating interfaces first.

backend: construction degrades to a nil peerClient instead of failing,
restoring the invariant documented on NewLocalBackend that only
common.Init is fatal — a peer-client failure must not cost the user issue
reporting. PeerStatus and applyPeerShare guard nil accordingly, with
applyPeerShare rolling the setting back so a persisted "on" can't
outlive an unavailable client.

backend: PatchSettings applies both the VPN and peer-share handlers and
joins their errors. settings.Patch has already persisted the whole diff
by then, so returning early on a VPN restart failure left
PeerShareEnabledKey persisted but unapplied.

peer: isUnconditionalReject tolerates an explicit "type":"default" on
inlined rules. launch_cfg is authored server-side rather than
round-tripped through sing-box's marshaller, so the discriminator can
legitimately appear, and treating it as an extra constraint made the peer
refuse a config that does reject unconditionally. Non-default types are
still rejected — they carry their own matching fields.

The go.mod staging-pin comment was resolved separately by bbb9508.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 20 out of 21 changed files in this pull request and generated 2 comments.

Comment thread peer/peer.go
Comment thread portforward/portforward.go
peer: credential rotation now re-runs validateAbuseRules on the freshly
fetched launch_cfg. The gate only ran in Start, so a server-side
regression would have reached every long-lived peer on its next hourly
rotation — precisely what the check exists to prevent. A rejected
rotation deregisters the orphan route and leaves the current, already
validated box serving.

portforward: MapPort had the same abandoned-goroutine leak just fixed in
renewLoop. runWithCtx returns on cancellation while AddPortMapping keeps
going, so the gateway could accept a mapping after MapPort returned an
error — and with f.mapping unset, UnmapPort short-circuits and nothing
ever removes the forward. MapPort now waits for the outcome and deletes
the mapping if the caller has given up.

Both tests were verified to fail with their fix reverted.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 20 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (2)

portforward/portforward.go:155

  • MapPort’s addCallTimeout path can still leak an untracked port mapping: on timeout you return an error without setting f.mapping, but the in-flight AddPortMapping goroutine may later succeed, and the deferred Start rollback’s UnmapPort will short-circuit on nil mapping. Consider scheduling a best-effort background cleanup that waits for the AddPortMapping result and issues DeletePortMapping if it eventually succeeds.
	case <-time.After(addCallTimeout):
		// Outcome unknowable; assume it landed so the cleanup below removes it.
		addLanded = true
		err = fmt.Errorf("add port mapping timed out after %s", addCallTimeout)
	}

go.mod:41

  • PR description says this is blocked on tagging lantern-box v0.0.108 and re-pinning to that tag before merge, but go.mod currently requires github.com/getlantern/lantern-box v0.0.109. Either the description needs updating or the dependency pin should be aligned with the intended lantern-box release/tag to avoid merging with an unexpected box version.
	github.com/getlantern/keepcurrent v0.0.0-20260616120552-f204338b01a3
	github.com/getlantern/kindling v0.0.0-20260727211028-573c1ef64464
	github.com/getlantern/lantern-box v0.0.109
	github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535

myleshorton added a commit that referenced this pull request Aug 10, 2026
Two conflicts, both from PatchSettings changing under this branch:

- peer-core replaced the trailing `return nil` with `return errs` so the
  VPN and peer-share handlers both run and join their errors. This
  branch's unbounded.Apply dispatch now sits ahead of that return,
  unchanged.

- radiance_test.go: union of this branch's
  TestPatchSettings_UnboundedDispatches and peer-core's three nil-client
  tests; git could not align them so the whole block conflicted.

Note for review: unbounded.Apply's failure is still only logged, not
joined into the returned error, which is what this branch had. It is the
same persisted-but-unapplied divergence class CodeRabbit flagged for
peer-share, so it may deserve the same treatment — left as-is here rather
than changing behavior inside a conflict resolution.
myleshorton added a commit that referenced this pull request Aug 10, 2026
peer.go auto-merged cleanly — this branch replaced boxRegistryCtx with
newPeerBoxContext near the bottom of the file, while peer-core's round-2
fix added the rotation abuse-rule gate up in rotateCredentials. Verified
both survived: validateAbuseRules now guards Start and rotation, and the
log factory is registered on the captured base context.

peer_test.go was a union — this branch's four TestPeerBoxContext_* cases
and peer-core's TestClient_RotationRejectsLaunchCfgMissingAbuseRules were
both appended at the end of the file, so git could not align them. All
five present and passing.

Note on the interaction: rotation calls BuildBoxService, so each rotation
now builds a fresh base context and registers a fresh log factory. That
is correct — Registry.Register overwrites rather than panicking, and a
per-box registry is the isolation this branch is after.
Only go.mod/go.sum conflicted; took main's newer lantern-box. v0.0.111
still carries the peerconn API this branch depends on (Event, Listener,
SetListener, Notify, Acquire).

Worth noting for the unbounded work stacked on this branch: v0.0.111
includes lantern-box #297, which fixes a broflake teardown hang (Close
tore down the QUIC layer before stopping the workers draining into it,
stranding a worker blocked mid-send) and bumps broflake past the version
the donor restart loop was observed against.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
portforward/portforward.go (2)

141-167: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The add-timeout path can leave an untracked mapping and misreports it as ErrNoPortForwarding.

If AddPortMapping exceeds addCallTimeout but ctx is still live, the code sets addLanded = true and err to the timeout error, then skips the cleanup at Line 150 because ctx.Err() is nil. Control reaches Line 156, f.mapping stays nil, and UnmapPort short-circuits on a nil mapping. The mapping that may have landed on the gateway is then untracked, which is the exact leak this block prevents on the cancellation path.

The same path also wraps the timeout in ErrNoPortForwarding. Per the sentinel docstring, callers read that as "this network cannot host a peer". A wedged gateway is a transient condition and should stay retryable.

🔒️ Proposed fix: clean up on any timeout and keep the timeout retryable
 	var addLanded bool
+	var addTimedOut bool
 	select {
 	case err = <-addErrCh:
 		addLanded = err == nil
 	case <-time.After(addCallTimeout):
 		// Outcome unknowable; assume it landed so the cleanup below removes it.
 		addLanded = true
+		addTimedOut = true
 		err = fmt.Errorf("add port mapping timed out after %s", addCallTimeout)
 	}
-	if ctxErr := ctx.Err(); ctxErr != nil && addLanded {
+	ctxErr := ctx.Err()
+	if addLanded && (ctxErr != nil || addTimedOut) {
 		// The caller gave up but the gateway accepted the mapping. Remove it —
 		// otherwise it survives with no Forwarder tracking it.
 		f.deleteRacedMapping(&Mapping{ExternalPort: externalPort, Protocol: "TCP"}, client)
+	}
+	if ctxErr != nil && addLanded {
 		return nil, fmt.Errorf("add port mapping: %w", ctxErr)
 	}
+	if addTimedOut {
+		// Transient: do not join ErrNoPortForwarding, the caller may retry.
+		return nil, fmt.Errorf("add port mapping: %w", err)
+	}
 	if err != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward.go` around lines 141 - 167, Update the add-timeout
handling around addLanded and err so every timeout triggers deleteRacedMapping
for the potentially landed mapping, regardless of whether ctx is canceled.
Return the timeout error as a retryable add-port-mapping failure without
wrapping it with ErrNoPortForwarding, while preserving the existing cancellation
propagation and sentinel wrapping for non-timeout gateway errors.

Source: Learnings


203-218: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reset closed when installing a new mapping

UnmapPort sets f.closed to true, and no code resets it. A mapping created after UnmapPort exits renewLoop at its first tick and expires when the router lease ends. Set f.closed = false when MapPort installs the new mapping, and add a test that starts renewal after remapping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward.go` around lines 203 - 218, Reset f.closed to false
in MapPort when a new mapping is successfully installed, so renewal can run
after UnmapPort followed by remapping. Add a test covering the unmap/remap
sequence and verify the renewed mapping remains active beyond the first renewal
tick.
🧹 Nitpick comments (2)
peer/peer_test.go (1)

1111-1128: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use require.Eventually for the deregister assertion instead of the fixed sleep.

time.Sleep(100 * time.Millisecond) bounds the negative assertions (built == 1, box not closed), which is reasonable. It also gates the positive assertion at Line 1126. On a loaded CI runner the rejected rotation may not have finished its deregister call within 100ms, so deregisterCount can still be 0 and the test flakes.

Wait for the deregister explicitly, then keep the sleep only for the negative assertions.

♻️ Proposed change: wait for the deregister before asserting
 	require.Eventually(t, func() bool { return srv.registerCount.Load() >= 2 },
 		3*time.Second, 10*time.Millisecond, "rotation never re-registered")
+	// The orphan route from the rejected rotation must be deregistered.
+	require.Eventually(t, func() bool { return srv.deregisterCount.Load() > 0 },
+		3*time.Second, 10*time.Millisecond,
+		"the orphan route created for the rejected rotation was never deregistered")
+	// Settle time for the negative assertions below.
 	time.Sleep(100 * time.Millisecond)
@@
 	assert.True(t, c.IsActive(), "the peer must stay active after a rejected rotation")
-	assert.Positive(t, srv.deregisterCount.Load(),
-		"the orphan route created for the rejected rotation must be deregistered")
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@peer/peer_test.go` around lines 1111 - 1128, Replace the fixed-sleep gating
of the deregistration check in this test with require.Eventually polling
srv.deregisterCount.Load() until it is positive. Keep the existing
time.Sleep(100 * time.Millisecond) only for the negative assertions about boxes
built and the original box remaining open, then retain the positive
deregistration assertion after the explicit wait.
portforward/portforward_test.go (1)

318-340: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test does not exercise the closed check.

UnmapPort sets f.mapping = nil on success. renewLoop then returns on the pre-existing m == nil condition, so the test passes with closed removed. To pin the new semantics, make DeletePortMapping return an error. UnmapPort then retains f.mapping and sets closed, so only the closed check stops the renewal.

♻️ Proposed change: force the mapping to survive teardown
-	c := &fakeIGD{}
+	// A failed delete leaves f.mapping set, so only the closed flag can stop
+	// the renewal.
+	c := &fakeIGD{deleteErr: errors.New("gateway refused")}
 	f := newTestForwarder(t, c)
 	f.mapping = &Mapping{
 		ExternalPort: 15001, InternalPort: 15001, InternalIP: "192.168.1.10",
 		Protocol: "TCP", LeaseDuration: time.Hour,
 	}
-	require.NoError(t, f.UnmapPort(context.Background()))
+	require.Error(t, f.UnmapPort(context.Background()))
 	require.Equal(t, int64(1), c.deleteCalls.Load())

Confirm that fakeIGD exposes a delete-error hook with this field name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portforward/portforward_test.go` around lines 318 - 340, Update
TestForwarder_RenewalAfterTeardown_DoesNotReAdd to configure the fakeIGD
delete-error hook before calling UnmapPort, causing DeletePortMapping to fail so
f.mapping remains set while teardown marks the forwarder closed. Retain the
existing renewal assertions, ensuring the test specifically verifies renewLoop
exits via the closed check without issuing AddPortMapping or a compensating
delete.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@events/events_test.go`:
- Around line 13-19: Update the Go doc comments immediately preceding
TestSetEmitDebugLogger_SafeConcurrentlyWithEmit and
TestSetEmitDebugLogger_ReceivesTypeAndCountThenClears in events/events_test.go
(sites 13-19 and 49-52) so each comment starts with its corresponding test
function name; no other changes are required.

---

Outside diff comments:
In `@portforward/portforward.go`:
- Around line 141-167: Update the add-timeout handling around addLanded and err
so every timeout triggers deleteRacedMapping for the potentially landed mapping,
regardless of whether ctx is canceled. Return the timeout error as a retryable
add-port-mapping failure without wrapping it with ErrNoPortForwarding, while
preserving the existing cancellation propagation and sentinel wrapping for
non-timeout gateway errors.
- Around line 203-218: Reset f.closed to false in MapPort when a new mapping is
successfully installed, so renewal can run after UnmapPort followed by
remapping. Add a test covering the unmap/remap sequence and verify the renewed
mapping remains active beyond the first renewal tick.

---

Nitpick comments:
In `@peer/peer_test.go`:
- Around line 1111-1128: Replace the fixed-sleep gating of the deregistration
check in this test with require.Eventually polling srv.deregisterCount.Load()
until it is positive. Keep the existing time.Sleep(100 * time.Millisecond) only
for the negative assertions about boxes built and the original box remaining
open, then retain the positive deregistration assertion after the explicit wait.

In `@portforward/portforward_test.go`:
- Around line 318-340: Update TestForwarder_RenewalAfterTeardown_DoesNotReAdd to
configure the fakeIGD delete-error hook before calling UnmapPort, causing
DeletePortMapping to fail so f.mapping remains set while teardown marks the
forwarder closed. Retain the existing renewal assertions, ensuring the test
specifically verifies renewLoop exits via the closed check without issuing
AddPortMapping or a compensating delete.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 21972017-ee43-40c8-ab1a-8931b72ea630

📥 Commits

Reviewing files that changed from the base of the PR and between bbb9508 and a25de28.

📒 Files selected for processing (12)
  • backend/peer_share.go
  • backend/radiance.go
  • backend/radiance_test.go
  • events/events.go
  • events/events_test.go
  • go.mod
  • peer/peer.go
  • peer/peer_test.go
  • peer/validate.go
  • peer/validate_test.go
  • portforward/portforward.go
  • portforward/portforward_test.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • events/events.go
  • go.mod
  • backend/radiance.go
  • backend/peer_share.go
  • peer/validate_test.go
  • backend/radiance_test.go
  • peer/validate.go
  • peer/peer.go

Comment thread events/events_test.go
Comment on lines +13 to +19
// SetEmitDebugLogger has to be safe against a concurrent Emit. peer.Client's
// heartbeat and rotation loops emit for the whole process lifetime, so any
// post-startup call to install the hook overlaps an in-flight Emit — and an
// unsynchronized write to a function-valued global racing a read is a data
// race, which `go test -race` fails on. Run with -race for this to be
// meaningful; without it, the test only shows neither side crashes.
func TestSetEmitDebugLogger_SafeConcurrentlyWithEmit(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Start each Go doc comment with its test function name.

  • events/events_test.go#L13-L19: Start the comment with TestSetEmitDebugLogger_SafeConcurrentlyWithEmit.
  • events/events_test.go#L49-L52: Start the comment with TestSetEmitDebugLogger_ReceivesTypeAndCountThenClears.
📍 Affects 1 file
  • events/events_test.go#L13-L19 (this comment)
  • events/events_test.go#L49-L52
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@events/events_test.go` around lines 13 - 19, Update the Go doc comments
immediately preceding TestSetEmitDebugLogger_SafeConcurrentlyWithEmit and
TestSetEmitDebugLogger_ReceivesTypeAndCountThenClears in events/events_test.go
(sites 13-19 and 49-52) so each comment starts with its corresponding test
function name; no other changes are required.

Source: Coding guidelines

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