forked from core/mobile_nebula
Support DN host enrollment (#86)
Co-authored-by: Nate Brown <nbrown.us@gmail.com>
This commit is contained in:
parent
c3f5c39d83
commit
c7a53c3905
|
@ -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/`
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
@ -38,10 +56,11 @@ class MainActivity: FlutterActivity() {
|
||||||
appContext = context
|
appContext = context
|
||||||
//TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this
|
//TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this
|
||||||
sites = Sites(flutterEngine)
|
sites = Sites(flutterEngine)
|
||||||
|
|
||||||
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)
|
||||||
|
@ -254,7 +331,7 @@ class MainActivity: FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val pending = call.argument<Boolean>("pending") ?: false
|
val pending = call.argument<Boolean>("pending") ?: false
|
||||||
|
|
||||||
if (outMessenger == null || activeSiteId == null || activeSiteId != id) {
|
if (outMessenger == null || activeSiteId == null || activeSiteId != id) {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
@ -302,7 +379,7 @@ class MainActivity: FlutterActivity() {
|
||||||
})
|
})
|
||||||
outMessenger?.send(msg)
|
outMessenger?.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeCloseTunnel(call: MethodCall, result: MethodChannel.Result) {
|
private fun activeCloseTunnel(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val id = call.argument<String>("id")
|
val id = call.argument<String>("id")
|
||||||
if (id == "") {
|
if (id == "") {
|
||||||
|
@ -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) {
|
||||||
|
@ -416,7 +495,7 @@ class MainActivity: FlutterActivity() {
|
||||||
private fun isRunning(site: SiteContainer, msg: Message) {
|
private fun isRunning(site: SiteContainer, msg: Message) {
|
||||||
var status = "Disconnected"
|
var status = "Disconnected"
|
||||||
var connected = false
|
var connected = false
|
||||||
|
|
||||||
if (msg.arg1 == 1) {
|
if (msg.arg1 == 1) {
|
||||||
status = "Connected"
|
status = "Connected"
|
||||||
connected = true
|
connected = true
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (managed) {
|
||||||
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
}
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
// Attempt to delete an existing key to allow for an overwrite
|
||||||
let val = SecItemAdd(query as CFDictionary, nil)
|
_ = self.delete(key: key)
|
||||||
return val == 0
|
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,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class IPCResponse: Codable {
|
||||||
var type: IPCResponseType
|
var type: IPCResponseType
|
||||||
//TODO: change message to data?
|
//TODO: change message to data?
|
||||||
var message: JSON?
|
var message: JSON?
|
||||||
|
|
||||||
init(type: IPCResponseType, message: JSON?) {
|
init(type: IPCResponseType, message: JSON?) {
|
||||||
self.type = type
|
self.type = type
|
||||||
self.message = message
|
self.message = message
|
||||||
|
@ -23,12 +23,12 @@ class IPCResponse: Codable {
|
||||||
class IPCRequest: Codable {
|
class IPCRequest: Codable {
|
||||||
var command: String
|
var command: String
|
||||||
var arguments: JSON?
|
var arguments: JSON?
|
||||||
|
|
||||||
init(command: String, arguments: JSON?) {
|
init(command: String, arguments: JSON?) {
|
||||||
self.command = command
|
self.command = command
|
||||||
self.arguments = arguments
|
self.arguments = arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
init(command: String) {
|
init(command: String) {
|
||||||
self.command = command
|
self.command = command
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,7 @@ struct CertificateInfo: Codable {
|
||||||
var cert: Certificate
|
var cert: Certificate
|
||||||
var rawCert: String
|
var rawCert: String
|
||||||
var validity: CertificateValidity
|
var validity: CertificateValidity
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case cert = "Cert"
|
case cert = "Cert"
|
||||||
case rawCert = "RawCert"
|
case rawCert = "RawCert"
|
||||||
|
@ -50,7 +50,7 @@ struct Certificate: Codable {
|
||||||
var fingerprint: String
|
var fingerprint: String
|
||||||
var signature: String
|
var signature: String
|
||||||
var details: CertificateDetails
|
var details: CertificateDetails
|
||||||
|
|
||||||
/// An empty initilizer to make error reporting easier
|
/// An empty initilizer to make error reporting easier
|
||||||
init() {
|
init() {
|
||||||
fingerprint = ""
|
fingerprint = ""
|
||||||
|
@ -69,7 +69,7 @@ struct CertificateDetails: Codable {
|
||||||
var subnets: [String]
|
var subnets: [String]
|
||||||
var isCa: Bool
|
var isCa: Bool
|
||||||
var issuer: String
|
var issuer: String
|
||||||
|
|
||||||
/// An empty initilizer to make error reporting easier
|
/// An empty initilizer to make error reporting easier
|
||||||
init() {
|
init() {
|
||||||
name = ""
|
name = ""
|
||||||
|
@ -87,7 +87,7 @@ struct CertificateDetails: Codable {
|
||||||
struct CertificateValidity: Codable {
|
struct CertificateValidity: Codable {
|
||||||
var valid: Bool
|
var valid: Bool
|
||||||
var reason: String
|
var reason: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case valid = "Valid"
|
case valid = "Valid"
|
||||||
case reason = "Reason"
|
case reason = "Reason"
|
||||||
|
@ -117,7 +117,7 @@ class Site: Codable {
|
||||||
// Stored in manager
|
// Stored in manager
|
||||||
var name: String
|
var name: String
|
||||||
var id: String
|
var id: String
|
||||||
|
|
||||||
// Stored in proto
|
// Stored in proto
|
||||||
var staticHostmap: Dictionary<String, StaticHosts>
|
var staticHostmap: Dictionary<String, StaticHosts>
|
||||||
var unsafeRoutes: [UnsafeRoute]
|
var unsafeRoutes: [UnsafeRoute]
|
||||||
|
@ -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
|
||||||
|
@ -147,33 +155,54 @@ class Site: Codable {
|
||||||
self.connected = statusMap[manager.connection.status]
|
self.connected = statusMap[manager.connection.status]
|
||||||
self.status = statusString[manager.connection.status]
|
self.status = statusString[manager.connection.status]
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
staticHostmap = incoming.staticHostmap
|
staticHostmap = incoming.staticHostmap
|
||||||
unsafeRoutes = incoming.unsafeRoutes ?? []
|
unsafeRoutes = incoming.unsafeRoutes ?? []
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let rawCert = incoming.cert
|
let rawCert = incoming.cert
|
||||||
let rawDetails = MobileNebulaParseCerts(rawCert, &err)
|
let rawDetails = MobileNebulaParseCerts(rawCert, &err)
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
throw err!
|
throw err!
|
||||||
}
|
}
|
||||||
|
|
||||||
var certs: [CertificateInfo]
|
var certs: [CertificateInfo]
|
||||||
|
|
||||||
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
|
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
|
||||||
if (certs.count == 0) {
|
if (certs.count == 0) {
|
||||||
throw "No certificate found"
|
throw "No certificate found"
|
||||||
|
@ -182,11 +211,11 @@ class Site: Codable {
|
||||||
if (!cert!.validity.valid) {
|
if (!cert!.validity.valid) {
|
||||||
errors.append("Certificate is invalid: \(cert!.validity.reason)")
|
errors.append("Certificate is invalid: \(cert!.validity.reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
errors.append("Error while loading certificate: \(error.localizedDescription)")
|
errors.append("Error while loading certificate: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let rawCa = incoming.ca
|
let rawCa = incoming.ca
|
||||||
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err)
|
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err)
|
||||||
|
@ -194,31 +223,43 @@ class Site: Codable {
|
||||||
throw err!
|
throw err!
|
||||||
}
|
}
|
||||||
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
|
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
|
||||||
|
|
||||||
var hasErrors = false
|
var hasErrors = false
|
||||||
ca.forEach { cert in
|
ca.forEach { cert in
|
||||||
if (!cert.validity.valid) {
|
if (!cert.validity.valid) {
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors) {
|
||||||
errors.append("There are issues with 1 or more ca certificates")
|
errors.append("There are issues with 1 or more ca certificates")
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
ca = []
|
ca = []
|
||||||
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 {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
|
@ -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!
|
||||||
|
@ -235,17 +277,53 @@ 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,76 +401,97 @@ struct IncomingSite: Codable {
|
||||||
var sortKey: Int?
|
var sortKey: Int?
|
||||||
var logVerbosity: String?
|
var logVerbosity: String?
|
||||||
var key: String?
|
var key: String?
|
||||||
|
var managed: Bool?
|
||||||
func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
|
// The following fields are present if managed = true
|
||||||
#if targetEnvironment(simulator)
|
var dnCredentials: DNCredentials?
|
||||||
let fileManager = FileManager.default
|
var lastManagedUpdate: String?
|
||||||
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id)
|
|
||||||
|
func getConfig() throws -> Data {
|
||||||
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
|
||||||
if (error != nil) {
|
if (error != nil) {
|
||||||
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)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finish(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) {
|
|
||||||
var config = self
|
|
||||||
|
|
||||||
// Store the private key if it was provided
|
|
||||||
if (config.key != nil) {
|
|
||||||
//TODO: should we ensure the resulting data is big enough? (conversion didn't fail)
|
|
||||||
let data = config.key!.data(using: .utf8)
|
|
||||||
if (!KeyChain.save(key: "\(config.id).key", data: data!)) {
|
|
||||||
return callback("failed to store key material in keychain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero out the key so that we don't save it in the profile
|
|
||||||
config.key = nil
|
|
||||||
|
|
||||||
|
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) {
|
||||||
// Stuff our details in the protocol
|
// 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
|
||||||
manager.protocolConfiguration = proto
|
manager.protocolConfiguration = proto
|
||||||
//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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(() {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
],
|
],
|
||||||
|
|
|
@ -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)
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!();
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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=
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
46
pubspec.lock
46
pubspec.lock
|
@ -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"
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue