diff --git a/lantern-core/mobile/ipc_lifecycle.go b/lantern-core/mobile/ipc_lifecycle.go new file mode 100644 index 0000000000..27707c308d --- /dev/null +++ b/lantern-core/mobile/ipc_lifecycle.go @@ -0,0 +1,240 @@ +package mobile + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/getlantern/radiance/backend" + "github.com/getlantern/radiance/common" + "github.com/getlantern/radiance/common/env" + "github.com/getlantern/radiance/ipc" + + "github.com/getlantern/lantern/lantern-core/utils" +) + +const ipcStartTimeout = 60 * time.Second + +var ( + errIPCLifecycleBusy = errors.New("ipc server lifecycle operation in progress") + errIPCStartCanceled = errors.New("ipc server startup canceled") + + ipcClient atomic.Pointer[ipc.Client] // loopback client for extension process + ipcLifecycle ipcLifecycleState +) + +type ipcLifecycleState struct { + mu sync.Mutex + server *ipc.Server + backend *backend.LocalBackend + starting bool + closing bool + generation uint64 +} + +type ipcResources struct { + server *ipc.Server + backend *backend.LocalBackend +} + +// close shuts down the server and its backend. +func (resources ipcResources) close() { + if resources.server != nil { + _ = resources.server.Close() + } + if resources.backend != nil { + resources.backend.Close() + } +} + +type ipcStartResult struct { + resources ipcResources + err error +} + +// getClient returns an IPC client. It prefers the loopback client created by +// StartIPCServer (extension process), falling back to lanternCore's client +// (main app process). +func getClient() (*ipc.Client, error) { + if client := ipcClient.Load(); client != nil { + return client, nil + } + core, err := getCore() + if err != nil { + return nil, err + } + return core.Client(), nil +} + +// StartIPCServer starts the local IPC server. Once it is running, repeated +// calls are no-ops. +func StartIPCServer(platform utils.PlatformInterface, opts *utils.Opts) error { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { + return struct{}{}, startIPCServer(platform, opts) + }) + return err +} + +// startIPCServer builds the backend, then publishes it if shutdown did not +// happen in the meantime. +func startIPCServer(platform utils.PlatformInterface, opts *utils.Opts) error { + generation, shouldStart, err := beginIPCStart() + if err != nil || !shouldStart { + return err + } + defer finishIPCStart() + + // The backend's config fetcher captures common.GetBaseURL() at + // construction, so the environment must be set before NewLocalBackend. + // SetupRadiance's SetStagingEnv runs too late on Android, where + // StartIPCServer is called first. + if opts.IsStaging() { + env.SetStagingEnv() + } + + startupCtx, cancelStartup := context.WithTimeout(context.Background(), ipcStartTimeout) + defer cancelStartup() + + resources, err := startIPCResources(startupCtx, backend.Options{ + DataDir: opts.DataDir, + LogDir: opts.LogDir, + Locale: opts.Locale, + LogLevel: opts.LogLevel, + DeviceID: opts.Deviceid, + TelemetryConsent: opts.TelemetryConsent, + PlatformInterface: platform, + }) + if err != nil { + return err + } + return publishIPCResources(generation, resources) +} + +// beginIPCStart reserves the lifecycle for one startup attempt. The generation +// tells us whether shutdown happened before that attempt finished. +func beginIPCStart() (generation uint64, shouldStart bool, err error) { + ipcLifecycle.mu.Lock() + defer ipcLifecycle.mu.Unlock() + + if ipcLifecycle.server != nil { + return 0, false, nil + } + if ipcLifecycle.starting || ipcLifecycle.closing { + return 0, false, errIPCLifecycleBusy + } + ipcLifecycle.starting = true + return ipcLifecycle.generation, true, nil +} + +// finishIPCStart releases the startup guard. +func finishIPCStart() { + ipcLifecycle.mu.Lock() + ipcLifecycle.starting = false + ipcLifecycle.mu.Unlock() +} + +// startIPCResources waits for backend setup and cleans up if the result arrives +// after the caller has stopped waiting. +func startIPCResources(startupCtx context.Context, opts backend.Options) (ipcResources, error) { + resultCh := make(chan ipcStartResult) + go func() { + result := newIPCResources(opts) + select { + case resultCh <- result: + case <-startupCtx.Done(): + result.resources.close() + } + }() + + select { + case result := <-resultCh: + return result.resources, result.err + case <-startupCtx.Done(): + return ipcResources{}, fmt.Errorf( + "ipc server startup exceeded %s: %w", + ipcStartTimeout, + startupCtx.Err(), + ) + } +} + +// newIPCResources builds the local backend and starts its IPC server. +func newIPCResources(opts backend.Options) ipcStartResult { + localBackend, err := backend.NewLocalBackend(context.Background(), opts) + if err != nil { + return ipcStartResult{err: fmt.Errorf("error creating backend for IPC server: %w", err)} + } + localBackend.Start() + + server := ipc.NewServer(localBackend, !common.IsMobile()) + if err := server.Start(); err != nil { + localBackend.Close() + return ipcStartResult{err: err} + } + return ipcStartResult{resources: ipcResources{ + server: server, + backend: localBackend, + }} +} + +// publishIPCResources makes a completed startup visible to clients. If +// CloseIPCServer ran during setup, it closes the new resources instead. +func publishIPCResources(generation uint64, resources ipcResources) error { + ipcLifecycle.mu.Lock() + if generation != ipcLifecycle.generation { + ipcLifecycle.mu.Unlock() + resources.close() + return errIPCStartCanceled + } + ipcLifecycle.backend = resources.backend + ipcLifecycle.server = resources.server + ipcClient.Store(newLoopbackClient(resources.backend)) + ipcLifecycle.mu.Unlock() + return nil +} + +// CloseIPCServer detaches the current IPC resources before shutting them down. +func CloseIPCServer() error { + _, err := utils.RunOffCgoStack(func() (struct{}, error) { + resources, shouldClose := beginIPCClose() + if !shouldClose { + return struct{}{}, nil + } + defer finishIPCClose() + + resources.close() + return struct{}{}, nil + }) + return err +} + +// beginIPCClose detaches the live resources and invalidates any in-flight start. +func beginIPCClose() (ipcResources, bool) { + ipcLifecycle.mu.Lock() + defer ipcLifecycle.mu.Unlock() + + if ipcLifecycle.closing { + return ipcResources{}, false + } + ipcLifecycle.closing = true + ipcLifecycle.generation++ + ipcClient.Store(nil) + + resources := ipcResources{ + server: ipcLifecycle.server, + backend: ipcLifecycle.backend, + } + ipcLifecycle.server = nil + ipcLifecycle.backend = nil + return resources, true +} + +// finishIPCClose releases the shutdown guard. +func finishIPCClose() { + ipcLifecycle.mu.Lock() + ipcLifecycle.closing = false + ipcLifecycle.mu.Unlock() +} diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index d34a8d3c9e..f49f7d9a5a 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -7,18 +7,14 @@ import ( "fmt" "log/slog" "os" - "sync" "sync/atomic" "time" _ "golang.org/x/mobile/bind" "github.com/getlantern/radiance/account" - "github.com/getlantern/radiance/backend" "github.com/getlantern/radiance/common" - "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/settings" - "github.com/getlantern/radiance/ipc" lanterncore "github.com/getlantern/lantern/lantern-core" "github.com/getlantern/lantern/lantern-core/logs" @@ -29,12 +25,6 @@ import ( var ( lanternCore atomic.Value errLanternNotReady = errors.New("radiance not initialized") - - ipcServer *ipc.Server - ipcClient *ipc.Client // loopback client for extension process - ipcBackend *backend.LocalBackend - ipcMu sync.Mutex - ipcOnce sync.Once ) func getCore() (lanterncore.Core, error) { @@ -73,23 +63,6 @@ func withCoreR[T any](fn func(c lanterncore.Core) (T, error)) (T, error) { }) } -// getClient returns an IPC client. It prefers the loopback client created by -// StartIPCServer (extension process), falling back to lanternCore's client -// (main app process). -func getClient() (*ipc.Client, error) { - ipcMu.Lock() - c := ipcClient - ipcMu.Unlock() - if c != nil { - return c, nil - } - core, err := getCore() - if err != nil { - return nil, err - } - return core.Client(), nil -} - // SetQAEnvOverrides applies QA-only environment overrides before Radiance starts. func SetQAEnvOverrides(outboundSocks, tz string) error { if outboundSocks != "" { @@ -282,63 +255,6 @@ func StopVPN() error { return err } -func StartIPCServer(platform utils.PlatformInterface, opts *utils.Opts) error { - _, err := utils.RunOffCgoStack(func() (struct{}, error) { - ipcMu.Lock() - defer ipcMu.Unlock() - if ipcServer != nil { - return struct{}{}, nil - } - // The backend's config fetcher captures common.GetBaseURL() at - // construction, so the environment must be set before - // NewLocalBackend — SetupRadiance's SetStagingEnv runs too late on - // Android, where StartIPCServer is called first. - if opts.IsStaging() { - env.SetStagingEnv() - } - bopts := backend.Options{ - DataDir: opts.DataDir, - LogDir: opts.LogDir, - Locale: opts.Locale, - LogLevel: opts.LogLevel, - DeviceID: opts.Deviceid, - TelemetryConsent: opts.TelemetryConsent, - PlatformInterface: platform, - } - be, err := backend.NewLocalBackend(context.Background(), bopts) - if err != nil { - return struct{}{}, fmt.Errorf("error creating backend for IPC server: %v", err) - } - be.Start() - ipcBackend = be - ipcServer = ipc.NewServer(be, !common.IsMobile()) - ipcClient = newLoopbackClient(be) - if err := ipcServer.Start(); err != nil { - return struct{}{}, err - } - return struct{}{}, nil - }) - return err -} - -func CloseIPCServer() error { - _, err := utils.RunOffCgoStack(func() (struct{}, error) { - ipcMu.Lock() - defer ipcMu.Unlock() - if ipcBackend != nil { - ipcBackend.Close() - ipcBackend = nil - } - if ipcServer != nil { - ipcServer.Close() - ipcServer = nil - } - ipcClient = nil - return struct{}{}, nil - }) - return err -} - // IsTagAvailable checks if a server with the given tag exists in the server list. // Returns true if the tag is found. Returns true when the check cannot be performed // (fail-open: allows connection attempts to proceed normally). diff --git a/lantern-core/mobile/mobile_test.go b/lantern-core/mobile/mobile_test.go index db752f92ae..9c39fe57cd 100644 --- a/lantern-core/mobile/mobile_test.go +++ b/lantern-core/mobile/mobile_test.go @@ -1,86 +1,58 @@ package mobile -// // todo implement a mock for all test cases -// func radianceOptions() radiance.Options { -// return radiance.Options{ -// DataDir: os.TempDir(), -// LogDir: os.TempDir(), -// DeviceID: "test-123", -// Locale: "en-us", -// } -// } - -// func TestSetupRadiance(t *testing.T) { -// rr, err := radiance.NewRadiance(radianceOptions()) -// assert.Nil(t, err) -// assert.NotNil(t, rr) - -// } - -// // // skip this test for now -// // func TestStartVPN(t *testing.T) { -// // data := radianceOptions().DataDir -// // log := radianceOptions().LogDir -// // rr, err := client.NewVPNClient(data, log, nil, false) -// // assert.Nil(t, err) -// // assert.NotNil(t, rr) -// // err1 := rr.StartVPN() -// // assert.Nil(t, err1) -// // } - -// func TestCreateUser(t *testing.T) { -// rr, err := radiance.NewRadiance(radianceOptions()) -// api := rr.APIHandler() -// assert.Nil(t, err) -// assert.NotNil(t, rr) -// user, err := api.NewUser(context.Background()) -// assert.Nil(t, err) -// assert.NotNil(t, user) -// } - -// func TestSubscriptionRedirect(t *testing.T) { -// rr, err := radiance.NewRadiance(radianceOptions()) -// apiClient := rr.APIHandler() -// assert.Nil(t, err) -// assert.NotNil(t, rr) -// data := api.PaymentRedirectData{ -// Provider: "stripe", -// Plan: "monthly", -// DeviceName: "test-123", -// Email: "test@getlantern.org", -// BillingType: api.SubscriptionTypeSubscription, -// } -// user, err := apiClient.SubscriptionPaymentRedirectURL(context.Background(), data) -// assert.Nil(t, err) -// assert.NotNil(t, user) -// } - -// func TestUserData(t *testing.T) { -// rr, err := radiance.NewRadiance(radianceOptions()) -// api := rr.APIHandler() -// assert.Nil(t, err) -// assert.NotNil(t, rr) -// user, err := api.UserData(context.Background()) -// assert.Nil(t, err) -// assert.NotNil(t, user) -// } - -// func TestPlans(t *testing.T) { -// rr, err := radiance.NewRadiance(radianceOptions()) -// api := rr.APIHandler() -// assert.Nil(t, err) -// assert.NotNil(t, rr) -// plans, err := api.SubscriptionPlans(context.Background(), "non-store") -// assert.Nil(t, err) -// assert.NotNil(t, plans) -// } - -// func TestOAuthLoginUrl(t *testing.T) { -// rr, err := radiance.NewRadiance(radianceOptions()) -// api := rr.APIHandler() -// assert.Nil(t, err) -// assert.NotNil(t, rr) -// user, err := api.OAuthLoginUrl(context.Background(), "google") -// assert.Nil(t, err) -// assert.NotNil(t, user) -// } +import ( + "errors" + "testing" + "time" + + "github.com/getlantern/radiance/ipc" +) + +func TestGetClientDoesNotWaitForIPCLifecycleLock(t *testing.T) { + want := &ipc.Client{} + previousClient := ipcClient.Swap(want) + t.Cleanup(func() { + ipcClient.Store(previousClient) + }) + + ipcLifecycle.mu.Lock() + defer ipcLifecycle.mu.Unlock() + + result := make(chan *ipc.Client, 1) + go func() { + client, _ := getClient() + result <- client + }() + + select { + case got := <-result: + if got != want { + t.Fatalf("getClient() = %p, want %p", got, want) + } + case <-time.After(time.Second): + t.Fatal("getClient blocked on the IPC lifecycle lock") + } +} + +func TestStartIPCServerReportsLifecycleBusy(t *testing.T) { + ipcLifecycle.mu.Lock() + previousServer := ipcLifecycle.server + previousStarting := ipcLifecycle.starting + previousClosing := ipcLifecycle.closing + ipcLifecycle.server = nil + ipcLifecycle.starting = true + ipcLifecycle.closing = false + ipcLifecycle.mu.Unlock() + t.Cleanup(func() { + ipcLifecycle.mu.Lock() + ipcLifecycle.server = previousServer + ipcLifecycle.starting = previousStarting + ipcLifecycle.closing = previousClosing + ipcLifecycle.mu.Unlock() + }) + + err := StartIPCServer(nil, nil) + if !errors.Is(err, errIPCLifecycleBusy) { + t.Fatalf("StartIPCServer() error = %v, want %v", err, errIPCLifecycleBusy) + } +} diff --git a/lantern-core/vpn_tunnel/vpn_tunnel.go b/lantern-core/vpn_tunnel/vpn_tunnel.go index af32d1f799..4c17c9c26f 100644 --- a/lantern-core/vpn_tunnel/vpn_tunnel.go +++ b/lantern-core/vpn_tunnel/vpn_tunnel.go @@ -15,12 +15,15 @@ const ( InternalTagAutoAll InternalTag = "auto-all" ) -// StartVPN is the gomobile entry point for Mobile.StartVPN (Android -// MainActivity / iOS VPNManager). It is also reached from Jigar's -// onSmartLocation rewrite in server_selection.dart via startVPN(force: true) -// → lantern.startVPN() → Mobile.StartVPN, which expects "switch back to -// auto" to work on a live tunnel. Delegate to ConnectToServer so the -// VPNStatus → /server/selected dispatch handles that case. +var connectSem = make(chan struct{}, 1) + +type vpnClient interface { + VPNStatus(context.Context) (vpn.VPNStatus, error) + ConnectVPN(context.Context, string) error + SelectServer(context.Context, string) error +} + +// StartVPN starts the tunnel with automatic server selection. func StartVPN(ctx context.Context, client *ipc.Client) error { slog.Info("StartVPN called") return ConnectToServer(ctx, client, vpn.AutoSelectTag) @@ -30,16 +33,19 @@ func StopVPN(ctx context.Context, client *ipc.Client) error { return client.DisconnectVPN(ctx) } -// ConnectToServer switches the live tunnel to a specific server or, when the -// caller passes an empty tag or vpn.AutoSelectTag, back to auto-select. -// Radiance normalizes the empty-tag case server-side (fac9089) for both -// ConnectVPN and SelectServer. -// -// The caller is responsible for putting a deadline on ctx — the connect -// path involves real network work (DNS, TLS, sing-box bring-up) and we -// don't want a hung lanternd to stall the UI forever. LanternCore.ConnectVPN -// uses 60 s. +// ConnectToServer starts the tunnel or changes the selected server. func ConnectToServer(ctx context.Context, client *ipc.Client, tag string) error { + return connectToServer(ctx, client, tag) +} + +func connectToServer(ctx context.Context, client vpnClient, tag string) error { + select { + case connectSem <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { <-connectSem }() + slog.Debug("Connecting to VPN server", "tag", tag) // Switch outbounds on the live tunnel when already connected; diff --git a/lantern-core/vpn_tunnel/vpn_tunnel_test.go b/lantern-core/vpn_tunnel/vpn_tunnel_test.go index e5cf11f800..8b13eb4781 100644 --- a/lantern-core/vpn_tunnel/vpn_tunnel_test.go +++ b/lantern-core/vpn_tunnel/vpn_tunnel_test.go @@ -1,17 +1,114 @@ package vpn_tunnel -// func TestStartVPN(t *testing.T) { -// radiance.NewRadiance(radianceOptions()) -// pltf := stub.NewPlatformInterfaceStub() -// err := StartVPN(pltf, &utils.Opts{}) -// assert.NoError(t, err) -// } - -// func radianceOptions() radiance.Options { -// return radiance.Options{ -// DataDir: os.TempDir(), -// LogDir: os.TempDir(), -// DeviceID: "test-123", -// Locale: "en-us", -// } -// } +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/getlantern/radiance/vpn" +) + +type blockingClient struct { + statusCalls atomic.Int32 + connectCalls atomic.Int32 + statusCalled chan struct{} + release chan struct{} + releaseOnce sync.Once +} + +func (c *blockingClient) VPNStatus(ctx context.Context) (vpn.VPNStatus, error) { + call := c.statusCalls.Add(1) + c.statusCalled <- struct{}{} + if call == 1 { + select { + case <-c.release: + case <-ctx.Done(): + return vpn.Disconnected, ctx.Err() + } + } + return vpn.Disconnected, nil +} + +func (c *blockingClient) ConnectVPN(context.Context, string) error { + c.connectCalls.Add(1) + return nil +} + +func (c *blockingClient) SelectServer(context.Context, string) error { + return nil +} + +func (c *blockingClient) unblock() { + c.releaseOnce.Do(func() { close(c.release) }) +} + +func TestConnectToServerSerializesRequests(t *testing.T) { + client := &blockingClient{ + statusCalled: make(chan struct{}, 2), + release: make(chan struct{}), + } + t.Cleanup(client.unblock) + + firstDone := make(chan error, 1) + go func() { + firstDone <- connectToServer(context.Background(), client, "first") + }() + waitForStatusCall(t, client.statusCalled) + + secondDone := make(chan error, 1) + secondCtx, cancelSecond := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancelSecond() + go func() { + secondDone <- connectToServer(secondCtx, client, "second") + }() + + select { + case <-client.statusCalled: + t.Fatal("second request entered while the first was still running") + case <-time.After(100 * time.Millisecond): + } + + if err := waitForError(t, secondDone); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("second request error = %v, want %v", err, context.DeadlineExceeded) + } + + client.unblock() + waitForResult(t, firstDone) + + if err := connectToServer(context.Background(), client, "third"); err != nil { + t.Fatal(err) + } + if got := client.connectCalls.Load(); got != 2 { + t.Fatalf("ConnectVPN called %d times, want 2", got) + } +} + +func waitForStatusCall(t *testing.T, called <-chan struct{}) { + t.Helper() + select { + case <-called: + case <-time.After(time.Second): + t.Fatal("timed out waiting for VPNStatus") + } +} + +func waitForResult(t *testing.T, result <-chan error) { + t.Helper() + if err := waitForError(t, result); err != nil { + t.Fatal(err) + } +} + +func waitForError(t *testing.T, result <-chan error) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(time.Second): + t.Fatal("timed out waiting for connect request") + return nil + } +} diff --git a/macos/Runner/VPN/VPNBase.swift b/macos/Runner/VPN/VPNBase.swift index 4c43eff841..e8166b4dda 100644 --- a/macos/Runner/VPN/VPNBase.swift +++ b/macos/Runner/VPN/VPNBase.swift @@ -3,13 +3,159 @@ // Lantern // +import Foundation import NetworkExtension -enum VPNManagerError: Swift.Error { +enum VPNManagerError: LocalizedError, Equatable { case userDisallowedVPNConfigurations case loadingProviderFailed case savingProviderFailed + case operationInProgress case unknown + + var errorDescription: String? { + switch self { + case .userDisallowedVPNConfigurations: + return "VPN configurations are not allowed." + case .loadingProviderFailed: + return "Unable to load the VPN configuration." + case .savingProviderFailed: + return "Unable to save the VPN configuration." + case .operationInProgress: + return "A VPN operation is already in progress." + case .unknown: + return "An unknown VPN error occurred." + } + } +} + +/// Coordinates connection changes while allowing stop to cancel a pending start. +actor VPNLifecycleCoordinator { + private struct StopWaiter { + let connectionID: UInt + let continuation: CheckedContinuation + } + + private var nextConnectionID: UInt = 0 + private var activeConnectionID: UInt? + private var stopPending = false + private var stopWaiter: StopWaiter? + private var stopHandoffTimeoutTask: Task? + private let stopHandoffTimeoutNanoseconds: UInt64 + + init(stopHandoffTimeoutNanoseconds: UInt64 = 5_000_000_000) { + self.stopHandoffTimeoutNanoseconds = stopHandoffTimeoutNanoseconds + } + + /// Starts a connection operation unless another lifecycle change owns the manager. + func beginConnectionOperation() throws -> UInt { + guard activeConnectionID == nil, !stopPending else { + throw VPNManagerError.operationInProgress + } + nextConnectionID &+= 1 + activeConnectionID = nextConnectionID + return nextConnectionID + } + + /// Returns false when a stop request has canceled this connection operation. + func canContinueConnectionOperation(_ id: UInt) -> Bool { + activeConnectionID == id && !stopPending + } + + /// Performs the final synchronous connection transition while this actor + /// still owns the lifecycle decision, so a stop cannot interleave between + /// the cancellation check and starting the system tunnel. + func performFinalConnectionTransition( + _ id: UInt, + _ transition: () throws -> T + ) throws -> T { + guard activeConnectionID == id, !stopPending else { + throw VPNManagerError.operationInProgress + } + return try transition() + } + + /// Hands the manager to a waiting stop request after startup work has finished. + func endConnectionOperation(_ id: UInt) { + guard activeConnectionID == id else { return } + activeConnectionID = nil + finishStopHandoff(for: id) + } + + /// Cancels any active connection operation and briefly waits for it to hand off the manager. + /// The return value tells the caller to tear down even if the system status has not caught up. + func beginStopOperation() async throws -> Bool { + guard !stopPending else { + throw VPNManagerError.operationInProgress + } + stopPending = true + guard let connectionID = activeConnectionID else { return false } + let timeout = stopHandoffTimeoutNanoseconds + + await withCheckedContinuation { continuation in + stopWaiter = StopWaiter(connectionID: connectionID, continuation: continuation) + stopHandoffTimeoutTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: timeout) + } catch { + return + } + await self?.expireStopHandoff(for: connectionID) + } + } + return true + } + + /// Allows connection changes again once the stop request has completed. + func endStopOperation() { + stopPending = false + } + + private func finishStopHandoff(for connectionID: UInt) { + guard let waiter = stopWaiter, waiter.connectionID == connectionID else { return } + stopWaiter = nil + stopHandoffTimeoutTask?.cancel() + stopHandoffTimeoutTask = nil + waiter.continuation.resume() + } + + private func expireStopHandoff(for connectionID: UInt) { + guard let waiter = stopWaiter, waiter.connectionID == connectionID else { return } + stopWaiter = nil + stopHandoffTimeoutTask = nil + if activeConnectionID == connectionID { + activeConnectionID = nil + } + waiter.continuation.resume() + } +} + +/// Returns whether a new tunnel should start for the current system status. +func shouldStartNewTunnel(for status: NEVPNStatus) throws -> Bool { + switch status { + case .connected: + return false + case .disconnected: + return true + case .connecting, .disconnecting, .reasserting: + throw VPNManagerError.operationInProgress + case .invalid: + throw VPNManagerError.loadingProviderFailed + @unknown default: + throw VPNManagerError.unknown + } +} + +/// Returns whether the current tunnel should be stopped. +func shouldStopTunnel(for status: NEVPNStatus) throws -> Bool { + switch status { + case .connected, .connecting, .reasserting: + return true + case .disconnected, .disconnecting, .invalid: + return false + @unknown default: + throw VPNManagerError.unknown + } } protocol VPNBase: ObservableObject { diff --git a/macos/Runner/VPN/VPNManager.swift b/macos/Runner/VPN/VPNManager.swift index d7f14ba972..1fff09905e 100644 --- a/macos/Runner/VPN/VPNManager.swift +++ b/macos/Runner/VPN/VPNManager.swift @@ -9,6 +9,7 @@ import NetworkExtension class VPNManager: VPNBase { private var observer: NSObjectProtocol? + private let lifecycleCoordinator = VPNLifecycleCoordinator() //Do not switch to NEVPNManager.shared() that is only for class app extension private var manager: NEVPNManager = NETunnelProviderManager() static let shared: VPNManager = VPNManager() @@ -135,68 +136,118 @@ class VPNManager: VPNBase { /// Starts the VPN tunnel. /// Loads VPN preferences and initiates the VPN connection. func startTunnel() async throws { + try await withConnectionOperation { operationID in + try await startTunnel(operationID: operationID) + } + } + + private func startTunnel(operationID: UInt) async throws { appLogger.log("Starting tunnel..") await self.setupVPN() + guard await lifecycleCoordinator.canContinueConnectionOperation(operationID) else { + throw VPNManagerError.operationInProgress + } let options = ["netEx.StartReason": NSString("Lantern")] appLogger.log("Calling manager.connection.startVPNTunnel..") - if manager.connection.status == .connected || manager.connection.status == .connecting { + if try !shouldStartNewTunnel(for: manager.connection.status) { appLogger.info("VPN is already connected, sending command to extension") - do { - let result = try await triggerExtensionMethod( - methodName: "Lantern" - ) - return - } catch { - // Rethrow so caller can handle it - throw error - } + _ = try await triggerExtensionMethod(methodName: "Lantern") + return } - try self.manager.connection.startVPNTunnel(options: options) self.manager.isOnDemandEnabled = false try await self.saveThenLoadProvider() + try await startOrNotifyExistingTunnel( + operationID: operationID, + options: options, + methodName: "Lantern" + ) } func connectToServer( serverName: String ) async throws { + try await withConnectionOperation { operationID in + try await connectToServer(serverName: serverName, operationID: operationID) + } + } + + private func connectToServer(serverName: String, operationID: UInt) async throws { await self.setupVPN() + guard await lifecycleCoordinator.canContinueConnectionOperation(operationID) else { + throw VPNManagerError.operationInProgress + } let options: [String: NSObject] = [ "netEx.Type": "PrivateServer" as NSString, "netEx.StartReason": "Private server Initiated" as NSString, "netEx.ServerName": serverName as NSString, ] - if manager.connection.status == .connected || manager.connection.status == .connecting { - appLogger.info("VPN is already connected, sending command to extension") - do { - let result = try await triggerExtensionMethod( - methodName: "PrivateServer", - params: ["server": serverName] - ) - return - } catch { - // Rethrow so caller can handle it - throw error + try await startOrNotifyExistingTunnel( + operationID: operationID, + options: options, + methodName: "PrivateServer", + params: ["server": serverName] + ) + } + + private func startOrNotifyExistingTunnel( + operationID: UInt, + options: [String: NSObject], + methodName: String, + params: [String: Any] = [:] + ) async throws { + let startedNewTunnel = try await lifecycleCoordinator.performFinalConnectionTransition( + operationID + ) { + if try !shouldStartNewTunnel(for: self.manager.connection.status) { + return false } + try self.manager.connection.startVPNTunnel(options: options) + return true + } + if !startedNewTunnel { + appLogger.info("VPN is already connected, sending command to extension") + _ = try await triggerExtensionMethod(methodName: methodName, params: params) } - - try self.manager.connection.startVPNTunnel(options: options) - /// Enable on-demand to allow automatic reconnections - /// if error it will stuck in infinite loop - // self.manager.isOnDemandEnabled = true - // try await self.saveThenLoadProvider() - } /// Stops the VPN tunnel. /// Terminates the VPN connection and updates the configuration. func stopTunnel() async throws { + let canceledConnectionOperation = try await lifecycleCoordinator.beginStopOperation() + do { + try await stopTunnelAfterHandoff( + canceledConnectionOperation: canceledConnectionOperation) + } catch { + await lifecycleCoordinator.endStopOperation() + throw error + } + await lifecycleCoordinator.endStopOperation() + } + + private func stopTunnelAfterHandoff(canceledConnectionOperation: Bool) async throws { appLogger.log("Stopping tunnel..") + + // A canceled start already owns the current manager. Stop it before any + // preference call can delay teardown again. + if canceledConnectionOperation { + let shouldSaveOnDemandChange = manager.isOnDemandEnabled + manager.isOnDemandEnabled = false + manager.connection.stopVPNTunnel() + if shouldSaveOnDemandChange { + try await manager.saveToPreferences() + } + appLogger.log("Tunnel stopped.") + return + } + await syncStatus() - guard connectionStatus == .connected else { - appLogger.log("In unexpected state: \(connectionStatus)") + let status = manager.connection.status + let shouldStop = try shouldStopTunnel(for: status) + if !shouldStop { + appLogger.log("VPN is already stopped or stopping: \(status)") return } @@ -209,6 +260,20 @@ class VPNManager: VPNBase { appLogger.log("Tunnel stopped.") } + private func withConnectionOperation( + _ operation: (UInt) async throws -> T + ) async throws -> T { + let operationID = try await lifecycleCoordinator.beginConnectionOperation() + do { + let result = try await operation(operationID) + await lifecycleCoordinator.endConnectionOperation(operationID) + return result + } catch { + await lifecycleCoordinator.endConnectionOperation(operationID) + throw error + } + } + /// Saves the current VPN configuration to preferences and reloads it. private func saveThenLoadProvider() async throws { try await self.manager.saveToPreferences() diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 69cf3b3491..27a4530c41 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -1,10 +1,130 @@ -@testable import Lantern import Foundation +import NetworkExtension import SystemExtensions import XCTest +@testable import Lantern + final class RunnerTests: XCTestCase { + private func assertVPNManagerError( + _ expected: VPNManagerError, + _ operation: () throws -> Void + ) { + XCTAssertThrowsError(try operation()) { error in + XCTAssertEqual(error as? VPNManagerError, expected) + } + } + + func testVPNStartStates() throws { + XCTAssertTrue(try shouldStartNewTunnel(for: .disconnected)) + XCTAssertFalse(try shouldStartNewTunnel(for: .connected)) + + for status in [NEVPNStatus.connecting, .disconnecting, .reasserting] { + XCTAssertThrowsError(try shouldStartNewTunnel(for: status)) { error in + guard let vpnError = error as? VPNManagerError, + case .operationInProgress = vpnError + else { + return XCTFail("Expected operationInProgress for \(status), got \(error)") + } + } + } + + assertVPNManagerError(.loadingProviderFailed) { + _ = try shouldStartNewTunnel(for: .invalid) + } + } + + func testVPNStopStates() throws { + for status in [NEVPNStatus.connected, .connecting, .reasserting] { + XCTAssertTrue(try shouldStopTunnel(for: status)) + } + for status in [NEVPNStatus.disconnected, .disconnecting, .invalid] { + XCTAssertFalse(try shouldStopTunnel(for: status)) + } + } + + func testVPNLifecycleCoordinatorRejectsOverlappingConnections() async throws { + let coordinator = VPNLifecycleCoordinator() + let operationID = try await coordinator.beginConnectionOperation() + + do { + _ = try await coordinator.beginConnectionOperation() + XCTFail("Expected operationInProgress") + } catch { + XCTAssertEqual(error as? VPNManagerError, .operationInProgress) + } + + await coordinator.endConnectionOperation(operationID) + let nextOperationID = try await coordinator.beginConnectionOperation() + await coordinator.endConnectionOperation(nextOperationID) + } + + func testVPNStopCancelsStartupAndWaitsForHandoff() async throws { + let coordinator = VPNLifecycleCoordinator() + let operationID = try await coordinator.beginConnectionOperation() + + let stopTask = Task { + return try await coordinator.beginStopOperation() + } + + let deadline = Date().addingTimeInterval(1) + while await coordinator.canContinueConnectionOperation(operationID) { + if Date() >= deadline { + await coordinator.endConnectionOperation(operationID) + _ = try await stopTask.value + await coordinator.endStopOperation() + XCTFail("Stop request did not take ownership of the manager") + return + } + await Task.yield() + } + do { + try await coordinator.performFinalConnectionTransition(operationID) { + XCTFail("Canceled connection transition must not run") + } + XCTFail("Expected operationInProgress for the canceled transition") + } catch { + XCTAssertEqual(error as? VPNManagerError, .operationInProgress) + } + await coordinator.endConnectionOperation(operationID) + let canceledConnectionOperation = try await stopTask.value + XCTAssertTrue(canceledConnectionOperation) + + do { + _ = try await coordinator.beginConnectionOperation() + XCTFail("Expected operationInProgress while stop owns the manager") + } catch { + XCTAssertEqual(error as? VPNManagerError, .operationInProgress) + } + + await coordinator.endStopOperation() + let nextOperationID = try await coordinator.beginConnectionOperation() + await coordinator.endConnectionOperation(nextOperationID) + } + + func testVPNStopForcesHandoffAfterTimeout() async throws { + let coordinator = VPNLifecycleCoordinator(stopHandoffTimeoutNanoseconds: 10_000_000) + let operationID = try await coordinator.beginConnectionOperation() + let stopFinished = expectation(description: "Stop handoff finished") + + let stopTask = Task { + let result = try await coordinator.beginStopOperation() + stopFinished.fulfill() + return result + } + await fulfillment(of: [stopFinished], timeout: 1) + let canceledConnectionOperation = try await stopTask.value + XCTAssertTrue(canceledConnectionOperation) + let canContinue = await coordinator.canContinueConnectionOperation(operationID) + XCTAssertFalse(canContinue) + + await coordinator.endConnectionOperation(operationID) + await coordinator.endStopOperation() + let nextOperationID = try await coordinator.beginConnectionOperation() + await coordinator.endConnectionOperation(nextOperationID) + } + func testHashBundleIsStableForIdenticalContents() throws { let firstURL = try createExtensionBundle( name: "First.systemextension",