Skip to content

Peer connection events + Unbounded integration + manual port forward#471

Draft
myleshorton wants to merge 48 commits into
fisk/peer-localbackendfrom
fisk/peer-connection-events
Draft

Peer connection events + Unbounded integration + manual port forward#471
myleshorton wants to merge 48 commits into
fisk/peer-localbackendfrom
fisk/peer-connection-events

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

Summary

Three related additions on top of the in-flight peer-share work (#460):

  1. Peer connection events: peer.ConnectionEvent fires on every samizdat accept/close, fed by lantern-box's new tracker/peerconn listener (lantern-box#255). Lantern-core subscribes and forwards via the existing FlutterEvent bridge so the UI globe can render arcs as remote clients connect.
  2. Manual port-forward override: new portforward.ManualForwarder for users on networks where UPnP doesn't work (router has UPnP off, ISP gateway without IGD, double-NAT). Configured via either the new `settings.PeerManualPortKey` (UI-driven) or `RADIANCE_PEER_EXTERNAL_PORT` env var (developer override). Resolution order in `peer.Client.Start`: setting > env var > UPnP discovery.
  3. Unbounded integration: new self-contained `unbounded` package wrapping the broflake widget-proxy lifecycle. Subscribes to `config.NewConfigEvent`, manages start/stop based on three conditions (local opt-in setting, server feature flag, server-supplied UnboundedConfig). Each consumer connection emits `unbounded.ConnectionEvent` on the radiance event bus — same shape as peer.ConnectionEvent so consumers can feed both protocols into a single Flutter event stream.

What's new vs. base branch

```mermaid
sequenceDiagram
autonumber
participant Box as lantern-box samizdat
participant Peer as radiance/peer
participant Bus as radiance/events
participant Unbnd as radiance/unbounded
participant Backend as LocalBackend

Box->>Peer: peerconn.Notify(+1, "ip:port")<br/>(lantern-box#255 hook)
Peer->>Bus: events.Emit(ConnectionEvent{State:+1, Source:"ip:port"})
Note over Backend: backend/radiance.go:Start<br/>unbounded.InitSubscription()
Backend->>Unbnd: subscribe to config.NewConfigEvent
Note over Unbnd: when settings.UnboundedKey<br/>+ Features[unbounded]<br/>+ cfg.Unbounded all hold
Unbnd->>Bus: events.Emit(ConnectionEvent{State:+1, Addr:"ip"})
rect rgba(200, 255, 200, 0.3)
    Note over Bus: lantern-core subscribes to BOTH<br/>and forwards as one FlutterEvent type
end

```

Test plan

  • `go test ./peer/... ./portforward/... ./unbounded/...` clean
  • `go build ./...` clean (cmd/lantern build error is pre-existing, unrelated)
  • End-to-end on macOS: with the matching lantern branch and a local SmC peer, ConnectionEvents flow through to the globe
  • Manual port: `RADIANCE_PEER_EXTERNAL_PORT=5698` causes peer.Client to skip UPnP and bind 5698 directly
  • Unbounded: with settings.UnboundedKey=true and a server feature flag rollout, broflake widget proxy actually runs

Caveats

  • Local `replace github.com/getlantern/lantern-box => ../lantern-box` in go.mod — needs to come out before merge once lantern-box#255 is tagged.
  • `unbounded` package lives at the repo root rather than under `vpn/` because the in-flight `adam/unbounded-widget-proxy` branch refactors `vpn/` heavily and we want this to land independently.

Related

🤖 Generated with Claude Code

atavism and others added 30 commits April 22, 2026 03:17
Picks up getlantern/lantern-box#253 which bumped broflake to land
getlantern/unbounded#360 (clientcore: classify QUIC connection
errors). After this ships in a Lantern build, lantern.log will carry
err_class structured-log fields on every "QUIC connection ended"
record, distinguishing idle_timeout / handshake_timeout /
application_close_remote / application_close_local / transport_error
/ stateless_reset / context_canceled / etc.

The transitive broflake bump also picked up getlantern/unbounded#359
(integration test for connection migration), which is test-only.

`go mod tidy` clean; pre-existing `cmd/lantern/lantern.go:78`
ipc.NewClient build error reproduces on main and is unrelated.

Co-authored-by: Adam Fisk <afisk@mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: preserve caller-supplied data dir; migrate v9.1.x stragglers

