diff --git a/README.md b/README.md index 9ded59f..b79c8a8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Update `version` in `pubspec.yaml` to reflect this release, then ## Android -`flutter build appbundle --no-shrink` +`flutter build appbundle` This will create an android app bundle at `build/app/outputs/bundle/release/` diff --git a/android/app/build.gradle b/android/app/build.gradle index 1c5599f..5d5610f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -78,9 +78,12 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" implementation "androidx.security:security-crypto:1.0.0" + implementation "androidx.work:work-runtime-ktx:$workVersion" implementation 'com.google.code.gson:gson:2.8.6' + implementation "com.google.guava:guava:31.0.1-android" implementation project(':mobileNebula') + } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5a9c21e..7148619 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + + + + + + + + + + + + + try { + updateSite(site) + } catch (e: Exception) { + failed = true + Log.e(TAG, "Error while updating site ${site.id}: ${e.stackTraceToString()}") + return@forEach + } + } + + return if (failed) Result.failure() else Result.success(); + } + + fun updateSite(site: Site) { + try { + DNUpdateLock(site).use { + if (updater.updateSite(site)) { + // Reload Nebula if this is the currently active site + Intent().also { intent -> + intent.action = NebulaVpnService.ACTION_RELOAD + intent.putExtra("id", site.id) + context.sendBroadcast(intent) + } + Intent().also { intent -> + intent.action = MainActivity.ACTION_REFRESH_SITES + context.sendBroadcast(intent) + } + } + } + } catch (e: java.nio.channels.OverlappingFileLockException) { + Log.w(TAG, "Can't lock site ${site.name}, skipping it...") + } + } +} + +class DNUpdateLock(private val site: Site): Closeable { + private val fileChannel = FileChannel.open( + Paths.get(site.path+"/update.lock"), + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + ) + private val fileLock = fileChannel.tryLock() + + override fun close() { + fileLock.close() + fileChannel.close() + } +} + +class DNSiteUpdater( + private val context: Context, + private val apiClient: APIClient, +) { + fun updateSite(site: Site): Boolean { + if (!site.managed) { + return false + } + + val credentials = site.getDNCredentials(context) + + val newSite: IncomingSite? + try { + newSite = apiClient.tryUpdate( + site.name, + credentials.hostID, + credentials.privateKey, + credentials.counter.toLong(), + credentials.trustedKeys, + ) + } catch (e: InvalidCredentialsException) { + if (!credentials.invalid) { + site.invalidateDNCredentials(context) + Log.d(TAG, "Invalidated credentials in site ${site.name}") + } + return true + } + + if (newSite != null) { + newSite.save(context) + Log.d(TAG, "Updated site ${site.id}: ${site.name}") + return true + } + + if (credentials.invalid) { + site.validateDNCredentials(context) + Log.d(TAG, "Revalidated credentials in site ${site.id}: ${site.name}") + } + + return false + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt index 2738c4b..5e6cd65 100644 --- a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -1,35 +1,53 @@ package net.defined.mobile_nebula import android.app.Activity +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.ServiceConnection import android.net.VpnService import android.os.* +import android.util.Log import androidx.annotation.NonNull +import androidx.work.* +import com.google.common.base.Throwables +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.FutureCallback import com.google.gson.Gson import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugins.GeneratedPluginRegistrant +import java.io.File +import java.util.concurrent.TimeUnit const val TAG = "nebula" const val VPN_PERMISSIONS_CODE = 0x0F const val VPN_START_CODE = 0x10 const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService" +const val UPDATE_WORKER = "dnUpdater" class MainActivity: FlutterActivity() { - private var sites: Sites? = null - private var permResult: MethodChannel.Result? = null - private var inMessenger: Messenger? = Messenger(IncomingHandler()) private var outMessenger: Messenger? = null + private var apiClient: APIClient? = null + private var sites: Sites? = null + private var permResult: MethodChannel.Result? = null + + private var ui: MethodChannel? = null + private var activeSiteId: String? = null + private val workManager = WorkManager.getInstance(application) + private val refreshReceiver: BroadcastReceiver = RefreshReceiver() + companion object { + const val ACTION_REFRESH_SITES = "net.defined.mobileNebula.REFRESH_SITES" + private var appContext: Context? = null fun getContext(): Context? { return appContext } } @@ -38,10 +56,11 @@ class MainActivity: FlutterActivity() { appContext = context //TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this sites = Sites(flutterEngine) - + GeneratedPluginRegistrant.registerWith(flutterEngine); - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + ui = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + ui!!.setMethodCallHandler { call, result -> when(call.method) { "android.requestPermissions" -> androidPermissions(result) "android.registerActiveSite" -> registerActiveSite(result) @@ -51,6 +70,8 @@ class MainActivity: FlutterActivity() { "nebula.renderConfig" -> nebulaRenderConfig(call, result) "nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result) + "dn.enroll" -> dnEnroll(call, result) + "listSites" -> listSites(result) "deleteSite" -> deleteSite(call, result) "saveSite" -> saveSite(call, result) @@ -71,6 +92,30 @@ class MainActivity: FlutterActivity() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + apiClient = APIClient(context) + + registerReceiver(refreshReceiver, IntentFilter(ACTION_REFRESH_SITES)) + + enqueueDNUpdater() + } + + override fun onDestroy() { + super.onDestroy() + + unregisterReceiver(refreshReceiver) + } + + private fun enqueueDNUpdater() { + val workRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + workManager.enqueueUniquePeriodicWork( + UPDATE_WORKER, + ExistingPeriodicWorkPolicy.KEEP, + workRequest) + } + // This is called by the UI _after_ it has finished rendering the site list to avoid a race condition with detecting // the current active site and attaching site specific event channels in the event the UI app was quit private fun registerActiveSite(result: MethodChannel.Result) { @@ -124,6 +169,28 @@ class MainActivity: FlutterActivity() { } } + private fun dnEnroll(call: MethodCall, result: MethodChannel.Result) { + val code = call.arguments as String + if (code == "") { + return result.error("required_argument", "code is a required argument", null) + } + + val site: IncomingSite + val siteDir: File + try { + site = apiClient!!.enroll(code) + siteDir = site.save(context) + } catch (err: Exception) { + return result.error("unhandled_error", err.message, null) + } + + if (!validateOrDeleteSite(siteDir)) { + return result.error("failure", "Enrollment failed due to invalid config", null) + } + + result.success(null) + } + private fun listSites(result: MethodChannel.Result) { sites!!.refreshSites(activeSiteId) val sites = sites!!.getSites() @@ -143,40 +210,50 @@ class MainActivity: FlutterActivity() { private fun saveSite(call: MethodCall, result: MethodChannel.Result) { val site: IncomingSite + val siteDir: File try { val gson = Gson() site = gson.fromJson(call.arguments as String, IncomingSite::class.java) - site.save(context) + siteDir = site.save(context) } catch (err: Exception) { //TODO: is toString the best or .message? return result.error("failure", err.toString(), null) } - val siteDir = context.filesDir.resolve("sites").resolve(site.id) - try { - // Try to render a full site, if this fails the config was bad somehow - Site(siteDir) - } catch (err: Exception) { - siteDir.deleteRecursively() + if (!validateOrDeleteSite(siteDir)) { return result.error("failure", "Site config was incomplete, please review and try again", null) } result.success(null) } + private fun validateOrDeleteSite(siteDir: File): Boolean { + try { + // Try to render a full site, if this fails the config was bad somehow + val site = Site(context, siteDir) + } catch(err: java.io.FileNotFoundException) { + Log.e(TAG, "Site not found at ${siteDir}") + return false + } catch(err: Exception) { + Log.e(TAG, "Deleting site at ${siteDir} due to error: ${err}") + siteDir.deleteRecursively() + return false + } + return true + } + private fun startSite(call: MethodCall, result: MethodChannel.Result) { val id = call.argument("id") if (id == "") { return result.error("required_argument", "id is a required argument", null) } - var siteContainer: SiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null) + val siteContainer: SiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null) - siteContainer.site.connected = true - siteContainer.site.status = "Initializing..." + siteContainer.updater.setState(true, "Initializing...") - val intent = VpnService.prepare(this) + var intent = VpnService.prepare(this) if (intent != null) { //TODO: ensure this boots the correct bit, I bet it doesn't and we need to go back to the active symlink intent.putExtra("path", siteContainer.site.path) @@ -184,7 +261,7 @@ class MainActivity: FlutterActivity() { startActivityForResult(intent, VPN_START_CODE) } else { - val intent = Intent(this, NebulaVpnService::class.java) + intent = Intent(this, NebulaVpnService::class.java) intent.putExtra("path", siteContainer.site.path) intent.putExtra("id", siteContainer.site.id) onActivityResult(VPN_START_CODE, Activity.RESULT_OK, intent) @@ -254,7 +331,7 @@ class MainActivity: FlutterActivity() { } val pending = call.argument("pending") ?: false - + if (outMessenger == null || activeSiteId == null || activeSiteId != id) { return result.success(null) } @@ -302,7 +379,7 @@ class MainActivity: FlutterActivity() { }) outMessenger?.send(msg) } - + private fun activeCloseTunnel(call: MethodCall, result: MethodChannel.Result) { val id = call.argument("id") if (id == "") { @@ -355,7 +432,8 @@ class MainActivity: FlutterActivity() { return result.error("PERMISSIONS", "User did not grant permission", null) } else if (requestCode == VPN_START_CODE) { - // We are processing a response for permissions while starting the VPN (or reusing code in the event we already have perms) + // We are processing a response for permissions while starting the VPN + // (or reusing code in the event we already have perms) startService(data) if (outMessenger == null) { bindService(data, connection, 0) @@ -368,14 +446,15 @@ class MainActivity: FlutterActivity() { } /** Defines callbacks for service binding, passed to bindService() */ - val connection = object : ServiceConnection { + private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { outMessenger = Messenger(service) + // We want to monitor the service for as long as we are connected to it. try { val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT) msg.replyTo = inMessenger - outMessenger?.send(msg) + outMessenger!!.send(msg) } catch (e: RemoteException) { // In this case the service has crashed before we could even @@ -386,7 +465,7 @@ class MainActivity: FlutterActivity() { } val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING) - outMessenger?.send(msg) + outMessenger!!.send(msg) } override fun onServiceDisconnected(arg0: ComponentName) { @@ -416,7 +495,7 @@ class MainActivity: FlutterActivity() { private fun isRunning(site: SiteContainer, msg: Message) { var status = "Disconnected" var connected = false - + if (msg.arg1 == 1) { status = "Connected" connected = true @@ -429,6 +508,32 @@ class MainActivity: FlutterActivity() { private fun serviceExited(site: SiteContainer, msg: Message) { activeSiteId = null site.updater.setState(false, "Disconnected", msg.data.getString("error")) + unbindVpnService() } } + + private fun unbindVpnService() { + if (outMessenger != null) { + // Unregister ourselves + val msg = Message.obtain(null, NebulaVpnService.MSG_UNREGISTER_CLIENT) + msg.replyTo = inMessenger + outMessenger!!.send(msg) + // Unbind + unbindService(connection) + } + outMessenger = null + } + + inner class RefreshReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.getAction() != ACTION_REFRESH_SITES) return + if (sites == null) return + + Log.d(TAG, "Refreshing sites in MainActivity") + + sites?.refreshSites(activeSiteId) + ui?.invokeMethod("refreshSites", null) + } + } + } diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MyApplication.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MyApplication.kt new file mode 100644 index 0000000..8ff8fd7 --- /dev/null +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MyApplication.kt @@ -0,0 +1,19 @@ +package net.defined.mobile_nebula + +import io.flutter.view.FlutterMain +import android.app.Application +import androidx.work.Configuration +import androidx.work.WorkManager + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + // In order to use the WorkManager from the nebulaVpnBg process (i.e. NebulaVpnService) + // we must explicitly initialize this rather than using the default initializer. + val myConfig = Configuration.Builder().build() + WorkManager.initialize(this, myConfig) + + FlutterMain.startInitialization(applicationContext) + } +} \ No newline at end of file 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 75d48d2..7905623 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 @@ -10,6 +10,7 @@ import android.os.* import android.system.OsConstants import android.util.Log import androidx.annotation.RequiresApi +import androidx.work.* import mobileNebula.CIDR import java.io.File @@ -17,8 +18,11 @@ import java.io.File class NebulaVpnService : VpnService() { companion object { - private const val TAG = "NebulaVpnService" + const val TAG = "NebulaVpnService" + const val ACTION_STOP = "net.defined.mobile_nebula.STOP" + const val ACTION_RELOAD = "net.defined.mobile_nebula.RELOAD" + const val MSG_REGISTER_CLIENT = 1 const val MSG_UNREGISTER_CLIENT = 2 const val MSG_IS_RUNNING = 3 @@ -36,6 +40,10 @@ class NebulaVpnService : VpnService() { private lateinit var messenger: Messenger private val mClients = ArrayList() + private val reloadReceiver: BroadcastReceiver = ReloadReceiver() + private var workManager: WorkManager? = null + + private var path: String? = null private var running: Boolean = false private var site: Site? = null private var nebula: mobileNebula.Nebula? = null @@ -43,13 +51,17 @@ class NebulaVpnService : VpnService() { private var didSleep = false private var networkCallback: NetworkCallback = NetworkCallback() + override fun onCreate() { + workManager = WorkManager.getInstance(this) + super.onCreate() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.getAction() == ACTION_STOP) { stopVpn() return Service.START_NOT_STICKY } - val path = intent?.getStringExtra("path") val id = intent?.getStringExtra("id") if (running) { @@ -63,9 +75,10 @@ class NebulaVpnService : VpnService() { return super.onStartCommand(intent, flags, startId) } + 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(File(path)) + site = Site(this, File(path)) if (site!!.cert == null) { announceExit(id, "Site is missing a certificate") @@ -73,6 +86,10 @@ class NebulaVpnService : VpnService() { return super.onStartCommand(intent, flags, startId) } + // Kick off a site update + val workRequest = OneTimeWorkRequestBuilder().build() + workManager!!.enqueue(workRequest) + // 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) @@ -117,6 +134,7 @@ class NebulaVpnService : VpnService() { } registerNetworkCallback() + registerReloadReceiver() //TODO: There is an open discussion around sleep killing tunnels or just changing mobile to tear down stale tunnels //registerSleep() @@ -173,12 +191,26 @@ class NebulaVpnService : VpnService() { registerReceiver(receiver, IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)) } + private fun registerReloadReceiver() { + registerReceiver(reloadReceiver, IntentFilter(ACTION_RELOAD)) + } + + private fun unregisterReloadReceiver() { + unregisterReceiver(reloadReceiver) + } + + private fun reload() { + site = Site(this, File(path)) + nebula?.reload(site!!.config, site!!.getKey(this)) + } + private fun stopVpn() { if (nebula == null) { return stopSelf() } unregisterNetworkCallback() + unregisterReloadReceiver() nebula?.stop() nebula = null running = false @@ -207,6 +239,18 @@ class NebulaVpnService : VpnService() { send(msg, id) } + inner class ReloadReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.getAction() != ACTION_RELOAD) return + if (!running) return + if (intent?.getStringExtra("id") != site!!.id) return + + Log.d(TAG, "Reloading Nebula") + + reload() + } + } + /** * Handler of incoming messages from clients. */ diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/PackageInfo.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/PackageInfo.kt new file mode 100644 index 0000000..c3f83c1 --- /dev/null +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/PackageInfo.kt @@ -0,0 +1,27 @@ +package net.defined.mobile_nebula + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageInfo +import android.os.Build + +class PackageInfo(val context: Context) { + private val pInfo: PackageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0) + private val appInfo: ApplicationInfo = context.getApplicationInfo() + + fun getVersion(): String { + val version: String = pInfo.versionName + val build: Int = pInfo.versionCode + return "%s-%d".format(version, build) + } + + fun getName(): String { + val stringId = appInfo.labelRes + return if (stringId == 0) appInfo.nonLocalizedLabel.toString() else context.getString(stringId) + } + + fun getSystemVersion(): String { + return Build.VERSION.RELEASE; + } +} \ No newline at end of file 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 a6b276e..f923f5b 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 @@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import java.io.File +import java.io.FileNotFoundException import kotlin.collections.HashMap data class SiteContainer( @@ -16,7 +17,7 @@ data class SiteContainer( ) class Sites(private var engine: FlutterEngine) { - private var sites: HashMap = HashMap() + private var containers: HashMap = HashMap() init { refreshSites() @@ -24,65 +25,111 @@ class Sites(private var engine: FlutterEngine) { fun refreshSites(activeSite: String? = null) { val context = MainActivity.getContext()!! - val sitesDir = context.filesDir.resolve("sites") - if (!sitesDir.isDirectory) { - sitesDir.delete() - sitesDir.mkdir() - } - - sites = HashMap() - sitesDir.listFiles().forEach { siteDir -> - try { - val site = Site(siteDir) - - // Make sure we can load the private key - site.getKey(context) - - val updater = SiteUpdater(site, engine) - if (site.id == activeSite) { - updater.setState(true, "Connected") - } - - this.sites[site.id] = SiteContainer(site, updater) - - } catch (err: Exception) { - siteDir.deleteRecursively() - Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err) + val sites = SiteList(context) + val containers: HashMap = HashMap() + sites.getSites().values.forEach { site -> + // Don't create a new SiteUpdater or we will lose subscribers + var updater = this.containers[site.id]?.updater + if (updater != null) { + updater.setSite(site) + } else { + updater = SiteUpdater(site, engine) } + + if (site.id == activeSite) { + updater.setState(true, "Connected") + } + + containers[site.id] = SiteContainer(site, updater) } + this.containers = containers } fun getSites(): Map { - return sites.mapValues { it.value.site } + return containers.mapValues { it.value.site } } fun deleteSite(id: String) { - sites.remove(id) val siteDir = MainActivity.getContext()!!.filesDir.resolve("sites").resolve(id) siteDir.deleteRecursively() + refreshSites() //TODO: make sure you stop the vpn //TODO: make sure you relink the active site if this is the active site } - + fun getSite(id: String): SiteContainer? { - return sites[id] + return containers[id] + } +} + +class SiteList(context: Context) { + private var sites: Map + + init { + val nebulaSites = getSites(context, context.filesDir) + val dnSites = getSites(context, context.noBackupFilesDir) + + // In case of a conflict, dnSites will take precedence. + sites = nebulaSites + dnSites + } + + fun getSites(): Map { + return sites + } + + companion object { + fun getSites(context: Context, directory: File): HashMap { + val sites = HashMap() + + val sitesDir = directory.resolve("sites") + + if (!sitesDir.isDirectory) { + sitesDir.delete() + sitesDir.mkdir() + } + + sitesDir.listFiles()?.forEach { siteDir -> + try { + val site = Site(context, siteDir) + + // Make sure we can load the private key + site.getKey(context) + + // Make sure we can load the DN credentials if managed + if (site.managed) { + site.getDNCredentials(context) + } + + sites[site.id] = site + } catch (err: Exception) { + siteDir.deleteRecursively() + Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err) + } + } + + return sites + } } } class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler { + private val gson = Gson() // eventSink is how we send info back up to flutter private var eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}") private var eventSink: EventChannel.EventSink? = null - + + fun setSite(site: Site) { + this.site = site + } + fun setState(connected: Boolean, status: String, err: String? = null) { site.connected = connected site.status = status - val d = mapOf("connected" to site.connected, "status" to site.status) if (err != null) { - eventSink?.error("", err, d) + eventSink?.error("", err, gson.toJson(site)) } else { - eventSink?.success(d) + eventSink?.success(gson.toJson(site)) } } @@ -130,7 +177,24 @@ data class CertificateValidity( @SerializedName("Reason") val reason: String ) -class Site { +data class DNCredentials( + val hostID: String, + val privateKey: String, + val counter: Int, + val trustedKeys: String, + var invalid: Boolean, +) { + fun save(context: Context, siteDir: File) { + val jsonCreds = Gson().toJson(this) + + val credsFile = siteDir.resolve("dnCredentials") + credsFile.delete() + + EncFile(context).openWrite(credsFile).use { it.write(jsonCreds) } + } +} + +class Site(context: Context, siteDir: File) { val name: String val id: String val staticHostmap: HashMap @@ -142,21 +206,25 @@ class Site { val mtu: Int val cipher: String val sortKey: Int - var logVerbosity: String + val logVerbosity: String var connected: Boolean? var status: String? val logFile: String? var errors: ArrayList = ArrayList() - + val managed: Boolean + // The following fields are present when managed = true + val rawConfig: String? + val lastManagedUpdate: String? + // Path to this site on disk - @Expose(serialize = false) + @Transient val path: String // Strong representation of the site config - @Expose(serialize = false) + @Transient val config: String - - constructor(siteDir: File) { + + init { val gson = Gson() config = siteDir.resolve("config.json").readText() val incomingSite = gson.fromJson(config, IncomingSite::class.java) @@ -173,6 +241,9 @@ class Site { sortKey = incomingSite.sortKey ?: 0 logFile = siteDir.resolve("log").absolutePath logVerbosity = incomingSite.logVerbosity ?: "info" + rawConfig = incomingSite.rawConfig + managed = incomingSite.managed ?: false + lastManagedUpdate = incomingSite.lastManagedUpdate connected = false status = "Disconnected" @@ -211,6 +282,10 @@ class Site { errors.add("Error while loading certificate authorities: ${err.message}") } + if (managed && getDNCredentials(context).invalid) { + errors.add("Unable to fetch updates - please re-enroll the device") + } + if (errors.isEmpty()) { try { mobileNebula.MobileNebula.testConfig(config, getKey(MainActivity.getContext()!!)) @@ -220,12 +295,31 @@ class Site { } } - fun getKey(context: Context): String? { + fun getKey(context: Context): String { val f = EncFile(context).openRead(File(path).resolve("key")) val k = f.readText() f.close() return k } + + fun getDNCredentials(context: Context): DNCredentials { + val filepath = File(path).resolve("dnCredentials") + val f = EncFile(context).openRead(filepath) + val cfg = f.use { it.readText() } + return Gson().fromJson(cfg, DNCredentials::class.java) + } + + fun invalidateDNCredentials(context: Context) { + val creds = getDNCredentials(context) + creds.invalid = true + creds.save(context, File(path)) + } + + fun validateDNCredentials(context: Context) { + val creds = getDNCredentials(context) + creds.invalid = false + creds.save(context, File(path)) + } } data class StaticHosts( @@ -251,13 +345,18 @@ class IncomingSite( val mtu: Int?, val cipher: String, val sortKey: Int?, - var logVerbosity: String?, - @Expose(serialize = false) - var key: String? + val logVerbosity: String?, + var key: String?, + val managed: Boolean?, + // The following fields are present when managed = true + val lastManagedUpdate: String?, + val rawConfig: String?, + var dnCredentials: DNCredentials?, ) { - - fun save(context: Context) { - val siteDir = context.filesDir.resolve("sites").resolve(id) + fun save(context: Context): File { + // Don't allow backups of DN-managed sites + val baseDir = if(managed == true) context.noBackupFilesDir else context.filesDir + val siteDir = baseDir.resolve("sites").resolve(id) if (!siteDir.exists()) { siteDir.mkdir() } @@ -269,10 +368,14 @@ class IncomingSite( encFile.use { it.write(key) } encFile.close() } - key = null - val gson = Gson() + + dnCredentials?.save(context, siteDir) + dnCredentials = null + val confFile = siteDir.resolve("config.json") - confFile.writeText(gson.toJson(this)) + confFile.writeText(Gson().toJson(this)) + + return siteDir } } diff --git a/android/build.gradle b/android/build.gradle index 83ae220..36aa165 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,9 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext { + workVersion = "2.7.1" + kotlinVersion = '1.6.10' + } + repositories { google() mavenCentral() @@ -7,7 +11,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.1.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } diff --git a/images/dn-logo-dark.svg b/images/dn-logo-dark.svg new file mode 100644 index 0000000..5becf15 --- /dev/null +++ b/images/dn-logo-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dn-logo-light.svg b/images/dn-logo-light.svg new file mode 100644 index 0000000..aa610d2 --- /dev/null +++ b/images/dn-logo-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/NebulaNetworkExtension/Keychain.swift b/ios/NebulaNetworkExtension/Keychain.swift index 2be94ee..0668987 100644 --- a/ios/NebulaNetworkExtension/Keychain.swift +++ b/ios/NebulaNetworkExtension/Keychain.swift @@ -3,17 +3,21 @@ import Foundation let groupName = "group.net.defined.mobileNebula" class KeyChain { - class func save(key: String, data: Data) -> Bool { - let query: [String: Any] = [ + class func save(key: String, data: Data, managed: Bool) -> Bool { + var query: [String: Any] = [ kSecClass as String : kSecClassGenericPassword as String, kSecAttrAccount as String : key, kSecValueData as String : data, kSecAttrAccessGroup as String: groupName, ] + + if (managed) { + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + } - SecItemDelete(query as CFDictionary) - let val = SecItemAdd(query as CFDictionary, nil) - return val == 0 + // Attempt to delete an existing key to allow for an overwrite + _ = self.delete(key: key) + return SecItemAdd(query as CFDictionary, nil) == 0 } class func load(key: String) -> Data? { @@ -38,10 +42,8 @@ class KeyChain { class func delete(key: String) -> Bool { let query: [String: Any] = [ - kSecClass as String : kSecClassGenericPassword, + kSecClass as String : kSecClassGenericPassword as String, kSecAttrAccount as String : key, - kSecReturnData as String : kCFBooleanTrue!, - kSecMatchLimit as String : kSecMatchLimitOne, kSecAttrAccessGroup as String: groupName, ] diff --git a/ios/NebulaNetworkExtension/PacketTunnelProvider.swift b/ios/NebulaNetworkExtension/PacketTunnelProvider.swift index f0c8644..c7b9cee 100644 --- a/ios/NebulaNetworkExtension/PacketTunnelProvider.swift +++ b/ios/NebulaNetworkExtension/PacketTunnelProvider.swift @@ -7,18 +7,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var networkMonitor: NWPathMonitor? private var site: Site? - private var _log = OSLog(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider") + private var log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider") private var nebula: MobileNebulaNebula? + private var dnUpdater = DNUpdater() private var didSleep = false private var cachedRouteDescription: String? // This is the system completionHandler, only set when we expect the UI to ask us to actually start so that errors can flow back to the UI private var startCompleter: ((Error?) -> Void)? - private func log(_ message: StaticString, _ args: Any...) { - os_log(message, log: _log, args) - } - override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { // There is currently no way to get initialization errors back to the UI via completionHandler here // `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept @@ -39,16 +36,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { var key: String do { - config = proto.providerConfiguration?["config"] as! Data site = try Site(proto: proto) + config = try site!.getConfig() } catch { //TODO: need a way to notify the app - log("Failed to render config from vpn object") + log.error("Failed to render config from vpn object") return completionHandler(error) } let _site = site! - _log = OSLog(subsystem: "net.defined.mobileNebula:\(_site.name)", category: "PacketTunnelProvider") do { key = try _site.getKey() @@ -96,14 +92,27 @@ class PacketTunnelProvider: NEPacketTunnelProvider { self.startNetworkMonitor() if err != nil { + self.log.error("We had an error starting up: \(err, privacy: .public)") return completionHandler(err!) } - + self.nebula!.start() + self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate) + completionHandler(nil) }) } + private func handleDNUpdate(newSite: Site) { + do { + self.site = newSite + try self.nebula?.reload(String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey()) + + } catch { + self.log.error("Got an error while updating nebula \(error.localizedDescription, privacy: .public)") + } + } + //TODO: Sleep/wake get called aggresively and do nothing to help us here, we should locate why that is and make these work appropriately // override func sleep(completionHandler: @escaping () -> Void) { // nebula!.sleep() @@ -156,7 +165,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) { guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else { - log("Failed to decode IPCRequest from network extension") + log.error("Failed to decode IPCRequest from network extension") return } @@ -196,7 +205,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { if nebula == nil { // Respond with an empty success message in the event a command comes in before we've truly started - log("Received command but do not have a nebula instance") + log.warning("Received command but do not have a nebula instance") return completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))) } diff --git a/ios/NebulaNetworkExtension/Site.swift b/ios/NebulaNetworkExtension/Site.swift index cf95aab..a9d50d8 100644 --- a/ios/NebulaNetworkExtension/Site.swift +++ b/ios/NebulaNetworkExtension/Site.swift @@ -13,7 +13,7 @@ class IPCResponse: Codable { var type: IPCResponseType //TODO: change message to data? var message: JSON? - + init(type: IPCResponseType, message: JSON?) { self.type = type self.message = message @@ -23,12 +23,12 @@ class IPCResponse: Codable { class IPCRequest: Codable { var command: String var arguments: JSON? - + init(command: String, arguments: JSON?) { self.command = command self.arguments = arguments } - + init(command: String) { self.command = command } @@ -38,7 +38,7 @@ struct CertificateInfo: Codable { var cert: Certificate var rawCert: String var validity: CertificateValidity - + enum CodingKeys: String, CodingKey { case cert = "Cert" case rawCert = "RawCert" @@ -50,7 +50,7 @@ struct Certificate: Codable { var fingerprint: String var signature: String var details: CertificateDetails - + /// An empty initilizer to make error reporting easier init() { fingerprint = "" @@ -69,7 +69,7 @@ struct CertificateDetails: Codable { var subnets: [String] var isCa: Bool var issuer: String - + /// An empty initilizer to make error reporting easier init() { name = "" @@ -87,7 +87,7 @@ struct CertificateDetails: Codable { struct CertificateValidity: Codable { var valid: Bool var reason: String - + enum CodingKeys: String, CodingKey { case valid = "Valid" case reason = "Reason" @@ -117,7 +117,7 @@ class Site: Codable { // Stored in manager var name: String var id: String - + // Stored in proto var staticHostmap: Dictionary var unsafeRoutes: [UnsafeRoute] @@ -132,13 +132,21 @@ class Site: Codable { var connected: Bool? //TODO: active is a better name var status: String? var logFile: String? - + var managed: Bool + // The following fields are present if managed = true + var lastManagedUpdate: String? + + /// If true then this site needs to be migrated to the filesystem. Should be handled by the initiator of the site + var needsToMigrateToFS: Bool = false + // A list of error encountered when trying to rehydrate a site from config var errors: [String] - + var manager: NETunnelProviderManager? - - // Creates a new site from a vpn manager instance + + var incomingSite: IncomingSite? + + /// Creates a new site from a vpn manager instance. Mainly used by the UI. A manager is required to be able to edit the system profile convenience init(manager: NETunnelProviderManager) throws { //TODO: Throw an error and have Sites delete the site, notify the user instead of using ! let proto = manager.protocolConfiguration as! NETunnelProviderProtocol @@ -147,33 +155,54 @@ class Site: Codable { self.connected = statusMap[manager.connection.status] self.status = statusString[manager.connection.status] } - + convenience init(proto: NETunnelProviderProtocol) throws { let dict = proto.providerConfiguration - let config = dict?["config"] as? Data ?? Data() + + if dict?["config"] != nil { + let config = dict?["config"] as? Data ?? Data() + let decoder = JSONDecoder() + let incoming = try decoder.decode(IncomingSite.self, from: config) + self.init(incoming: incoming) + self.needsToMigrateToFS = true + return + } + + let id = dict?["id"] as? String ?? nil + if id == nil { + throw("Non-conforming site \(String(describing: dict))") + } + + try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false)) + } + + /// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager + convenience init(path: URL) throws { + let config = try Data(contentsOf: path) let decoder = JSONDecoder() let incoming = try decoder.decode(IncomingSite.self, from: config) self.init(incoming: incoming) } - + init(incoming: IncomingSite) { var err: NSError? - + + incomingSite = incoming errors = [] name = incoming.name id = incoming.id staticHostmap = incoming.staticHostmap unsafeRoutes = incoming.unsafeRoutes ?? [] - + do { let rawCert = incoming.cert let rawDetails = MobileNebulaParseCerts(rawCert, &err) if (err != nil) { throw err! } - + var certs: [CertificateInfo] - + certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!) if (certs.count == 0) { throw "No certificate found" @@ -182,11 +211,11 @@ class Site: Codable { if (!cert!.validity.valid) { errors.append("Certificate is invalid: \(cert!.validity.reason)") } - + } catch { errors.append("Error while loading certificate: \(error.localizedDescription)") } - + do { let rawCa = incoming.ca let rawCaDetails = MobileNebulaParseCerts(rawCa, &err) @@ -194,31 +223,43 @@ class Site: Codable { throw err! } ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!) - + var hasErrors = false ca.forEach { cert in if (!cert.validity.valid) { hasErrors = true } } - + if (hasErrors) { errors.append("There are issues with 1 or more ca certificates") } - + } catch { ca = [] errors.append("Error while loading certificate authorities: \(error.localizedDescription)") } - + + do { + logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path + } catch { + logFile = nil + errors.append("Unable to create the site directory: \(error.localizedDescription)") + } + lhDuration = incoming.lhDuration port = incoming.port cipher = incoming.cipher sortKey = incoming.sortKey ?? 0 logVerbosity = incoming.logVerbosity ?? "info" mtu = incoming.mtu ?? 1300 - logFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")?.appendingPathComponent(id).appendingPathExtension("log").path - + managed = incoming.managed ?? false + lastManagedUpdate = incoming.lastManagedUpdate + + if (managed && (try? getDNCredentials())?.invalid != false) { + errors.append("Unable to fetch managed updates - please re-enroll the device") + } + if (errors.isEmpty) { do { let encoder = JSONEncoder() @@ -226,6 +267,7 @@ class Site: Codable { let key = try getKey() let strConfig = String(data: rawConfig, encoding: .utf8) var err: NSError? + MobileNebulaTestConfig(strConfig, key, &err) if (err != nil) { throw err! @@ -235,17 +277,53 @@ class Site: Codable { } } } - + // Gets the private key from the keystore, we don't always need it in memory func getKey() throws -> String { guard let keyData = KeyChain.load(key: "\(id).key") else { - throw "failed to get key material from keychain" + throw "failed to get key from keychain" } //TODO: make sure this is valid on return! return String(decoding: keyData, as: UTF8.self) } + + func getDNCredentials() throws -> DNCredentials { + if (!managed) { + throw "unmanaged site has no dn credentials" + } + + let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials") + if rawDNCredentials == nil { + throw "failed to find dn credentials in keychain" + } + + let decoder = JSONDecoder() + return try decoder.decode(DNCredentials.self, from: rawDNCredentials!) + } + + func invalidateDNCredentials() throws { + let creds = try getDNCredentials() + creds.invalid = true + + if (!(try creds.save(siteID: self.id))) { + throw "failed to store dn credentials in keychain" + } + } + func validateDNCredentials() throws { + let creds = try getDNCredentials() + creds.invalid = false + + if (!(try creds.save(siteID: self.id))) { + throw "failed to store dn credentials in keychain" + } + } + + func getConfig() throws -> Data { + return try self.incomingSite!.getConfig() + } + // Limits what we export to the UI private enum CodingKeys: String, CodingKey { case name @@ -264,6 +342,8 @@ class Site: Codable { case logVerbosity case errors case mtu + case managed + case lastManagedUpdate } } @@ -278,6 +358,34 @@ class UnsafeRoute: Codable { var mtu: Int? } +class DNCredentials: Codable { + var hostID: String + var privateKey: String + var counter: Int + var trustedKeys: String + var invalid: Bool { + get { return _invalid ?? false } + set { _invalid = newValue } + } + + private var _invalid: Bool? + + func save(siteID: String) throws -> Bool { + let encoder = JSONEncoder() + let rawDNCredentials = try encoder.encode(self) + + return KeyChain.save(key: "\(siteID).dnCredentials", data: rawDNCredentials, managed: true) + } + + enum CodingKeys: String, CodingKey { + case hostID + case privateKey + case counter + case trustedKeys + case _invalid = "invalid" + } +} + // This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site struct IncomingSite: Codable { var name: String @@ -293,76 +401,97 @@ struct IncomingSite: Codable { var sortKey: Int? var logVerbosity: String? var key: String? - - func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) { -#if targetEnvironment(simulator) - let fileManager = FileManager.default - let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id) + var managed: Bool? + // The following fields are present if managed = true + var dnCredentials: DNCredentials? + var lastManagedUpdate: String? + + func getConfig() throws -> Data { let encoder = JSONEncoder() + var config = self + + config.key = nil + config.dnCredentials = nil + + return try encoder.encode(config) + } + + func save(manager: NETunnelProviderManager?, saveToManager: Bool = true, callback: @escaping (Error?) -> ()) { + let configPath: URL do { - var config = self - config.key = nil - let rawConfig = try encoder.encode(config) - try rawConfig.write(to: sitePath) + configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true) + + } catch { + callback(error) + return + } + + print("Saving to \(configPath)") + do { + if (self.key != nil) { + let data = self.key!.data(using: .utf8) + if (!KeyChain.save(key: "\(self.id).key", data: data!, managed: self.managed ?? false)) { + return callback("failed to store key material in keychain") + } + } + + do { + if ((try self.dnCredentials?.save(siteID: self.id)) == false) { + return callback("failed to store dn credentials in keychain") + } + } catch { + return callback(error) + } + + try self.getConfig().write(to: configPath) + } catch { return callback(error) } - + + +#if targetEnvironment(simulator) + // We are on a simulator and there is no NEVPNManager for us to interact with callback(nil) #else + if saveToManager { + self.saveToManager(manager: manager, callback: callback) + } else { + callback(nil) + } +#endif + } + + private func saveToManager(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) { if (manager != nil) { // We need to refresh our settings to properly update config manager?.loadFromPreferences { error in if (error != nil) { return callback(error) } - - return self.finish(manager: manager!, callback: callback) + + return self.finishSaveToManager(manager: manager!, callback: callback) } return } - - return finish(manager: NETunnelProviderManager(), callback: callback) -#endif - } - - private func finish(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) { - var config = self - - // Store the private key if it was provided - if (config.key != nil) { - //TODO: should we ensure the resulting data is big enough? (conversion didn't fail) - let data = config.key!.data(using: .utf8) - if (!KeyChain.save(key: "\(config.id).key", data: data!)) { - return callback("failed to store key material in keychain") - } - } - - // Zero out the key so that we don't save it in the profile - config.key = nil + return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback) + } + + private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) { // Stuff our details in the protocol let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol() - let encoder = JSONEncoder() - let rawConfig: Data - // We tried using NSSecureCoder but that was obnoxious and didn't work so back to JSON - do { - rawConfig = try encoder.encode(config) - } catch { - return callback(error) - } - - proto.providerConfiguration = ["config": rawConfig] + proto.providerConfiguration = ["id": self.id] proto.serverAddress = "Nebula" - + // Finish up the manager, this is what stores everything at the system level manager.protocolConfiguration = proto //TODO: cert name? manager.protocolConfiguration?.username //TODO: This is what is shown on the vpn page. We should add more identifying details in - manager.localizedDescription = config.name + manager.localizedDescription = self.name manager.isEnabled = true manager.saveToPreferences{ error in diff --git a/ios/NebulaNetworkExtension/SiteList.swift b/ios/NebulaNetworkExtension/SiteList.swift new file mode 100644 index 0000000..2a0a478 --- /dev/null +++ b/ios/NebulaNetworkExtension/SiteList.swift @@ -0,0 +1,140 @@ +import NetworkExtension + +class SiteList { + private var sites = [String: Site]() + + /// Gets the root directory that can be used to share files between the UI and VPN process. Does ensure the directory exists + static func getRootDir() throws -> URL { + let fileManager = FileManager.default + let rootDir = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")! + + if (!fileManager.fileExists(atPath: rootDir.absoluteString)) { + try fileManager.createDirectory(at: rootDir, withIntermediateDirectories: true) + } + + return rootDir + } + + /// Gets the directory where all sites live, $rootDir/sites. Does ensure the directory exists + static func getSitesDir() throws -> URL { + let fileManager = FileManager.default + let sitesDir = try getRootDir().appendingPathComponent("sites", isDirectory: true) + if (!fileManager.fileExists(atPath: sitesDir.absoluteString)) { + try fileManager.createDirectory(at: sitesDir, withIntermediateDirectories: true) + } + return sitesDir + } + + /// Gets the directory where a single site would live, $rootDir/sites/$siteID + static func getSiteDir(id: String, create: Bool = false) throws -> URL { + let fileManager = FileManager.default + let siteDir = try getSitesDir().appendingPathComponent(id, isDirectory: true) + if (create && !fileManager.fileExists(atPath: siteDir.absoluteString)) { + try fileManager.createDirectory(at: siteDir, withIntermediateDirectories: true) + } + return siteDir + } + + /// Gets the file that represents the site configuration, $rootDir/sites/$siteID/config.json + static func getSiteConfigFile(id: String, createDir: Bool) throws -> URL { + return try getSiteDir(id: id, create: createDir).appendingPathComponent("config", isDirectory: false).appendingPathExtension("json") + } + + /// Gets the file that represents the site log output, $rootDir/sites/$siteID/log + static func getSiteLogFile(id: String, createDir: Bool) throws -> URL { + return try getSiteDir(id: id, create: createDir).appendingPathComponent("logs", isDirectory: false) + } + + init(completion: @escaping ([String: Site]?, Error?) -> ()) { +#if targetEnvironment(simulator) + SiteList.loadAllFromFS { sites, err in + if sites != nil { + self.sites = sites! + } + completion(sites, err) + } +#else + SiteList.loadAllFromNETPM { sites, err in + if sites != nil { + self.sites = sites! + } + completion(sites, err) + } +#endif + } + + private static func loadAllFromFS(completion: @escaping ([String: Site]?, Error?) -> ()) { + let fileManager = FileManager.default + var siteDirs: [URL] + var sites = [String: Site]() + + do { + siteDirs = try fileManager.contentsOfDirectory(at: getSitesDir(), includingPropertiesForKeys: nil) + + } catch { + completion(nil, error) + return + } + + siteDirs.forEach { path in + do { + let site = try Site(path: path.appendingPathComponent("config").appendingPathExtension("json")) + sites[site.id] = site + + } catch { + print(error) + try? fileManager.removeItem(at: path) + print("Deleted non conforming site \(path)") + } + } + + completion(sites, nil) + } + + private static func loadAllFromNETPM(completion: @escaping ([String: Site]?, Error?) -> ()) { + var sites = [String: Site]() + + // dispatchGroup is used to ensure we have migrated all sites before returning them + // If there are no sites to migrate, there are never any entrants + let dispatchGroup = DispatchGroup() + + NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in + if (err != nil) { + return completion(nil, err) + } + + newManagers?.forEach { manager in + do { + let site = try Site(manager: manager) + if site.needsToMigrateToFS { + dispatchGroup.enter() + site.incomingSite?.save(manager: manager) { error in + if error != nil { + print("Error while migrating site to fs: \(error!.localizedDescription)") + } + + print("Migraded site to fs: \(site.name)") + site.needsToMigrateToFS = false + dispatchGroup.leave() + } + } + sites[site.id] = site + + } catch { + //TODO: notify the user about this + print("Deleted non conforming site \(manager) \(error)") + manager.removeFromPreferences() + //TODO: delete from disk, we need to try and discover the site id though + } + } + + dispatchGroup.notify(queue: .main) { + completion(sites, nil) + } + } + } + + func getSites() -> [String: Site] { + return sites + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a584599..ae628f4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 432D0E3E291C562200752563 /* SiteList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432D0E3D291C562200752563 /* SiteList.swift */; }; + 432D0E3F291C562200752563 /* SiteList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432D0E3D291C562200752563 /* SiteList.swift */; }; 43498725289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; }; 43498726289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; }; 437F72592469AAC500A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; }; @@ -21,11 +23,17 @@ 43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; }; 43AD63F424EB3802000FB47E /* Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD63F324EB3802000FB47E /* Share.swift */; }; + 43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; }; + 43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; }; 4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + BE45F626291AEAB300902884 /* PackageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE45F625291AEAB300902884 /* PackageInfo.swift */; }; + BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5BC105291C41E600B6FE5B /* APIClient.swift */; }; + BEC5939E291C502F00709118 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5BC105291C41E600B6FE5B /* APIClient.swift */; }; + BEC5939F291C503D00709118 /* PackageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE45F625291AEAB300902884 /* PackageInfo.swift */; }; E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */; }; /* End PBXBuildFile section */ @@ -69,6 +77,7 @@ 384887B4785D38431E800D3A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 41927814D2E140A347A01067 /* Pods-NebulaNetworkExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.debug.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.debug.xcconfig"; sourceTree = ""; }; + 432D0E3D291C562200752563 /* SiteList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteList.swift; sourceTree = ""; }; 43498724289B484C00476B19 /* MobileNebula.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = MobileNebula.xcframework; sourceTree = SOURCE_ROOT; }; 436DE7A226EFF18500BB2950 /* CtlInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CtlInfo.h; sourceTree = ""; }; 437F72582469AAC500A0C4B9 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = ""; }; @@ -83,6 +92,7 @@ 43AD63F324EB3802000FB47E /* Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Share.swift; sourceTree = ""; }; 43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNUpdate.swift; sourceTree = ""; }; 53C42258A2092B55937DCF53 /* Pods-NebulaNetworkExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.profile.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.profile.xcconfig"; sourceTree = ""; }; 5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NebulaNetworkExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6E7A71D8C71BF965D042667D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -98,6 +108,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BE45F625291AEAB300902884 /* PackageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageInfo.swift; sourceTree = ""; }; + BE5BC105291C41E600B6FE5B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -147,6 +159,7 @@ 43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */, 437F72582469AAC500A0C4B9 /* Site.swift */, 436DE7A226EFF18500BB2950 /* CtlInfo.h */, + 432D0E3D291C562200752563 /* SiteList.swift */, ); path = NebulaNetworkExtension; sourceTree = ""; @@ -198,6 +211,9 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 43871C9C2444E2EC004F9075 /* Sites.swift */, 43AD63F324EB3802000FB47E /* Share.swift */, + 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */, + BE45F625291AEAB300902884 /* PackageInfo.swift */, + BE5BC105291C41E600B6FE5B /* APIClient.swift */, ); path = Runner; sourceTree = ""; @@ -445,8 +461,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 432D0E3F291C562200752563 /* SiteList.swift in Sources */, 43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */, 437F72592469AAC500A0C4B9 /* Site.swift in Sources */, + 43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */, + BEC5939E291C502F00709118 /* APIClient.swift in Sources */, + BEC5939F291C503D00709118 /* PackageInfo.swift in Sources */, 437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -457,10 +477,14 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 43AD63F424EB3802000FB47E /* Share.swift in Sources */, + 432D0E3E291C562200752563 /* SiteList.swift in Sources */, 43871C9D2444E2EC004F9075 /* Sites.swift in Sources */, + BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */, 437F725F2469B4B000A0C4B9 /* Site.swift in Sources */, + BE45F626291AEAB300902884 /* PackageInfo.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */, + 43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/APIClient.swift b/ios/Runner/APIClient.swift new file mode 100644 index 0000000..d4f4350 --- /dev/null +++ b/ios/Runner/APIClient.swift @@ -0,0 +1,54 @@ +import MobileNebula + +enum APIClientError: Error { + case invalidCredentials +} + +class APIClient { + let apiClient: MobileNebulaAPIClient + let json = JSONDecoder() + + init() { + let packageInfo = PackageInfo() + apiClient = MobileNebulaNewAPIClient("\(packageInfo.getName())/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")! + } + + func enroll(code: String) throws -> IncomingSite { + let res = try apiClient.enroll(code) + return try decodeIncomingSite(jsonSite: res.site) + } + + func tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String) throws -> IncomingSite? { + let res: MobileNebulaTryUpdateResult + do { + res = try apiClient.tryUpdate( + siteName, + hostID: hostID, + privateKey: privateKey, + counter: counter, + trustedKeys: trustedKeys) + } catch { + // type information from Go is not available, use string matching instead + if (error.localizedDescription == "invalid credentials") { + throw APIClientError.invalidCredentials + } + + throw error + } + + if (res.fetchedUpdate) { + return try decodeIncomingSite(jsonSite: res.site) + } + + return nil + } + + private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite { + do { + return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!) + } catch { + print("decodeIncomingSite: \(error)") + throw error + } + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 0135453..d68c77f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,7 +14,11 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { + private let dnUpdater = DNUpdater() + private let apiClient = APIClient() private var sites: Sites? + private var ui: FlutterMethodChannel? + override func application( _ application: UIApplication, @@ -22,20 +26,36 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { ) -> Bool { GeneratedPluginRegistrant.register(with: self) + + dnUpdater.updateAllLoop { site in + // Signal the site has changed in case the current site details screen is active + let container = self.sites?.getContainer(id: site.id) + if (container != nil) { + // Update references to the site with the new site config + container!.site = site + container!.updater.update(connected: site.connected ?? false, replaceSite: site) + } + + // Signal to the main screen to reload + self.ui?.invokeMethod("refreshSites", arguments: nil) + } + guard let controller = window?.rootViewController as? FlutterViewController else { fatalError("rootViewController is not type FlutterViewController") } sites = Sites(messenger: controller.binaryMessenger) - let channel = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger) + ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger) - channel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + ui!.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in switch call.method { case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result) case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result) case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result) case "nebula.verifyCertAndKey": return self.nebulaVerifyCertAndKey(call: call, result: result) + case "dn.enroll": return self.dnEnroll(call: call, result: result) + case "listSites": return self.listSites(result: result) case "deleteSite": return self.deleteSite(call: call, result: result) case "saveSite": return self.saveSite(call: call, result: result) @@ -109,6 +129,25 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError { return result(yaml) } + func dnEnroll(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let code = call.arguments as? String else { return result(NoArgumentsError()) } + + do { + let site = try apiClient.enroll(code: code) + + let oldSite = self.sites?.getSite(id: site.id) + site.save(manager: oldSite?.manager) { error in + if (error != nil) { + return result(CallFailedError(message: "Failed to enroll", details: error!.localizedDescription)) + } + + result(nil) + } + } catch { + return result(CallFailedError(message: "Error from DN api", details: error.localizedDescription)) + } + } + func listSites(result: @escaping FlutterResult) { self.sites?.loadSites { (sites, err) -> () in if (err != nil) { diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..5a57f30 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/DNUpdate.swift b/ios/Runner/DNUpdate.swift new file mode 100644 index 0000000..ebbfbbe --- /dev/null +++ b/ios/Runner/DNUpdate.swift @@ -0,0 +1,132 @@ +import Foundation + +class DNUpdater { + private let apiClient = APIClient() + private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes + + func updateAll(onUpdate: @escaping (Site) -> ()) { + _ = SiteList{ (sites, _) -> () in + sites?.values.forEach { site in + if (site.connected == true) { + // The vpn service is in charge of updating the currently connected site + return + } + + self.updateSite(site: site, onUpdate: onUpdate) + } + } + } + + func updateAllLoop(onUpdate: @escaping (Site) -> ()) { + timer.eventHandler = { + self.updateAll(onUpdate: onUpdate) + } + timer.resume() + } + + func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> ()) { + timer.eventHandler = { + self.updateSite(site: site, onUpdate: onUpdate) + } + timer.resume() + } + + func updateSite(site: Site, onUpdate: @escaping (Site) -> ()) { + do { + if (!site.managed) { + return + } + + let credentials = try site.getDNCredentials() + + let newSite: IncomingSite? + do { + newSite = try apiClient.tryUpdate( + siteName: site.name, + hostID: credentials.hostID, + privateKey: credentials.privateKey, + counter: credentials.counter, + trustedKeys: credentials.trustedKeys + ) + } catch (APIClientError.invalidCredentials) { + if (!credentials.invalid) { + try site.invalidateDNCredentials() + print("Invalidated credentials in site \(site.name)") + } + + return + } + + newSite?.save(manager: nil) { error in + if (error != nil) { + print("failed to save update: \(error!.localizedDescription)") + } else { + onUpdate(Site(incoming: newSite!)) + } + } + + if (credentials.invalid) { + try site.validateDNCredentials() + print("Revalidated credentials in site \(site.name)") + } + + } catch { + print("Error while updating \(site.name): \(error.localizedDescription)") + } + } +} + +// From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9 +class RepeatingTimer { + + let timeInterval: TimeInterval + + init(timeInterval: TimeInterval) { + self.timeInterval = timeInterval + } + + private lazy var timer: DispatchSourceTimer = { + let t = DispatchSource.makeTimerSource() + t.schedule(deadline: .now(), repeating: self.timeInterval) + t.setEventHandler(handler: { [weak self] in + self?.eventHandler?() + }) + return t + }() + + var eventHandler: (() -> Void)? + + private enum State { + case suspended + case resumed + } + + private var state: State = .suspended + + deinit { + timer.setEventHandler {} + timer.cancel() + /* + If the timer is suspended, calling cancel without resuming + triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902 + */ + resume() + eventHandler = nil + } + + func resume() { + if state == .resumed { + return + } + state = .resumed + timer.resume() + } + + func suspend() { + if state == .suspended { + return + } + state = .suspended + timer.suspend() + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 05db08d..5a780b8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -18,8 +20,23 @@ $(MARKETING_VERSION) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + mailto + CFBundleURLSchemes + + mailto + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) + FlutterDeepLinkingEnabled + ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -47,7 +64,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - diff --git a/ios/Runner/PackageInfo.swift b/ios/Runner/PackageInfo.swift new file mode 100644 index 0000000..50bba99 --- /dev/null +++ b/ios/Runner/PackageInfo.swift @@ -0,0 +1,26 @@ +import Foundation + +class PackageInfo { + func getVersion() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? + "unknown" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + + if (buildNumber == nil) { + return version + } + + return "\(version)-\(buildNumber!)" + } + + func getName() -> String { + return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? + Bundle.main.infoDictionary?["CFBundleName"] as? String ?? + "Nebula" + } + + func getSystemVersion() -> String { + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + } +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index fe9a9e3..f9ffddc 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:api.defined.net + com.apple.developer.networking.networkextension packet-tunnel-provider diff --git a/ios/Runner/Sites.swift b/ios/Runner/Sites.swift index a21479e..5c95b87 100644 --- a/ios/Runner/Sites.swift +++ b/ios/Runner/Sites.swift @@ -12,7 +12,7 @@ class SiteContainer { } class Sites { - private var sites = [String: SiteContainer]() + private var containers = [String: SiteContainer]() private var messenger: FlutterBinaryMessenger? init(messenger: FlutterBinaryMessenger?) { @@ -20,77 +20,39 @@ class Sites { } func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) { -#if targetEnvironment(simulator) - let fileManager = FileManager.default - let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites") - var configPaths: [URL] - - do { - if (!fileManager.fileExists(atPath: documentsURL.absoluteString)) { - try fileManager.createDirectory(at: documentsURL, withIntermediateDirectories: true) - } - configPaths = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) - } catch { - return completion(nil, error) - } - - configPaths.forEach { path in - do { - let config = try Data(contentsOf: path) - let decoder = JSONDecoder() - let incoming = try decoder.decode(IncomingSite.self, from: config) - let site = try Site(incoming: incoming) - let updater = SiteUpdater(messenger: self.messenger!, site: site) - self.sites[site.id] = SiteContainer(site: site, updater: updater) - } catch { - print(error) - // try? fileManager.removeItem(at: path) - print("Deleted non conforming site \(path)") - } - } - - let justSites = self.sites.mapValues { - return $0.site - } - completion(justSites, nil) - -#else - NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in + _ = SiteList { (sites, err) in if (err != nil) { return completion(nil, err) } - - newManagers?.forEach { manager in - do { - let site = try Site(manager: manager) - // Load the private key to make sure we can - _ = try site.getKey() - let updater = SiteUpdater(messenger: self.messenger!, site: site) - self.sites[site.id] = SiteContainer(site: site, updater: updater) - } catch { - //TODO: notify the user about this - print("Deleted non conforming site \(manager) \(error)") - manager.removeFromPreferences() - } + + sites?.values.forEach{ site in + let updater = SiteUpdater(messenger: self.messenger!, site: site) + self.containers[site.id] = SiteContainer(site: site, updater: updater) } - let justSites = self.sites.mapValues { + let justSites = self.containers.mapValues { return $0.site } completion(justSites, nil) } -#endif } func deleteSite(id: String, callback: @escaping (Error?) -> ()) { - if let site = self.sites.removeValue(forKey: id) { -#if targetEnvironment(simulator) - let fileManager = FileManager.default - let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(site.site.id) - try? fileManager.removeItem(at: sitePath) -#else - _ = KeyChain.delete(key: site.site.id) + if let site = self.containers.removeValue(forKey: id) { + _ = KeyChain.delete(key: "\(site.site.id).dnCredentials") + _ = KeyChain.delete(key: "\(site.site.id).key") + + do { + let fileManager = FileManager.default + let siteDir = try SiteList.getSiteDir(id: site.site.id) + try fileManager.removeItem(at: siteDir) + } catch { + print("Failed to delete site from fs: \(error.localizedDescription)") + } + +#if !targetEnvironment(simulator) site.site.manager!.removeFromPreferences(completionHandler: callback) + return #endif } @@ -99,15 +61,15 @@ class Sites { } func getSite(id: String) -> Site? { - return self.sites[id]?.site + return self.containers[id]?.site } func getUpdater(id: String) -> SiteUpdater? { - return self.sites[id]?.updater + return self.containers[id]?.updater } func getContainer(id: String) -> SiteContainer? { - return self.sites[id] + return self.containers[id] } } @@ -117,18 +79,44 @@ class SiteUpdater: NSObject, FlutterStreamHandler { private var site: Site private var notification: Any? public var startFunc: (() -> Void)? + private var configFd: Int32? = nil + private var configObserver: DispatchSourceFileSystemObject? = nil init(messenger: FlutterBinaryMessenger, site: Site) { + do { + let configPath = try SiteList.getSiteConfigFile(id: site.id, createDir: false) + self.configFd = open(configPath.path, O_EVTONLY) + self.configObserver = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: self.configFd!, + eventMask: .write + ) + + } catch { + // SiteList.getSiteConfigFile should never throw because we are not creating it here + self.configObserver = nil + } + eventChannel = FlutterEventChannel(name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger) self.site = site super.init() + eventChannel.setStreamHandler(self) + + self.configObserver?.setEventHandler(handler: self.configUpdated) + self.configObserver?.setCancelHandler { + if self.configFd != nil { + close(self.configFd!) + } + self.configObserver = nil + } + + self.configObserver?.resume() } /// onListen is called when flutter code attaches an event listener func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { eventSink = events; - +#if !targetEnvironment(simulator) self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in let connected = self.site.connected self.site.status = statusString[self.site.manager!.connection.status] @@ -140,13 +128,9 @@ class SiteUpdater: NSObject, FlutterStreamHandler { self.startFunc = nil } - let d: Dictionary = [ - "connected": self.site.connected!, - "status": self.site.status!, - ] - self.eventSink?(d) + self.update(connected: self.site.connected!) } - +#endif return nil } @@ -159,11 +143,27 @@ class SiteUpdater: NSObject, FlutterStreamHandler { } /// update is a way to send information to the flutter listener and generally should not be used directly - func update(connected: Bool) { - let d: Dictionary = [ - "connected": connected, - "status": connected ? "Connected" : "Disconnected", - ] - self.eventSink?(d) + func update(connected: Bool, replaceSite: Site? = nil) { + if (replaceSite != nil) { + site = replaceSite! + } + site.connected = connected + site.status = connected ? "Connected" : "Disconnected" + + let encoder = JSONEncoder() + let data = try! encoder.encode(site) + self.eventSink?(String(data: data, encoding: .utf8)) + } + + private func configUpdated() { + if self.site.connected != true { + return + } + + guard let newSite = try? Site(manager: self.site.manager!) else { + return + } + + self.update(connected: newSite.connected ?? false, replaceSite: newSite) } } diff --git a/lib/components/CIDRFormField.dart b/lib/components/CIDRFormField.dart index ff00553..a950643 100644 --- a/lib/components/CIDRFormField.dart +++ b/lib/components/CIDRFormField.dart @@ -91,7 +91,7 @@ class _CIDRFormField extends FormFieldState { } if (widget.bitsController == null) { - _bitsController = TextEditingController(text: widget.initialValue?.bits?.toString() ?? ""); + _bitsController = TextEditingController(text: widget.initialValue?.bits.toString() ?? ""); } else { widget.bitsController!.addListener(_handleControllerChanged); } diff --git a/lib/components/FormPage.dart b/lib/components/FormPage.dart index fcfcfb8..34094df 100644 --- a/lib/components/FormPage.dart +++ b/lib/components/FormPage.dart @@ -58,7 +58,7 @@ class _FormPageState extends State { leadingAction: _buildLeader(context), trailingActions: _buildTrailer(context), scrollController: widget.scrollController, - title: widget.title, + title: Text(widget.title), child: Form( key: _formKey, onChanged: () => setState(() { diff --git a/lib/components/IPAndPortField.dart b/lib/components/IPAndPortField.dart index 463000d..6ed4190 100644 --- a/lib/components/IPAndPortField.dart +++ b/lib/components/IPAndPortField.dart @@ -87,7 +87,7 @@ class _IPAndPortFieldState extends State { nextFocusNode: widget.nextFocusNode, controller: widget.portController, onChanged: (val) { - _ipAndPort.port = int.tryParse(val ?? ""); + _ipAndPort.port = int.tryParse(val); widget.onChanged(_ipAndPort); }, maxLength: 5, diff --git a/lib/components/SimplePage.dart b/lib/components/SimplePage.dart index d21bde4..31bb9c4 100644 --- a/lib/components/SimplePage.dart +++ b/lib/components/SimplePage.dart @@ -24,13 +24,15 @@ class SimplePage extends StatelessWidget { this.bottomBar, this.onRefresh, this.onLoading, + this.alignment, this.refreshController}) : super(key: key); - final String title; + final Widget title; final Widget child; final SimpleScrollable scrollable; final ScrollController? scrollController; + final AlignmentGeometry? alignment; /// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorder-able listviews /// This is set to true if you have any scrollable other than none @@ -85,6 +87,10 @@ class SimplePage extends StatelessWidget { realChild = Scrollbar(child: realChild); } + if (alignment != null) { + realChild = Align(alignment: this.alignment!, child: realChild); + } + if (bottomBar != null) { realChild = Column(children: [ Expanded(child: realChild), @@ -95,7 +101,7 @@ class SimplePage extends StatelessWidget { return PlatformScaffold( backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context), appBar: PlatformAppBar( - title: Text(title), + title: title, leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context), trailingActions: trailingActions, cupertino: (_, __) => CupertinoNavigationBarData( diff --git a/lib/components/SiteItem.dart b/lib/components/SiteItem.dart index 10550e4..90b04ad 100644 --- a/lib/components/SiteItem.dart +++ b/lib/components/SiteItem.dart @@ -1,4 +1,6 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:mobile_nebula/components/SpecialButton.dart'; import 'package:mobile_nebula/models/Site.dart'; import 'package:mobile_nebula/services/utils.dart'; @@ -26,10 +28,7 @@ class SiteItem extends StatelessWidget { Widget _buildContent(BuildContext context) { final border = BorderSide(color: Utils.configSectionBorder(context)); - var ip = "Error"; - if (site.certInfo != null && site.certInfo!.cert.details.ips.length > 0) { - ip = site.certInfo!.cert.details.ips[0]; - } + final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg'; return SpecialButton( decoration: @@ -40,8 +39,10 @@ class SiteItem extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)), - Expanded(child: Text(ip, textAlign: TextAlign.end)), + site.managed ? + Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) : + Container(), + Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold))), Padding(padding: EdgeInsets.only(right: 10)), Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) ], diff --git a/lib/components/config/ConfigPageItem.dart b/lib/components/config/ConfigPageItem.dart index 800fed5..30aeb1e 100644 --- a/lib/components/config/ConfigPageItem.dart +++ b/lib/components/config/ConfigPageItem.dart @@ -12,6 +12,7 @@ class ConfigPageItem extends StatelessWidget { this.content, this.labelWidth = 100, this.onPressed, + this.disabled = false, this.crossAxisAlignment = CrossAxisAlignment.center}) : super(key: key); @@ -20,6 +21,7 @@ class ConfigPageItem extends StatelessWidget { final double labelWidth; final CrossAxisAlignment crossAxisAlignment; final onPressed; + final bool disabled; @override Widget build(BuildContext context) { @@ -40,7 +42,7 @@ class ConfigPageItem extends StatelessWidget { Widget _buildContent(BuildContext context) { return SpecialButton( - onPressed: onPressed, + onPressed: this.disabled ? null : onPressed, color: Utils.configItemBackground(context), child: Container( padding: EdgeInsets.only(left: 15), @@ -50,7 +52,7 @@ class ConfigPageItem extends StatelessWidget { children: [ label != null ? Container(width: labelWidth, child: label) : Container(), Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))), - Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) + this.disabled ? Container() : Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) ], )), ); diff --git a/lib/main.dart b/lib/main.dart index c386099..4c9627d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations; import 'package:flutter/material.dart' show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode; @@ -6,11 +8,16 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:mobile_nebula/screens/MainScreen.dart'; +import 'package:mobile_nebula/screens/EnrollmentScreen.dart'; import 'package:mobile_nebula/services/settings.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; //TODO: EventChannel might be better than the stream controller we are using now -void main() => runApp(Main()); +void main() { + usePathUrlStrategy(); + runApp(Main()); +} class Main extends StatelessWidget { // This widget is the root of your application. @@ -26,6 +33,7 @@ class App extends StatefulWidget { class _AppState extends State { final settings = Settings(); Brightness brightness = SchedulerBinding.instance.window.platformBrightness; + StreamController dnEnrolled = StreamController.broadcast(); @override void initState() { @@ -41,6 +49,12 @@ class _AppState extends State { super.initState(); } + @override + void dispose() { + dnEnrolled.close(); + super.dispose(); + } + @override Widget build(BuildContext context) { final ThemeData lightTheme = ThemeData( @@ -93,7 +107,28 @@ class _AppState extends State { cupertino: (_, __) => CupertinoAppData( theme: CupertinoThemeData(brightness: brightness), ), - home: MainScreen(), + onGenerateRoute: (settings) { + if (settings.name == '/') { + return platformPageRoute(context: context, builder: (context) => MainScreen(this.dnEnrolled.stream)); + } + + final uri = Uri.parse(settings.name!); + if (uri.path == EnrollmentScreen.routeName) { + String? code; + if (uri.hasFragment) { + final qp = Uri.splitQueryString(uri.fragment); + code = qp["code"]; + } + + // TODO: maybe implement this as a dialog instead of a page, you can stack multiple enrollment screens which is annoying in dev + return platformPageRoute( + context: context, + builder: (context) => EnrollmentScreen(code: code, stream: this.dnEnrolled), + ); + } + + return null; + }, ), ), ); diff --git a/lib/models/Site.dart b/lib/models/Site.dart index e630208..07153a8 100644 --- a/lib/models/Site.dart +++ b/lib/models/Site.dart @@ -44,6 +44,11 @@ class Site { late String logFile; late String logVerbosity; + late bool managed; + // The following fields are present when managed = true + late String? rawConfig; + late DateTime? lastManagedUpdate; + // A list of errors encountered while loading the site late List errors; @@ -64,6 +69,9 @@ class Site { String logVerbosity = 'info', List? errors, List? unsafeRoutes, + bool managed = false, + String? rawConfig, + DateTime? lastManagedUpdate, }) { this.name = name; this.id = id ?? uuid.v4(); @@ -81,26 +89,75 @@ class Site { this.logVerbosity = logVerbosity; this.errors = errors ?? []; this.unsafeRoutes = unsafeRoutes ?? []; + this.managed = managed; + this.rawConfig = rawConfig; + this.lastManagedUpdate = lastManagedUpdate; _updates = EventChannel('net.defined.nebula/$id'); _updates.receiveBroadcastStream().listen((d) { try { - this.status = d['status']; - this.connected = d['connected']; + _updateFromJson(d); _change.add(null); } catch (err) { //TODO: handle the error print(err); } }, onError: (err) { + _updateFromJson(err.details); var error = err as PlatformException; - this.status = error.details['status']; - this.connected = error.details['connected']; _change.addError(error.message ?? 'An unexpected error occurred'); }); } factory Site.fromJson(Map json) { + var decoded = Site._fromJson(json); + return Site( + name: decoded["name"], + id: decoded['id'], + staticHostmap: decoded['staticHostmap'], + ca: decoded['ca'], + certInfo: decoded['certInfo'], + lhDuration: decoded['lhDuration'], + port: decoded['port'], + cipher: decoded['cipher'], + sortKey: decoded['sortKey'], + mtu: decoded['mtu'], + connected: decoded['connected'], + status: decoded['status'], + logFile: decoded['logFile'], + logVerbosity: decoded['logVerbosity'], + errors: decoded['errors'], + unsafeRoutes: decoded['unsafeRoutes'], + managed: decoded['managed'], + rawConfig: decoded['rawConfig'], + lastManagedUpdate: decoded['lastManagedUpdate'], + ); + } + + _updateFromJson(String json) { + var decoded = Site._fromJson(jsonDecode(json)); + this.name = decoded["name"]; + this.id = decoded['id']; // TODO update EventChannel + this.staticHostmap = decoded['staticHostmap']; + this.ca = decoded['ca']; + this.certInfo = decoded['certInfo']; + this.lhDuration = decoded['lhDuration']; + this.port = decoded['port']; + this.cipher = decoded['cipher']; + this.sortKey = decoded['sortKey']; + this.mtu = decoded['mtu']; + this.connected = decoded['connected']; + this.status = decoded['status']; + this.logFile = decoded['logFile']; + this.logVerbosity = decoded['logVerbosity']; + this.errors = decoded['errors']; + this.unsafeRoutes = decoded['unsafeRoutes']; + this.managed = decoded['managed']; + this.rawConfig = decoded['rawConfig']; + this.lastManagedUpdate = decoded['lastManagedUpdate']; + } + + static _fromJson(Map json) { Map rawHostmap = json['staticHostmap']; Map staticHostmap = {}; rawHostmap.forEach((key, val) { @@ -130,24 +187,28 @@ class Site { errors.add(error); }); - return Site( - name: json['name'], - id: json['id'], - staticHostmap: staticHostmap, - ca: ca, - certInfo: certInfo, - lhDuration: json['lhDuration'], - port: json['port'], - cipher: json['cipher'], - sortKey: json['sortKey'], - mtu: json['mtu'], - connected: json['connected'] ?? false, - status: json['status'] ?? "", - logFile: json['logFile'], - logVerbosity: json['logVerbosity'], - errors: errors, - unsafeRoutes: unsafeRoutes, - ); + return { + "name": json["name"], + "id": json['id'], + "staticHostmap": staticHostmap, + "ca": ca, + "certInfo": certInfo, + "lhDuration": json['lhDuration'], + "port": json['port'], + "cipher": json['cipher'], + "sortKey": json['sortKey'], + "mtu": json['mtu'], + "connected": json['connected'] ?? false, + "status": json['status'] ?? "", + "logFile": json['logFile'], + "logVerbosity": json['logVerbosity'], + "errors": errors, + "unsafeRoutes": unsafeRoutes, + "managed": json['managed'] ?? false, + "rawConfig": json['rawConfig'], + "lastManagedUpdate": json["lastManagedUpdate"] == null ? + null : DateTime.parse(json["lastManagedUpdate"]), + }; } Stream onChange() { @@ -171,6 +232,8 @@ class Site { 'cipher': cipher, 'sortKey': sortKey, 'logVerbosity': logVerbosity, + 'managed': managed, + 'rawConfig': rawConfig, }; } diff --git a/lib/screens/AboutScreen.dart b/lib/screens/AboutScreen.dart index 31879cc..72135b3 100644 --- a/lib/screens/AboutScreen.dart +++ b/lib/screens/AboutScreen.dart @@ -43,7 +43,7 @@ class _AboutScreenState extends State { } return SimplePage( - title: 'About', + title: Text('About'), child: Column(children: [ ConfigSection(children: [ ConfigItem( diff --git a/lib/screens/EnrollmentScreen.dart b/lib/screens/EnrollmentScreen.dart new file mode 100644 index 0000000..b9ba9d9 --- /dev/null +++ b/lib/screens/EnrollmentScreen.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class EnrollmentScreen extends StatefulWidget { + final String? code; + final StreamController? stream; + final bool allowCodeEntry; + + static const routeName = '/v1/mobile-enrollment'; + + const EnrollmentScreen({super.key, this.code, this.stream, this.allowCodeEntry = false}); + + @override + _EnrollmentScreenState createState() => _EnrollmentScreenState(); +} + +class _EnrollmentScreenState extends State { + String? error; + var enrolled = false; + var enrollInput = TextEditingController(); + String? code; + + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + + void initState() { + code = widget.code; + super.initState(); + _enroll(); + } + + @override + void dispose() { + enrollInput.dispose(); + super.dispose(); + } + + _enroll() async { + try { + await platform.invokeMethod("dn.enroll", code); + setState(() { + enrolled = true; + if (widget.stream != null) { + // Signal a new site has been added + widget.stream!.add(null); + } + }); + } on PlatformException catch (err) { + setState(() { + error = err.details ?? err.message; + }); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final bodyTextStyle = textTheme.bodyLarge!.apply(color: colorScheme.onPrimary); + final contactUri = Uri.parse('mailto:support@defined.net'); + + Widget child; + AlignmentGeometry? alignment; + + if (code == null) { + if (widget.allowCodeEntry) { + child = _codeEntry(); + } else { + // No code, show the error + child = Padding( + child: Center(child: Text( + 'No valid enrollment code was found.\n\nContact your administrator to obtain a new enrollment code.', + textAlign: TextAlign.center, + + )), + padding: EdgeInsets.only(top: 20) + ); + } + + } else if (this.error != null) { + // Error while enrolling, display it + child = Center(child: Column( + children: [ + Padding( + child: SelectableText('There was an issue while attempting to enroll this device. Contact your administrator to obtain a new enrollment code.'), + padding: EdgeInsets.symmetric(vertical: 20) + ), + Padding(child: SelectableText.rich(TextSpan(children: [ + TextSpan(text: 'If the problem persists, please let us know at '), + TextSpan( + text: 'support@defined.net', + style: bodyTextStyle.apply(color: colorScheme.primary), + recognizer: TapGestureRecognizer() + ..onTap = () async { + if (await canLaunchUrl(contactUri)) { + print(await launchUrl(contactUri)); + } + }, + ), + TextSpan(text: ' and provide the following error:'), + ])), padding: EdgeInsets.only(bottom: 10)), + Container( + child: Padding(child: SelectableText(this.error!), padding: EdgeInsets.all(10)), + color: Theme.of(context).colorScheme.errorContainer, + ), + ], + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + )); + + } else if (this.enrolled) { + // Enrollment complete! + child = Padding( + child: Center(child: Text( + 'Enrollment complete! 🎉', + textAlign: TextAlign.center, + )), + padding: EdgeInsets.only(top: 20) + ); + + } else { + // Have a code and actively enrolling + alignment = Alignment.center; + child = Center(child: Column( + children: [ + Padding(child: Text('Contacting DN for enrollment'), padding: EdgeInsets.only(bottom: 25)), + PlatformCircularProgressIndicator(cupertino: (_, __) { + return CupertinoProgressIndicatorData(radius: 50); + }) + ] + )); + } + + return SimplePage(title: Text('DN Enrollment'), child: Padding(child: child, padding: EdgeInsets.symmetric(horizontal: 10)), alignment: alignment); + } + + Widget _codeEntry() { + return Column(children: [ + Container(height: 20), + PlatformTextField(controller: enrollInput), + CupertinoButton(child: Text('Enroll'), onPressed: () { + setState(() { + code = enrollInput.text; + error = null; + _enroll(); + }); + }), + ]); + } +} diff --git a/lib/screens/HostInfoScreen.dart b/lib/screens/HostInfoScreen.dart index e8cd601..1662307 100644 --- a/lib/screens/HostInfoScreen.dart +++ b/lib/screens/HostInfoScreen.dart @@ -50,7 +50,7 @@ class _HostInfoScreenState extends State { final title = widget.pending ? 'Pending' : 'Active'; return SimplePage( - title: '$title Host Info', + title: Text('$title Host Info'), refreshController: refreshController, onRefresh: () async { await _getHostInfo(); diff --git a/lib/screens/MainScreen.dart b/lib/screens/MainScreen.dart index 3e77819..9dfb02b 100644 --- a/lib/screens/MainScreen.dart +++ b/lib/screens/MainScreen.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -14,41 +14,119 @@ import 'package:mobile_nebula/models/IPAndPort.dart'; import 'package:mobile_nebula/models/Site.dart'; import 'package:mobile_nebula/models/StaticHosts.dart'; import 'package:mobile_nebula/models/UnsafeRoute.dart'; +import 'package:mobile_nebula/screens/EnrollmentScreen.dart'; import 'package:mobile_nebula/screens/SettingsScreen.dart'; import 'package:mobile_nebula/screens/SiteDetailScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart'; import 'package:mobile_nebula/services/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:uuid/uuid.dart'; -//TODO: add refresh +/// Contains an expired CA and certificate +const badDebugSave = { + 'name': 'Bad Site', + 'cert': '''-----BEGIN NEBULA CERTIFICATE----- +CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa +A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp +VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU +Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ== +-----END NEBULA CERTIFICATE-----''', + 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- +rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= +-----END NEBULA X25519 PRIVATE KEY-----''', + 'ca': '''-----BEGIN NEBULA CERTIFICATE----- +CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT +4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv +mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= +-----END NEBULA CERTIFICATE-----''', +}; + +/// Contains an expired CA and certificate +const goodDebugSave = { + 'name': 'Good Site', + 'cert': '''-----BEGIN NEBULA CERTIFICATE----- +CmcKCmRlYnVnIGhvc3QSCYKAhFCA/v//DyiX0ZaaBjDjjPf5ETogyYzKdlRh7pW6 +yOd8+aMQAFPha2wuYixuq53ru9+qXC9KIJd3ow6qIiaHInT1dgJvy+122WK7g86+ +Z8qYtTZnox1cEkBYpC0SySrCp6jd/zeAFEJM6naPYgc6rmy/H/qveyQ6WAtbgLpK +tM3EXbbOE9+fV/Ma6Oilf1SixO3ZBo30nRYL +-----END NEBULA CERTIFICATE-----''', + 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- +vu9t0mNy8cD5x3CMVpQ/cdKpjdz46NBlcRqvJAQpO44= +-----END NEBULA X25519 PRIVATE KEY-----''', + 'ca': '''-----BEGIN NEBULA CERTIFICATE----- +CjcKBWRlYnVnKOTQlpoGMOSM9/kROiCWNJUs7c4ZRzUn2LbeAEQrz2PVswnu9dcL +Sn/2VNNu30ABEkCQtWxmCJqBr5Yd9vtDWCPo/T1JQmD3stBozcM6aUl1hP3zjURv +MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI +-----END NEBULA CERTIFICATE-----''', +}; class MainScreen extends StatefulWidget { - const MainScreen({Key? key}) : super(key: key); + const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key); + + final Stream dnEnrollStream; @override _MainScreenState createState() => _MainScreenState(); } class _MainScreenState extends State { - bool ready = false; List? sites; // A set of widgets to display in a column that represents an error blocking us from moving forward entirely List? error; static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + RefreshController refreshController = RefreshController(); + ScrollController scrollController = ScrollController(); @override void initState() { _loadSites(); + widget.dnEnrollStream.listen((_) { + _loadSites(); + }); + + platform.setMethodCallHandler(handleMethodCall); + super.initState(); } + @override + void dispose() { + scrollController.dispose(); + refreshController.dispose(); + super.dispose(); + } + + Future handleMethodCall(MethodCall call) async { + switch (call.method) { + case "refreshSites": + _loadSites(); + break; + default: + print("ERR: Unexpected method call ${call.method}"); + } + } + @override Widget build(BuildContext context) { + Widget? debugSite; + + if (kDebugMode) { + debugSite = Row( + children: [ + _debugSave(badDebugSave), + _debugSave(goodDebugSave), + _debugDNEnroll(), + ], + mainAxisAlignment: MainAxisAlignment.center, + ); + } + return SimplePage( - title: 'Nebula', - scrollable: SimpleScrollable.none, + title: Text('Nebula'), + scrollable: SimpleScrollable.vertical, + scrollController: scrollController, leadingAction: PlatformIconButton( padding: EdgeInsets.zero, icon: Icon(Icons.add, size: 28.0), @@ -58,6 +136,12 @@ class _MainScreenState extends State { }); }), ), + refreshController: refreshController, + onRefresh: () { + print("onRefresh"); + _loadSites(); + refreshController.refreshCompleted(); + }, trailingActions: [ PlatformIconButton( padding: EdgeInsets.zero, @@ -65,7 +149,7 @@ class _MainScreenState extends State { onPressed: () => Utils.openPage(context, (_) => SettingsScreen()), ), ], - bottomBar: kDebugMode ? _debugSave() : null, + bottomBar: debugSite, child: _buildBody(), ); } @@ -82,14 +166,6 @@ class _MainScreenState extends State { padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10))); } - if (!ready) { - return Center( - child: PlatformCircularProgressIndicator(cupertino: (_, __) { - return CupertinoProgressIndicatorData(radius: 50); - }), - ); - } - return _buildSites(); } @@ -128,6 +204,8 @@ class _MainScreenState extends State { }); Widget child = ReorderableListView( + shrinkWrap: true, + scrollController: scrollController, padding: EdgeInsets.symmetric(vertical: 5), children: items, onReorder: (oldI, newI) async { @@ -141,7 +219,11 @@ class _MainScreenState extends State { sites!.insert(newI, moved); }); - for (var i = min(oldI, newI); i <= max(oldI, newI); i++) { + for (var i = 0; i < sites!.length; i++) { + if (sites![i].sortKey == i) { + continue; + } + sites![i].sortKey = i; try { await sites![i].save(); @@ -162,41 +244,25 @@ class _MainScreenState extends State { return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child); } - Widget _debugSave() { + Widget _debugSave(Map siteConfig) { return CupertinoButton( - key: Key('debug-save'), - child: Text("DEBUG SAVE"), + child: Text(siteConfig['name']!), onPressed: () async { var uuid = Uuid(); - var cert = '''-----BEGIN NEBULA CERTIFICATE----- -CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa -A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp -VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU -Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ== ------END NEBULA CERTIFICATE-----'''; - - var ca = '''-----BEGIN NEBULA CERTIFICATE----- -CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT -4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv -mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= ------END NEBULA CERTIFICATE-----'''; - var s = Site( - name: "DEBUG TEST", + name: siteConfig['name']!, id: uuid.v4(), staticHostmap: { "10.1.0.1": StaticHost( lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)]) }, - ca: [CertificateInfo.debug(rawCert: ca)], - certInfo: CertificateInfo.debug(rawCert: cert), + ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])], + certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']), unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]); - s.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY----- -rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= ------END NEBULA X25519 PRIVATE KEY-----'''; + s.key = siteConfig['key']; var err = await s.save(); if (err != null) { @@ -208,6 +274,15 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= ); } + Widget _debugDNEnroll() { + return CupertinoButton( + child: Text('DN Enroll'), + onPressed: () => Utils.openPage(context, (context) { + return EnrollmentScreen(allowCodeEntry: true); + }), + ); + } + _loadSites() async { if (Platform.isAndroid) { try { @@ -268,6 +343,7 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= } }); + sites!.add(site); } catch (err) { //TODO: handle error @@ -282,17 +358,14 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= platform.invokeMethod("android.registerActiveSite"); } - if (hasErrors) { - Utils.popError(context, "Site Error(s)", - "1 or more sites have errors and need your attention, problem sites have a red border."); - } - sites!.sort((a, b) { + if (a.sortKey == b.sortKey) { + return a.name.compareTo(b.name); + } + return a.sortKey - b.sortKey; }); - setState(() { - ready = true; - }); + setState(() {}); } } diff --git a/lib/screens/SettingsScreen.dart b/lib/screens/SettingsScreen.dart index 010612e..43d723f 100644 --- a/lib/screens/SettingsScreen.dart +++ b/lib/screens/SettingsScreen.dart @@ -87,7 +87,7 @@ class _SettingsScreenState extends State { ])); return SimplePage( - title: 'Settings', + title: Text('Settings'), child: Column(children: items), ); } diff --git a/lib/screens/SiteDetailScreen.dart b/lib/screens/SiteDetailScreen.dart index 9ed698d..7a18ac2 100644 --- a/lib/screens/SiteDetailScreen.dart +++ b/lib/screens/SiteDetailScreen.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart'; @@ -37,28 +38,24 @@ class _SiteDetailScreenState extends State { List? activeHosts; List? pendingHosts; RefreshController refreshController = RefreshController(initialRefresh: false); - late bool lastState; @override void initState() { site = widget.site; - lastState = site.connected; if (site.connected) { _listHostmap(); } onChange = site.onChange().listen((_) { - if (lastState != site.connected) { - //TODO: connected is set before the nebula object exists leading to a crash race, waiting for "Connected" status is a gross hack but keeps it alive - if (site.status == 'Connected') { - lastState = true; - _listHostmap(); - } else { - lastState = false; - activeHosts = null; - pendingHosts = null; - } + // TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started. + // If we fetch the hostmap now we'll never get a response. Wait until Nebula is running. + if (site.status == 'Connected') { + _listHostmap(); + } else { + activeHosts = null; + pendingHosts = null; } + setState(() {}); }, onError: (err) { setState(() {}); @@ -76,8 +73,16 @@ class _SiteDetailScreenState extends State { @override Widget build(BuildContext context) { + final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg'; + final title = Row(children: [ + site.managed ? + Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) : + Container(), + Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold))) + ]); + return SimplePage( - title: site.name, + title: title, leadingAction: Utils.leadingBackWidget(context, onPressed: () { if (changed && widget.onChanged != null) { widget.onChanged!(); diff --git a/lib/screens/SiteLogsScreen.dart b/lib/screens/SiteLogsScreen.dart index e7b8944..44d95ff 100644 --- a/lib/screens/SiteLogsScreen.dart +++ b/lib/screens/SiteLogsScreen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/models/Site.dart'; import 'package:mobile_nebula/services/settings.dart'; @@ -39,8 +40,16 @@ class _SiteLogsScreenState extends State { @override Widget build(BuildContext context) { + final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg'; + final title = Row(children: [ + widget.site.managed ? + Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) : + Container(), + Expanded(child: Text(widget.site.name, style: TextStyle(fontWeight: FontWeight.bold))) + ]); + return SimplePage( - title: widget.site.name, + title: title, scrollable: SimpleScrollable.both, scrollController: controller, onRefresh: () async { @@ -113,6 +122,8 @@ class _SiteLogsScreenState extends State { setState(() { logs = v; }); + } on FileSystemException { + Utils.popError(context, 'Error while reading logs', 'No log file was present'); } catch (err) { Utils.popError(context, 'Error while reading logs', err.toString()); } diff --git a/lib/screens/SiteTunnelsScreen.dart b/lib/screens/SiteTunnelsScreen.dart index c587e9c..06f2ed1 100644 --- a/lib/screens/SiteTunnelsScreen.dart +++ b/lib/screens/SiteTunnelsScreen.dart @@ -38,6 +38,7 @@ class _SiteTunnelsScreenState extends State { @override void dispose() { + refreshController.dispose(); super.dispose(); } @@ -83,7 +84,7 @@ class _SiteTunnelsScreenState extends State { final title = widget.pending ? 'Pending' : 'Active'; return SimplePage( - title: "$title Tunnels", + title: Text('$title Tunnels'), leadingAction: Utils.leadingBackWidget(context, onPressed: () { Navigator.pop(context); }), diff --git a/lib/screens/siteConfig/AddCertificateScreen.dart b/lib/screens/siteConfig/AddCertificateScreen.dart index 38270a0..943cb4e 100644 --- a/lib/screens/siteConfig/AddCertificateScreen.dart +++ b/lib/screens/siteConfig/AddCertificateScreen.dart @@ -77,7 +77,7 @@ class _AddCertificateScreenState extends State { items.add(_buildKey()); items.addAll(_buildLoadCert()); - return SimplePage(title: 'Certificate', child: Column(children: items)); + return SimplePage(title: Text('Certificate'), child: Column(children: items)); } List _buildShare() { diff --git a/lib/screens/siteConfig/AdvancedScreen.dart b/lib/screens/siteConfig/AdvancedScreen.dart index 7b301d8..f76a5ed 100644 --- a/lib/screens/siteConfig/AdvancedScreen.dart +++ b/lib/screens/siteConfig/AdvancedScreen.dart @@ -86,57 +86,64 @@ class _AdvancedScreenState extends State { label: Text("Lighthouse interval"), labelWidth: 200, //TODO: Auto select on focus? - content: PlatformTextFormField( - initialValue: settings.lhDuration.toString(), - keyboardType: TextInputType.number, - suffix: Text("seconds"), - textAlign: TextAlign.right, - maxLength: 5, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onSaved: (val) { - setState(() { - if (val != null) { - settings.lhDuration = int.parse(val!); - } - }); - }, - )), + content: widget.site.managed ? + Text(settings.lhDuration.toString() + " seconds", textAlign: TextAlign.right) : + PlatformTextFormField( + initialValue: settings.lhDuration.toString(), + keyboardType: TextInputType.number, + suffix: Text("seconds"), + textAlign: TextAlign.right, + maxLength: 5, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onSaved: (val) { + setState(() { + if (val != null) { + settings.lhDuration = int.parse(val); + } + }); + }, + )), ConfigItem( label: Text("Listen port"), labelWidth: 150, //TODO: Auto select on focus? - content: PlatformTextFormField( - initialValue: settings.port.toString(), - keyboardType: TextInputType.number, - textAlign: TextAlign.right, - maxLength: 5, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onSaved: (val) { - setState(() { - if (val != null) { - settings.port = int.parse(val!); - } - }); - }, - )), + content: widget.site.managed ? + Text(settings.port.toString(), textAlign: TextAlign.right) : + PlatformTextFormField( + initialValue: settings.port.toString(), + keyboardType: TextInputType.number, + textAlign: TextAlign.right, + maxLength: 5, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onSaved: (val) { + setState(() { + if (val != null) { + settings.port = int.parse(val); + } + }); + }, + )), ConfigItem( label: Text("MTU"), labelWidth: 150, - content: PlatformTextFormField( - initialValue: settings.mtu.toString(), - keyboardType: TextInputType.number, - textAlign: TextAlign.right, - maxLength: 5, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onSaved: (val) { - setState(() { - if (val != null) { - settings.mtu = int.parse(val!); - } - }); - }, - )), + content: widget.site.managed ? + Text(settings.mtu.toString(), textAlign: TextAlign.right) : + PlatformTextFormField( + initialValue: settings.mtu.toString(), + keyboardType: TextInputType.number, + textAlign: TextAlign.right, + maxLength: 5, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onSaved: (val) { + setState(() { + if (val != null) { + settings.mtu = int.parse(val); + } + }); + }, + )), ConfigPageItem( + disabled: widget.site.managed, label: Text('Cipher'), labelWidth: 150, content: Text(settings.cipher, textAlign: TextAlign.end), @@ -153,6 +160,7 @@ class _AdvancedScreenState extends State { }); }), ConfigPageItem( + disabled: widget.site.managed, label: Text('Log verbosity'), labelWidth: 150, content: Text(settings.verbosity, textAlign: TextAlign.end), @@ -176,7 +184,7 @@ class _AdvancedScreenState extends State { Utils.openPage(context, (context) { return UnsafeRoutesScreen( unsafeRoutes: settings.unsafeRoutes, - onSave: (routes) { + onSave: widget.site.managed ? null : (routes) { setState(() { settings.unsafeRoutes = routes; changed = true; diff --git a/lib/screens/siteConfig/CAListScreen.dart b/lib/screens/siteConfig/CAListScreen.dart index 41053aa..22ff968 100644 --- a/lib/screens/siteConfig/CAListScreen.dart +++ b/lib/screens/siteConfig/CAListScreen.dart @@ -56,20 +56,23 @@ class _CAListScreenState extends State { items.add(ConfigSection(children: caItems)); } - items.addAll(_addCA()); + if (widget.onSave != null) { + items.addAll(_addCA()); + } + return FormPage( - title: 'Certificate Authorities', - changed: changed, - onSave: () { - if (widget.onSave != null) { - Navigator.pop(context); - widget.onSave!(cas.values.map((ca) { - return ca; - }).toList()); - } - }, - child: Column(children: items)); - } + title: 'Certificate Authorities', + changed: changed, + onSave: () { + if (widget.onSave != null) { + Navigator.pop(context); + widget.onSave!(cas.values.map((ca) { + return ca; + }).toList()); + } + }, + child: Column(children: items)); + } List _buildCAs() { List items = []; @@ -80,7 +83,7 @@ class _CAListScreenState extends State { Utils.openPage(context, (context) { return CertificateDetailsScreen( certInfo: ca, - onDelete: () { + onDelete: widget.onSave == null ? null : () { setState(() { changed = true; cas.remove(key); diff --git a/lib/screens/siteConfig/RenderedConfigScreen.dart b/lib/screens/siteConfig/RenderedConfigScreen.dart index a6c9042..a5328f3 100644 --- a/lib/screens/siteConfig/RenderedConfigScreen.dart +++ b/lib/screens/siteConfig/RenderedConfigScreen.dart @@ -16,7 +16,7 @@ class RenderedConfigScreen extends StatelessWidget { @override Widget build(BuildContext context) { return SimplePage( - title: 'Rendered Site Config', + title: Text('Rendered Site Config'), scrollable: SimpleScrollable.both, trailingActions: [ PlatformIconButton( diff --git a/lib/screens/siteConfig/SiteConfigScreen.dart b/lib/screens/siteConfig/SiteConfigScreen.dart index 9d6a6b7..463a57d 100644 --- a/lib/screens/siteConfig/SiteConfigScreen.dart +++ b/lib/screens/siteConfig/SiteConfigScreen.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' as fpw; +import 'package:intl/intl.dart'; import 'package:mobile_nebula/components/FormPage.dart'; import 'package:mobile_nebula/components/PlatformTextFormField.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; @@ -93,6 +94,7 @@ class _SiteConfigScreenState extends State { _keys(), _hosts(), _advanced(), + _managed(), kDebugMode ? _debugConfig() : Container(height: 0), ], )); @@ -127,6 +129,26 @@ class _SiteConfigScreenState extends State { ]); } + Widget _managed() { + final formatter = DateFormat.yMMMMd('en_US').add_jm(); + var lastUpdate = "Unknown"; + if (site.lastManagedUpdate != null) { + lastUpdate = formatter.format(site.lastManagedUpdate!.toLocal()); + } + + return site.managed ? ConfigSection( + label: "MANAGED CONFIG", + children: [ + ConfigItem( + label: Text("Last Update"), + content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Text(lastUpdate), + ]), + ) + ] + ) : Container(); + } + Widget _keys() { final certError = site.certInfo == null || site.certInfo!.validity == null || !site.certInfo!.validity!.valid; var caError = site.ca.length == 0; @@ -158,7 +180,7 @@ class _SiteConfigScreenState extends State { certInfo: site.certInfo!, pubKey: pubKey, privKey: privKey, - onReplace: (result) { + onReplace: site.managed ? null : (result) { setState(() { changed = true; site.certInfo = result.certInfo; @@ -195,7 +217,7 @@ class _SiteConfigScreenState extends State { Utils.openPage(context, (context) { return CAListScreen( cas: site.ca, - onSave: (ca) { + onSave: site.managed ? null : (ca) { setState(() { changed = true; site.ca = ca; @@ -209,7 +231,7 @@ class _SiteConfigScreenState extends State { Widget _hosts() { return ConfigSection( - label: "Set up static hosts and lighthouses", + label: "LIGHTHOUSES / STATIC HOSTS", children: [ ConfigPageItem( label: Text('Hosts'), @@ -227,7 +249,7 @@ class _SiteConfigScreenState extends State { Utils.openPage(context, (context) { return StaticHostsScreen( hostmap: site.staticHostmap, - onSave: (map) { + onSave: site.managed ? null : (map) { setState(() { changed = true; site.staticHostmap = map; @@ -242,6 +264,7 @@ class _SiteConfigScreenState extends State { Widget _advanced() { return ConfigSection( + label: "ADVANCED", children: [ ConfigPageItem( label: Text('Advanced'), diff --git a/lib/screens/siteConfig/StaticHostmapScreen.dart b/lib/screens/siteConfig/StaticHostmapScreen.dart index e6b9a36..814c380 100644 --- a/lib/screens/siteConfig/StaticHostmapScreen.dart +++ b/lib/screens/siteConfig/StaticHostmapScreen.dart @@ -32,7 +32,7 @@ class StaticHostmapScreen extends StatefulWidget { final List destinations; final String nebulaIp; final bool lighthouse; - final ValueChanged onSave; + final ValueChanged? onSave; final Function? onDelete; @override @@ -66,7 +66,7 @@ class _StaticHostmapScreenState extends State { @override Widget build(BuildContext context) { return FormPage( - title: widget.onDelete == null ? 'New Static Host' : 'Edit Static Host', + title: widget.onDelete == null ? widget.onSave == null ? 'View Static Host' : 'New Static Host' : 'Edit Static Host', changed: changed, onSave: _onSave, child: Column(children: [ @@ -74,7 +74,9 @@ class _StaticHostmapScreenState extends State { ConfigItem( label: Text('Nebula IP'), labelWidth: 200, - content: IPFormField( + content: widget.onSave == null ? + Text(_nebulaIp, textAlign: TextAlign.end) : + IPFormField( help: "Required", initialValue: _nebulaIp, ipOnly: true, @@ -94,7 +96,7 @@ class _StaticHostmapScreenState extends State { child: Switch.adaptive( value: _lighthouse, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onChanged: (v) { + onChanged: widget.onSave == null ? null : (v) { setState(() { changed = true; _lighthouse = v; @@ -125,13 +127,16 @@ class _StaticHostmapScreenState extends State { _onSave() { Navigator.pop(context); - var map = Hostmap(nebulaIp: _nebulaIp, destinations: [], lighthouse: _lighthouse); + if (widget.onSave != null) { + var map = Hostmap( + nebulaIp: _nebulaIp, destinations: [], lighthouse: _lighthouse); - _destinations.forEach((_, dest) { - map.destinations.add(dest.destination); - }); + _destinations.forEach((_, dest) { + map.destinations.add(dest.destination); + }); - widget.onSave(map); + widget.onSave!(map); + } } List _buildHosts() { @@ -142,7 +147,7 @@ class _StaticHostmapScreenState extends State { key: key, label: Align( alignment: Alignment.centerLeft, - child: PlatformIconButton( + child: widget.onSave == null ? Container() : PlatformIconButton( padding: EdgeInsets.zero, icon: Icon(Icons.remove_circle, color: CupertinoColors.systemRed.resolveFrom(context)), onPressed: () => setState(() { @@ -152,28 +157,33 @@ class _StaticHostmapScreenState extends State { labelWidth: 70, content: Row(children: [ Expanded( - child: IPAndPortFormField( - ipHelp: 'public ip or name', - ipTextAlign: TextAlign.end, - enableIPV6: true, - noBorder: true, - initialValue: dest.destination, - onSaved: (v) { - if (v != null) { - dest.destination = v; - } - }, - )), + child: widget.onSave == null ? + Text(dest.destination.toString(), textAlign: TextAlign.end) : + IPAndPortFormField( + ipHelp: 'public ip or name', + ipTextAlign: TextAlign.end, + enableIPV6: true, + noBorder: true, + initialValue: dest.destination, + onSaved: (v) { + if (v != null) { + dest.destination = v; + } + }, + )), ]), )); }); - items.add(ConfigButtonItem( - content: Text('Add another'), - onPressed: () => setState(() { - _addDestination(); - _dismissKeyboard(); - }))); + if (widget.onSave != null) { + items.add(ConfigButtonItem( + content: Text('Add another'), + onPressed: () => + setState(() { + _addDestination(); + _dismissKeyboard(); + }))); + } return items; } diff --git a/lib/screens/siteConfig/StaticHostsScreen.dart b/lib/screens/siteConfig/StaticHostsScreen.dart index 6746948..c77f39c 100644 --- a/lib/screens/siteConfig/StaticHostsScreen.dart +++ b/lib/screens/siteConfig/StaticHostsScreen.dart @@ -34,7 +34,7 @@ class StaticHostsScreen extends StatefulWidget { }) : super(key: key); final Map hostmap; - final ValueChanged> onSave; + final ValueChanged>? onSave; @override _StaticHostsScreenState createState() => _StaticHostsScreenState(); @@ -67,12 +67,15 @@ class _StaticHostsScreenState extends State { _onSave() { Navigator.pop(context); - Map map = {}; - _hostmap.forEach((_, host) { - map[host.nebulaIp] = StaticHost(destinations: host.destinations, lighthouse: host.lighthouse); - }); + if (widget.onSave != null) { + Map map = {}; + _hostmap.forEach((_, host) { + map[host.nebulaIp] = StaticHost( + destinations: host.destinations, lighthouse: host.lighthouse); + }); - widget.onSave(map); + widget.onSave!(map); + } } List _buildHosts() { @@ -95,7 +98,7 @@ class _StaticHostsScreenState extends State { nebulaIp: host.nebulaIp, destinations: host.destinations, lighthouse: host.lighthouse, - onSave: (map) { + onSave: widget.onSave == null ? null :(map) { setState(() { changed = true; host.nebulaIp = map.nebulaIp; @@ -103,7 +106,7 @@ class _StaticHostsScreenState extends State { host.lighthouse = map.lighthouse; }); }, - onDelete: () { + onDelete: widget.onSave == null ? null : () { setState(() { changed = true; _hostmap.remove(key); @@ -114,19 +117,21 @@ class _StaticHostsScreenState extends State { )); }); - items.add(ConfigButtonItem( - content: Text('Add a new entry'), - onPressed: () { - Utils.openPage(context, (context) { - return StaticHostmapScreen(onSave: (map) { - setState(() { - changed = true; - _addHostmap(map); + if (widget.onSave != null) { + items.add(ConfigButtonItem( + content: Text('Add a new entry'), + onPressed: () { + Utils.openPage(context, (context) { + return StaticHostmapScreen(onSave: (map) { + setState(() { + changed = true; + _addHostmap(map); + }); }); }); - }); - }, - )); + }, + )); + } return items; } diff --git a/lib/screens/siteConfig/UnsafeRoutesScreen.dart b/lib/screens/siteConfig/UnsafeRoutesScreen.dart index 85fe5a0..08ba491 100644 --- a/lib/screens/siteConfig/UnsafeRoutesScreen.dart +++ b/lib/screens/siteConfig/UnsafeRoutesScreen.dart @@ -15,7 +15,7 @@ class UnsafeRoutesScreen extends StatefulWidget { }) : super(key: key); final List unsafeRoutes; - final ValueChanged> onSave; + final ValueChanged>? onSave; @override _UnsafeRoutesScreenState createState() => _UnsafeRoutesScreenState(); @@ -48,7 +48,9 @@ class _UnsafeRoutesScreenState extends State { _onSave() { Navigator.pop(context); - widget.onSave(unsafeRoutes.values.toList()); + if (widget.onSave != null) { + widget.onSave!(unsafeRoutes.values.toList()); + } } List _buildRoutes() { @@ -56,6 +58,7 @@ class _UnsafeRoutesScreenState extends State { List items = []; unsafeRoutes.forEach((key, route) { items.add(ConfigPageItem( + disabled: widget.onSave == null, label: Text(route.route ?? ''), labelWidth: ipWidth, content: Text('via ${route.via}', textAlign: TextAlign.end), @@ -80,21 +83,23 @@ class _UnsafeRoutesScreenState extends State { )); }); - items.add(ConfigButtonItem( - content: Text('Add a new route'), - onPressed: () { - Utils.openPage(context, (context) { - return UnsafeRouteScreen( - route: UnsafeRoute(), - onSave: (route) { - setState(() { - changed = true; - unsafeRoutes[UniqueKey()] = route; + if (widget.onSave != null) { + items.add(ConfigButtonItem( + content: Text('Add a new route'), + onPressed: () { + Utils.openPage(context, (context) { + return UnsafeRouteScreen( + route: UnsafeRoute(), + onSave: (route) { + setState(() { + changed = true; + unsafeRoutes[UniqueKey()] = route; + }); }); - }); - }); - }, - )); + }); + }, + )); + } return items; } diff --git a/lib/services/utils.dart b/lib/services/utils.dart index fbc261f..40ce317 100644 --- a/lib/services/utils.dart +++ b/lib/services/utils.dart @@ -177,7 +177,7 @@ class Utils { return null; } - final file = File(result!.files.first.path!); + final file = File(result.files.first.path!); return file.readAsString(); } } diff --git a/lib/validators/mtuValidator.dart b/lib/validators/mtuValidator.dart index 9683334..7372cae 100644 --- a/lib/validators/mtuValidator.dart +++ b/lib/validators/mtuValidator.dart @@ -1,6 +1,6 @@ Function mtuValidator(bool required) { return (String str) { - if (str == null || str == "") { + if (str == "") { return required ? 'Please fill out this field' : null; } diff --git a/nebula/api.go b/nebula/api.go new file mode 100644 index 0000000..9e455c4 --- /dev/null +++ b/nebula/api.go @@ -0,0 +1,134 @@ +package mobileNebula + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/DefinedNet/dnapi" + "github.com/sirupsen/logrus" + "github.com/slackhq/nebula/cert" +) + +type APIClient struct { + c *dnapi.Client + l *logrus.Logger +} + +type EnrollResult struct { + Site string +} + +type TryUpdateResult struct { + FetchedUpdate bool + Site string +} + +func NewAPIClient(useragent string) *APIClient { + // TODO Use a log file + l := logrus.New() + l.SetOutput(io.Discard) + + return &APIClient{ + // TODO Make the server configurable + c: dnapi.NewClient(useragent, "https://api.defined.net"), + l: l, + } +} + +type InvalidCredentialsError struct{} + +func (e InvalidCredentialsError) Error() string { + // XXX Type information is not available in Kotlin/Swift. Instead we make use of string matching on the error + // message. DO NOT CHANGE THIS STRING unless you also update the Kotlin and Swift code that checks for it. + return "invalid credentials" +} + +func (c *APIClient) Enroll(code string) (*EnrollResult, error) { + cfg, pkey, creds, meta, err := c.c.EnrollWithTimeout(context.Background(), 30*time.Second, c.l, code) + var apiError *dnapi.APIError + switch { + case errors.As(err, &apiError): + return nil, fmt.Errorf("%s (request ID: %s)", apiError, apiError.ReqID) + case errors.Is(err, context.DeadlineExceeded): + return nil, fmt.Errorf("enrollment request timed out - try again?") + case err != nil: + return nil, fmt.Errorf("unexpected failure: %s", err) + } + + site, err := newDNSite(meta.OrganizationName, cfg, string(pkey), *creds) + if err != nil { + return nil, fmt.Errorf("failure generating site: %s", err) + } + + jsonSite, err := json.Marshal(site) + if err != nil { + return nil, fmt.Errorf("failed to marshal site: %s", err) + } + + return &EnrollResult{Site: string(jsonSite)}, nil +} + +func (c *APIClient) TryUpdate(siteName string, hostID string, privateKey string, counter int, trustedKeys string) (*TryUpdateResult, error) { + // Build dnapi.Credentials struct from inputs + if counter < 0 { + return nil, fmt.Errorf("invalid counter value: must be unsigned") + } + + credsPkey, rest, err := cert.UnmarshalEd25519PrivateKey([]byte(privateKey)) + switch { + case err != nil: + return nil, fmt.Errorf("invalid private key: %s", err) + case len(rest) > 0: + return nil, fmt.Errorf("invalid private key: %d trailing bytes", len(rest)) + } + + keys, err := dnapi.Ed25519PublicKeysFromPEM([]byte(trustedKeys)) + if err != nil { + return nil, fmt.Errorf("invalid trusted keys: %s", err) + } + + creds := dnapi.Credentials{ + HostID: hostID, + PrivateKey: credsPkey, + Counter: uint(counter), + TrustedKeys: keys, + } + + // Check for update + updateAvailable, err := c.c.CheckForUpdateWithTimeout(context.Background(), 10*time.Second, creds) + switch { + case errors.As(err, &dnapi.InvalidCredentialsError{}): + return nil, InvalidCredentialsError{} + case err != nil: + return nil, fmt.Errorf("CheckForUpdate error: %s", err) + } + + if !updateAvailable { + return &TryUpdateResult{FetchedUpdate: false}, nil + } + + // Perform the update and return the new site object + cfg, pkey, newCreds, err := c.c.DoUpdateWithTimeout(context.Background(), 10*time.Second, creds) + switch { + case errors.As(err, &dnapi.InvalidCredentialsError{}): + return nil, InvalidCredentialsError{} + case err != nil: + return nil, fmt.Errorf("DoUpdate error: %s", err) + } + + site, err := newDNSite(siteName, cfg, string(pkey), *newCreds) + if err != nil { + return nil, fmt.Errorf("failure generating site: %s", err) + } + + jsonSite, err := json.Marshal(site) + if err != nil { + return nil, fmt.Errorf("failed to marshal site: %s", err) + } + + return &TryUpdateResult{Site: string(jsonSite), FetchedUpdate: true}, nil +} diff --git a/nebula/control.go b/nebula/control.go index 83e6e43..90b71f9 100644 --- a/nebula/control.go +++ b/nebula/control.go @@ -18,8 +18,9 @@ import ( ) type Nebula struct { - c *nebula.Control - l *logrus.Logger + c *nebula.Control + l *logrus.Logger + config *nc.C } func init() { @@ -62,7 +63,7 @@ func NewNebula(configData string, key string, logFile string, tunFd int) (*Nebul } } - return &Nebula{ctrl, l}, nil + return &Nebula{ctrl, l, c}, nil } func (n *Nebula) Log(v string) { @@ -86,6 +87,16 @@ func (n *Nebula) Rebind(reason string) { n.c.RebindUDPServer() } +func (n *Nebula) Reload(configData string, key string) error { + n.l.Info("Reloading Nebula") + yamlConfig, err := RenderConfig(configData, key) + if err != nil { + return err + } + + return n.config.ReloadConfigString(yamlConfig) +} + func (n *Nebula) ListHostmap(pending bool) (string, error) { hosts := n.c.ListHostmap(pending) b, err := json.Marshal(hosts) diff --git a/nebula/go.mod b/nebula/go.mod index 9309322..e2061c7 100644 --- a/nebula/go.mod +++ b/nebula/go.mod @@ -1,10 +1,11 @@ module github.com/DefinedNet/mobile_nebula/nebula -go 1.18 +go 1.19 // replace github.com/slackhq/nebula => /Volumes/T7/nate/src/github.com/slackhq/nebula require ( + github.com/DefinedNet/dnapi v0.0.0-20221117210952-6f56f055f991 github.com/sirupsen/logrus v1.9.0 github.com/slackhq/nebula v1.6.2-0.20221116023309-813b64ffb179 golang.org/x/crypto v0.3.0 diff --git a/nebula/go.sum b/nebula/go.sum index c6693c3..7ab9647 100644 --- a/nebula/go.sum +++ b/nebula/go.sum @@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DefinedNet/dnapi v0.0.0-20221117210952-6f56f055f991 h1:TVDFasW5VaUmEMQjwci7NevCXHfef8HCWvJfS6osFjs= +github.com/DefinedNet/dnapi v0.0.0-20221117210952-6f56f055f991/go.mod h1:J+zO5WxmoN8/hJrP7dt78/1NJVJYXY2diwMPLgHMPtg= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -224,7 +226,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= @@ -544,8 +546,8 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/nebula/mobileNebula.go b/nebula/mobileNebula.go index b327e17..0652916 100644 --- a/nebula/mobileNebula.go +++ b/nebula/mobileNebula.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/DefinedNet/dnapi" "github.com/sirupsen/logrus" "github.com/slackhq/nebula" "github.com/slackhq/nebula/cert" @@ -46,7 +47,6 @@ type KeyPair struct { } func RenderConfig(configData string, key string) (string, error) { - config := newConfig() var d m err := json.Unmarshal([]byte(configData), &d) @@ -54,35 +54,46 @@ func RenderConfig(configData string, key string) (string, error) { return "", err } - config.PKI.CA, _ = d["ca"].(string) - config.PKI.Cert, _ = d["cert"].(string) - config.PKI.Key = key + // If this is a managed config, go ahead and return it + if rawCfg, ok := d["rawConfig"].(string); ok { + yamlCfg, err := dnapi.InsertConfigPrivateKey([]byte(rawCfg), []byte(key)) + if err != nil { + return "", err + } + return "# DN-managed config\n" + string(yamlCfg), nil + } + + // Otherwise, build the config + cfg := newConfig() + cfg.PKI.CA, _ = d["ca"].(string) + cfg.PKI.Cert, _ = d["cert"].(string) + cfg.PKI.Key = key i, _ := d["port"].(float64) - config.Listen.Port = int(i) + cfg.Listen.Port = int(i) - config.Cipher, _ = d["cipher"].(string) + cfg.Cipher, _ = d["cipher"].(string) // Log verbosity is not required if val, _ := d["logVerbosity"].(string); val != "" { - config.Logging.Level = val + cfg.Logging.Level = val } i, _ = d["lhDuration"].(float64) - config.Lighthouse.Interval = int(i) + cfg.Lighthouse.Interval = int(i) if i, ok := d["mtu"].(float64); ok { mtu := int(i) - config.Tun.MTU = &mtu + cfg.Tun.MTU = &mtu } - config.Lighthouse.Hosts = make([]string, 0) + cfg.Lighthouse.Hosts = make([]string, 0) staticHostmap := d["staticHostmap"].(map[string]interface{}) for nebIp, mapping := range staticHostmap { def := mapping.(map[string]interface{}) isLh := def["lighthouse"].(bool) if isLh { - config.Lighthouse.Hosts = append(config.Lighthouse.Hosts, nebIp) + cfg.Lighthouse.Hosts = append(cfg.Lighthouse.Hosts, nebIp) } hosts := def["destinations"].([]interface{}) @@ -92,20 +103,20 @@ func RenderConfig(configData string, key string) (string, error) { realHosts[i] = h.(string) } - config.StaticHostmap[nebIp] = realHosts + cfg.StaticHostmap[nebIp] = realHosts } if unsafeRoutes, ok := d["unsafeRoutes"].([]interface{}); ok { - config.Tun.UnsafeRoutes = make([]configUnsafeRoute, len(unsafeRoutes)) + cfg.Tun.UnsafeRoutes = make([]configUnsafeRoute, len(unsafeRoutes)) for i, r := range unsafeRoutes { rawRoute := r.(map[string]interface{}) - route := &config.Tun.UnsafeRoutes[i] + route := &cfg.Tun.UnsafeRoutes[i] route.Route = rawRoute["route"].(string) route.Via = rawRoute["via"].(string) } } - finalConfig, err := yaml.Marshal(config) + finalConfig, err := yaml.Marshal(cfg) if err != nil { return "", err } diff --git a/nebula/site.go b/nebula/site.go new file mode 100644 index 0000000..b2a228a --- /dev/null +++ b/nebula/site.go @@ -0,0 +1,130 @@ +package mobileNebula + +import ( + "time" + + "github.com/DefinedNet/dnapi" + "github.com/slackhq/nebula/cert" + "gopkg.in/yaml.v2" +) + +// Site represents an IncomingSite in Kotlin/Swift. +type site struct { + Name string `json:"name"` + ID string `json:"id"` + StaticHostmap map[string]staticHost `json:"staticHostmap"` + UnsafeRoutes *[]unsafeRoute `json:"unsafeRoutes"` + Cert string `json:"cert"` + CA string `json:"ca"` + LHDuration int `json:"lhDuration"` + Port int `json:"port"` + MTU *int `json:"mtu"` + Cipher string `json:"cipher"` + SortKey *int `json:"sortKey"` + LogVerbosity *string `json:"logVerbosity"` + Key *string `json:"key"` + Managed jsonTrue `json:"managed"` + LastManagedUpdate *time.Time `json:"lastManagedUpdate"` + RawConfig *string `json:"rawConfig"` + DNCredentials *dnCredentials `json:"dnCredentials"` +} + +type staticHost struct { + Lighthouse bool `json:"lighthouse"` + Destinations []string `json:"destinations"` +} + +type unsafeRoute struct { + Route string `json:"route"` + Via string `json:"via"` + MTU *int `json:"mtu"` +} + +type dnCredentials struct { + HostID string `json:"hostID"` + PrivateKey string `json:"privateKey"` + Counter int `json:"counter"` + TrustedKeys string `json:"trustedKeys"` +} + +// jsonTrue always marshals to true. +type jsonTrue bool + +func (f jsonTrue) MarshalJSON() ([]byte, error) { + return []byte(`true`), nil +} + +func newDNSite(name string, rawCfg []byte, key string, creds dnapi.Credentials) (*site, error) { + // Convert YAML Nebula config to a JSON Site + var cfg config + if err := yaml.Unmarshal(rawCfg, &cfg); err != nil { + return nil, err + } + + strCfg := string(rawCfg) + + // build static hostmap + shm := map[string]staticHost{} + for vpnIP, remoteIPs := range cfg.StaticHostmap { + sh := staticHost{Destinations: remoteIPs} + shm[vpnIP] = sh + } + for _, vpnIP := range cfg.Lighthouse.Hosts { + if sh, ok := shm[vpnIP]; ok { + sh.Lighthouse = true + shm[vpnIP] = sh + } else { + shm[vpnIP] = staticHost{Lighthouse: true} + } + } + + // build unsafe routes + ur := []unsafeRoute{} + for _, canon := range cfg.Tun.UnsafeRoutes { + ur = append(ur, unsafeRoute{ + Route: canon.Route, + Via: canon.Via, + MTU: canon.MTU, + }) + } + + // log verbosity is nullable + var logVerb *string + if cfg.Logging.Level != "" { + v := cfg.Logging.Level + logVerb = &v + } + + // TODO the mobile app requires an explicit cipher or it will display an error + cipher := cfg.Cipher + if cipher == "" { + cipher = "aes" + } + + now := time.Now() + + return &site{ + Name: name, + ID: creds.HostID, + StaticHostmap: shm, + UnsafeRoutes: &ur, + Cert: cfg.PKI.Cert, + CA: cfg.PKI.CA, + LHDuration: cfg.Lighthouse.Interval, + Port: cfg.Listen.Port, + MTU: cfg.Tun.MTU, + Cipher: cipher, + SortKey: nil, + LogVerbosity: logVerb, + Key: &key, + Managed: true, + LastManagedUpdate: &now, + RawConfig: &strCfg, + DNCredentials: &dnCredentials{ + HostID: creds.HostID, + PrivateKey: string(cert.MarshalEd25519PrivateKey(creds.PrivateKey)), + Counter: int(creds.Counter), + TrustedKeys: string(dnapi.Ed25519PublicKeysToPEM(creds.TrustedKeys)), + }, + }, nil +} diff --git a/pubspec.lock b/pubspec.lock index cc6fee2..6296b8b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -104,6 +104,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.7" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.5" flutter_test: dependency: "direct dev" description: flutter @@ -114,6 +121,13 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" js: dependency: transitive description: @@ -156,6 +170,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.2" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" path_provider: dependency: "direct main" description: @@ -205,6 +233,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.3" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" platform: dependency: transitive description: @@ -293,7 +328,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.17" + version: "6.1.6" url_launcher_android: dependency: transitive description: @@ -328,7 +363,7 @@ packages: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.1.1" url_launcher_web: dependency: transitive description: @@ -371,6 +406,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" sdks: dart: ">=2.18.1 <3.0.0" flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 6a57861..8518b52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,9 +28,11 @@ dependencies: file_picker: ^5.0.1 uuid: ^3.0.4 package_info: ^2.0.0 - url_launcher: ^6.0.6 + url_launcher: ^6.1.6 pull_to_refresh: ^2.0.0 flutter_barcode_scanner: ^2.0.0 + flutter_svg: ^1.1.5 + intl: ^0.17.0 dev_dependencies: flutter_test: @@ -52,6 +54,9 @@ flutter: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - images/dn-logo-light.svg + - images/dn-logo-dark.svg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware.