Skip to content

env: forward shell RADIANCE_* vars from main app to system extension#8683

Open
myleshorton wants to merge 70 commits intomainfrom
fisk/env-propagation-to-extension
Open

env: forward shell RADIANCE_* vars from main app to system extension#8683
myleshorton wants to merge 70 commits intomainfrom
fisk/env-propagation-to-extension

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

@myleshorton myleshorton commented Apr 20, 2026

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-baked RADIANCE_VERSION and 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.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.
  • Main-app Swift (macos/Runner/VPN/VPNManager.swift, ios/Runner/VPN/VPNManager.swift) filters ProcessInfo.environment for RADIANCE_*, serializes as JSON, and attaches 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.

Radiance bump (commit d7c8d9f)

Pulls in getlantern/radiance#429 (merged to refactor as 97ff9ac), which adds backend.Options.EnvOverrides plus the Unbounded signaling fix (direct-transport + streaming wrapper to freddie) and the transitive bumps of broflake and lantern-box. Pins qpack to v0.5.1 to match radiance and lantern-box.

Test plan

  • `go build ./...` passes
  • End-to-end verified 2026-04-21: desktop Lantern built against this branch + radiance Dashboard never comes up and no message appears in IE6 #429 connects through widget → egress; consumer state progresses to signaling-complete, datachannel opens, traffic flows.
  • Build Lantern, launch with `RADIANCE_VERSION=9.1.1 RADIANCE_ENV=staging Lantern`, verify system extension's `X-Lantern-App-Version` header is 9.1.1 and traffic hits `api.staging.iantem.io`
  • iOS parity

garmr-ulfr and others added 30 commits March 24, 2026 16:15
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.
jigar-f and others added 11 commits April 15, 2026 18:31
* 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.
Copilot AI review requested due to automatic review settings April 20, 2026 11:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 carrying RADIANCE_* overrides through gomobile.
  • Pass netEx.EnvOverrides from the host app VPN managers into the extension providers and attach it to UtilsOpts on 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.

Comment thread lantern-core/init_mobile.go Outdated
Comment thread lantern-core/utils/common.go
jigar-f and others added 8 commits April 21, 2026 19:33
* 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).
@myleshorton myleshorton force-pushed the fisk/env-propagation-to-extension branch from c56d520 to d7c8d9f Compare April 21, 2026 19:59
- 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.
@myleshorton
Copy link
Copy Markdown
Contributor Author

Note this only solves this issue on MacOS and iOS. We'd need a followup PR(s) for other platforms.

@myleshorton
Copy link
Copy Markdown
Contributor Author

Follow-up: unified IPC /bootstrap mechanism

Tracking in getlantern/engineering#3251.

This PR ships env propagation for macOS and iOS only because those are the sandboxed-system-extension platforms that actually need it today. It leaves gaps on Windows/Linux (lanternd runs as a service and ignores opts.EnvOverrides on the desktop path) and Android (backend plumbing compiled in, but no Kotlin/Java code populates it).

A file-based unified mechanism was considered and rejected — on macOS the main app and the network extension run as different users with incompatible sandbox restrictions, so a shared file is fragile.

The follow-up epic proposes replacing the Swift + gomobile + utils.Opts.EnvOverrides stack with a single IPC /bootstrap handshake that every platform uses identically:

Phase 1: daemon/extension opens IPC socket, blocks common.Init
Phase 2: GUI sends POST /bootstrap { env_overrides: {...} }
         → daemon applies os.Setenv → common.Init → ready

Merging this PR first unblocks the staging workflow immediately; the unified path lands as its own series on top.

@myleshorton
Copy link
Copy Markdown
Contributor Author

FYI the radiance bump has been split out into #8686 so the Unbounded signaling fix can land without waiting on the env-propagation design discussion. This PR (#8683) continues to carry just the Swift/gomobile env-forwarding plumbing; it can be merged or left open independently of #8686.

myleshorton added a commit that referenced this pull request Apr 22, 2026
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.
Base automatically changed from garmr/radiance-daemon-refactor to main April 28, 2026 18:20
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.

6 participants