react-native-ovpn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/Openvpn.podspec +34 -0
- package/README.md +80 -0
- package/android/build.gradle +98 -0
- package/android/libs/README.md +46 -0
- package/android/libs/ics-openvpn.aar +0 -0
- package/android/src/main/AndroidManifest.xml +54 -0
- package/android/src/main/java/com/openvpn/NotificationHelper.kt +59 -0
- package/android/src/main/java/com/openvpn/OpenvpnEventBus.kt +52 -0
- package/android/src/main/java/com/openvpn/OpenvpnException.kt +6 -0
- package/android/src/main/java/com/openvpn/OpenvpnModule.kt +140 -0
- package/android/src/main/java/com/openvpn/OpenvpnPackage.kt +31 -0
- package/android/src/main/java/com/openvpn/OpenvpnService.kt +248 -0
- package/android/src/main/java/com/openvpn/PermissionLauncher.kt +39 -0
- package/android/src/main/java/com/openvpn/ProfileBuilder.kt +68 -0
- package/android/src/main/res/drawable/ic_vpn_default.xml +10 -0
- package/android/src/main/res/values/strings.xml +6 -0
- package/android/src/test/java/com/openvpn/NotificationHelperTest.kt +49 -0
- package/android/src/test/java/com/openvpn/ProfileBuilderTest.kt +83 -0
- package/app.plugin.js +3 -0
- package/ios/Openvpn-Bridging-Header.h +8 -0
- package/ios/Openvpn.h +5 -0
- package/ios/Openvpn.mm +123 -0
- package/ios/OpenvpnAppGroup.swift +59 -0
- package/ios/OpenvpnConstants.swift +46 -0
- package/ios/OpenvpnEventBridge.swift +58 -0
- package/ios/OpenvpnManager.swift +219 -0
- package/ios/PacketTunnelProvider/Info.plist +31 -0
- package/ios/PacketTunnelProvider/PacketTunnelProvider.swift +199 -0
- package/ios/PacketTunnelProvider/README.md +106 -0
- package/lib/module/NativeOpenvpn.js +5 -0
- package/lib/module/NativeOpenvpn.js.map +1 -0
- package/lib/module/OpenVPNClient.js +185 -0
- package/lib/module/OpenVPNClient.js.map +1 -0
- package/lib/module/errors.js +13 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/reconnect.js +51 -0
- package/lib/module/reconnect.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/plugin/src/android/index.d.ts +5 -0
- package/lib/typescript/plugin/src/android/index.d.ts.map +1 -0
- package/lib/typescript/plugin/src/android/withAndroidAarCheck.d.ts +5 -0
- package/lib/typescript/plugin/src/android/withAndroidAarCheck.d.ts.map +1 -0
- package/lib/typescript/plugin/src/android/withAndroidLegacyPackaging.d.ts +5 -0
- package/lib/typescript/plugin/src/android/withAndroidLegacyPackaging.d.ts.map +1 -0
- package/lib/typescript/plugin/src/android/withAndroidMinSdk.d.ts +5 -0
- package/lib/typescript/plugin/src/android/withAndroidMinSdk.d.ts.map +1 -0
- package/lib/typescript/plugin/src/android/withAndroidNotificationIcon.d.ts +5 -0
- package/lib/typescript/plugin/src/android/withAndroidNotificationIcon.d.ts.map +1 -0
- package/lib/typescript/plugin/src/android/withAndroidPermissions.d.ts +5 -0
- package/lib/typescript/plugin/src/android/withAndroidPermissions.d.ts.map +1 -0
- package/lib/typescript/plugin/src/android/withAndroidService.d.ts +5 -0
- package/lib/typescript/plugin/src/android/withAndroidService.d.ts.map +1 -0
- package/lib/typescript/plugin/src/index.d.ts +6 -0
- package/lib/typescript/plugin/src/index.d.ts.map +1 -0
- package/lib/typescript/plugin/src/ios/index.d.ts +5 -0
- package/lib/typescript/plugin/src/ios/index.d.ts.map +1 -0
- package/lib/typescript/plugin/src/ios/withIosDeploymentTarget.d.ts +5 -0
- package/lib/typescript/plugin/src/ios/withIosDeploymentTarget.d.ts.map +1 -0
- package/lib/typescript/plugin/src/ios/withIosEntitlements.d.ts +5 -0
- package/lib/typescript/plugin/src/ios/withIosEntitlements.d.ts.map +1 -0
- package/lib/typescript/plugin/src/ios/withIosInfoPlist.d.ts +5 -0
- package/lib/typescript/plugin/src/ios/withIosInfoPlist.d.ts.map +1 -0
- package/lib/typescript/plugin/src/types.d.ts +14 -0
- package/lib/typescript/plugin/src/types.d.ts.map +1 -0
- package/lib/typescript/src/NativeOpenvpn.d.ts +41 -0
- package/lib/typescript/src/NativeOpenvpn.d.ts.map +1 -0
- package/lib/typescript/src/OpenVPNClient.d.ts +37 -0
- package/lib/typescript/src/OpenVPNClient.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +9 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +5 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/reconnect.d.ts +23 -0
- package/lib/typescript/src/reconnect.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +41 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +193 -0
- package/plugin/build/android/index.d.ts +4 -0
- package/plugin/build/android/index.js +24 -0
- package/plugin/build/android/withAndroidAarCheck.d.ts +4 -0
- package/plugin/build/android/withAndroidAarCheck.js +60 -0
- package/plugin/build/android/withAndroidLegacyPackaging.d.ts +4 -0
- package/plugin/build/android/withAndroidLegacyPackaging.js +18 -0
- package/plugin/build/android/withAndroidMinSdk.d.ts +4 -0
- package/plugin/build/android/withAndroidMinSdk.js +13 -0
- package/plugin/build/android/withAndroidNotificationIcon.d.ts +4 -0
- package/plugin/build/android/withAndroidNotificationIcon.js +64 -0
- package/plugin/build/android/withAndroidPermissions.d.ts +4 -0
- package/plugin/build/android/withAndroidPermissions.js +30 -0
- package/plugin/build/android/withAndroidService.d.ts +4 -0
- package/plugin/build/android/withAndroidService.js +40 -0
- package/plugin/build/index.d.ts +5 -0
- package/plugin/build/index.js +18 -0
- package/plugin/build/ios/index.d.ts +4 -0
- package/plugin/build/ios/index.js +15 -0
- package/plugin/build/ios/withIosDeploymentTarget.d.ts +4 -0
- package/plugin/build/ios/withIosDeploymentTarget.js +28 -0
- package/plugin/build/ios/withIosEntitlements.d.ts +4 -0
- package/plugin/build/ios/withIosEntitlements.js +15 -0
- package/plugin/build/ios/withIosInfoPlist.d.ts +4 -0
- package/plugin/build/ios/withIosInfoPlist.js +14 -0
- package/plugin/build/types.d.ts +13 -0
- package/plugin/build/types.js +2 -0
- package/src/NativeOpenvpn.ts +46 -0
- package/src/OpenVPNClient.ts +239 -0
- package/src/errors.ts +29 -0
- package/src/index.ts +12 -0
- package/src/reconnect.ts +68 -0
- package/src/types.ts +53 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
package com.openvpn
|
|
2
|
+
|
|
3
|
+
import android.app.NotificationChannel
|
|
4
|
+
import android.app.NotificationManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.graphics.Color
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.os.RemoteException
|
|
10
|
+
import android.util.Log
|
|
11
|
+
import de.blinkt.openvpn.VpnProfile
|
|
12
|
+
import de.blinkt.openvpn.core.ConnectionStatus
|
|
13
|
+
import de.blinkt.openvpn.core.OpenVPNService
|
|
14
|
+
import de.blinkt.openvpn.core.ProfileManager
|
|
15
|
+
import de.blinkt.openvpn.core.VpnStatus
|
|
16
|
+
|
|
17
|
+
private const val TAG = "OpenvpnDbg"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extends [OpenVPNService] to intercept state/byte-count callbacks and forward
|
|
21
|
+
* them to [OpenvpnEventBus].
|
|
22
|
+
*
|
|
23
|
+
* Starting the tunnel
|
|
24
|
+
* -------------------
|
|
25
|
+
* The parent's `startOpenVPN(Intent, startId)` is private, so we cannot call it
|
|
26
|
+
* directly from a subclass. Instead we:
|
|
27
|
+
* 1. Register the [VpnProfile] as a temporary profile via
|
|
28
|
+
* [ProfileManager.setTemporaryProfile], which persists it by UUID.
|
|
29
|
+
* 2. Re-build the intent with the UUID extras that the parent's
|
|
30
|
+
* `fetchVPNProfile(intent)` expects (`VpnProfile.EXTRA_PROFILEUUID` +
|
|
31
|
+
* `VpnProfile.EXTRA_PROFILE_VERSION`) and delegate to
|
|
32
|
+
* `super.onStartCommand(...)`.
|
|
33
|
+
*
|
|
34
|
+
* Stopping the tunnel
|
|
35
|
+
* -------------------
|
|
36
|
+
* [OpenVPNService.stopVPN] is declared in `IOpenVPNServiceInternal` and throws
|
|
37
|
+
* [RemoteException]; we catch and ignore it here.
|
|
38
|
+
*
|
|
39
|
+
* Listener overrides
|
|
40
|
+
* ------------------
|
|
41
|
+
* The parent already implements [VpnStatus.StateListener] and
|
|
42
|
+
* [VpnStatus.ByteCountListener]. We override those methods, forward to the
|
|
43
|
+
* EventBus, and then call super so the parent's notification logic still runs.
|
|
44
|
+
* We do NOT register/unregister the listeners separately — the parent
|
|
45
|
+
* `onStartCommand` and `onDestroy` handle that.
|
|
46
|
+
*/
|
|
47
|
+
class OpenvpnService : OpenVPNService() {
|
|
48
|
+
|
|
49
|
+
private var lastEmittedAt: Long = 0L
|
|
50
|
+
// Latched at the moment we first observe LEVEL_CONNECTED so that
|
|
51
|
+
// durationMs is honest after service restarts / reconnections.
|
|
52
|
+
private var sessionStartedAt: Long = 0L
|
|
53
|
+
|
|
54
|
+
override fun onCreate() {
|
|
55
|
+
Log.i(TAG, "OpenvpnService.onCreate")
|
|
56
|
+
createOpenVpnChannels()
|
|
57
|
+
super.onCreate()
|
|
58
|
+
// Capture ics-openvpn's internal log lines (including the openvpn binary's
|
|
59
|
+
// stdout/stderr) and forward them both to logcat (for debugging) and to
|
|
60
|
+
// the JS bridge.
|
|
61
|
+
VpnStatus.addLogListener { item ->
|
|
62
|
+
val s = item.getString(this)
|
|
63
|
+
Log.i(TAG, "vpn-log: $s")
|
|
64
|
+
OpenvpnEventBus.emitLog(s)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private fun createOpenVpnChannels() {
|
|
69
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
70
|
+
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
|
71
|
+
?: return
|
|
72
|
+
|
|
73
|
+
if (mgr.getNotificationChannel(NOTIFICATION_CHANNEL_BG_ID) == null) {
|
|
74
|
+
val bg = NotificationChannel(
|
|
75
|
+
NOTIFICATION_CHANNEL_BG_ID,
|
|
76
|
+
"VPN background",
|
|
77
|
+
NotificationManager.IMPORTANCE_MIN,
|
|
78
|
+
).apply {
|
|
79
|
+
description = "Ongoing VPN connection status"
|
|
80
|
+
enableLights(false)
|
|
81
|
+
lightColor = Color.DKGRAY
|
|
82
|
+
}
|
|
83
|
+
mgr.createNotificationChannel(bg)
|
|
84
|
+
}
|
|
85
|
+
if (mgr.getNotificationChannel(NOTIFICATION_CHANNEL_NEWSTATUS_ID) == null) {
|
|
86
|
+
val status = NotificationChannel(
|
|
87
|
+
NOTIFICATION_CHANNEL_NEWSTATUS_ID,
|
|
88
|
+
"VPN status",
|
|
89
|
+
NotificationManager.IMPORTANCE_LOW,
|
|
90
|
+
).apply {
|
|
91
|
+
description = "VPN status change notifications"
|
|
92
|
+
enableLights(true)
|
|
93
|
+
lightColor = Color.BLUE
|
|
94
|
+
}
|
|
95
|
+
mgr.createNotificationChannel(status)
|
|
96
|
+
}
|
|
97
|
+
if (mgr.getNotificationChannel(NOTIFICATION_CHANNEL_USERREQ_ID) == null) {
|
|
98
|
+
val req = NotificationChannel(
|
|
99
|
+
NOTIFICATION_CHANNEL_USERREQ_ID,
|
|
100
|
+
"VPN requests",
|
|
101
|
+
NotificationManager.IMPORTANCE_HIGH,
|
|
102
|
+
).apply {
|
|
103
|
+
description = "Urgent VPN requests (e.g. 2FA)"
|
|
104
|
+
enableVibration(true)
|
|
105
|
+
lightColor = Color.CYAN
|
|
106
|
+
}
|
|
107
|
+
mgr.createNotificationChannel(req)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
112
|
+
if (intent?.action == ACTION_STOP) {
|
|
113
|
+
stopTunnel()
|
|
114
|
+
return START_NOT_STICKY
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Suppress("DEPRECATION")
|
|
118
|
+
val profile = intent?.getSerializableExtra(EXTRA_PROFILE) as? VpnProfile
|
|
119
|
+
|
|
120
|
+
if (profile == null) {
|
|
121
|
+
OpenvpnEventBus.emitError("NATIVE_ERROR", "OpenvpnService started without a profile")
|
|
122
|
+
return START_NOT_STICKY
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Register the profile so the parent's fetchVPNProfile can find it by UUID.
|
|
126
|
+
ProfileManager.setTemporaryProfile(this, profile)
|
|
127
|
+
|
|
128
|
+
// Build an intent the parent's fetchVPNProfile understands.
|
|
129
|
+
val parentIntent = Intent(intent).apply {
|
|
130
|
+
putExtra(VpnProfile.EXTRA_PROFILEUUID, profile.uuid.toString())
|
|
131
|
+
putExtra(VpnProfile.EXTRA_PROFILE_VERSION, profile.mVersion)
|
|
132
|
+
// Remove our own extras so the parent doesn't trip on them.
|
|
133
|
+
removeExtra(EXTRA_PROFILE)
|
|
134
|
+
removeExtra(EXTRA_NOTIF_TITLE)
|
|
135
|
+
removeExtra(EXTRA_NOTIF_TEXT)
|
|
136
|
+
removeExtra(EXTRA_NOTIF_ICON)
|
|
137
|
+
removeExtra(EXTRA_NOTIF_CHANNEL)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return super.onStartCommand(parentIntent, flags, startId)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun stopTunnel() {
|
|
144
|
+
// Emit transition states synchronously: the openvpn binary takes ~50-200ms
|
|
145
|
+
// to actually exit after SIGINT, and stopSelf() below unregisters our
|
|
146
|
+
// VpnStatus listener before the binary's exit callback fires. Without
|
|
147
|
+
// these explicit emits the JS bridge never observes the disconnected state
|
|
148
|
+
// and the UI stays stuck on "connected".
|
|
149
|
+
OpenvpnEventBus.emitState("disconnecting")
|
|
150
|
+
try {
|
|
151
|
+
stopVPN(false)
|
|
152
|
+
} catch (_: RemoteException) {
|
|
153
|
+
// Management interface not running; nothing to stop.
|
|
154
|
+
}
|
|
155
|
+
OpenvpnEventBus.emitState("disconnected")
|
|
156
|
+
sessionStartedAt = 0L
|
|
157
|
+
@Suppress("DEPRECATION")
|
|
158
|
+
stopForeground(true)
|
|
159
|
+
stopSelf()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── VpnStatus.StateListener ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
override fun updateState(
|
|
165
|
+
state: String?,
|
|
166
|
+
logmessage: String?,
|
|
167
|
+
resid: Int,
|
|
168
|
+
level: ConnectionStatus?,
|
|
169
|
+
intent: Intent?,
|
|
170
|
+
) {
|
|
171
|
+
Log.i(TAG, "updateState: state=$state level=$level msg=$logmessage")
|
|
172
|
+
// Let the parent update the foreground notification.
|
|
173
|
+
super.updateState(state, logmessage, resid, level, intent)
|
|
174
|
+
|
|
175
|
+
val mapped = mapState(level)
|
|
176
|
+
OpenvpnEventBus.emitState(mapped)
|
|
177
|
+
if (!logmessage.isNullOrEmpty()) OpenvpnEventBus.emitLog(logmessage)
|
|
178
|
+
|
|
179
|
+
// Latch session start when we first observe the connected level. Reset
|
|
180
|
+
// on disconnect so that a subsequent connection re-latches. This makes
|
|
181
|
+
// durationMs accurate across service restarts / reconnects.
|
|
182
|
+
when (level) {
|
|
183
|
+
ConnectionStatus.LEVEL_CONNECTED ->
|
|
184
|
+
if (sessionStartedAt == 0L) sessionStartedAt = System.currentTimeMillis()
|
|
185
|
+
ConnectionStatus.LEVEL_NOTCONNECTED,
|
|
186
|
+
ConnectionStatus.LEVEL_NONETWORK,
|
|
187
|
+
ConnectionStatus.LEVEL_AUTH_FAILED ->
|
|
188
|
+
sessionStartedAt = 0L
|
|
189
|
+
else -> { /* keep latched */ }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Map ics-openvpn ConnectionStatus levels to our error codes. The `state`
|
|
193
|
+
// string varies across versions, so we discriminate on the typed enum.
|
|
194
|
+
when (level) {
|
|
195
|
+
ConnectionStatus.LEVEL_AUTH_FAILED ->
|
|
196
|
+
OpenvpnEventBus.emitError("AUTH_FAILED", logmessage)
|
|
197
|
+
ConnectionStatus.LEVEL_NONETWORK ->
|
|
198
|
+
OpenvpnEventBus.emitError("NETWORK_UNREACHABLE", logmessage)
|
|
199
|
+
else -> { /* not an error level */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
override fun setConnectedVPN(uuid: String?) {
|
|
204
|
+
super.setConnectedVPN(uuid)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── VpnStatus.ByteCountListener ──────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
override fun updateByteCount(inBytes: Long, outBytes: Long, diffIn: Long, diffOut: Long) {
|
|
210
|
+
super.updateByteCount(inBytes, outBytes, diffIn, diffOut)
|
|
211
|
+
|
|
212
|
+
val now = System.currentTimeMillis()
|
|
213
|
+
if (now - lastEmittedAt < BYTES_EMIT_INTERVAL_MS) return
|
|
214
|
+
lastEmittedAt = now
|
|
215
|
+
// Before we've observed LEVEL_CONNECTED, durationMs is 0 — better than
|
|
216
|
+
// reporting time-since-service-create as connected duration.
|
|
217
|
+
val durationMs = if (sessionStartedAt == 0L) 0L else (now - sessionStartedAt)
|
|
218
|
+
OpenvpnEventBus.emitStats(inBytes, outBytes, durationMs)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
private fun mapState(level: ConnectionStatus?): String =
|
|
224
|
+
when (level) {
|
|
225
|
+
ConnectionStatus.LEVEL_CONNECTED -> "connected"
|
|
226
|
+
ConnectionStatus.LEVEL_NOTCONNECTED -> "disconnected"
|
|
227
|
+
ConnectionStatus.LEVEL_AUTH_FAILED -> "error"
|
|
228
|
+
ConnectionStatus.LEVEL_CONNECTING_NO_SERVER_REPLY_YET,
|
|
229
|
+
ConnectionStatus.LEVEL_CONNECTING_SERVER_REPLIED -> "connecting"
|
|
230
|
+
ConnectionStatus.LEVEL_WAITING_FOR_USER_INPUT -> "connecting"
|
|
231
|
+
ConnectionStatus.LEVEL_VPNPAUSED -> "disconnecting"
|
|
232
|
+
ConnectionStatus.LEVEL_NONETWORK -> "disconnected"
|
|
233
|
+
ConnectionStatus.LEVEL_START -> "connecting"
|
|
234
|
+
ConnectionStatus.UNKNOWN_LEVEL,
|
|
235
|
+
null -> "idle"
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
companion object {
|
|
239
|
+
const val ACTION_START = "com.openvpn.START"
|
|
240
|
+
const val ACTION_STOP = "com.openvpn.STOP"
|
|
241
|
+
const val EXTRA_PROFILE = "profile"
|
|
242
|
+
const val EXTRA_NOTIF_TITLE = "notif_title"
|
|
243
|
+
const val EXTRA_NOTIF_TEXT = "notif_text"
|
|
244
|
+
const val EXTRA_NOTIF_ICON = "notif_icon"
|
|
245
|
+
const val EXTRA_NOTIF_CHANNEL = "notif_channel"
|
|
246
|
+
private const val BYTES_EMIT_INTERVAL_MS = 1000L
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.openvpn
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.net.VpnService
|
|
6
|
+
import com.facebook.react.bridge.Promise
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
9
|
+
|
|
10
|
+
object PermissionLauncher {
|
|
11
|
+
|
|
12
|
+
private const val REQUEST_CODE = 0xCAF3
|
|
13
|
+
|
|
14
|
+
private val pending = AtomicReference<Promise?>(null)
|
|
15
|
+
|
|
16
|
+
fun request(reactContext: ReactApplicationContext, promise: Promise) {
|
|
17
|
+
val intent = VpnService.prepare(reactContext)
|
|
18
|
+
if (intent == null) {
|
|
19
|
+
promise.resolve(true)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
val activity = reactContext.currentActivity
|
|
23
|
+
if (activity == null) {
|
|
24
|
+
promise.reject("PERMISSION_DENIED", "No current Activity to launch the VPN consent dialog")
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
if (!pending.compareAndSet(null, promise)) {
|
|
28
|
+
promise.reject("PERMISSION_DENIED", "Another permission request is already in flight")
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
activity.startActivityForResult(intent, REQUEST_CODE)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun onActivityResult(requestCode: Int, resultCode: Int, @Suppress("UNUSED_PARAMETER") data: Intent?) {
|
|
35
|
+
if (requestCode != REQUEST_CODE) return
|
|
36
|
+
val promise = pending.getAndSet(null) ?: return
|
|
37
|
+
promise.resolve(resultCode == Activity.RESULT_OK)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package com.openvpn
|
|
2
|
+
|
|
3
|
+
import de.blinkt.openvpn.VpnProfile
|
|
4
|
+
import de.blinkt.openvpn.core.ConfigParser
|
|
5
|
+
import java.io.StringReader
|
|
6
|
+
|
|
7
|
+
object ProfileBuilder {
|
|
8
|
+
|
|
9
|
+
fun build(
|
|
10
|
+
ovpn: String,
|
|
11
|
+
username: String,
|
|
12
|
+
password: String,
|
|
13
|
+
dns: List<String>,
|
|
14
|
+
killSwitch: Boolean,
|
|
15
|
+
): VpnProfile {
|
|
16
|
+
val sanitized = ensureLegacyCipherSupport(ovpn)
|
|
17
|
+
val parser = ConfigParser()
|
|
18
|
+
try {
|
|
19
|
+
parser.parseConfig(StringReader(sanitized))
|
|
20
|
+
} catch (e: Exception) {
|
|
21
|
+
throw OpenvpnException("INVALID_CONFIG", e.message ?: "failed to parse .ovpn")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
val profile = parser.convertProfile()
|
|
25
|
+
profile.mUsername = username
|
|
26
|
+
profile.mPassword = password
|
|
27
|
+
|
|
28
|
+
if (dns.isNotEmpty()) {
|
|
29
|
+
profile.mOverrideDNS = true
|
|
30
|
+
profile.mDNS1 = dns.getOrNull(0).orEmpty()
|
|
31
|
+
profile.mDNS2 = dns.getOrNull(1).orEmpty()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (killSwitch) {
|
|
35
|
+
profile.mBlockUnusedAddressFamilies = true
|
|
36
|
+
profile.mAllowLocalLAN = false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return profile
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// OpenVPN 2.6+ rejects deprecated ciphers (AES-128-CBC, AES-256-CBC, BF-CBC)
|
|
43
|
+
// from negotiation by default. Many legacy servers (VPN Gate, SoftEther
|
|
44
|
+
// exports, older OpenVPN Access Servers) still push these. To preserve
|
|
45
|
+
// compatibility we ensure the resulting profile advertises both the modern
|
|
46
|
+
// defaults and the common legacy fallbacks unless the user already supplied
|
|
47
|
+
// an explicit data-ciphers directive.
|
|
48
|
+
private fun ensureLegacyCipherSupport(ovpn: String): String {
|
|
49
|
+
val hasDataCiphers = ovpn.lineSequence().any {
|
|
50
|
+
val t = it.trim()
|
|
51
|
+
t.startsWith("data-ciphers ") || t == "data-ciphers"
|
|
52
|
+
}
|
|
53
|
+
val hasFallback = ovpn.lineSequence().any {
|
|
54
|
+
val t = it.trim()
|
|
55
|
+
t.startsWith("data-ciphers-fallback ") || t == "data-ciphers-fallback"
|
|
56
|
+
}
|
|
57
|
+
if (hasDataCiphers && hasFallback) return ovpn
|
|
58
|
+
val builder = StringBuilder(ovpn)
|
|
59
|
+
if (!ovpn.endsWith("\n")) builder.append('\n')
|
|
60
|
+
if (!hasDataCiphers) {
|
|
61
|
+
builder.append("data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305:AES-256-CBC:AES-128-CBC\n")
|
|
62
|
+
}
|
|
63
|
+
if (!hasFallback) {
|
|
64
|
+
builder.append("data-ciphers-fallback AES-128-CBC\n")
|
|
65
|
+
}
|
|
66
|
+
return builder.toString()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="24dp"
|
|
3
|
+
android:height="24dp"
|
|
4
|
+
android:viewportWidth="24"
|
|
5
|
+
android:viewportHeight="24"
|
|
6
|
+
android:tint="?attr/colorControlNormal">
|
|
7
|
+
<path
|
|
8
|
+
android:fillColor="#FFFFFFFF"
|
|
9
|
+
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12c5.16,-1.26 9,-6.45 9,-12V5l-9,-4zM10.94,16.93L7.4,13.4l1.41,-1.41l2.12,2.12l5.66,-5.66l1.41,1.41l-7.07,7.07z"/>
|
|
10
|
+
</vector>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package com.openvpn
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.test.core.app.ApplicationProvider
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertNotNull
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
import org.junit.runner.RunWith
|
|
9
|
+
import org.robolectric.RobolectricTestRunner
|
|
10
|
+
|
|
11
|
+
@RunWith(RobolectricTestRunner::class)
|
|
12
|
+
class NotificationHelperTest {
|
|
13
|
+
|
|
14
|
+
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
15
|
+
|
|
16
|
+
@Test
|
|
17
|
+
fun `build returns a Notification with defaults`() {
|
|
18
|
+
val n = NotificationHelper.build(context, NotificationOverrides())
|
|
19
|
+
assertNotNull(n)
|
|
20
|
+
val extras = n.extras
|
|
21
|
+
assertEquals("Connected", extras.getString("android.title"))
|
|
22
|
+
assertEquals("VPN is active", extras.getString("android.text"))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun `build applies title override`() {
|
|
27
|
+
val n = NotificationHelper.build(
|
|
28
|
+
context,
|
|
29
|
+
NotificationOverrides(title = "MyVPN connected"),
|
|
30
|
+
)
|
|
31
|
+
assertEquals("MyVPN connected", n.extras.getString("android.title"))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test
|
|
35
|
+
fun `build applies text override`() {
|
|
36
|
+
val n = NotificationHelper.build(
|
|
37
|
+
context,
|
|
38
|
+
NotificationOverrides(text = "Routing your traffic"),
|
|
39
|
+
)
|
|
40
|
+
assertEquals("Routing your traffic", n.extras.getString("android.text"))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test
|
|
44
|
+
fun `ensureChannel does not throw`() {
|
|
45
|
+
NotificationHelper.ensureChannel(context, "openvpn", "VPN status")
|
|
46
|
+
// Channel manager assertions vary by Robolectric version; this test just
|
|
47
|
+
// verifies the call does not throw.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
package com.openvpn
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertNotNull
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import org.junit.runner.RunWith
|
|
8
|
+
import org.robolectric.RobolectricTestRunner
|
|
9
|
+
|
|
10
|
+
@RunWith(RobolectricTestRunner::class)
|
|
11
|
+
class ProfileBuilderTest {
|
|
12
|
+
|
|
13
|
+
private val minimalOvpn = """
|
|
14
|
+
client
|
|
15
|
+
dev tun
|
|
16
|
+
proto udp
|
|
17
|
+
remote 1.2.3.4 1194
|
|
18
|
+
cipher AES-256-CBC
|
|
19
|
+
""".trimIndent()
|
|
20
|
+
|
|
21
|
+
@Test
|
|
22
|
+
fun `builds VpnProfile from minimal config`() {
|
|
23
|
+
val profile = ProfileBuilder.build(
|
|
24
|
+
ovpn = minimalOvpn,
|
|
25
|
+
username = "alice",
|
|
26
|
+
password = "s3cret",
|
|
27
|
+
dns = emptyList(),
|
|
28
|
+
killSwitch = false,
|
|
29
|
+
)
|
|
30
|
+
assertNotNull(profile)
|
|
31
|
+
assertEquals("alice", profile.mUsername)
|
|
32
|
+
assertEquals("s3cret", profile.mPassword)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun `injects DNS override when provided`() {
|
|
37
|
+
val profile = ProfileBuilder.build(
|
|
38
|
+
ovpn = minimalOvpn,
|
|
39
|
+
username = "u",
|
|
40
|
+
password = "p",
|
|
41
|
+
dns = listOf("1.1.1.1", "1.0.0.1"),
|
|
42
|
+
killSwitch = false,
|
|
43
|
+
)
|
|
44
|
+
assertTrue(profile.mOverrideDNS)
|
|
45
|
+
assertEquals("1.1.1.1", profile.mDNS1)
|
|
46
|
+
assertEquals("1.0.0.1", profile.mDNS2)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun `does not override DNS when list is empty`() {
|
|
51
|
+
val profile = ProfileBuilder.build(
|
|
52
|
+
ovpn = minimalOvpn,
|
|
53
|
+
username = "u",
|
|
54
|
+
password = "p",
|
|
55
|
+
dns = emptyList(),
|
|
56
|
+
killSwitch = false,
|
|
57
|
+
)
|
|
58
|
+
assertEquals(false, profile.mOverrideDNS)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun `sets kill-switch flag when requested`() {
|
|
63
|
+
val profile = ProfileBuilder.build(
|
|
64
|
+
ovpn = minimalOvpn,
|
|
65
|
+
username = "u",
|
|
66
|
+
password = "p",
|
|
67
|
+
dns = emptyList(),
|
|
68
|
+
killSwitch = true,
|
|
69
|
+
)
|
|
70
|
+
assertTrue(profile.mBlockUnusedAddressFamilies)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test(expected = OpenvpnException::class)
|
|
74
|
+
fun `throws OpenvpnException on garbage input`() {
|
|
75
|
+
ProfileBuilder.build(
|
|
76
|
+
ovpn = "this is not an ovpn config",
|
|
77
|
+
username = "u",
|
|
78
|
+
password = "p",
|
|
79
|
+
dns = emptyList(),
|
|
80
|
+
killSwitch = false,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Openvpn-Bridging-Header.h
|
|
3
|
+
// react-native-ovpn
|
|
4
|
+
//
|
|
5
|
+
// Exposes Objective-C / Objective-C++ symbols to the Swift sources in this
|
|
6
|
+
// pod. React Native's framework imports propagate automatically; add types
|
|
7
|
+
// here only when a Swift compile error demands them.
|
|
8
|
+
//
|
package/ios/Openvpn.h
ADDED
package/ios/Openvpn.mm
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#import "Openvpn.h"
|
|
2
|
+
#import <React/RCTBridge.h>
|
|
3
|
+
#import <React/RCTEventEmitter.h>
|
|
4
|
+
|
|
5
|
+
// Bridges to Swift sources in this pod. CocoaPods generates this header
|
|
6
|
+
// from the @objc-annotated Swift classes (OpenvpnManager, OpenvpnEventBridge,
|
|
7
|
+
// OpenvpnConstants, etc.). Header name pattern: "<ModuleName>-Swift.h".
|
|
8
|
+
#import "Openvpn-Swift.h"
|
|
9
|
+
|
|
10
|
+
@implementation Openvpn
|
|
11
|
+
|
|
12
|
+
RCT_EXPORT_MODULE(Openvpn)
|
|
13
|
+
|
|
14
|
+
+ (BOOL)requiresMainQueueSetup {
|
|
15
|
+
return NO;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
- (NSArray<NSString *> *)supportedEvents {
|
|
19
|
+
return @[ @"OpenVpn:state", @"OpenVpn:stats", @"OpenVpn:log", @"OpenVpn:error" ];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
- (void)setBridge:(RCTBridge *)bridge {
|
|
23
|
+
[super setBridge:bridge];
|
|
24
|
+
[[OpenvpnEventBridge shared] attach:bridge];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
- (void)invalidate {
|
|
28
|
+
[[OpenvpnEventBridge shared] detach];
|
|
29
|
+
[super invalidate];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// MARK: TurboModule methods
|
|
33
|
+
|
|
34
|
+
- (void)requestPermission:(RCTPromiseResolveBlock)resolve
|
|
35
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
36
|
+
[[OpenvpnManager shared] requestPermissionWithCompletion:^(BOOL granted, NSError * _Nullable error) {
|
|
37
|
+
if (error) {
|
|
38
|
+
reject(@"PERMISSION_DENIED", error.localizedDescription, error);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
resolve(@(granted));
|
|
42
|
+
}];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
- (void)connect:(NSDictionary *)params
|
|
46
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
47
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
48
|
+
// Per-app routing rejection (iOS does not support it without MDM).
|
|
49
|
+
NSArray *allowedApps = params[@"allowedApps"];
|
|
50
|
+
NSArray *disallowedApps = params[@"disallowedApps"];
|
|
51
|
+
if ((allowedApps && allowedApps.count > 0) ||
|
|
52
|
+
(disallowedApps && disallowedApps.count > 0)) {
|
|
53
|
+
reject(@"INVALID_CONFIG",
|
|
54
|
+
@"per-app routing not supported on iOS (MDM-only)",
|
|
55
|
+
nil);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
NSString *ovpn = params[@"config"];
|
|
60
|
+
if (!ovpn) {
|
|
61
|
+
reject(@"INVALID_CONFIG", @"config missing", nil);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
NSString *username = params[@"username"] ?: @"";
|
|
65
|
+
NSString *password = params[@"password"] ?: @"";
|
|
66
|
+
NSArray<NSString *> *dns = params[@"dns"] ?: @[];
|
|
67
|
+
|
|
68
|
+
// killSwitch is silently ignored on iOS (deferred to Plan 3.1) per the
|
|
69
|
+
// Plan 3 spec extension §5.
|
|
70
|
+
|
|
71
|
+
[[OpenvpnManager shared] connectWithOvpn:ovpn
|
|
72
|
+
username:username
|
|
73
|
+
password:password
|
|
74
|
+
dns:dns
|
|
75
|
+
completion:^(BOOL ok, NSError * _Nullable error) {
|
|
76
|
+
if (error) {
|
|
77
|
+
reject(@"NATIVE_ERROR", error.localizedDescription, error);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
resolve(nil);
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
- (void)disconnect:(RCTPromiseResolveBlock)resolve
|
|
85
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
86
|
+
[[OpenvpnManager shared] disconnectWithCompletion:^(BOOL ok, NSError * _Nullable error) {
|
|
87
|
+
if (error) {
|
|
88
|
+
reject(@"NATIVE_ERROR", error.localizedDescription, error);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
resolve(nil);
|
|
92
|
+
}];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
- (void)getStatus:(RCTPromiseResolveBlock)resolve
|
|
96
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
97
|
+
resolve(@{ @"state": @"idle" });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
- (void)getStats:(RCTPromiseResolveBlock)resolve
|
|
101
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
102
|
+
resolve(@{
|
|
103
|
+
@"bytesIn": @0,
|
|
104
|
+
@"bytesOut": @0,
|
|
105
|
+
@"durationMs": @0,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// addListener / removeListeners are required by NativeEventEmitter and are
|
|
110
|
+
// no-ops on our side (the event bus emits unconditionally).
|
|
111
|
+
- (void)addListener:(NSString *)eventName {}
|
|
112
|
+
- (void)removeListeners:(double)count {}
|
|
113
|
+
|
|
114
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
115
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params {
|
|
116
|
+
return std::make_shared<facebook::react::NativeOpenvpnSpecJSI>(params);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
+ (NSString *)moduleName {
|
|
120
|
+
return @"Openvpn";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@end
|