Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<action android:name="android.net.VpnService"/>
</intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="false"/>
android:value="true"/>
</service>
<activity
android:name=".MainActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.*
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.*
Expand Down Expand Up @@ -44,6 +45,7 @@ class MainActivity: FlutterActivity() {
private var startingSiteContainer: SiteContainer? = null

private var activeSiteId: String? = null
private var onStopCallback: (() -> Unit)? = null

private lateinit var workManager: WorkManager
private val refreshReceiver: BroadcastReceiver = RefreshReceiver()
Expand All @@ -66,6 +68,7 @@ class MainActivity: FlutterActivity() {
when(call.method) {
"android.registerActiveSite" -> registerActiveSite(result)
"android.deviceHasCamera" -> deviceHasCamera(result)
"android.openVpnSettings" -> openVpnSettings(result)

"nebula.parseCerts" -> nebulaParseCerts(call, result)
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
Expand Down Expand Up @@ -134,6 +137,12 @@ class MainActivity: FlutterActivity() {
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
}

private fun openVpnSettings(result: MethodChannel.Result) {
val intent = Intent(Settings.ACTION_VPN_SETTINGS)
startActivity(intent)
result.success(null)
}

private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) {
val certs = call.argument<String>("certs")
if (certs == "") {
Expand Down Expand Up @@ -233,7 +242,16 @@ class MainActivity: FlutterActivity() {
return result.error("failure", "Site config was incomplete, please review and try again", null)
}

sites?.refreshSites()
if (site.alwaysOn == true) {
if (activeSiteId != null && activeSiteId != site.id) {
stopSite { sites?.getSite(site.id)?.let { startSiteDirectly(it) } }
} else if (activeSiteId != site.id) {
sites?.getSite(site.id)?.let { startSiteDirectly(it) }
}
}

sites?.refreshSites(activeSiteId)
sites?.updateAll()

result.success(null)
}
Expand All @@ -253,6 +271,22 @@ class MainActivity: FlutterActivity() {
return true
}

private fun startSiteDirectly(siteContainer: SiteContainer) {
if (VpnService.prepare(this) != null) {
// VPN permission not granted; cannot start without user interaction
return
}
siteContainer.updater.setState(true, "Initializing...")
val intent = Intent(this, NebulaVpnService::class.java).apply {
putExtra("path", siteContainer.site.path)
putExtra("id", siteContainer.site.id)
}
startService(intent)
if (outMessenger == null) {
bindService(intent, connection, 0)
}
}

private fun startSite(call: MethodCall, result: MethodChannel.Result) {
val id = call.argument<String>("id")
if (id == "") {
Expand All @@ -271,7 +305,8 @@ class MainActivity: FlutterActivity() {
}
}

private fun stopSite() {
private fun stopSite(onStopped: (() -> Unit)? = null) {
onStopCallback = onStopped
val intent = Intent(this, NebulaVpnService::class.java).apply {
action = NebulaVpnService.ACTION_STOP
}
Expand Down Expand Up @@ -499,9 +534,13 @@ class MainActivity: FlutterActivity() {
inner class IncomingHandler: Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
val id = msg.data.getString("id")
if (id == null) {
Log.i(TAG, "got a message without an id from the vpn service")
return
}

//TODO: If the elvis hits then we had a deleted site running, which shouldn't happen
val site = sites!!.getSite(id!!) ?: return
val site = sites!!.getSite(id) ?: return

when (msg.what) {
NebulaVpnService.MSG_IS_RUNNING -> isRunning(site, msg)
Expand Down Expand Up @@ -530,6 +569,8 @@ class MainActivity: FlutterActivity() {
// a site while another is actively running.
activeSiteId = null
unbindVpnService()
onStopCallback?.invoke()
onStopCallback = null
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import android.system.OsConstants
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.*
import mobileNebula.CIDR
import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
Expand Down Expand Up @@ -64,45 +63,82 @@ class NebulaVpnService : VpnService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopVpn()
return Service.START_NOT_STICKY
return START_NOT_STICKY
}

val id = intent?.getStringExtra("id")
var autoStart = false
var sitePath: String? = null
try {
sitePath = intent?.getStringExtra("path")
if (sitePath == null) {
// If the UI is starting us we expect a path, if there is no path then we expect the system is starting us
autoStart = true
sitePath = filesDir.resolve("always-on-site").readText()
}
} catch (_: Exception) {
// Ignore errors
}
if (sitePath.isNullOrEmpty()) {
Log.e(TAG, "Could not find site path in intent or always-on-site file")
stopSelf(startId)
return START_NOT_STICKY
}

var startSite: Site? = null
try {
startSite = Site(this, File(sitePath))
} catch (err: Exception) {
// Ignore errors
}
if (startSite == null) {
Log.e(TAG, "Could not get site details from: $sitePath")
stopSelf(startId)
return START_NOT_STICKY
}

if (running) {
// if the UI triggers this twice, check if we are already running the requested site. if not, return an error.
// otherwise, just ignore the request since we handled it the first time.
if (site!!.id != id) {
announceExit(id, "Trying to run nebula but it is already running")
if (site!!.id != startSite.id) {
announceExit(startSite.id, "Trying to run nebula but it is already running")
}
return super.onStartCommand(intent, flags, startId)

return START_NOT_STICKY
}

// Make sure we don't accept commands for a different site id
if (site != null && site!!.id != id) {
announceExit(id, "Command received for a site id that is different from the current active site")
return super.onStartCommand(intent, flags, startId)
if (site != null && site!!.id != startSite.id) {
announceExit(startSite.id, "Command received for a site id that is different from the current active site")
stopSelf(startId)
return START_NOT_STICKY
}

path = intent!!.getStringExtra("path")!!
//TODO: if we fail to start, android will attempt a restart lacking all the intent data we need.
// Link active site config in Main to avoid this
site = Site(this, File(path!!))

if (site!!.cert == null) {
announceExit(id, "Site is missing a certificate")
return super.onStartCommand(intent, flags, startId)
if (startSite.cert == null) {
announceExit(startSite.id, "Site is missing a certificate")
stopSelf(startId)
return START_NOT_STICKY
}

// Kick off a site update
val workRequest = OneTimeWorkRequestBuilder<DNUpdateWorker>().build()
workManager!!.enqueue(workRequest)

site = startSite
path = sitePath

if (autoStart) {
startVpn()
}
// We don't actually start here. In order to properly capture boot errors we wait until an IPC connection is made
return super.onStartCommand(intent, flags, startId)
}

private fun startVpn() {
if (site == null) {
Log.e(TAG, "Got a start command but we don't have a site to run")
return
}

val builder = Builder()
.setMtu(site!!.mtu)
.setSession(TAG)
Expand Down
28 changes: 28 additions & 0 deletions android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.defined.mobile_nebula

import android.content.Context
import android.provider.Settings
import android.util.Log
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
Expand Down Expand Up @@ -48,10 +49,19 @@ class Sites(private var engine: FlutterEngine) {
return containers.mapValues { it.value.site }
}

fun updateAll() {
containers.values.forEach { it.updater.notifyChanged() }
}

fun deleteSite(id: String) {
val context = MainActivity.getContext()!!
val site = containers[id]!!.site

val alwaysOnFile = context.filesDir.resolve("always-on-site")
if (alwaysOnFile.exists() && alwaysOnFile.readText() == site.path) {
alwaysOnFile.delete()
}

val baseDir = if(site.managed) context.noBackupFilesDir else context.filesDir
val siteDir = baseDir.resolve("sites").resolve(id)
siteDir.deleteRecursively()
Expand Down Expand Up @@ -132,6 +142,10 @@ class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.S
}
}

fun notifyChanged() {
eventSink?.success(gson.toJson(site))
}

init {
eventChannel.setStreamHandler(this)
}
Expand Down Expand Up @@ -213,6 +227,7 @@ class Site(context: Context, siteDir: File) {
// The following fields are present when managed = true
val rawConfig: String?
val lastManagedUpdate: String?
val alwaysOn: Boolean

// Path to this site on disk
@Transient
Expand Down Expand Up @@ -247,6 +262,9 @@ class Site(context: Context, siteDir: File) {
connected = false
status = "Disconnected"

val alwaysOnPath = try { context.filesDir.resolve("always-on-site").readText() } catch (_: Exception) { null }
alwaysOn = alwaysOnPath == path

try {
val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert)
val certs = gson.fromJson(rawDetails, Array<CertificateInfo>::class.java)
Expand Down Expand Up @@ -352,6 +370,7 @@ class IncomingSite(
val lastManagedUpdate: String?,
val rawConfig: String?,
var dnCredentials: DNCredentials?,
val alwaysOn: Boolean?,
) {
fun save(context: Context): File {
// Don't allow backups of DN-managed sites
Expand All @@ -373,6 +392,15 @@ class IncomingSite(
dnCredentials?.save(context, siteDir)
dnCredentials = null

val alwaysOnFile = context.filesDir.resolve("always-on-site")
when (alwaysOn) {
true -> alwaysOnFile.writeText(siteDir.absolutePath)
false -> if (alwaysOnFile.exists() && alwaysOnFile.readText() == siteDir.absolutePath) {
alwaysOnFile.delete()
}
null -> {}
}

val confFile = siteDir.resolve("config.json")
confFile.writeText(Gson().toJson(this))

Expand Down
10 changes: 10 additions & 0 deletions ios/NebulaNetworkExtension/Site.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ class Site: Codable {
var connected: Bool? //TODO: active is a better name
var status: String?
var logFile: String?
var alwaysOn: Bool
var managed: Bool
var dnsResolvers: [String]
// The following fields are present if managed = true
Expand All @@ -188,6 +189,7 @@ class Site: Codable {
self.manager = manager
self.connected = statusMap[manager.connection.status]
self.status = statusString[manager.connection.status]
self.alwaysOn = manager.isOnDemandEnabled
}

convenience init(proto: NETunnelProviderProtocol) throws {
Expand Down Expand Up @@ -237,6 +239,7 @@ class Site: Codable {
lastManagedUpdate = incoming.lastManagedUpdate
dnsResolvers = incoming.dnsResolvers ?? []
rawConfig = incoming.rawConfig
alwaysOn = incoming.alwaysOn ?? false

// Default these to disconnected for the UI
status = statusString[.disconnected]
Expand Down Expand Up @@ -385,6 +388,7 @@ class Site: Codable {
case lastManagedUpdate
case dnsResolvers
case rawConfig
case alwaysOn
}
}

Expand Down Expand Up @@ -444,6 +448,7 @@ struct IncomingSite: Codable {
var key: String?
var managed: Bool?
var dnsResolvers: [String]?
var alwaysOn: Bool?
// The following fields are present if managed = true
var dnCredentials: DNCredentials?
var lastManagedUpdate: String?
Expand Down Expand Up @@ -546,6 +551,11 @@ struct IncomingSite: Codable {
manager.localizedDescription = self.name
manager.isEnabled = true

manager.isOnDemandEnabled = self.alwaysOn == true
let rule = NEOnDemandRuleConnect()
rule.interfaceTypeMatch = .any
manager.onDemandRules = [rule]

manager.saveToPreferences { error in
return callback(error)
}
Expand Down
10 changes: 7 additions & 3 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,15 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
updater?.update(connected: false)

#else
let manager = self.sites?.getSite(id: id)?.manager
let container = self.sites?.getContainer(id: id)
let manager = container?.site.manager

manager?.loadFromPreferences { error in
//TODO: Handle load error

manager?.connection.stopVPNTunnel()
manager?.isOnDemandEnabled = false
manager?.saveToPreferences { _ in
manager?.connection.stopVPNTunnel()
}
return result(nil)
}
#endif
Expand Down
Loading
Loading