Share My Connection: peer-core integration (collapses #458, #460, #466, #472, #484, #499, #503) - #589
Share My Connection: peer-core integration (collapses #458, #460, #466, #472, #484, #499, #503)#589myleshorton wants to merge 50 commits into
Conversation
PR 1 of 4 stacked PRs implementing the radiance side of "Share My
Connection" (peer-proxy). This PR introduces a self-contained peer
module — wiring into LocalBackend / settings / FFI lands in PRs 2-4.
* portforward: UPnP IGDv2 with IGDv1 fallback (huin/goupnp). Forwarder
exposes MapPort, UnmapPort, StartRenewal (50%-of-lease cadence,
1-min floor), and ExternalIP. Each goupnp call is wrapped in a
ctx-respecting helper so an unresponsive gateway can't block the
caller past its deadline.
* peer.Client: orchestrates one session — open UPnP port → fetch
external IP → register with lantern-cloud → start a second sing-box
instance with the server-supplied config → run the heartbeat loop.
Stop deregisters, closes the box, unmaps the port, and continues
past individual failures so partial state never lingers. The box's
lifetime ctx is derived from Background (not the Start caller's
ctx) so a short-lived Start ctx doesn't kill it.
* peer.API: thin HTTP client for /v1/peer/{register,heartbeat,
deregister}. X-Lantern-Device-Id is sent on every request so the
server can owner-gate.
* heartbeatLoop auto-stops on a 404 from the server (registration
reaped or wrong owner). Stop runs in a separate goroutine to avoid
the cyclic Stop → cancelRun → loop-exit deadlock.
Tests cover the happy path, every failure phase (port-forward,
external-IP, register, sing-box build, sing-box start), Stop
idempotency, Stop continuing past individual errors, the 404
auto-stop path, and the transient-error stays-running path. portforward
gets fake-IGD coverage including ctx-cancellation. go test -race and
golangci-lint are clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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)
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.
Formatting only; gofmt's doc-comment list indentation had not been applied to these files on the source branches.
📝 WalkthroughWalkthroughPeer 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. ChangesPeer sharing
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()
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
portforwardpackage (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 forlaunch_cfg, credential rotation, and connection-event forwarding via lantern-boxpeerconn. - 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.
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (7)
portforward/portforward_test.go (2)
128-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThis test does not cover the mid-call cancellation path.
runWithCtxreturns at itsctx.Err()pre-check when ctx is already cancelled, soAddPortMappingis never called here.c.addBlockis never received on, andclose(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 afterMapPortblocks, so theselectbranch inrunWithCtxis 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
StartRenewalitself has no test, and the loop goroutine is not joined.This test calls
f.renewLoopdirectly, sof.cancelis never set. The guards inStartRenewalare untested: the early return when a renewal is already running, the early return whenf.mappingis nil, and the 1-minute interval floor.UnmapPortdepends onf.cancelbeing set to stop the loop.The test also returns immediately after
cancel()without waiting forrenewLoopto 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 winAdd doc comments to the exported
Mappingtype andLocalIPfunction.
Mappingat Line 26 andLocalIPat Line 319 are exported and carry no doc comment. Every other exported identifier in this file is documented.LocalIPis a bare re-export oflocalIP, 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 winLog the discarded discovery errors.
NewForwarderdrops the error fromdiscoverIGDv2anddiscoverIGDv1. A gateway that is present but returns a SOAP or transport error is then reported asErrNoPortForwarding, 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 valueAdd Go doc comments to these exported identifiers.
Status(line 96),NewClient(line 204),IsActive(line 562), andCurrentStatus(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 winExported identifiers in the new
peerpackage 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 toRegisterRequest,RegisterResponse,LifecycleRequest, and toAPI(line 45),Register(line 60), andDeregister(line 90).peer/peer.go#L96-L96: add doc comments toStatus, and toNewClient(line 204),IsActive(line 562), andCurrentStatus(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
drainPhasesreads a fixed count, so one stray event breaks the assertions.
eventsuses a process-global subscription map. The subscriber inTestClient_StatusEventEmittedOnStartAndStopreceives everyStatusEventemitted 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).
drainPhasesreads exactlynevents. One unrelated event consumes a slot, so a wanted phase is never collected andassert.Containsfails. 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
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (19)
backend/peer_share.gobackend/radiance.gobackend/radiance_test.gocommon/env/env.gocommon/settings/settings.goevents/events.gogo.modipc/client_events_mobile.goipc/client_events_nonmobile.goipc/server.gopeer/api.gopeer/peer.gopeer/peer_test.gopeer/validate.gopeer/validate_test.goportforward/manual.goportforward/manual_test.goportforward/portforward.goportforward/portforward_test.go
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.
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.
There was a problem hiding this comment.
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
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 reportedMERGEABLE/CLEANonly 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 thestack is collapsed into one integration PR rather than 8 sequential rebase-and-merge cycles.
Dependency
go.modpinsgithub.com/getlantern/lantern-box v0.0.109— the released tag, no longer astaging pseudo-version. lantern-box #255 and #256 have merged; that repo auto-tags per merge,
so #255 produced
v0.0.108and #256 producedv0.0.109.v0.0.109is required, notv0.0.108:peerconn.Eventarrives with #256, andpeer/peer.gocallsSetListener(func(evt peerconn.Event) {...}).Conflict resolutions worth reviewing
events.Emitmainadded panic recovery around callbacks; the peer branch replaced whole-loopRLockwith snapshot-then-release, fixing a realconcurrent map iteration and map writerace againstUnsubscribe. Taking either side alone silently drops a fix and still compiles. Now: snapshot underRLock→ release → dispatch each callback in a goroutine with recovery.LocalBackend.stopChanmain;Closenow drains viashutdownFuncs+closePeerClient'speerWG.Wait(). The peer shutdown test helper drops the field instead of reintroducing it.peerconn.SetListenerfunc(peerconn.Event)as of lantern-box #256. Call site adapted.Client.Stopstate resetPhaseStoppingstatus — the emittedstoppingSnapshotdepends on it.backend/radiance_test.gomain+ 8 peer tests); git could not align the files so the whole body conflicted. All 13 present and passing.launch_cfgwithout a route block, which #472's rotation test did not supply. It now uses the sharedminimalValidLaunchCfgfixture. 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.runWithCtxreturns the moment itsctx is cancelled but its goroutine keeps running, so an
AddPortMappingcould reach thegateway 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(racingUnmapPort) andMapPort(racing caller cancellation; worse there,since
f.mappingwas never recorded soUnmapPortshort-circuited). Both now wait for thecall's outcome, bounded by a timeout treated as "may have landed", and delete the mapping if
teardown or cancellation happened meanwhile.
UnmapPortmarks teardown under the lock so ablocked renewal is ordered strictly after it.
peer: rotation bypassed the abuse-rule gate.validateAbuseRulesran only inStart,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:emitDebugLoggerdata race. A plain global read byEmitfrom arbitrarygoroutines and written by
SetEmitDebugLogger. Now anatomic.Pointer. The package had notest file; the new one reproduces the race and fails under
-racewithout the fix(
WARNING: DATA RACE).backend: construction no longer fatal on peer-client failure. Restores the invariantdocumented on
NewLocalBackend— onlycommon.Initis fatal, so a user can always come upand report an issue.
PeerStatusandapplyPeerShareguard nil, andapplyPeerSharerollsPeerShareEnabledKeyback so a persisted "on" can't outlive an unavailable client.backend:PatchSettingsapplies both handlers and joins their errors.settings.Patchhas already persisted the whole diff by then, so an early return on a VPN-restart failure
left
PeerShareEnabledKeypersisted but unapplied.peer:isUnconditionalRejecttolerates"type":"default"on inlined rules.launch_cfgis authored server-side rather than round-tripped through sing-box's marshaller(which omits
typefor default rules), so the discriminator can legitimately appear and wasmaking the peer refuse a config that does reject unconditionally. Non-default types are still
rejected — they carry their own matching fields.
MapPortalso 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_DoesNotReAddand
TestValidateAbuseRules_RejectsNonDefaultRuleType.Not included
port would have been a silent no-op:
boxRegistryCtx.Valuefalls through tobox.BaseContext(), which builds a fresh registry per call, so aservice.MustRegisterthrough it writes into a registry discarded before libbox reads it.
Event.Destinationfrom lantern-box Adding kindling issue request integration test #256 has no consumer yet — staged for the abuse aggregator.Verification
go build -tags "with_clash_api standalone" ./...— cleango test -tags "with_clash_api standalone" ./...— all 21 packages passgo test -raceonevents,portforward,backend,peer— cleangofmtclean across all changed filesstandaloneis needed locally on darwin:ipc/client_nonmobile.gois built for(!android && !ios && !darwin) || (darwin && standalone), so without it darwin selects themobile
NewClientandcmd/lanternfails to compile. CI runs on ubuntu and takes thenonmobile path.
Known pre-existing issue (not introduced here)
go vet ./peer/...reports alostcancelfinding oncancelRuninpeer/peer.go. Itreproduces on the unmodified
fisk/peer-manual-portforwardbranch, so it predates thisintegration, and it is not CI-gating (
lostcancelis outsidego 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 affectingevery call site, not this PR.