DEV Community

Ragul
Ragul

Posted on

Build VPN Application using React Native with WireGuard VPN Protocol

1. Project Setup

Hey Developer ! πŸ‘‹

WireGuard-based React Native (Expo) Android VPN Project Setup Guide

This is the first post in a series on setting up a React Native project for building a VPN application using the WireGuard VPN protocol. The UI/UX design is completely up to you; this post primarily focuses on setting up a React Native Expo Bare workflow project.

Explore my repository for a fully functional React Native VPN application built with React Native: Project Anywhere

0. Prerequisites (Strict)

Ensure these are installed and configured before starting:

  • Node.js: LTS version (e.g., v18+ or v20+).
  • JDK: Version 17 or 21 (Required for modern Android Gradle plugins).
  • Android Studio: Latest Stable.
  • Android SDK & NDK:
    • Install standard SDK platforms.
    • IMPORTANT: Install NDK version 27.1.12297006 (or matching the config below) via SDK Manager.

1. Project Initialization

npx create-expo-app Anywhere
cd Anywhere
Enter fullscreen mode Exit fullscreen mode

2. Install Dependencies

npm install @react-navigation/native @react-navigation/stack react-native-safe-area-context react-native-reanimated
npm install nativewind tailwindcss
npx tailwindcss init
npm install expo-status-bar
Enter fullscreen mode Exit fullscreen mode

3. Prebuild for Native Code

npx expo prebuild --platform android
Enter fullscreen mode Exit fullscreen mode

4. Native Module Setup (Critical Step)

This step involves creating Kotlin files. Copy the code exactly to avoid signature mismatch errors that cause build failures.

4.1 Create WireGuardModule.kt

Path: android/app/src/main/java/com/raguls/Anywhere/WireGuardModule.kt

package com.wireguardapp

import android.app.Activity
import android.content.Intent
import android.net.VpnService
import android.util.Log
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import java.io.ByteArrayInputStream
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import java.nio.charset.StandardCharsets

class WireGuardModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
    private var pendingPromise: Promise? = null
    private var pendingName: String? = null
    private var pendingConfig: String? = null

    companion object {
        private const val REQUEST_CODE_VPN_PERMISSION = 1001
        private var backend: GoBackend? = null // Singleton instance
        private val tunnels = HashMap<String, Tunnel>() // Cache tunnel instances
    }

    private val activityEventListener: ActivityEventListener = object : BaseActivityEventListener() {
        override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
            if (requestCode == REQUEST_CODE_VPN_PERMISSION) {
                if (resultCode == Activity.RESULT_OK) {
                    val name = pendingName
                    val config = pendingConfig
                    val promise = pendingPromise
                    if (name != null && config != null && promise != null) {
                        Log.d("WireGuardModule", "VPN permission granted, retrying connection...")
                        connect(name, config, promise)
                    } else {
                         Log.e("WireGuardModule", "VPN permission granted but pending data is missing")
                        pendingPromise?.reject("ERROR", "Lost pending connection data")
                    }
                } else {
                    Log.e("WireGuardModule", "VPN permission denied")
                    pendingPromise?.reject("PERMISSION_DENIED", "VPN permission denied by user")
                }
                // Cleanup
                pendingPromise = null
                pendingName = null
                pendingConfig = null
            }
        }
    }

    init {
        reactContext.addActivityEventListener(activityEventListener)
        initializeBackend()
    }

    private fun initializeBackend() {
        if (backend != null) return
        try {
            backend = GoBackend(reactContext.applicationContext)
            Log.d("WireGuardModule", "Backend initialized successfully (Singleton)")
        } catch (e: Exception) {
            Log.e("WireGuardModule", "Error initializing backend", e)
        }
    }

    override fun getName(): String {
        return "WireGuardModule"
    }

    class WgTunnel(private val name: String) : Tunnel {
        override fun getName() = name
        override fun onStateChange(newState: Tunnel.State) {
            Log.d("WireGuardModule", "Tunnel $name state changed to $newState")
        }
    }

    @ReactMethod
    fun connect(name: String, configInterface: String, promise: Promise) {
        initializeBackend()
        val backend = WireGuardModule.backend
        if (backend == null) {
            promise.reject("BACKEND_ERROR", "WireGuard backend failed to initialize. Check logs.")
            return
        }

        // Check for VPN permission
        val intent = VpnService.prepare(reactContext)
        if (intent != null) {
            val activity = reactContext.currentActivity
            if (activity == null) {
                promise.reject("ACTIVITY_ERROR", "Current activity is null, cannot request permission")
                return
            }

            Log.d("WireGuardModule", "Requesting VPN permission")
            pendingName = name
            pendingConfig = configInterface
            pendingPromise = promise
            try {
                activity.startActivityForResult(intent, REQUEST_CODE_VPN_PERMISSION)
            } catch (e: Exception) {
                promise.reject("PERMISSION_ERROR", "Failed to start VPN permission activity: ${e.message}", e)
                pendingPromise = null
                pendingName = null
                pendingConfig = null
            }
            return
        }

        try {
            val inputStream = ByteArrayInputStream(configInterface.toByteArray(StandardCharsets.UTF_8))
            val config = Config.parse(inputStream)

            // Reuse existing tunnel object or create new one
            var tunnel = tunnels[name]
            if (tunnel == null) {
                tunnel = WgTunnel(name)
                tunnels[name] = tunnel
                Log.d("WireGuardModule", "Created new tunnel instance for: $name")
            } else {
                Log.d("WireGuardModule", "Reusing existing tunnel instance for: $name")
            }

            val finalTunnel = tunnel

            // Using a thread because setState might be blocking
            Thread {
                try {
                    backend.setState(finalTunnel, Tunnel.State.UP, config)
                    promise.resolve("CONNECTED")
                } catch (e: BackendException) {
                    Log.e("WireGuardModule", "BackendException during connect", e)
                    promise.reject("CONNECT_FAIL_BACKEND", "Backend error: ${e.reason}", e)
                } catch (e: Exception) {
                    Log.e("WireGuardModule", "Exception during connect", e)
                    promise.reject("CONNECT_FAIL", "Generic error: ${e.message}", e)
                }
            }.start()
        } catch (e: Exception) {
            promise.reject("CONFIG_ERROR", "Config parse error: ${e.message}", e)
        }
    }

    @ReactMethod
    fun disconnect(name: String, promise: Promise) {
        Log.d("WireGuardModule", "Disconnecting tunnel: $name")
        initializeBackend()
         val backend = WireGuardModule.backend
        if (backend == null) {
            Log.e("WireGuardModule", "Backend is null during disconnect")
            promise.reject("BACKEND_ERROR", "WireGuard backend not initialized")
            return
        }

        try {
            // Retrieve existing tunnel instance
            var tunnel = tunnels[name]
            if (tunnel == null) {
                Log.w("WireGuardModule", "No cached tunnel found for $name. Creating temporary instance (May fail if backend requires identity).")
                tunnel = WgTunnel(name)
            } else {
                Log.d("WireGuardModule", "Found cached tunnel instance for: $name")
            }

            val finalTunnel = tunnel

             Thread {
                try {
                    Log.d("WireGuardModule", "Setting state to DOWN for $name")
                    backend.setState(finalTunnel, Tunnel.State.DOWN, null)
                    Log.d("WireGuardModule", "State set to DOWN successfully")

                    try {
                        val intent = Intent(reactContext.applicationContext, GoBackend.VpnService::class.java)
                        val stopped = reactContext.applicationContext.stopService(intent)
                        Log.d("WireGuardModule", "Triggered stopService explicitly. Result: $stopped")
                    } catch (e: Exception) {
                        Log.e("WireGuardModule", "Failed to stop service explicitly", e)
                    }

                    promise.resolve("DISCONNECTED")
                } catch (e: Exception) {
                    Log.e("WireGuardModule", "Exception in disconnect thread", e)
                    promise.reject("DISCONNECT_FAIL", e)
                } catch (e: Throwable) {
                    Log.e("WireGuardModule", "Fatal error in disconnect thread", e)
                    promise.reject("DISCONNECT_FATAL", "Fatal error: ${e.message}")
                }
            }.start()
        } catch (e: Exception) {
            Log.e("WireGuardModule", "Error starting disconnect thread", e)
            promise.reject("ERROR", e)
        }
    }

    @ReactMethod
    fun getStatistics(name: String, promise: Promise) {
        initializeBackend()
        val backend = WireGuardModule.backend
        if (backend == null) {
            promise.reject("BACKEND_ERROR", "Backend not initialized")
            return
        }

        try {
            var tunnel = tunnels[name]
            if (tunnel == null) {
                // If checking stats for a running tunnel not in our cache (e.g. after restart),
                // we might need to recreate it. However, getStatistics might work with a new object 
                // if it's just reading DB/Kernel stats by name. But safer to assume identity matters.
                tunnel = WgTunnel(name)
            }

            val stats = backend.getStatistics(tunnel)
            val map = Arguments.createMap()
            map.putDouble("totalRx", stats.totalRx().toDouble())
            map.putDouble("totalTx", stats.totalTx().toDouble())
            promise.resolve(map)
        } catch (e: Exception) {
            promise.reject("STATS_ERROR", e.message, e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4.2 Create WireGuardPackage.kt

Path: android/app/src/main/java/com/raguls/Anywhere/WireGuardPackage.kt

package com.wireguardapp

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class WireGuardPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
        return listOf(WireGuardModule(reactContext))
    }

    override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
        return emptyList()
    }
}
Enter fullscreen mode Exit fullscreen mode