`setupDirectories` (added in #370) unconditionally appended `/data`
and `/logs` suffixes to the caller-supplied paths, even when the
caller had already passed a fully-qualified directory. On Android,
where `MainActivity.kt` calls `Mobile.initLogging(initConfigDir(),
…)` and `initConfigDir()` returns `<application.dataDir>/.lantern`,
this rewrote the radiance data dir from `.lantern` (used by v9.0.x)
to `.lantern/data` (used by v9.1.x).

The settings file path therefore moved silently between minor
versions:

  v9.0.x →  <app.dataDir>/.lantern/settings.json
  v9.1.x →  <app.dataDir>/.lantern/data/settings.json

On every existing install, the v9.1.x client failed to find the
prior file, fell through to defaults (`UserLevelKey="free"`, no
`UserIDKey`, no `DeviceIDKey`, no `JwtTokenKey`), generated a fresh
device id, and called `UserRecoverByDevice` cold against the auth
server. The user's local Pro state was effectively wiped on
upgrade, surfacing as "Pro shown as expired" — which is exactly
what the v9.1.5 China-Android tickets (#174455 / #174496 / #174515)
reported. Auto-diagnosis on those tickets initially blamed
cancelled Stripe checkouts; the actual cause was this path
regression and the prior payment channel (Shepherd / AliPay /
WeChat — not Stripe — for the vast majority of CN users).

Two fixes:

1. setupDirectories now honors the caller's path as-is (the
   pre-#370 behavior). When the caller passes an empty string we
   still fall back to internal.DefaultDataPath() / DefaultLogPath();
   the `maybeAddSuffix` helper is removed.

2. Settings init has a one-shot v9.1.x migration: on the first
   launch of the fixed client, if `<dataDir>/settings.json` is
   missing but `<dataDir>/data/settings.json` exists, the latter is
   copied up. This means v9.1.x users who'd already lost their
   v9.0.x state recover their freshly-minted v9.1.x identifiers on
   upgrade rather than getting wiped again. Quick stat check, no-op
   for the vast majority of installs that never had the bad nested
   file. Three sub-tests cover the recover, no-op, and
   canonical-already-present paths.

Affects Android (confirmed via auto-diagnosis) and any other
platform that passes a fully-qualified path through to
`common.Init` — iOS via `lantern-core/ffi/ffi.go:114` and desktop
via `cmd/lanternd/lanternd.go:328` use the same code.

Refs Freshdesk #174455, #174515, #174496.

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

* ask-migration: prefer the file whose user_level == 'pro'

Prior version of the migration just preferred the canonical path
when it existed. That was correct for the typical upgrade path
(v9.0.x canonical=pro vs v9.1.x nested=expired) but wrong if the
inverse ever happened — e.g., a Shepherd payment landed during the
v9.1.x window and the nested file legitimately holds the pro state.

Now the migration reads both files, compares their user_level, and
prefers whichever actually says 'pro'. Falls back to canonical when
both or neither have pro.

Three additional sub-tests cover:
- canonical-pro vs nested-expired (the broken-upgrade case)
- canonical-expired vs nested-pro (rare inverse)
- both-pro and neither-pro (canonical wins by tiebreaker)

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

* ask-migration: also recover v9.0.x local.json (Derek's failing case)

Derek's macOS test on the fix branch went 9.1.5 → fix (Pro restored
✓) but 9.0.25 → fix (Pro lost ✗). Investigation of #174542 logs
shows 'Created new user' fired in (*fetcher).ensureUser at
fetcher.go:135 because UserIDKey was 0 — the canonical settings.json
didn't exist on his disk.

Reason: v9.0.x's radiance (commit 2d39607) called the settings
file `local.json`. The LocalBackend refactor in #370 renamed it to
`settings.json`. So 9.0.25 wrote `<dataDir>/local.json`; the fix
was looking only at `<dataDir>/settings.json` and the v9.1.x nested
`<dataDir>/data/settings.json`. v9.0.x's good state at local.json
was orphaned.

The keys (UserIDKey, TokenKey, JwtTokenKey, UserLevelKey, etc.) are
identical between v9.0.x and current — only the filename changed —
so the file format is forward-compatible. The migration just needs
to add `<dataDir>/local.json` as a third candidate path.

Generalized the migration to consider all three candidates and pick
whichever has user_level == "pro" first; if none has pro, prefer
canonical → legacy → nested in that order so user identifiers
survive even when Pro state is non-pro (losing identifiers creates
server-side device-registration orphans, which matters more than
losing the Pro string).

Renamed migrateV91xSettingsIfNeeded → migrateLegacySettingsIfNeeded
since it's no longer about a single version.

Eight sub-tests cover: v9.0.x-only (Derek's case), v9.1.x-only,
v9.0.x-pro-vs-v9.1.x-expired (chained upgrade), canonical-pro vs
nested-expired, nested-pro-only, all-pro tiebreak, no-pro
identifier-survival, and the empty-disk no-op.

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

* review: distinguish ENOENT, atomic write the migrated file

Address Copilot's two review comments on radiance#463:

1. Migration was treating any os.Stat error as "file not present,"
   which masked permission/I/O errors on the canonical path. Now
   distinguishes errors.Is(err, fs.ErrNotExist) (expected, proceed)
   from other read errors (logged; if the canonical path itself is
   unreadable for non-ENOENT reasons, skip migration entirely so we
   don't try to write over a file the OS won't let us read).

2. writeMigrated used os.WriteFile, which is non-atomic and could
   leave a partially-written settings.json if the process crashed
   mid-write. Switched to atomicfile.WriteFile, the same mechanism
   the normal save path uses (writes to a temp file then renames).

New sub-test exercises the unreadable-canonical case (skipped on
windows where chmod 000 doesn't reproduce the same semantics).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

* settings: also migrate from pre-9.x flashlight/lantern-client YAML

The fix in #463 covered the v9.0.x → v9.1.x → fixed upgrade path
(rename of local.json → settings.json plus the bogus /data suffix).
But Lantern users on the older flashlight + lantern-client stack
(pre-9.0.x) wrote their state to a different file entirely:

  macOS    ~/.lantern/settings.yaml
  Windows  %APPDATA%\Lantern\settings.yaml
  Linux    ~/.config/lantern/settings.yaml
  iOS      <fileDir>/userconfig.yaml

Anyone upgrading directly from v8.x (or anything with that schema)
would skip both fixed paths and start fresh — same Pro-lost
symptom Derek reproduced for the v9.0.x case.

Adds a fourth candidate to migrateLegacySettingsIfNeeded that
reads the platform-specific YAML, translates field names and types
into the canonical settings.json schema, and feeds the translated
JSON into the same priority-pick logic the existing JSON candidates
use. Order: canonical > v9.0.x local.json > pre-9.x YAML > v9.1.x
nested. The pre-9.x YAML beats the v9.1.x nested file because the
nested file is known-bugged (fresh device id, possibly wrong
user_id) while the YAML is real legitimate state.

Field translation (desktop schema):
  userID       → user_id
  deviceID     → device_id
  userPro bool → user_level "pro" / "free" (only "free" if user_id
                 is set; an anonymous yaml leaves it unset so the
                 next /account/login decides)
  userToken    → token
  emailAddress → email

Field translation (iOS schema): UserID/DeviceID/Token only — iOS
didn't persist user_level locally.

Android is intentionally not handled here — its pre-9.x state lived
in an encrypted SQLite (/data/data/.../files/masterDBv2/db) whose
password lives in EncryptedSharedPreferences. Reading that requires
Kotlin-side code in the lantern repo, not a pure-Go migration in
radiance. Will be a separate change.

Adds 6 new sub-tests covering both desktop and iOS layouts (pro,
free-but-identified, anonymous, malformed, unknown layout, and the
end-to-end iOS migration on darwin/ios).

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

* fix macOS path: ~/Library/Application Support/Lantern (not ~/.lantern)

The pre-9.x desktop client used getlantern/appdir.General('Lantern')
which on macOS routes to os.UserConfigDir() — i.e.
~/Library/Application Support/Lantern, NOT ~/.lantern. (Verified
against lantern-client/desktop/app/settings.go:132 and
appdir/appdir_darwin.go.) Linux is os.UserConfigDir() + lowercase
'lantern' (linux build tag in appdir.General does the lowercasing).
Windows is os.UserConfigDir() + 'Lantern' which resolves to
%APPDATA%\Lantern.

Switched to os.UserConfigDir() across all three desktop platforms
to match exactly. Also factored the path resolver into a package-
level var legacyYAMLPathFn so tests can redirect the lookup at a
tempDir without picking up the host machine's actual Lantern
install (which is what was breaking the 'nothing on disk is a no-op'
test on my macOS).

Three new sub-tests cover the YAML migration path end-to-end:
  - pre-9.x desktop YAML recovered (Pro state translates correctly)
  - v9.0.x local.json beats pre-9.x YAML when both present
  - pre-9.x YAML beats v9.1.x bugged nested file

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

* trim verbose comments

Drop multi-paragraph philosophical asides and per-platform tables
that duplicate what the code shows. Keeping only the non-obvious
WHYs:
  - linux's lowercase appdir quirk
  - iOS sandbox path provenance
  - "free" fallback when userID is set
  - empty user_level on iOS

~90 fewer lines, no behavior change.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Adam Fisk <afisk@mini.local>
* deps: bump kindling for method-aware retry across transports

raceTransport in kindling was retrying every request on 4xx/5xx and on
post-RoundTrip transport errors, which meant non-idempotent endpoints
(notably POST /peer/verify) were being replayed across transports —
each replay observable on the server, with the first succeeding,
deprecating its row, and subsequent replays 404'ing. Bump pulls in
getlantern/kindling#32 which makes the retry behavior method-aware:

  - Non-idempotent (POST/PUT/DELETE/PATCH/OPTIONS): exactly one request
    fires once any transport connects; whatever happens is returned.
  - Idempotent (GET/HEAD): retain retry-on-5xx and retry-on-transport-
    error across transports for the legitimate "fronting CDN is being
    blocked" case. 4xx still short-circuits — the request itself is
    the problem and replay won't help.
  - Connection-establishment failures fall back regardless of method.

Behavioral effect on radiance:
  /config-new (GET) — unchanged retry behavior.
  /peer/register, /peer/verify, /peer/heartbeat, /peer/deregister
    (POST) — single-shot, removing the multi-pod-deprecation race
    seen during peer-share testing on 2026-05-07.

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

* config: opt /config-new POST into kindling's idempotent retry

Address Copilot review on PR #468: /config-new is a POST
(config/fetcher.go:150), not a GET, so kindling's new method-aware
retry made it single-shot — losing the cross-transport fallback that
historically kept config fetches resilient when one fronting CDN
returns 5xx.

/config-new is semantically a read-only fetch despite the POST shape
(body carries the client's last-known etag/version and metadata), so
it's safe to retry. Set kindling.IdempotentHeader on the request to
opt it back into raceTransport's GET/HEAD-style retry behavior.

Pulls in the merge SHA of getlantern/kindling#33 which adds
IdempotentHeader.

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

---------

Co-authored-by: Adam Fisk <afisk@mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): add monitor TUI, throughput, and session history

Adds a `lantern monitor` subcommand showing live VPN status, throughput,
recent sessions, and errors, with q/Ctrl-C to quit. Supporting changes:

- vpn: throughput tracker (global + per-outbound) and session history
  recorded across server changes; expose Bytes() and Throughput() on
  VPNClient
- ipc: /vpn/throughput and /vpn/sessions endpoints; map net errors to
  ErrIPCNotRunning so clients can drive reconnect
- cli: --json on status, ip, and servers list; promote servers/private
  flags to subcommands with positional args; `throughput` subcommand;
  --level and --grep filters plus reconnect on `logs`
- backend: wire SessionHistory into LocalBackend; disconnect VPN on
  shutdown
- AGENTS.md: expand code comment guidance and reinstate Go doc rules

* test(vpn): cover session history and throughput tracker; preserve ipc dial error

Wrap ErrIPCNotRunning with the underlying connection error so callers can
errors.Is the sentinel while still seeing the network failure in the chain.
issue-report: add multipart attachment support
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>
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>
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>
…_PORT

Many users can't or don't use UPnP — routers ship with UPnP off for
security, ISP gateways without IGD, networks behind double-NAT — but can
manually configure a port forward on their router. Today peer.Client
fails to start in those environments because NewForwarder only knows
how to talk UPnP IGDv2/v1.

This adds a ManualForwarder that satisfies the peer.portForwarder
interface without router interaction:
- MapPort returns the configured port unchanged (1:1 NAT — every
  consumer router exposes port forwarding as a single port number, and
  splitting external/internal isn't a real-world use case)
- UnmapPort and StartRenewal are no-ops (user owns the router rule)
- ExternalIP probes a public-IP discovery service since no UPnP
  gateway is available to ask

peer.Client.Start now reads RADIANCE_PEER_EXTERNAL_PORT at the
NewForwarder factory; if set, ManualForwarder is used instead of UPnP
discovery. Surfacing this as a proper user-facing setting (so
non-engineers can configure it without env vars) is a follow-up.

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

Adds settings.PeerManualPortKey so the user-facing Advanced UI can
persist the manual port forward without an env var. Resolution order
in peer.Client.Start's NewForwarder:

  1. settings.PeerManualPortKey (Advanced UI in lantern Flutter)
  2. RADIANCE_PEER_EXTERNAL_PORT env var (developer / power-user)
  3. UPnP discovery (default)

The setting is wired through lantern-core's
PatchSettings(PeerShareEnabledKey...) path on a separate branch — the
new `setPeerManualPort` FFI export over there calls
PatchSettings({PeerManualPortKey: <int>}) which lands in radiance's
settings store and gets picked up on the next peer.Client.Start.

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

* ci: open a PR for fronted refresh instead of pushing direct to main

The scheduled refresh of kindling/fronted/fronted.yaml.gz was direct-
pushing to main, which the repo's pull_request rule (ruleset
"copilot-review") now rejects with GH013. Switch to peter-evans/
create-pull-request so the daily refresh produces a PR on a stable
branch (chore/refresh-fronted-config) — successive runs supersede
the prior unmerged refresh rather than piling up. Add pull-requests:
write to the workflow's permissions block.

Failing run for reference:
https://github.com/getlantern/radiance/actions/runs/25478096992

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

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Adam Fisk <afisk@mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* vpn: tune base box options and route RU locale to Yandex DNS

- drop explicit MTU=1500 to let sing-box pick the platform default
- enable EndpointIndependentNat for QUIC migration and UDP hole-punching
- enable StoreFakeIP and StoreRDRC so fake-IP and reject-cache survive restarts
- drop RU from AliDNS locales; resolve via Yandex (77.88.8.8) instead

* use Yandex DNS for RURU - normalized ru_RU

* fix test
atavism and others added 4 commits May 7, 2026 16:47
Adds the radiance side of the Unbounded ("Basic mode" in the SmC UI)
WebRTC donor-mode integration. Self-contained package under
radiance/unbounded; drops cleanly onto the current branch without
needing the larger vpn/ refactor that adam/unbounded-widget-proxy
ships with.

unbounded.SetEnabled(bool) toggles the local opt-in
(settings.UnboundedKey). InitSubscription wires the manager to
config.NewConfigEvent — the broflake widget actually runs only when:
  1. settings.UnboundedKey is true (local opt-in)
  2. server cfg.Features[UNBOUNDED] is on
  3. server provides cfg.Unbounded discovery + egress URLs

Each consumer connection change emits unbounded.ConnectionEvent on
the radiance event bus, mirroring the shape of peer.ConnectionEvent
so lantern-core subscribers can feed both into one Flutter event
stream.

Wired into LocalBackend.Start so the manager is live for the process
lifetime; sync.Once-guarded against double-subscribe.

Mostly a port of the unbounded.go file from adam/unbounded-widget-proxy
(#336), with the package moved out of vpn/ since
that branch's vpn/ is undergoing a separate refactor and we want the
unbounded code to land independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
atavism and others added 14 commits May 8, 2026 07:38
account: forward payment redirect idempotency keys
The original manualPortForwarder (introduced in b81b6b0) returned
"", nil from ExternalIP, leaning on lantern-cloud peer_handler's
"external_ip empty → use observed" fall-through to fill the IP from
the /v1/peer/register call's RemoteAddr. The refactor into
portforward.ManualForwarder regressed this path by actively probing
publicip.Detect, which fails on the only deployment where it
matters: machines with Lantern's own VPN tunnel up.

When the tunnel is up, outbound traffic routes through it. The
publicip discovery endpoints (api.iantem.io etc) either time out or
return the tunnel exit's IP rather than the user's WAN IP — neither
is what we want. Worse, the failure surfaces as a hard error so
peer.Client.Start aborts before /v1/peer/register is even called,
breaking peer-share for every user with a manual port + an active
tunnel.

Empty is also more correct: peer_handler uses the IP that will
actually receive inbound traffic on the manually-forwarded port (the
register-call's RemoteAddr), which is by definition the right answer.

Add a regression test pinning the empty-string contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <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>
…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>
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>
* set max compressed size for issue report to 19

* change to 19.5MB
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>
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>
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>
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>
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>
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>
…events

# Conflicts:
#	backend/radiance.go
#	go.mod
#	go.sum
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.

3 participants