Support DN host enrollment (#86)

Co-authored-by: Nate Brown <nbrown.us@gmail.com>
This commit is contained in:
John Maguire 2022-11-17 16:43:16 -05:00 committed by GitHub
parent c3f5c39d83
commit c7a53c3905
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 2338 additions and 511 deletions

View File

@ -41,7 +41,7 @@ Update `version` in `pubspec.yaml` to reflect this release, then
## Android ## Android
`flutter build appbundle --no-shrink` `flutter build appbundle`
This will create an android app bundle at `build/app/outputs/bundle/release/` This will create an android app bundle at `build/app/outputs/bundle/release/`

View File

@ -78,9 +78,12 @@ flutter {
} }
dependencies { 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.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.code.gson:gson:2.8.6'
implementation "com.google.guava:guava:31.0.1-android"
implementation project(':mobileNebula') implementation project(':mobileNebula')
} }

View File

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="net.defined.mobile_nebula"> package="net.defined.mobile_nebula">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that <!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method. calls FlutterMain.startInitialization(this); in its onCreate method.
@ -8,8 +9,14 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="mailto" />
</intent>
</queries>
<application <application
android:name="${applicationName}" android:name="MyApplication"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<service android:name=".NebulaVpnService" <service android:name=".NebulaVpnService"
@ -32,6 +39,15 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- App linking -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="http" android:host="api.defined.net" android:pathPrefix="/v1/mobile-enrollment"/>
<data android:scheme="https"/>
</intent-filter>
</activity> </activity>
<receiver android:name=".ShareReceiver" android:exported="false"/> <receiver android:name=".ShareReceiver" android:exported="false"/>
<provider <provider
@ -43,6 +59,18 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/> android:resource="@xml/provider_paths"/>
</provider> </provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- If you are using androidx.startup to initialize other components -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@ -0,0 +1,47 @@
package net.defined.mobile_nebula
import android.content.Context
import android.util.Log
import com.google.gson.Gson
class InvalidCredentialsException(): Exception("Invalid credentials")
class APIClient(context: Context) {
private val packageInfo = PackageInfo(context)
private val client = mobileNebula.MobileNebula.newAPIClient(
"%s/%s (Android %s)".format(
packageInfo.getName(),
packageInfo.getVersion(),
packageInfo.getSystemVersion(),
))
private val gson = Gson()
fun enroll(code: String): IncomingSite {
val res = client.enroll(code)
return decodeIncomingSite(res.site)
}
fun tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Long, trustedKeys: String): IncomingSite? {
val res: mobileNebula.TryUpdateResult
try {
res = client.tryUpdate(siteName, hostID, privateKey, counter, trustedKeys)
} catch (e: Exception) {
// type information from Go is not available, use string matching instead
if (e.message == "invalid credentials") {
throw InvalidCredentialsException()
}
throw e
}
if (res.fetchedUpdate) {
return decodeIncomingSite(res.site)
}
return null
}
private fun decodeIncomingSite(jsonSite: String): IncomingSite {
return gson.fromJson(jsonSite, IncomingSite::class.java)
}
}

View File

@ -0,0 +1,118 @@
package net.defined.mobile_nebula
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.io.Closeable
import java.io.IOException
import java.nio.channels.FileChannel
import java.nio.file.Paths
import java.nio.file.StandardOpenOption
class DNUpdateWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
companion object {
private const val TAG = "DNUpdateWorker"
}
private val context = applicationContext
private val apiClient: APIClient = APIClient(ctx)
private val updater = DNSiteUpdater(context, apiClient)
private val sites = SiteList(context)
override fun doWork(): Result {
var failed = false
sites.getSites().values.forEach { site ->
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
}
}

View File

