Skip to content

About

A package that allow you to connect OpenVPN service with Flutter

Topics

Resources

Stars

115 stars

Watchers

2 watching

Forks

Repository files navigation

openvpn_flutter

pub package License: GPL v3

Connect to OpenVPN servers from Flutter apps on Android, iOS and macOS. Contributions through issues and pull requests are highly appreciated!

  • Android: a bundled OpenVPN 2.7 engine (ics-openvpn, OpenSSL 3.5) with 16 KB page size support required by Google Play, per-app VPN (allow or bypass apps), a connection notification and no MainActivity changes.
  • iOS and macOS: a NetworkExtension packet tunnel running OpenVPN 3 in your app's extension, with the same Dart API.
  • One API everywhere: stage, traffic and log streams, an awaitable connect() and disconnect(), error reasons, session timeouts and encrypted private keys.
Android iOS macOS
Minimum version 5.0 (API 21) 12.0 10.15
Engine OpenVPN 2.7, bundled OpenVPN 3 (OpenVPNAdapter) in your Network Extension same as iOS
Detailed stages yes connecting / connected / reconnect / disconnecting same as iOS
Per-app VPN allowedPackages, bypassPackages – –
Session timeout yes yes (with the provided extension code) same as iOS
Engine log yes yes (with the provided extension code) same as iOS

Flutter 3.10 / Dart 3.0 or newer.

Contents

Installation

flutter pub add openvpn_flutter

Android setup

1. Extract native libraries (required)

OpenVPN runs as its own process from a native library, so the library must be extracted when the app is installed. Recent Android Gradle Plugin versions do not extract native libraries unless the app asks for it, and the VPN then stops right after connecting ("OpenVPN process exited"). Add this to the android block of android/app/build.gradle.kts:

android {
    packaging {
        jniLibs {
            useLegacyPackaging = true
        }
    }
}

or, in android/app/build.gradle:

android {
    packaging {
        jniLibs {
            useLegacyPackaging = true
        }
    }
}

This covers APKs and App Bundles (Google Play). Without it, connect() fails with a native_libraries_not_extracted error that says the same, and the Gradle build prints a warning.

2. Notification permission

The VPN runs as a foreground service with a notification. On Android 13+ ask for the notification permission, for example with permission_handler:

await Permission.notification.request();

The VPN works without it, but the notification is then hidden.

3. Publishing on Google Play

  • Fill in the VPN declaration in the Play Console (VpnService).

  • Declare the systemExempted foreground service type (used for VPN apps on Android 14+) in the Play Console's foreground service declaration.

  • Only ship the ABIs you need to make the app smaller:

    android {
        defaultConfig {
            ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
        }
    }

Optional: the notification

  • Icon: add your own android/app/src/main/res/drawable/ic_notification.png (or .xml); it replaces the engine's icon.
  • Title: the name you pass to connect().
  • "Disconnect" button label: override the cancel_connection string in android/app/src/main/res/values/strings.xml.
  • initialize(showTrafficInNotification: false) keeps the notification static instead of refreshing it with the traffic every two seconds (some launchers show that as a blinking icon).

A foreground service cannot run without a notification, so it cannot be hidden.

Optional: dependency resolution

The engine is downloaded from JitPack. The plugin adds that repository to the project. If your settings.gradle(.kts) uses dependencyResolutionManagement with RepositoriesMode.FAIL_ON_PROJECT_REPOS, add it there:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

Nothing else is needed: the plugin asks for the VPN permission itself, so the onActivityResult / connectWhileGranted code of 1.x can be removed from your MainActivity (it is harmless if it stays).

iOS setup

VPN apps on iOS run the VPN in a Network Extension. You add one to your app and put the provided PacketTunnelProvider.swift in it. VPN extensions do not run in the simulator: test on a device.

1. Capabilities

Add the App Groups and Network Extensions (Packet Tunnel) capabilities to the Runner target:

2. Network Extension target

In Xcode, File → New → Target… → Network Extension, name it (for example) VPNExtension, provider type Packet Tunnel:

Give the extension the same two capabilities and the same App Group:

Set the extension's Minimum Deployments to the same iOS version as Runner. An extension that requires a newer iOS than the device fails with "Update required" in the system VPN settings.

3. OpenVPNAdapter

The extension runs OpenVPN with OpenVPNAdapter. With CocoaPods, add a top-level target to ios/Podfile (not nested inside target 'Runner', or the extension inherits every Flutter plugin and fails to link):

target 'VPNExtension' do
  use_frameworks!
  pod 'OpenVPNAdapter', :git => 'https://github.com/ss-abramchuk/OpenVPNAdapter.git', :tag => '0.8.0'
end

Without CocoaPods, add it with Swift Package Manager instead: File → Add Package Dependencies…, https://github.com/ss-abramchuk/OpenVPNAdapter.git, version 0.8.0, added to the VPNExtension target.

4. Packet tunnel provider

Replace VPNExtension/PacketTunnelProvider.swift with PacketTunnelProvider.swift. It reads what the plugin sends (configuration, credentials, private key password, session end), answers status requests and writes the OpenVPN log and errors to the App Group so the app can show them.

Writing your own provider (another OpenVPN engine)

The plugin does not depend on OpenVPNAdapter; any packet tunnel provider that follows this contract works (TunnelKit/Partout, a fork, ...):

  • protocolConfiguration.providerConfiguration holds config (the .ovpn content, Data), groupIdentifier, username, password and privateKeyPassword (Data, may be empty), name (String) and expireAt (NSNumber, milliseconds since 1970, optional). startTunnel(options:) receives username, password, privateKeyPassword and expireAt too.
  • handleAppMessage receives OPENVPN_STATS and answers JSON with connected_on (milliseconds), byte_in, byte_out, packets_in, packets_out.
  • Log lines go to <App Group container>/openvpn_flutter/vpn.log, one per line: <milliseconds>\t<level>\t<message>.
  • The last fatal error goes to the App Group's UserDefaults under openvpn_flutter.lastError.

5. Build phase order

In the Runner target's Build Phases, drag Embed Foundation Extensions (or "Embed App Extensions") above Thin Binary and Run Script. Otherwise Xcode reports "Cycle inside Runner; building could produce unreliable results".

6. Initialize with your identifiers

Pass the App Group and the extension's bundle identifier to initialize(), see Usage.

macOS setup

macOS uses the same NETunnelProviderManager API, so the Dart side and the PacketTunnelProvider.swift above are the same. Complete the iOS setup first, then apply the macOS-specific steps below. What differs is how the provider is packaged:

Distribution Provider is a Entitlement value (com.apple.developer.networking.networkextension)
Mac App Store App Extension (.appex) packet-tunnel-provider
Developer ID (outside the store) System Extension packet-tunnel-provider-systemextension (Developer ID profile), packet-tunnel-provider while developing

1. Choose the extension type

Use a Packet Tunnel App Extension for Mac App Store distribution. Use a Packet Tunnel System Extension for a Developer ID build distributed outside the store. The rest of this section describes the System Extension setup, which is the configuration used by a notarized, directly distributed app.

2. Configure both targets and identifiers

In Xcode, give Runner and VPNExtension the same Development Team. Use a unique extension bundle identifier, for example com.example.app.VPNExtension, and pass that exact identifier as providerBundleIdentifier to initialize().

For a System Extension, check these target settings:

  • Product Type is System Extension and the built wrapper ends in .systemextension (WRAPPER_EXTENSION = systemextension).
  • Skip Install is YES, Hardened Runtime is enabled, and the macOS deployment target is no newer than Runner's deployment target.
  • Runner has a target dependency on VPNExtension and an Embed System Extensions copy-files phase whose destination is Contents/Library/SystemExtensions. Put that phase before Flutter's Thin Binary and Run Script phases to avoid a dependency cycle.
  • Every Debug, Profile and Release configuration selects the intended signing identity, provisioning profile and entitlements file. Do not leave Release on an empty entitlements file unless a release pipeline deliberately stamps those entitlements before signing.

3. Add capabilities and provisioning

Enable App Groups, Network Extensions → Packet Tunnel, and System Extension on Runner. Enable the matching App Group and Network Extension on VPNExtension. The resulting entitlements are conceptually:

<!-- Runner -->
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider</string></array>
<key>com.apple.developer.system-extension.install</key>
<true/>
<key>com.apple.security.application-groups</key>
<array><string>$(TeamIdentifierPrefix)group.com.example.app</string></array>

<!-- VPNExtension during development -->
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider</string></array>
<key>com.apple.security.application-groups</key>
<array><string>$(TeamIdentifierPrefix)group.com.example.app</string></array>

The Developer ID provisioning profile for the provider uses packet-tunnel-provider-systemextension. The entitlements embedded in the signed app, extension and profiles must agree; inspect release artifacts with codesign -d --entitlements :- <path> and security cms -D -i <profile>.

App Group identifiers are Team-ID-prefixed on macOS. Pass the resolved value, such as ABCDE12345.group.com.example.app, to initialize(groupIdentifier:). Passing only group.com.example.app can put the app and provider in different containers, leaving shared logs and status empty.

4. Configure the System Extension entry point and Info.plist

The System Extension is a standalone executable. Add this main.swift:

import Foundation
import NetworkExtension

autoreleasepool {
    NEProvider.startSystemExtensionMode()
}

CFRunLoopRun()

Use CFRunLoopRun(), not dispatchMain(). OpenVPNAdapter attaches its inbound packet socket to the main Core Foundation run loop; with dispatchMain() the tunnel can connect and send traffic while never receiving packets.

The extension's Info.plist must use SYSX as CFBundlePackageType, include a user-facing NSSystemExtensionUsageDescription, and declare:

<key>NetworkExtension</key>
<dict>
  <key>NEMachServiceName</key>
  <string>ABCDE12345.group.com.example.app.VPNExtension</string>
  <key>NEProviderClasses</key>
  <dict>
    <key>com.apple.networkextension.packet-tunnel</key>
    <string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
  </dict>
</dict>

NEMachServiceName must contain the real Team ID in the final built plist and must match the value authorized by the signing profile. Write the Team ID out when an unsigned CI build is signed later: $(TeamIdentifierPrefix) is resolved while Xcode builds the plist and can become empty when no provisioning profile is present; signing afterwards does not reprocess the plist.

5. Add OpenVPNAdapter to the provider target

This plugin already ships a Flutter Swift Package at darwin/openvpn_flutter/Package.swift; Flutter consumes it for the Runner plugin. That package does not replace the VPN engine inside your separate provider target.

Prefer Swift Package Manager for the provider: in Xcode select File → Add Package Dependencies…, add https://github.com/ss-abramchuk/OpenVPNAdapter.git at 0.8.0, and attach the OpenVPNAdapter product to VPNExtension only.

If using CocoaPods instead, keep VPNExtension as a top-level Podfile target. CocoaPods may not embed its frameworks into a System Extension automatically; add its generated framework embed script as the extension's final build phase:

"${PODS_ROOT}/Target Support Files/Pods-VPNExtension/Pods-VPNExtension-frameworks.sh"

6. Install and approve the extension

Before creating or starting the VPN configuration, Runner must submit an OSSystemExtensionRequest.activationRequest for the provider bundle identifier. Keep its delegate alive until the request finishes. A request that needs user approval remains in flight even after the UI is updated, so prevent duplicate activation requests.

System extensions are installed only from an application in /Applications. Copy the built app there and launch that copy; a build run from Flutter's build directory or a Gatekeeper-translocated location cannot be activated. The first activation requires approval in System Settings → General → Login Items & Extensions → Network Extensions. Re-submit activation after replacing the extension with a newer build.

7. Sign and verify

For Developer ID distribution, sign from the inside out: OpenVPNAdapter and other nested frameworks, VPNExtension.systemextension with its entitlements, then Runner. Do not use codesign --deep. Verify the finished app before notarization:

codesign --verify --deep --strict --verbose=2 YourApp.app
spctl --assess --type execute --verbose=4 YourApp.app

A System Extension runs as root and may not see the same App Group container as Runner. Provider messages such as OPENVPN_STATS remain the reliable path for live status. Shared-file logs may be unavailable, and lastError() may then only report the error supplied by NetworkExtension on macOS 13 or later.

Usage

Initialize

import 'package:openvpn_flutter/openvpn_flutter.dart';

late final OpenVPN openvpn;