4.3 Register Package in MainApplication.kt

Path: android/app/src/main/java/com/raguls/Anywhere/MainApplication.kt

Add the package to the list.

// ... imports
import com.wireguardapp.WireGuardPackage

// ... in MainApplication class
    override fun getPackages(): List<ReactPackage> =
        PackageList(this).packages.apply {
            add(WireGuardPackage()) // <--- ADD THIS
        }
Enter fullscreen mode Exit fullscreen mode

5. Build Configuration (Avoids NDK/Version Errors)

5.1 android/build.gradle

Crucial: Explicitly set versions to avoid compile errors.

ext {
    buildToolsVersion = "35.0.0"
    minSdkVersion = 24           // WireGuard requires a higher min SDK than default
    compileSdkVersion = 35
    targetSdkVersion = 35
    ndkVersion = "27.1.12297006" // MUST match your installed NDK version exactly
}
Enter fullscreen mode Exit fullscreen mode

5.2 android/app/build.gradle

Add the WireGuard implementation dependency.

dependencies {
    implementation("com.facebook.react:react-android")
    // ...
    implementation 'com.wireguard.android:tunnel:1.+' // <--- ADD THIS
}
Enter fullscreen mode Exit fullscreen mode

6. Android Manifest

Path: android/app/src/main/AndroidManifest.xml

Include the xmlns:tools attribute inside the opening <manifest> tag, along with the xmlns:android declaration.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">
Enter fullscreen mode Exit fullscreen mode

Add the VPN service entry inside the <application> tag.

<service android:name="com.wireguard.android.backend.GoBackend$VpnService"
     android:permission="android.permission.BIND_VPN_SERVICE"
     android:exported="false"
     tools:replace="android:exported">
    <intent-filter>
        <action android:name="android.net.VpnService"/>
    </intent-filter>
</service>
Enter fullscreen mode Exit fullscreen mode

Also ensure permissions are present:

<uses-permission android:name="android.permission.INTERNET"/>
Enter fullscreen mode Exit fullscreen mode

7 Create local.properties

Path: android/local.properties

Add the SDK and NDK paths available on your system.

sdk.dir=C:\\Users\\<username>\\AppData\\Local\\Android\\Sdk
ndk.dir=C:\\Users\\<username>\\AppData\\Local\\Android\\Sdk\\ndk\\27.1.12297006
Enter fullscreen mode Exit fullscreen mode

8. Build and Run

npm run android
Enter fullscreen mode Exit fullscreen mode

If you follow these steps exactly, you will bypass the common pitfalls of NDK mismatches and Kotlin signature errors.

Top comments (0)