@ -1,35 +1,53 @@
package net.defined.mobile_nebula package net.defined.mobile_nebula
import android.app.Activity import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.VpnService import android.net.VpnService
import android.os.* import android.os.*
import android.util.Log
import androidx.annotation.NonNull 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 com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.GeneratedPluginRegistrant
import java.io.File
import java.util.concurrent.TimeUnit
const val TAG = "nebula" const val TAG = "nebula"
const val VPN_PERMISSIONS_CODE = 0x0F const val VPN_PERMISSIONS_CODE = 0x0F
const val VPN_START_CODE = 0x10 const val VPN_START_CODE = 0x10
const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService" const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService"
const val UPDATE_WORKER = "dnUpdater"
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private var sites: Sites? = null
private var permResult: MethodChannel.Result? = null
private var inMessenger: Messenger? = Messenger(IncomingHandler()) private var inMessenger: Messenger? = Messenger(IncomingHandler())
private var outMessenger: Messenger? = null 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 var activeSiteId: String? = null
private val workManager = WorkManager.getInstance(application)
private val refreshReceiver: BroadcastReceiver = RefreshReceiver()
companion object { companion object {
const val ACTION_REFRESH_SITES = "net.defined.mobileNebula.REFRESH_SITES"
private var appContext: Context? = null private var appContext: Context? = null
fun getContext(): Context? { return appContext } fun getContext(): Context? { return appContext }
} }
@ -41,7 +59,8 @@ class MainActivity: FlutterActivity() {
GeneratedPluginRegistrant.registerWith(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) { when(call.method) {
"android.requestPermissions" -> androidPermissions(result) "android.requestPermissions" -> androidPermissions(result)
"android.registerActiveSite" -> registerActiveSite(result) "android.registerActiveSite" -> registerActiveSite(result)
@ -51,6 +70,8 @@ class MainActivity: FlutterActivity() {
"nebula.renderConfig" -> nebulaRenderConfig(call, result) "nebula.renderConfig" -> nebulaRenderConfig(call, result)
"nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result) "nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result)
"dn.enroll" -> dnEnroll(call, result)
"listSites" -> listSites(result) "listSites" -> listSites(result)
"deleteSite" -> deleteSite(call, result) "deleteSite" -> deleteSite(call, result)
"saveSite" -> saveSite(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<DNUpdateWorker>(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 // 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 // the current active site and attaching site specific event channels in the event the UI app was quit
private fun registerActiveSite(result: MethodChannel.Result) { 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) { private fun listSites(result: MethodChannel.Result) {
sites!!.refreshSites(activeSiteId) sites!!.refreshSites(activeSiteId)
val sites = sites!!.getSites() val sites = sites!!.getSites()
@ -143,40 +210,50 @@ class MainActivity: FlutterActivity() {
private fun saveSite(call: MethodCall, result: MethodChannel.Result) { private fun saveSite(call: MethodCall, result: MethodChannel.Result) {
val site: IncomingSite val site: IncomingSite
val siteDir: File
try { try {
val gson = Gson() val gson = Gson()
site = gson.fromJson(call.arguments as String, IncomingSite::class.java) site = gson.fromJson(call.arguments as String, IncomingSite::class.java)
site.save(context) siteDir = site.save(context)
} catch (err: Exception) { } catch (err: Exception) {
//TODO: is toString the best or .message? //TODO: is toString the best or .message?
return result.error("failure", err.toString(), null) return result.error("failure", err.toString(), null)
} }
val siteDir = context.filesDir.resolve("sites").resolve(site.id) if (!validateOrDeleteSite(siteDir)) {
try {
// Try to render a full site, if this fails the config was bad somehow
Site(siteDir)
} catch (err: Exception) {
siteDir.deleteRecursively()
return result.error("failure", "Site config was incomplete, please review and try again", null) return result.error("failure", "Site config was incomplete, please review and try again", null)
} }
result.success(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) { private fun startSite(call: MethodCall, result: MethodChannel.Result) {
val id = call.argument<String>("id") val id = call.argument<String>("id")
if (id == "") { if (id == "") {
return result.error("required_argument", "id is a required argument", null) 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.updater.setState(true, "Initializing...")
siteContainer.site.status = "Initializing..."
val intent = VpnService.prepare(this) var intent = VpnService.prepare(this)
if (intent != null) { 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 //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) intent.putExtra("path", siteContainer.site.path)
@ -184,7 +261,7 @@ class MainActivity: FlutterActivity() {
startActivityForResult(intent, VPN_START_CODE) startActivityForResult(intent, VPN_START_CODE)
} else { } else {
val intent = Intent(this, NebulaVpnService::class.java) intent = Intent(this, NebulaVpnService::class.java)
intent.putExtra("path", siteContainer.site.path) intent.putExtra("path", siteContainer.site.path)
intent.putExtra("id", siteContainer.site.id) intent.putExtra("id", siteContainer.site.id)
onActivityResult(VPN_START_CODE, Activity.RESULT_OK, intent) onActivityResult(VPN_START_CODE, Activity.RESULT_OK, intent)
@ -355,7 +432,8 @@ class MainActivity: FlutterActivity() {
return result.error("PERMISSIONS", "User did not grant permission", null) return result.error("PERMISSIONS", "User did not grant permission", null)
} else if (requestCode == VPN_START_CODE) { } 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) startService(data)
if (outMessenger == null) { if (outMessenger == null) {
bindService(data, connection, 0) bindService(data, connection, 0)
@ -368,14 +446,15 @@ class MainActivity: FlutterActivity() {
} }
/** Defines callbacks for service binding, passed to bindService() */ /** Defines callbacks for service binding, passed to bindService() */
val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) { override fun onServiceConnected(className: ComponentName, service: IBinder) {
outMessenger = Messenger(service) outMessenger = Messenger(service)
// We want to monitor the service for as long as we are connected to it. // We want to monitor the service for as long as we are connected to it.
try { try {
val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT) val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT)
msg.replyTo = inMessenger msg.replyTo = inMessenger
outMessenger?.send(msg) outMessenger!!.send(msg)
} catch (e: RemoteException) { } catch (e: RemoteException) {
// In this case the service has crashed before we could even // 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) val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING)
outMessenger?.send(msg) outMessenger!!.send(msg)
} }
override fun onServiceDisconnected(arg0: ComponentName) { override fun onServiceDisconnected(arg0: ComponentName) {
@ -429,6 +508,32 @@ class MainActivity: FlutterActivity() {
private fun serviceExited(site: SiteContainer, msg: Message) { private fun serviceExited(site: SiteContainer, msg: Message) {
activeSiteId = null activeSiteId = null
site.updater.setState(false, "Disconnected", msg.data.getString("error")) 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)
}
}
} }

View File

@ -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)
}
}

View File

@ -10,6 +10,7 @@ import android.os.*
import android.system.OsConstants import android.system.OsConstants
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.work.*
import mobileNebula.CIDR import mobileNebula.CIDR
import java.io.File import java.io.File
@ -17,8 +18,11 @@ import java.io.File
class NebulaVpnService : VpnService() { class NebulaVpnService : VpnService() {
companion object { companion object {
private const val TAG = "NebulaVpnService" const val TAG = "NebulaVpnService"
const val ACTION_STOP = "net.defined.mobile_nebula.STOP" 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_REGISTER_CLIENT = 1
const val MSG_UNREGISTER_CLIENT = 2 const val MSG_UNREGISTER_CLIENT = 2
const val MSG_IS_RUNNING = 3 const val MSG_IS_RUNNING = 3
@ -36,6 +40,10 @@ class NebulaVpnService : VpnService() {
private lateinit var messenger: Messenger private lateinit var messenger: Messenger
private val mClients = ArrayList<Messenger>() private val mClients = ArrayList<Messenger>()
private val reloadReceiver: BroadcastReceiver = ReloadReceiver()
private var workManager: WorkManager? = null
private var path: String? = null
private var running: Boolean = false private var running: Boolean = false
private var site: Site? = null private var site: Site? = null
private var nebula: mobileNebula.Nebula? = null private var nebula: mobileNebula.Nebula? = null
@ -43,13 +51,17 @@ class NebulaVpnService : VpnService() {
private var didSleep = false private var didSleep = false
private var networkCallback: NetworkCallback = NetworkCallback() private var networkCallback: NetworkCallback = NetworkCallback()
override fun onCreate() {
workManager = WorkManager.getInstance(this)
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.getAction() == ACTION_STOP) { if (intent?.getAction() == ACTION_STOP) {
stopVpn() stopVpn()
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
} }
val path = intent?.getStringExtra("path")
val id = intent?.getStringExtra("id") val id = intent?.getStringExtra("id")
if (running) { if (running) {
@ -63,9 +75,10 @@ class NebulaVpnService : VpnService() {
return super.onStartCommand(intent, flags, startId) 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. //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 // Link active site config in Main to avoid this
site = Site(File(path)) site = Site(this, File(path))
if (site!!.cert == null) { if (site!!.cert == null) {
announceExit(id, "Site is missing a certificate") announceExit(id, "Site is missing a certificate")
@ -73,6 +86,10 @@ class NebulaVpnService : VpnService() {
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
// Kick off a site update
val workRequest = OneTimeWorkRequestBuilder<DNUpdateWorker>().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 // 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) return super.onStartCommand(intent, flags, startId)
@ -117,6 +134,7 @@ class NebulaVpnService : VpnService() {
} }
registerNetworkCallback() registerNetworkCallback()
registerReloadReceiver()
//TODO: There is an open discussion around sleep killing tunnels or just changing mobile to tear down stale tunnels //TODO: There is an open discussion around sleep killing tunnels or just changing mobile to tear down stale tunnels
//registerSleep() //registerSleep()
@ -173,12 +191,26 @@ class NebulaVpnService : VpnService() {
registerReceiver(receiver, IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)) 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() { private fun stopVpn() {
if (nebula == null) { if (nebula == null) {
return stopSelf() return stopSelf()
} }
unregisterNetworkCallback() unregisterNetworkCallback()
unregisterReloadReceiver()
nebula?.stop() nebula?.stop()
nebula = null nebula = null
running = false running = false
@ -207,6 +239,18 @@ class NebulaVpnService : VpnService() {
send(msg, id) 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. * Handler of incoming messages from clients.
*/ */

View File

@ -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;
}
}

View File

@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import java.io.File import java.io.File
import java.io.FileNotFoundException
import kotlin.collections.HashMap import kotlin.collections.HashMap
data class SiteContainer( data class SiteContainer(
@ -16,7 +17,7 @@ data class SiteContainer(
) )
class Sites(private var engine: FlutterEngine) { class Sites(private var engine: FlutterEngine) {
private var sites: HashMap<String, SiteContainer> = HashMap() private var containers: HashMap<String, SiteContainer> = HashMap()
init { init {
refreshSites() refreshSites()
@ -24,65 +25,111 @@ class Sites(private var engine: FlutterEngine) {
fun refreshSites(activeSite: String? = null) { fun refreshSites(activeSite: String? = null) {
val context = MainActivity.getContext()!! val context = MainActivity.getContext()!!
val sitesDir = context.filesDir.resolve("sites")
if (!sitesDir.isDirectory) { val sites = SiteList(context)
sitesDir.delete() val containers: HashMap<String, SiteContainer> = HashMap()
sitesDir.mkdir() sites.getSites().values.forEach { site ->
} // Don't create a new SiteUpdater or we will lose subscribers
var updater = this.containers[site.id]?.updater
sites = HashMap() if (updater != null) {
sitesDir.listFiles().forEach { siteDir -> updater.setSite(site)
try { } else {
val site = Site(siteDir) updater = SiteUpdater(site, engine)
// 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)
} }
if (site.id == activeSite) {
updater.setState(true, "Connected")
}
containers[site.id] = SiteContainer(site, updater)
} }
this.containers = containers
} }
fun getSites(): Map<String, Site> { fun getSites(): Map<String, Site> {
return sites.mapValues { it.value.site } return containers.mapValues { it.value.site }
} }
fun deleteSite(id: String) { fun deleteSite(id: String) {
sites.remove(id)
val siteDir = MainActivity.getContext()!!.filesDir.resolve("sites").resolve(id) val siteDir = MainActivity.getContext()!!.filesDir.resolve("sites").resolve(id)
siteDir.deleteRecursively() siteDir.deleteRecursively()
refreshSites()
//TODO: make sure you stop the vpn //TODO: make sure you stop the vpn
//TODO: make sure you relink the active site if this is the active site //TODO: make sure you relink the active site if this is the active site
} }
fun getSite(id: String): SiteContainer? { fun getSite(id: String): SiteContainer? {
return sites[id] return containers[id]
}
}
class SiteList(context: Context) {
private var sites: Map<String, Site>
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<String, Site> {
return sites
}
companion object {
fun getSites(context: Context, directory: File): HashMap<String, Site> {
val sites = HashMap<String, Site>()
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 { class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler {
private val gson = Gson()
// eventSink is how we send info back up to flutter // 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 eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}")
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
fun setSite(site: Site) {
this.site = site
}
fun setState(connected: Boolean, status: String, err: String? = null) { fun setState(connected: Boolean, status: String, err: String? = null) {
site.connected = connected site.connected = connected
site.status = status site.status = status
val d = mapOf("connected" to site.connected, "status" to site.status)
if (err != null) { if (err != null) {
eventSink?.error("", err, d) eventSink?.error("", err, gson.toJson(site))
} else { } else {
eventSink?.success(d) eventSink?.success(gson.toJson(site))
} }
} }
@ -130,7 +177,24 @@ data class CertificateValidity(
@SerializedName("Reason") val reason: String @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 name: String
val id: String val id: String
val staticHostmap: HashMap<String, StaticHosts> val staticHostmap: HashMap<String, StaticHosts>
@ -142,21 +206,25 @@ class Site {
val mtu: Int val mtu: Int
val cipher: String val cipher: String
val sortKey: Int val sortKey: Int
var logVerbosity: String val logVerbosity: String
var connected: Boolean? var connected: Boolean?
var status: String? var status: String?
val logFile: String? val logFile: String?
var errors: ArrayList<String> = ArrayList() var errors: ArrayList<String> = ArrayList()
val managed: Boolean
// The following fields are present when managed = true
val rawConfig: String?
val lastManagedUpdate: String?
// Path to this site on disk // Path to this site on disk
@Expose(serialize = false) @Transient
val path: String val path: String
// Strong representation of the site config // Strong representation of the site config
@Expose(serialize = false) @Transient
val config: String val config: String
constructor(siteDir: File) { init {
val gson = Gson() val gson = Gson()
config = siteDir.resolve("config.json").readText() config = siteDir.resolve("config.json").readText()
val incomingSite = gson.fromJson(config, IncomingSite::class.java) val incomingSite = gson.fromJson(config, IncomingSite::class.java)
@ -173,6 +241,9 @@ class Site {
sortKey = incomingSite.sortKey ?: 0 sortKey = incomingSite.sortKey ?: 0
logFile = siteDir.resolve("log").absolutePath logFile = siteDir.resolve("log").absolutePath
logVerbosity = incomingSite.logVerbosity ?: "info" logVerbosity = incomingSite.logVerbosity ?: "info"
rawConfig = incomingSite.rawConfig
managed = incomingSite.managed ?: false
lastManagedUpdate = incomingSite.lastManagedUpdate
connected = false connected = false
status = "Disconnected" status = "Disconnected"
@ -211,6 +282,10 @@ class Site {
errors.add("Error while loading certificate authorities: ${err.message}") 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()) { if (errors.isEmpty()) {
try { try {
mobileNebula.MobileNebula.testConfig(config, getKey(MainActivity.getContext()!!)) 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 f = EncFile(context).openRead(File(path).resolve("key"))
val k = f.readText() val k = f.readText()
f.close() f.close()
return k 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( data class StaticHosts(
@ -251,13 +345,18 @@ class IncomingSite(
val mtu: Int?, val mtu: Int?,
val cipher: String, val cipher: String,
val sortKey: Int?, val sortKey: Int?,
var logVerbosity: String?, val logVerbosity: String?,
@Expose(serialize = false) var key: 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): File {
fun save(context: Context) { // Don't allow backups of DN-managed sites
val siteDir = context.filesDir.resolve("sites").resolve(id) val baseDir = if(managed == true) context.noBackupFilesDir else context.filesDir
val siteDir = baseDir.resolve("sites").resolve(id)
if (!siteDir.exists()) { if (!siteDir.exists()) {
siteDir.mkdir() siteDir.mkdir()
} }
@ -269,10 +368,14 @@ class IncomingSite(
encFile.use { it.write(key) } encFile.use { it.write(key) }
encFile.close() encFile.close()
} }
key = null key = null
val gson = Gson()
dnCredentials?.save(context, siteDir)
dnCredentials = null
val confFile = siteDir.resolve("config.json") val confFile = siteDir.resolve("config.json")
confFile.writeText(gson.toJson(this)) confFile.writeText(Gson().toJson(this))
return siteDir
} }
} }

View File

@ -1,5 +1,9 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10' ext {
workVersion = "2.7.1"
kotlinVersion = '1.6.10'
}
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
@ -7,7 +11,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.1.2' 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"
} }
} }

4
images/dn-logo-dark.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="53" height="62" viewBox="0 0 53 62" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.1128 61.2016H25.8226C30.4449 55.8553 42.14 32.9921 36.5151 23.1053C32.4774 15.9477 19.5464 12.8338 0 14.1999V0.323899C25.6196 -1.42992 41.6675 3.94663 48.6585 16.2567C57.4851 31.9077 47.3469 52.4022 42.1128 61.2016Z" fill="white"/>
<path d="M0 61.2106H13.9245V21.6453L0 14.0424V61.2106Z" fill="#6E7D91"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

4
images/dn-logo-light.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="53" height="62" viewBox="0 0 53 62" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.1128 61.2016H25.8226C30.4449 55.8553 42.14 32.9921 36.5151 23.1053C32.4774 15.9477 19.5464 12.8338 0 14.1999V0.323899C25.6196 -1.42992 41.6675 3.94663 48.6585 16.2567C57.4851 31.9077 47.3469 52.4022 42.1128 61.2016Z" fill="#0B0D0F"/>
<path d="M0 61.2106H13.9245V21.6453L0 14.0424V61.2106Z" fill="#6E7D91"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

View File

@ -3,17 +3,21 @@ import Foundation
let groupName = "group.net.defined.mobileNebula" let groupName = "group.net.defined.mobileNebula"
class KeyChain { class KeyChain {
class func save(key: String, data: Data) -> Bool { class func save(key: String, data: Data, managed: Bool) -> Bool {
let query: [String: Any] = [ var query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword as String, kSecClass as String : kSecClassGenericPassword as String,
kSecAttrAccount as String : key, kSecAttrAccount as String : key,
kSecValueData as String : data, kSecValueData as String : data,
kSecAttrAccessGroup as String: groupName, kSecAttrAccessGroup as String: groupName,
] ]
SecItemDelete(query as CFDictionary) if (managed) {
let val = SecItemAdd(query as CFDictionary, nil) query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
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? { class func load(key: String) -> Data? {
@ -38,10 +42,8 @@ class KeyChain {
class func delete(key: String) -> Bool { class func delete(key: String) -> Bool {
let query: [String: Any] = [ let query: [String: Any] = [
kSecClass as String : kSecClassGenericPassword, kSecClass as String : kSecClassGenericPassword as String,
kSecAttrAccount as String : key, kSecAttrAccount as String : key,
kSecReturnData as String : kCFBooleanTrue!,
kSecMatchLimit as String : kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName, kSecAttrAccessGroup as String: groupName,
] ]

View File

@ -7,18 +7,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
private var networkMonitor: NWPathMonitor? private var networkMonitor: NWPathMonitor?
private var site: Site? 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 nebula: MobileNebulaNebula?
private var dnUpdater = DNUpdater()
private var didSleep = false private var didSleep = false
private var cachedRouteDescription: String? 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 // 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 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) { 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 // 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 // `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 var key: String
do { do {
config = proto.providerConfiguration?["config"] as! Data
site = try Site(proto: proto) site = try Site(proto: proto)
config = try site!.getConfig()
} catch { } catch {
//TODO: need a way to notify the app //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) return completionHandler(error)
} }
let _site = site! let _site = site!
_log = OSLog(subsystem: "net.defined.mobileNebula:\(_site.name)", category: "PacketTunnelProvider")
do { do {
key = try _site.getKey() key = try _site.getKey()
@ -96,14 +92,27 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
self.startNetworkMonitor() self.startNetworkMonitor()
if err != nil { if err != nil {
self.log.error("We had an error starting up: \(err, privacy: .public)")
return completionHandler(err!) return completionHandler(err!)
} }
self.nebula!.start() self.nebula!.start()
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate)
completionHandler(nil) 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 //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) { // override func sleep(completionHandler: @escaping () -> Void) {
// nebula!.sleep() // nebula!.sleep()
@ -156,7 +165,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) { override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) {
guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else { 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 return
} }
@ -196,7 +205,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
if nebula == nil { if nebula == nil {
// Respond with an empty success message in the event a command comes in before we've truly started // 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))) return completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))
} }

View File

@ -132,13 +132,21 @@ class Site: Codable {
var connected: Bool? //TODO: active is a better name var connected: Bool? //TODO: active is a better name
var status: String? var status: String?
var logFile: 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 // A list of error encountered when trying to rehydrate a site from config
var errors: [String] var errors: [String]
var manager: NETunnelProviderManager? 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 { convenience init(manager: NETunnelProviderManager) throws {
//TODO: Throw an error and have Sites delete the site, notify the user instead of using ! //TODO: Throw an error and have Sites delete the site, notify the user instead of using !
let proto = manager.protocolConfiguration as! NETunnelProviderProtocol let proto = manager.protocolConfiguration as! NETunnelProviderProtocol
@ -150,7 +158,27 @@ class Site: Codable {
convenience init(proto: NETunnelProviderProtocol) throws { convenience init(proto: NETunnelProviderProtocol) throws {
let dict = proto.providerConfiguration 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 decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config) let incoming = try decoder.decode(IncomingSite.self, from: config)
self.init(incoming: incoming) self.init(incoming: incoming)
@ -159,6 +187,7 @@ class Site: Codable {
init(incoming: IncomingSite) { init(incoming: IncomingSite) {
var err: NSError? var err: NSError?
incomingSite = incoming
errors = [] errors = []
name = incoming.name name = incoming.name
id = incoming.id id = incoming.id
@ -211,13 +240,25 @@ class Site: Codable {
errors.append("Error while loading certificate authorities: \(error.localizedDescription)") 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 lhDuration = incoming.lhDuration
port = incoming.port port = incoming.port
cipher = incoming.cipher cipher = incoming.cipher
sortKey = incoming.sortKey ?? 0 sortKey = incoming.sortKey ?? 0
logVerbosity = incoming.logVerbosity ?? "info" logVerbosity = incoming.logVerbosity ?? "info"
mtu = incoming.mtu ?? 1300 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) { if (errors.isEmpty) {
do { do {
@ -226,6 +267,7 @@ class Site: Codable {
let key = try getKey() let key = try getKey()
let strConfig = String(data: rawConfig, encoding: .utf8) let strConfig = String(data: rawConfig, encoding: .utf8)
var err: NSError? var err: NSError?
MobileNebulaTestConfig(strConfig, key, &err) MobileNebulaTestConfig(strConfig, key, &err)
if (err != nil) { if (err != nil) {
throw err! throw err!
@ -239,13 +281,49 @@ class Site: Codable {
// Gets the private key from the keystore, we don't always need it in memory // Gets the private key from the keystore, we don't always need it in memory
func getKey() throws -> String { func getKey() throws -> String {
guard let keyData = KeyChain.load(key: "\(id).key") else { 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! //TODO: make sure this is valid on return!
return String(decoding: keyData, as: UTF8.self) 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 // Limits what we export to the UI
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
@ -264,6 +342,8 @@ class Site: Codable {
case logVerbosity case logVerbosity
case errors case errors
case mtu case mtu
case managed
case lastManagedUpdate
} }
} }
@ -278,6 +358,34 @@ class UnsafeRoute: Codable {
var mtu: Int? 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 // This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site
struct IncomingSite: Codable { struct IncomingSite: Codable {
var name: String var name: String
@ -293,24 +401,69 @@ struct IncomingSite: Codable {
var sortKey: Int? var sortKey: Int?
var logVerbosity: String? var logVerbosity: String?
var key: String? var key: String?
var managed: Bool?
// The following fields are present if managed = true
var dnCredentials: DNCredentials?
var lastManagedUpdate: String?
func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) { func getConfig() throws -> Data {
#if targetEnvironment(simulator)
let fileManager = FileManager.default
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id)
let encoder = JSONEncoder() 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 { do {
var config = self configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true)
config.key = nil
let rawConfig = try encoder.encode(config) } catch {
try rawConfig.write(to: sitePath) 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 { } catch {
return callback(error) return callback(error)
} }
#if targetEnvironment(simulator)
// We are on a simulator and there is no NEVPNManager for us to interact with
callback(nil) callback(nil)
#else #else
if saveToManager {
self.saveToManager(manager: manager, callback: callback)
} else {
callback(nil)
}
#endif
}
private func saveToManager(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
if (manager != nil) { if (manager != nil) {
// We need to refresh our settings to properly update config // We need to refresh our settings to properly update config
manager?.loadFromPreferences { error in manager?.loadFromPreferences { error in
@ -318,43 +471,19 @@ struct IncomingSite: Codable {
return callback(error) return callback(error)
} }
return self.finish(manager: manager!, callback: callback) return self.finishSaveToManager(manager: manager!, callback: callback)
} }
return return
} }
return finish(manager: NETunnelProviderManager(), callback: callback) return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
#endif
} }
private func finish(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) { private func finishSaveToManager(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
// Stuff our details in the protocol // Stuff our details in the protocol
let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol() 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 proto.providerConfiguration = ["id": self.id]
do {
rawConfig = try encoder.encode(config)
} catch {
return callback(error)
}
proto.providerConfiguration = ["config": rawConfig]
proto.serverAddress = "Nebula" proto.serverAddress = "Nebula"
// Finish up the manager, this is what stores everything at the system level // Finish up the manager, this is what stores everything at the system level
@ -362,7 +491,7 @@ struct IncomingSite: Codable {
//TODO: cert name? manager.protocolConfiguration?.username //TODO: cert name? manager.protocolConfiguration?.username
//TODO: This is what is shown on the vpn page. We should add more identifying details in //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.isEnabled = true
manager.saveToPreferences{ error in manager.saveToPreferences{ error in

View File

@ -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
}
}

View File

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 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 */; }; 43498725289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; };
43498726289B484C00476B19 /* 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 */; }; 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, ); }; }; 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 */; }; 43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
43AD63F424EB3802000FB47E /* Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD63F324EB3802000FB47E /* Share.swift */; }; 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 */; }; 4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 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 */; }; E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */; };
/* End PBXBuildFile section */ /* 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; }; 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 = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
432D0E3D291C562200752563 /* SiteList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteList.swift; sourceTree = "<group>"; };
43498724289B484C00476B19 /* MobileNebula.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = MobileNebula.xcframework; sourceTree = SOURCE_ROOT; }; 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 = "<group>"; }; 436DE7A226EFF18500BB2950 /* CtlInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CtlInfo.h; sourceTree = "<group>"; };
437F72582469AAC500A0C4B9 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; }; 437F72582469AAC500A0C4B9 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
@ -83,6 +92,7 @@
43AD63F324EB3802000FB47E /* Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Share.swift; sourceTree = "<group>"; }; 43AD63F324EB3802000FB47E /* Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Share.swift; sourceTree = "<group>"; };
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 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; }; 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 = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NebulaNetworkExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 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 = "<group>"; };
@ -98,6 +108,8 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BE45F625291AEAB300902884 /* PackageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageInfo.swift; sourceTree = "<group>"; };
BE5BC105291C41E600B6FE5B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -147,6 +159,7 @@
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */, 43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */,
437F72582469AAC500A0C4B9 /* Site.swift */, 437F72582469AAC500A0C4B9 /* Site.swift */,
436DE7A226EFF18500BB2950 /* CtlInfo.h */, 436DE7A226EFF18500BB2950 /* CtlInfo.h */,
432D0E3D291C562200752563 /* SiteList.swift */,
); );
path = NebulaNetworkExtension; path = NebulaNetworkExtension;
sourceTree = "<group>"; sourceTree = "<group>";
@ -198,6 +211,9 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
43871C9C2444E2EC004F9075 /* Sites.swift */, 43871C9C2444E2EC004F9075 /* Sites.swift */,
43AD63F324EB3802000FB47E /* Share.swift */, 43AD63F324EB3802000FB47E /* Share.swift */,
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */,
BE45F625291AEAB300902884 /* PackageInfo.swift */,
BE5BC105291C41E600B6FE5B /* APIClient.swift */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@ -445,8 +461,12 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
432D0E3F291C562200752563 /* SiteList.swift in Sources */,
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */, 43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */,
437F72592469AAC500A0C4B9 /* Site.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 */, 437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -457,10 +477,14 @@
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
43AD63F424EB3802000FB47E /* Share.swift in Sources */, 43AD63F424EB3802000FB47E /* Share.swift in Sources */,
432D0E3E291C562200752563 /* SiteList.swift in Sources */,
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */, 43871C9D2444E2EC004F9075 /* Sites.swift in Sources */,
BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */,
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */, 437F725F2469B4B000A0C4B9 /* Site.swift in Sources */,
BE45F626291AEAB300902884 /* PackageInfo.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */, 437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */,
43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -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
}
}
}

View File

@ -14,7 +14,11 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
@UIApplicationMain @UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
private let dnUpdater = DNUpdater()
private let apiClient = APIClient()
private var sites: Sites? private var sites: Sites?
private var ui: FlutterMethodChannel?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
@ -22,20 +26,36 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) 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 { guard let controller = window?.rootViewController as? FlutterViewController else {
fatalError("rootViewController is not type FlutterViewController") fatalError("rootViewController is not type FlutterViewController")
} }
sites = Sites(messenger: controller.binaryMessenger) 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 { switch call.method {
case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result) case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result)
case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result) case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result)
case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result) case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result)
case "nebula.verifyCertAndKey": return self.nebulaVerifyCertAndKey(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 "listSites": return self.listSites(result: result)
case "deleteSite": return self.deleteSite(call: call, result: result) case "deleteSite": return self.deleteSite(call: call, result: result)
case "saveSite": return self.saveSite(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) 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) { func listSites(result: @escaping FlutterResult) {
self.sites?.loadSites { (sites, err) -> () in self.sites?.loadSites { (sites, err) -> () in
if (err != nil) { if (err != nil) {

View File

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_0" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--Flutter View Controller--> <!--Flutter View Controller-->
@ -14,13 +16,14 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides> </layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/> <rect key="frame" x="0.0" y="0.0" width="390" height="844"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="-16" y="-40"/>
</scene> </scene>
</scenes> </scenes>
</document> </document>

132
ios/Runner/DNUpdate.swift Normal file
View File

@ -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()
}
}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
@ -18,8 +20,23 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>mailto</string>
<key>CFBundleURLSchemes</key>
<array>
<string>mailto</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
@ -47,7 +64,5 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -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)"
}
}

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:api.defined.net</string>
</array>
<key>com.apple.developer.networking.networkextension</key> <key>com.apple.developer.networking.networkextension</key>
<array> <array>
<string>packet-tunnel-provider</string> <string>packet-tunnel-provider</string>

View File

@ -12,7 +12,7 @@ class SiteContainer {
} }
class Sites { class Sites {
private var sites = [String: SiteContainer]() private var containers = [String: SiteContainer]()
private var messenger: FlutterBinaryMessenger? private var messenger: FlutterBinaryMessenger?
init(messenger: FlutterBinaryMessenger?) { init(messenger: FlutterBinaryMessenger?) {
@ -20,77 +20,39 @@ class Sites {
} }
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) { func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
#if targetEnvironment(simulator) _ = SiteList { (sites, err) in
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
if (err != nil) { if (err != nil) {
return completion(nil, err) return completion(nil, err)
} }
newManagers?.forEach { manager in sites?.values.forEach{ site in
do { let updater = SiteUpdater(messenger: self.messenger!, site: site)
let site = try Site(manager: manager) self.containers[site.id] = SiteContainer(site: site, updater: updater)
// 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()
}
} }
let justSites = self.sites.mapValues { let justSites = self.containers.mapValues {
return $0.site return $0.site
} }
completion(justSites, nil) completion(justSites, nil)
} }
#endif
} }
func deleteSite(id: String, callback: @escaping (Error?) -> ()) { func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
if let site = self.sites.removeValue(forKey: id) { if let site = self.containers.removeValue(forKey: id) {
#if targetEnvironment(simulator) _ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
let fileManager = FileManager.default _ = KeyChain.delete(key: "\(site.site.id).key")
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(site.site.id)
try? fileManager.removeItem(at: sitePath) do {
#else let fileManager = FileManager.default
_ = KeyChain.delete(key: site.site.id) 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) site.site.manager!.removeFromPreferences(completionHandler: callback)
return
#endif #endif
} }
@ -99,15 +61,15 @@ class Sites {
} }
func getSite(id: String) -> Site? { func getSite(id: String) -> Site? {
return self.sites[id]?.site return self.containers[id]?.site
} }
func getUpdater(id: String) -> SiteUpdater? { func getUpdater(id: String) -> SiteUpdater? {
return self.sites[id]?.updater return self.containers[id]?.updater
} }
func getContainer(id: String) -> SiteContainer? { 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 site: Site
private var notification: Any? private var notification: Any?
public var startFunc: (() -> Void)? public var startFunc: (() -> Void)?
private var configFd: Int32? = nil
private var configObserver: DispatchSourceFileSystemObject? = nil
init(messenger: FlutterBinaryMessenger, site: Site) { 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) eventChannel = FlutterEventChannel(name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
self.site = site self.site = site
super.init() super.init()
eventChannel.setStreamHandler(self) 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 /// onListen is called when flutter code attaches an event listener
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events; eventSink = events;
#if !targetEnvironment(simulator)
self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in
let connected = self.site.connected let connected = self.site.connected
self.site.status = statusString[self.site.manager!.connection.status] self.site.status = statusString[self.site.manager!.connection.status]
@ -140,13 +128,9 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
self.startFunc = nil self.startFunc = nil
} }
let d: Dictionary<String, Any> = [ self.update(connected: self.site.connected!)
"connected": self.site.connected!,
"status": self.site.status!,
]
self.eventSink?(d)
} }
#endif
return nil 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 /// update is a way to send information to the flutter listener and generally should not be used directly
func update(connected: Bool) { func update(connected: Bool, replaceSite: Site? = nil) {
let d: Dictionary<String, Any> = [ if (replaceSite != nil) {
"connected": connected, site = replaceSite!
"status": connected ? "Connected" : "Disconnected", }
] site.connected = connected
self.eventSink?(d) 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)
} }
} }

View File

@ -91,7 +91,7 @@ class _CIDRFormField extends FormFieldState<CIDR> {
} }
if (widget.bitsController == null) { if (widget.bitsController == null) {
_bitsController = TextEditingController(text: widget.initialValue?.bits?.toString() ?? ""); _bitsController = TextEditingController(text: widget.initialValue?.bits.toString() ?? "");
} else { } else {
widget.bitsController!.addListener(_handleControllerChanged); widget.bitsController!.addListener(_handleControllerChanged);
} }

View File

@ -58,7 +58,7 @@ class _FormPageState extends State<FormPage> {
leadingAction: _buildLeader(context), leadingAction: _buildLeader(context),
trailingActions: _buildTrailer(context), trailingActions: _buildTrailer(context),
scrollController: widget.scrollController, scrollController: widget.scrollController,
title: widget.title, title: Text(widget.title),
child: Form( child: Form(
key: _formKey, key: _formKey,
onChanged: () => setState(() { onChanged: () => setState(() {

View File

@ -87,7 +87,7 @@ class _IPAndPortFieldState extends State<IPAndPortField> {
nextFocusNode: widget.nextFocusNode, nextFocusNode: widget.nextFocusNode,
controller: widget.portController, controller: widget.portController,
onChanged: (val) { onChanged: (val) {
_ipAndPort.port = int.tryParse(val ?? ""); _ipAndPort.port = int.tryParse(val);
widget.onChanged(_ipAndPort); widget.onChanged(_ipAndPort);
}, },
maxLength: 5, maxLength: 5,

View File

@ -24,13 +24,15 @@ class SimplePage extends StatelessWidget {
this.bottomBar, this.bottomBar,
this.onRefresh, this.onRefresh,
this.onLoading, this.onLoading,
this.alignment,
this.refreshController}) this.refreshController})
: super(key: key); : super(key: key);
final String title; final Widget title;
final Widget child; final Widget child;
final SimpleScrollable scrollable; final SimpleScrollable scrollable;
final ScrollController? scrollController; 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 /// 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 /// 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); realChild = Scrollbar(child: realChild);
} }
if (alignment != null) {
realChild = Align(alignment: this.alignment!, child: realChild);
}
if (bottomBar != null) { if (bottomBar != null) {
realChild = Column(children: [ realChild = Column(children: [
Expanded(child: realChild), Expanded(child: realChild),
@ -95,7 +101,7 @@ class SimplePage extends StatelessWidget {
return PlatformScaffold( return PlatformScaffold(
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context), backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context),
appBar: PlatformAppBar( appBar: PlatformAppBar(
title: Text(title), title: title,
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context), leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
trailingActions: trailingActions, trailingActions: trailingActions,
cupertino: (_, __) => CupertinoNavigationBarData( cupertino: (_, __) => CupertinoNavigationBarData(

View File

@ -1,4 +1,6 @@
import 'package:flutter/cupertino.dart'; 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/components/SpecialButton.dart';
import 'package:mobile_nebula/models/Site.dart'; import 'package:mobile_nebula/models/Site.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
@ -26,10 +28,7 @@ class SiteItem extends StatelessWidget {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final border = BorderSide(color: Utils.configSectionBorder(context)); final border = BorderSide(color: Utils.configSectionBorder(context));
var ip = "Error"; final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
if (site.certInfo != null && site.certInfo!.cert.details.ips.length > 0) {
ip = site.certInfo!.cert.details.ips[0];
}
return SpecialButton( return SpecialButton(
decoration: decoration:
@ -40,8 +39,10 @@ class SiteItem extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)), site.managed ?
Expanded(child: Text(ip, textAlign: TextAlign.end)), 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)), Padding(padding: EdgeInsets.only(right: 10)),
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
], ],

View File

@ -12,6 +12,7 @@ class ConfigPageItem extends StatelessWidget {
this.content, this.content,
this.labelWidth = 100, this.labelWidth = 100,
this.onPressed, this.onPressed,
this.disabled = false,
this.crossAxisAlignment = CrossAxisAlignment.center}) this.crossAxisAlignment = CrossAxisAlignment.center})
: super(key: key); : super(key: key);
@ -20,6 +21,7 @@ class ConfigPageItem extends StatelessWidget {
final double labelWidth; final double labelWidth;
final CrossAxisAlignment crossAxisAlignment; final CrossAxisAlignment crossAxisAlignment;
final onPressed; final onPressed;
final bool disabled;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -40,7 +42,7 @@ class ConfigPageItem extends StatelessWidget {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
return SpecialButton( return SpecialButton(
onPressed: onPressed, onPressed: this.disabled ? null : onPressed,
color: Utils.configItemBackground(context), color: Utils.configItemBackground(context),
child: Container( child: Container(
padding: EdgeInsets.only(left: 15), padding: EdgeInsets.only(left: 15),
@ -50,7 +52,7 @@ class ConfigPageItem extends StatelessWidget {
children: <Widget>[ children: <Widget>[
label != null ? Container(width: labelWidth, child: label) : Container(), label != null ? Container(width: labelWidth, child: label) : Container(),
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))), 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)
], ],
)), )),
); );

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations; import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
import 'package:flutter/material.dart' import 'package:flutter/material.dart'
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode; show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode;
@ -6,11 +8,16 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:mobile_nebula/screens/MainScreen.dart'; import 'package:mobile_nebula/screens/MainScreen.dart';
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
import 'package:mobile_nebula/services/settings.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 //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 { class Main extends StatelessWidget {
// This widget is the root of your application. // This widget is the root of your application.
@ -26,6 +33,7 @@ class App extends StatefulWidget {
class _AppState extends State<App> { class _AppState extends State<App> {
final settings = Settings(); final settings = Settings();
Brightness brightness = SchedulerBinding.instance.window.platformBrightness; Brightness brightness = SchedulerBinding.instance.window.platformBrightness;
StreamController dnEnrolled = StreamController.broadcast();
@override @override
void initState() { void initState() {
@ -41,6 +49,12 @@ class _AppState extends State<App> {
super.initState(); super.initState();
} }
@override
void dispose() {
dnEnrolled.close();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData lightTheme = ThemeData( final ThemeData lightTheme = ThemeData(
@ -93,7 +107,28 @@ class _AppState extends State<App> {
cupertino: (_, __) => CupertinoAppData( cupertino: (_, __) => CupertinoAppData(
theme: CupertinoThemeData(brightness: brightness), 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;
},
), ),
), ),
); );

View File

@ -44,6 +44,11 @@ class Site {
late String logFile; late String logFile;
late String logVerbosity; 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 // A list of errors encountered while loading the site
late List<String> errors; late List<String> errors;
@ -64,6 +69,9 @@ class Site {
String logVerbosity = 'info', String logVerbosity = 'info',
List<String>? errors, List<String>? errors,
List<UnsafeRoute>? unsafeRoutes, List<UnsafeRoute>? unsafeRoutes,
bool managed = false,
String? rawConfig,
DateTime? lastManagedUpdate,
}) { }) {
this.name = name; this.name = name;
this.id = id ?? uuid.v4(); this.id = id ?? uuid.v4();
@ -81,26 +89,75 @@ class Site {
this.logVerbosity = logVerbosity; this.logVerbosity = logVerbosity;
this.errors = errors ?? []; this.errors = errors ?? [];
this.unsafeRoutes = unsafeRoutes ?? []; this.unsafeRoutes = unsafeRoutes ?? [];
this.managed = managed;
this.rawConfig = rawConfig;
this.lastManagedUpdate = lastManagedUpdate;
_updates = EventChannel('net.defined.nebula/$id'); _updates = EventChannel('net.defined.nebula/$id');
_updates.receiveBroadcastStream().listen((d) { _updates.receiveBroadcastStream().listen((d) {
try { try {
this.status = d['status']; _updateFromJson(d);
this.connected = d['connected'];
_change.add(null); _change.add(null);
} catch (err) { } catch (err) {
//TODO: handle the error //TODO: handle the error
print(err); print(err);
} }
}, onError: (err) { }, onError: (err) {
_updateFromJson(err.details);
var error = err as PlatformException; var error = err as PlatformException;
this.status = error.details['status'];
this.connected = error.details['connected'];
_change.addError(error.message ?? 'An unexpected error occurred'); _change.addError(error.message ?? 'An unexpected error occurred');
}); });
} }
factory Site.fromJson(Map<String, dynamic> json) { factory Site.fromJson(Map<String, dynamic> 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<String, dynamic> json) {
Map<String, dynamic> rawHostmap = json['staticHostmap']; Map<String, dynamic> rawHostmap = json['staticHostmap'];
Map<String, StaticHost> staticHostmap = {}; Map<String, StaticHost> staticHostmap = {};
rawHostmap.forEach((key, val) { rawHostmap.forEach((key, val) {
@ -130,24 +187,28 @@ class Site {
errors.add(error); errors.add(error);
}); });
return Site( return {
name: json['name'], "name": json["name"],
id: json['id'], "id": json['id'],
staticHostmap: staticHostmap, "staticHostmap": staticHostmap,
ca: ca, "ca": ca,
certInfo: certInfo, "certInfo": certInfo,
lhDuration: json['lhDuration'], "lhDuration": json['lhDuration'],
port: json['port'], "port": json['port'],
cipher: json['cipher'], "cipher": json['cipher'],
sortKey: json['sortKey'], "sortKey": json['sortKey'],
mtu: json['mtu'], "mtu": json['mtu'],
connected: json['connected'] ?? false, "connected": json['connected'] ?? false,
status: json['status'] ?? "", "status": json['status'] ?? "",
logFile: json['logFile'], "logFile": json['logFile'],
logVerbosity: json['logVerbosity'], "logVerbosity": json['logVerbosity'],
errors: errors, "errors": errors,
unsafeRoutes: unsafeRoutes, "unsafeRoutes": unsafeRoutes,
); "managed": json['managed'] ?? false,
"rawConfig": json['rawConfig'],
"lastManagedUpdate": json["lastManagedUpdate"] == null ?
null : DateTime.parse(json["lastManagedUpdate"]),
};
} }
Stream onChange() { Stream onChange() {
@ -171,6 +232,8 @@ class Site {
'cipher': cipher, 'cipher': cipher,
'sortKey': sortKey, 'sortKey': sortKey,
'logVerbosity': logVerbosity, 'logVerbosity': logVerbosity,
'managed': managed,
'rawConfig': rawConfig,
}; };
} }

View File

@ -43,7 +43,7 @@ class _AboutScreenState extends State<AboutScreen> {
} }
return SimplePage( return SimplePage(
title: 'About', title: Text('About'),
child: Column(children: [ child: Column(children: [
ConfigSection(children: <Widget>[ ConfigSection(children: <Widget>[
ConfigItem( ConfigItem(

View File

@ -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<EnrollmentScreen> {
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();
});
}),
]);
}
}

View File

@ -50,7 +50,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final title = widget.pending ? 'Pending' : 'Active'; final title = widget.pending ? 'Pending' : 'Active';
return SimplePage( return SimplePage(
title: '$title Host Info', title: Text('$title Host Info'),
refreshController: refreshController, refreshController: refreshController,
onRefresh: () async { onRefresh: () async {
await _getHostInfo(); await _getHostInfo();

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.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/Site.dart';
import 'package:mobile_nebula/models/StaticHosts.dart'; import 'package:mobile_nebula/models/StaticHosts.dart';
import 'package:mobile_nebula/models/UnsafeRoute.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/SettingsScreen.dart';
import 'package:mobile_nebula/screens/SiteDetailScreen.dart'; import 'package:mobile_nebula/screens/SiteDetailScreen.dart';
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
import 'package:mobile_nebula/services/utils.dart'; import 'package:mobile_nebula/services/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:uuid/uuid.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 { class MainScreen extends StatefulWidget {
const MainScreen({Key? key}) : super(key: key); const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key);
final Stream dnEnrollStream;
@override @override
_MainScreenState createState() => _MainScreenState(); _MainScreenState createState() => _MainScreenState();
} }
class _MainScreenState extends State<MainScreen> { class _MainScreenState extends State<MainScreen> {
bool ready = false;
List<Site>? sites; List<Site>? sites;
// A set of widgets to display in a column that represents an error blocking us from moving forward entirely // A set of widgets to display in a column that represents an error blocking us from moving forward entirely
List<Widget>? error; List<Widget>? error;
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
RefreshController refreshController = RefreshController();
ScrollController scrollController = ScrollController();
@override @override
void initState() { void initState() {
_loadSites(); _loadSites();
widget.dnEnrollStream.listen((_) {
_loadSites();
});
platform.setMethodCallHandler(handleMethodCall);
super.initState(); super.initState();
} }
@override
void dispose() {
scrollController.dispose();
refreshController.dispose();
super.dispose();
}
Future<dynamic> handleMethodCall(MethodCall call) async {
switch (call.method) {
case "refreshSites":
_loadSites();
break;
default:
print("ERR: Unexpected method call ${call.method}");
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget? debugSite;
if (kDebugMode) {
debugSite = Row(
children: [
_debugSave(badDebugSave),
_debugSave(goodDebugSave),
_debugDNEnroll(),
],
mainAxisAlignment: MainAxisAlignment.center,
);
}
return SimplePage( return SimplePage(
title: 'Nebula', title: Text('Nebula'),
scrollable: SimpleScrollable.none, scrollable: SimpleScrollable.vertical,
scrollController: scrollController,
leadingAction: PlatformIconButton( leadingAction: PlatformIconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: Icon(Icons.add, size: 28.0), icon: Icon(Icons.add, size: 28.0),
@ -58,6 +136,12 @@ class _MainScreenState extends State<MainScreen> {
}); });
}), }),
), ),
refreshController: refreshController,
onRefresh: () {
print("onRefresh");
_loadSites();
refreshController.refreshCompleted();
},
trailingActions: <Widget>[ trailingActions: <Widget>[
PlatformIconButton( PlatformIconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -65,7 +149,7 @@ class _MainScreenState extends State<MainScreen> {
onPressed: () => Utils.openPage(context, (_) => SettingsScreen()), onPressed: () => Utils.openPage(context, (_) => SettingsScreen()),
), ),
], ],
bottomBar: kDebugMode ? _debugSave() : null, bottomBar: debugSite,
child: _buildBody(), child: _buildBody(),
); );
} }
@ -82,14 +166,6 @@ class _MainScreenState extends State<MainScreen> {
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10))); padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10)));
} }
if (!ready) {
return Center(
child: PlatformCircularProgressIndicator(cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 50);
}),
);
}
return _buildSites(); return _buildSites();
} }
@ -128,6 +204,8 @@ class _MainScreenState extends State<MainScreen> {
}); });
Widget child = ReorderableListView( Widget child = ReorderableListView(
shrinkWrap: true,
scrollController: scrollController,
padding: EdgeInsets.symmetric(vertical: 5), padding: EdgeInsets.symmetric(vertical: 5),
children: items, children: items,
onReorder: (oldI, newI) async { onReorder: (oldI, newI) async {
@ -141,7 +219,11 @@ class _MainScreenState extends State<MainScreen> {
sites!.insert(newI, moved); 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; sites![i].sortKey = i;
try { try {
await sites![i].save(); await sites![i].save();
@ -162,41 +244,25 @@ class _MainScreenState extends State<MainScreen> {
return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child); return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child);
} }
Widget _debugSave() { Widget _debugSave(Map<String, String> siteConfig) {
return CupertinoButton( return CupertinoButton(
key: Key('debug-save'), child: Text(siteConfig['name']!),
child: Text("DEBUG SAVE"),
onPressed: () async { onPressed: () async {
var uuid = Uuid(); 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( var s = Site(
name: "DEBUG TEST", name: siteConfig['name']!,
id: uuid.v4(), id: uuid.v4(),
staticHostmap: { staticHostmap: {
"10.1.0.1": StaticHost( "10.1.0.1": StaticHost(
lighthouse: true, lighthouse: true,
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)]) destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
}, },
ca: [CertificateInfo.debug(rawCert: ca)], ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])],
certInfo: CertificateInfo.debug(rawCert: cert), certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']),
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]); unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]);
s.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY----- s.key = siteConfig['key'];
rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
-----END NEBULA X25519 PRIVATE KEY-----''';
var err = await s.save(); var err = await s.save();
if (err != null) { 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 { _loadSites() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
try { try {
@ -268,6 +343,7 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
} }
}); });
sites!.add(site); sites!.add(site);
} catch (err) { } catch (err) {
//TODO: handle error //TODO: handle error
@ -282,17 +358,14 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
platform.invokeMethod("android.registerActiveSite"); 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) { sites!.sort((a, b) {
if (a.sortKey == b.sortKey) {
return a.name.compareTo(b.name);
}
return a.sortKey - b.sortKey; return a.sortKey - b.sortKey;
}); });
setState(() { setState(() {});
ready = true;
});
} }
} }

View File

@ -87,7 +87,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
])); ]));
return SimplePage( return SimplePage(
title: 'Settings', title: Text('Settings'),
child: Column(children: items), child: Column(children: items),
); );
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.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/SimplePage.dart';
import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
import 'package:mobile_nebula/components/config/ConfigItem.dart'; import 'package:mobile_nebula/components/config/ConfigItem.dart';
@ -37,28 +38,24 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
List<HostInfo>? activeHosts; List<HostInfo>? activeHosts;
List<HostInfo>? pendingHosts; List<HostInfo>? pendingHosts;
RefreshController refreshController = RefreshController(initialRefresh: false); RefreshController refreshController = RefreshController(initialRefresh: false);
late bool lastState;
@override @override
void initState() { void initState() {
site = widget.site; site = widget.site;
lastState = site.connected;
if (site.connected) { if (site.connected) {
_listHostmap(); _listHostmap();
} }
onChange = site.onChange().listen((_) { onChange = site.onChange().listen((_) {
if (lastState != site.connected) { // TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
//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 we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
if (site.status == 'Connected') { if (site.status == 'Connected') {
lastState = true; _listHostmap();
_listHostmap(); } else {
} else { activeHosts = null;
lastState = false; pendingHosts = null;
activeHosts = null;
pendingHosts = null;
}
} }
setState(() {}); setState(() {});
}, onError: (err) { }, onError: (err) {
setState(() {}); setState(() {});
@ -76,8 +73,16 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
@override @override
Widget build(BuildContext context) { 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( return SimplePage(
title: site.name, title: title,
leadingAction: Utils.leadingBackWidget(context, onPressed: () { leadingAction: Utils.leadingBackWidget(context, onPressed: () {
if (changed && widget.onChanged != null) { if (changed && widget.onChanged != null) {
widget.onChanged!(); widget.onChanged!();

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.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/SimplePage.dart';
import 'package:mobile_nebula/models/Site.dart'; import 'package:mobile_nebula/models/Site.dart';
import 'package:mobile_nebula/services/settings.dart'; import 'package:mobile_nebula/services/settings.dart';
@ -39,8 +40,16 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
@override @override
Widget build(BuildContext context) { 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( return SimplePage(
title: widget.site.name, title: title,
scrollable: SimpleScrollable.both, scrollable: SimpleScrollable.both,
scrollController: controller, scrollController: controller,
onRefresh: () async { onRefresh: () async {
@ -113,6 +122,8 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
setState(() { setState(() {
logs = v; logs = v;
}); });
} on FileSystemException {
Utils.popError(context, 'Error while reading logs', 'No log file was present');
} catch (err) { } catch (err) {
Utils.popError(context, 'Error while reading logs', err.toString()); Utils.popError(context, 'Error while reading logs', err.toString());
} }

View File

@ -38,6 +38,7 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
@override @override
void dispose() { void dispose() {
refreshController.dispose();
super.dispose(); super.dispose();
} }
@ -83,7 +84,7 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
final title = widget.pending ? 'Pending' : 'Active'; final title = widget.pending ? 'Pending' : 'Active';
return SimplePage( return SimplePage(
title: "$title Tunnels", title: Text('$title Tunnels'),
leadingAction: Utils.leadingBackWidget(context, onPressed: () { leadingAction: Utils.leadingBackWidget(context, onPressed: () {
Navigator.pop(context); Navigator.pop(context);
}), }),

View File

@ -77,7 +77,7 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
items.add(_buildKey()); items.add(_buildKey());
items.addAll(_buildLoadCert()); items.addAll(_buildLoadCert());
return SimplePage(title: 'Certificate', child: Column(children: items)); return SimplePage(title: Text('Certificate'), child: Column(children: items));
} }
List<Widget> _buildShare() { List<Widget> _buildShare() {

View File

@ -86,57 +86,64 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
label: Text("Lighthouse interval"), label: Text("Lighthouse interval"),
labelWidth: 200, labelWidth: 200,
//TODO: Auto select on focus? //TODO: Auto select on focus?
content: PlatformTextFormField( content: widget.site.managed ?
initialValue: settings.lhDuration.toString(), Text(settings.lhDuration.toString() + " seconds", textAlign: TextAlign.right) :
keyboardType: TextInputType.number, PlatformTextFormField(
suffix: Text("seconds"), initialValue: settings.lhDuration.toString(),
textAlign: TextAlign.right, keyboardType: TextInputType.number,
maxLength: 5, suffix: Text("seconds"),
inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.right,
onSaved: (val) { maxLength: 5,
setState(() { inputFormatters: [FilteringTextInputFormatter.digitsOnly],
if (val != null) { onSaved: (val) {
settings.lhDuration = int.parse(val!); setState(() {
} if (val != null) {
}); settings.lhDuration = int.parse(val);
}, }
)), });
},
)),
ConfigItem( ConfigItem(
label: Text("Listen port"), label: Text("Listen port"),
labelWidth: 150, labelWidth: 150,
//TODO: Auto select on focus? //TODO: Auto select on focus?
content: PlatformTextFormField( content: widget.site.managed ?
initialValue: settings.port.toString(), Text(settings.port.toString(), textAlign: TextAlign.right) :
keyboardType: TextInputType.number, PlatformTextFormField(
textAlign: TextAlign.right, initialValue: settings.port.toString(),
maxLength: 5, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.right,
onSaved: (val) { maxLength: 5,
setState(() { inputFormatters: [FilteringTextInputFormatter.digitsOnly],
if (val != null) { onSaved: (val) {
settings.port = int.parse(val!); setState(() {
} if (val != null) {
}); settings.port = int.parse(val);
}, }
)), });
},
)),
ConfigItem( ConfigItem(
label: Text("MTU"), label: Text("MTU"),
labelWidth: 150, labelWidth: 150,
content: PlatformTextFormField( content: widget.site.managed ?
initialValue: settings.mtu.toString(), Text(settings.mtu.toString(), textAlign: TextAlign.right) :
keyboardType: TextInputType.number, PlatformTextFormField(
textAlign: TextAlign.right, initialValue: settings.mtu.toString(),
maxLength: 5, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.right,
onSaved: (val) { maxLength: 5,
setState(() { inputFormatters: [FilteringTextInputFormatter.digitsOnly],
if (val != null) { onSaved: (val) {
settings.mtu = int.parse(val!); setState(() {
} if (val != null) {
}); settings.mtu = int.parse(val);
}, }
)), });
},
)),
ConfigPageItem( ConfigPageItem(
disabled: widget.site.managed,
label: Text('Cipher'), label: Text('Cipher'),
labelWidth: 150, labelWidth: 150,
content: Text(settings.cipher, textAlign: TextAlign.end), content: Text(settings.cipher, textAlign: TextAlign.end),
@ -153,6 +160,7 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
}); });
}), }),
ConfigPageItem( ConfigPageItem(
disabled: widget.site.managed,
label: Text('Log verbosity'), label: Text('Log verbosity'),
labelWidth: 150, labelWidth: 150,
content: Text(settings.verbosity, textAlign: TextAlign.end), content: Text(settings.verbosity, textAlign: TextAlign.end),
@ -176,7 +184,7 @@ class _AdvancedScreenState extends State<AdvancedScreen> {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return UnsafeRoutesScreen( return UnsafeRoutesScreen(
unsafeRoutes: settings.unsafeRoutes, unsafeRoutes: settings.unsafeRoutes,
onSave: (routes) { onSave: widget.site.managed ? null : (routes) {
setState(() { setState(() {
settings.unsafeRoutes = routes; settings.unsafeRoutes = routes;
changed = true; changed = true;

View File

@ -56,20 +56,23 @@ class _CAListScreenState extends State<CAListScreen> {
items.add(ConfigSection(children: caItems)); items.add(ConfigSection(children: caItems));
} }
items.addAll(_addCA()); if (widget.onSave != null) {
items.addAll(_addCA());
}
return FormPage( return FormPage(
title: 'Certificate Authorities', title: 'Certificate Authorities',
changed: changed, changed: changed,
onSave: () { onSave: () {
if (widget.onSave != null) { if (widget.onSave != null) {
Navigator.pop(context); Navigator.pop(context);
widget.onSave!(cas.values.map((ca) { widget.onSave!(cas.values.map((ca) {
return ca; return ca;
}).toList()); }).toList());
} }
}, },
child: Column(children: items)); child: Column(children: items));
} }
List<Widget> _buildCAs() { List<Widget> _buildCAs() {
List<Widget> items = []; List<Widget> items = [];
@ -80,7 +83,7 @@ class _CAListScreenState extends State<CAListScreen> {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return CertificateDetailsScreen( return CertificateDetailsScreen(
certInfo: ca, certInfo: ca,
onDelete: () { onDelete: widget.onSave == null ? null : () {
setState(() { setState(() {
changed = true; changed = true;
cas.remove(key); cas.remove(key);

View File

@ -16,7 +16,7 @@ class RenderedConfigScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SimplePage( return SimplePage(
title: 'Rendered Site Config', title: Text('Rendered Site Config'),
scrollable: SimpleScrollable.both, scrollable: SimpleScrollable.both,
trailingActions: <Widget>[ trailingActions: <Widget>[
PlatformIconButton( PlatformIconButton(

View File

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart' as fpw; 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/FormPage.dart';
import 'package:mobile_nebula/components/PlatformTextFormField.dart'; import 'package:mobile_nebula/components/PlatformTextFormField.dart';
import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
@ -93,6 +94,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
_keys(), _keys(),
_hosts(), _hosts(),
_advanced(), _advanced(),
_managed(),
kDebugMode ? _debugConfig() : Container(height: 0), kDebugMode ? _debugConfig() : Container(height: 0),
], ],
)); ));
@ -127,6 +129,26 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
]); ]);
} }
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: <Widget>[
ConfigItem(
label: Text("Last Update"),
content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: <Widget>[
Text(lastUpdate),
]),
)
]
) : Container();
}
Widget _keys() { Widget _keys() {
final certError = site.certInfo == null || site.certInfo!.validity == null || !site.certInfo!.validity!.valid; final certError = site.certInfo == null || site.certInfo!.validity == null || !site.certInfo!.validity!.valid;
var caError = site.ca.length == 0; var caError = site.ca.length == 0;
@ -158,7 +180,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
certInfo: site.certInfo!, certInfo: site.certInfo!,
pubKey: pubKey, pubKey: pubKey,
privKey: privKey, privKey: privKey,
onReplace: (result) { onReplace: site.managed ? null : (result) {
setState(() { setState(() {
changed = true; changed = true;
site.certInfo = result.certInfo; site.certInfo = result.certInfo;
@ -195,7 +217,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return CAListScreen( return CAListScreen(
cas: site.ca, cas: site.ca,
onSave: (ca) { onSave: site.managed ? null : (ca) {
setState(() { setState(() {
changed = true; changed = true;
site.ca = ca; site.ca = ca;
@ -209,7 +231,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
Widget _hosts() { Widget _hosts() {
return ConfigSection( return ConfigSection(
label: "Set up static hosts and lighthouses", label: "LIGHTHOUSES / STATIC HOSTS",
children: <Widget>[ children: <Widget>[
ConfigPageItem( ConfigPageItem(
label: Text('Hosts'), label: Text('Hosts'),
@ -227,7 +249,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
Utils.openPage(context, (context) { Utils.openPage(context, (context) {
return StaticHostsScreen( return StaticHostsScreen(
hostmap: site.staticHostmap, hostmap: site.staticHostmap,
onSave: (map) { onSave: site.managed ? null : (map) {
setState(() { setState(() {
changed = true; changed = true;
site.staticHostmap = map; site.staticHostmap = map;
@ -242,6 +264,7 @@ class _SiteConfigScreenState extends State<SiteConfigScreen> {
Widget _advanced() { Widget _advanced() {
return ConfigSection( return ConfigSection(
label: "ADVANCED",
children: <Widget>[ children: <Widget>[
ConfigPageItem( ConfigPageItem(
label: Text('Advanced'), label: Text('Advanced'),

View File

@ -32,7 +32,7 @@ class StaticHostmapScreen extends StatefulWidget {
final List<IPAndPort> destinations; final List<IPAndPort> destinations;
final String nebulaIp; final String nebulaIp;
final bool lighthouse; final bool lighthouse;
final ValueChanged<Hostmap> onSave; final ValueChanged<Hostmap>? onSave;
final Function? onDelete; final Function? onDelete;
@override @override
@ -66,7 +66,7 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FormPage( 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, changed: changed,
onSave: _onSave, onSave: _onSave,
child: Column(children: [ child: Column(children: [
@ -74,7 +74,9 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
ConfigItem( ConfigItem(
label: Text('Nebula IP'), label: Text('Nebula IP'),
labelWidth: 200, labelWidth: 200,
content: IPFormField( content: widget.onSave == null ?
Text(_nebulaIp, textAlign: TextAlign.end) :
IPFormField(
help: "Required", help: "Required",
initialValue: _nebulaIp, initialValue: _nebulaIp,
ipOnly: true, ipOnly: true,
@ -94,7 +96,7 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
child: Switch.adaptive( child: Switch.adaptive(
value: _lighthouse, value: _lighthouse,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (v) { onChanged: widget.onSave == null ? null : (v) {
setState(() { setState(() {
changed = true; changed = true;
_lighthouse = v; _lighthouse = v;
@ -125,13 +127,16 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
_onSave() { _onSave() {
Navigator.pop(context); 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) { _destinations.forEach((_, dest) {
map.destinations.add(dest.destination); map.destinations.add(dest.destination);
}); });
widget.onSave(map); widget.onSave!(map);
}
} }
List<Widget> _buildHosts() { List<Widget> _buildHosts() {
@ -142,7 +147,7 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
key: key, key: key,
label: Align( label: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: PlatformIconButton( child: widget.onSave == null ? Container() : PlatformIconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: Icon(Icons.remove_circle, color: CupertinoColors.systemRed.resolveFrom(context)), icon: Icon(Icons.remove_circle, color: CupertinoColors.systemRed.resolveFrom(context)),
onPressed: () => setState(() { onPressed: () => setState(() {
@ -152,28 +157,33 @@ class _StaticHostmapScreenState extends State<StaticHostmapScreen> {
labelWidth: 70, labelWidth: 70,
content: Row(children: <Widget>[ content: Row(children: <Widget>[
Expanded( Expanded(
child: IPAndPortFormField( child: widget.onSave == null ?
ipHelp: 'public ip or name', Text(dest.destination.toString(), textAlign: TextAlign.end) :
ipTextAlign: TextAlign.end, IPAndPortFormField(
enableIPV6: true, ipHelp: 'public ip or name',
noBorder: true, ipTextAlign: TextAlign.end,
initialValue: dest.destination, enableIPV6: true,
onSaved: (v) { noBorder: true,
if (v != null) { initialValue: dest.destination,
dest.destination = v; onSaved: (v) {
} if (v != null) {
}, dest.destination = v;
)), }
},
)),
]), ]),
)); ));
}); });
items.add(ConfigButtonItem( if (widget.onSave != null) {
content: Text('Add another'), items.add(ConfigButtonItem(
onPressed: () => setState(() { content: Text('Add another'),
_addDestination(); onPressed: () =>
_dismissKeyboard(); setState(() {
}))); _addDestination();
_dismissKeyboard();
})));
}
return items; return items;
} }

View File

@ -34,7 +34,7 @@ class StaticHostsScreen extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
final Map<String, StaticHost> hostmap; final Map<String, StaticHost> hostmap;
final ValueChanged<Map<String, StaticHost>> onSave; final ValueChanged<Map<String, StaticHost>>? onSave;
@override @override
_StaticHostsScreenState createState() => _StaticHostsScreenState(); _StaticHostsScreenState createState() => _StaticHostsScreenState();
@ -67,12 +67,15 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
_onSave() { _onSave() {
Navigator.pop(context); Navigator.pop(context);
Map<String, StaticHost> map = {}; if (widget.onSave != null) {
_hostmap.forEach((_, host) { Map<String, StaticHost> map = {};
map[host.nebulaIp] = StaticHost(destinations: host.destinations, lighthouse: host.lighthouse); _hostmap.forEach((_, host) {
}); map[host.nebulaIp] = StaticHost(
destinations: host.destinations, lighthouse: host.lighthouse);
});
widget.onSave(map); widget.onSave!(map);
}
} }
List<Widget> _buildHosts() { List<Widget> _buildHosts() {
@ -95,7 +98,7 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
nebulaIp: host.nebulaIp, nebulaIp: host.nebulaIp,
destinations: host.destinations, destinations: host.destinations,
lighthouse: host.lighthouse, lighthouse: host.lighthouse,
onSave: (map) { onSave: widget.onSave == null ? null :(map) {
setState(() { setState(() {
changed = true; changed = true;
host.nebulaIp = map.nebulaIp; host.nebulaIp = map.nebulaIp;
@ -103,7 +106,7 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
host.lighthouse = map.lighthouse; host.lighthouse = map.lighthouse;
}); });
}, },
onDelete: () { onDelete: widget.onSave == null ? null : () {
setState(() { setState(() {
changed = true; changed = true;
_hostmap.remove(key); _hostmap.remove(key);
@ -114,19 +117,21 @@ class _StaticHostsScreenState extends State<StaticHostsScreen> {
)); ));
}); });
items.add(ConfigButtonItem( if (widget.onSave != null) {
content: Text('Add a new entry'), items.add(ConfigButtonItem(
onPressed: () { content: Text('Add a new entry'),
Utils.openPage(context, (context) { onPressed: () {
return StaticHostmapScreen(onSave: (map) { Utils.openPage(context, (context) {
setState(() { return StaticHostmapScreen(onSave: (map) {
changed = true; setState(() {
_addHostmap(map); changed = true;
_addHostmap(map);
});
}); });
}); });
}); },
}, ));
)); }
return items; return items;
} }

View File

@ -15,7 +15,7 @@ class UnsafeRoutesScreen extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
final List<UnsafeRoute> unsafeRoutes; final List<UnsafeRoute> unsafeRoutes;
final ValueChanged<List<UnsafeRoute>> onSave; final ValueChanged<List<UnsafeRoute>>? onSave;
@override @override
_UnsafeRoutesScreenState createState() => _UnsafeRoutesScreenState(); _UnsafeRoutesScreenState createState() => _UnsafeRoutesScreenState();
@ -48,7 +48,9 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
_onSave() { _onSave() {
Navigator.pop(context); Navigator.pop(context);
widget.onSave(unsafeRoutes.values.toList()); if (widget.onSave != null) {
widget.onSave!(unsafeRoutes.values.toList());
}
} }
List<Widget> _buildRoutes() { List<Widget> _buildRoutes() {
@ -56,6 +58,7 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
List<Widget> items = []; List<Widget> items = [];
unsafeRoutes.forEach((key, route) { unsafeRoutes.forEach((key, route) {
items.add(ConfigPageItem( items.add(ConfigPageItem(
disabled: widget.onSave == null,
label: Text(route.route ?? ''), label: Text(route.route ?? ''),
labelWidth: ipWidth, labelWidth: ipWidth,
content: Text('via ${route.via}', textAlign: TextAlign.end), content: Text('via ${route.via}', textAlign: TextAlign.end),
@ -80,21 +83,23 @@ class _UnsafeRoutesScreenState extends State<UnsafeRoutesScreen> {
)); ));
}); });
items.add(ConfigButtonItem( if (widget.onSave != null) {
content: Text('Add a new route'), items.add(ConfigButtonItem(
onPressed: () { content: Text('Add a new route'),
Utils.openPage(context, (context) { onPressed: () {
return UnsafeRouteScreen( Utils.openPage(context, (context) {
route: UnsafeRoute(), return UnsafeRouteScreen(
onSave: (route) { route: UnsafeRoute(),
setState(() { onSave: (route) {
changed = true; setState(() {
unsafeRoutes[UniqueKey()] = route; changed = true;
unsafeRoutes[UniqueKey()] = route;
});
}); });
}); });
}); },
}, ));
)); }
return items; return items;
} }

View File

@ -177,7 +177,7 @@ class Utils {
return null; return null;
} }
final file = File(result!.files.first.path!); final file = File(result.files.first.path!);
return file.readAsString(); return file.readAsString();
} }
} }

View File

@ -1,6 +1,6 @@
Function mtuValidator(bool required) { Function mtuValidator(bool required) {
return (String str) { return (String str) {
if (str == null || str == "") { if (str == "") {
return required ? 'Please fill out this field' : null; return required ? 'Please fill out this field' : null;
} }

134
nebula/api.go Normal file
View File

@ -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
}

View File

@ -18,8 +18,9 @@ import (
) )
type Nebula struct { type Nebula struct {
c *nebula.Control c *nebula.Control
l *logrus.Logger l *logrus.Logger
config *nc.C
} }
func init() { 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) { func (n *Nebula) Log(v string) {
@ -86,6 +87,16 @@ func (n *Nebula) Rebind(reason string) {
n.c.RebindUDPServer() 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) { func (n *Nebula) ListHostmap(pending bool) (string, error) {
hosts := n.c.ListHostmap(pending) hosts := n.c.ListHostmap(pending)
b, err := json.Marshal(hosts) b, err := json.Marshal(hosts)

View File

@ -1,10 +1,11 @@
module github.com/DefinedNet/mobile_nebula/nebula 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 // replace github.com/slackhq/nebula => /Volumes/T7/nate/src/github.com/slackhq/nebula
require ( require (
github.com/DefinedNet/dnapi v0.0.0-20221117210952-6f56f055f991
github.com/sirupsen/logrus v1.9.0 github.com/sirupsen/logrus v1.9.0
github.com/slackhq/nebula v1.6.2-0.20221116023309-813b64ffb179 github.com/slackhq/nebula v1.6.2-0.20221116023309-813b64ffb179
golang.org/x/crypto v0.3.0 golang.org/x/crypto v0.3.0

View File

@ -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= 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/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/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-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/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= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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.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 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= 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= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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-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.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-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-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/DefinedNet/dnapi"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/slackhq/nebula" "github.com/slackhq/nebula"
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
@ -46,7 +47,6 @@ type KeyPair struct {
} }
func RenderConfig(configData string, key string) (string, error) { func RenderConfig(configData string, key string) (string, error) {
config := newConfig()
var d m var d m
err := json.Unmarshal([]byte(configData), &d) err := json.Unmarshal([]byte(configData), &d)
@ -54,35 +54,46 @@ func RenderConfig(configData string, key string) (string, error) {
return "", err return "", err
} }
config.PKI.CA, _ = d["ca"].(string) // If this is a managed config, go ahead and return it
config.PKI.Cert, _ = d["cert"].(string) if rawCfg, ok := d["rawConfig"].(string); ok {
config.PKI.Key = key 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) 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 // Log verbosity is not required
if val, _ := d["logVerbosity"].(string); val != "" { if val, _ := d["logVerbosity"].(string); val != "" {
config.Logging.Level = val cfg.Logging.Level = val
} }
i, _ = d["lhDuration"].(float64) i, _ = d["lhDuration"].(float64)
config.Lighthouse.Interval = int(i) cfg.Lighthouse.Interval = int(i)
if i, ok := d["mtu"].(float64); ok { if i, ok := d["mtu"].(float64); ok {
mtu := int(i) 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{}) staticHostmap := d["staticHostmap"].(map[string]interface{})
for nebIp, mapping := range staticHostmap { for nebIp, mapping := range staticHostmap {
def := mapping.(map[string]interface{}) def := mapping.(map[string]interface{})
isLh := def["lighthouse"].(bool) isLh := def["lighthouse"].(bool)
if isLh { if isLh {
config.Lighthouse.Hosts = append(config.Lighthouse.Hosts, nebIp) cfg.Lighthouse.Hosts = append(cfg.Lighthouse.Hosts, nebIp)
} }
hosts := def["destinations"].([]interface{}) hosts := def["destinations"].([]interface{})
@ -92,20 +103,20 @@ func RenderConfig(configData string, key string) (string, error) {
realHosts[i] = h.(string) realHosts[i] = h.(string)
} }
config.StaticHostmap[nebIp] = realHosts cfg.StaticHostmap[nebIp] = realHosts
} }
if unsafeRoutes, ok := d["unsafeRoutes"].([]interface{}); ok { 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 { for i, r := range unsafeRoutes {
rawRoute := r.(map[string]interface{}) rawRoute := r.(map[string]interface{})
route := &config.Tun.UnsafeRoutes[i] route := &cfg.Tun.UnsafeRoutes[i]
route.Route = rawRoute["route"].(string) route.Route = rawRoute["route"].(string)
route.Via = rawRoute["via"].(string) route.Via = rawRoute["via"].(string)
} }
} }
finalConfig, err := yaml.Marshal(config) finalConfig, err := yaml.Marshal(cfg)
if err != nil { if err != nil {
return "", err return "", err
} }

130
nebula/site.go Normal file
View File

@ -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
}

View File

@ -104,6 +104,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.7" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -114,6 +121,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -156,6 +170,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.2" 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: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -205,6 +233,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
petitparser:
dependency: transitive
description:
name: petitparser
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -293,7 +328,7 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.17" version: "6.1.6"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
@ -328,7 +363,7 @@ packages:
name: url_launcher_platform_interface name: url_launcher_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.5" version: "2.1.1"
url_launcher_web: url_launcher_web:
dependency: transitive dependency: transitive
description: description:
@ -371,6 +406,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.0"
xml:
dependency: transitive
description:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.0"
sdks: sdks:
dart: ">=2.18.1 <3.0.0" dart: ">=2.18.1 <3.0.0"
flutter: ">=3.0.0" flutter: ">=3.0.0"

View File

@ -28,9 +28,11 @@ dependencies:
file_picker: ^5.0.1 file_picker: ^5.0.1
uuid: ^3.0.4 uuid: ^3.0.4
package_info: ^2.0.0 package_info: ^2.0.0
url_launcher: ^6.0.6 url_launcher: ^6.1.6
pull_to_refresh: ^2.0.0 pull_to_refresh: ^2.0.0
flutter_barcode_scanner: ^2.0.0 flutter_barcode_scanner: ^2.0.0
flutter_svg: ^1.1.5
intl: ^0.17.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -52,6 +54,9 @@ flutter:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.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 # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware. # https://flutter.dev/assets-and-images/#resolution-aware.