@override
void initState() {
  super.initState();
  openvpn = OpenVPN(
    onVpnStageChanged: (stage, raw) => setState(() => this.stage = stage),
    onVpnStatusChanged: (status) => setState(() => this.status = status),
  );
  openvpn.initialize(
    // iOS and macOS:
    groupIdentifier: 'group.com.example.vpn',
    providerBundleIdentifier: 'com.example.app.VPNExtension',
    localizedDescription: 'Example VPN', // name in the system VPN settings
    // State of a VPN that was already running (app reopened while connected):
    lastStage: (stage) => setState(() => this.stage = stage),
    lastStatus: (status) => setState(() => this.status = status),
  );
}

stageStream, statusStream and logStream report the same as the callbacks; currentStage and currentStatus hold the latest values. Call dispose() when you no longer need the instance (the VPN keeps running).

Connect

try {
  final started = await openvpn.connect(
    ovpnFileContent, // the content of the .ovpn file
    'Japan #1',      // shown in the Android notification
    username: username, // for auth-user-pass configurations
    password: password,
  );
  if (!started) {
    // The user refused the VPN permission (stage: VPNStage.denied).
  }
} on PlatformException catch (e) {
  // Invalid configuration, missing engine, iOS entitlements, ...
  print('${e.code}: ${e.message}');
}

connect() completes once the connection is starting; the stages tell how it goes. To wait for the outcome:

await openvpn.connect(config, 'Japan #1');
final stage = await openvpn.stageStream
    .firstWhere((stage) => stage.isConnected || stage.isDisconnected)
    .timeout(const Duration(seconds: 30), onTimeout: () => openvpn.currentStage);
if (!stage.isConnected) {
  print('Failed: ${await openvpn.lastError()}');
  await openvpn.disconnect(); // stop retrying
}

Other options:

await openvpn.connect(
  config,
  'Japan #1',
  // Encrypted <key>:
  privateKeyPassword: 'key passphrase',
  // Disconnect automatically, even with the app closed (Android enforces it in
  // the VPN service, iOS/macOS in the packet tunnel provider):
  expireAt: DateTime.now().add(const Duration(hours: 1)),
  // Android: only these apps use the VPN...
  allowedPackages: ['com.android.chrome'],
  // ...or every app except these (not both):
  // bypassPackages: ['com.example.banking'],
  // Whether the configuration uses a client certificate; detected when null.
  certIsRequired: null,
);

Calling connect() while connected switches to the new server.

Disconnect

await openvpn.disconnect(); // completes once the tunnel is down

Status

final status = await openvpn.status(); // or currentStatus / statusStream
status.connectedOn; // DateTime?, null while not connected
status.duration;    // '01:02:03'
status.byteIn;      // bytes received, as a string
status.byteOut;     // bytes sent
status.speedIn;     // bytes per second
status.speedOut;

Stages

Stage Meaning
prepare Waiting for the Android VPN permission dialog
vpn_generate_config, resolve, tcp_connect, wait_connection, authenticating, get_config, assign_ip, add_routes Connection progress (Android)
connecting Connecting
connected The tunnel is up
reconnect The connection dropped and is coming back
no_network / paused Waiting for a network / paused while the screen is off (Android)
auth_pending Waiting for extra authentication (Android)
disconnecting, exiting Stopping
disconnected No tunnel
denied The user refused the VPN permission
auth_failed The server rejected the credentials or certificate
error The connection failed or stopped on its own; see lastError()
unknown A stage this version does not know

stage.isConnected, stage.isConnecting and stage.isDisconnected group them.

Logs and errors

final logs = await openvpn.getLogs(); // the engine log so far
openvpn.logStream.listen((log) => print('${log.level.name}: ${log.message}'));
final reason = await openvpn.lastError(); // why the last connection failed

On iOS and macOS the log and errors come from the packet tunnel provider above.

Kill switch and always-on (Android)

Android has these as system settings: "Always-on VPN" and "Block connections without VPN". openvpn.openVpnSettings() opens that screen.

Disconnect when the app is swiped away (Android)

openvpn.initialize(disconnectOnTaskRemoved: true);

Without an activity (Android)

