env: forward shell RADIANCE_* vars from main app to system extension#8683
env: forward shell RADIANCE_* vars from main app to system extension#8683myleshorton wants to merge 70 commits intomainfrom
Conversation
Server tags are determined by URL content, not caller-supplied names. addServerBasedOnURLs now returns the tags of added servers so callers can connect using the actual tag. Also sends VPN status updates from connectToServer on Linux so the UI reflects connection state changes.
* mobile: return string instead of []byte from gomobile-exported funcs The gomobile wrapper copies Go pointer-containing return values to the C thread stack using runtime.wbMove. When a GC cycle runs during the copy, bulkBarrierPreWrite panics because the destination isn't GC-tracked. Returning string avoids this — gomobile marshals strings via C heap allocation rather than leaving them as Go slice headers. See getlantern/engineering#3175 for the full crash analysis (from Freshdesk #172640 — Derek reporting "Lantern Crash" on macOS 26.3.1). Go changes: AvailableFeatures, UserData, FetchUserData, GetAvailableServers, GetSelectedServerJSON, OAuthLoginCallback, AcknowledgeGooglePurchase, AcknowledgeApplePurchase, Login, Logout, DeleteAccount Swift changes (macos + ios): preserve Flutter contract by converting the string back to Data for methods whose Dart side reads `bytes` via utf8.decode (getUserData, fetchUserData, oauthLoginCallback, login, logout, deleteAccount, acknowledgeInAppPurchase). For methods whose Dart side expects String (featureFlags, getLanternAvailableServers, getSelectedServerJSON), just pass the gomobile string directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * android: update MethodHandler for string-returning gomobile bindings The gomobile-exported funcs in lantern-core/mobile/mobile.go now return string instead of []byte. The generated Android binding will therefore return String where it used to return ByteArray. For each affected method, match what the iOS handler does so the Flutter platform-channel contract stays stable: * Methods whose Dart callers expect bytes (Uint8List) — login, logout, deleteAccount, userData, fetchUserData, oauthLoginCallback, acknowledgeGooglePurchase — convert the String result via `.toByteArray(Charsets.UTF_8)` before calling success() (mirrors Swift's `.data(using: .utf8)`). * Methods whose Dart callers expect a String — availableFeatures, getAvailableServers, getSelectedServerJSON — drop the `String(byteArray)` constructor and use the return value directly, with the same "{}" / "[]" empty-default that iOS uses. Addresses Copilot review on PR #8663. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Route all IPC operations through LanternCore methods instead of exposing Client() to callers. Add GetSelectedServerTag, GetAutoLocationJSON, CheckDaemonReachable, PatchSettings, and VPNStatusEvents to the Core interface. Update FFI and mobile layers to use them, and remove now-unused vpn_tunnel helper functions. Also includes Flutter-side fixes: device-removal sign-in race condition, plans fetch retry logic, and private server setup improvements.
The gomobile-exported functions in lantern-core/mobile/mobile.go were
migrated from ([]byte, error) to (string, error). gomobile renders the
new signatures with a non-optional Swift String return (Data was
optional; String is not), so `json?.data(using: .utf8)` and
`payload?.data(using: .utf8)` now fail to compile:
error: cannot use optional chaining on non-optional value of type
'String'
Drop the `?` on all 14 call sites (7 each in ios/ and macos/). The
resulting `json.data(using: .utf8)` returns Data? anyway — an empty
Go string still produces a non-nil empty Data, which preserves the
Flutter contract the comment on these lines describes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…reproduction (#8672) * add android-test script for quick emulator testing with env overrides Usage: scripts/android/android-test <apk> [ENV_KEY=VALUE ...] Example: scripts/android/android-test lantern.apk RADIANCE_COUNTRY=BG RADIANCE_FEATURE_OVERRIDES=dns_ruleset_host_bypass Starts an emulator, installs the APK, pushes a .env file with overrides to the app's data dir (via adb root on Google APIs images, run-as on debug APKs, or su on rooted devices), restarts the app, and streams filtered logcat. Prefers the "lantern-test" AVD if it exists (create with Google APIs image for root access): sdkmanager "system-images;android-35;google_apis;arm64-v8a" avdmanager create avd -n lantern-test -k "system-images;android-35;google_apis;arm64-v8a" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * address review: serial targeting, su quoting, trap cleanup, fix comment - Use -s <serial> throughout so multiple devices don't break adb - Fix su -c quoting so $(stat ...) expands on-device - Add trap to clean up temp .env on EXIT/INT/TERM - Fix header comment (no /sdcard/ fallback) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * android-test: push .env to .lantern data dir (not app root) The Go env package reads .env from the data directory (via env.LoadFromDir called from common.Init), not from the app's root data dir. Push to /data/data/$PKG/.lantern/.env so radiance finds it. Companion: getlantern/radiance#421 (env.LoadFromDir) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * android-test: auto-install system image and create AVD if none exists If no AVDs are found, the script now automatically: 1. Detects host arch (arm64 vs x86_64) 2. Installs the Google APIs system image via sdkmanager 3. Creates a "lantern-test" AVD via avdmanager This means running android-test on a fresh machine with just the Android SDK installed works out of the box — no manual AVD setup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * address review: array for ADB_CMD, timeouts, remove unused PID - Use bash array for ADB_CMD so paths with spaces work correctly - Add configurable timeouts for emulator appear (120s) and boot (300s) - Remove unused EMULATOR_PID — emulator intentionally left running between invocations so subsequent runs don't pay boot cost Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * add android-reproduce: reproduce Freshdesk tickets on emulator Usage: android-reproduce /tmp/ticket-172722 # auto-downloads APK android-reproduce /tmp/ticket-172722 lantern.apk # uses provided APK After running /analyze-ticket, this script: 1. Extracts country + version from the ticket's config/logs 2. Downloads the matching APK from GitHub releases (gh CLI) 3. Pushes the user's exact config.json, servers.json, split-tunnel.json to the emulator so it gets the same proxies, DNS rules, rule sets 4. Sets RADIANCE_COUNTRY to match the user's region 5. Installs, restarts, and streams filtered logcat This gives near-exact reproduction of Android-specific issues by replicating the user's proxy assignments, country routing, and sing-box config on a local emulator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * android-reproduce: match user's Android API level from ticket logs Extracts sdkInt, osVersion, and model from flutter.log's "Device info" line. Creates an AVD with the matching API level (e.g. "lantern-api36" for a user on Android 16/SDK 36). Falls back to API 35 if the target image isn't available. Example for ticket #172722 (Android 16, SM-A556B): Creates lantern-api35 (API 36 clamped to 35), installs matching APK, pushes user's exact config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * android-reproduce: dynamically find closest available API image Instead of hardcoding a fallback to API 35, step down from the user's sdkInt until we find an installable Google APIs image. Each API level gets its own AVD (lantern-api29, lantern-api34, etc.) that persists across runs, building up a catalog over time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * address review: install-before-push, fix eval injection, f-string, file search - Install APK + launch once before pushing configs (so data dir exists) - Replace eval with mapfile for device info extraction (no shell injection) - Fix f-string syntax error in locations display - Search both ticket-dir and config-dir for servers.json/split-tunnel.json - Remove unused SCRIPT_DIR - Update android-test header to document auto-AVD-creation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Companion to #8678. The refactor branch still pins v1.12.19-lantern, which is missing the non-fatal-rule-set-fetch fix (sing-box-minimal 9c79c311, shipped in v1.12.21-lantern). Without it, Android builds from this branch hit the same bootstrap deadlock.
There was a problem hiding this comment.
Pull request overview
This PR forwards RADIANCE_* environment variables from the macOS/iOS host app into the sandboxed NetworkExtension process by serializing them as JSON and threading that string through the NETunnelProvider options and into lantern-core/radiance backend options.
Changes:
- Add
utils.Opts.EnvOverrides(JSON string) for carryingRADIANCE_*overrides through gomobile. - Pass
netEx.EnvOverridesfrom the host app VPN managers into the extension providers and attach it toUtilsOptson each gomobile call. - Decode the JSON on the Go side and plumb it into
backend.Options.EnvOverrides.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| macos/Runner/VPN/VPNManager.swift | Collect/encode RADIANCE_* env vars and include them in startVPNTunnel(options:). |
| ios/Runner/VPN/VPNManager.swift | Same as macOS host app: forward RADIANCE_* as JSON via tunnel start options. |
| macos/PacketTunnel/SingBox/ExtensionProvider.swift | Read netEx.EnvOverrides early and attach it to UtilsOpts for gomobile calls. |
| ios/Tunnel/SingBox/ExtensionProvider.swift | Same as macOS extension: capture/propagate netEx.EnvOverrides into UtilsOpts. |
| lantern-core/utils/common.go | Add EnvOverrides field to utils.Opts (JSON map string). |
| lantern-core/mobile/mobile.go | Parse JSON overrides and pass into backend.Options.EnvOverrides for IPC server backend creation. |
| lantern-core/init_mobile.go | Parse JSON overrides and pass into backend.Options.EnvOverrides for IPC client creation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
* lantern-core: subscribe to config events over IPC (/config/events) The refactor branch removed listenConfigEvents when it was discovered that the in-process events.SubscribeContext no longer worked — the extension's radiance process is where config.NewConfigEvent is emitted, and the host's subscription never fires across processes. Now that the companion radiance PR adds a /config/events SSE endpoint, restore the listener using lc.client.ConfigEvents with the same reconnect-with-backoff pattern listenAutoSelectedEvents uses. Each frame fires notifyFlutter(EventTypeConfig, "") so Flutter's app_event_notifier "config" case resumes driving availableServersProvider.forceFetchAvailableServers() and homeProvider.fetchUserDataIfNeeded() on every config change. Also bumps the radiance pin to the commit that adds the endpoint. Addresses the config-events half of getlantern/engineering#3182. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * lantern-core: update StartBackgroundListeners comment to include config Reflects that listenConfigEvents also starts automatically from initialize, addressing Copilot review on PR #8673. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * add android-reproduce: reproduce Freshdesk tickets on emulator Usage: android-reproduce /tmp/ticket-172722 # auto-downloads APK android-reproduce /tmp/ticket-172722 lantern.apk # uses provided APK After running /analyze-ticket, this script: 1. Extracts country + version from the ticket's config/logs 2. Downloads the matching APK from GitHub releases (gh CLI) 3. Pushes the user's exact config.json, servers.json, split-tunnel.json to the emulator so it gets the same proxies, DNS rules, rule sets 4. Sets RADIANCE_COUNTRY to match the user's region 5. Installs, restarts, and streams filtered logcat This gives near-exact reproduction of Android-specific issues by replicating the user's proxy assignments, country routing, and sing-box config on a local emulator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Adam Fisk <afisk@mini.local>
macOS/iOS system extensions are sandboxed subprocesses launched by launchd, so they don't inherit the shell environment of the main Lantern app. That made it impossible to point the tunnel at staging with `RADIANCE_ENV=staging Lantern` — the main process hit staging, but the extension (which actually fetches /config-new) kept reading its ldflags-baked RADIANCE_VERSION and hitting prod constraints. Wire the main app's RADIANCE_* env through to the extension: - `utils.Opts.EnvOverrides` (JSON string — gomobile doesn't marshal maps) flows shell-set vars into gomobile calls. - `lantern-core/mobile.StartIPCServer` and `lantern-core/init_mobile.createClient` parse the JSON and pass via `backend.Options.EnvOverrides`, which radiance applies with `os.Setenv` before `common.Init` reads them (see radiance PR #429). - Main-app Swift (`macos/Runner/VPN/VPNManager.swift`, `ios/Runner/VPN/VPNManager.swift`) filters `ProcessInfo.environment` for `RADIANCE_*`, serializes as JSON, and attaches it to `startVPNTunnel(options:)` as `netEx.EnvOverrides`. - Extension Swift (`macos/PacketTunnel/...`, `ios/Tunnel/...`) reads `netEx.EnvOverrides` out of `startTunnel(options:)` before `MobileStartIPCServer`, stashes it, and attaches to every subsequent `opts()` call. Requires radiance PR #429. No-op until that lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls in getlantern/radiance#429 (merged to refactor as 97ff9ac): - EnvOverrides on backend.Options (consumed by the Swift→core hop added in the prior commit on this branch) so shell RADIANCE_* vars reach the sandboxed macOS/iOS system extension. - Direct-transport + streaming wrapper for Unbounded signaling — freddie long-polls now bypass the VPN tunnel and skip kindling's non-streamable AMP transport. Also pulls in the transitive bumps that landed alongside on refactor: broflake → caea079, lantern-box → v0.0.70. Pin qpack to v0.5.1 to keep sagernet/quic-go happy (same replace as radiance + lantern-box).
c56d520 to
d7c8d9f
Compare
- Consolidate duplicate EnvOverrides JSON-parse logic into utils.ParseEnvOverrides so both call sites (init_mobile.createClient, mobile.StartIPCServer) drop overrides entirely on parse error rather than applying a partially-populated map. - Implement Opts.LogValue() so slog.Debug/Info calls that pass opts as a value don't emit EnvOverrides' raw JSON body — only the sorted key names are logged, limiting accidental secret disclosure if a sensitive RADIANCE_* var ever ends up in the forwarded set. Addresses Copilot feedback on #8683.
|
Note this only solves this issue on MacOS and iOS. We'd need a followup PR(s) for other platforms. |
Follow-up: unified IPC
|
Pulls in getlantern/radiance#429 (merged to refactor as 97ff9ac), whose VPN-layer changes fix the Unbounded signaling path: - Direct-transport wiring via ContextWithDirectTransport so freddie long-polls bypass the VPN tunnel instead of recursively self-dialing. - streamingRoundTripper sets Accept: text/event-stream on freddie requests so kindling drops its non-streamable AMP transport during the race (AMP would otherwise buffer the long-poll body and leave the broflake consumer FSM stuck on 'No answer for genesis!'). Also pulls the transitive bumps that landed alongside on refactor: broflake → caea079, lantern-box → v0.0.70. Pin qpack to v0.5.1 to keep sagernet/quic-go (v0.52.0-sing-box-mod.3) happy — matches the replace in radiance and lantern-box. The env-propagation work from #429's other commits is only useful once the host-app Swift plumbing in #8683 lands; that PR stays open separately. Nothing in this bump requires or breaks the env-propagation consumer — it's inert until someone populates backend.Options.EnvOverrides.
Summary
macOS/iOS system extensions are sandboxed subprocesses launched by launchd, so they don't inherit the shell environment of the main Lantern app. That made it impossible to point the tunnel at staging with
RADIANCE_ENV=staging Lantern— the main process hit staging, but the extension (which actually fetches/config-new) kept reading its ldflags-bakedRADIANCE_VERSIONand hitting prod constraints.This PR wires the main app's
RADIANCE_*env through to the extension:utils.Opts.EnvOverrides(JSON string — gomobile doesn't marshal maps) carries shell-set vars into gomobile calls.lantern-core/mobile.StartIPCServerandlantern-core/init_mobile.createClientparse the JSON and pass viabackend.Options.EnvOverrides, which radiance applies withos.Setenvbeforecommon.Initreads them.macos/Runner/VPN/VPNManager.swift,ios/Runner/VPN/VPNManager.swift) filtersProcessInfo.environmentforRADIANCE_*, serializes as JSON, and attaches tostartVPNTunnel(options:)asnetEx.EnvOverrides.macos/PacketTunnel/...,ios/Tunnel/...) readsnetEx.EnvOverridesout ofstartTunnel(options:)beforeMobileStartIPCServer, stashes it, and attaches to every subsequentopts()call.Radiance bump (commit d7c8d9f)
Pulls in getlantern/radiance#429 (merged to refactor as 97ff9ac), which adds
backend.Options.EnvOverridesplus the Unbounded signaling fix (direct-transport + streaming wrapper to freddie) and the transitive bumps of broflake and lantern-box. Pinsqpackto v0.5.1 to match radiance and lantern-box.Test plan