diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d798e98..b0187f37 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,7 @@ + android:value="true"/> Unit)? = null private lateinit var workManager: WorkManager private val refreshReceiver: BroadcastReceiver = RefreshReceiver() @@ -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) @@ -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("certs") if (certs == "") { @@ -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) } @@ -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("id") if (id == "") { @@ -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 } @@ -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) @@ -530,6 +569,8 @@ class MainActivity: FlutterActivity() { // a site while another is actively running. activeSiteId = null unbindVpnService() + onStopCallback?.invoke() + onStopCallback = null } } } diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt index fa0e648f..1ccb7ab3 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt @@ -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 @@ -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().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) diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt index 088c883a..e94ff67f 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt @@ -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 @@ -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() @@ -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) } @@ -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 @@ -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::class.java) @@ -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 @@ -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)) diff --git a/ios/NebulaNetworkExtension/Site.swift b/ios/NebulaNetworkExtension/Site.swift index 97a64a0a..31a9bada 100644 --- a/ios/NebulaNetworkExtension/Site.swift +++ b/ios/NebulaNetworkExtension/Site.swift @@ -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 @@ -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 { @@ -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] @@ -385,6 +388,7 @@ class Site: Codable { case lastManagedUpdate case dnsResolvers case rawConfig + case alwaysOn } } @@ -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? @@ -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) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5ea07705..1224bb4a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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 diff --git a/ios/Runner/Sites.swift b/ios/Runner/Sites.swift index ed7b198d..2686b01d 100644 --- a/ios/Runner/Sites.swift +++ b/ios/Runner/Sites.swift @@ -82,7 +82,6 @@ class SiteUpdater: NSObject, FlutterStreamHandler { private var eventSink: FlutterEventSink? private var eventChannel: FlutterEventChannel private var site: Site - private var notification: Any? public var startFunc: (() -> Void)? private var configFd: Int32? = nil private var configObserver: (any DispatchSourceFileSystemObject)? = nil @@ -137,31 +136,38 @@ class SiteUpdater: NSObject, FlutterStreamHandler { code: "Internal Error", message: "Flutter manager was not present", details: nil) } - self.notification = NotificationCenter.default.addObserver( - forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection, - queue: nil - ) { n in - let oldConnected = self.site.connected - self.site.status = statusString[self.site.manager!.connection.status] - self.site.connected = statusMap[self.site.manager!.connection.status] - - // Check to see if we just moved to connected and if we have a start function to call when that happens - if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil { - self.startFunc!() - self.startFunc = nil - } - - self.update(connected: self.site.connected!) - } + NotificationCenter.default.addObserver( + self, selector: #selector(onNotification), + name: NSNotification.Name.NEVPNConfigurationChange, object: site.manager) + NotificationCenter.default.addObserver( + self, selector: #selector(onNotification), name: NSNotification.Name.NEVPNStatusDidChange, + object: site.manager!.connection) #endif return nil } + @objc func onNotification(n: Notification) { + let oldConnected = self.site.connected + + self.site.status = statusString[self.site.manager!.connection.status] + self.site.connected = statusMap[self.site.manager!.connection.status] + self.site.alwaysOn = self.site.manager?.isOnDemandEnabled == true + + // Check to see if we just moved to connected and if we have a start function to call when that happens + if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil { + self.startFunc!() + self.startFunc = nil + } + + self.update(connected: self.site.connected!) + } + /// onCancel is called when the flutter listener stops listening func onCancel(withArguments arguments: Any?) -> FlutterError? { - if self.notification != nil { - NotificationCenter.default.removeObserver(self.notification!) - } + NotificationCenter.default.removeObserver( + self, name: NSNotification.Name.NEVPNConfigurationChange, object: nil) + NotificationCenter.default.removeObserver( + self, name: NSNotification.Name.NEVPNStatusDidChange, object: nil) return nil } diff --git a/lib/components/site_item.dart b/lib/components/site_item.dart index f38c9a95..818d5dd6 100644 --- a/lib/components/site_item.dart +++ b/lib/components/site_item.dart @@ -6,12 +6,17 @@ import 'config/config_item.dart'; import 'config/config_page_item.dart'; import 'config/config_section.dart'; -class SiteItem extends StatelessWidget { +class SiteItem extends StatefulWidget { const SiteItem({super.key, required this.site, this.onPressed}); final Site site; final void Function()? onPressed; + @override + State createState() => _SiteItemState(); +} + +class _SiteItemState extends State { @override Widget build(BuildContext context) { return _buildContent(context); @@ -23,9 +28,9 @@ class SiteItem extends StatelessWidget { List children = []; // Add the name - children.add(TextSpan(text: site.name, style: nameStyle)); + children.add(TextSpan(text: widget.site.name, style: nameStyle)); - if (site.managed) { + if (widget.site.managed) { // Toss some space in children.add(TextSpan(text: ' ', style: nameStyle)); @@ -50,7 +55,7 @@ class SiteItem extends StatelessWidget { Widget _siteStatusWidget(BuildContext context) { final grayTextColor = Theme.of(context).colorScheme.onSecondaryContainer; var fontStyle = TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500); - if (site.errors.isNotEmpty) { + if (widget.site.errors.isNotEmpty) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -61,56 +66,61 @@ class SiteItem extends StatelessWidget { ); } - return Text(site.status, style: fontStyle); + return Text(widget.site.status, style: fontStyle); } Widget _buildContent(BuildContext context) { - void handleChange(v) async { - try { - if (v) { - await site.start(); - } else { - await site.stop(); - } - } catch (error) { - var action = v ? 'start' : 'stop'; - Utils.popError('Failed to $action the site', error.toString()); - } - } - final grayTextColor = Theme.of(context).colorScheme.onSecondaryContainer; - return ConfigSection( - children: [ - ConfigItem( - labelWidth: 0, - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), - content: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [_siteNameWidget(context), Container(height: 4), _siteStatusWidget(context)], - ), - ), - Switch.adaptive( - value: site.connected, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onChanged: site.errors.isNotEmpty && !site.connected ? null : handleChange, + List children = []; + children.add( + ConfigItem( + labelWidth: 0, + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), + content: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_siteNameWidget(context), Container(height: 4), _siteStatusWidget(context)], ), - ], - ), + ), + Switch.adaptive( + value: widget.site.connected, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: widget.site.errors.isNotEmpty && !widget.site.connected ? null : toggleSite, + ), + ], ), - ConfigPageItem( - padding: EdgeInsets.fromLTRB(16, 8, 16, 16), - label: Text( - 'Details', - style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500), - ), - onPressed: onPressed, + ), + ); + + children.add( + ConfigPageItem( + padding: EdgeInsets.fromLTRB(16, 8, 16, 16), + label: Text( + 'Details', + style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500), ), - ], + onPressed: widget.onPressed, + ), ); + + return ConfigSection(children: children); + } + + void toggleSite(bool val) async { + try { + if (val) { + await widget.site.start(); + } else { + await widget.site.stop(); + } + setState(() {}); + } catch (error) { + var action = val ? 'start' : 'stop'; + Utils.popError('Failed to $action the site', error.toString()); + } } } diff --git a/lib/models/site.dart b/lib/models/site.dart index 2538926b..dbc5c830 100644 --- a/lib/models/site.dart +++ b/lib/models/site.dart @@ -55,6 +55,7 @@ class Site { late String status; late String logFile; late String logVerbosity; + late bool alwaysOn; late List dnsResolvers; late bool managed; @@ -85,6 +86,7 @@ class Site { this.managed = false, this.rawConfig, this.lastManagedUpdate, + this.alwaysOn = false, List? dnsResolvers, }) { this.id = id ?? uuid.v4(); @@ -141,6 +143,7 @@ class Site { rawConfig: decoded['rawConfig'], lastManagedUpdate: decoded['lastManagedUpdate'], dnsResolvers: decoded['dnsResolvers'], + alwaysOn: decoded['alwaysOn'], ); } @@ -186,6 +189,7 @@ class Site { rawConfig = decoded['rawConfig']; lastManagedUpdate = decoded['lastManagedUpdate']; dnsResolvers = decoded['dnsResolvers']; + alwaysOn = decoded['alwaysOn']; } static Map _fromJson(Map json) { @@ -245,6 +249,7 @@ class Site { "rawConfig": json['rawConfig'], "lastManagedUpdate": json["lastManagedUpdate"] == null ? null : DateTime.parse(json["lastManagedUpdate"]), "dnsResolvers": dnsResolvers, + "alwaysOn": json['alwaysOn'] ?? false, }; } @@ -274,6 +279,7 @@ class Site { 'managed': managed, 'rawConfig': rawConfig, 'dnsResolvers': dnsResolvers, + 'alwaysOn': alwaysOn, }; } diff --git a/lib/screens/site_detail_screen.dart b/lib/screens/site_detail_screen.dart index adbd0b3c..89c50cb0 100644 --- a/lib/screens/site_detail_screen.dart +++ b/lib/screens/site_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -105,6 +106,7 @@ class SiteDetailScreenState extends State { _buildConfig(), site.connected ? _buildHosts() : Container(), _buildSiteDetails(), + _buildAlwaysOn(), _buildDelete(), ], ), @@ -271,6 +273,69 @@ class SiteDetailScreenState extends State { ); } + Widget _buildAlwaysOn() { + if (Platform.isAndroid) { + return ConfigSection( + children: [ + ConfigItem( + label: Text('Always-on'), + content: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Switch.adaptive( + value: widget.site.alwaysOn, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (val) async { + widget.site.alwaysOn = val; + try { + await widget.site.save(); + } catch (e) { + Utils.popError('Failed to update always-on', e.toString()); + widget.site.alwaysOn = !val; + setState(() {}); + return; + } + setState(() {}); + }, + ), + ], + ), + ), + ConfigPageItem( + content: Text('Verify system always-on is enabled'), + onPressed: () { + platform.invokeMethod('android.openVpnSettings'); + }, + ), + ], + ); + } else if (Platform.isIOS || Platform.isMacOS) { + return ConfigSection( + children: [ + ConfigItem( + label: Text('Always-on'), + content: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Switch.adaptive( + value: widget.site.alwaysOn, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (val) async { + widget.site.alwaysOn = val; + await widget.site.save(); + setState(() {}); + }, + ), + ], + ), + ), + ], + ); + } + + return Container(); + } + Widget _buildDelete() { final outerContext = context; return Padding(