initialize, connect, disconnect, status and the streams also work from background isolates and services (no activity). The VPN permission has to be granted once from the UI first (requestPermissionAndroid() or a connect()).

Configurations with many remotes

final config = await OpenVPN.filteredConfig(ovpnFileContent); // keeps one random remote

Migrating from 1.x

  1. Android: add useLegacyPackaging = true (see Android setup). The onActivityResult / connectWhileGranted code in MainActivity is no longer needed.
  2. connect() returns Future<bool> (false when the permission is refused) and throws PlatformException for invalid configurations: wrap it in try/catch. On Android it used to never complete.
  3. disconnect() returns a Future that completes once the tunnel is down.
  4. New stages: add_routes, reconnect, auth_pending, auth_failed, no_network, paused. Exhaustive switches over VPNStage need them; stages that used to be unknown (like reconnect) now have their own value.
  5. certIsRequired is detected from the configuration when omitted. On Android it no longer appends client-cert-not-required to the configuration.
  6. iOS: replace PacketTunnelProvider.swift with the new one, use OpenVPNAdapter 0.8.0, and give the extension the same deployment target as the app.
  7. VpnStatus.packetsIn/packetsOut are null on Android (they repeated the byte counts); VpnStatus.toJson() encodes connected_on as an ISO 8601 string.
  8. bypassPackages only excludes those apps; it no longer lets every app bypass the VPN on its own.
  9. The Android engine is OpenVPN 2.7 with OpenSSL 3.5. Servers that cannot negotiate ciphers keep working when the configuration names its cipher; see Troubleshooting for BF-CBC and weak certificates.

Troubleshooting

await openvpn.lastError() and await openvpn.getLogs() usually tell what went wrong; the issues below are the frequent ones.

Android: "OpenVPN process exited", "no process running", connects then disconnects, works in debug but not from Google Play. The native libraries are not extracted: add useLegacyPackaging = true (Android setup).

Android: "You must select a User certificate". The configuration has neither a client certificate (<cert>/<key>) nor auth-user-pass. Pass a username and password, or fix the configuration.

"No usable data channel cipher", VPN Gate or OpenVPN 2.3 servers stuck reconnecting. OpenVPN 2.6+ only falls back to the configuration's cipher when told to; the plugin adds data-ciphers-fallback for you on Android. For servers that rely on the old BF-CBC default without a cipher line, add to the configuration:

data-ciphers AES-256-GCM:AES-128-GCM:BF-CBC
data-ciphers-fallback BF-CBC
providers legacy default

"FATAL: could not read Private Key", "private key password verification failed". The <key> is encrypted: pass privateKeyPassword.

auth_failed. Wrong credentials, or the server refused the session (some public servers limit sessions per IP address).

"MissingForegroundServiceTypeException". Fixed since 1.3.4; update.

16 KB page size warnings in Google Play. Fixed in 2.0.0.

iOS: connects and disconnects immediately. Check lastError() and the log, then: the extension's deployment target (see below), the App Group (same in both targets and in initialize()), credentials for auth-user-pass configurations, and options OpenVPN 3 does not support.

iOS: "Update required" in the VPN settings. The extension's deployment target is newer than the device's iOS: lower it to match Runner.

iOS: "permission denied" (NEConfigurationErrorDomain 10). The app lacks the Network Extensions capability or provisioning profile, or runs in the simulator.

iOS: "Cycle inside Runner". Move Embed Foundation Extensions above Thin Binary (iOS setup).

iOS: "Module 'openvpn_flutter' not found". Open Runner.xcworkspace, not Runner.xcodeproj, and run pod install in ios/. With Swift Package Manager on Flutter versions before 3.44, use CocoaPods instead: flutter config --no-enable-swift-package-manager.

iOS: "Undefined symbol" errors (MarketplaceKit, __swift_FORCE_LOAD_...). The extension's Podfile target is nested inside target 'Runner'; make it a top-level target.

WireGuard, IKEv2, Windows, Linux. Not supported; this plugin is for OpenVPN on Android, iOS and macOS.

Licenses

Support

If you appreciate this work, give it a thumbs up or support it with a cup of coffee.

About

A package that allow you to connect OpenVPN service with Flutter

Topics

Resources

Stars

115 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages