Skip to content

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

Open
myleshorton wants to merge 50 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 50 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.

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)
myleshorton and others added 7 commits May 30, 2026 15:26
Three findings (each surfaced as a duplicate thread, so 6 total):

1. portforward.ManualForwarder doc claimed 'every consumer router
   exposes port forwarding as a single port number' — broad and
   inaccurate (many routers support distinct external/internal
   ports). Reworded as an implementation requirement: 'this
   implementation reports the same value for both the external and
   internal port — callers needing distinct ports should use the
   UPnP-based Forwarder.'

2. Same misleading claim was inline in peer.go's NewForwarder closure
   comment. Replaced with the same implementation-requirement framing.

3. The manual-port resolution order (setting → env → UPnP) and the
   out-of-range setting behavior had no test coverage. Extracted the
   resolution logic into pickManualForwarder() so it's directly
   testable without standing up a real UPnP probe. The default
   NewForwarder factory now calls pickManualForwarder() first; nil
   return means fall through to UPnP.

   New TestPickManualForwarder covers 10 cases:
   - setting takes precedence over env
   - setting-only / env-only / both-unset
   - setting out-of-range (positive + negative) → fallthrough
   - setting out-of-range + env valid → env wins
   - setting unset + env unparseable → fallthrough
   - low/high boundary values (1 and 65535)

No behavior change — the extraction is line-for-line equivalent to
the previous inline logic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
portforward/manual.go:
- ManualForwarder.MapPort now sets Protocol="TCP" to match the
  UPnP-based Forwarder, which hard-codes the same value. Samizdat-in
  inbound traffic is TCP-only on both code paths; widening to UDP
  later means widening both forwarders together.
- Defensive guard: MapPort returns an error when constructed with
  port==0. pickManualForwarder already range-checks 1..65535 before
  calling NewManualForwarder, but the check belongs on the type
  itself — a caller that bypasses the validator (programmatic use,
  tests, future code paths) gets a clear error instead of silently
  registering port 0 with lantern-cloud.

portforward/manual_test.go:
- Asserts Protocol="TCP" in TestManualForwarder.
- TestManualForwarder_RejectsZeroPort verifies the new guard.

peer/peer.go:
- slog warning in the env-var path used 'error' as the attribute
  key while every other log line in this file uses 'err'. Renamed
  for log-aggregation consistency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…check

The Share My Connection UI flow needs to decide which mode to start
(Full SmC vs Unbounded) based on whether UPnP discovery succeeds on
the user's network. Previously that gate didn't exist in the lib —
share_my_connection.dart was using Random().nextBool() as a stand-in,
which routed half of opted-in users to a mode that didn't match
their actual capabilities.

ProbeUPnP wraps NewForwarder and returns true on success, false on
any failure (ErrNoPortForwarding, ctx timeout, ctx cancellation).
The discovered forwarder is discarded after the probe; Forwarder
holds no goroutines or sockets that need explicit cleanup, so a
subsequent NewForwarder call can re-discover without coordination.

Callers (the lantern-core FFI export + the Dart-side
isPeerProxyEnabled-style call) treat true/false binary; the
underlying error is not surfaced because no UI flow does anything
productive with the distinction between 'no IGD on this LAN' and
'discovery timed out'.

TestProbeUPnP_CancelledContextReturnsFalse pins the
cancellation-fast-path contract: a cancelled ctx must yield false
within ~2s, not block for the M-SEARCH multicast wait. A positive-
path test would require a real IGD on the CI host's network, which
isn't available.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapses the Share My Connection peer stack onto current main, which had
advanced 81 commits since the branches were cut.

Conflict resolutions of note:

- events.Emit: main added panic recovery around callbacks; the peer branch
  replaced whole-loop RLock with a snapshot-then-release to fix a
  concurrent map iteration/write race against Unsubscribe. Both are kept —
  snapshot under RLock, release, then dispatch each callback in a
  goroutine with recovery.

- LocalBackend.stopChan no longer exists on main; Close now drains via
  shutdownFuncs and closePeerClient's peerWG.Wait, so the peer shutdown
  test helper drops the field rather than reintroducing it.

- peerconn.SetListener takes func(peerconn.Event) as of lantern-box #256;
  the call site is adapted. Event.Destination has no consumer yet.

lantern-box is pinned to the peerconn branch tip pending the #255/#256
merge and a v0.0.108 tag.
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 includes port forwarding, remote registration, sing-box lifecycle management, credential rotation, abuse-rule validation, 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 with IGDv1/IGDv2 support, manual port forwarding, renewal, external-IP lookup, configuration keys, and dependency updates.
Peer API and lifecycle
peer/*
Adds peer registration APIs, startup and shutdown sequencing, 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/events.go
Adds peer status and connection SSE endpoints, 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

Possibly related PRs

Suggested reviewers: garmr-ulfr

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.99% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: integrating the Share My Connection peer-core stack.
✨ Finishing Touches
🧪 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

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