From b546dd1c9d4abe3e9a8074d5a003167669c810f0 Mon Sep 17 00:00:00 2001 From: Nate Brown Date: Mon, 27 Jul 2020 15:43:58 -0500 Subject: [PATCH] Initial commit --- .gitignore | 47 + .metadata | 10 + android/.gitignore | 8 + android/app/build.gradle | 102 +++ android/app/proguard-rules.pro | 11 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 49 + .../net/defined/mobile_nebula/EncFile.kt | 22 + .../net/defined/mobile_nebula/MainActivity.kt | 402 +++++++++ .../defined/mobile_nebula/NebulaVpnService.kt | 209 +++++ .../kotlin/net/defined/mobile_nebula/Sites.kt | 267 ++++++ .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes android/app/src/main/res/values/styles.xml | 8 + .../app/src/main/res/xml/provider_paths.xml | 5 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle | 31 + android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + android/settings.gradle | 15 + android/settings_aar.gradle | 1 + env.sh.example | 4 + fonts/RobotoMono-Regular.ttf | Bin 0 -> 109212 bytes gen-artifacts.sh | 51 ++ ios/.gitignore | 33 + ios/Flutter/AppFrameworkInfo.plist | 26 + ios/Flutter/Debug.xcconfig | 2 + ios/Flutter/Release.xcconfig | 2 + ios/NebulaNetworkExtension/Info.plist | 31 + ios/NebulaNetworkExtension/Keychain.swift | 67 ++ .../NebulaNetworkExtension.entitlements | 18 + .../PacketTunnelProvider.swift | 174 ++++ ios/NebulaNetworkExtension/Site.swift | 383 ++++++++ ios/Podfile | 91 ++ ios/Podfile.lock | 148 +++ ios/Runner.xcodeproj/project.pbxproj | 849 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/Runner.xcscheme | 91 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + ios/Runner/AppDelegate.swift | 316 +++++++ .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 564 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1588 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 1025 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 1716 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 1920 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1895 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 3831 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 1888 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 3294 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 3612 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 + ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 51 ++ ios/Runner/Runner-Bridging-Header.h | 1 + ios/Runner/Runner.entitlements | 18 + ios/Runner/Sites.swift | 163 ++++ lib/components/CIDRField.dart | 102 +++ lib/components/CIDRFormField.dart | 170 ++++ lib/components/FormPage.dart | 95 ++ lib/components/IPAndPortField.dart | 108 +++ lib/components/IPAndPortFormField.dart | 180 ++++ lib/components/IPField.dart | 59 ++ lib/components/IPFormField.dart | 140 +++ lib/components/PlatformTextFormField.dart | 150 ++++ lib/components/SimplePage.dart | 105 +++ lib/components/SiteItem.dart | 51 ++ lib/components/SpecialButton.dart | 145 +++ lib/components/SpecialTextField.dart | 199 ++++ lib/components/config/ConfigButtonItem.dart | 41 + lib/components/config/ConfigCheckboxItem.dart | 46 + lib/components/config/ConfigHeader.dart | 30 + lib/components/config/ConfigItem.dart | 29 + lib/components/config/ConfigPageItem.dart | 58 ++ lib/components/config/ConfigSection.dart | 45 + lib/components/config/ConfigTextItem.dart | 25 + lib/main.dart | 100 +++ lib/models/CIDR.dart | 25 + lib/models/Certificate.dart | 83 ++ lib/models/HostInfo.dart | 49 + lib/models/Hostmap.dart | 9 + lib/models/IPAndPort.dart | 25 + lib/models/Site.dart | 305 +++++++ lib/models/StaticHosts.dart | 28 + lib/models/UnsafeRoute.dart | 18 + lib/screens/AboutScreen.dart | 70 ++ lib/screens/HostInfoScreen.dart | 191 ++++ lib/screens/MainScreen.dart | 244 +++++ lib/screens/SettingsScreen.dart | 76 ++ lib/screens/SiteDetailScreen.dart | 275 ++++++ lib/screens/SiteLogsScreen.dart | 123 +++ lib/screens/SiteTunnelsScreen.dart | 132 +++ lib/screens/siteConfig/AdvancedScreen.dart | 186 ++++ lib/screens/siteConfig/CAListScreen.dart | 233 +++++ .../siteConfig/CertificateDetailsScreen.dart | 131 +++ lib/screens/siteConfig/CertificateScreen.dart | 293 ++++++ lib/screens/siteConfig/CipherScreen.dart | 70 ++ .../siteConfig/LogVerbosityScreen.dart | 68 ++ .../siteConfig/RenderedConfigScreen.dart | 21 + lib/screens/siteConfig/SiteConfigScreen.dart | 229 +++++ .../siteConfig/StaticHostmapScreen.dart | 197 ++++ lib/screens/siteConfig/StaticHostsScreen.dart | 142 +++ lib/screens/siteConfig/UnsafeRouteScreen.dart | 115 +++ .../siteConfig/UnsafeRoutesScreen.dart | 98 ++ lib/services/settings.dart | 88 ++ lib/services/storage.dart | 67 ++ lib/services/utils.dart | 166 ++++ lib/validators/dnsValidator.dart | 34 + lib/validators/ipValidator.dart | 17 + lib/validators/mtuValidator.dart | 14 + nebula/Makefile | 12 + nebula/config.go | 204 +++++ nebula/control.go | 116 +++ nebula/go.mod | 14 + nebula/go.sum | 158 ++++ nebula/mobileNebula.go | 212 +++++ nebula/mobileNebula_test.go | 50 ++ pubspec.lock | 376 ++++++++ pubspec.yaml | 85 ++ 134 files changed, 10907 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/net/defined/mobile_nebula/EncFile.kt create mode 100644 android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt create mode 100644 android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt create mode 100644 android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/provider_paths.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle create mode 100644 android/settings_aar.gradle create mode 100644 env.sh.example create mode 100644 fonts/RobotoMono-Regular.ttf create mode 100755 gen-artifacts.sh create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/NebulaNetworkExtension/Info.plist create mode 100644 ios/NebulaNetworkExtension/Keychain.swift create mode 100644 ios/NebulaNetworkExtension/NebulaNetworkExtension.entitlements create mode 100644 ios/NebulaNetworkExtension/PacketTunnelProvider.swift create mode 100644 ios/NebulaNetworkExtension/Site.swift create mode 100644 ios/Podfile create mode 100644 ios/Podfile.lock create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/Runner/Runner.entitlements create mode 100644 ios/Runner/Sites.swift create mode 100644 lib/components/CIDRField.dart create mode 100644 lib/components/CIDRFormField.dart create mode 100644 lib/components/FormPage.dart create mode 100644 lib/components/IPAndPortField.dart create mode 100644 lib/components/IPAndPortFormField.dart create mode 100644 lib/components/IPField.dart create mode 100644 lib/components/IPFormField.dart create mode 100644 lib/components/PlatformTextFormField.dart create mode 100644 lib/components/SimplePage.dart create mode 100644 lib/components/SiteItem.dart create mode 100644 lib/components/SpecialButton.dart create mode 100644 lib/components/SpecialTextField.dart create mode 100644 lib/components/config/ConfigButtonItem.dart create mode 100644 lib/components/config/ConfigCheckboxItem.dart create mode 100644 lib/components/config/ConfigHeader.dart create mode 100644 lib/components/config/ConfigItem.dart create mode 100644 lib/components/config/ConfigPageItem.dart create mode 100644 lib/components/config/ConfigSection.dart create mode 100644 lib/components/config/ConfigTextItem.dart create mode 100644 lib/main.dart create mode 100644 lib/models/CIDR.dart create mode 100644 lib/models/Certificate.dart create mode 100644 lib/models/HostInfo.dart create mode 100644 lib/models/Hostmap.dart create mode 100644 lib/models/IPAndPort.dart create mode 100644 lib/models/Site.dart create mode 100644 lib/models/StaticHosts.dart create mode 100644 lib/models/UnsafeRoute.dart create mode 100644 lib/screens/AboutScreen.dart create mode 100644 lib/screens/HostInfoScreen.dart create mode 100644 lib/screens/MainScreen.dart create mode 100644 lib/screens/SettingsScreen.dart create mode 100644 lib/screens/SiteDetailScreen.dart create mode 100644 lib/screens/SiteLogsScreen.dart create mode 100644 lib/screens/SiteTunnelsScreen.dart create mode 100644 lib/screens/siteConfig/AdvancedScreen.dart create mode 100644 lib/screens/siteConfig/CAListScreen.dart create mode 100644 lib/screens/siteConfig/CertificateDetailsScreen.dart create mode 100644 lib/screens/siteConfig/CertificateScreen.dart create mode 100644 lib/screens/siteConfig/CipherScreen.dart create mode 100644 lib/screens/siteConfig/LogVerbosityScreen.dart create mode 100644 lib/screens/siteConfig/RenderedConfigScreen.dart create mode 100644 lib/screens/siteConfig/SiteConfigScreen.dart create mode 100644 lib/screens/siteConfig/StaticHostmapScreen.dart create mode 100644 lib/screens/siteConfig/StaticHostsScreen.dart create mode 100644 lib/screens/siteConfig/UnsafeRouteScreen.dart create mode 100644 lib/screens/siteConfig/UnsafeRoutesScreen.dart create mode 100644 lib/services/settings.dart create mode 100644 lib/services/storage.dart create mode 100644 lib/services/utils.dart create mode 100644 lib/validators/dnsValidator.dart create mode 100644 lib/validators/ipValidator.dart create mode 100644 lib/validators/mtuValidator.dart create mode 100644 nebula/Makefile create mode 100644 nebula/config.go create mode 100644 nebula/control.go create mode 100644 nebula/go.mod create mode 100644 nebula/go.sum create mode 100644 nebula/mobileNebula.go create mode 100644 nebula/mobileNebula_test.go create mode 100644 pubspec.lock create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcc3a05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +/nebula/local.settings +/nebula/MobileNebula.framework/ +/nebula/mobileNebula-sources.jar +/nebula/vendor/ +/android/app/src/main/libs/mobileNebula.aar +/nebula/mobileNebula.aar +/android/key.properties +/env.sh +/lib/gen.versions.dart +/lib/.gen.versions.dart diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..01d2dcb --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 0b8abb4724aa590dd0f429683339b1e045a1594d + channel: stable + +project_type: app diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..e71e2bf --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +/build/build-attribution/ diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..cc25d96 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,102 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdkVersion 28 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + applicationId "net.defined.mobile_nebula" + minSdkVersion 25 + targetSdkVersion 28 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['password'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['password'] + } + } + + buildTypes { + release { + signingConfig signingConfigs.release + + // We are disabling minification and proguard because it wrecks the crypto for storing keys + // Ideally we would turn these on. We had issues with gson as well but resolved those with proguardFiles + minifyEnabled false + useProguard false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +flutter { + source '../..' +} + +repositories { + flatDir { + dirs 'src/main/libs' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "androidx.security:security-crypto:1.0.0-rc02" + implementation 'com.google.code.gson:gson:2.8.6' + + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + implementation (name:'mobileNebula', ext:'aar') { + exec { + workingDir '../../' + environment("ANDROID_NDK_HOME", android.ndkDirectory) + environment("ANDROID_HOME", android.sdkDirectory) + commandLine './gen-artifacts.sh', 'android' + } + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..51a9fa3 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,11 @@ +# Flutter Wrapper - this came from guidance at https://medium.com/@swav.kulinski/flutter-and-android-obfuscation-8768ac544421 +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } + +# Keep our class names for gson +-keep class net.defined.mobile_nebula.** { *; } +-keep class androidx.security.crypto.** { *; } \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..bc4f1bf --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8e4fc32 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/EncFile.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/EncFile.kt new file mode 100644 index 0000000..581ddbd --- /dev/null +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/EncFile.kt @@ -0,0 +1,22 @@ +package net.defined.mobile_nebula + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKeys +import java.io.* + +class EncFile(var context: Context) { + private val scheme = EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + private val master: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + + fun openRead(file: File): BufferedReader { + val eFile = EncryptedFile.Builder(file, context, master, scheme).build() + return eFile.openFileInput().bufferedReader() + } + + fun openWrite(file: File): BufferedWriter { + val eFile = EncryptedFile.Builder(file, context, master, scheme).build() + return eFile.openFileOutput().bufferedWriter() + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt new file mode 100644 index 0000000..051450a --- /dev/null +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/MainActivity.kt @@ -0,0 +1,402 @@ +package net.defined.mobile_nebula + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.net.VpnService +import android.os.* +import androidx.annotation.NonNull; +import com.google.gson.Gson +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.GeneratedPluginRegistrant + +const val TAG = "nebula" +const val VPN_PERMISSIONS_CODE = 0x0F +const val VPN_START_CODE = 0x10 +const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService" + +class MainActivity: FlutterActivity() { + private var sites: Sites? = null + private var permResult: MethodChannel.Result? = null + + private var inMessenger: Messenger? = Messenger(IncomingHandler()) + private var outMessenger: Messenger? = null + + private var activeSiteId: String? = null + + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + //TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this + sites = Sites(context, flutterEngine) + + // Bind against our service to detect which site is running on app boot + val intent = Intent(this, NebulaVpnService::class.java) + bindService(intent, connection, 0) + + GeneratedPluginRegistrant.registerWith(flutterEngine); + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + when(call.method) { + "android.requestPermissions" -> androidPermissions(result) + + "nebula.parseCerts" -> nebulaParseCerts(call, result) + "nebula.generateKeyPair" -> nebulaGenerateKeyPair(result) + "nebula.renderConfig" -> nebulaRenderConfig(call, result) + + "listSites" -> listSites(result) + "deleteSite" -> deleteSite(call, result) + "saveSite" -> saveSite(call, result) + "startSite" -> startSite(call, result) + "stopSite" -> stopSite() + + "active.listHostmap" -> activeListHostmap(call, result) + "active.listPendingHostmap" -> activeListPendingHostmap(call, result) + "active.getHostInfo" -> activeGetHostInfo(call, result) + "active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result) + "active.closeTunnel" -> activeCloseTunnel(call, result) + + else -> result.notImplemented() + } + } + } + + private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) { + val certs = call.argument("certs") + if (certs == "") { + return result.error("required_argument", "certs is a required argument", null) + } + + return try { + val json = mobileNebula.MobileNebula.parseCerts(certs) + result.success(json) + } catch (err: Exception) { + result.error("unhandled_error", err.message, null) + } + } + + private fun nebulaGenerateKeyPair(result: MethodChannel.Result) { + val kp = mobileNebula.MobileNebula.generateKeyPair() + return result.success(kp) + } + + private fun nebulaRenderConfig(call: MethodCall, result: MethodChannel.Result) { + val config = call.arguments as String + val yaml = mobileNebula.MobileNebula.renderConfig(config, "") + return result.success(yaml) + } + + private fun listSites(result: MethodChannel.Result) { + sites!!.refreshSites(activeSiteId) + val sites = sites!!.getSites() + val gson = Gson() + val json = gson.toJson(sites) + result.success(json) + } + + private fun deleteSite(call: MethodCall, result: MethodChannel.Result) { + val id = call.arguments as String + if (activeSiteId == id) { + stopSite() + } + sites!!.deleteSite(id) + result.success(null) + } + + private fun saveSite(call: MethodCall, result: MethodChannel.Result) { + val site: IncomingSite + try { + val gson = Gson() + site = gson.fromJson(call.arguments as String, IncomingSite::class.java) + site.save(context) + + } catch (err: Exception) { + //TODO: is toString the best or .message? + return result.error("failure", err.toString(), null) + } + + val siteDir = context.filesDir.resolve("sites").resolve(site.id) + try { + // Try to render a full site, if this fails the config was bad somehow + Site(siteDir) + } catch (err: Exception) { + siteDir.deleteRecursively() + return result.error("failure", "Site config was incomplete, please review and try again", null) + } + + result.success(null) + } + + private fun startSite(call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + if (id == "") { + return result.error("required_argument", "id is a required argument", null) + } + + var siteContainer: SiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null) + + siteContainer.site.connected = true + siteContainer.site.status = "Initializing..." + + val intent = VpnService.prepare(this) + if (intent != null) { + //TODO: ensure this boots the correct bit, I bet it doesn't and we need to go back to the active symlink + intent.putExtra("path", siteContainer.site.path) + intent.putExtra("id", siteContainer.site.id) + startActivityForResult(intent, VPN_START_CODE) + + } else { + val intent = Intent(this, NebulaVpnService::class.java) + intent.putExtra("path", siteContainer.site.path) + intent.putExtra("id", siteContainer.site.id) + onActivityResult(VPN_START_CODE, Activity.RESULT_OK, intent) + } + + result.success(null) + } + + private fun stopSite() { + val intent = Intent(this, NebulaVpnService::class.java) + intent.putExtra("COMMAND", "STOP") + + //This is odd but stopService goes nowhere in my tests and this is correct + // according to the official example https://android.googlesource.com/platform/development/+/master/samples/ToyVpn/src/com/example/android/toyvpn/ToyVpnClient.java#116 + startService(intent) + //TODO: why doesn't this work!?!? +// if (serviceIntent != null) { +// Log.e(TAG, "stopping ${serviceIntent.toString()}") +// stopService(serviceIntent) +// } + } + + private fun activeListHostmap(call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + if (id == "") { + return result.error("required_argument", "id is a required argument", null) + } + + if (outMessenger == null || activeSiteId == null || activeSiteId != id) { + return result.success(null) + } + + var msg = Message.obtain() + msg.what = NebulaVpnService.MSG_LIST_HOSTMAP + msg.replyTo = Messenger(object: Handler() { + override fun handleMessage(msg: Message) { + result.success(msg.data.getString("data")) + } + }) + outMessenger?.send(msg) + } + + private fun activeListPendingHostmap(call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + if (id == "") { + return result.error("required_argument", "id is a required argument", null) + } + + if (outMessenger == null || activeSiteId == null || activeSiteId != id) { + return result.success(null) + } + + var msg = Message.obtain() + msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP + msg.replyTo = Messenger(object: Handler() { + override fun handleMessage(msg: Message) { + result.success(msg.data.getString("data")) + } + }) + outMessenger?.send(msg) + } + + private fun activeGetHostInfo(call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + if (id == "") { + return result.error("required_argument", "id is a required argument", null) + } + + val vpnIp = call.argument("vpnIp") + if (vpnIp == "") { + return result.error("required_argument", "vpnIp is a required argument", null) + } + + val pending = call.argument("pending") ?: false + + if (outMessenger == null || activeSiteId == null || activeSiteId != id) { + return result.success(null) + } + + var msg = Message.obtain() + msg.what = NebulaVpnService.MSG_GET_HOSTINFO + msg.data.putString("vpnIp", vpnIp) + msg.data.putBoolean("pending", pending) + msg.replyTo = Messenger(object: Handler() { + override fun handleMessage(msg: Message) { + result.success(msg.data.getString("data")) + } + }) + outMessenger?.send(msg) + } + + private fun activeSetRemoteForTunnel(call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + if (id == "") { + return result.error("required_argument", "id is a required argument", null) + } + + val vpnIp = call.argument("vpnIp") + if (vpnIp == "") { + return result.error("required_argument", "vpnIp is a required argument", null) + } + + val addr = call.argument("addr") + if (vpnIp == "") { + return result.error("required_argument", "addr is a required argument", null) + } + + if (outMessenger == null || activeSiteId == null || activeSiteId != id) { + return result.success(null) + } + + var msg = Message.obtain() + msg.what = NebulaVpnService.MSG_SET_REMOTE_FOR_TUNNEL + msg.data.putString("vpnIp", vpnIp) + msg.data.putString("addr", addr) + msg.replyTo = Messenger(object: Handler() { + override fun handleMessage(msg: Message) { + result.success(msg.data.getString("data")) + } + }) + outMessenger?.send(msg) + } + + private fun activeCloseTunnel(call: MethodCall, result: MethodChannel.Result) { + val id = call.argument("id") + if (id == "") { + return result.error("required_argument", "id is a required argument", null) + } + + val vpnIp = call.argument("vpnIp") + if (vpnIp == "") { + return result.error("required_argument", "vpnIp is a required argument", null) + } + + if (outMessenger == null || activeSiteId == null || activeSiteId != id) { + return result.success(null) + } + + var msg = Message.obtain() + msg.what = NebulaVpnService.MSG_CLOSE_TUNNEL + msg.data.putString("vpnIp", vpnIp) + msg.replyTo = Messenger(object: Handler() { + override fun handleMessage(msg: Message) { + result.success(msg.data.getBoolean("data")) + } + }) + outMessenger?.send(msg) + } + + private fun androidPermissions(result: MethodChannel.Result) { + val intent = VpnService.prepare(this) + if (intent != null) { + permResult = result + return startActivityForResult(intent, VPN_PERMISSIONS_CODE) + } + + // We already have the permission + result.success(null) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + // This is where activity results come back to us (startActivityForResult) + if (requestCode == VPN_PERMISSIONS_CODE && permResult != null) { + // We are processing a response for vpn permissions and the UI is waiting for feedback + //TODO: unlikely we ever register multiple attempts but this could be a trouble spot if we did + val result = permResult!! + permResult = null + if (resultCode == Activity.RESULT_OK) { + return result.success(null) + } + + return result.error("denied", "User did not grant permission", null) + + } else if (requestCode == VPN_START_CODE) { + // We are processing a response for permissions while starting the VPN (or reusing code in the event we already have perms) + startService(data) + if (outMessenger == null) { + bindService(data, connection, 0) + } + return + } + + // The file picker needs us to super + super.onActivityResult(requestCode, resultCode, data) + } + + /** Defines callbacks for service binding, passed to bindService() */ + val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + outMessenger = Messenger(service) + // We want to monitor the service for as long as we are connected to it. + try { + val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT) + msg.replyTo = inMessenger + outMessenger?.send(msg) + + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + //TODO: + } + + val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING) + outMessenger?.send(msg) + } + + override fun onServiceDisconnected(arg0: ComponentName) { + outMessenger = null + if (activeSiteId != null) { + //TODO: this indicates the service died, notify that it is disconnected + } + activeSiteId = null + } + } + + // Handle and route messages coming from the vpn service + inner class IncomingHandler: Handler() { + override fun handleMessage(msg: Message) { + val id = msg.data.getString("id") + + //TODO: If the elvis hits then we had a deleted site running, which shouldn't happen + val site = sites!!.getSite(id) ?: return + + when (msg.what) { + NebulaVpnService.MSG_IS_RUNNING -> isRunning(site, msg) + NebulaVpnService.MSG_EXIT -> serviceExited(site, msg) + else -> super.handleMessage(msg) + } + } + + private fun isRunning(site: SiteContainer, msg: Message) { + var status = "Disconnected" + var connected = false + + if (msg.arg1 == 1) { + status = "Connected" + connected = true + } + + activeSiteId = site.site.id + site.updater.setState(connected, status) + } + + private fun serviceExited(site: SiteContainer, msg: Message) { + activeSiteId = null + site.updater.setState(false, "Disconnected", msg.data.getString("error")) + } + } +} diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt new file mode 100644 index 0000000..32357e6 --- /dev/null +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt @@ -0,0 +1,209 @@ +package net.defined.mobile_nebula + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.VpnService +import android.os.* +import android.util.Log +import mobileNebula.CIDR +import java.io.File + +class NebulaVpnService : VpnService() { + + companion object { + private const val TAG = "NebulaVpnService" + const val MSG_REGISTER_CLIENT = 1 + const val MSG_UNREGISTER_CLIENT = 2 + const val MSG_IS_RUNNING = 3 + const val MSG_LIST_HOSTMAP = 4 + const val MSG_LIST_PENDING_HOSTMAP = 5 + const val MSG_GET_HOSTINFO = 6 + const val MSG_SET_REMOTE_FOR_TUNNEL = 7 + const val MSG_CLOSE_TUNNEL = 8 + const val MSG_EXIT = 9 + } + + /** + * Target we publish for clients to send messages to IncomingHandler. + */ + private lateinit var messenger: Messenger + private val mClients = ArrayList() + + private var running: Boolean = false + private var site: Site? = null + private var nebula: mobileNebula.Nebula? = null + private var vpnInterface: ParcelFileDescriptor? = null + + //TODO: bindService seems to be how to do IPC + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.getStringExtra("COMMAND") == "STOP") { + stopVpn() + return Service.START_NOT_STICKY + } + + startVpn(intent?.getStringExtra("path"), intent?.getStringExtra("id")) + return super.onStartCommand(intent, flags, startId) + } + + private fun startVpn(path: String?, id: String?) { + if (running) { + return announceExit(id, "Trying to run nebula but it is already running") + } + + //TODO: if we fail to start, android will attempt a restart lacking all the intent data we need. + // Link active site config in Main to avoid this + site = Site(File(path)) + var ipNet: CIDR + + if (site!!.cert == null) { + return announceExit(id, "Site is missing a certificate") + } + + try { + ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0]) + } catch (err: Exception) { + return announceExit(id, err.message ?: "$err") + } + + val builder = Builder() + .addAddress(ipNet.ip, ipNet.maskSize.toInt()) + .addRoute(ipNet.network, ipNet.maskSize.toInt()) + .setMtu(site!!.mtu) + .setSession(TAG) + + // Add our unsafe routes + site!!.unsafeRoutes.forEach { unsafeRoute -> + val ipNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route) + builder.addRoute(ipNet.network, ipNet.maskSize.toInt()) + } + + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + cm.allNetworks.forEach { network -> + cm.getLinkProperties(network).dnsServers.forEach { builder.addDnsServer(it) } + } + + try { + vpnInterface = builder.establish() + nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.fd.toLong()) + + } catch (e: Exception) { + Log.e(TAG, "Got an error $e") + vpnInterface?.close() + announceExit(id, e.message) + return stopSelf() + } + + nebula!!.start() + running = true + sendSimple(MSG_IS_RUNNING, if (running) 1 else 0) + } + + private fun stopVpn() { + nebula?.stop() + vpnInterface?.close() + running = false + announceExit(site?.id, null) + } + + override fun onDestroy() { + stopVpn() + //TODO: wait for the thread to exit + super.onDestroy() + } + + private fun announceExit(id: String?, err: String?) { + val msg = Message.obtain(null, MSG_EXIT) + if (err != null) { + msg.data.putString("error", err) + Log.e(TAG, "$err") + } + send(msg, id) + } + + /** + * Handler of incoming messages from clients. + */ + inner class IncomingHandler(context: Context, private val applicationContext: Context = context.applicationContext) : Handler() { + override fun handleMessage(msg: Message) { + //TODO: how do we limit what can talk to us? + //TODO: Make sure replyTo is actually a messenger + when (msg.what) { + MSG_REGISTER_CLIENT -> mClients.add(msg.replyTo) + MSG_UNREGISTER_CLIENT -> mClients.remove(msg.replyTo) + MSG_IS_RUNNING -> isRunning() + MSG_LIST_HOSTMAP -> listHostmap(msg) + MSG_LIST_PENDING_HOSTMAP -> listHostmap(msg) + MSG_GET_HOSTINFO -> getHostInfo(msg) + MSG_CLOSE_TUNNEL -> closeTunnel(msg) + MSG_SET_REMOTE_FOR_TUNNEL -> setRemoteForTunnel(msg) + else -> super.handleMessage(msg) + } + } + + private fun isRunning() { + sendSimple(MSG_IS_RUNNING, if (running) 1 else 0) + } + + private fun listHostmap(msg: Message) { + val res = nebula!!.listHostmap(msg.what == MSG_LIST_PENDING_HOSTMAP) + var m = Message.obtain(null, msg.what) + m.data.putString("data", res) + msg.replyTo.send(m) + } + + private fun getHostInfo(msg: Message) { + val res = nebula!!.getHostInfoByVpnIp(msg.data.getString("vpnIp"), msg.data.getBoolean("pending")) + var m = Message.obtain(null, msg.what) + m.data.putString("data", res) + msg.replyTo.send(m) + } + + private fun setRemoteForTunnel(msg: Message) { + val res = nebula!!.setRemoteForTunnel(msg.data.getString("vpnIp"), msg.data.getString("addr")) + var m = Message.obtain(null, msg.what) + m.data.putString("data", res) + msg.replyTo.send(m) + } + + private fun closeTunnel(msg: Message) { + val res = nebula!!.closeTunnel(msg.data.getString("vpnIp")) + var m = Message.obtain(null, msg.what) + m.data.putBoolean("data", res) + msg.replyTo.send(m) + } + } + + private fun sendSimple(type: Int, arg1: Int = 0, arg2: Int = 0) { + send(Message.obtain(null, type, arg1, arg2)) + } + + private fun sendObj(type: Int, obj: Any?) { + send(Message.obtain(null, type, obj)) + } + + private fun send(msg: Message, id: String? = null) { + msg.data.putString("id", id ?: site?.id) + mClients.forEach { m -> + try { + m.send(msg) + } catch (e: RemoteException) { + // The client is dead. Remove it from the list; + // we are going through the list from back to front + // so this is safe to do inside the loop. + //TODO: seems bad to remove in loop, double check this is ok +// mClients.remove(m) + } + } + } + + override fun onBind(intent: Intent?): IBinder? { + if (intent != null && SERVICE_INTERFACE == intent.action) { + return super.onBind(intent) + } + + messenger = Messenger(IncomingHandler(this)) + return messenger.binder + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt new file mode 100644 index 0000000..51e6b8e --- /dev/null +++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt @@ -0,0 +1,267 @@ +package net.defined.mobile_nebula + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import java.io.File +import kotlin.collections.HashMap + +data class SiteContainer( + val site: Site, + val updater: SiteUpdater +) + +class Sites(private var context: Context, private var engine: FlutterEngine) { + private var sites: HashMap = HashMap() + + init { + refreshSites() + } + + fun refreshSites(activeSite: String? = null) { + val sitesDir = context.filesDir.resolve("sites") + if (!sitesDir.isDirectory) { + sitesDir.delete() + sitesDir.mkdir() + } + + sites = HashMap() + sitesDir.listFiles().forEach { siteDir -> + try { + val site = Site(siteDir) + + // Make sure we can load the private key + site.getKey(context) + + val updater = SiteUpdater(site, engine) + if (site.id == activeSite) { + updater.setState(true, "Connected") + } + + this.sites[site.id] = SiteContainer(site, updater) + + } catch (err: Exception) { + siteDir.deleteRecursively() + Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err) + } + } + } + + fun getSites(): Map { + return sites.mapValues { it.value.site } + } + + fun deleteSite(id: String) { + sites.remove(id) + val siteDir = context.filesDir.resolve("sites").resolve(id) + siteDir.deleteRecursively() + //TODO: make sure you stop the vpn + //TODO: make sure you relink the active site if this is the active site + } + + fun getSite(id: String): SiteContainer? { + return sites[id] + } +} + +class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler { + // eventSink is how we send info back up to flutter + private var eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}") + private var eventSink: EventChannel.EventSink? = null + + fun setState(connected: Boolean, status: String, err: String? = null) { + site.connected = connected + site.status = status + val d = mapOf("connected" to site.connected, "status" to site.status) + + if (err != null) { + eventSink?.error("", err, d) + } else { + eventSink?.success(d) + } + } + + init { + eventChannel.setStreamHandler(this) + } + + // Methods for EventChannel.StreamHandler + override fun onListen(p0: Any?, p1: EventChannel.EventSink?) { + eventSink = p1 + } + + override fun onCancel(p0: Any?) { + eventSink = null + } + +} + +data class CertificateInfo( + @SerializedName("Cert") val cert: Certificate, + @SerializedName("RawCert") val rawCert: String, + @SerializedName("Validity") val validity: CertificateValidity +) + +data class Certificate( + val fingerprint: String, + val signature: String, + val details: CertificateDetails +) + +data class CertificateDetails( + val name: String, + val notBefore: String, + val notAfter: String, + val publicKey: String, + val groups: List, + val ips: List, + val subnets: List, + val isCa: Boolean, + val issuer: String +) + +data class CertificateValidity( + @SerializedName("Valid") val valid: Boolean, + @SerializedName("Reason") val reason: String +) + +class Site { + val name: String + val id: String + val staticHostmap: HashMap + val unsafeRoutes: List + var cert: CertificateInfo? = null + var ca: Array + val lhDuration: Int + val port: Int + val mtu: Int + val cipher: String + val sortKey: Int + var logVerbosity: String + var connected: Boolean? + var status: String? + val logFile: String? + var errors: ArrayList = ArrayList() + + // Path to this site on disk + @Expose(serialize = false) + val path: String + + // Strong representation of the site config + @Expose(serialize = false) + val config: String + + constructor(siteDir: File) { + val gson = Gson() + config = siteDir.resolve("config.json").readText() + val incomingSite = gson.fromJson(config, IncomingSite::class.java) + + path = siteDir.absolutePath + name = incomingSite.name + id = incomingSite.id + staticHostmap = incomingSite.staticHostmap + unsafeRoutes = incomingSite.unsafeRoutes ?: ArrayList() + lhDuration = incomingSite.lhDuration + port = incomingSite.port + mtu = incomingSite.mtu ?: 1300 + cipher = incomingSite.cipher + sortKey = incomingSite.sortKey ?: 0 + logFile = siteDir.resolve("log").absolutePath + logVerbosity = incomingSite.logVerbosity ?: "info" + + connected = false + status = "Disconnected" + + try { + val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert) + val certs = gson.fromJson(rawDetails, Array::class.java) + if (certs.isEmpty()) { + throw IllegalArgumentException("No certificate found") + } + cert = certs[0] + if (!cert!!.validity.valid) { + errors.add("Certificate is invalid: ${cert!!.validity.reason}") + } + + } catch (err: Exception) { + errors.add("Error while loading certificate: ${err.message}") + } + + try { + val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca) + ca = gson.fromJson(rawCa, Array::class.java) + var hasErrors = false + ca.forEach { + if (!it.validity.valid) { + hasErrors = true + } + } + + if (hasErrors) { + errors.add("There are issues with 1 or more ca certificates") + } + + } catch (err: Exception) { + ca = arrayOf() + errors.add("Error while loading certificate authorities: ${err.message}") + } + } + + fun getKey(context: Context): String? { + val f = EncFile(context).openRead(File(path).resolve("key")) + val k = f.readText() + f.close() + return k + } +} + +data class StaticHosts( + val lighthouse: Boolean, + val destinations: List +) + +data class UnsafeRoute( + val route: String, + val via: String, + val mtu: Int? +) + +class IncomingSite( + val name: String, + val id: String, + val staticHostmap: HashMap, + val unsafeRoutes: List?, + val cert: String, + val ca: String, + val lhDuration: Int, + val port: Int, + val mtu: Int?, + val cipher: String, + val sortKey: Int?, + var logVerbosity: String?, + @Expose(serialize = false) + var key: String? +) { + + fun save(context: Context) { + val siteDir = context.filesDir.resolve("sites").resolve(id) + if (!siteDir.exists()) { + siteDir.mkdir() + } + + if (key != null) { + val f = EncFile(context).openWrite(siteDir.resolve("key")) + f.use { it.write(key) } + f.close() + } + + key = null + val gson = Gson() + val confFile = siteDir.resolve("config.json") + confFile.writeText(gson.toJson(this)) + } +} diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..00fa441 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..7f4dbfc --- /dev/null +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..bc4f1bf --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..f040383 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.61' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.0.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..38c8d45 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a6cfe7d --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 05 14:55:48 CDT 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..5a2f14f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/android/settings_aar.gradle b/android/settings_aar.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/env.sh.example b/env.sh.example new file mode 100644 index 0000000..3f40465 --- /dev/null +++ b/env.sh.example @@ -0,0 +1,4 @@ +#!/bin/sh + +# Ensure your go and flutter bin folders are here +export PATH="$PATH:/path/to/go/bin:/path/to/flutter/bin" \ No newline at end of file diff --git a/fonts/RobotoMono-Regular.ttf b/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5919b5d1bf061d687f60300fd7ab774c6b06df50 GIT binary patch literal 109212 zcmbTf2Yi#~xi}uk zAOn+zBqpnE(www8Y5TQllQvCz($mw^)0LdgG*~a+^}Ldd32DFn|NOv~-uaCCxyN;1 z_X9&1h6%$Do?)shDy#g5{0A9wIT}X`su#4E{p6SL`Qv*V!>~0?3$k)={_r=a7&7j} z`QCxizKNfUrdfPH&oHvifpu$>`~Dcw!;qcy{KLZ&BcmTjeE2Ctraohsz|$jrlM^@| zhUbOhx8KOBjl=HmU;a;qAwdjTn7wSMZ}9IqVK~2(-fvxo13?`34Lm;{-?hs|*KXLI zk@RDn-@q`T7gvoB^sQd}%^eKsSj;f;Uyk-|m=K;1j>Y#PT%SDFH#$`QyH$AJ&Yv<2 z7cenCx%Q1WS``f0^J|7l(M_xwny^h3zRr*xM{xbK3`_cXHMdm8Fai_Eet-CkfNg^+I@fxPteKPA8m3X)z1ZPx5!wPI8J2sdkW4 z!g28s>EAjk{ls80#TfDjg=r>^Nn+NzrlasCLr5UY1+gqcIHu2!$Ye#X&;XX_g)VL#)f+N7OP+(ZSG2>}ff9`6hWuU9z}v;}()! zIohz~)H0iG@Nmb;#=~P}Gr3KJ=F(*~2C}|;XLdsU@_k)x`-U6=KmBRc@Bh7gbxY=b zD{~v2w%DZl;k{iQ`v;2rzxKLc{>qE1+s4b{lZ(-0Cgb*R`E$ZI85NVxlrkMmH{);? zb}wRCu8YXz=rX1ieICF*p7ru0&7GxrwZb z_DqwK$aE)LfQ5l-@!P2s!at-}mI#i+AACjD-)#N0Gh9=S9Ng?FB znhIPpSfSBW4B%2+5}K4tF9``r%GGFbiMVZ?wT<&v?IVpQQ{#xe!9LtzGBpg_lkBFr zIFmi8A*qmlC`_8YY#*lA4BH#$t|_3W3X&R=ydO?E_+SUaF)MHXj=zF&OJnRzJ+s!S zERGg9B4lMI$M8JSKv1|k*K`b)M+(NsH)Mpc-5g=rVwa5Yy!SJUm63e+MO}>V8m=27 zUdh#WIYTqjYpc`k8Fo!XVul=RmX5Sducx9V4LFq~Ik8%8<_MWQN@I}8=~5`I+mexFtIt?M%$ij4WNp=rj~3KD*FSK6 zqL@rq@3xDt#^*ZJfys`=1-YiFq|``%r7lmMUYZ_Brpnh0bftG+>}=oFm6_W<;jnbn zSE!rCQRkV??H_jjeypUtVA;_}^0LXbXk%lgU1{zt({sPdpDOK9WoBhZCEC;DlC9MY zA%Ec2>_JJ3nLIiy5Q0X!a7NN#P>584;VaXH4^UsvhbvgWNU!o@532T)bHY*a2iE6`*WaH#jMozG}00wi3_3exNUYb>^ktkZe4kMz`=9eX zpE=L`FW<|*&4e(q&Zv;!puhlsKe>!!VRWi7F3Tf`pDLZ`YyxNIbWsA4bDV*V5%0H> z(U|8F$V#iYlQ74A^xiS{sora+$tAJ<)N@P3SH{Rn@!Sdw-+Ja4-^0HEi=lUFpb1!^ zohJ;;16F(=#F2#%3T%UwIx;O+P{i6 z{e;co-e>1lxOQtIULJjzSkWB^{c;=$KTeE-5i8lE+Lzd$LkA?;o$PKmPs!(thCm6AcX~-ajCIk9_s$>2JQ;AvPU6@%AgUN8f`d zAw!R3OoUS*pm97?gTX3h>ci;Vs5J_~qBC={8F3eD2<6{AdgVRb)edW)F1*QTnM`Lo zY#mR#M0jYo2@Hs&o6t3$rwfukD^?qk9wzw3q^s2ya!1jqG`=2E5g=+leYT2x`??!_w`FclD zUT&tzXwXLJll`a*L~XAn4-)_jbytHuR{CyRMpdYW?A=|pvR1!yYhhod{$}aAOGEt^ z*E(aZ?S<7#?NL=*uJrd_*-|@Ws2M3K8LlyyS|%%Zb2<95#kOrbG-ZqJ3yzPLSeBg~ zN?X+KiixUQJe)gxZq#mFcB7^;#8WZ@b~OBMpFR>--B-+5=oy^MX}o9->-oka3Kr^RPU>nQiX zvv&6XEZVH%I zoSZ5fuGARnR#jz|TeD)=pRhlAUfdF+uiEzf*s|w#)aDMJ8QQWvt7DU^VqHgWaCAx} z+I0{u*9q4d15?BlIBotAQWFt)>a)Sr3WOhkFi1_68qIcNs4CfdV`^S*YLT%hAvH}+ zZ7NY=`gys7o7Z$6`rN^rImm^aT2WswxX!S}N5`7-;`!K$rtaL)bHmy8HCM))b}ef( z$Ho*5KlG!+zsTyWFHmGQ4(FE+R%(*V28HWS=*zlStt(El7@`*Jd%A1*`TH9)n^tYv zTi!AFt(PvWt8h;wTI(|%y(fn)xt*J9oEw+q)Ab8k=4+(&o8*kg_*3PApVU!6hor@a zN$04;3GV|slCN=J`UcxI`#bIfq2}|KgpiBSRM(~XeG^*B&NMIB>^JTyAozEm|`|`U>HMLtW z_x4}eT9e{jl%L&bO?0k)q-XKDwPk0FbtA=ev0%|Si-&8CDZ0`{R%@qIqbcpOS{9XR z`MlAq+0oUDR}~MOU2d~3KRZ}F+FcVJUA~~-y7c6*)jE7~_H*2snp!(tTryCh)m99! z|7lxXuG5tD*zI_?rgSkR=H%_$yb=0?N=&)aX@Zo<34Gl;IpHzjJbYdlK`JK|@YcM- zQ_MkR2;&hNoi<7p9LN}m!Ot%uU6%{@B7~FFgh$=+)CcA@8jBKgZ}SO06(KS;aB93R zE0T+LG%n3w^#~+q^_7XX`$upr+|qmYr>B*lW_7s=6{dzEJ48pDTsrW$#@VrasyNY- z8r8o4+0Ky{ztWPmVD+xUHRU~Tyl`Pd)g|)NBzr?vQQw*6`FY)2Yn+>V3z^%u*%a8v zDt?mHGhw+5)MOZw%dKMkuM_eTw`$s)j%&!T#A&V=*BEft$gRRvAQTv~1QoFm6**w8Z)0;G2e1?qjY1}iA zJvo!&Ou|#(VlaISnWD}`(L8Uisb{Dsm{^No!xW9HD_dUmalh^U} z(%X50w=3z3EjpXwIc{?HAbWpN5qI!)aqls4&)5E}P~zRM-~J09C(W>#dDAr=0B2l> z2XP2gCuD@<8IG4?IL{nI_N92HSg4P4O$YsJ7bEz=#UcON#q`PlN(=DNuC6X8oSEG0 zj5G)+Y!p0xENurTx$j;Zbw^84|DMZ(zD2w&)1>IhZ4!`Nl$%mrsK0M2tE)0?hEz;G z(mnL(SV3-MUsj?zjQ@_^ZZ9mv4|2Aqr?xy(Q`fR6cXTo;$CcN%wTm&)<)5e!eq8U6m>0GE!Gp42*QnlIu3K`HJ^e z%^^^QMr70zvniz^=kMo|ONZ-vaW1Lkr$;W@Nr%>S2YDcy|xNy^JJDhHpkdd9!oM?zov@}`O zoT~ZBeG8rT{L;K7I~&Kpy0zS*75$9ngaCWr&f0C?exPpY)!#j^@4e#<{)*_(DP_FU zf8-`1501QX_26*+(&@j6e;r<9Eg`Q412~{-uLU;a2yBKNeuK`L5w>D{AB^Eg5!L~5gsoELEy~err=}Y9iLoaiz6h?wqWvD_vq7GTtPGb z8Fedb%Z6%m5`%*iv+D-SYgg7~_>&igu52tfFWPkET+2Hx=Z(Q^s=%w3v<%z z`o|`%?^-9v`(5do3s#q5&L6_8Okh@m5%*>|jRNt;9o|`h;-nKE62dScaUrpaP+Sh`UMEbm1K0m{YUQcF zT^}Gq^sQ~A<$>46N5@}1(9F_td;zVTwwb)RRyIlGgaa#ygu>;?DiBf|7KF@rw)bv(Nm&-Q0D&SbAN!=6Z%g!)2X zlxXVz(&us?xG%oU-RCZ5m$19sTj}#K3WOO10=5`)Zei?BYYqco4?@gxh_n$v$QS{> zFMa&IQ?F3bX-}aJuTCa2WD57rdZG{&iAkqeCBJy_9}X=#yM=~`AZ96}V@{HA4P`UQ;@QLOj@`QY_c%LuXb+;{v!>y?%ZC${xiOQtRj zm#nN$H`NbKthGL6o0=G`Hxc3&ZB&!$hiQKfVth)Zu~$12F+S9l>7#N`BKe(Kq0+4gG~@1?@D`w+W^KJQsPFHw3P##?~=cwK))fdC0HGVqD+ zJdt|-biAnU9YmW_dPW<{N(03|5v#5x)EpDvRVTxOwR@mUEN&i zn0#!gcv(YQPTRNxZ=;&(al9>vf$tmu%MTf;W6_f$7km*qBH>C!xa5AwrO~iK2NDe% zc=6R&50a-o7CAQQWA^cv#0ZRveVUDO@0xuV&wUHet;TZ&CdP>v#N&vQMLr1(qu#iV zj)$3SW8a?LHuDIi826pPef9VhLZj*csxGD26=zs7ftZDkgI6K z87Bvv4KqMd$RZaeQ|i7VTpDS$26+IqR%zxPuejggUVeqOa4+-DTVE5VZ+(NO)_(@I)YneE@XwlJ$ zR!b})F&63Dv56K-40(|RC1lt1mpc1vvJ;RRfKJ4>(t+x%gh0aXZhZ8__FkK$5T#*)bApSx|6moA@7&nw}GrZB~qKthIIWWKYk@$pske zmq=hjPTfdl#c-WuZ}7RIa-=Ssj_v7|$ItA^`9B-~9B%L5bLO$;?alZ;V5eU$HM6$$ zlgpYivznGouD9;Au3rn=i!W=}+t8~U7~M4(-Ed@w(5q0Ydwd>;geg*2drr7h)oE%k zP9Xt{-r*&3_#i%`&mIv!dWk%RFuB24GgyRR;|=%gpE<@BWM{UnF8>qT>V6iZY+d!( zSoONzx>)zIAoe#zS2a{twY*x3ktWPKNbGWqbO~d2W`8k*gT>&@X50ZXiVCcjoGfib zrj)*+uE(AD)@#sNRU#J!c-M>I`4kg(xwv=I((vf61q=3GUAT05Un~2XUuOOCGRsg? z7I7>c&#{gTcb4fo_CMRvck}pS?p6Q%&dCbv^42^uQNFk&O%YQyd|=6v6T_C0H5Ug< zM(T`-MV$pD9Yv|3iuC-du9c#7s5aPgAjg>j?e*&P#K;O!@y!t+!Bfr^EA)JiD6=NDxE+> z5T#caE6qx;HdgXZmaV$)*<~x9-BLx^X`UErN2{w>)#~9uP0#9wpWD+=zvrvN+)rlp zr5hIK+j=&XazDX)0b}#q@Ln0Ca)#5E&$HehB!jx5X11Snep9qu5cA$5Y5d;Ln{f;7 zuEpKw@G*!C+r1GxhevSm-e54K zcPyhS&F76L#9Vema=;tIg@$f$+AYX~e z{qHB)-Y13WzyAPlJ&Cub%)J$242{@_R}r=y{h_^J3C%|M#93{DEhHz&L2>eDKa*|$ ztmcetpY&Gt68B%y_+mZC@m6Vg??Ctp$u3jDf*Cc)$?>O&a~mmn>aEaF+!;9g7xJr$ z3bg*A+spZEH29z7A_TgivS(LvCr_WodmiOq=S%|4Ui`#^HXwgB_g*iiFn7p-gt!Me zm^*|70}h2Dpq@MAxhj1AANlM|G?tD}CzHZUqy3rJNFg~*| z`NQ6k)QX&V+S_aBZ8>t3G+%zvli&>vCOoH3W(8pKfF%HY%s)bK1b_#fi8u^^gQiO) z9J-o<=S(+s!7vJ#c*@#t|J51GsljFESLM$nRg5)fH<#on!gHL>+4ZB9 zYF4rKTPND;DmTj*_giIEt;gQk*mQW*8DlEeiUw_QW=zSdLrs|K!x$km9S;2Hy)adFjTo!?@)u6kBqdc(?M${em}+(n6n36`V^!M9 zH&1pJFF(?n(^00c9N%A+R()W+60=GzV7xS|MrOY=)DPh*CX^R~=jHL^u);BWUctre z%}?O_X73A^&@8@hzL3PHvv=oyUl2V13Oaj+x!Ln@DI%jVcfNG~9Gk+Xpt(Y%sty#d zK0nZR)G~9`&w6UG|IrD@OiJ}wLsq>bJ6yY|yK%fah1G9-=U8j0gU$JzSyNWgcH&!W zn+~lii8R_ZnmU~x}!09~h`0>#83 zk?Lf^=yl2I>h$Qya79o6lSGmz94=7m$K1rJ;5${iX`o8+M1J`?| zuC2At-r~$%TU-NY&8y>DR_qVkDyRfv>Gj+x6YU^(}|Lv3fz>&c6KS zvQDF`K*N38*fi-1v94&#gXAF{%&(NC!;i5!^MVmpg&^-otsz`%mK%To!WdEnqy!2` zf)=r;;MypZI-Nq{hZHx0R*TxxXz&9tz%rRiMI$)!4e>`Lt$6(5-mF_Z4f!-B-x%eQL=WbWA+5*S7ZlXpARyCF{TPYWghl9ePOE)ka-0-@`^Err zD)HXn^mM(bP-;{uk`oAK4CN)k56;ueLBPTXGZ`tkx}yC+zh!3Ix~`+9nHlcE_N63e z*`uTG4)&=%J4r{)>>tn&%;`Iju6E%K_*yCkWCDbhPFub$AAfCp!R+w)^X%WrtC=MwnWmy5`sN>qF0K=Aj$$-4 zXNL%l3xM)~WD9~M6rSjt=HMX<-srIw0E$LcVewKoJabJ+DH4QYQK;v)g`~47_R&WM z?E|)8MV=)mByVwrK|D>GtohO(D_?Ny^=0Qr?ecXfhb9n=Ip1fu%DKVLfi?R0L zlJhGtP(e)PixR{#H)Y^0QoYHtbbO@P8kqqcV=zbfde<2c62APrR5Fda-%BwTJ5>sk6L_kP7Xrk6x| zP*^`8Jz%T4kV2OvB+eP@N96Jnm*+Hf1n!)a(m!}h3Kd_aM&pl_OQNUJrVC06oT1Zv!LZ#)*5;LvV<%=m;L`Sy@FZj8 z?CV^5X%)%T&pyT%P;Zr@eMa;zifMQu0_#qiM%oD^)DRI$=+wMr2i_6SOG4*ed|uqD z%NdM;j$)#eI+aF(uB2vrmmf{JNb8TZ1s6W;S%1kT-Gw~|(`NXB^KDB>_V6RC+%4== zHG_`$p8DC(&^VUa1pCyAwo%`UVozRZ8(PmR^8gLOM3l%i{|XIpQ^mFy)=a**t-|+v zM&Gcup?*K;#*UZQ*WPWYdi7wRP)g>r*rIRb7a%evC`79 zqmt!(M(pKFq?Q?&e#!k~lM?3~LZA2Na~LpQUN4}M!quhJ&z8@>w`m*Q>Uq&Xzbdk)oxhaFj1rNICRb; z_h-US)|FTL9J&Ri?!P%(O2>{!J$nE>i)ZZbCCoE{Qm?X&E>puaioUpB)xa`FJ#jiA7FE8KN_ z!P8wMSg|xBq48JYKf$*o$3TiY;Du6pau!k-Uxsgi_~gllQV-ipPnH-B67+86w#GV! z>w6v?Epkje)H4Wn=#19cZ7=L9OpGoZs2@DH%3iwmV(-|~>q};MOH*&w)IO4=wq)o+ zl~&hKdD-N`+`Pq`t2O0$$*~y)Ny)jX$)PG+!*F@cy0*;1zU`Iv#_WiNR1Y0OuZ)5d zz9_OXp-@9%aOEgBL&nI~P#=!zqW&C47mC-*K!V0eVv-apqt}}wQsM_4k&*^dVi!KC zL||kG$;0Aca(=Bb(OGO!#731hcBI#AU2GA5N#X^k`1xV?^Chi05%PdQepiSp)X&&; zaFDG(jMo$9d9*DF8YzmYxS>STsB=Q&yKt)fq!xMRTZ-2ry$5+-GIc&sjXEYek7I)5 zku=2c@};&yq9NtnD(l4+?fc=G#OJqIRv$>YdHG~>mu2FajmTbY?p#|Lzqpa}yY&{@ z(+_zaLVNreARcsRJb;oUc@hMulmMkDn%1Cr+CoJ*$$C|+J}8#IMgq#qx$IkSd9*+9 z4*p4e3}=7@1|w5KGZRSk(1DhC%ShNoC?X)T0M(*wzyRqFz*Gh~Mb}7cWZX0BpH7I| z@c6HeU5Sr-^4PB)C;J|Hh<(o8c=am#+^x6R)0Z#1m*WjI7^If8fVl@{E1ARZz6@m> zCF&oGqvYI=#2<=3{D7PjM}Nd#AxGW+G6!#sg8xSBz45s z53-mpb5b9fqqT&4I)kzCM4JZcB}x*5@nVazN!@qdA1VvmCTfF|ljHpp<3v+xgS*h|a`71V-?CzG^nhop;lv zNTAXtFimMjMT{Z_o*-roq-T&E4HnQ#5$I4tG^bL5(gi}awC1_*m#%0lHb#8q;K8qi z>kHdPD^)e^=|N;{?b8S%TTfoBQ&KNl2I)}t3=R0Rl0!0fhEsMdGQi!fReCP5s_we5x*(3 zpuD&>M@4%RoZeOwQ90UHs1M)&zytd;dJgs9LLW9euB^Y2nRR2s)#5Ga!=$&jSJ73n z^T?Iv$M!D7dLT?TImSQCr7$|0#naIsMH!ArQ;IizEoL~7RiuImj%m^x^>^`p4SIwj z2(AoXa%@y&qz#~!g?~7sd8|CErNNbI8_d@fXT#Ru3A_$G{7C{yUE)KU4ACG3G$@FeFW zoPJ|S&)jLYzUu`9FdnEC%19}(iZHIA&aeS9Gv6u9w-*%H^9$L5S)hN zw*!Gl>zN$J<*bR1VFeC#KFByC3v@RU#38fJ2?Ib8aJqwn3Dg^B*apI+>QmHlvB5z= zgK|jDd^E^gDMKT4T1|t5(OrnpHZLMn(7&s8ruS%XP7Z#|H1F=U+=~f0r^7g>%P^D5lv^(RRA>=kqSjbAf+Kip)Nx$2W|}+HE1km&O7mUj4rb0G#Tgs9jku-U1xLG9$PDi$BKaBXYFCGfj?-My8h|>bOvUXp9s;8O^-c#VA-( zqmd)`=$cM~AyYwUCBB2#n4vS0EmLBGu@*UsRaUO=$ZgBDTC-Q(KUs3TW#QS$yW z7M{_VkYNvbwn*~oqm>+-hvM;4i3tV=nyO=uOh*wWgQ*fA^;dU6oxZ~=$x6$rO* z2PCZG_RP$T_(v{k_J6py*}u}+?GP3GTF7QCvy}QtoeinT07)rV-(my1mmR~E_`4Kc zlU$_=q28C{Op|h)m=-!YAqdOhAlc^4kCgh9wCO#vimTut>HyY5wbC!JAc#-lKk{q) zJ1c9ecYJlU|K($gW{P?@6je<$rRm%D^sabjQ(5oAvg+#X&yTKoVO!;lv2n82wYou{ z(z17O)iaw)-M=uk=o*`}t@{QunsxOHN$SFVjGKCnV=L zW@T5|^AZv&`}Zzga=@6gZ?NtDUbB7l!m@%9SBBc&Xv(Xx=fo#g_3n8hC?+I)u-Z~r zm>VA)9TG86on2dG!Jv>N{!{KUYBvIDut_7?pr&Oq|8BHl$V@kL&C6F3)s+#AZ)<~7c3k%aJp@^$) z-fU7VPfoe)6bJK8H@9ThI!AArR$JEVShstOl-KC(`tWf4s3{}vuUBk%Z0VB6HdNsE zlBM|Fp{wXyx8Jq;8;4t40miO=1ND_JuU((fu%e{Hv9d8^!(???qPaS?(^!?4SXML8 z|ICg$*N&(A`<~ifTf6J+Zzc04U@?v^;He>sONbJPykX6 z>Ps${a8#h%@FsZh?C)GieWyE5g-z^osti4@x`y(PJYnjuxKVrfufBW`FV0lxzxAX!1dzGx`ZgI)zox_u-jD+PT%m}99 zu4HQ*3;`~m{NnMh8vx6skU~;~+^re+X3jy@6Tb`a(K;aQ+xZ4W&j!f5lc{&s<`KCc zse_u8vzRNC=YY*CP&RJ>k@J4?KCh4D36Q0kqxD;De7X_O%uKaF7A~E`;@TXXoj6r~04TURSsMss8?_x7F5ed-_2)yE!_$COf||QvBH6 zM>5lF8FVa(pyuA0^LlZF`uKC1Z|dB0@3^h(w*~cK4DNF7Yg|9%pN`l(TnloH`Jg$n z5;_{G9_P`ghrP@9ivvKG8!LPA%-t2KsTJMkyq-$q7L_JDE-q7}RBAHg;<7a={%e+{ z)kb6WQj29tWol|=k2Nlfp3RDj&(TIiXmhBsco-z%Z2nKsi=mXs1NZ`}5w18`JPa!r zSnBr3y0Fkd(3T7(M1q$8&My+A{baw1dc%{K#cgEAWl}o(Dp$^RoDuuUxijS9TOl-S zUc^4cJt4fzBr>ONV6nq`d4w_+BZJVBR$IeH0(kWnad~pql%M~Pu7K*svZti{e{=oX+mZ5qF;*LH@RFh}sH&AJ!({Ti3d7`T?g{r5l3bc!R!~<>&ZRYuS5k&ooVhyv z8ad29w2Filb!c*mT?N+OYNOiGVN0#9tTr5_GE&YS<{lMpLg%f!5lv|Mnrk`(Z3fvL z{=obyHW})#_@#6GcVXx~XsaB(NW159U(wqaZbPq}f!Y)hmBY0tc*9iD5<^<=HosT! z1bmjkB2{cz4RrmmA#GLlW##rd@`$NnS&?&9V|sPzsjLMPPW#FQ=5wT}w4xrlk~V&* zz-%hW&M>#ylM?K0w){mgX@~L`79_>lJBu?5i_9fp;Sq8cOo`mPnG)!;mmY$-qq7?b z)gv9E#lJqL#9dc;m=bqg%Wd6*J+UFBg)+;x?QDKYOVFeT8; zHR#fpFeQMtu&w_g{`mpnTjF1F*(<_ErVq}Cy9`q<0)v^+NgyrWg z5(gw&%43e>eW#>+GAJn$;6)KroJPF^{PRdAG8{>NokT*Pk0VKC4+i*;WGcC^C41%8 zgqu(LXY5#XtQAnZ``F<2y7<;=F6q`=YfGzZ#dP5(v_n6@JH7|~6(Z>)9HBL}l(#1( zF7P^#Qd8ADc#$CPQSXClCAET!>{$2WeWj)My||8`GP`Qa)5AkgZLR*ina=rV>`L}$ zTrD&Tt?k7DDT=30W=NAZMXK$c?_7OG<4YQ6vOlK9Cs`#W^rm;k1_acUe}SrHZ-N5N zBH$|HiHmWjRwCuA7?mPgYM@lC26Ie9Yf9#`vFm~oZ0WJ*j>=PtOmPv$qST~3Av(1< zy>Ei742;&r#iXc$xV`jkJJ|=>zY5QSp%(58y;C*2T&kG`cp*p4Ec@3--Y5OxxU$Q> zeTw>&VeE6v?&_6)Mw->P(tQSQv56XAds-^uKSvrwsmD?b>7$1ysazy zu}D)zW=45NRFtXQgujveRPE-j+}y6swNo{o4>f7kd5MX6)oD}4YD;3GrP_!pFXn%l zGn~lXfR`|Jdk7;XCsWL83_U|}zk|XHOcr@T{EYp?ewNXbb$|6Ep=5@XF$}&ds3*aG z;_)P={XBJJj28Eu5}spm^-VwN?%%P$wV%a(CkfIA-utA>sRP3P)_b20_dP4TMDMf9 zao-H^9bAq3Ub-F3{@Qn+oW733eZQvnVI2Qm+`u#NEet5S7;;V1<){NniD1V=GxBmw zyeuPhkwvASC*6>8&vo>HY%hW$&*dn(a)#Yoa)p8jAY?$0sQmZ{DG+yBh&8|DihYmx zCl=-;mDx0FN7GwM^fRPn>|{sZgUfAMwcVzK^v{`&kytbLNh}S5quKM<4U_k#4_*ouPPO+5))F;{V(~Qa3 zH-JYl4kw@LhDj_Oi8Up;LU4+_86zMPCX>>n)Jr@kN;Ri6CndPLQt=HXIB;O;3J6Q3 zvU-&DQf8S{T2IO6U0(8e_aXbTL+4X+)nQ)#`S2r!1BWie8{>k>?CT^df&5W7-soka zv-?S?QdH>%%f3iN&z>eBITIVU7JJF)m4%nZPqUVfPv$>S`A`+-vXpul=>2TZn9CB7C$Um{cumu!>dcg zf5W$O_s>kOev5UW!GuA-0w2C!7KwRjaR#6$fKQ)=koVjy1)YvFA&!c^YpaLkbt@-lgfO?v<$nVj^!RJi;| zyK85EermSCRMA{vGB4g%Z|rQTh)4^!lvJi@yv)MZBj&HPD%2>dcv1XOm8SL zwryM3w6d)sH?yv)s&%lm{l2!eKxJ&$LxFKop>f$xeO5zd^@5QKLs_A%utcBhGRHoS zA}WqaN0#S`lvk^PrCQ+}QA5$xTUkH34QUZS2q)OaNcc?z5H<)4kd+UF021T`Frx&a z+7%ke`uQRK8O%!PSzT>$5n-AdYCGyW%FBvsifeMS)6;aCq=cC0$OuKKznm!~g<%qN z>JI;rhLe(u=0%Aq#3BtHa42tJKgX#;(GBbyLCo(V?chAOedI)@(p=V@UA?N#@V#%A zI4VjSziL{ru4chjL;Cug{oL^6D|>4z^2aWhoEi8^oH6Xt$XfI6!P(j1=-@cBALq)^J+7(&%B7-l30C%+RxRsp6%8uNhE> zN7u3{HIhukV>Kn7g?-3WyLo8I5an7}m!{Wk9vWCNlbuZ#CY0(Ws>N`INqpK?LFpGB z+J!3O#JjHF9Fdq8O2z4SYyWVf`w=_)La2h$F51u7-A%ESiGfj-_!rPuDtHFi{ecp4 z=kYHfN^lSVA+_Smc@=lFWx-aN<5gH!K0G{lHh*Tuc4lzdxfNE4ZrQy>r{A=seyl3R zL#rrtxc?%5!Tr3mVvbi)=Kj32)yJ!dh6KC`h+0!W6#fLl{|{hb#{)1A0bHLy z$U#Legus+e_Jy6$f@nU$ySWE+K9UIliD{u3LT1 z8CuuJGlz*<{5es(4@vg?9kioe%1}zxf}WjR?q;%458@wlqkN1t%)<8DznALTI3|W^ z@-Qc2A?Kh72@_c0H1|hs_Z$lh)rohVlCQLw)HWZ~C$6K~_W*XbJcML3w%c;(QF>MwbvA4=o|x{C`}eqqPhp>pcYsPgOE1Px6;CVy;kzAD90|C zPKWv)j-5t_DS=u>25tUgo{u53&yv%3n0MA}iFx-k_mkxDpSw4F@*6JR$G~&Hc84Q) z>+8!N9m|&ooTFU4nBtKJ?%V}0Z}6s;>(|MpR-i-`dmd3b2>WM}La-=2 zbj)YS0`UlI>!BbWDU2`jW~IjpeyIaN~TQ z8b$6TCF%)AW0_cm%AoU~RdHH53G$O*j zPtcq`s$GRE0FD{pGRy~!hCKH>ZFgS@ajkICo4oOp=N@TKE6@GXZ~>E!bLRT)L{dgF zjtEHR?xVn*_b%e+=er4M17%{oCOJMMF@tIhByPe2fxoHMKH`t$4@X8(qPg@>$em;N zQz(ajLTq#FuIzhgw6I{+Lw%Kd9ps`oNjk;L&jU1jk=wS>3{e%f-UwhZiFB=9o;3?=++)gDGR0m}+MQh^R~)SPR;C zNDx;SK(LHCKUT6qJ_;WVMEGt>_{8p673Hql@}`O=AQ=XoN(*q_xAo&aE~=G!s5Cme zi?@dm#g_n9=_Jop^42m<9ot*mwr7|&R4v%pq!+CbsgCrD?qr+7R8*hcvCEXPZNY-= zou-=#eNlQEs2kz>;L@NN+i-Px#bj${X6t0dTNP^-WM;Kamix7*H5TjA8^_DBcHf3(3q&OW5P7E- zxnc%BKB#|K9~1*0fD=b;GQMKdIi1dfgLx;BRt->Ahl0F~!i+&(wJs_=GofOnO3M$A zUYRTk@;@ZV(i$f!Z=I#<_dM2b9o~dW0cTM%HcmlxfDCpCZ^Sy5VtihZ;f-if7cIe% zQu4m#OA8m22Av0a*W6`)z#@ZJAOUl5k3F>va+R6e_Fit!{q4qP?AmI~Fa)+QCfBbQ zjGpby>dK0Wtxny!g}lc-HLLD9(q|5fIN=xQ&upW+0(z&FOkN^^v`QoeBM}KGE)SRN%T9K^Xfmlx zdPz~wuOF9^%jQUlsB}*u`Jyi^Bwu}{*qTe8B!f#bQrZ9S<>YPE;j0#GyR7N__TWFS zDPNC~c$U8k|JuxCIWxcwVL}lzLtbDPsN?C=taoB!qY>N?+DD%|jRb>PVqx8hY<&B{ z-dQK>sq@@>uGnh6Ou82u)BYd4M*@BH1wH1#q{wMThss2~kCHxvQ31$TRG^kdX z3-3l9UKTxi;fnir4+X3KR*-b z9{z`w!eCqqCH!&FJG-6!@i8*cjwNC`MeUDcI&6rQu12><4$_q=(sbwd{ z%|RHY6!i$HCzyacg;w+Fm?}(e(xdaJu&<^f#?=?BwQ;V2tt0k6S8kyD9d-FKzNzKtTT>ZR zZyjzXHQTQ*-&ntQu)vYIH6~jhHEYRi8N)7$*w}<>$glIiLDV%h6tdeUC*q(Io^m%d3tw)6RGvS~8MM_o_@=tO{h361qVh;_x8-JHlA*8#4 zVh)jR&_-ZE1C8_1Xy%qLcQmbWtfF$PG5u7yHb2#1(}oMr6%JGx3{?Y#iMG_}=u}%G zZRH>NkJ%4|*MP0kolMb6>X1<$%Du^vKl-3#p;6kI3otQ@tlAPTNBD0TCC!)Q9UL5X z9{qnMk*Nnq&Z=#$w6t1VaTChmf3JOLGdf>FG7*_;_~s=gYq0kO*laZUhmB-CX>^z)I6pLZhE#kUZa^Z51H;328X3|TGzQ&#k|r-n zxEHUKB2KO#$y#qx=cVfn8F3kQvMkB!%1kRSDK)6{aSGuFrq*I@h$==EQkofUT2P`- z)Fg%{8o^0snZNSC;C>{0f_KUpAJ<5N=}~h{V{B>N)2ehZs@cDMEPnS&-FN?TO87+l z6A2dogeQ<^#J%iCx1YicCqZpU#YIZmC*VqylP8U@#OMUP;5&-mb$HLQqzSxEy+;TH zZ?AmlD}b}PgfMv~IxRmr$!d&_Hd>RC^V6ai5<`5fULPB8_@88(0sG}bI;?cRTx)Wi zUKgKWFkn7rzsQ!2O%2Z?2U^iI;BQp54d)=k0vVvz)njOGMX*yNmy^cv!X;BLVhpI5eAwDU30K za{|Rm8NwWbc<74;e1x9{9;UQD=?=uS$iYT8%L5-u?j9%Ab#a+RYWa~z<1+H&3IF2< zg?M9RVz}Rm!A`wZ8^L2wmRW)SrTAaMAAGY%Rma@+`g61A*;T;o{*No-cRnV!PyO|~ z{4Ys>_*V+-xDDcEG5GeLf0h9i=3#zrgKv@l*0;t9YvljNnPi*L3SSnrosI|iB@A{) z2xH}dN2&O%c;o6K*(Px-Mu8|HcYl(-j(aE$NpT+Bff18-g*nKjaUCBjqwXBR;myfqf zJH%uog7)m01evDglBqOaHqu29hTUx_k^*88pY1NW$!!**KmRqZ#~%FffU(IDdLg4( zX_Vd3IcFH$49TavF3o!afH0dHd%>M2|8Z;6r|cE+Cf&O3G>;m7Zf*}Fj!~mNm+nx8 zxkTzv%Ais8kUa-|=CDXInGD`>0aPH&_em4)-P4F`5VOhmNW_;qq>KKQosCYMe)9j< z-bMu2F2kS42ub^?U_4SKV8kfp0HtxI)gJ`1A%5jm^2-b4zh31ZzBNb(hK>qIDbijd zR6BY0ZG%=p6bQzTAmJ<+Xpf|M2Aaw|l@=I&DVxaQ_fhVN*+uQ_AKf7fxKpz$PM_u+ zXSjef7~hL{dK%g$?NWobNs;#a28QwSXqp@H%zfI7`!_S(KJVn?{t4Vaj{AeChfTN4 z3X-99$owKPh5852?r(Ru5CmTOOhVJ07Hp%2UxbShi@4ZXLjFsPx6&yg*tkiA5uwooAs_}8sYD=8|e>hMJ zW-(KSa}+i$6DZ$6;o#Ue`Yvs(tlW61Z>Im!rizM9m-=T!yS5VlsR8zOWq+Wp9B?`Z zDz*GC{95{?r?nG zgONcdU)pmiiMBN%ESP2%uw4z9C3I;>t0F22`;pV3fTvE~T2Iv|GNm3L7yApa|7Pnd z(ae-goLe%p^kKS%KuMQ%hWtDzf6>-vcG!J(-HSWx1I1TJMPU8T7uV4v&07}b2XWs+ z3;NK4RX}U9m^7y$9IMf@FeOWyEdkgB#af>A8H+uRQmNSJtaP7zZ?TywGCT^C1dkHO zyvadHIb+V)%iH&v+P5?|Y--OW2X}}?MCQO))!zTw)YR+u*J;c99ni2vUr!*(e{Y$( zvDxX|d}FHRmiUW=ubJ6wkaK)0i;(E~Hm5baeDON0!MSGvPeK|vH`{1H`%{csoz9QWt&j90 zIa70Biw^*>d06*%swKHkf>a6Np>Y9GNrlO-foSX5k^WP|d3nR92bMi#&2hgRUou$T zwO+5xM(t$HP)R(1{txx$#9*G?uL>@&tE_1{@cM*m>kl95tgqhYzUkUKRMc9^DwFc` zs;c!5Vb31sS#dk?qBnt-7BJhKA=v4FO9!JDi~)?9r_w+RupM3IusIA3ld)Y7BPn0W zlgXi)-Q?Nk0C>UVKfRXH+hXG52t)IPdTm@md;uu@p@?{5{pFqlOIm*5Td_f%|pqQB*w<7V~73!Tm-MG47{?gHD=%2d;lp7ND- zsVQYkiw_HL+Ls^cSbRDw;~TqHURqO_nJZd^>#fJfobFR8MQ!HHg=MLRip7@MZ(2&y z8&;NBhFfw`p#d)ytgusv4g+X>zqGJ*M{YA_nKjQh>Py4VGS!M&`zF}EBYH+G4qk!%dpWzQG0RRa&M8IgEOhNAi zkVO?IC78m@m6zpPQ<4o@fqD?q8U$)|Cxsd{Kj%t?%7$IPmA>369YKR0s27o22Oe11 z{HV(qRZ`QKnpT+`A7$+-|LM;wcHCUI;pLqb)}C!Od{(_RFaQPaX*u-u)vAs&8(I~CN^50{+t#pu zq_}uwe}k%I>vC5RDPDe{9-~Qja_I#_sEG3H69~u@z|drqAN^` z#72^YnG$#KS+G}W4EFe?;TEaG?jo3aoJ4d1dnFO#(im)s%+ZygLZKr9uP9pH3wD#_ z59*~GEI!!%8AwjF^uKr{B8%MO z*@!Cvxg<*RpiYpalc1d_#Dv!5&=~Tb^SSfSBZGKnmGoo_iwr%S~CHejO?c3I~rv2|9Z)iCF{(jQF|NWB< z4JY5<|H;9d-~RSZqTO-x?YEKYLrw@iDL|)G)VD{U8!vWn4D zsy&POPB5-ezr@CnxU3?zsWHLmSnH@+-IOMtM+(rd)TIlESM1D)O=-P^rq0%>^NrY!l07Z+PbnWPW7F3|O0J3P z`Am2O6?AXv3V2yINiww>maWEVT)q@5%i}mQ^<_?VY6?#SLT^sRqgc^ys$%E#7o1w~ zeth~1E~M9baeuR~E?#jxPmnp$E@^dxc~}7$CKXDh_D>KaTeqsJQla+~;YueZYvrD-P4^ltk z5jxSuobFG# zkIA+-_f&K~^xl2#53j3A&aYe0VH7`9+l^6$!+Q~y5oUwfg}RkSWMaTcP$#nla>ar} zN=*nnE5=N>o0B%0GZQoKa}4Kpy^sXUYZoC`p8va#c7Pooyu7jUnzm|1b<31Xlisy& z@xuN6c_g!Tk*Oilnw3l@R_%OqS9#feFY=9+p<_L{ou!^F<|;P#R|W^E^E=842bzl_ zHU}h`ic-xvA@e)hum?|a;haZjRV(IlEit4ZS+ z)1=LwCQWj4ZMytzlQwO-_jbE&qI3M8_naBPB}s2>2{4@HTi@?pp7(j>4b1ct*ehr1 zpMD~(c0!#Mw@bh3=`UXM?C99DYm1B5ULNJMZk~>=URqx_Qe#lQS4RtvRDHNr+k0hS z3(?yIj=Zw3+5XW4%T3+Rfr5g8o$zrp?i;Y0($L0W4jDm{%bA8%b`aclun^$E(N@8^ zCMgVz_R2i8Y0|?iG$B`?P#lt-K-Cf_9TT<*580oz*2cPr`fdpf^;SpEz!B4M zlHPw3V^oCqN5~^Y&Ud`gF;3Vi_z}?@lQu?0B172-Q0{>y19^$l%W3gtt+5ro`SnXG zqlEH|SG1-`zl@Pf>zABgUWlXZaQphEnEEv*8n!dlx|F4xA7~!^@R9D^70<8Z2Qf=a zq$qI(S`dTv1r9JUu5z&k4YX=dlVEEAw&+}IXpEV!OHGc64hbTyOGR?Yk0Rl6;l~Lb zK(WO&SZj(v<~^4_JU?;G#s!ImMhzdF*;b@0m}_m=K0haA;PGWPTj|{VP+p4!0KJZR zdG{ZEvF{wybZ#@v8(f@W-8eMEkAF6$z1%eO{?|qpTszk9Ev52{{F;Y16;78s1pel5#(m2GF-x~^N?UAy=7Vi$L7l`@A^!Q*RLxrSwYLp1$VZk7+P2mB>G4IfC zNuyLC*5NSEZo3GY!jy}kcHZ&#+a5wBlG&MwF#$#u>BVYPts-GeJ1TGx@=zI@{LO_Z>UIXTU|4KeM^#|EYc3sW~8#F zp|pt~3vxX@bZ$Jqc*T?btbvy4+!*90JhJe{p7zJq)eiOdEva4iSo@wE3y<5Et+lUOWnY^& zG$ZZog&Q|6oK0{2QVs-4l*9_A)ePx%;8sF zrjswD3H{2;l&4YVrC1#!;9AC=NG>I!Tm^` z&)^UmZwfO z=##nR7+*x4P0`cT(EOZdtE~KD!A=mPz=bM%Bq&BNE^@0{cX4#t3tK8Gw!E-x^y0cI zH=A2R%~0+16-_2^mA__cS~0zLsMhSpo?r0P>eAMSf3$_=Z29p+twk%pv7~l+V|?kd zN9NWZYwLPsc`32vk<>#xNutx|P#GPW+mKaIS_OX|%Nh`-G(U}o{)D5P{Br#7J7;ccNl0kf zG*kM+Yb>H-d{$&&RTgZbjLw7tz=dNA%qWHNM)`_I=gxg}MVTtd{)a2lC$IM(WqB-( zf7+gGzmh%!h2sXA)6n)UX!~xoovIjhIk*BKhEQz^3{E1FZy3YMb#W1J@(6;KID8Ep z0H3ZR?}|txsvU7+&)rwSMXS|PjiV?mq&QSLaHDlZAZ=&jivemm{s*VtMAul}7gtg{ zcXlQv;Xl9q;>s0vDK)*^?5Bw@PZx6d751Zr!^b*09vm%JDzx0ZW&dkpO;>vQ+^PiH zg{5fD0QwR_ITfI3VaSgpvuh7qoa4#zNJSSWs^4l!$F;JOm`dDO-S+N6hV(ji2pK2rsM(I_2f z{j|m?end4>C`$^q2vv1N4ji(iU@lV98=me!D9QbzB&jG2Jo<)(1z4pc-3Tj7&n6S3 z#Bh5Na~pq-wBzT-5gJ-Uzc(zcF&Ju=HlUBi`NI$Obe|qA!jkf{Ur$2)=$kQs2V_X8&SOVE-I%zIwl-T!# zz3Ac4;-rc+ohp*wxxxOxv=S?OS^7kJ5T%TdGfB7f!e3AG)n9zZzyDY1fu-Um%qun$ z@+dFD(}Nk;xI)oD4H00Tpg*Zli(QQ>?18nYu_kXjh0#W?4eF+*1B4bJvH?*QL0*_3 zazk+NM&|Q{@BW&3{`#Y5%AYU%#t(nPyne;+wl9;vKH`65ALIARUnVrjkuFT*NJGTA|)6Jd>!!`>8!%yd85-WtlbcE>%OF5Br zuOQ@{+ROyP#^JB;KXt;?yt=t)q&d|#eaEHVu@`sLunkMr?P%Kn>R8wF2Rg2%w5+UZ z+SHkn*s^Z=^FkWiF_Ja6A}*$McIKSJ{n;4<5B2pwo)UjtM$?7GtYp<`Wiju|`9ea1^AZoG9Vt-nWwGe15zRKKlaHOX zYkqoM-zsA!B>CQ z#!s_7;CJ@sIfq2}gQP+(HJ*7M<7Y&_f=M0(p<8$uNjm@=U^esu;KuV9O=6)Xb3rL# zGBJ+M<8qR7Q8e20C6!GnJ;w&JY@t~*b7mYs!Bx~#>h$pQa!Fl$ZWVh|%AU2fJUl#z ze}P&6aD~{27Pyn=9YHKKfEEHi?SL@S%2tXyN2&zM;wlFm9RT54 zvaG|S=&2YhbQbG^Sf)Wr)9i}U-t=hn2ok&lYJgTvUBc3!f2qE{vId|$Th0WMhfaRS zVe6F+zcV*?Ip2e7ZD0IGn#Q{D2A)epTd5YwT~gKID*h^|O2X?lsXB3vrLo@~7=C(L zLBX=8ho$GG_wZ}=Kxsro=|DBa=u^a^-gRosilutaem%LNKRc_p#*BG8fYwkxi5Exp zgQ)ii-+%;8-Ae{G+5#W^Y+5pqs2YtXLZg#U*GN~KA|SKC*+qMUWa=H~YW3`{`+L*U zdhhQ-7OcSAY&WIZSq&+HfvNSVB~~!}aF^U3+CH+FPoCbFoi(q9%1%;tZj;=mP#Gx# z2`eJGgmfEutIB%^?eX^Z4)qSfkrOHJ)G6l;$h)Q{ zv_vw|k#h>iY*;-rQ3_+9Cd^zty>h6=h%Eimbr+W``}X$g>h0fNw&dcv zQpP^0Thp3i9X&a>$p|rt ze#&R8^N6s>I+m{k2agp(Y$h^1U-lMYD8z+Hvv+p1Y;H{$wb@3^t(#jqcg{|-HO)^f zjP>z}Elf%)kJs?RhBtOrRqcFZ!)p5$tdaT0HoUQ`s%qC88~AJ2lIQN7E6wWKH#a$X z?mqBEam83Ou~;+SP;Y^#hTe6LDp=4E9Op7*lQz&FS9`~ioX}Dg0w8sePf5BwLUsm^jnDZ}G!K~_2**$eUbZxF6r?0m95lHsrA!x& znhr@U$%W2j+G9>N`Ut{CGmzQc? z%AaGJ?~DbwX$=-r$=qU7n4g=srDMx%L_>p*WzAV#rmfpa;ozNh+OpMiV1fwTV{sT&v(`*4I;OKQlAw2H~?th*yu=IKzKk{P#|vB=v~!vbxc{lOq9MR zB~oV3iE)4?4Ue;@=0CoysG}vLF)J*0@$oL{9hNaNzHC@}=Pzrw?O6L~F*mwuq`rQ6 zq(^E=V*+faSoRa?>deN5nTKi`njp{__dME6Nb%dyacH2TTL2x0)d6i5=b{*Iue>UZ z36I?D!u33tDScNwfuDSE&|%F%U^1SxQ5j(#R12aA2u@j3h7E%i7o-iuwLWxy1Wv=* zgn5+JQ95&>6~de(t@I&tUaZ!|hqvwad&2H{){niV@($Dov8&Rhqw4ART^nadZqAW_ zE0Vs;uJfKVk8K-C;QvKEK@dcI8twTC%{1t1HB&aBnKEdm|H!rFee`o?lD_!Ci+Q%Z zZ~s907mNRx{XmMRFFu`jkw{_2m;H15yJ*7>#9(TiSb%?1H;o`Gdr~)5umBUfDPaNR zeV{-{W!;qgMs)N{VoShYaHndzcH>4v>HOl{f$7OMR=??d*Zi|9OBQzak5zAcdVcHPO}F-0T%M_ zzXxYex|l0vvbXL3w7-X^V|$9Rc)FZjHKF-pncuGY0<(J$%{NQt$km9kSEW%FJ#*i} zOu>{>Yxd>kE<7T=edyOGXPjX63y3*mBbAWyt}PevQ<4RcaMA!s9n*3y&WmX}KD)zGh_G zhH}KMgON%~jM4{cTvf2%F!^`~!Lvi)yb0vhfpXds(Uc>wG9@MuLoguV9W8nsSpEc-daV-RI=42QQ;Lzi}vjG)lKtiVEDdw$ma!D)meIbm=9As8UN5Rx?==6e+y7C4sj5$CgBI>yFOkt$WB@BS)Tb5<0jTbcBjC#TjF#lxa(3iSkJ( zAAt^^^Mq`utSvrPG~SVkkz9&?Y(hUmr&mT_kjw@6uj5l2dfrhJKsneM+_nF-Puq0qez^$%$>)@x8 z&v&QVYm_aDyMyg4ezQwj$HrN6c6B5qwC|kLx}_sky$NogTpv@JC8Hd=y{X)iQ5NUJ z5BFWzRNZpqt8VuxuH^)~6Ebn8v#J1~;7@I?17 zgVO6PtV4P+rzIO;-y5&9GvKxUPVA|LRjXRlq-Ue7sXEq=1=z&7o1+7lRLS65X#T00qN(eri5Nob?Sh;n!Ej?4TRFehXS z15Oz=g=`(f0x;qxDjdP{g@~4(?~^`vRPJI=oruiTdAo+`LS0L$m{P~<#TRWbEN#-q zj%r@Nw14vCvo3Cjh-+Z5vwfnpmuNeBQTDp*$C{$5h05MQ$~Ee~gA3RN9tarOh|3{F zM<75Mn-l?#5XUaTiutm=A(Q?(8RI5mt&X@1uzx6t_cb*P8C%MX!}95onkR^1mm_ajJ#6WixEwpI53=`yQq8sRVZ*u!7?Qj8O=o# z&NVtl_QYvq=gUL}0*b^aC|4-gjvvJJ%-k?k(l8zwIbL5n)L=$A^1O`va`sYrK}Ied zYvr=82PzA*oCM1_9YFUW%Yi(kAazbIe zR{exa>a5jGtvkEYj$FASSbH~pZ%<9$&_mq?y_K<<3l7d+aP+}B4=l)xF6}97J33rg zv-^9S9<;Y&<6wLr#2(ryqY-y09?w*6nwyK;w8>-yck+KyHbM{Q=BDl>ya#qKtPR;6 z3e77NL-ghM$XC0$$zvibY#1WC8R7`{VuaGuk`m&K#8l-Eq>C~_axGQ_b>O$-Wrwdk z6uwSP#|egX(F<=#*ZF|>(qvs)OaQx(Hfzk#)>rx!5Pc1t%r;eRFGFcK4>5xSDKz_S{uglJ-!@#7h_^ zcWCLrse2*K3Wg@}{mb%{LSZ9@m7lZ|m*bY7LBKzVr`^$^= z=h@bm*%hgZzU0tNI}hg`q~b-6 ze?IP0BC{IDyilcd6Ku^BH)=RRD(Vx*rqzi@Q! z;m4ssz7CrP?LdaIxJ^DujO2R;Qw(Mr^Gp(vLMfn1B7S@fi3`SrKo%Tlj3ud^3VedE zUw|6`3QDycApBhVUd#45DYn3b(xkF!HlZqQQU*Ups|V5Qc8s0@ zD;2d>LW2-^xmU9>g10rBn6jwZzDl!6@k3G#wHgE!a-@2))dBEu>Icn8>ydsfT$FxQ zJ=z#=WBzdk@u_ZCRR!?{asI4CbxAt%livNalLhv3DN>a(XHU;hep1?=sr8lW*$dMs zzv?IaY*v9bH=@myy9#V42cBS*UV#yuI$O?IgJaYpK!l?uv~uMCl`; zsAx+33>rm509%G#hFFO*#AaY|@?d|jE%vt`;s>>SaW(zgm~Y>RD9QkT%>FKW5bbV4y9xaq zjj_fG0d*?YLk$O~HCpY;dLbKK+KCSl+AFdvWe=M%RxYuNR{LxGpH8Ae!t$n8!XBVc&6(mHst@Y1qoS$FX zU8swz?9DIlE72bn&N$doc@IUm1rU=nt2V|wfCd^nU6I!`St8|dsG zEn9xNGo_)5(ZJLu&8bbnz^th?Wz8vxHPtOH-FrqE7=Cb8tv)+Wd)(BB!bQss5v$r8 zRyLdO8kL}g!dP=roc~cF+%YPG%$U_7M?jOnI{>{990o}ARq+51JIn{DM?{ecofVpE zN^*n_;FzM6qPSRHQbbZ>tSelv><$J36>6!gAOl4gl|DeH!b$+S^}PwP9Q*?}UjmJ_ zM|euryy6L3Yx{EMsiRNsh-GESIlwKQXjMbZ?%weH{k=jTT zBDji!`8L0cg%N-es*47`3m0UH&vCL}Ilr?%mM^MHaIv3uNvK~`R<^J%$%XH7O{`mV zk1D>>Qp@Z$6%}h{gTpvD0bclOu9D+lfh)E*o~3&=Q@>UplAdmDmsEkhf)a%2Kq_)H%AuH zWILe@PHCsCQaZ+7`5f&e<~kYelcgc(A7P&r@G~_vG${CD{FeH#jF14e)=Qu7CB3p& z>iag!U$B6W*fh#qeBn#UFx`9>QX8BJQj>TUD?0#)k}3{pRxLmYDey4Hz}^8rgQFf( zI4m=Y9#LFUUgPp+pN)akqbNd5U`~LRW*-Jkaq!%$gtH*i{k*~YHExKxIz_cKu-hF zt|C|u2E{Q*x~YQ_YK^&l$S|+>x=!|5PLAK*)vsgjJ$`rlF|!QyD`&N=u8)qcUp1>` zRb8~u@45J%!{?*M@Y(mfhFKC5Eup9=lbM)+?|d9;W}{U0Tg$Y|-g;o>%m?0DHt#aO z`rd37QMJ4p-q@1mTajh*_BnPSAtZ1d-ZY&YBfAMwSAsHffoUhwb9 zqYUmsDBu?<@fH1tgAk+aBDl~f1Dk_U1{Zigma7~ZmlvUGHjXNtibk0dVlm41x(kE$ zB{xx?EO5Lc4fXAB`to}ydzM}_^&S^NjoDFs*-dhHoY&KIs=xXM1^$l;ErcezeL}x9 z%rQx1!oPYI|Los#o=MK{cU6`bD%iF!jD!@{)+H zmBbPp!4^w%lcmm5TTxb!mz@~^reELSXp@gO*`lO)-r+Hmg)$Y?b@=n(n35fPL|SXq z1dvOC^iz=W5u24xOeZWa<)Ppl6rvh7&&f!dv8DSgi7=?(4W1^E?M zEAsOz80)BiA-;ONyD2s;hizCr6jfAESXQ4XRq3O42Sz6ct1hI^f1ulLZr#?ATvbs~ zmE5tdmA~Ho!2I;1Z2h?1+%q)4jop;Qw)sOn{PpqmjJ>cSF4>rwrjZWB zRmOJrh38okJt_L#kI`ML`WZ&IhHK$2S^b=&+cGh_wHITt`10=@)yfH5mGlY4v6f~88*IYB+(_~D`WgFHEWps8eE|W?fb0pTM z^&ae&wzqC=PprVd#P+SNY<2g+-n64^)pBWj&tP{OvD&qD5B9Lt%U3aW&M`+ZOBNZE zIWEQ*$uoo4+Vmb?~ zvC4Ib>U`ye?N)zRSJwpB1e1|m+OZ@BWWS;w{LDzNQhmL9n}zc4GwGL4N`DsyztYj| zA}(IH?l)iS>;}~&`MRMKQtwyZ)_bF)_k?zkv0j0-O8?=OxV`^z&i;ShrC&D!NUbHm z;S>kIb6rh1(2+QCm(;FpO##4b z?&n=kZ;_cXUP9aRpb36$+i|M?J8h5n%Cf&TzPS{^TbHeh1lYb zj5@H*L{==W??c;1e0mOCo)^F9)x&KC3-`}RZ7V^}`tI`h%H6}|XSLG6`PJv}_7B9f;9R|lRli5s z>R`FR7Kdhn@J>I}XeGZU5KeL>dI2UG$KScJJN`^%tpo9AuuenX-~LxC2wk0j0LD#V zv(PmG;WL2=4U`S&fgU0f3{jw>ka~bIOK;Q$uoY(+AIi?C4C~l$7qcIt#Wz)2;eXTt z7!K6Gpz{LUqcDD%AS2j8azke?B_f96CxlsAoqwi3$Y2GIpN`xy#!*Qi4|i8Jr)8jW zAm}ZX?M1;A9I7&c895*m#KnmyyiNZ!d;1q$)<9pLc< zWDeE3=mw2^gON(F^Y_)|KC22W9^bQubCo`mUR7^VU4Z>(=8Dm)A>`E^=Ps}Uh2^U+ z5@P@bnu#MZ;CXXFO$}GI%5@|brS{xa)7;?M@feBL@W`GS^JiipqN<|0H;B20FswGW z-Z18YY}cN=gzE0s}ej#DxUzs#^@u%LNJCzB^*Z!&8cuYBQgPZ ze==d|eZ@6*-}Uc&a*rqfcQ~>pQW~;!BUC0aG)C*jex}Q$kFs@3>7x*QRIPmCxCo!P zOK;!xk$w7YkD{yW^}0KsF8xfGRX_Q{kmw+Hi}K0D`vTp?znQ=?7M50S@{bPl^$m;m zmwtcOC)4iy#Qyo6pGcp^m#2n>rIyG0gXtJ#DgLUEyRP{+p2LACvO(%~*{6CP8*Lh* zTN*A1sDUW>wuh_{`p7`!K^vpOgR~H+-dNRc$jJhQZYXjg$T5%vv7x01Hc!;3l&k4D z+2s>00HzM)5-;>j{TzWlxD7egG!=w1-YWV*J2yiZ88m{C^7^t=0ieZ*v8Y&93?A;H zGR6o-Aim*NaS_BO_to7MxgPiruk&Q}(%Bp1p3-I3Aw0yh;xg&SH~+>kaK*1q!0k4Kv&RJK#bw*fLNSJM1Y%IK3YUEB0NAN`iUry0nN^g zlU`}!J8TDf7R_X8->9UReaNdQj_4QjX%#%K05!S_xiHXyPtoZrE z3igKzX{Y^)>LQ=J z0|?C(9WIXC-_RngHaXV;HZ8TdJD z+1%sfaK>2k?f+8!czJ2%L@laf`#-HsN-ZkJeG79^slJ6HPOd)93v{A8Rv&so2x%z6 zlL%tKS}X0QtNagb3O_5A`c$5hs#@~@JB7Q3m{75%uG1E@QQSmA*`qSPy~qy#IhgA?@tf%FSG|cqbYK1^#Os-XzqZ|mH%J>k|5$pSE&d&4L4a3FPJ=)P zX@0TI#1rU~Cug)q)6zmToO;9B>FEm03c+w?CY{S1xItRivWL;k#^C3;c17B2vk4LW z{l~ue4Zp)q;uTLv(w+Da`f=~4DFO%jNPRqhb~$)YnOIVn8{ zy7}COWDV05c9+^D`xSm%DCTDz`*72yxA)XMkT>{1>)d0D@_75aNp9$I=~v=f^i4a3WM!i(>hD{O-v|r^t%fIV72QFk1x2|nqvDrpVT*Fw6Z=_FW zkGXNIs%os!%xWtP4GB_7b-5+eJ0dwEGAYtq$Mg}Q;eF5ib{o^K{I7>PPnIk_+A4jv z;Nq5gQS7|=Ae*t^+3gK=+kjd=w&yQx9-chg_`R~y*T&hR^3@&bnVl<3ArWT7W3d~x zN74}iM*p)?Bks|E3Ivj`4Cg7uI}?IUrhD{XhRy~M&|JwZq)L+$a9TO<%F+8QXG&{; zxVz(IdCr!a;paA0rp?~m7*`k{7~e2jm7Fkl0m`h^)~;wWmlRI#k-VGg(=(ZmJ}oLL zH4>DS;bD=6&Qm{Jo7?-1O*JP|XN{HQcb;5U>M{LC8wWZc8Y`|`{n+fmrLWcou%`x} z$jLc5cQK`OnX2aHU{B;AbNgG^d>T48D10Dixif%4ox9nk1Kx10i(^YSDH{UPiG*PQ zZ3KvB0pD2#)qJr1=}&CPcE8^ld!;JqC}T%&Yyc&Ok^s-R3}kog#;W(e$J>ADmq>mw_V&8FTw(AiuiTb?sJfQ53_)aH=gt%|%+bjNvsP zl}O>Qf(sMLu;4fJ<*RQwGo=LZIT8DI5ueHg#k^U_r`K z1sdotZTe!FB4IF#IXDt3p(;G)p-~tTq#X8!!jNFEAmmr1||5l=V4v)*i-?(Gx3EEJ*7U?GSA2Ot(FXJ8gU2sr<#sg?)W}@s9 z6>*volUX{7{>sL(AENKh{9_{9BW7JC8X?55eQ9%PaZj=SFw4lTSbe7J`s??Q zlIx2tfyv&Mx;|+o`(r?QYw3nPfo%_ewDEvPQP=v~nJa@2{QA*W>ALibYxi}WT2bK> zqVu}X!w?ZDw2d_E?92b#ujQWpLEHwLAr=wvS=MGxtSP7@YSe5JqU4?;s0ge$yw3k$~_-jsffs_hpsFLCg^1MGyI z16UKO1YNYEWu&h|t%&mtmUZ*nPki~1B^5NCe|zzhqxl8PFO2bxLuKJCsdM+7k8QSn z?7@!&i}##^udUef_U`I~xq}BgdQU9Ow)bJF0a%DvJu69KKK8JV1D?|zeEpT!&M==j z+0W**H{_F^GM)WZ{ouw&-W@D zI?|ne-@18z2Ys@uJ7qpnXErO^@h#-R1Y{lZ1E@y8lEv(7S!8@VOY!`x}z>A|1rtf}nc>T5YMM9Zz*7muU_L_p=WZ%5j z(M}XUPhj?C`r1{hSG%74?7koG`O`B!tmV|lyUxy;_so_$pWq1E=aGS5e0%!#%e@^l z4h|Jzd=H^7%^2xuF5j91ql-mD)r9lHnMa2T5+aX!23U^@sue@+g!;us`^VvAQ!G}C zo@keyd1VthbL8JOScDq;xUhgp3l^VSR)})t3tOMs+GLBb87!(?+7SQ5sr@iomP`I> z>+q@heJ7XXqz#-N&grc+o2usLJ+$vA+PPC&CRSneqY*83m)nUld1z?u#T5DAR!(39 z#DUNeVJs-kl|`?-P(#R1t#=&M6PJMwU6d_xxpP6pyfF>EGCQ)sq04+#rxNVyulXubOHHimK45^QZR0Kw2*Ox)u*VG;e{@sU_LqhKZ}{&3kz7QSR0) zt^}(a>zt3}o`G}7$X>kV$rV8*`R*+^Avo7>GIp`(V!9buj^j#L1-SAz&B~QUx8}=N zmf^~2xbn)a`M8wMsUB?D6wIj?c1?;kQFcQCXG)VuMkN;HY{e1c?agsS^y24(Yd!Ty zv4i1{qf$>N5uHFt&>&5fv=m{5O$u73aEmQt$iw znn$<^=cYn_NMUiFSBPK{*EJapMs18nMvXaZX`&NqjK9(klr^J%sGZi7lS^ElqChI( zU|bMeJ6fADQ*UGLL7?nQ1LX=I5q>EQJxgmDFMYB!X0Wnmq%M|y6qs*HDw1ho5|gZ2 z_$_(s@XPy}Vhz0JMp$%lN;u13cz^5sx978YZ})G1XHS)|y{N2e{keJUygsg=e_Pd- z!r_)QmJ;V5ucae1>(*~YP&0A4bbg?A34xx8{8WW16_{P@TAFcLdf*rlMJKUOc4l%C z!UeJVXct28k&1%^AtEGk`~>j`LJCM1xtq7ms5;ij{uv54_qOIuE5dVI^FH}x-h+#C z=69x+CuqFFOrhrD1TD`nwIT65pWCygX6G?A8_k_pVLY~NLta;D^s&tT6Z1#Ur393= zEzBDXON$9)c?%Ds+Y8X^9q;b0h#17U2l_Ehxr9N68W7I~;nlK?2fcu;^?_*Rtk!}6CUQ8%U^S$rl7EnL z8F1o56H+P}$cMf_#DsD-LslkkL>Vc}n4FY5F;|oLWqGawwE>7eD{vyhKob2dkTcBZ zv8Nu4t{rQT{=odlzqPJp#YkaGX0Xoqk~Y{wh%N8Q=liwri;bnky~VFOwrO*I4`#_S z@L=EAaFEYX$>sP=J~FHn+EBvGNnLFqNelsFckvsYMo zQ#uv=-)qO0t&x7oSlIlNOLFJU&8SWaP zjv2huN}7^-{Nm$dJvCk_>B*j5%^ggdIi*XC^T*CQBhS}dMTSO@vWm?GOV!C3Nj{+o z;veFgy6Xz8M;WFJ^=)$V!B&IF3DARbpFGb7%>+|vA#ZWi(YRgq#2WeDx0zwa&IM80 zjEZ=y&K&0I8>Wd$)%!3O;D6aaoJ7r7$?7dgPPhN&iu8+D`;Im|_}Ov(;*GA*bYl?Y z%E6W?a)m!|gmzdmdbtfd%*+idy^MzvOh*9DYe9{A(elnBLxcJXWGQVj`A0h3rr|Zg=9DZXCLMgyH9XZ??7ZSoc*%< znNUslz6gIN9p7a(98NkHVl_Z-$PerAIbo(K30U!`+&PVWp7b{~-P7i#jnalEgn7e? z@xMeGb#l{C(WbpB2c-{)m%eQ3Xm##`ro0S3&_vRdz2F)4#<5PCGP*@Ys2rOMCO^~} z85;p>pCOl67p5U)9~=_|eUPnu#oy_`s6)Y${Xm(S^1+}JaKiJ4mc4XeP0f8TEgN~J zG_QDn%k;Ig5>Yp9`re}gAFjRZ=I;xve8ZM+1azJK#TKUC^2rlj2?<;EhMEme&6WPu zb#7w~|L}bs&vhhpbap0=JUN_&9-KxG8e!F&sR!Y(<#pg+Ko21OaW+p-xJLljKDk>M z7Zn*A?BnI429>^B@(9RVdIM z#?`^|LioCX5mXUmyUy^1_g*c;PLf@WS289idhBoOD!pQd+Hv&5pKA z2W1bhhw>LM@I{LcGy^K0EW#o=cC$Efdaj=34IXTBNGs9@n7Ly&qIZm|#u{IUHGc0_ zK?#^_73w%(u~J<0u2!L(gRIiwI5~l2zirZXjWIhWr7A6$8gHGB+$Pnf_#hv8lP)wB(7UIs7*fjAqCTQ z)Wn3i*g$_DZ)nxX-kRV>p|Kj(0i*EE@8>z6lvi$4)j+*_VMzbejL zGe2ke^gt>i!~Gl2$7@TP=BD>OIB3ZpI6NnFc1dhp#oVmy&f@4}WJy)?yT$v2D6W#- zcMImVJpeKk!dww%fn`J$X@Fj0f1pEsL2~kQ;v>le&_Svy549Y|Jg|2~7ye_^ z4xYGQh8!vPPihVP$IkmzTPE(OvyferKHxv(KDpQZ6jgVqQv8Q>|6p>mtuDEL{+#IQ zibE|&Z{1?=;qAY~+re=wUf{TwVUXa{YSCMGL>-RR-+yZky5qw7iHRD2Nfcd#J?cRG zW#Cq~{FedlX?*8l=)ETla6d;ow4A>ckS2hc@K|iU!O#$@dp)H!swoYta5juZIe)56 z9&k4C(BzL;vnya5%);(=a9)Y|GVPcW1uY^EYJnWHrw!yv8wk=KTbm@lI7?b&lSbG9 z8#_$VDp=fXxwH$!fq8D4FL7Ozj_R^P)9hC<0)y+;H72M>v9>4j&G;=3-t&l4GKOmoi0GPuE zl(CK4nP?Q7Zi=Qcn*c0LxJ%fH@Qm8z(a=vcW ziY1GCdrYQ8lPTI19qoZ}Q+h_hyD8~z6S2R$`>ioDtx`}2+NRU*6`*p+naNM?&g@|= z5rv)kxor^)k_4y70Il zclTgZxGpZ(T{?}Q9G}GDJ3jf?JtR&ShM(Mnl}}i5_JaDPc!MDxS%KLJ*?skiCWFD0 z2rq~56n;apU>`nSP?wZcw;nW?a3-S6)TP!n45dT*&yn(vO&NFCUAQ zxmCk}a==rLIi3=0HIN4gkAeG^o`Y}B2g%Dqtr1A7p^~Bj)=&tGm43XB@yK_6PFkap z*06nSKW<}Y?z(VN`~d2k`Z6AuVvfPwv4@uOZ1j4GaJ7V_g*Ts#s)Mg!1$j{%&l9l% zs@KWa5I__U=NKUZWY9@I2>S`ypzLYH3ki;H1Q7z`_t^c1Zqnyzgu`qbrpLI^~elx0HS^q&-vj$luAgyh{`X>B&0tBIqfXUm@iZ5 zIVv>f6Rb(8*odJ}{-iLM<1e?@WOOAaacDkr1_?3VASr}W-`P9i(ZePz!# z{m-e+ud!CM=-Tl{uqyn&h3)yN_U{XkG;frHzZ`z!bgtG~<)py?Wd;hSphQNGC)EIe zW)J_Jg9D>XZmW!4D&XtV(^6BCli(tXBGnfR{Qf>(wB__#I7jp@2)hEOV}$2K{*nVo za3xG2DFOHg=wFDE&I{oY{4dyzFKR=h1E&d3KdE}`l)7ZaW8K}y2D5E(rL8IH?baB8 za-_#Z*3LevEZ>@P^r%aAbyH;J>RoGWeUC29&+gmTTGXBsYOG#VYFo1lbI)yn=5ZMk z*MvGa56c;W&ryLmRuhWvK$%f(IHE~T<6PD5YFGCWtP+vA!OP(8E_+RA_#x2b%y?Ps zEA>?%XhN--|K?39l`PO@-wSR8k}xwP0i{BH{q?@lR98+GQg_#tGeDCis02wZE%^-* z(1D_(c%a0S7rld{1N_Y%!P=(yjFuupyQ`&rO+&d&YFN{j>Dq3)c=JSgq7}q>am9({ z!g>4Jg!~v^H*u?PV08V;8T^*!UA(ORzunNAc_yE>9%nFzuIwHua(TGitYogFKLP(rS*E}bxj%INrV_BJ^wF~J-k zBh#bAfE`6%96AubWjYJ!(aNykivn&F>N=5k1?Yt((t19k=b$=5X(K45i%#1>MRObmeX@ zlbzWY$A_CtfS(=Huj5+0TDa}Lq^#wySQcM+dETL(!fe~pXQT0(E_X>(l39?rf z3^k|Vnt}%B4+d+xPM2;qG(;8C55-Xj98!_{>8;-je^b4M*hMPm`dnfRii)Q=qP+mc zSSbGQ+^y706u5%^EMPM`>Zl4c7oQwVPaVk49Y{u^ehrjkW5$99y0zIIWq-_^o)MC0 zD#;5dS#frOmxt#?FHiRKmOZ_hi8IElO!ZGac7H}|*-*WSFEVy7TAFQcXdGLJnOP;v zqL+c_jewUdhh_$D*yvy{`2AAA{>ebsl8#~;N+23?WMcfY?mC<`P?GpxUX=d9E?!_B z?Ba}oxcpdSL0ka78;yCUfCZwbR-@5+itvYN{InjbA4%_m0_K66Lm+usD(rtQt|~Jk zBC{&)`*D?)hzLt%+%&B*+}AhUsJ#{#M?b^`+ONar;<vI}!UJx18lQOFD+*~r4@Xzw3O|)e?}D$*qcpw z>T$@fL$HjATif-yD4iFNr^YxCOBs}_KqH_hBGf<>7bD59_l=DewX?4FE_rEhv#F|g z*`|{I=e9N2xvV`6b8YR?fx108d|2jvb1en!$KM&N+rPHI#?*Z9^|55>N_rm4FB0NQ zvZdcMU3wu|Dvh`PSG1`vaLMdjw}SZJ{6eJHF! z?^(@>_}y2&@Aq^cFI*HI@3Z24PssPZa4Xq)--pV5<5Sv4_t8A9$Ih=%{Q={dj}c8Z zhWgMvLFgpGWuT_WCM3|ERk-gUTu&1C#?G_(?j`d#*EH;?^a(Gj?abZ(-VV9E&FJN| z#i-)6v3>*`>(wK5(^2L|9aptTkm||@FRrhQ3_a=@?C-wz|4AP_n|I%zE-q-=`<Vj@h9pG43g7Fn@q$D#%vC9r~TGkF4KeTM2)-`Jzzjh(P3E&mP2E-Oxg~Me$ZG zDw!~YfYr_i1qQQyFWP^x_a8QthH4rd6uST$8b5(ISh$bH6T(k%GM6H0%HZo<_ZGIe_fDS;0Lw68e6ebQ{WMMg`xMWe|mZ^@`! zSQ$-KMKfBGRN- z#7tcxKIdyHx6IE^sA!3gZ>dPgpTDItzT)-|DpFcYmABfAK3?^?RAWSSZ}$^cZQ^=StJ` zwCce}k)DY`z5OIkLMFc2#RVGWJ^zM1ax`UisWH!z9vPmJnjKxbI@v8{bxBNCYKkr* zBO~945B$IO3SqFUfw}vK`gjNXxi^%ZFP-l07wqj5<`3$-hO%3w4Gez`^F|2ReR|pX zvIaK|{u+wEHXy8wRmwdFZu4ST(dx?{G;&Puf^;zpVj}p`x`4aXMK8wMZ_aKgk^ao) zZIp0w)zqhcD!sFjJybn0J&)Xi?0JEkCVGk|V0Y^9#A{YaHAJxQ4Zf-yK00(~1g9rBQ*>&Ict(!Nl zU%P62>B!LH!GQ(yd%EY$Zky5AP*qu0oS&DI5gQTgHH{BMmt+G3r!pdnnHH@as-o~3 z!7Y4^^7@AQ04qEY*0dV=trmlFsRc$O;WJ?_;5+?n2#BHof|4c>VOBzG;8&TK7T+~f zf2ODT8w@IFkT_xGFBG4i{P}+>a&jsvb8^a0rRRi&X|;t}{cUlEXcL*syq8WJ5|N&t zl9-!z^Zvrc{6|9tH?E&sER>!rkjleDL&GbgkCz^|Up!W-eCtowM;50`O@;D5;g1FK zKRzb2q$IPD{MFp7Sqz_ian$58wDKf{-3ep9IBqgRLzHeXn%y3cB@H0Z! z!`3--tPjtTQbPg*g66RItn$B`WkvEozRyRS(7a1}VkZmSDLvu%0Z|Ih+kRcV;5r5w zYJ%TC6_V83>XDI_l9UkdCnElqVx9)x7`OwWtT|Lymvr)dV4R@Hfihle2uK$&y7a}* zY%@^4I#mqau)sR|Ho158PY0?+AX2n&OR~qk!JY>G& zr^27rpTn0ydR1&7LXudz2BQl|d9oabkBrEWX}_Rk$wrs(=ku?m`1A@gh_IOJ9_#3k=ozhU$Ga`r@>Rz>q+n5WO#C@lEkl z@v8bgygLN%_H~*1ZmqmkfG)^#%#@4p)AO&tevbKF`(IXI4Kikgsn_h$#-;`{@zbkU znR@LO6^io)`?$<*snsV(_zC%VgAaQ5x%x2P;D$GVH*ZSsRG4w$^ViOudzD2UllJd< z>rL^~lP8(FjX@$YceL&AQ|<)hN|sW}$Or~L`6x5I_SCu8g{U{)+^hPX^abwyrevov zO+g;<)9NSS`v}Hcg93qJ!o7~coWxxbra*Wa)D%_K%Vs1V_BUA*%ta>u8At!?hpLNt zfIk>3=H=-!O<2H{-(vgUU|Q+7;@%r$>=||g3z>T#Pp`n!QSS|?TRHLupbAPjI4I~Q>fbnn-|!WO zhH_j+T6|onF*H^ehFgR6uJCZdUkv&Vd1LAbs3sZ!Z&Eg{F@}66*uh4_`|RQL!Fer3 z(UCAiQfC%KN9E7x9<)e*XC4V{73SFDmSpMopT{+qn3HNc%)b=(dg)Rs=N6>Y=b61c zlFB=?3c71DqI`HiX&cj+%hNNeleKIw^EFzNGfKhMiBomSt^W}-#fPa!@J$1A3PQ0F zssJgd09}Kpj8d#+%rLrHj>ChTxkghISlnH(Qys-ojTS{q(;A5Aip)kR7_d&nOpoUE zkIh{MU>$@bE=eEt|PRqJ~%(U?a1rH3zoidq&2he z(A?!4ONaN@_Eqm2DQBxz)7(Cf(Vv0Q_XLuRB5wqnKu#mUco-t@^0az+ahzAESBS4S zF7wc3^Q@i$bihKFS``=zu zwEpe=4NUm^&Bqw!uNAl5KbT8uF^A+L(SSC%bCg#EoQ+Bldmx(?PZ_))lvoP)7=?o; zA_P#wkzI)|;Whiq{K=bVgjpbHwDMlk&ZFYqb z9cf)vt7j~3#`;!$MQLF$gKhcFkyH@GwjO?DPyZwj3PPOE_{ATQeV2nzPQBxDtU=}?RK7U%iNDJ zu0G-(%osfnYeshb5_&<#5EVF*z+}&_#xYYC%+-&Ctp88ldj~{ybbsS_<}SO41+goN zOI7KJ6dNE_0TmGytRNO_Sg>oNu|&mQQKX5zW-)dX(|wYd-eZcI-eZbs!tVPyGj~^Z z(dT)--{0?#_eIa%GUuK-bLPyMnKRA&Bul7$iB1#IZP-_PwyH|S%nhdOvfNg`0*7rC zTGNTFnghwgz+ep$?Yo!6(y?oDa~=6;ZTqTO{X|h}*DIVS&fbwRVt3I1=PO+kv%^9% z`gn8Q#@g@3ZOac29J6&|Oipxr&%Qad?&S=)Uyit%P+TrpOo0fUraF+%}cIqzj$#4+wi&Biz~OdVUu|cB=X*7wio9&Ee72;K{pe0I^Wn0{Wi{{ij_MI>mJ$2E`&%nhhcC z7`CDE5pr|T^Dg8Q&v zbFzjNC3!IJQ}{~R*s*lc9!t3?S#ukIZ+ zvh=mNe9-neNy$aq(~|~npPQUKX9t~_u?0C4h#aCbB*kvevOOo}nWK(C_QM;jDL_|G zf1J&HZ;Vvba@@c$PHn5+c%J#2i|km|zK7C$;zo35UjM55;*XF~DM11K$9DgPJ!6j5 zZDd`CKRPZpH?3zIa|aim&Dw>fMMMwo-j3?7V&rm9$T3OOUD`Gnu*ooYg~(;JH#tp% z6tfAE)b#kcKE0zNXz@p@7J6qaTZjzSL4a`h3ZI&7Q}b3HI`of4rA)HMoCs@i1 zpkA@D?n!$d8QLX2JAZO$R^`J(^p|-^dT#fwBh$Ng?$IwHu6^>hh3Q=rM^2a$HvD+; zNc|H$YWUR1u44vAFn^y&|4wb18{EP(7Y^?^CMDdnc|gviBcld&Z_~U>zc8;JT|2dQ z)whTmwrE)IG09<_oB8Jy=k^^M-nyA@98!x>a@`b2A48+qZw2+T0&{S$YLFI38!FaK zF^_n{oTF=kIt;J}S)-hVv;y#bd$NQ$Z2;pTuv~Jp4_UFxMM_R6`>dr_EO5ktOny{L zoj>C#3#&$ru3C`H*jdJW6UOz%q*Y%`+}T+B`?wtik&%;j=BiCtEyNJo(sy8K)Es5*+zPpa&So&mSJFCQieej#ZdO&(O?9t<5;JU zu(GM-WAg{*>mQ>dKkp-cojqQ6rS2@dY);~7T=>^$-uWX!0v+Sp(*{2dA^>S20+5ES z1l>t2|{E`)qpm{!RStZ|Jy8wqxKdeq>B_&JMg?koJA<5L;R7(vi&a(1F8@^);hc-Oi z%or5t;@C-PtCsqwO^=VCKG1(FOG!^nW^d_}QrrW384VlubnuUKSDj2D=}{gYQRyL} z2??QYOLc8JyR~hq&rDCm2BFQ>81RY;Q{k_xttzEAZi-4(}E+&^@o*7W+q*HIM zW+kV`C-h8ZZP8W^70vva4O zSy9m=+xu^d9^R|7d(Z5?!M*#0$M&EUdZXQQ1d{l7qt)n!TiciS9jy+>Xm#Id)5^{3 z^lZ^S^Qs$eEMUdaqm#Ja+=h8}M2h2(;thxeYiJ*LW0YcQ^T6>ZXL@TMSdmJMcad8M zRUsSJoqcNF-=Ly^k8*8CRnQ_l;o<-O+WLu{9WPOYCTZhOAs4>yF|ex3EB0S1gs&Dt9!BuxV8jjP5=V1?CPY z&Hfzy?F$^qu;l2H%|;h$ao zc+bX>wy-fm zZJ`YoxO>pr!IIlFKu}Iy`rMJXQA50f+9M6M2d%|IEL4D0u?LKGb0E>o#uCc*=s$}^ zSbCZuMx9Nq`F3wRAhzlPdpWCoacb(KiY)U_tVP)9B!B;;(P6khXHG@KEf}Xgk6Sas zA6o#m?LIInG9#iR2?Bn6MXcq2zzL|dIfs~mEw&nw(^SQ52$}}L7*0Sve(_=%yZnyX zjko(;Y%*f1x5vm^7(Jz)Z-_EJuBn%2v!+leERYea^JvBgtRVF)XofK^I-U=)$`fJG z6?Bg?HV9T#%;G@DaTHpTNlvsVFPJv3^rAxVtEm>%2N5l$>qDHNA?)eB<$749(Abz2 z*?mxCr?qRce(aPL8#^?t^@ZnO={GBXL>Kcpf-bh@LZ}&bI38*?UOX_+jf< zm$qF3(=dt4mH+bY`<=oP{LJSJA%kZmkKB;ky=@1KVQ7CG>z--WopPOrsqgcb@Jx#e zxaA#4dK1PU+)$$72f%;>`e^8F*p`I%p^5A&j>bVERk(J%y*xX&!I>%eqPeG=TO+i~ zq(dF~FI*@z5U=@AaCh;`FWHdYKOY>y*ocEa?@sx6=lm11qfoETIx&Ce$Go(JFXQ<& zf9{&L>(82e#!Kt!*pLlZX3oHlJsI5G{{I0E<-YZ)dODkI{5kDtvNV$?6v>F`hma-H zL!1Uf3{9{W{r?oPAqspzP^Nk)woo9;L(G7J{SV1eB+oGuGi2Og^K7%a46sxvd>GZh zz=tO{h@tDpm}RN}F}gw}1qqAH!(zXd8F z+l#~qd{)?OoJ^C5m1ee*4?sQn{h3ccHOJ_p%`xArQME6rQ6(&~qQd+H#jpt_uA5UF z+=4j!vL$8(wSxDMr#5CTipw0VD1_I7d|9w*@LJG$9hgsr$hGnWMTI}n53|Txi+!Qp z#7rUbZndV-AZ>mJEyEWLFq$xgO+7r__yksFp77mw#^?dvJmUIBxOHe7KWIci|A$6L zeE6aH3)ayo&iv2jOC@(>Gs9XLTpH_EHf!zb+%0G0gu5k|HY3+iUWPz6TMXV<$BNb$ zGbr(vJ@A1vs^RD>kJLu}B#zOBQ>3hBBQ%^5b~ycQIAzKjsDh+3$_^*fhEuIH|avI0x#}ac*bDVOK1*>=5Co7uX>UXPSASZj96O z!0|;E=#>SLB$0EFNDm>AE}BF(3yEMH)Fu&$LtIK6j2{Y#5RO$M5{D!ramtj3L`n$9 zni5Ak*x^Lt)JyFt@Z%x)!9)&`L!%n-BXNiyiBqJcG~h?#5I+*9Oc~ICA4fU`mbV)4 zgUP457z5=ZTC(7$TA40#k&(x$ZaUyIuManuhUBj7f zhcm{8Q>LuP+EuAlv+Qst+i4sh!!Gu&Jv?@Jq#ynEI2!%v7o?Bb`%EKxU6y+Pv3LX z_}AH*DcW zOWR1U+eoS+TyL<2mpzPeNs?WMdf`s@zAw;=aBdC1hWr`k^a{Sm)Jl^56&2rOhX7T6 zFT-Dw;qX`$DaS+$MZW(<`;H`j0_`yIB~9)^%Iv-b<$@j}oQug{2&YooO;0(JA!4px zLivH1wftBN$z%$xfaNC6itV*RhEsmY@S^$lIw8V^PRQ`Gh4zw_;S@iGlLc7ipb?HS z6iA)2X~Y?D*xBh-P#7UOTxTx<8UC^@yl5ViDWv5W+5y}DoQ8So2g)e zG*p(p3&4@3vk(pdp<&LFOF_esYNJtsx^A~dV|RTTdS@GripIkw4PgRswwZabZVTEr z9_SchA4gAQcL%g_o1o8xjbAy=ZP3F*t=El5$>}4y6==$1;D^p|96$p<9ejPUWr#DD z8e;?)i>S~i$Dk#aQ;D_)&85KcIW#oPyX;-nrwje%+FLcytI$4xDE0 zFz+JEWhYJjN6Y6h@)7lp?mAmtz;yW*2kR2k6t264O%? zGm|oXLonvlwTlaq;)^BxT4RYixj3_vy1bai>@A0A>1YY88gH1|NZQ>qDuT|JK&wlg z>FlC*Od2{iV)**Jh?EuQXTR)&NzG9s`gmSqy=PWV99>Zq->QY5gG@%WJCO-G|Lf%y~2?a99{NIPMr6>(zVCsOX?kYe$(IMJ@_z?jW_qHD@~p z_cnf%JGpCOSVz(&IxlyOfod?cRfNVK6SXDPPS)B|oH8x!Fzlqj*)FU-EQ4n4B^9bq zB^B5nQ4bOoYdt8ah|({qlqow!=>-+do=YmhHY$}$kNZ<`dLKAMMXywLA{*jJW2}uv zxe~!=tD;qo!w!*pXVFd5=ZNKZ=<;yvYl@+I>b8jzHbCs9*%k)D zt$8Ea6~k_R_bDY&*1`PO>#Fz*Sq}Q2(Z6oJIT$y8wuge_QO z&eJ8q7KG!xy41LoaP*n5LW$$N89LR=0N18LCyo&n)+&VxJ*Q1PlG0$z&@L4V1tWU& z>e(YUBG#uh*7SC3L>>vaA;a&-HO9HD?nq1?r!gIxI}YzI&bU)`QC`#1m_t&c!e?Gw zJs@j+&6s(Q&FxuxR}IfunwDQ0Zax(~AtS0WPgcDXCqEfJs*^(^Bc_C^8Q2-hf930& z(}!$+cJ|QJ6?xGa@qAym!9{7!BBy5cu;n-Duhf}x?DIkTOFCoKU#T->|E1oQ8{DPN zxJ#XxfO*}c4OF7Li84^8Y;8~$H<*X(I*AfJ062Q3Y=(V(E@_Oj(Wt<}!;=~xC$%yn zX%HWhM#UfX>LH{>(jc24`KSa9YN^+^EP{rx36e(T9}aw&eX;Wn^~VGawpFPyzbGe}wyM@|b5&j^ z1KRjV#ooZe*{3-t1~{RGAPj=E2-evl_bXlGk?=gF&NA<3W6U2r6r_y)Se-k}eB8;j z@9x$rhtdL*P^Fs*Z=G@tTn@u}#m-1+a$G_^`_-tCIE5PPtLRWWFdU1Wv+irc{JUa% z4Qa$^<#0=3s~*TOlXG%~6B&~}vB<8nA?q91Eb|tY9TgoFjsH>DQe@Ko(!l>_PX@=v z1_$-&15K40MAls9TBWrwUC)}2N3DLt&>J{`QWM!T4f>oWc*XBHeFL0QOJ`wzSggPqZ~jihp;akVx-cZDOTm`g ze%MKmG`h$V$)Y3)Q*NbWj@Du8PAeVobKd-!7T0H1I_93b zVS*n)2RLt;Ki6d`4seznlb8(0-fsg(|JZs~QX;U%2{&MZG^I6vK=j#8=2 zt$RV@)V)xTPBy%p)^t^}<#k_cIA3cx()U4r7U}yaQpVob&(ck_!esBPXa@DJU=y@9 z*k;HkMx;s5K)P2okj*LJbQ3*ZqCxq&y#bEog=!DUOS$E{2DQg2$fld<<4Ru2|F&yE zSuTn2HlTrU>_1RY_oL{eO5cnt-iL`iF2q4G;~)#_-Hg*@A^UjLyP;kn?3T{HcOz*~ zts-eupoc`Y3em9EDzYa@wTh%sfteDbMMS+D=p)gXX}CvYzp_WvD&*<2wpWS9XwgF= z8V2kq|ENQ6^DI(1$3Wf|3^S8fD+`6!hB(o@HVieAE==TFmPk!ewtcRhMUKo7xh7~J zN6JKwfQDJqXx1D!VItRv26D~vS%X}Yy!5s4QjRg+3{5H-QnIWS@gj0f@>2eVU9Ktf zk>V+$FA$-nXGMd)z+=FP6zLhJ;jD7#3rH%|a*$NYlvNFCY(Yh|93+)8O}6*i@$=1z zx+$X0CMxiu4*64-8OxuVo;-$gth z)}ul=M;f#ZWQwRyAyZVYG;PqQkSU@*h2*B()n2yg=3p8tKzJm{4e{(Zf1vU5fyT=w z;K17toZ%9OIW@pZhtJAJhkVPiWM9Xie#87F?#ECvNfx18+6E5N2;YRSY?RRgUZCs~ zEWzaYA_}% zsSMN7COs>)z#-2JwP{vjMv$P=xE>Y#Pc|xrN*IgLsKnS&IjB*AZBXw>49gu2L-G=1 zqf!A6=mw3-20JRh+o)71@sgAHdYl-R*=STLfwEl$>tOA3iS+2H-z+^%RhB_SgPX{u ztI1@Y;T|9RmB$-+Vu{8mCo3OhVOH1oC!uw<-u$pG9<8fjWlWq4=9F`6ye5WQX|scl z*sexZF~~$`xj1%T+~e9)XGUS;9IxES=J%@axCt8R z&j+nCO;oSf`t@5lIy!31!hY&?aC!zhoQPeP0+n8IQQg{LudP5#JH((C$371@xdv0x zRWZC(FR`@0=>Tl%;@jHa*Ox|(v87+bL|8Y`)bu<3L8|lJt5gEX&R2;3K~JIIWy-P! z{R7meSX)cpMKm-~O3NO$_by7$d$0rUnZtUh}x|bxg<)g=F5~+nt~TerBdnA zU_?Svc@8*41^yZz`!q`$WPv1&a;0kn3nXb!A4Jk9_ti^2^s=lO!Rc`WwVsw~oW3tw zPgD@F1FO@CMk~RIDM@!3o)Ogu~uy;YAC=Wq5eKaJ`Q$ylhdF45#Ke z^jUnbN55NqPxg8>=820K`qsm!7efJB^a$rBI|`F+6p)L`a*46L9tAz@pNs=zsXr+z z2tzfkkQjxS?Sd6Fz^D+ZT;N})$)OGiRPMy85g`ZAhnB`$W`K90rJ5up21$Z2^h!|< zNdhemO%ftpNJ55}jiPiQTuX=J_XS^wa9cV*gru80eGd8uWKJBiulZ&8O)Z>SiQ8zV zCB@KIrcz)~OH)uFzrha7A`%#yH^!a@Mh!ZOaBkAVp9K$XL~b1wQm9rw7P*BuwA>Q2 z_Jy1)Y%#yy0x7Qb0vM{Qsm8?Q1ta+r8=T~2={cPbCY_1HSODc5|q&GG|(#SLey@qLhi1Y(gfo z92OPW%S40=smt)PsrE9L;l7ZG3@`sZTIzSSl-YU6e5jtn$!_YE^2-zsnQGy(43f-c z87=&VWR6_dWG>4v$y|n4d_gitILX{*CGOb5D`gp$I)Pg4MdS&sJ@Zsz)pFw;%1)*zn}-FJB1=aHoA{x(3#wfg6DVZhkg@ti|+keJVoB2?zEd$6m{2Jwo}{~DiAc+3 zc!hU88w@Tfe3{-FPCc)qvM#gL0$Y%dK#`6Uf`cLt``T~|WQz4#h=IZ>XOe_W$!}wo zsh}ZJA!(E!v$twujg^Swq>N*>y;T+ALO*19S&_YV6r)#H zZ)0RQwG3qpRhVT!EkpDmwU!}aED`x5G4?A5Wyz5FgYtjYyg{tE3UTZix3%_+bt)F7 z-@j)}U1AD`)X_nn{JHXH3%=?E1$3jCu9R>K+|y*Eja@)IDHPS6t!9 z70^<>ZAzLkF6Ydi(bWMgA z{3yb0`hF4L$BXbYB8DPZY zhVh&|hSCVVzIlRnSSv~Url5TqUw$Iq_txTECwOtf`{m;Oc@6(#@%{qn^b)*-XQcha z7njLmsTVAlHH#%u2mhX)QimKviPzK6SpP7z z;DV6kaaWq5g_fQ+M~(b$D7@j zb&KV;mQxjoAy(uiT8)&KPdnx%jb6;r^@41WD@eN-O@OCaPooKVegb-H?o(ROcAA7 zet-OX$21Q{n%C(G1EXw2p|vB1&`*)S&`*@M`^^|Wi*$tw9I82}_qE)9lnUll_{y>7 zC|1GwnON|FO+x706uE9#TM^rdW?YE57anSQpI(s>VVGhSEDqzKtvST9VeAltt+cEg zjbn*~hm&=spl#Lgk-oV_5d$k%4juaFvEk!PYcu&3=iu}y@evaThp?D&Gs7YaCuPU` zjwn7gBJa}n(dv_pqO*$&!bOvuhocW6$cE>kUJ>bLaq<9B&oG5Nz>W@l$*0+PTxlYr$X^;FjoJcY>>!5!3VfTS{!W> zdIUOu;j{iw#$oxpf$!YW?l{^lN4q0via1g4AWla+G!3$$x$=6NIfigYyOac;)`;)H zJ;ELBl9&rA$|mMQB3#UC5zi^&`5?aU;zYi~ACM-!Qt^Ae`3t~zEZZkhwqwQjnF>Bo zHiJLR5r!4U{08VTW;b9oODy#%v}!RO2#Z+;WkGkG_En-~lNjDq@67NCqQZc!njRITgscw0h5E<%%U$huOt%W4=MQz(*eA z`vdrXr-EB)IvCIa3mzgpUl%?!X-6=KywPlS`dZ)*b|x%9KY3K}_rM*J50 zIzrMQf3c)dgcTGcwDLAWD{qp<%QhNCOYME%jx-7|%1!H1$jd(J0W=Dr5W?4i8@=ZdtfMh{Sqo8@RUq&z4sQXcW;c4=Wh={)&Hy@Fh5L{eqrb)M=t@vIZiUs>)L z_Fx3bPrVH(8`RsnSB3Ox_QzePZ$Yo|pl`&gSHh`5X5MPLrFRVXW9KuS? zYaq2T$elT2ba=3aQ|2%_JjDDc%_~N{)C$xq2Ye~l%kWl?<)ReR+wbt!vJPc0Pd^;E zXv3h7^;F#s_)gJU;HGV065q*Mi&h!QS}Weta7~T0YP9}BTD5q~iUxCGWQln2oPTLW zXih#^5z@Mc;GGj~QEgWKM3Hi8Pk|?JmS^s&TQ17r%i_5M!fzSk(RywzC>+)0uq;hq zvb4O808XyH18~}CIH4E~6B-jJ)0<-s(ajR0;vWD*&z|{ZmzHb0G#YC#`z2L35S)H5 zF`TP3j4BOd8Zd0DlZb}J5o0qjZ0jNjqopH;V{0)5R&$p~?In>~Q7a0oEo;e6NZBb- zd%Z7GR)KM5$C+ksz&Rl-^>@U11552lQ`C0-|=R*{^khwRBj8 zVg%oqe}hv|ut%$fnCYDfr#KWtxLl z$t*Erg!Ga4s+Ds+z?XF%%^I9!kt)W_ghna_F72dFx05<8%BL9jm*un2QrBSIUzR_b z%P4E{!oM8nGRjik3K)_~0opE(E1x74N{yuAm>M~=h{p88G;L_OnoG17goT6dkW?M! z1mz2zDqkx8Wj{76P=_UBQD{1^F=E>Vu%82%)m_hoHpi!v! zHkd&w>mHhIF-p)VZ0k7NLbggtha{B(v`QUyNK&DiR8nzV@#5IB-LD*e01a!afc$lP z>d%ybT#us^b`iDHCz?ziZ$O3Gs$E2jufY|f-sBFXi#AhZ41!2pr;uKlo~zG@*&#+PHpp`iOLq|5F3?Umfu*UMui5E ztyCg~m6B9;{Y+(nsNB&oL{5kqAdoC~IeXe@VDAjJSWDkxyYx|? zK&G!$A@3b>OB;ccGD^46IBWSGa~oxD4X97U;9{e3_O?ONFx;Pyr!DWWAsUS#^=atu z@$vKy(j-?}td`#*{v*1lum^<5 zV>c~+=wA=9@>x=;#)@THzfM&0w6sVnZZ;~_=w%rt72`cBH$<5fR8S_rm%WL4Ws<1e z)SuL-piKTibun^T*2R`z1t(M{C5_#d&qSG|UX8U({stOJdeXKOQJ!{xZlB8}lhdM1 zN*X1WzeJfN8rCvNd_1my-bSP3Z^zOkBrD3Kq_WHMttgX3#abq%On$ae+4bEYQkyJ) zXi^h7E6OCOAWdSeJn}&1u$z!Dl}R#fw6uasy2xwm`dw%Z*6)fhkmFL`TKh?~M};Lt z8y6Tybj1zw{u*R4Og{@8QlFS`euUiIm_HHUFq}c~!Jv zc8NSL!rW`}Y!G#;XJY|yPKlOb2}xclT57*aThgFDwWJYm*)2RQM8i6NP|~3N0VIw1 zEy9l2_6LwO$Sz76M|A&c(8>@rgdPzMX&3EV8Inq-jY_q$t--z?l1g)p69Z-%6g#XL z&xh^4O08+)McD4Qh3#H|@^DIb%=YCm-L(egVLiUZ{9E8eY2U)bj+{Az_nk#+Y=riH zt9VbXxp>1Ri7(Rh6-$Qv$&x2)nlEP#&jt>@6*w0#)+KF%bygebIIR@PtrJ|JKw;v2^zw( z5e>?{iw!KBq(k{9=~OE#8q{l&m%AD-lz*!ntlxTbmD5VGPDxTZrh8S`HptXw+mIhl zu{v74N3tOMj&?ev(HEAJM!l>awqrUvvrhPJw3T#;=EiguH87}mLbmm=C11;pd@aX> zx5My)jmi<-8PRT`T(!1aL@!5Axhm_ z-h#ER&dzL7(72x;+>f+ZWLna>g}nyHp?d9!ZYEoC>245US&3c10h{kH* zgM5Y?pgB>E%p{%b*B_B5a2UB&xCyq_=dWl!@Nc3!+cj`x@& z4$VRjS@0?2B|arT=oQy{PxxKx<13#EI)vXv(AV&Z9{4$n_n`tGEhD010bh839`F4H z{x{nD#^QY^yuT~(-$+W?2!ZGoc4oeKImO%l&>x?Y$KE_QOm?p-Qoh0xi1tP#%WW6reC zF%Egg-ePpzI!>T#+qSKDTQ4u#)2pjza~C~!^}?Q7w6z%R;fQU5<)&f`=Md0NpV%XU zZ#KWq((+0Qq9O}+=hfA2-+qyW(W_ork?lJ~4(+9SuMEpxJfQzWqk5>`CCita2b(u} zbdUG(PKfYSal|X za+x-Hc5>3?UhUT*W>G20eBzp&g&;+#BSsnWzJg6K_pMt8`%#UTpIb+z74-ONR2nsPGoEX?4y#SIFsdI>K5D++o0#tui7?ID)FM zOt9QGdiFRw%*9Sz(2=8L&G@ngbk>2pF9e;RH9C0>a6$-2(19ON#MKZd3OF|e9ry$V z&S-l&&>qp|fc7*~ut%W_whLsb_jKP$XpN?ua!*j9_c&<|XUz#k!H>H4YxRWsMWuQ9 z+boTxzhz#wpSP&{iMJ?G4RyZ!quRd_k2V6NG*@sQaAOe=wF+b0eYtbet8H14`bh zV{H8g<}2nWKVlnBA2e_HkVUdy*UcNsc}3lLo?Lf{SCsH2buaMVbvSX^I-36P<8Jp~ z#$8>;roU%?*Zl4WZ2H;5=9BL+59axy`Q#>c%iNiEVPv;T*t6!DY^ym28c1!wT#WAN zX?7HzQ9!Xqi?kMXKP<~Sbeu;mlc2JYl3F^bx1NoM2on#VLh>z3?R)xx{Q$`&uSENFSU<`UhW_r%^JmL8-ua;g$uRdPc zUUR)}dq;SW^#0VRtIsjtY~O2su71n?KJ0?y61!~c>e{t;*Qs64bgk=F;NQ;wi2o-z zYW}If&VjQ6Uk_>=R2Z}+s3z$1U^O^7cxmw4!M}$jhKvlE6|yMgbjaHwcS2o52Zjy{ zofBFTriOWi4GEhab|}o;J+}Mq@U-wxdl-7G?qQC|jHr$@MD~lyih3zJAbNH5t)AgM zi+Y~v)uvZoujhM@?)^cZxIQ2CjqO_=qsFAf%#OJfQybei_Dt;cxMpz~af{hW zW54`jviz zzMRoN zh2x*d*XL*CpPS$^Vcvw|2~`umnmBsm$CJ_~)l4={UN^b6U|zvzQ_`lKnA&J+*3^n= z9Ul14cUtJQ_-W&(t(dlN+KFiwr(K=)*L1Jx4hK7 zTsZT~Sp~CB&u%um=j_F^x6M8^`{mi6&FMX7$ecNIHq1Fv)U{|#(F;XC%ypXUF*j-M zxVhuzCC#gx_vL)=`C0R4&)+=%iTUp@&@Y&^;Dv>Tg*_IIT6kgM%|(qC#V(q*Xw9Ot zi#}THzxdI`uPsShGI7biC0CdHw$y8B%F@|OA6xp|(w`pc@zCUl4nK6|;rxf^K5TmU zt!3`ZdMul??C7!&mJeD!eEHbrk3FJ1lKaTkM}AtNtZ1>qXGP?S{wqeVn7(4cighdY zt~j~ksTFUo_g|RxMeze%0PphgV%#_2Q}zR(-STuhq`0+pq4ny65Tvt8-RQS-p65@#?bG zCstos{o12`k3Re8JCA<(=$~tx*K}CpzoyTc^fhDG%v!Tz&9*fM)|^>$b-_GTle(3x7K}D98tWt_+;@@#cvjW zUVLZ0VSW4c0qgs&AG|(qebM^W>r2)jUVm}@we_E@|8f1_8yatL-w?E+?}ot}ayQJ` zuwuiG4Tm;d*zn?p>l?n`P`9z!#?Bi_i;aJ5 zGHhzU$$wLyP3fD)ZYtcgeACv=hRu^UFWkI#bLr;eo1fhL`sSOPf8D~jwA$jcC2~v3 zmYgk9wk+AQaZAOPQ(LZVd2P#$EkAEnwz_Tg+8Vw!Ve8PX`CE&&uG+eD>%pz(wqD)( z{?>1{{=Kcyw)WfnxAoqZwr$k5Y1@`;dw1KH+wN?4+TMD*@Aio8N!v@eAKQLu`^(!u z-u~lu^NwaaJa)|5v3$qY9s75j*>QEp2Rpvoad&6aot<_D?d-dA(9W?tXYE|RbIZ=E zoyT`x-g#~3$2))6S-Y$0E{|P1cOBYwZr9aa@9+A0*PkV7iED|mB&?)w$$*k!C6h`D zOXin6T=HnirjlJH6(xsCPL-T1xl(eq%iXSd7l4!iw#_uiepJ8Spo-4k|C z-#vHts@*$wAJ~0%_to7W?EY@g$UWot%-yqe&#FDU_8i$`+H+yg(|carbA8Wed%oNA z>z;o~d8tcji_#9IUZwt}-Am(3Q%f^TPn4c1eWLW0()UYmlzv_MXQ^ec%ia!q1NZjX zJ819Ny|eZ%-@9#Z_1eihLb{VPUROs$wxvAAMGMOnqEiYpawRNSigtx~OQUFlaDUD>~KWaZS#`IRdw ziz|0kR#%>?yj1y0hskv zRex0dL$!H-v;D^Xq5I?a58Xd;|NQ-1_8;AUdH=QjpX~o}zvV#l16~Kh55yiAa$wAX zDF>Du*m$7wz_9~Q9C+oxrw48y?|t%=Wn|O)6JVJ9v@ZfRQIl756<#b&~xG3w*e;YehjYkeldX30z5`> z5sgiNGXUZ@6i^6A1VDZ)0dnXluN209#ry4m*?`FaPrS3>3Z7YKTu0&xeB~X$liGWR zcT;c=o>?CNXtGi~KLG%b>@UEpfE9q|c=ro{`0xSv0$u?a0VFSy(GkE=KmdT!Pz121 zs~zqs?aKiffJ=a00D4Dhy#N>t2u55l;)*mX`2gsNkR!>#1Rx!F1~}k@^#&l_#4{sV z6$Adj{YhPB?aR8%x&mC+;+li26Ruq#vu+4)i7Tap_(psp?;~;D3aAFm0we*TuSy=^ z5x^+`rEwu(JzyT-0N^5kc<%|IbmRaI0tjygfbz5gPzWGel*ZivijU&<2M~P^z)Aq} zK=43(Bm+7Dh$cOU07$ln0LK9jY0w?lEdY`~bW_NzAFf1~csviF_}l<%0K^}?j{>aI zp6OiyfbxrUdn16ppAVq45q-j;ce4RVhvi#bHv{|t&=H07i*Tj^#%a&QBb7tQUX&mD zmgGAOFa?kS7y@`1@F{@ud<>uifZitoh|j42;%y*cGJxn%93_CK0P|sj-UMLg3wsWo zF!(#oU*ma@7EbwaPJ@fMQl3$s?gs<`q5&j3%BLBCxd4*KXaJ?Z2Y_V04e&XD(o8_Q zO1eq39|w#C>;RBl;{ik`4=@lg5kP67G!yO{fK~v?*O>s)sZ9XVeX>0R0Mh}={}AXq zN;mX`{Q!6sfHKWq2Ox|E0VvJ|fJL}31t6WQ5uTwZY=Z{SHRcLP!}}?KWf~CuPXPE< zO)^Vm;D_d4YZ-YM?49|VO6 zqq12asBG2;Yq_MdSsx%9Q8uktDwp-+A=*~3+JO4;&^_4$D_HG9{kZ9V7l7n;4uJA( z1LDgH_4y*(VTEGc*9WUTvGQF18UkEO1Hr|5FvKZLadir_)B!N|tTe*)G_Fp-p*o7n z5a}fGNBOf6a0)>0b^u8KNYAJaB)y<~904F4x~KY@bm9Pj=v4qnN2x470T>S;Iad)nP@I&8`2eCvK=@?eD1OojD}RJX_|*X7i})nI=$Uw%1|a^ZJ}3UjK2RAy2%tDm z0w^t{i^LCwQNBI~xTe9|xKcU@$Ev@MS4uO&MSo*_qf*gNK)I$mQ1C^0q}e6QI^0`% zw(>%CG}YG~01<#30EH3%#2?X50gx;@0o=5Eji!~hXcv=wh(3Kk5>TUo^*bwVk`c+l zdL_A$Oss7o>y`4K@{{P23>>e=@s8wf)lZT$$#Oj)1P}#q3zV}@+;!`4e>l6T*&nr!b!e#waUWrs({Xq|0bMdwgB*-t|XiPiQTl~lO9kW*k8Yg ztO;ZpIe;tW2jwm4egVLmC&zJ5Vb&{^pYs5#ZusGzfaK5uX?;h7I$ZCPozUG9a;(Pg z4I2SFa2iGkW>WRV%+V1TM_G^Yi$Q4rD(oxz!Su@Ub?>6vURvw)*BrwhJD)xf)SEcD z{WZ1)-haiZ)F~XFbe323O=XhS)hBZJyNMy`=86$>aTFsx&ssF6NQuLkNu7+tc{~p= zDy(uaV#PWM6HxGoUm~LdT4`entrVR;5zjZ3<(OlYi#ra#LR0KJ^RQBdRsJWjqV=ls zy7HCsvtnkh%#Hc7E-aJ{W#ian_Apz&RS;lHUE8B-gn zt{7}9#>t`E)P1T+y{NvVzNWsVzNhomg<#I{VBL7#Ox*(Aqq^60@9S>q?qCIKbElRb z%p=xgwZ|_Wzj^%Kd7!bev9-~|=w!^RVyPM$42+jx3; z`gsO>j`iYRdM{V67G52^JiG$D`g`SiP4s>FkLS$TaRDPr^e0njRJ@SFeMsRcUMR% zTBBZ4Usm5l3KgB7E>xGS%h2WPX6Y8{*6Lo@eW3eH_XkE$-7xYI7d~TtmZw<<3)UpF zrT{pTFzdn8Qcy9w1+?;x|;O3YcTW@CG^t-Y0)1x;Q+?ao3 z=8fq$a&IKw2)g0_(N&k%_;!Z%55$A``@njG>l*+XN2b(y)Pvl5I0_q&-F_eNhdG4! z1IEMnrFoR}gp@JJh=U@J1Ed4q1E4hXe}G{3$A7YW)E@Y!zy?gjEFXD^&5X}yYw&Y z2=NlXt-P#UQ$zS4%Io}3Y1Qt(es|ibZX!wo}`y9Wd_(dvvMpYIn6GQ<;uAsn=DE0<)H^ z6>H7fs1a%;EKC>mhI&)|Q2hY5rW^BT0qRHU$1IQqsc)Wi#9hV*-|2n^vzsXf(Ru%<7oz12SI*XlRwHT4x1&3dY@s;{wLtT*eUzK`R) zW7K!m_i#pMj2g>gS)BT%`W5TP;?-N~XX@+f8|vrk7c4SVYPvd59i$Fsi7W{g z>@AEcPh`m~MV+KhR=-uhWBt_v^?P-SI+aae6V)HpAJ`)GdA7u)1R71sumHd3~@$R0UCMCzDBzPyDMu|?yjSn)h zK$9_dQjm!U8YdV{PiL5Pej`tJWiAOx`AM0hvOT>$-HWr0ri_eiPg7iuyU`R)chNaH z#u|x~H^I~uue2wlDU`xP3G?ZUY$KQ`&NG@^GO}~=#zg0=L4av^UaL>!k$@b1cXxz|j1i4e9w3vfTPJt#v zLcnQMR6;a*JbK4_L&V>AQs2rIrA*-O#-dwSzD>%F@Sr%#~!6qW(S!9#Lb)_lSiOpY8vpr;IT7!^Z;!mc%-W* zc%-Wrc%-X0c%-Wjc%-W@c%-Wzc%*9=@JQFL;E}F@fyO?f2m}Q}G|h933CN#Z%FjGp zO@X9u!GWfr08d>|t$8A^tT^taVa0%n)Tl zSbfPf4DKFij1c^W1CJ#&EYUC&_hpaXD{an*Mn+_ftFM z#FGtlhBZZeh|xP4r4PAjUzAYpS%5V5JRx_2w@FRNn*cM!6Y|_~pPPfS>-bF`Sb|~p z#zr*m-iRR?>B2?C0($p}1dNce)WHBi_D)bJn05zFKrs&=`wAxTi<*Y4+`YJ<5xqzT zX!e_&{4^PO_kn17i`OP+6mX+4**k?IqulBvL`DhFB&uX(2OImKZX}Mimmqde#GfF519O2MSF5%Ek z9^u5}W<250O+Mk!%>=@sn~8y@UN$jLq6bq9UQZTxvA8P``h?eUc$gAs>TScGN)G~i znz$qE>Ee!1X9Sx1*iZ}UL7>hQcZ51i+!5;RKvQ2E>Ku9ys72zAQ0Iy}LY)`*cq5#S zt~H+G15C~nO{!1ELTi;1q$p^NznF=J3flzBESul6Y7~9^StTXBvrh4+`#6_@te3N& zb1Q?+S$i=^Nz{kxJ2{CLF7Z#cT;A;IMo&A%;k?~OKxr2LB&wP?@lU)_mBbn!wsr`b zo@=vf)Cq|-e)M$7c{#e|aclF@{sIbG*g0`7gOonbU7gz)bWQv(Fw1I_ZX;@x#2Tjw ziK6=Y&p#KHi&!&OLOw05NKnkV&!A+Z z<#fxQ8lLab>rT5$!YfuyUNaq5EGT0E@}6PF?pW=fqvbVMyH{}n$qMaWR}aTY$x>)u zGSSg1KJZz{dxMh7x@z~0uvPjT?Y^#4ip0}I z?0+~BwC3Y?0x&c2^?1NsfKeHU-$FnpLMJQpKp_vf_2cOEfG>L4cp5I~7a?AvMZ5-s zTl&`%yE8;!*RXs1)BRlhn}m4g2_6dYT_Ns_m>WJHX$;1GuHEe7wDK6_z~%i@Vgx-( zd7g-A4z3fx-wctiX?QmiaobBV*e<>G(;f`j&cwF~$OX!cBJeo{lqi)lc9QH|yr-0> zBP<_r6$*(@0M0zzQ|xnaKNmD8$NC{}^MEIx?7s;_`2D3xS}+%1_JU_R1oO%1Uohz0 zqZJGu<{%G4kcaj8D8id`=t+W-1TXeb{03<`1eca9rk1JT@;-l<$4*{G4()V;j$=I` ze9A6Z=h*~hs2OJDH-~4uh0+pU=+-D1ZIyP|WvGMVj&1)tVXx%QNR21Fv)+mic1`qC zx*&(UVckjqb_)vv_aQ>3yCc7QKuaRwagT<^^n$+hQTi$|*qbvB(uxP?iQqaJxzr!& zNX6~~>CoFj%3#c`AA)=sijp`CavPy!V<)|lSdls!CzFg-#=#Gshdp@UOH-c2s_Jjx zMcV{_SvhvmJ^;_zb)4pDMp#r;cOF9p!g;Rjc7EyRB@6$7mfq z%->-r*nP@ROjX_|Ul3Mfex`hmwG7U%0yV6Wa)`OG#;ggBoo>cl;g`CFy@o!FEZ%(QfcZYn~%4erm_svkuA;=FU1|4S)yh%#6%asbpTvoB6;u55JPtpTq){y)1}4 zNaQ`L?>Qnbk@OIee~7$72;1!3x<->;y2I&0$4sE}O^Z zvjuD+Tf`Q#C2T2sNQ@yYXOFNIY$aR8RA(+!*;fV z?PR-H3ER!~uu`^{m9cVG!75o5#vrQMes+KzWQW*cc7z>e$JlXpf}Lci;JY`m8g`mJ z&d#v2>>N9f6;&76C3cxTffce>*i-CjtWtfJJ;$DBFR-iZU+hKrTwf-Srg9qRT4AJ2 zIj=m=UQ^B}XW@5TfnDI&u-D<)ysBKm_{bB|Qw#6sJM3Nd9(!Nez&>Cfvg_<4_A%Cm zmtxiS&ln%thFuX3V<+PsSmAoHzEAg_KexUwm;J{s?1`<>lkf3QF6`*!d7 zb-BuQ+==VCfjjd?+=VyhO?Xq@jJxvY+ztNM7QChMEpNqJ^EMdocnN;fQ?TXvye-P_ zHCUGy;X!>}c|~~@yV*}t-d5g#@ANU)g!hzply_lqzE`HgHcW#>o30e{cCZ+;VL|4= zUXd-H51X=3S&W)ziSm%L6#K+IOkQg2LUxI_$NA;%yd&?#J(N1d%sX=<_vBs}XHl?h z)Q9_WKi-9R<=wbH58#13hzIi!9;*DM{B8A4JNl-{FRlD@&of=$E6r1R8c*i~`5-=+ zXYe6BlMm%te3CYd_2!rzEHm86Zk|viBIMQd)$E*2%et;k3hxlQB zgdgR{_;G%MpX8_bW8B1R_-Xz)Kf}-RbNoENz%OF;&t;6aJ;|@|r})$S8U8GPjz7;| z;8!sQ_aerqUgp>MEBsad8h>5--}&489sVwVkH3%cE8&MHFFg6+`AvR{yzl%AN54D& zk^JubHvfhH%75d(^E>L+z{@ah8i0#tMB@U)4|TqIOlgss3t!8mI=T!D@&a3Qv1?@}rag zT=>Vy|4qJc;oDZ@)P8EbnxH0PHB_>iqV|VJJN2GVTg_01sF~_eHA@|)4p&F0*=mkD zQXQp^R>!Dg)p2UBnx~Fe^U3Ee{M`lQ=T@hw)72Sjp*mBYrOsC8s72~rb)Gt3U7#*h z7paTYCF)Z3A*{VxrY=_>QCFxd)m7?h^-*<=x>j9>anJP_7u<-E!OiLxbt}d|x2rqU zo$4;NMBT0KQA^dmYMENDR;ZO~6~;%aF@AVJJ*Xa14`VggQT3R597_{Vs;AV)Fm_s_ zo>m`M&!}hBbL#);Ac9~OiAHctfZ_ohBany!o%tJop?Kn7jlUUxH~vA-_5T?EH2!7$ zn|@GyIyyNi-#!o1((#>A{Bh&?G>xL=JX}uVu@Nk%Mzow(=E-uJRnBHfx~wE}9m#dg z*Xnv1TTZiG$;PwGP;i(NK9N*;mL$)mY10~Bn?BS_9tLmt`;tnRWy&{uyPbG7GdS#ELw%;Nw_$lgr&!E>0VrU5KkAOac>qI z_p-PW&M#(R`J7r+rqW{)%~xSHy2xU(qx_mG46a<8poIOkHJ>1Wp zrE)#o*Pf+%d6wGYS<2&CD&MnIPtQ`hp0$yy`g)e?>sdO^o~7gL*~05s%1x(Fjle9dK)6l$#GWMOpI!&ah4SL&FwQRdpi?UU`2v^ahmMo(vp3YXY#%e|n z7fomNXYq?ox{(p_a#KMMjYSxa$I)`d(ucuN24y%L%AgSrs;kT77U}e*ah#6Vi)Zub z<@VWfGcV((3_pSj&9;xFLfQ5Kb^V-2VB6$IL6el86RXJdZ)J<|h1pZ+D-09@g`vWc z!fl0Pg*ys&6;2dBR5(>QQ}~*~eT4@K4;4lVUsrgf@L1uA!c&2Lv1c<1RhkGMG_`VT zbWLW3E3Q3D)~P0FZa7hsiIJ4dj#Q4*B+KZHp_azWsfejf!YmGxmvN}Fw{uY+ze?lz zJRYy&abuHaeaw5JVCC+uaK1V?iqZwWah%UHDHWG%`2%x6RN<;TBeL>CzO3l7=WluU z!fmqZFG6a)tcHrFFSeVZK-EvCWc~AMs7TSl?ZT;#^RzJAxLIwhM;e4A)SL)Z18_v( z2$Uld*bC@AMwjhgv`tn6Fh`1Jpb{g2`cckZpXO=R!%^pBJ9gu!uoN59LZ*H@?^U1W zY4cbn!Hfl}$IzT^NggHhST&DzbdI58jM2H1%c*~yryF+*#0|c)HuovLkMT<$y+|=M zP6}0xc_Cw-sP%J!@(B%7c}^GeRQoTr|7oF1V_C?Ur`mt1qqht%l5CYG7qh5(1`88d zc%~L60{dsPHLcZjy_kpV)qYaUnfH}DRqp!*x<*>anD>=CRd}GiGJ*Poe3a^0o|buZ zY<(Cwwmu9TTOS6Ftq%jch4iaDZ9dgOcrCE^bi3;JUT>3CKakpL80k=* zW%DqbX*wyS^=6k5xE8EtlBn@rFR8jOZW(FmE$Kr)R|l0cK$5=hZ}t+BRnH;GgWQQo ze!oACCgiC?$<>1bnW``4hrxSf*Mlnr_2WXnixqeoVaaNFV_MVLT zS>EaT*;RR?f{al?#^^m6*GE_TJ;~cP;81ly)SF7W0bi;W{k+Fv!KtEBR<5Sild1&x zQLRuXD(QpVg`%OZOEk=-9TxniRvyX1m-LOIvy_sK!HBfNw-aE~f#vC>00C z+Z8SO!-9{Lf*VEWsIt^4YHiUaqBb|D#*R=+)e&l+q8mhQ-lqYFrxoh$Bz^OWuhVLE zagx4Kba1H6rPkH0N%>*HsY${9gDW@5gF-iTRVq#$m82VRQ>e(tc27K%6yLn!pwO)_ z?}r7aB(;YHZzKiRpIlwV>XoGGAoodTpMFzE*f?4UiSw2h?oW{C9@}IB$5BZ3X6EjZ)6#rw=E~N=;fFWx#$gB-zNc!w!^$qK>|zvsY`1+JYxo#dAG6elyguahF-v{a z4^WR|I2Xro?f|1bz-$gM>I3LAfIb8851`KgdJHfN1K2fyUIXw3&?5j(0G z!QTUa5Bz!@MD^hBfxicSJszTZ@b|#q1OH~d>oF0v1AhW$CQy%u~dZs-1bv;ua_Uo}0<-xD( zoATh-bxnEj>$;{q?APNkJJ91WLfEhCobs?=*E!{3zpiu2!+u@ol!yJg&M6Q3b)8cl z_Uk&g16}8YuwU0TzwMvUdP(_k*@GsZddHKTwTY6xL4 z>N+D79zx-**o}xHrq|?VPe-}bOg`r=h z9T4u&w;%eaMBjt_mj3SUez5x$x<1|=)Ai-Iik!lWsWLyT$v-w99!n-GRK!W!pt#d zjxuwcnIp{_YvyP($D29g%rR$0eWPnj_R4qvj~J#;G|{&9Um=mA*~i4(-$TKlDGy&{sSk z(YHb0rN1tHkMq49kH5~?Y3+Vi+TZEW_YEKHe2cy#=+ST32Bi^w>p)Tdoi`NY|7hn^ Qit=CXyrr1vyPZG%7iFl@s{jB1 literal 0 HcmV?d00001 diff --git a/gen-artifacts.sh b/gen-artifacts.sh new file mode 100755 index 0000000..802f6ec --- /dev/null +++ b/gen-artifacts.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +set -e + +. env.sh + +# Generate gomobile nebula bindings +cd nebula + +if [ "$1" = "ios" ]; then + # Build for nebula for iOS + make MobileNebula.framework + rm -rf ../ios/NebulaNetworkExtension/MobileNebula.framework + cp -r MobileNebula.framework ../ios/NebulaNetworkExtension/ + +elif [ "$1" = "android" ]; then + # Build nebula for android + make mobileNebula.aar + rm -rf ../android/app/src/main/libs/mobileNebula.aar + cp mobileNebula.aar ../android/app/src/main/libs/mobileNebula.aar + +else + echo "Error: unsupported target os $1" + exit 1 +fi + +cd .. + +# Generate version info to display in about +{ + # Get the flutter and dart versions + printf "const flutterVersion = " + flutter --version --machine + echo ";" + + # Get our current git sha + git rev-parse --short HEAD | sed -e "s/\(.*\)/const gitSha = '\1';/" + + # Get the nebula version + cd nebula + NEBULA_VERSION="$(go list -m -f "{{.Version}}" github.com/slackhq/nebula | cut -f1 -d'-' | cut -c2-)" + echo "const nebulaVersion = '$NEBULA_VERSION';" + cd .. + + # Get our golang version + echo "const goVersion = '$(go version | awk '{print $3}')';" +} > lib/.gen.versions.dart + +# Try and avoid issues with building by moving into place after we are complete +#TODO: this might be a parallel build of deps issue in kotlin, might need to solve there +mv lib/.gen.versions.dart lib/gen.versions.dart \ No newline at end of file diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..d7108be --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,33 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 +/NebulaNetworkExtension/MobileNebula.framework/ diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..6b4c0f7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..e8efba1 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..399e934 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/NebulaNetworkExtension/Info.plist b/ios/NebulaNetworkExtension/Info.plist new file mode 100644 index 0000000..eca99a3 --- /dev/null +++ b/ios/NebulaNetworkExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NebulaNetworkExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.packet-tunnel + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PacketTunnelProvider + + + diff --git a/ios/NebulaNetworkExtension/Keychain.swift b/ios/NebulaNetworkExtension/Keychain.swift new file mode 100644 index 0000000..2be94ee --- /dev/null +++ b/ios/NebulaNetworkExtension/Keychain.swift @@ -0,0 +1,67 @@ +import Foundation + +let groupName = "group.net.defined.mobileNebula" + +class KeyChain { + class func save(key: String, data: Data) -> Bool { + let query: [String: Any] = [ + kSecClass as String : kSecClassGenericPassword as String, + kSecAttrAccount as String : key, + kSecValueData as String : data, + kSecAttrAccessGroup as String: groupName, + ] + + SecItemDelete(query as CFDictionary) + let val = SecItemAdd(query as CFDictionary, nil) + return val == 0 + } + + class func load(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String : kSecClassGenericPassword, + kSecAttrAccount as String : key, + kSecReturnData as String : kCFBooleanTrue!, + kSecMatchLimit as String : kSecMatchLimitOne, + kSecAttrAccessGroup as String: groupName, + ] + + var dataTypeRef: AnyObject? = nil + + let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + if status == noErr { + return dataTypeRef as! Data? + } else { + return nil + } + } + + class func delete(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String : kSecClassGenericPassword, + kSecAttrAccount as String : key, + kSecReturnData as String : kCFBooleanTrue!, + kSecMatchLimit as String : kSecMatchLimitOne, + kSecAttrAccessGroup as String: groupName, + ] + + return SecItemDelete(query as CFDictionary) == 0 + } +} + +extension Data { + + init(from value: T) { + var value = value + var data = Data() + withUnsafePointer(to: &value, { (ptr: UnsafePointer) -> Void in + data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1)) + }) + self.init(data) + } + + func to(type: T.Type) -> T { + return self.withUnsafeBytes { $0.load(as: T.self) } + } +} + diff --git a/ios/NebulaNetworkExtension/NebulaNetworkExtension.entitlements b/ios/NebulaNetworkExtension/NebulaNetworkExtension.entitlements new file mode 100644 index 0000000..fe9a9e3 --- /dev/null +++ b/ios/NebulaNetworkExtension/NebulaNetworkExtension.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.net.defined.mobileNebula + + keychain-access-groups + + $(AppIdentifierPrefix)group.net.defined.mobileNebula + + + diff --git a/ios/NebulaNetworkExtension/PacketTunnelProvider.swift b/ios/NebulaNetworkExtension/PacketTunnelProvider.swift new file mode 100644 index 0000000..8781320 --- /dev/null +++ b/ios/NebulaNetworkExtension/PacketTunnelProvider.swift @@ -0,0 +1,174 @@ +import NetworkExtension +import MobileNebula +import os.log +import MMWormhole + +class PacketTunnelProvider: NEPacketTunnelProvider { + private var networkMonitor: NWPathMonitor? + private var ifname: String? + + private var site: Site? + private var _log = OSLog(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider") + private var wormhole = MMWormhole(applicationGroupIdentifier: "group.net.defined.mobileNebula", optionalDirectory: "ipc") + private var nebula: MobileNebulaNebula? + + private func log(_ message: StaticString, _ args: CVarArg...) { + os_log(message, log: _log, args) + } + + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { + NSKeyedUnarchiver.setClass(IPCRequest.classForKeyedUnarchiver(), forClassName: "Runner.IPCRequest") + let proto = self.protocolConfiguration as! NETunnelProviderProtocol + var config: Data + var key: String + + do { + config = proto.providerConfiguration?["config"] as! Data + site = try Site(proto: proto) + } catch { + //TODO: need a way to notify the app + log("Failed to render config from vpn object") + return completionHandler(error) + } + + let _site = site! + _log = OSLog(subsystem: "net.defined.mobileNebula:\(_site.name)", category: "PacketTunnelProvider") + + do { + key = try _site.getKey() + } catch { + wormhole.passMessageObject(IPCMessage(id: _site.id, type: "error", message: error.localizedDescription), identifier: "nebula") + return completionHandler(error) + } + + self.networkMonitor = NWPathMonitor() + self.networkMonitor!.pathUpdateHandler = self.pathUpdate + self.networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor")) + + let fileDescriptor = (self.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32) ?? -1 + if fileDescriptor < 0 { + let msg = IPCMessage(id: _site.id, type: "error", message: "Starting tunnel failed: Could not determine file descriptor") + wormhole.passMessageObject(msg, identifier: "nebula") + return completionHandler(NSError()) + } + + var ifnameSize = socklen_t(IFNAMSIZ) + let ifnamePtr = UnsafeMutablePointer.allocate(capacity: Int(ifnameSize)) + ifnamePtr.initialize(repeating: 0, count: Int(ifnameSize)) + if getsockopt(fileDescriptor, 2 /* SYSPROTO_CONTROL */, 2 /* UTUN_OPT_IFNAME */, ifnamePtr, &ifnameSize) == 0 { + self.ifname = String(cString: ifnamePtr) + } + ifnamePtr.deallocate() + + // This is set to 127.0.0.1 because it has to be something.. + let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") + + // Make sure our ip is routed to the tun device + var err: NSError? + let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err) + if (err != nil) { + let msg = IPCMessage(id: _site.id, type: "error", message: err?.localizedDescription ?? "Unknown error from go MobileNebula.ParseCIDR - certificate") + self.wormhole.passMessageObject(msg, identifier: "nebula") + return completionHandler(err) + } + tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR]) + var routes: [NEIPv4Route] = [NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)] + + // Add our unsafe routes + _site.unsafeRoutes.forEach { unsafeRoute in + let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err) + if (err != nil) { + let msg = IPCMessage(id: _site.id, type: "error", message: err?.localizedDescription ?? "Unknown error from go MobileNebula.ParseCIDR - unsafe routes") + self.wormhole.passMessageObject(msg, identifier: "nebula") + return completionHandler(err) + } + routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)) + } + + tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes + tunnelNetworkSettings.mtu = _site.mtu as NSNumber + + wormhole.listenForMessage(withIdentifier: "app", listener: self.wormholeListener) + self.setTunnelNetworkSettings(tunnelNetworkSettings, completionHandler: {(error:Error?) in + if (error != nil) { + let msg = IPCMessage(id: _site.id, type: "error", message: error?.localizedDescription ?? "Unknown setTunnelNetworkSettings error") + self.wormhole.passMessageObject(msg, identifier: "nebula") + return completionHandler(error) + } + + var err: NSError? + self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, Int(fileDescriptor), &err) + if err != nil { + let msg = IPCMessage(id: _site.id, type: "error", message: err?.localizedDescription ?? "Unknown error from go MobileNebula.Main") + self.wormhole.passMessageObject(msg, identifier: "nebula") + return completionHandler(err) + } + + self.nebula!.start() + completionHandler(nil) + }) + } + + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + nebula?.stop() + networkMonitor?.cancel() + networkMonitor = nil + completionHandler() + } + + private func pathUpdate(path: Network.NWPath) { + nebula?.rebind() + } + + private func wormholeListener(msg: Any?) { + guard let call = msg as? IPCRequest else { + log("Failed to decode IPCRequest from network extension") + return + } + + var error: Error? + var data: Any? + + //TODO: try catch over all this + switch call.type { + case "listHostmap": (data, error) = listHostmap(pending: false) + case "listPendingHostmap": (data, error) = listHostmap(pending: true) + case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!) + case "setRemoteForTunnel": (data, error) = setRemoteForTunnel(args: call.arguments!) + case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!) + + default: + error = "Unknown IPC message type \(call.type)" + } + + if (error != nil) { + self.wormhole.passMessageObject(IPCMessage(id: "", type: "error", message: error!.localizedDescription), identifier: call.callbackId) + } else { + self.wormhole.passMessageObject(IPCMessage(id: "", type: "success", message: data), identifier: call.callbackId) + } + } + + private func listHostmap(pending: Bool) -> (String?, Error?) { + var err: NSError? + let res = nebula!.listHostmap(pending, error: &err) + return (res, err) + } + + private func getHostInfo(args: Dictionary) -> (String?, Error?) { + var err: NSError? + let res = nebula!.getHostInfo(byVpnIp: args["vpnIp"] as? String, pending: args["pending"] as! Bool, error: &err) + return (res, err) + } + + private func setRemoteForTunnel(args: Dictionary) -> (String?, Error?) { + var err: NSError? + let res = nebula!.setRemoteForTunnel(args["vpnIp"] as? String, addr: args["addr"] as? String, error: &err) + return (res, err) + } + + private func closeTunnel(args: Dictionary) -> (Bool?, Error?) { + let res = nebula!.closeTunnel(args["vpnIp"] as? String) + return (res, nil) + } +} + diff --git a/ios/NebulaNetworkExtension/Site.swift b/ios/NebulaNetworkExtension/Site.swift new file mode 100644 index 0000000..8adfcb8 --- /dev/null +++ b/ios/NebulaNetworkExtension/Site.swift @@ -0,0 +1,383 @@ +import NetworkExtension +import MobileNebula + +extension String: Error {} + +class IPCMessage: NSObject, NSCoding { + var id: String + var type: String + var message: Any? + + func encode(with aCoder: NSCoder) { + aCoder.encode(id, forKey: "id") + aCoder.encode(type, forKey: "type") + aCoder.encode(message, forKey: "message") + } + + required init(coder aDecoder: NSCoder) { + id = aDecoder.decodeObject(forKey: "id") as! String + type = aDecoder.decodeObject(forKey: "type") as! String + message = aDecoder.decodeObject(forKey: "message") as Any? + } + + init(id: String, type: String, message: Any) { + self.id = id + self.type = type + self.message = message + } +} + +class IPCRequest: NSObject, NSCoding { + var type: String + var callbackId: String + var arguments: Dictionary? + + func encode(with aCoder: NSCoder) { + aCoder.encode(type, forKey: "type") + aCoder.encode(arguments, forKey: "arguments") + aCoder.encode(callbackId, forKey: "callbackId") + } + + required init(coder aDecoder: NSCoder) { + callbackId = aDecoder.decodeObject(forKey: "callbackId") as! String + type = aDecoder.decodeObject(forKey: "type") as! String + arguments = aDecoder.decodeObject(forKey: "arguments") as? Dictionary + } + + init(callbackId: String, type: String, arguments: Dictionary?) { + self.callbackId = callbackId + self.type = type + self.arguments = arguments + } + + init(callbackId: String, type: String) { + self.callbackId = callbackId + self.type = type + } +} + +struct CertificateInfo: Codable { + var cert: Certificate + var rawCert: String + var validity: CertificateValidity + + enum CodingKeys: String, CodingKey { + case cert = "Cert" + case rawCert = "RawCert" + case validity = "Validity" + } +} + +struct Certificate: Codable { + var fingerprint: String + var signature: String + var details: CertificateDetails + + /// An empty initilizer to make error reporting easier + init() { + fingerprint = "" + signature = "" + details = CertificateDetails() + } +} + +struct CertificateDetails: Codable { + var name: String + var notBefore: String + var notAfter: String + var publicKey: String + var groups: [String] + var ips: [String] + var subnets: [String] + var isCa: Bool + var issuer: String + + /// An empty initilizer to make error reporting easier + init() { + name = "" + notBefore = "" + notAfter = "" + publicKey = "" + groups = [] + ips = ["ERROR"] + subnets = [] + isCa = false + issuer = "" + } +} + +struct CertificateValidity: Codable { + var valid: Bool + var reason: String + + enum CodingKeys: String, CodingKey { + case valid = "Valid" + case reason = "Reason" + } +} + +let statusMap: Dictionary = [ + NEVPNStatus.invalid: false, + NEVPNStatus.disconnected: false, + NEVPNStatus.connecting: true, + NEVPNStatus.connected: true, + NEVPNStatus.reasserting: true, + NEVPNStatus.disconnecting: true, +] + +let statusString: Dictionary = [ + NEVPNStatus.invalid: "Invalid configuration", + NEVPNStatus.disconnected: "Disconnected", + NEVPNStatus.connecting: "Connecting...", + NEVPNStatus.connected: "Connected", + NEVPNStatus.reasserting: "Reasserting...", + NEVPNStatus.disconnecting: "Disconnecting...", +] + +// Represents a site that was pulled out of the system configuration +struct Site: Codable { + // Stored in manager + var name: String + var id: String + + // Stored in proto + var staticHostmap: Dictionary + var unsafeRoutes: [UnsafeRoute] + var cert: CertificateInfo? + var ca: [CertificateInfo] + var lhDuration: Int + var port: Int + var mtu: Int + var cipher: String + var sortKey: Int + var logVerbosity: String + var connected: Bool? + var status: String? + var logFile: String? + + // A list of error encountered when trying to rehydrate a site from config + var errors: [String] + + // We initialize to avoid an error with Codable, there is probably a better way since manager must be present for a Site but is not codable + var manager: NETunnelProviderManager = NETunnelProviderManager() + + // Creates a new site from a vpn manager instance + init(manager: NETunnelProviderManager) throws { + //TODO: Throw an error and have Sites delete the site, notify the user instead of using ! + let proto = manager.protocolConfiguration as! NETunnelProviderProtocol + try self.init(proto: proto) + self.manager = manager + self.connected = statusMap[manager.connection.status] + self.status = statusString[manager.connection.status] + } + + init(proto: NETunnelProviderProtocol) throws { + let dict = proto.providerConfiguration + let rawConfig = dict?["config"] as? Data ?? Data() + let decoder = JSONDecoder() + let incoming = try decoder.decode(IncomingSite.self, from: rawConfig) + self.init(incoming: incoming) + } + + init(incoming: IncomingSite) { + var err: NSError? + + errors = [] + name = incoming.name + id = incoming.id + staticHostmap = incoming.staticHostmap + unsafeRoutes = incoming.unsafeRoutes ?? [] + + do { + let rawCert = incoming.cert + let rawDetails = MobileNebulaParseCerts(rawCert, &err) + if (err != nil) { + throw err! + } + + var certs: [CertificateInfo] + + certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!) + if (certs.count == 0) { + throw "No certificate found" + } + cert = certs[0] + if (!cert!.validity.valid) { + errors.append("Certificate is invalid: \(cert!.validity.reason)") + } + + } catch { + errors.append("Error while loading certificate: \(error.localizedDescription)") + } + + do { + let rawCa = incoming.ca + let rawCaDetails = MobileNebulaParseCerts(rawCa, &err) + if (err != nil) { + throw err! + } + ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!) + + var hasErrors = false + ca.forEach { cert in + if (!cert.validity.valid) { + hasErrors = true + } + } + + if (hasErrors) { + errors.append("There are issues with 1 or more ca certificates") + } + + } catch { + ca = [] + errors.append("Error while loading certificate authorities: \(error.localizedDescription)") + } + + lhDuration = incoming.lhDuration + port = incoming.port + cipher = incoming.cipher + sortKey = incoming.sortKey ?? 0 + logVerbosity = incoming.logVerbosity ?? "info" + mtu = incoming.mtu ?? 1300 + logFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")?.appendingPathComponent(id).appendingPathExtension("log").path + } + + // Gets the private key from the keystore, we don't always need it in memory + func getKey() throws -> String { + guard let keyData = KeyChain.load(key: "\(id).key") else { + throw "failed to get key material from keychain" + } + + //TODO: make sure this is valid on return! + return String(decoding: keyData, as: UTF8.self) + } + + // Limits what we export to the UI + private enum CodingKeys: String, CodingKey { + case name + case id + case staticHostmap + case cert + case ca + case lhDuration + case port + case cipher + case sortKey + case connected + case status + case logFile + case unsafeRoutes + case logVerbosity + case errors + case mtu + } +} + +class StaticHosts: Codable { + var lighthouse: Bool + var destinations: [String] +} + +class UnsafeRoute: Codable { + var route: String + var via: String + var mtu: Int? +} + +// This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site +struct IncomingSite: Codable { + var name: String + var id: String + var staticHostmap: Dictionary + var unsafeRoutes: [UnsafeRoute]? + var cert: String + var ca: String + var lhDuration: Int + var port: Int + var mtu: Int? + var cipher: String + var sortKey: Int? + var logVerbosity: String? + var key: String? + + func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) { +#if targetEnvironment(simulator) + let fileManager = FileManager.default + let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id) + let encoder = JSONEncoder() + + do { + var config = self + config.key = nil + let rawConfig = try encoder.encode(config) + try rawConfig.write(to: sitePath) + } catch { + return callback(error) + } + + callback(nil) +#else + if (manager != nil) { + // We need to refresh our settings to properly update config + manager?.loadFromPreferences { error in + if (error != nil) { + return callback(error) + } + + return self.finish(manager: manager!, callback: callback) + } + return + } + + 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 + + // Stuff our details in the protocol + let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol() + let encoder = JSONEncoder() + let rawConfig: Data + + // We tried using NSSecureCoder but that was obnoxious and didn't work so back to JSON + do { + rawConfig = try encoder.encode(config) + } catch { + return callback(error) + } + + proto.providerConfiguration = ["config": rawConfig] + + //TODO: proto is a subclass and we should probably set some settings on the parents + //TODO: set these to meaningful values, or not at all + proto.serverAddress = "TODO" + proto.username = "TEST USERNAME" + + // Finish up the manager, this is what stores everything at the system level + manager.protocolConfiguration = proto + //TODO: cert name? manager.protocolConfiguration?.username + + //TODO: This is what is shown on the vpn page. We should add more identifying details in + manager.localizedDescription = config.name + manager.isEnabled = true + + manager.saveToPreferences{ error in + return callback(error) + } + } +} diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..eaec93c --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,91 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + generated_key_values = {} + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) do |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + generated_key_values[podname] = podpath + else + puts "Invalid plugin specification: #{line}" + end + end + generated_key_values +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Flutter Pod + + copied_flutter_dir = File.join(__dir__, 'Flutter') + copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework') + copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec') + unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path) + # Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet. + # That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration. + # CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist. + + generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig') + unless File.exist?(generated_xcode_build_settings_path) + raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path) + cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR']; + + unless File.exist?(copied_framework_path) + FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir) + end + unless File.exist?(copied_podspec_path) + FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir) + end + end + + # Keep pod path relative so it can be checked into Podfile.lock. + pod 'Flutter', :path => 'Flutter' + pod 'MMWormhole', '~> 2.0.0' + + # Plugin Pods + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + system('rm -rf .symlinks') + system('mkdir -p .symlinks/plugins') + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.each do |name, path| + symlink = File.join('.symlinks', 'plugins', name) + File.symlink(path, symlink) + pod name, :path => File.join(symlink, 'ios') + end +end + +# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. +install! 'cocoapods', :disable_input_output_paths => true + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['ENABLE_BITCODE'] = 'NO' + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..716bdc5 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,148 @@ +PODS: + - barcode_scan (0.0.1): + - Flutter + - MTBBarcodeScanner + - SwiftProtobuf + - DKImagePickerController/Core (4.3.0): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.0) + - DKImagePickerController/PhotoGallery (4.3.0): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.0) + - DKPhotoGallery (0.0.15): + - DKPhotoGallery/Core (= 0.0.15) + - DKPhotoGallery/Model (= 0.0.15) + - DKPhotoGallery/Preview (= 0.0.15) + - DKPhotoGallery/Resource (= 0.0.15) + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Core (0.0.15): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Model (0.0.15): + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Preview (0.0.15): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Resource (0.0.15): + - SDWebImage + - SDWebImageFLPlugin + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - FLAnimatedImage (1.0.12) + - Flutter (1.0.0) + - flutter_plugin_android_lifecycle (0.0.1): + - Flutter + - flutter_share (0.0.1): + - Flutter + - MMWormhole (2.0.0): + - MMWormhole/Core (= 2.0.0) + - MMWormhole/Core (2.0.0) + - MTBBarcodeScanner (5.0.11) + - package_info (0.0.1): + - Flutter + - path_provider (0.0.1): + - Flutter + - path_provider_linux (0.0.1): + - Flutter + - path_provider_macos (0.0.1): + - Flutter + - SDWebImage (5.8.0): + - SDWebImage/Core (= 5.8.0) + - SDWebImage/Core (5.8.0) + - SDWebImageFLPlugin (0.4.0): + - FLAnimatedImage (>= 1.0.11) + - SDWebImage/Core (~> 5.6) + - SwiftProtobuf (1.8.0) + - url_launcher (0.0.1): + - Flutter + - url_launcher_macos (0.0.1): + - Flutter + - url_launcher_web (0.0.1): + - Flutter + +DEPENDENCIES: + - barcode_scan (from `.symlinks/plugins/barcode_scan/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_plugin_android_lifecycle (from `.symlinks/plugins/flutter_plugin_android_lifecycle/ios`) + - flutter_share (from `.symlinks/plugins/flutter_share/ios`) + - MMWormhole (~> 2.0.0) + - package_info (from `.symlinks/plugins/package_info/ios`) + - path_provider (from `.symlinks/plugins/path_provider/ios`) + - path_provider_linux (from `.symlinks/plugins/path_provider_linux/ios`) + - path_provider_macos (from `.symlinks/plugins/path_provider_macos/ios`) + - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + - url_launcher_macos (from `.symlinks/plugins/url_launcher_macos/ios`) + - url_launcher_web (from `.symlinks/plugins/url_launcher_web/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - FLAnimatedImage + - MMWormhole + - MTBBarcodeScanner + - SDWebImage + - SDWebImageFLPlugin + - SwiftProtobuf + +EXTERNAL SOURCES: + barcode_scan: + :path: ".symlinks/plugins/barcode_scan/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_plugin_android_lifecycle: + :path: ".symlinks/plugins/flutter_plugin_android_lifecycle/ios" + flutter_share: + :path: ".symlinks/plugins/flutter_share/ios" + package_info: + :path: ".symlinks/plugins/package_info/ios" + path_provider: + :path: ".symlinks/plugins/path_provider/ios" + path_provider_linux: + :path: ".symlinks/plugins/path_provider_linux/ios" + path_provider_macos: + :path: ".symlinks/plugins/path_provider_macos/ios" + url_launcher: + :path: ".symlinks/plugins/url_launcher/ios" + url_launcher_macos: + :path: ".symlinks/plugins/url_launcher_macos/ios" + url_launcher_web: + :path: ".symlinks/plugins/url_launcher_web/ios" + +SPEC CHECKSUMS: + barcode_scan: a5c27959edfafaa0c771905bad0b29d6d39e4479 + DKImagePickerController: 397702a3590d4958fad336e9a77079935c500ddb + DKPhotoGallery: e880aef16c108333240e1e7327896f2ea380f4f0 + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 + FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31 + Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + flutter_plugin_android_lifecycle: dc0b544e129eebb77a6bfb1239d4d1c673a60a35 + flutter_share: 4be0208963c60b537e6255ed2ce1faae61cd9ac2 + MMWormhole: 0cd3fd35a9118b2e2d762b499f54eeaace0be791 + MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb + package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 + path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c + path_provider_linux: 4d630dc393e1f20364f3e3b4a2ff41d9674a84e4 + path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 + SDWebImage: 84000f962cbfa70c07f19d2234cbfcf5d779b5dc + SDWebImageFLPlugin: 6c2295fb1242d44467c6c87dc5db6b0a13228fd8 + SwiftProtobuf: 2cbd9409689b7df170d82a92a33443c8e3e14a70 + url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef + url_launcher_macos: fd7894421cd39320dce5f292fc99ea9270b2a313 + url_launcher_web: e5527357f037c87560776e36436bf2b0288b965c + +PODFILE CHECKSUM: 40eeeb97ef2165edffec94ac1ddff2d14dd420f7 + +COCOAPODS: 1.9.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8b58192 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,849 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 437F72592469AAC500A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; }; + 437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; }; + 437F725F2469B4B000A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; }; + 437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; }; + 43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43871C9A2444DD39004F9075 /* MobileNebula.framework */; }; + 43871C9D2444E2EC004F9075 /* Sites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43871C9C2444E2EC004F9075 /* Sites.swift */; }; + 43871C9E2444E61F004F9075 /* MobileNebula.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43871C9A2444DD39004F9075 /* MobileNebula.framework */; }; + 43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; }; + 43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */; }; + 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 */; }; + 4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 43AA895A2444DA6500EDC39C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 43AA89532444DA6500EDC39C; + remoteInfo = NebulaNetworkExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 43AA89612444DA6500EDC39C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 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 = ""; }; + 437F72582469AAC500A0C4B9 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = ""; }; + 437F725C2469AC5700A0C4B9 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + 43871C9A2444DD39004F9075 /* MobileNebula.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = MobileNebula.framework; sourceTree = ""; }; + 43871C9C2444E2EC004F9075 /* Sites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sites.swift; sourceTree = ""; }; + 43AA894C2444D8BC00EDC39C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; + 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NebulaNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; + 43AA89582444DA6500EDC39C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NebulaNetworkExtension.entitlements; sourceTree = ""; }; + 43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; + 43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 43B828DA249C08DC00CA229C /* MMWormhole.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MMWormhole.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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8E4961BE2F06B97C8C693530 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 43AA89512444DA6500EDC39C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */, + 43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */, + 4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */, + 43871C9E2444E61F004F9075 /* MobileNebula.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 43AA894D2444D8BC00EDC39C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 43B828DA249C08DC00CA229C /* MMWormhole.framework */, + 43B66ECC245A146300B18C36 /* Foundation.framework */, + 43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */, + 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */, + 384887B4785D38431E800D3A /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 43AA89552444DA6500EDC39C /* NebulaNetworkExtension */ = { + isa = PBXGroup; + children = ( + 437F725C2469AC5700A0C4B9 /* Keychain.swift */, + 43871C9A2444DD39004F9075 /* MobileNebula.framework */, + 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */, + 43AA89582444DA6500EDC39C /* Info.plist */, + 43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */, + 437F72582469AAC500A0C4B9 /* Site.swift */, + ); + path = NebulaNetworkExtension; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 43AA89552444DA6500EDC39C /* NebulaNetworkExtension */, + 97C146EF1CF9000F007C117D /* Products */, + 43AA894D2444D8BC00EDC39C /* Frameworks */, + 9D19B3FACD187D51D2854929 /* Pods */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 43AA894C2444D8BC00EDC39C /* Runner.entitlements */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 43871C9C2444E2EC004F9075 /* Sites.swift */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + ); + name = "Supporting Files"; + sourceTree = ""; + }; + 9D19B3FACD187D51D2854929 /* Pods */ = { + isa = PBXGroup; + children = ( + C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */, + 6E7A71D8C71BF965D042667D /* Pods-Runner.release.xcconfig */, + 8E4961BE2F06B97C8C693530 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 43AA89532444DA6500EDC39C /* NebulaNetworkExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 43AA895D2444DA6500EDC39C /* Build configuration list for PBXNativeTarget "NebulaNetworkExtension" */; + buildPhases = ( + 43AA89632444DAD100EDC39C /* ShellScript */, + 43AA89502444DA6500EDC39C /* Sources */, + 43AA89512444DA6500EDC39C /* Frameworks */, + 43AA89522444DA6500EDC39C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NebulaNetworkExtension; + productName = NebulaNetworkExtension; + productReference = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + FF0E0EB9A684F086443A8FBA /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 43AA89612444DA6500EDC39C /* Embed App Extensions */, + 00C7A79AE88792090BDAC68B /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 43AA895B2444DA6500EDC39C /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1140; + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = "The Chromium Authors"; + TargetAttributes = { + 43AA89532444DA6500EDC39C = { + CreatedOnToolsVersion = 11.4; + DevelopmentTeam = 576H3XS7FP; + ProvisioningStyle = Automatic; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + DevelopmentTeam = 576H3XS7FP; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 43AA89532444DA6500EDC39C /* NebulaNetworkExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 43AA89522444DA6500EDC39C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00C7A79AE88792090BDAC68B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; + }; + 43AA89632444DAD100EDC39C /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd ..\n./gen-artifacts.sh ios\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; + FF0E0EB9A684F086443A8FBA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 43AA89502444DA6500EDC39C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */, + 437F72592469AAC500A0C4B9 /* Site.swift in Sources */, + 437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 43871C9D2444E2EC004F9075 /* Sites.swift in Sources */, + 437F725F2469B4B000A0C4B9 /* Site.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 43AA895B2444DA6500EDC39C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 43AA89532444DA6500EDC39C /* NebulaNetworkExtension */; + targetProxy = 43AA895A2444DA6500EDC39C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 576H3XS7FP; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + "$(PROJECT_DIR)/NebulaNetworkExtension", + ); + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 0.0.30; + PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 43AA895E2444DA6500EDC39C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 576H3XS7FP; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/NebulaNetworkExtension", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = NebulaNetworkExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MARKETING_VERSION = 0.0.30; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 43AA895F2444DA6500EDC39C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 576H3XS7FP; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/NebulaNetworkExtension", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = NebulaNetworkExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MARKETING_VERSION = 0.0.30; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 43AA89602444DA6500EDC39C /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 576H3XS7FP; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/NebulaNetworkExtension", + ); + GCC_C_LANGUAGE_STANDARD = gnu11; + INFOPLIST_FILE = NebulaNetworkExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + MARKETING_VERSION = 0.0.30; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 576H3XS7FP; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + "$(PROJECT_DIR)/NebulaNetworkExtension", + ); + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 0.0.30; + PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 576H3XS7FP; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + "$(PROJECT_DIR)/NebulaNetworkExtension", + ); + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + MARKETING_VERSION = 0.0.30; + PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 43AA895D2444DA6500EDC39C /* Build configuration list for PBXNativeTarget "NebulaNetworkExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 43AA895E2444DA6500EDC39C /* Debug */, + 43AA895F2444DA6500EDC39C /* Release */, + 43AA89602444DA6500EDC39C /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a28140c --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..64f1601 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,316 @@ +import UIKit +import Flutter +import MobileNebula +import NetworkExtension +import MMWormhole + +enum ChannelName { + static let vpn = "net.defined.mobileNebula/NebulaVpnService" +} + +func MissingArgumentError(message: String, details: Any?) -> FlutterError { + return FlutterError(code: "missing_argument", message: message, details: details) +} + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + private var sites: Sites? + private var wormhole = MMWormhole(applicationGroupIdentifier: "group.net.defined.mobileNebula", optionalDirectory: "ipc") + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + + guard let controller = window?.rootViewController as? FlutterViewController else { + fatalError("rootViewController is not type FlutterViewController") + } + + sites = Sites(messenger: controller.binaryMessenger) + let channel = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger) + + NSKeyedUnarchiver.setClass(IPCMessage.classForKeyedUnarchiver(), forClassName: "NebulaNetworkExtension.IPCMessage") + wormhole.listenForMessage(withIdentifier: "nebula", listener: self.wormholeListener) + + channel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result) + case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result) + case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result) + + case "listSites": return self.listSites(result: result) + case "deleteSite": return self.deleteSite(call: call, result: result) + case "saveSite": return self.saveSite(call: call, result: result) + case "startSite": return self.startSite(call: call, result: result) + case "stopSite": return self.stopSite(call: call, result: result) + + case "active.listHostmap": self.activeListHostmap(call: call, result: result) + case "active.listPendingHostmap": self.activeListPendingHostmap(call: call, result: result) + case "active.getHostInfo": self.activeGetHostInfo(call: call, result: result) + case "active.setRemoteForTunnel": self.activeSetRemoteForTunnel(call: call, result: result) + case "active.closeTunnel": self.activeCloseTunnel(call: call, result: result) + + default: + result(FlutterMethodNotImplemented) + } + }) + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func nebulaParseCerts(call: FlutterMethodCall, result: FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let certs = args["certs"] else { return result(MissingArgumentError(message: "certs is a required argument")) } + + var err: NSError? + let json = MobileNebulaParseCerts(certs, &err) + if (err != nil) { + return result(CallFailedError(message: "Error while parsing certificate(s)", details: err!.localizedDescription)) + } + + return result(json) + } + + func nebulaGenerateKeyPair(result: FlutterResult) { + var err: NSError? + let kp = MobileNebulaGenerateKeyPair(&err) + if (err != nil) { + return result(CallFailedError(message: "Error while generating key pairs", details: err!.localizedDescription)) + } + + return result(kp) + } + + func nebulaRenderConfig(call: FlutterMethodCall, result: FlutterResult) { + guard let config = call.arguments as? String else { return result(NoArgumentsError()) } + + var err: NSError? + print(config) + let yaml = MobileNebulaRenderConfig(config, "", &err) + if (err != nil) { + return result(CallFailedError(message: "Error while rendering config", details: err!.localizedDescription)) + } + + return result(yaml) + } + + func listSites(result: @escaping FlutterResult) { + self.sites?.loadSites { (sites, err) -> () in + if (err != nil) { + return result(CallFailedError(message: "Failed to load site list", details: err!.localizedDescription)) + } + + let encoder = JSONEncoder() + let data = try! encoder.encode(sites) + let ret = String(data: data, encoding: .utf8) + result(ret) + } + } + + func deleteSite(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let id = call.arguments as? String else { return result(NoArgumentsError()) } + //TODO: stop the site if its running currently + self.sites?.deleteSite(id: id) { error in + if (error != nil) { + result(CallFailedError(message: "Failed to delete site", details: error!.localizedDescription)) + } + + result(nil) + } + } + + func saveSite(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let json = call.arguments as? String else { return result(NoArgumentsError()) } + guard let data = json.data(using: .utf8) else { return result(NoArgumentsError()) } + + guard let site = try? JSONDecoder().decode(IncomingSite.self, from: data) else { + return result(NoArgumentsError()) + } + + let oldSite = self.sites?.getSite(id: site.id) + site.save(manager: oldSite?.manager) { error in + if (error != nil) { + return result(CallFailedError(message: "Failed to save site", details: error!.localizedDescription)) + } + + result(nil) + } + } + + func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } +#if targetEnvironment(simulator) + let updater = self.sites?.getUpdater(id: id) + updater?.update(connected: true) + +#else + let manager = self.sites?.getSite(id: id)?.manager + manager?.loadFromPreferences{ error in + //TODO: Handle load error + // This is silly but we need to enable the site each time to avoid situations where folks have multiple sites + manager?.isEnabled = true + manager?.saveToPreferences{ error in + //TODO: Handle load error + manager?.loadFromPreferences{ error in + //TODO: Handle load error + do { + try manager?.connection.startVPNTunnel() + } catch { + return result(CallFailedError(message: "Could not start site", details: error.localizedDescription)) + } + + return result(nil) + } + } + } +#endif + } + + func stopSite(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } +#if targetEnvironment(simulator) + let updater = self.sites?.getUpdater(id: id) + updater?.update(connected: false) + +#else + let manager = self.sites?.getSite(id: id)?.manager + manager?.loadFromPreferences{ error in + //TODO: Handle load error + + manager?.connection.stopVPNTunnel() + return result(nil) + } +#endif + } + + func activeListHostmap(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } + //TODO: match id for safety? + wormholeRequestWithCallback(type: "listHostmap", arguments: nil) { (data, err) -> () in + if (err != nil) { + return result(CallFailedError(message: err!.localizedDescription)) + } + + result(data) + } + } + + func activeListPendingHostmap(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } + //TODO: match id for safety? + wormholeRequestWithCallback(type: "listPendingHostmap", arguments: nil) { (data, err) -> () in + if (err != nil) { + return result(CallFailedError(message: err!.localizedDescription)) + } + + result(data) + } + } + + func activeGetHostInfo(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] as? String else { return result(MissingArgumentError(message: "id is a required argument")) } + guard let vpnIp = args["vpnIp"] as? String else { return result(MissingArgumentError(message: "vpnIp is a required argument")) } + let pending = args["pending"] as? Bool ?? false + + //TODO: match id for safety? + wormholeRequestWithCallback(type: "getHostInfo", arguments: ["vpnIp": vpnIp, "pending": pending]) { (data, err) -> () in + if (err != nil) { + return result(CallFailedError(message: err!.localizedDescription)) + } + + result(data) + } + } + + func activeSetRemoteForTunnel(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } + guard let vpnIp = args["vpnIp"] else { return result(MissingArgumentError(message: "vpnIp is a required argument")) } + guard let addr = args["addr"] else { return result(MissingArgumentError(message: "addr is a required argument")) } + + //TODO: match id for safety? + wormholeRequestWithCallback(type: "setRemoteForTunnel", arguments: ["vpnIp": vpnIp, "addr": addr]) { (data, err) -> () in + if (err != nil) { + return result(CallFailedError(message: err!.localizedDescription)) + } + + result(data) + } + } + + func activeCloseTunnel(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? Dictionary else { return result(NoArgumentsError()) } + guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) } + guard let vpnIp = args["vpnIp"] else { return result(MissingArgumentError(message: "vpnIp is a required argument")) } + + //TODO: match id for safety? + wormholeRequestWithCallback(type: "closeTunnel", arguments: ["vpnIp": vpnIp]) { (data, err) -> () in + if (err != nil) { + return result(CallFailedError(message: err!.localizedDescription)) + } + + result(data as? Bool ?? false) + } + } + + func wormholeListener(msg: Any?) { + guard let call = msg as? IPCMessage else { + print("Failed to decode IPCMessage from network extension") + return + } + + switch call.type { + case "error": + guard let updater = self.sites?.getUpdater(id: call.id) else { + return print("Could not find site to deliver error to \(call.id): \(String(describing: call.message))") + } + updater.setError(err: call.message as! String) + + default: + print("Unknown IPC message type \(call.type)") + } + } + + func wormholeRequestWithCallback(type: String, arguments: Dictionary?, completion: @escaping (Any?, Error?) -> ()) { + let uuid = UUID().uuidString + + wormhole.listenForMessage(withIdentifier: uuid) { msg -> () in + self.wormhole.stopListeningForMessage(withIdentifier: uuid) + + guard let call = msg as? IPCMessage else { + completion("", "Failed to decode IPCMessage callback from network extension") + return + } + + switch call.type { + case "error": + completion("", call.message as? String ?? "Failed to convert error") + case "success": + completion(call.message, nil) + + default: + completion("", "Unknown IPC message type \(call.type)") + } + } + + wormhole.passMessageObject(IPCRequest(callbackId: uuid, type: type, arguments: arguments), identifier: "app") + } +} + +func MissingArgumentError(message: String, details: Error? = nil) -> FlutterError { + return FlutterError(code: "missingArgument", message: message, details: details) +} + +func NoArgumentsError(message: String? = "no arguments were provided or could not be deserialized", details: Error? = nil) -> FlutterError { + return FlutterError(code: "noArguments", message: message, details: details) +} + +func CallFailedError(message: String, details: String? = "") -> FlutterError { + return FlutterError(code: "callFailed", message: message, details: details) +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f091b6b0bca859a3f474b03065bef75ba58a9e4c GIT binary patch literal 1588 zcmV-42Fv-0P)C1SqPt}wig>|5Crh^=oyX$BK<}M8eLU3e2hGT;=G|!_SP)7zNI6fqUMB=)y zRAZ>eDe#*r`yDAVgB_R*LB*MAc)8(b{g{9McCXW!lq7r(btRoB9!8B-#AI6JMb~YFBEvdsV)`mEQO^&#eRKx@b&x- z5lZm*!WfD8oCLzfHGz#u7sT0^VLMI1MqGxF^v+`4YYnVYgk*=kU?HsSz{v({E3lb9 z>+xILjBN)t6`=g~IBOelGQ(O990@BfXf(DRI5I$qN$0Gkz-FSc$3a+2fX$AedL4u{ z4V+5Ong(9LiGcIKW?_352sR;LtDPmPJXI{YtT=O8=76o9;*n%_m|xo!i>7$IrZ-{l z-x3`7M}qzHsPV@$v#>H-TpjDh2UE$9g6sysUREDy_R(a)>=eHw-WAyfIN z*qb!_hW>G)Tu8nSw9yn#3wFMiLcfc4pY0ek1}8(NqkBR@t4{~oC>ryc-h_ByH(Cg5 z>ao-}771+xE3um9lWAY1FeQFxowa1(!J(;Jg*wrg!=6FdRX+t_<%z&d&?|Bn){>zm zZQj(aA_HeBY&OC^jj*)N`8fa^ePOU72VpInJoI1?`ty#lvlNzs(&MZX+R%2xS~5Kh zX*|AU4QE#~SgPzOXe9>tRj>hjU@c1k5Y_mW*Jp3fI;)1&g3j|zDgC+}2Q_v%YfDax z!?umcN^n}KYQ|a$Lr+51Nf9dkkYFSjZZjkma$0KOj+;aQ&721~t7QUKx61J3(P4P1 zstI~7-wOACnWP4=8oGOwz%vNDqD8w&Q`qcNGGrbbf&0s9L0De{4{mRS?o0MU+nR_! zrvshUau0G^DeMhM_v{5BuLjb#Hh@r23lDAk8oF(C+P0rsBpv85EP>4CVMx#04MOfG z;P%vktHcXwTj~+IE(~px)3*MY77e}p#|c>TD?sMatC0Tu4iKKJ0(X8jxQY*gYtxsC z(zYC$g|@+I+kY;dg_dE>scBf&bP1Nc@Hz<3R)V`=AGkc;8CXqdi=B4l2k|g;2%#m& z*jfX^%b!A8#bI!j9-0Fi0bOXl(-c^AB9|nQaE`*)Hw+o&jS9@7&Gov#HbD~#d{twV zXd^Tr^mWLfFh$@Dr$e;PBEz4(-2q1FF0}c;~B5sA}+Q>TOoP+t>wf)V9Iy=5ruQa;z)y zI9C9*oUga6=hxw6QasLPnee@3^Rr*M{CdaL5=R41nLs(AHk_=Y+A9$2&H(B7!_pURs&8aNw7?`&Z&xY_Ye z)~D5Bog^td-^QbUtkTirdyK^mTHAOuptDflut!#^lnKqU md>ggs(5nOWAqO?umG&QVYK#ibz}*4>0000U6E9hRK9^#O7(mu>ETqrXGsduA8$)?`v2seloOCza43C{NQ$$gAOH**MCn0Q?+L7dl7qnbRdqZ8LSVp1ItDxhxD?t@5_yHg6A8yI zC*%Wgg22K|8E#!~cTNYR~@Y9KepMPrrB8cABapAFa=`H+UGhkXUZV1GnwR1*lPyZ;*K(i~2gp|@bzp8}og7e*#% zEnr|^CWdVV!-4*Y_7rFvlww2Ze+>j*!Z!pQ?2l->4q#nqRu9`ELo6RMS5=br47g_X zRw}P9a7RRYQ%2Vsd0Me{_(EggTnuN6j=-?uFS6j^u69elMypu?t>op*wBx<=Wx8?( ztpe^(fwM6jJX7M-l*k3kEpWOl_Vk3@(_w4oc}4YF4|Rt=2V^XU?#Yz`8(e?aZ@#li0n*=g^qOcVpd-Wbok=@b#Yw zqn8u9a)z>l(1kEaPYZ6hwubN6i<8QHgsu0oE) ziJ(p;Wxm>sf!K+cw>R-(^Y2_bahB+&KI9y^);#0qt}t-$C|Bo71lHi{_+lg#f%RFy z0um=e3$K3i6K{U_4K!EX?F&rExl^W|G8Z8;`5z-k}OGNZ0#WVb$WCpQu-_YsiqKP?BB# vzVHS-CTUF4Ozn5G+mq_~Qqto~ahA+K`|lyv3(-e}00000NkvXXu0mjfd`9t{ literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d0ef06e7edb86cdfe0d15b4b0d98334a86163658 GIT binary patch literal 1716 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f9ed8f5cee1c98386d13b17e89f719e83555b2 GIT binary patch literal 1895 zcmV-t2blPYP)FQtfgmafE#=YDCq`qUBt#QpG%*H6QHY765~R=q zZ6iudfM}q!Pz#~9JgOi8QJ|DSu?1-*(kSi1K4#~5?#|rh?sS)(-JQqX*}ciXJ56_H zdw=^s_srbAdqxlvGyrgGet#6T7_|j;95sL%MtM;q86vOxKM$f#puR)Bjv9Zvz9-di zXOTSsZkM83)E9PYBXC<$6(|>lNLVBb&&6y{NByFCp%6+^ALR@NCTse_wqvNmSWI-m z!$%KlHFH2omF!>#%1l3LTZg(s7eof$7*xB)ZQ0h?ejh?Ta9fDv59+u#MokW+1t8Zb zgHv%K(u9G^Lv`lh#f3<6!JVTL3(dCpxHbnbA;kKqQyd1~^Xe0VIaYBSWm6nsr;dFj z4;G-RyL?cYgsN1{L4ZFFNa;8)Rv0fM0C(~Tkit94 zz#~A)59?QjD&pAPSEQ)p8gP|DS{ng)j=2ux)_EzzJ773GmQ_Cic%3JJhC0t2cx>|v zJcVusIB!%F90{+}8hG3QU4KNeKmK%T>mN57NnCZ^56=0?&3@!j>a>B43pi{!u z7JyDj7`6d)qVp^R=%j>UIY6f+3`+qzIc!Y_=+uN^3BYV|o+$vGo-j-Wm<10%A=(Yk^beI{t%ld@yhKjq0iNjqN4XMGgQtbKubPM$JWBz}YA65k%dm*awtC^+f;a-x4+ddbH^7iDWGg&N0n#MW{kA|=8iMUiFYvMoDY@sPC#t$55gn6ykUTPAr`a@!(;np824>2xJthS z*ZdmT`g5-`BuJs`0LVhz+D9NNa3<=6m;cQLaF?tCv8)zcRSh66*Z|vXhG@$I%U~2l z?`Q zykI#*+rQ=z6Jm=Bui-SfpDYLA=|vzGE(dYm=OC8XM&MDo7ux4UF1~0J1+i%aCUpRe zt3L_uNyQ*cE(38Uy03H%I*)*Bh=Lb^Xj3?I^Hnbeq72(EOK^Y93CNp*uAA{5Lc=ky zx=~RKa4{iTm{_>_vSCm?$Ej=i6@=m%@VvAITnigVg{&@!7CDgs908761meDK5azA} z4?=NOH|PdvabgJ&fW2{Mo$Q0CcD8Qc84%{JPYt5EiG{MdLIAeX%T=D7NIP4%Hw}p9 zg)==!2Lbp#j{u_}hMiao9=!VSyx0gHbeCS`;q&vzeq|fs`y&^X-lso(Ls@-706qmA z7u*T5PMo_w3{se1t2`zWeO^hOvTsohG_;>J0wVqVe+n)AbQCx)yh9;w+J6?NF5Lmo zecS@ieAKL8%bVd@+-KT{yI|S}O>pYckUFs;ry9Ow$CD@ztz5K-*D$^{i(_1llhSh^ zEkL$}tsQt5>QA^;QgjgIfBDmcOgi5YDyu?t6vSnbp=1+@6D& z5MJ}B8q;bRlVoxasyhcUF1+)o`&3r0colr}QJ3hcSdLu;9;td>kf@Tcn<@9sIx&=m z;AD;SCh95=&p;$r{Xz3iWCO^MX83AGJ(yH&eTXgv|0=34#-&WAmw{)U7OU9!Wz^!7 zZ%jZFi@JR;>Mhi7S>V7wQ176|FdW2m?&`qa(ScO^CFPR80HucLHOTy%5s*HR0^8)i h0WYBP*#0Ks^FNSabJA*5${_#%002ovPDHLkV1oKhTl@e3 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..75b2d164a5a98e212cca15ea7bf2ab5de5108680 GIT binary patch literal 3831 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..c4df70d39da7941ef3f6dcb7f06a192d8dcb308d GIT binary patch literal 1888 zcmV-m2cP(fP)x~L`~4d)Rspd&<9kFh{hn*KP1LP0~$;u(LfAu zp%fx&qLBcRHx$G|3q(bv@+b;o0*D|jwD-Q9uQR(l*ST}s+uPgQ-MeFwZ#GS?b332? z&Tk$&_miXn3IGq)AmQ)3sisq{raD4(k*bHvpCe-TdWq^NRTEVM)i9xbgQ&ccnUVx* zEY%vS%gDcSg=!tuIK8$Th2_((_h^+7;R|G{n06&O2#6%LK`a}n?h_fL18btz<@lFG za}xS}u?#DBMB> zw^b($1Z)`9G?eP95EKi&$eOy@K%h;ryrR3la%;>|o*>CgB(s>dDcNOXg}CK9SPmD? zmr-s{0wRmxUnbDrYfRvnZ@d z6johZ2sMX{YkGSKWd}m|@V7`Degt-43=2M?+jR%8{(H$&MLLmS;-|JxnX2pnz;el1jsvqQz}pGSF<`mqEXRQ5sC4#BbwnB_4` zc5bFE-Gb#JV3tox9fp-vVEN{(tOCpRse`S+@)?%pz+zVJXSooTrNCUg`R6`hxwb{) zC@{O6MKY8tfZ5@!yy=p5Y|#+myRL=^{tc(6YgAnkg3I(Cd!r5l;|;l-MQ8B`;*SCE z{u)uP^C$lOPM z5d~UhKhRRmvv{LIa^|oavk1$QiEApSrP@~Jjbg`<*dW4TO?4qG%a%sTPUFz(QtW5( zM)lA+5)0TvH~aBaOAs|}?u2FO;yc-CZ1gNM1dAxJ?%m?YsGR`}-xk2*dxC}r5j$d* zE!#Vtbo69h>V4V`BL%_&$} z+oJAo@jQ^Tk`;%xw-4G>hhb&)B?##U+(6Fi7nno`C<|#PVA%$Y{}N-?(Gc$1%tr4Pc}}hm~yY#fTOe!@v9s-ik$dX~|ygArPhByaXn8 zpI^FUjNWMsTFKTP3X7m?UK)3m zp6rI^_zxRYrx6_QmhoWoDR`fp4R7gu6;gdO)!KexaoO2D88F9x#TM1(9Bn7g;|?|o z)~$n&Lh#hCP6_LOPD>a)NmhW})LADx2kq=X7}7wYRj-0?dXr&bHaRWCfSqvzFa=sn z-8^gSyn-RmH=BZ{AJZ~!8n5621GbUJV7Qvs%JNv&$%Q17s_X%s-41vAPfIR>;x0Wlqr5?09S>x#%Qkt>?(&XjFRY}*L6BeQ3 z<6XEBh^S7>AbwGm@XP{RkeEKj6@_o%oV?hDuUpUJ+r#JZO?!IUc;r0R?>mi)*ZpQ) z#((dn=A#i_&EQn|hd)N$#A*fjBFuiHcYvo?@y1 z5|fV=a^a~d!c-%ZbMNqkMKiSzM{Yq=7_c&1H!mXk60Uv32dV;vMg&-kQ)Q{+PFtwc zj|-uQ;b^gts??J*9VxxOro}W~Q9j4Em|zSRv)(WSO9$F$s=Ydu%Q+5DOid~lwk&we zY%W(Z@ofdwPHncEZzZgmqS|!gTj3wQq9rxQy+^eNYKr1mj&?tm@wkO*9@UtnRMG>c aR{jt9+;fr}hV%pg00001^@s67{VYS000c7NklQEG_j zup^)eW&WUIApqy$=APz8jE@awGp)!bsTjDbrJO`$x^ZR^dr;>)LW>{ zs70vpsD38v)19rI=GNk1b(0?Js9~rjsQsu*K;@SD40RB-3^gKU-MYC7G!Bw{fZsqp zih4iIi;Hr_xZ033Iu{sQxLS=}yBXgLMn40d++>aQ0#%8D1EbGZp7+ z5=mK?t31BkVYbGOxE9`i748x`YgCMwL$qMsChbSGSE1`p{nSmadR zcQ#R)(?!~dmtD0+D2!K zR9%!Xp1oOJzm(vbLvT^$IKp@+W2=-}qTzTgVtQ!#Y7Gxz}stUIm<1;oBQ^Sh2X{F4ibaOOx;5ZGSNK z0maF^@(UtV$=p6DXLgRURwF95C=|U8?osGhgOED*b z7woJ_PWXBD>V-NjQAm{~T%sjyJ{5tn2f{G%?J!KRSrrGvQ1(^`YLA5B!~eycY(e5_ z*%aa{at13SxC(=7JT7$IQF~R3sy`Nn%EMv!$-8ZEAryB*yB1k&stni)=)8-ODo41g zkJu~roIgAih94tb=YsL%iH5@^b~kU9M-=aqgXIrbtxMpFy5mekFm#edF9z7RQ6V}R zBIhbXs~pMzt0VWy1Fi$^fh+1xxLDoK09&5&MJl(q#THjPm(0=z2H2Yfm^a&E)V+a5 zbi>08u;bJsDRUKR9(INSc7XyuWv(JsD+BB*0hS)FO&l&7MdViuur@-<-EHw>kHRGY zqoT}3fDv2-m{NhBG8X}+rgOEZ;amh*DqN?jEfQdqxdj08`Sr=C-KmT)qU1 z+9Cl)a1mgXxhQiHVB}l`m;-RpmKy?0*|yl?FXvJkFxuu!fKlcmz$kN(a}i*saM3nr z0!;a~_%Xqy24IxA2rz<+08=B-Q|2PT)O4;EaxP^6qixOv7-cRh?*T?zZU`{nIM-at zTKYWr9rJ=tppQ9I#Z#mLgINVB!pO-^FOcvFw6NhV0gztuO?g ztoA*C-52Q-Z-P#xB4HAY3KQVd%dz1S4PA3vHp0aa=zAO?FCt zC_GaTyVBg2F!bBr3U@Zy2iJgIAt>1sf$JWA9kh{;L+P*HfUBX1Zy{4MgNbDfBV_ly z!y#+753arsZUt@366jIC0klaC@ckuk!qu=pAyf7&QmiBUT^L1&tOHzsK)4n|pmrVT zs2($4=?s~VejTFHbFdDOwG;_58LkIj1Fh@{glkO#F1>a==ymJS$z;gdedT1zPx4Kj ztjS`y_C}%af-RtpehdQDt3a<=W5C4$)9W@QAse;WUry$WYmr51ml9lkeunUrE`-3e zmq1SgSOPNEE-Mf+AGJ$g0M;3@w!$Ej;hMh=v=I+Lpz^n%Pg^MgwyqOkNyu2c^of)C z1~ALor3}}+RiF*K4+4{(1%1j3pif1>sv0r^mTZ?5Jd-It!tfPfiG_p$AY*Vfak%FG z4z#;wLtw&E&?}w+eKG^=#jF7HQzr8rV0mY<1YAJ_uGz~$E13p?F^fPSzXSn$8UcI$ z8er9{5w5iv0qf8%70zV71T1IBB1N}R5Kp%NO0=5wJalZt8;xYp;b{1K) zHY>2wW-`Sl{=NpR%iu3(u6l&)rc%%cSA#aV7WCowfbFR4wcc{LQZv~o1u_`}EJA3>ki`?9CKYTA!rhO)if*zRdd}Kn zEPfYbhoVE~!FI_2YbC5qAj1kq;xP6%J8+?2PAs?`V3}nyFVD#sV3+uP`pi}{$l9U^ zSz}_M9f7RgnnRhaoIJgT8us!1aB&4!*vYF07Hp&}L zCRlop0oK4DL@ISz{2_BPlezc;xj2|I z23RlDNpi9LgTG_#(w%cMaS)%N`e>~1&a3<{Xy}>?WbF>OOLuO+j&hc^YohQ$4F&ze z+hwnro1puQjnKm;vFG~o>`kCeUIlkA-2tI?WBKCFLMBY=J{hpSsQ=PDtU$=duS_hq zHpymHt^uuV1q@uc4bFb{MdG*|VoW@15Osrqt2@8ll0qO=j*uOXn{M0UJX#SUztui9FN4)K3{9!y8PC-AHHvpVTU;x|-7P+taAtyglk#rjlH2 z5Gq8ik}BPaGiM{#Woyg;*&N9R2{J0V+WGB69cEtH7F?U~Kbi6ksi*`CFXsi931q7Y zGO82?whBhN%w1iDetv%~wM*Y;E^)@Vl?VDj-f*RX>{;o_=$fU!&KAXbuadYZ46Zbg z&6jMF=49$uL^73y;;N5jaHYv)BTyfh&`qVLYn?`o6BCA_z-0niZz=qPG!vonK3MW_ zo$V96zM!+kJRs{P-5-rQVse0VBH*n6A58)4uc&gfHMa{gIhV2fGf{st>E8sKyP-$8zp~wJX^A*@DI&-;8>gANXZj zU)R+Y)PB?=)a|Kj>8NXEu^S_h^7R`~Q&7*Kn!xyvzVv&^>?^iu;S~R2e-2fJx-oUb cX)(b1KSk$MOV07*qoM6N<$f&6$jw%VRuvdN2+38CZWny1cRtlsl+0_KtW)EU14Ei(F!UtWuj4IK+3{sK@>rh zs1Z;=(DD&U6+tlyL?UnHVN^&g6QhFi2#HS+*qz;(>63G(`|jRtW|nz$Pv7qTovP!^ zP_jES{mr@O-02w%!^a?^1ZP!_KmQiz0L~jZ=W@Qt`8wzOoclQsAS<5YdH;a(4bGLE zk8s}1If(PSIgVi!XE!5kA?~z*sobvNyohr;=Q_@h2@$6Flyej3J)D-6YfheRGl`HEcPk|~huT_2-U?PfL=4BPV)f1o!%rQ!NMt_MYw-5bUSwQ9Z&zC>u zOrl~UJglJNa%f50Ok}?WB{on`Ci`p^Y!xBA?m@rcJXLxtrE0FhRF3d*ir>yzO|BD$ z3V}HpFcCh6bTzY}Nt_(W%QYd3NG)jJ4<`F<1Od) zfQblTdC&h2lCz`>y?>|9o2CdvC8qZeIZt%jN;B7Hdn2l*k4M4MFEtq`q_#5?}c$b$pf_3y{Y!cRDafZBEj-*OD|gz#PBDeu3QoueOesLzB+O zxjf2wvf6Wwz>@AiOo2mO4=TkAV+g~%_n&R;)l#!cBxjuoD$aS-`IIJv7cdX%2{WT7 zOm%5rs(wqyPE^k5SIpUZ!&Lq4<~%{*>_Hu$2|~Xa;iX*tz8~G6O3uFOS?+)tWtdi| zV2b#;zRN!m@H&jd=!$7YY6_}|=!IU@=SjvGDFtL;aCtw06U;-v^0%k0FOyESt z1Wv$={b_H&8FiRV?MrzoHWd>%v6KTRU;-v^Miiz+@q`(BoT!+<37CKhoKb)|8!+RG z6BQFU^@fRW;s8!mOf2QViKQGk0TVER6EG1`#;Nm39Do^PoT!+<37AD!%oJe86(=et zZ~|sLzU>V-qYiU6V8$0GmU7_K8|Fd0B?+9Un1BhKAz#V~Fk^`mJtlCX#{^8^M8!me z8Yg;8-~>!e<-iG;h*0B1kBKm}hItVGY6WnjVpgnTTAC$rqQ^v)4KvOtpY|sIj@WYg zyw##ZZ5AC2IKNC;^hwg9BPk0wLStlmBr;E|$5GoAo$&Ui_;S9WY62n3)i49|T%C#i017z3J=$RF|KyZWnci*@lW4 z=AKhNN6+m`Q!V3Ye68|8y@%=am>YD0nG99M)NWc20%)gwO!96j7muR}Fr&54SxKP2 zP30S~lt=a*qDlbu3+Av57=9v&vr<6g0&`!8E2fq>I|EJGKs}t|{h7+KT@)LfIV-3K zK)r_fr2?}FFyn*MYoLC>oV-J~eavL2ho4a4^r{E-8m2hi>~hA?_vIG4a*KT;2eyl1 zh_hUvUJpNCFwBvRq5BI*srSle>c6%n`#VNsyC|MGa{(P&08p=C9+WUw9Hl<1o9T4M zdD=_C0F7#o8A_bRR?sFNmU0R6tW`ElnF8p53IdHo#S9(JoZCz}fHwJ6F<&?qrpVqE zte|m%89JQD+XwaPU#%#lVs-@-OL);|MdfINd6!XwP2h(eyafTUsoRkA%&@fe?9m@jw-v(yTTiV2(*fthQH9}SqmsRPVnwwbV$1E(_lkmo&S zF-truCU914_$jpqjr(>Ha4HkM4YMT>m~NosUu&UZ>zirfHo%N6PPs9^_o$WqPA0#5 z%tG>qFCL+b*0s?sZ;Sht0nE7Kl>OVXy=gjWxxK;OJ3yGd7-pZf7JYNcZo2*1SF`u6 zHJyRRxGw9mDlOiXqVMsNe#WX`fC`vrtjSQ%KmLcl(lC>ZOQzG^%iql2w-f_K@r?OE zwCICifM#L-HJyc7Gm>Ern?+Sk3&|Khmu4(~3qa$(m6Ub^U0E5RHq49za|XklN#?kP zl;EstdW?(_4D>kwjWy2f!LM)y?F94kyU3`W!6+AyId-89v}sXJpuic^NLL7GJItl~ zsiuB98AI-(#Mnm|=A-R6&2fwJ0JVSY#Q>&3$zFh|@;#%0qeF=j5Ajq@4i0tIIW z&}sk$&fGwoJpe&u-JeGLi^r?dO`m=y(QO{@h zQqAC7$rvz&5+mo3IqE?h=a~6m>%r5Quapvzq;{y~p zJpyXOBgD9VrW7@#p6l7O?o3feml(DtSL>D^R) zZUY%T2b0-vBAFN7VB;M88!~HuOXi4KcI6aRQ&h|XQ0A?m%j2=l1f0cGP}h(oVfJ`N zz#PpmFC*ieab)zJK<4?^k=g%OjPnkANzbAbmGZHoVRk*mTfm75s_cWVa`l*f$B@xu z5E*?&@seIo#*Y~1rBm!7sF9~~u6Wrj5oICUOuz}CS)jdNIznfzCA(stJ(7$c^e5wN z?lt>eYgbA!kvAR7zYSD&*r1$b|(@;9dcZ^67R0 zXAXJKa|5Sdmj!g578Nwt6d$sXuc&MWezA0Whd`94$h{{?1IwXP4)Tx4obDK%xoFZ_Z zjjHJ_P@R_e5blG@yEjnaJb`l;s%Lb2&=8$&Ct-fV`E^4CUs)=jTk!I}2d&n!f@)bm z@ z_4Dc86+3l2*p|~;o-Sb~oXb_RuLmoifDU^&Te$*FevycC0*nE3Xws8gsWp|Rj2>SM zns)qcYj?^2sd8?N!_w~4v+f-HCF|a$TNZDoNl$I1Uq87euoNgKb6&r26TNrfkUa@o zfdiFA@p{K&mH3b8i!lcoz)V{n8Q@g(vR4ns4r6w;K z>1~ecQR0-<^J|Ndg5fvVUM9g;lbu-){#ghGw(fg>L zh)T5Ljb%lWE;V9L!;Cqk>AV1(rULYF07ZBJbGb9qbSoLAd;in9{)95YqX$J43-dY7YU*k~vrM25 zxh5_IqO0LYZW%oxQ5HOzmk4x{atE*vipUk}sh88$b2tn?!ujEHn`tQLe&vo}nMb&{ zio`xzZ&GG6&ZyN3jnaQy#iVqXE9VT(3tWY$n-)uWDQ|tc{`?fq2F`oQ{;d3aWPg4Hp-(iE{ry>MIPWL> iW8Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..8adf96b --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nebula + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSCameraUsageDescription + Camera permission is required for qr code scanning. + NSPhotoLibraryUsageDescription + Just in case you store a nebula certificate as a photo, we aren't interested in your photos but a file picker might come across them + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..7335fdf --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" \ No newline at end of file diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..fe9a9e3 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + com.apple.security.application-groups + + group.net.defined.mobileNebula + + keychain-access-groups + + $(AppIdentifierPrefix)group.net.defined.mobileNebula + + + diff --git a/ios/Runner/Sites.swift b/ios/Runner/Sites.swift new file mode 100644 index 0000000..b653147 --- /dev/null +++ b/ios/Runner/Sites.swift @@ -0,0 +1,163 @@ +import NetworkExtension +import MobileNebula + +class SiteContainer { + var site: Site + var updater: SiteUpdater + + init(site: Site, updater: SiteUpdater) { + self.site = site + self.updater = updater + } +} + +class Sites { + private var sites = [String: SiteContainer]() + private var messenger: FlutterBinaryMessenger? + + init(messenger: FlutterBinaryMessenger?) { + self.messenger = messenger + } + + func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) { +#if targetEnvironment(simulator) + let fileManager = FileManager.default + let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites") + var configPaths: [URL] + + do { + if (!fileManager.fileExists(atPath: documentsURL.absoluteString)) { + try fileManager.createDirectory(at: documentsURL, withIntermediateDirectories: true) + } + configPaths = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) + } catch { + return completion(nil, error) + } + + configPaths.forEach { path in + do { + let config = try Data(contentsOf: path) + let decoder = JSONDecoder() + let incoming = try decoder.decode(IncomingSite.self, from: config) + let site = try Site(incoming: incoming) + let updater = SiteUpdater(messenger: self.messenger!, site: site) + self.sites[site.id] = SiteContainer(site: site, updater: updater) + } catch { + print(error) + // try? fileManager.removeItem(at: path) + print("Deleted non conforming site \(path)") + } + } + + let justSites = self.sites.mapValues { + return $0.site + } + completion(justSites, nil) + +#else + NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in + if (err != nil) { + return completion(nil, err) + } + + newManagers?.forEach { manager in + do { + let site = try Site(manager: manager) + // Load the private key to make sure we can + _ = try site.getKey() + let updater = SiteUpdater(messenger: self.messenger!, site: site) + self.sites[site.id] = SiteContainer(site: site, updater: updater) + } catch { + //TODO: notify the user about this + print("Deleted non conforming site \(manager) \(error)") + manager.removeFromPreferences() + } + } + + let justSites = self.sites.mapValues { + return $0.site + } + completion(justSites, nil) + } +#endif + } + + func deleteSite(id: String, callback: @escaping (Error?) -> ()) { + if let site = self.sites.removeValue(forKey: id) { +#if targetEnvironment(simulator) + let fileManager = FileManager.default + let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(site.site.id) + try? fileManager.removeItem(at: sitePath) +#else + _ = KeyChain.delete(key: site.site.id) + site.site.manager.removeFromPreferences(completionHandler: callback) +#endif + } + + // Nothing to remove + callback(nil) + } + + func getSite(id: String) -> Site? { + return self.sites[id]?.site + } + + func getUpdater(id: String) -> SiteUpdater? { + return self.sites[id]?.updater + } +} + +class SiteUpdater: NSObject, FlutterStreamHandler { + private var eventSink: FlutterEventSink?; + private var eventChannel: FlutterEventChannel; + private var site: Site + private var notification: Any? + + init(messenger: FlutterBinaryMessenger, site: Site) { + eventChannel = FlutterEventChannel(name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger) + self.site = site + super.init() + eventChannel.setStreamHandler(self) + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events; + + self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager.connection , queue: nil) { _ in + + self.site.status = statusString[self.site.manager.connection.status] + self.site.connected = statusMap[self.site.manager.connection.status] + + let d: Dictionary = [ + "connected": self.site.connected!, + "status": self.site.status!, + ] + self.eventSink?(d) + } + + return nil + } + + func setError(err: String) { + let d: Dictionary = [ + "connected": self.site.connected!, + "status": self.site.status!, + ] + self.eventSink?(FlutterError(code: "", message: err, details: d)) + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + if (self.notification != nil) { + NotificationCenter.default.removeObserver(self.notification!) + } + return nil + } + + func update(connected: Bool) { + let d: Dictionary = [ + "connected": connected, + "status": connected ? "Connected" : "Disconnected", + ] + self.eventSink?(d) + } +} diff --git a/lib/components/CIDRField.dart b/lib/components/CIDRField.dart new file mode 100644 index 0000000..50e3972 --- /dev/null +++ b/lib/components/CIDRField.dart @@ -0,0 +1,102 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/SpecialTextField.dart'; +import 'package:mobile_nebula/models/CIDR.dart'; +import '../services/utils.dart'; +import 'IPField.dart'; + +//TODO: Support initialValue +class CIDRField extends StatefulWidget { + const CIDRField({ + Key key, + this.ipHelp = "ip address", + this.autoFocus = false, + this.focusNode, + this.nextFocusNode, + this.onChanged, + this.textInputAction, + this.ipController, + this.bitsController, + }) : super(key: key); + + final String ipHelp; + final bool autoFocus; + final FocusNode focusNode; + final FocusNode nextFocusNode; + final ValueChanged onChanged; + final TextInputAction textInputAction; + final TextEditingController ipController; + final TextEditingController bitsController; + + @override + _CIDRFieldState createState() => _CIDRFieldState(); +} + +//TODO: if the keyboard is open on the port field and you switch to dark mode, it crashes +//TODO: maybe add in a next/done step for numeric keyboards +//TODO: rig up focus node and next node +//TODO: rig up textInputAction +class _CIDRFieldState extends State { + final bitsFocus = FocusNode(); + final cidr = CIDR(); + + @override + void initState() { + //TODO: this won't track external controller changes appropriately + cidr.ip = widget.ipController?.text ?? ""; + cidr.bits = int.tryParse(widget.bitsController?.text ?? ""); + super.initState(); + } + + @override + Widget build(BuildContext context) { + var textStyle = CupertinoTheme.of(context).textTheme.textStyle; + + return Container( + child: Row(children: [ + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(6, 6, 2, 6), + child: IPField( + help: widget.ipHelp, + ipOnly: true, + textPadding: EdgeInsets.all(0), + textInputAction: TextInputAction.next, + textAlign: TextAlign.end, + focusNode: widget.focusNode, + nextFocusNode: bitsFocus, + onChanged: (val) { + cidr.ip = val; + widget.onChanged(cidr); + }, + controller: widget.ipController, + ))), + Text("/"), + Container( + width: Utils.textSize("bits", textStyle).width + 12, + padding: EdgeInsets.fromLTRB(2, 6, 6, 6), + child: SpecialTextField( + keyboardType: TextInputType.number, + focusNode: bitsFocus, + nextFocusNode: widget.nextFocusNode, + controller: widget.bitsController, + onChanged: (val) { + cidr.bits = int.tryParse(val ?? ""); + widget.onChanged(cidr); + }, + maxLength: 2, + inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], + textInputAction: widget.textInputAction ?? TextInputAction.done, + placeholder: 'bits', + )) + ])); + } + + @override + void dispose() { + bitsFocus.dispose(); + super.dispose(); + } +} diff --git a/lib/components/CIDRFormField.dart b/lib/components/CIDRFormField.dart new file mode 100644 index 0000000..1101f3b --- /dev/null +++ b/lib/components/CIDRFormField.dart @@ -0,0 +1,170 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/CIDRField.dart'; +import 'package:mobile_nebula/models/CIDR.dart'; +import 'package:mobile_nebula/validators/ipValidator.dart'; + +class CIDRFormField extends FormField { + //TODO: onSaved, validator, autovalidate, enabled? + CIDRFormField({ + Key key, + autoFocus = false, + focusNode, + nextFocusNode, + ValueChanged onChanged, + FormFieldSetter onSaved, + textInputAction, + CIDR initialValue, + this.ipController, + this.bitsController, + }) : super( + key: key, + initialValue: initialValue, + onSaved: onSaved, + validator: (cidr) { + if (cidr == null) { + return "Please fill out this field"; + } + + if (!ipValidator(cidr.ip)) { + return 'Please enter a valid ip address'; + } + + if (cidr.bits == null || cidr.bits > 32 || cidr.bits < 0) { + return "Please enter a valid number of bits"; + } + + return null; + }, + builder: (FormFieldState field) { + final _CIDRFormField state = field; + + void onChangedHandler(CIDR value) { + if (onChanged != null) { + onChanged(value); + } + field.didChange(value); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + CIDRField( + autoFocus: autoFocus, + focusNode: focusNode, + nextFocusNode: nextFocusNode, + onChanged: onChangedHandler, + textInputAction: textInputAction, + ipController: state._effectiveIPController, + bitsController: state._effectiveBitsController, + ), + field.hasError + ? Text(field.errorText, + style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13), + textAlign: TextAlign.end) + : Container(height: 0) + ]); + }); + + final TextEditingController ipController; + final TextEditingController bitsController; + + @override + _CIDRFormField createState() => _CIDRFormField(); +} + +class _CIDRFormField extends FormFieldState { + TextEditingController _ipController; + TextEditingController _bitsController; + + TextEditingController get _effectiveIPController => widget.ipController ?? _ipController; + TextEditingController get _effectiveBitsController => widget.bitsController ?? _bitsController; + + @override + CIDRFormField get widget => super.widget; + + @override + void initState() { + super.initState(); + if (widget.ipController == null) { + _ipController = TextEditingController(text: widget.initialValue.ip); + } else { + widget.ipController.addListener(_handleControllerChanged); + } + + if (widget.bitsController == null) { + _bitsController = TextEditingController(text: widget.initialValue?.bits?.toString() ?? ""); + } else { + widget.bitsController.addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(CIDRFormField oldWidget) { + super.didUpdateWidget(oldWidget); + var update = CIDR(ip: widget.ipController?.text, bits: int.tryParse(widget.bitsController?.text ?? "") ?? null); + bool shouldUpdate = false; + + if (widget.ipController != oldWidget.ipController) { + oldWidget.ipController?.removeListener(_handleControllerChanged); + widget.ipController?.addListener(_handleControllerChanged); + + if (oldWidget.ipController != null && widget.ipController == null) { + _ipController = TextEditingController.fromValue(oldWidget.ipController.value); + } + + if (widget.ipController != null) { + shouldUpdate = true; + update.ip = widget.ipController.text; + if (oldWidget.ipController == null) _ipController = null; + } + } + + if (widget.bitsController != oldWidget.bitsController) { + oldWidget.bitsController?.removeListener(_handleControllerChanged); + widget.bitsController?.addListener(_handleControllerChanged); + + if (oldWidget.bitsController != null && widget.bitsController == null) { + _bitsController = TextEditingController.fromValue(oldWidget.bitsController.value); + } + + if (widget.bitsController != null) { + shouldUpdate = true; + update.bits = int.parse(widget.bitsController.text); + if (oldWidget.bitsController == null) _bitsController = null; + } + } + + if (shouldUpdate) { + setValue(update); + } + } + + @override + void dispose() { + widget.ipController?.removeListener(_handleControllerChanged); + widget.bitsController?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveIPController.text = widget.initialValue.ip; + _effectiveBitsController.text = widget.initialValue.bits.toString(); + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + final effectiveBits = int.parse(_effectiveBitsController.text); + if (_effectiveIPController.text != value.ip || effectiveBits != value.bits) { + didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits)); + } + } +} diff --git a/lib/components/FormPage.dart b/lib/components/FormPage.dart new file mode 100644 index 0000000..73cbad1 --- /dev/null +++ b/lib/components/FormPage.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +/// SimplePage with a form and built in validation and confirmation to discard changes if any are made +class FormPage extends StatefulWidget { + const FormPage( + {Key key, this.title, @required this.child, @required this.onSave, @required this.changed, this.hideSave = false}) + : super(key: key); + + final String title; + final Function onSave; + final Widget child; + + /// If you need the page to progress to a certain point before saving, control it here + final bool hideSave; + + /// Useful if you have a non form field that can change, overrides the internal changed state if true + final bool changed; + + @override + _FormPageState createState() => _FormPageState(); +} + +class _FormPageState extends State { + var changed = false; + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + changed = widget.changed || changed; + + return WillPopScope( + onWillPop: () { + if (!changed) { + return Future.value(true); + } + + var completer = Completer(); + + Utils.confirmDelete(context, 'Discard changes?', () { + completer.complete(true); + }, deleteLabel: 'Yes', cancelLabel: 'No'); + + return completer.future; + }, + child: SimplePage( + leadingAction: _buildLeader(context), + trailingActions: _buildTrailer(context), + title: widget.title, + child: Form( + key: _formKey, + onChanged: () => setState(() { + changed = true; + }), + child: widget.child), + )); + } + + Widget _buildLeader(BuildContext context) { + return Utils.leadingBackWidget(context, label: changed ? 'Cancel' : 'Back', onPressed: () { + if (changed) { + Utils.confirmDelete(context, 'Discard changes?', () { + changed = false; + Navigator.pop(context); + }, deleteLabel: 'Yes', cancelLabel: 'No'); + } else { + Navigator.pop(context); + } + }); + } + + List _buildTrailer(BuildContext context) { + if (!changed || widget.hideSave) { + return []; + } + + return [ + Utils.trailingSaveWidget( + context, + () { + if (!_formKey.currentState.validate()) { + return; + } + + _formKey.currentState.save(); + widget.onSave(); + }, + ) + ]; + } +} diff --git a/lib/components/IPAndPortField.dart b/lib/components/IPAndPortField.dart new file mode 100644 index 0000000..7b78367 --- /dev/null +++ b/lib/components/IPAndPortField.dart @@ -0,0 +1,108 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/SpecialTextField.dart'; +import 'package:mobile_nebula/models/IPAndPort.dart'; +import '../services/utils.dart'; +import 'IPField.dart'; + +//TODO: Support initialValue +class IPAndPortField extends StatefulWidget { + const IPAndPortField({ + Key key, + this.ipOnly = false, + this.ipHelp = "ip address", + this.autoFocus = false, + this.focusNode, + this.nextFocusNode, + this.onChanged, + this.textInputAction, + this.noBorder = false, + this.ipTextAlign, + this.ipController, + this.portController, + }) : super(key: key); + + final String ipHelp; + final bool ipOnly; + final bool autoFocus; + final FocusNode focusNode; + final FocusNode nextFocusNode; + final ValueChanged onChanged; + final TextInputAction textInputAction; + final bool noBorder; + final TextAlign ipTextAlign; + final TextEditingController ipController; + final TextEditingController portController; + + @override + _IPAndPortFieldState createState() => _IPAndPortFieldState(); +} + +//TODO: if the keyboard is open on the port field and you switch to dark mode, it crashes +//TODO: maybe add in a next/done step for numeric keyboards +//TODO: rig up focus node and next node +//TODO: rig up textInputAction +class _IPAndPortFieldState extends State { + final _portFocus = FocusNode(); + final _ipAndPort = IPAndPort(); + + @override + void initState() { + //TODO: this won't track external controller changes appropriately + _ipAndPort.ip = widget.ipController?.text ?? ""; + _ipAndPort.port = int.tryParse(widget.portController?.text ?? ""); + super.initState(); + } + + @override + Widget build(BuildContext context) { + var textStyle = CupertinoTheme.of(context).textTheme.textStyle; + + return Container( + child: Row(children: [ + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(6, 6, 2, 6), + child: IPField( + help: widget.ipHelp, + ipOnly: widget.ipOnly, + nextFocusNode: _portFocus, + textPadding: EdgeInsets.all(0), + textInputAction: TextInputAction.next, + focusNode: widget.focusNode, + onChanged: (val) { + _ipAndPort.ip = val; + widget.onChanged(_ipAndPort); + }, + textAlign: widget.ipTextAlign, + controller: widget.ipController, + ))), + Text(":"), + Container( + width: Utils.textSize("00000", textStyle).width + 12, + padding: EdgeInsets.fromLTRB(2, 6, 6, 6), + child: SpecialTextField( + keyboardType: TextInputType.number, + focusNode: _portFocus, + nextFocusNode: widget.nextFocusNode, + controller: widget.portController, + onChanged: (val) { + _ipAndPort.port = int.tryParse(val ?? ""); + widget.onChanged(_ipAndPort); + }, + maxLength: 5, + inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], + textInputAction: TextInputAction.done, + placeholder: 'port', + )) + ])); + } + + @override + void dispose() { + _portFocus.dispose(); + super.dispose(); + } +} diff --git a/lib/components/IPAndPortFormField.dart b/lib/components/IPAndPortFormField.dart new file mode 100644 index 0000000..30db081 --- /dev/null +++ b/lib/components/IPAndPortFormField.dart @@ -0,0 +1,180 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/models/IPAndPort.dart'; +import 'package:mobile_nebula/validators/dnsValidator.dart'; +import 'package:mobile_nebula/validators/ipValidator.dart'; + +import 'IPAndPortField.dart'; + +class IPAndPortFormField extends FormField { + //TODO: onSaved, validator, autovalidate, enabled? + IPAndPortFormField({ + Key key, + ipOnly = false, + ipHelp = "ip address", + autoFocus = false, + focusNode, + nextFocusNode, + ValueChanged onChanged, + FormFieldSetter onSaved, + textInputAction, + IPAndPort initialValue, + noBorder, + ipTextAlign = TextAlign.center, + this.ipController, + this.portController, + }) : super( + key: key, + initialValue: initialValue, + onSaved: onSaved, + validator: (ipAndPort) { + if (ipAndPort == null) { + return "Please fill out this field"; + } + + if (!ipValidator(ipAndPort.ip) && (!ipOnly && !dnsValidator(ipAndPort.ip))) { + return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name'; + } + + if (ipAndPort.port == null || ipAndPort.port > 65535 || ipAndPort.port < 0) { + return "Please enter a valid port"; + } + + return null; + }, + builder: (FormFieldState field) { + final _IPAndPortFormField state = field; + + void onChangedHandler(IPAndPort value) { + if (onChanged != null) { + onChanged(value); + } + field.didChange(value); + } + + return Column(children: [ + IPAndPortField( + ipOnly: ipOnly, + ipHelp: ipHelp, + autoFocus: autoFocus, + focusNode: focusNode, + nextFocusNode: nextFocusNode, + onChanged: onChangedHandler, + textInputAction: textInputAction, + ipController: state._effectiveIPController, + portController: state._effectivePortController, + noBorder: noBorder, + ipTextAlign: ipTextAlign, + ), + field.hasError + ? Text(field.errorText, + style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13)) + : Container(height: 0) + ]); + }); + + final TextEditingController ipController; + final TextEditingController portController; + + @override + _IPAndPortFormField createState() => _IPAndPortFormField(); +} + +class _IPAndPortFormField extends FormFieldState { + TextEditingController _ipController; + TextEditingController _portController; + + TextEditingController get _effectiveIPController => widget.ipController ?? _ipController; + TextEditingController get _effectivePortController => widget.portController ?? _portController; + + @override + IPAndPortFormField get widget => super.widget; + + @override + void initState() { + super.initState(); + if (widget.ipController == null) { + _ipController = TextEditingController(text: widget.initialValue.ip); + } else { + widget.ipController.addListener(_handleControllerChanged); + } + + if (widget.portController == null) { + _portController = TextEditingController(text: widget.initialValue?.port?.toString() ?? ""); + } else { + widget.portController.addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(IPAndPortFormField oldWidget) { + super.didUpdateWidget(oldWidget); + var update = + IPAndPort(ip: widget.ipController?.text, port: int.tryParse(widget.portController?.text ?? "") ?? null); + bool shouldUpdate = false; + + if (widget.ipController != oldWidget.ipController) { + oldWidget.ipController?.removeListener(_handleControllerChanged); + widget.ipController?.addListener(_handleControllerChanged); + + if (oldWidget.ipController != null && widget.ipController == null) { + _ipController = TextEditingController.fromValue(oldWidget.ipController.value); + } + + if (widget.ipController != null) { + shouldUpdate = true; + update.ip = widget.ipController.text; + if (oldWidget.ipController == null) _ipController = null; + } + } + + if (widget.portController != oldWidget.portController) { + oldWidget.portController?.removeListener(_handleControllerChanged); + widget.portController?.addListener(_handleControllerChanged); + + if (oldWidget.portController != null && widget.portController == null) { + _portController = TextEditingController.fromValue(oldWidget.portController.value); + } + + if (widget.portController != null) { + shouldUpdate = true; + update.port = int.parse(widget.portController.text); + if (oldWidget.portController == null) _portController = null; + } + } + + if (shouldUpdate) { + setValue(update); + } + } + + @override + void dispose() { + widget.ipController?.removeListener(_handleControllerChanged); + widget.portController?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveIPController.text = widget.initialValue.ip; + _effectivePortController.text = widget.initialValue.port.toString(); + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + final effectivePort = int.parse(_effectivePortController.text); + if (_effectiveIPController.text != value.ip || effectivePort != value.port) { + didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort)); + } + } +} diff --git a/lib/components/IPField.dart b/lib/components/IPField.dart new file mode 100644 index 0000000..d71a1c7 --- /dev/null +++ b/lib/components/IPField.dart @@ -0,0 +1,59 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SpecialTextField.dart'; +import '../services/utils.dart'; + +class IPField extends StatelessWidget { + final String help; + final bool ipOnly; + final bool autoFocus; + final FocusNode focusNode; + final FocusNode nextFocusNode; + final ValueChanged onChanged; + final EdgeInsetsGeometry textPadding; + final TextInputAction textInputAction; + final controller; + final textAlign; + + const IPField( + {Key key, + this.ipOnly = false, + this.help = "ip address", + this.autoFocus = false, + this.focusNode, + this.nextFocusNode, + this.onChanged, + this.textPadding = const EdgeInsets.all(6.0), + this.textInputAction, + this.controller, + this.textAlign = TextAlign.center}) + : super(key: key); + + @override + Widget build(BuildContext context) { + var textStyle = CupertinoTheme.of(context).textTheme.textStyle; + final double ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null; + + return SizedBox( + width: ipWidth, + child: SpecialTextField( + keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true) : null, + textAlign: textAlign, + autofocus: autoFocus, + focusNode: focusNode, + nextFocusNode: nextFocusNode, + controller: controller, + onChanged: onChanged, + maxLength: ipOnly ? 15 : null, + maxLengthEnforced: ipOnly ? true : false, + inputFormatters: ipOnly + ? [WhitelistingTextInputFormatter(RegExp(r'[\d\.]+'))] + : [WhitelistingTextInputFormatter(RegExp(r'[^\s]+'))], + textInputAction: this.textInputAction, + placeholder: help, + )); + } +} diff --git a/lib/components/IPFormField.dart b/lib/components/IPFormField.dart new file mode 100644 index 0000000..4cbe129 --- /dev/null +++ b/lib/components/IPFormField.dart @@ -0,0 +1,140 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/validators/dnsValidator.dart'; +import 'package:mobile_nebula/validators/ipValidator.dart'; + +import 'IPField.dart'; + +//TODO: reset doesn't update the ui but clears the field + +class IPFormField extends FormField { + //TODO: validator, autovalidate, enabled? + IPFormField({ + Key key, + ipOnly = false, + help = "ip address", + autoFocus = false, + focusNode, + nextFocusNode, + ValueChanged onChanged, + FormFieldSetter onSaved, + textPadding = const EdgeInsets.all(6.0), + textInputAction, + initialValue, + this.controller, + crossAxisAlignment = CrossAxisAlignment.center, + textAlign = TextAlign.center, + }) : super( + key: key, + initialValue: initialValue, + onSaved: onSaved, + validator: (ip) { + if (ip == null || ip == "") { + return "Please fill out this field"; + } + + if (!ipValidator(ip) || (!ipOnly && !dnsValidator(ip))) { + print(ip); + return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name'; + } + + return null; + }, + builder: (FormFieldState field) { + final _IPFormField state = field; + + void onChangedHandler(String value) { + if (onChanged != null) { + onChanged(value); + } + field.didChange(value); + } + + return Column(crossAxisAlignment: crossAxisAlignment, children: [ + IPField( + ipOnly: ipOnly, + help: help, + autoFocus: autoFocus, + focusNode: focusNode, + nextFocusNode: nextFocusNode, + onChanged: onChangedHandler, + textPadding: textPadding, + textInputAction: textInputAction, + controller: state._effectiveController, + textAlign: textAlign), + field.hasError + ? Text( + field.errorText, + style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13), + textAlign: textAlign, + ) + : Container(height: 0) + ]); + }); + + final TextEditingController controller; + + @override + _IPFormField createState() => _IPFormField(); +} + +class _IPFormField extends FormFieldState { + TextEditingController _controller; + + TextEditingController get _effectiveController => widget.controller ?? _controller; + + @override + IPFormField get widget => super.widget; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = TextEditingController(text: widget.initialValue); + } else { + widget.controller.addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(IPFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.removeListener(_handleControllerChanged); + widget.controller?.addListener(_handleControllerChanged); + + if (oldWidget.controller != null && widget.controller == null) + _controller = TextEditingController.fromValue(oldWidget.controller.value); + if (widget.controller != null) { + setValue(widget.controller.text); + if (oldWidget.controller == null) _controller = null; + } + } + } + + @override + void dispose() { + widget.controller?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveController.text = widget.initialValue; + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController.text != value) didChange(_effectiveController.text); + } +} diff --git a/lib/components/PlatformTextFormField.dart b/lib/components/PlatformTextFormField.dart new file mode 100644 index 0000000..78ded7b --- /dev/null +++ b/lib/components/PlatformTextFormField.dart @@ -0,0 +1,150 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/SpecialTextField.dart'; +//TODO: reset doesn't update the ui but clears the field + +class PlatformTextFormField extends FormField { + //TODO: autovalidate, enabled? + PlatformTextFormField( + {Key key, + widgetKey, + this.controller, + focusNode, + nextFocusNode, + TextInputType keyboardType, + textInputAction, + List inputFormatters, + textAlign, + autofocus, + maxLines = 1, + maxLength, + maxLengthEnforced, + onChanged, + keyboardAppearance, + minLines, + expands, + suffix, + textAlignVertical, + String initialValue, + String placeholder, + FormFieldValidator validator, + ValueChanged onSaved}) + : super( + key: key, + initialValue: controller != null ? controller.text : (initialValue ?? ''), + onSaved: onSaved, + validator: (str) { + if (validator != null) { + return validator(str); + } + + return null; + }, + builder: (FormFieldState field) { + final _PlatformTextFormFieldState state = field; + + void onChangedHandler(String value) { + if (onChanged != null) { + onChanged(value); + } + field.didChange(value); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + SpecialTextField( + key: widgetKey, + controller: state._effectiveController, + focusNode: focusNode, + nextFocusNode: nextFocusNode, + keyboardType: keyboardType, + textInputAction: textInputAction, + textAlign: textAlign, + autofocus: autofocus, + maxLines: maxLines, + maxLength: maxLength, + maxLengthEnforced: maxLengthEnforced, + onChanged: onChangedHandler, + keyboardAppearance: keyboardAppearance, + minLines: minLines, + expands: expands, + textAlignVertical: textAlignVertical, + placeholder: placeholder, + inputFormatters: inputFormatters, + suffix: suffix), + field.hasError + ? Text( + field.errorText, + style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13), + textAlign: textAlign, + ) + : Container(height: 0) + ]); + }); + + final TextEditingController controller; + + @override + _PlatformTextFormFieldState createState() => _PlatformTextFormFieldState(); +} + +class _PlatformTextFormFieldState extends FormFieldState { + TextEditingController _controller; + + TextEditingController get _effectiveController => widget.controller ?? _controller; + + @override + PlatformTextFormField get widget => super.widget; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = TextEditingController(text: widget.initialValue); + } else { + widget.controller.addListener(_handleControllerChanged); + } + } + + @override + void didUpdateWidget(PlatformTextFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.removeListener(_handleControllerChanged); + widget.controller?.addListener(_handleControllerChanged); + + if (oldWidget.controller != null && widget.controller == null) + _controller = TextEditingController.fromValue(oldWidget.controller.value); + if (widget.controller != null) { + setValue(widget.controller.text); + if (oldWidget.controller == null) _controller = null; + } + } + } + + @override + void dispose() { + widget.controller?.removeListener(_handleControllerChanged); + super.dispose(); + } + + @override + void reset() { + super.reset(); + setState(() { + _effectiveController.text = widget.initialValue; + }); + } + + void _handleControllerChanged() { + // Suppress changes that originated from within this class. + // + // In the case where a controller has been passed in to this widget, we + // register this change listener. In these cases, we'll also receive change + // notifications for changes originating from within this class -- for + // example, the reset() method. In such cases, the FormField value will + // already have been set. + if (_effectiveController.text != value) didChange(_effectiveController.text); + } +} diff --git a/lib/components/SimplePage.dart b/lib/components/SimplePage.dart new file mode 100644 index 0000000..ade5700 --- /dev/null +++ b/lib/components/SimplePage.dart @@ -0,0 +1,105 @@ +import 'package:flutter/cupertino.dart' as cupertino; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +enum SimpleScrollable { + none, + vertical, + horizontal, + both, +} + +class SimplePage extends StatelessWidget { + const SimplePage( + {Key key, + this.title, + @required this.child, + this.leadingAction, + this.trailingActions = const [], + this.scrollable = SimpleScrollable.vertical, + this.scrollbar = true, + this.scrollController, + this.bottomBar, + this.onRefresh, + this.onLoading, + this.refreshController}) + : super(key: key); + + final String title; + final Widget child; + final SimpleScrollable scrollable; + final ScrollController scrollController; + + /// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorderable listviews + /// This is set to true if you have any scrollable other than none + final bool scrollbar; + final Widget bottomBar; + + /// If no leading action is provided then a default "Back" widget than pops the page will be provided + final Widget leadingAction; + final List trailingActions; + + final VoidCallback onRefresh; + final VoidCallback onLoading; + final RefreshController refreshController; + + + @override + Widget build(BuildContext context) { + Widget realChild = child; + var addScrollbar = this.scrollbar; + + if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) { + realChild = SingleChildScrollView(scrollDirection: Axis.vertical, child: realChild, controller: refreshController == null ? scrollController : null); + addScrollbar = true; + } + + if (scrollable == SimpleScrollable.horizontal || scrollable == SimpleScrollable.both) { + realChild = SingleChildScrollView(scrollDirection: Axis.horizontal, child: realChild); + addScrollbar = true; + } + + if (refreshController != null) { + realChild = RefreshConfiguration( + headerTriggerDistance: 100, + footerTriggerDistance: -100, + maxUnderScrollExtent: 100, + child: SmartRefresher( + scrollController: scrollController, + onRefresh: onRefresh, + onLoading: onLoading, + controller: refreshController, + child: realChild, + enablePullUp: onLoading != null, + enablePullDown: onRefresh != null, + footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading), + )); + addScrollbar = true; + } + + if (addScrollbar) { + realChild = Scrollbar(child: realChild); + } + + if (bottomBar != null) { + realChild = Column(children: [ + Expanded(child: realChild), + bottomBar, + ]); + } + + return PlatformScaffold( + backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context), + appBar: PlatformAppBar( + title: Text(title), + leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context), + trailingActions: trailingActions, + ios: (_) => CupertinoNavigationBarData( + transitionBetweenRoutes: false, + ), + ), + body: SafeArea(child: realChild)); + } +} diff --git a/lib/components/SiteItem.dart b/lib/components/SiteItem.dart new file mode 100644 index 0000000..60150a6 --- /dev/null +++ b/lib/components/SiteItem.dart @@ -0,0 +1,51 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/components/SpecialButton.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class SiteItem extends StatelessWidget { + const SiteItem({Key key, this.site, this.onPressed}) : super(key: key); + + final Site site; + final onPressed; + + @override + Widget build(BuildContext context) { + final borderColor = site.errors.length > 0 + ? CupertinoColors.systemRed.resolveFrom(context) + : site.connected + ? CupertinoColors.systemGreen.resolveFrom(context) + : CupertinoColors.systemGrey2.resolveFrom(context); + final border = BorderSide(color: borderColor, width: 10); + + return Container( + margin: EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration(border: Border(left: border)), + child: _buildContent(context)); + } + + Widget _buildContent(BuildContext context) { + final border = BorderSide(color: Utils.configSectionBorder(context)); + var ip = "Error"; + if (site.cert != null) { + ip = site.cert.cert.details.ips[0]; + } + + return SpecialButton( + decoration: + BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)), + onPressed: onPressed, + child: Padding( + padding: EdgeInsets.fromLTRB(10, 10, 5, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text(ip, textAlign: TextAlign.end)), + Padding(padding: EdgeInsets.only(right: 10)), + Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) + ], + ))); + } +} diff --git a/lib/components/SpecialButton.dart b/lib/components/SpecialButton.dart new file mode 100644 index 0000000..cd04346 --- /dev/null +++ b/lib/components/SpecialButton.dart @@ -0,0 +1,145 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +// This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to +class SpecialButton extends StatefulWidget { + const SpecialButton({Key key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration}) : super(key: key); + + final Widget child; + final Color color; + final bool useButtonTheme; + final BoxDecoration decoration; + + final Function onPressed; + + @override + _SpecialButtonState createState() => _SpecialButtonState(); +} + +class _SpecialButtonState extends State with SingleTickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return Platform.isAndroid ? _buildAndroid() : _buildGeneric(); + } + + Widget _buildAndroid() { + var textStyle; + if (widget.useButtonTheme) { + textStyle = Theme.of(context).textTheme.button; + } + + return Material( + textStyle: textStyle, + child: Ink( + decoration: widget.decoration, + color: widget.color, + child: InkWell( + child: widget.child, + onTap: widget.onPressed, + ))); + } + + Widget _buildGeneric() { + var textStyle = CupertinoTheme.of(context).textTheme.textStyle; + if (widget.useButtonTheme) { + textStyle = CupertinoTheme.of(context).textTheme.actionTextStyle; + } + + return Container( + decoration: widget.decoration, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + onTap: widget.onPressed, + child: Semantics( + button: true, + child: FadeTransition( + opacity: _opacityAnimation, + child: DefaultTextStyle(style: textStyle, child: Container(child: widget.child, color: widget.color)), + ), + ), + ) + ); + } + + // Eyeballed values. Feel free to tweak. + static const Duration kFadeOutDuration = Duration(milliseconds: 10); + static const Duration kFadeInDuration = Duration(milliseconds: 100); + final Tween _opacityTween = Tween(begin: 1.0); + + AnimationController _animationController; + Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + value: 0.0, + vsync: this, + ); + _opacityAnimation = _animationController.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween); + _setTween(); + } + + @override + void didUpdateWidget(SpecialButton old) { + super.didUpdateWidget(old); + _setTween(); + } + + void _setTween() { + _opacityTween.end = 0.4; + } + + @override + void dispose() { + _animationController.dispose(); + _animationController = null; + super.dispose(); + } + + bool _buttonHeldDown = false; + + void _handleTapDown(TapDownDetails event) { + if (!_buttonHeldDown) { + _buttonHeldDown = true; + _animate(); + } + } + + void _handleTapUp(TapUpDetails event) { + if (_buttonHeldDown) { + _buttonHeldDown = false; + _animate(); + } + } + + void _handleTapCancel() { + if (_buttonHeldDown) { + _buttonHeldDown = false; + _animate(); + } + } + + void _animate() { + if (_animationController.isAnimating) { + return; + } + + final bool wasHeldDown = _buttonHeldDown; + final TickerFuture ticker = _buttonHeldDown + ? _animationController.animateTo(1.0, duration: kFadeOutDuration) + : _animationController.animateTo(0.0, duration: kFadeInDuration); + + ticker.then((void value) { + if (mounted && wasHeldDown != _buttonHeldDown) { + _animate(); + } + }); + } +} diff --git a/lib/components/SpecialTextField.dart b/lib/components/SpecialTextField.dart new file mode 100644 index 0000000..ac5deca --- /dev/null +++ b/lib/components/SpecialTextField.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +/// A normal TextField or CupertinoTextField that watches for copy, paste, cut, or select all keyboard actions +class SpecialTextField extends StatefulWidget { + const SpecialTextField( + {Key key, + this.placeholder, + this.suffix, + this.controller, + this.focusNode, + this.nextFocusNode, + this.autocorrect, + this.minLines, + this.maxLines, + this.maxLength, + this.maxLengthEnforced, + this.style, + this.keyboardType, + this.textInputAction, + this.textCapitalization, + this.textAlign, + this.autofocus, + this.onChanged, + this.expands, + this.keyboardAppearance, + this.textAlignVertical, + this.inputFormatters}) + : super(key: key); + + final String placeholder; + final TextEditingController controller; + final FocusNode focusNode; + final FocusNode nextFocusNode; + final bool autocorrect; + final int minLines; + final int maxLines; + final int maxLength; + final bool maxLengthEnforced; + final Widget suffix; + final TextStyle style; + final TextInputType keyboardType; + final Brightness keyboardAppearance; + + final TextInputAction textInputAction; + final TextCapitalization textCapitalization; + final TextAlign textAlign; + final TextAlignVertical textAlignVertical; + + final bool autofocus; + final ValueChanged onChanged; + final List inputFormatters; + final bool expands; + + @override + _SpecialTextFieldState createState() => _SpecialTextFieldState(); +} + +class _SpecialTextFieldState extends State { + FocusNode _focusNode = FocusNode(); + List formatters; + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + void initState() { + formatters = widget.inputFormatters; + if (formatters == null || formatters.length == 0) { + formatters = [WhitelistingTextInputFormatter(RegExp(r'[^\t]'))]; + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return RawKeyboardListener( + focusNode: _focusNode, + onKey: _onKey, + child: PlatformTextField( + autocorrect: widget.autocorrect, + minLines: widget.minLines, + maxLines: widget.maxLines, + maxLength: widget.maxLength, + maxLengthEnforced: widget.maxLengthEnforced, + keyboardType: widget.keyboardType, + keyboardAppearance: widget.keyboardAppearance, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + autofocus: widget.autofocus, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + onSubmitted: (_) { + if (widget.nextFocusNode != null) { + FocusScope.of(context).requestFocus(widget.nextFocusNode); + } + }, + expands: widget.expands, + inputFormatters: formatters, + android: (_) => MaterialTextFieldData( + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + hintText: widget.placeholder, + counterText: '', + suffix: widget.suffix)), + ios: (_) => CupertinoTextFieldData( + decoration: BoxDecoration(), + padding: EdgeInsets.zero, + placeholder: widget.placeholder, + suffix: widget.suffix), + style: widget.style, + controller: widget.controller)); + } + + _onKey(RawKeyEvent event) { + // We don't care about key up events + if (event is RawKeyUpEvent) { + return; + } + + if (event.logicalKey == LogicalKeyboardKey.tab) { + // Handle tab to the next node + if (widget.nextFocusNode != null) { + FocusScope.of(context).requestFocus(widget.nextFocusNode); + } + return; + } + + // Handle special keyboard events with control key + if (event.data.isControlPressed) { + // Handle paste + if (event.logicalKey == LogicalKeyboardKey.keyV) { + Clipboard.getData("text/plain").then((data) { + // Adjust our clipboard entry to confirm with the leftover space if we have maxLength + var text = data.text; + if (widget.maxLength != null && widget.maxLength > 0) { + var leftover = widget.maxLength - widget.controller.text.length; + if (leftover < data.text.length) { + text = text.substring(0, leftover); + } + } + + // If maxLength took us to 0 then bail + if (text.length == 0) { + return; + } + + var end = widget.controller.selection.end; + var start = widget.controller.selection.start; + + // Insert our paste buffer into the selection, which can be 0 selected text (normal caret) + widget.controller.text = widget.controller.selection.textBefore(widget.controller.text) + + text + + widget.controller.selection.textAfter(widget.controller.text); + + // Adjust our caret to be at the end of the pasted contents, need to take into account the size of the selection + // We may want runes instead of + end += text.length - (end - start); + widget.controller.selection = TextSelection(baseOffset: end, extentOffset: end); + }); + + return; + } + + // Handle select all + if (event.logicalKey == LogicalKeyboardKey.keyA) { + widget.controller.selection = TextSelection(baseOffset: 0, extentOffset: widget.controller.text.length); + return; + } + + // Handle copy + if (event.logicalKey == LogicalKeyboardKey.keyC) { + Clipboard.setData(ClipboardData(text: widget.controller.selection.textInside(widget.controller.text))); + return; + } + + // Handle cut + if (event.logicalKey == LogicalKeyboardKey.keyX) { + Clipboard.setData(ClipboardData(text: widget.controller.selection.textInside(widget.controller.text))); + + var start = widget.controller.selection.start; + widget.controller.text = widget.controller.selection.textBefore(widget.controller.text) + + widget.controller.selection.textAfter(widget.controller.text); + widget.controller.selection = TextSelection(baseOffset: start, extentOffset: start); + return; + } + } + } +} diff --git a/lib/components/config/ConfigButtonItem.dart b/lib/components/config/ConfigButtonItem.dart new file mode 100644 index 0000000..c0c9b0b --- /dev/null +++ b/lib/components/config/ConfigButtonItem.dart @@ -0,0 +1,41 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SpecialButton.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +// A config item that detects tapping and calls back on a tap +class ConfigButtonItem extends StatelessWidget { + const ConfigButtonItem({Key key, this.content, this.onPressed}) : super(key: key); + + final Widget content; + final onPressed; + + @override + Widget build(BuildContext context) { + return SpecialButton( + color: Utils.configItemBackground(context), + onPressed: onPressed, + useButtonTheme: true, + child: Container( + constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), + child: Center(child: content), + )); + + return Container( + color: Utils.configItemBackground(context), + constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), + child: PlatformButton( + androidFlat: (_) => MaterialFlatButtonData( + textTheme: ButtonTextTheme.normal, padding: EdgeInsets.zero, shape: RoundedRectangleBorder()), + ios: (_) => CupertinoButtonData(padding: EdgeInsets.zero, borderRadius: BorderRadius.zero), + padding: EdgeInsets.symmetric(vertical: 7), + child: content, + onPressed: () { + if (onPressed != null) { + onPressed(); + } + }, + )); + } +} diff --git a/lib/components/config/ConfigCheckboxItem.dart b/lib/components/config/ConfigCheckboxItem.dart new file mode 100644 index 0000000..ce7bc86 --- /dev/null +++ b/lib/components/config/ConfigCheckboxItem.dart @@ -0,0 +1,46 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/components/SpecialButton.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class ConfigCheckboxItem extends StatelessWidget { + const ConfigCheckboxItem({Key key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked}) + : super(key: key); + + final Widget label; + final Widget content; + final double labelWidth; + final bool checked; + final Function onChanged; + + @override + Widget build(BuildContext context) { + Widget item = Container( + padding: EdgeInsets.only(left: 15), + constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + label != null ? Container(width: labelWidth, child: label) : Container(), + Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))), + checked + ? Icon(CupertinoIcons.check_mark, color: CupertinoColors.systemBlue.resolveFrom(context), size: 34) + : Container() + ], + )); + + if (onChanged != null) { + return SpecialButton( + color: Utils.configItemBackground(context), + child: item, + onPressed: () { + if (onChanged != null) { + onChanged(); + } + }, + ); + } else { + return item; + } + } +} diff --git a/lib/components/config/ConfigHeader.dart b/lib/components/config/ConfigHeader.dart new file mode 100644 index 0000000..3a51fb4 --- /dev/null +++ b/lib/components/config/ConfigHeader.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +TextStyle basicTextStyle(BuildContext context) => + Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.subhead; + +const double _headerFontSize = 13.0; + +class ConfigHeader extends StatelessWidget { + const ConfigHeader({Key key, this.label, this.color}) : super(key: key); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(left: 10.0, top: 30.0, bottom: 5.0, right: 10.0), + child: Text( + label, + style: basicTextStyle(context).copyWith( + color: color ?? CupertinoColors.secondaryLabel.resolveFrom(context), + fontSize: _headerFontSize, + ), + ), + ); + } +} diff --git a/lib/components/config/ConfigItem.dart b/lib/components/config/ConfigItem.dart new file mode 100644 index 0000000..f4fef54 --- /dev/null +++ b/lib/components/config/ConfigItem.dart @@ -0,0 +1,29 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class ConfigItem extends StatelessWidget { + const ConfigItem( + {Key key, this.label, this.content, this.labelWidth = 100, this.crossAxisAlignment = CrossAxisAlignment.center}) + : super(key: key); + + final Widget label; + final Widget content; + final double labelWidth; + final CrossAxisAlignment crossAxisAlignment; + + @override + Widget build(BuildContext context) { + return Container( + color: Utils.configItemBackground(context), + padding: EdgeInsets.only(top: 2, bottom: 2, left: 15, right: 10), + constraints: BoxConstraints(minHeight: Utils.minInteractiveSize), + child: Row( + crossAxisAlignment: crossAxisAlignment, + children: [ + Container(width: labelWidth, child: label), + Expanded(child: content), + ], + )); + } +} diff --git a/lib/components/config/ConfigPageItem.dart b/lib/components/config/ConfigPageItem.dart new file mode 100644 index 0000000..09a7c68 --- /dev/null +++ b/lib/components/config/ConfigPageItem.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/components/SpecialButton.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class ConfigPageItem extends StatelessWidget { + const ConfigPageItem( + {Key key, + this.label, + this.content, + this.labelWidth = 100, + this.onPressed, + this.crossAxisAlignment = CrossAxisAlignment.center}) + : super(key: key); + + final Widget label; + final Widget content; + final double labelWidth; + final CrossAxisAlignment crossAxisAlignment; + final onPressed; + + @override + Widget build(BuildContext context) { + var theme; + + if (Platform.isAndroid) { + final origTheme = Theme.of(context); + theme = origTheme.copyWith( + textTheme: + origTheme.textTheme.copyWith(button: origTheme.textTheme.button.copyWith(fontWeight: FontWeight.normal))); + return Theme(data: theme, child: _buildContent(context)); + } else { + final origTheme = CupertinoTheme.of(context); + theme = origTheme.copyWith(primaryColor: CupertinoColors.label.resolveFrom(context)); + return CupertinoTheme(data: theme, child: _buildContent(context)); + } + } + + Widget _buildContent(BuildContext context) { + return SpecialButton( + onPressed: onPressed, + color: Utils.configItemBackground(context), + child: Container( + padding: EdgeInsets.only(left: 15), + constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), + child: Row( + crossAxisAlignment: crossAxisAlignment, + children: [ + label != null ? Container(width: labelWidth, child: label) : Container(), + Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))), + Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) + ], + )), + ); + } +} diff --git a/lib/components/config/ConfigSection.dart b/lib/components/config/ConfigSection.dart new file mode 100644 index 0000000..6a2edc1 --- /dev/null +++ b/lib/components/config/ConfigSection.dart @@ -0,0 +1,45 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +import 'ConfigHeader.dart'; + +class ConfigSection extends StatelessWidget { + const ConfigSection({Key key, this.label, this.children, this.borderColor, this.labelColor}) : super(key: key); + + final List children; + final String label; + final Color borderColor; + final Color labelColor; + + @override + Widget build(BuildContext context) { + final border = BorderSide(color: borderColor ?? Utils.configSectionBorder(context)); + + List _children = []; + final len = children.length; + + for (var i = 0; i < len; i++) { + _children.add(children[i]); + + if (i < len - 1) { + double pad = 15; + if (children[i + 1].runtimeType.toString() == 'ConfigButtonItem') { + pad = 0; + } + _children.add(Padding( + child: Divider(height: 1, color: Utils.configSectionBorder(context)), padding: EdgeInsets.only(left: pad))); + } + } + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + label != null ? ConfigHeader(label: label, color: labelColor) : Container(height: 20), + Container( + decoration: + BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)), + child: Column( + children: _children, + )) + ]); + } +} diff --git a/lib/components/config/ConfigTextItem.dart b/lib/components/config/ConfigTextItem.dart new file mode 100644 index 0000000..aa6e975 --- /dev/null +++ b/lib/components/config/ConfigTextItem.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/components/SpecialTextField.dart'; + +class ConfigTextItem extends StatelessWidget { + const ConfigTextItem({Key key, this.placeholder, this.controller}) : super(key: key); + + final String placeholder; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return Padding( + padding: Platform.isAndroid ? EdgeInsets.all(5) : EdgeInsets.zero, + child: SpecialTextField( + autocorrect: false, + minLines: 3, + maxLines: 10, + placeholder: placeholder, + style: TextStyle(fontFamily: 'RobotoMono'), + controller: controller)); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..329d8c1 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,100 @@ +import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations; +import 'package:flutter/material.dart' + show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/screens/MainScreen.dart'; +import 'package:mobile_nebula/services/settings.dart'; + +//TODO: EventChannel might be better than the streamcontroller we are using now + +void main() => runApp(Main()); + +class Main extends StatelessWidget { + // This widget is the root of your application. + @override + Widget build(BuildContext context) => App(); +} + +class App extends StatefulWidget { + @override + _AppState createState() => _AppState(); +} + +class _AppState extends State { + final settings = Settings(); + Brightness brightness = SchedulerBinding.instance.window.platformBrightness; + + @override + void initState() { + //TODO: wait until settings is ready? + settings.onChange().listen((_) { + setState(() { + if (!settings.useSystemColors) { + brightness = settings.darkMode ? Brightness.dark : Brightness.light; + } + }); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ThemeData lightTheme = ThemeData( + brightness: Brightness.light, + primarySwatch: Colors.blueGrey, + primaryColor: Colors.blueGrey[900], + primaryColorBrightness: Brightness.dark, + accentColor: Colors.cyan[600], + accentColorBrightness: Brightness.dark, + fontFamily: 'PublicSans', + //scaffoldBackgroundColor: Colors.grey[100], + scaffoldBackgroundColor: Colors.white, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: Colors.blueGrey[50], + ), + ); + + final ThemeData darkTheme = ThemeData( + brightness: Brightness.dark, + primarySwatch: Colors.grey, + primaryColor: Colors.grey[900], + primaryColorBrightness: Brightness.dark, + accentColor: Colors.cyan[600], + accentColorBrightness: Brightness.dark, + fontFamily: 'PublicSans', + scaffoldBackgroundColor: Colors.grey[800], + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: Colors.grey[850], + ), + ); + + // This theme is required since icons light/dark mode will look for it + return Theme( + data: brightness == Brightness.light ? lightTheme : darkTheme, + child: PlatformProvider( + //initialPlatform: initialPlatform, + builder: (context) => PlatformApp( + localizationsDelegates: >[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + DefaultCupertinoLocalizations.delegate, + ], + title: 'Nebula', + android: (_) { + return new MaterialAppData( + themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark, + ); + }, + ios: (_) => CupertinoAppData( + theme: CupertinoThemeData(brightness: brightness), + ), + home: MainScreen(), + ), + ), + ); + } +} diff --git a/lib/models/CIDR.dart b/lib/models/CIDR.dart new file mode 100644 index 0000000..788805e --- /dev/null +++ b/lib/models/CIDR.dart @@ -0,0 +1,25 @@ +class CIDR { + CIDR({this.ip, this.bits}); + + String ip; + int bits; + + @override + String toString() { + return '$ip/$bits'; + } + + String toJson() { + return toString(); + } + + CIDR.fromString(String val) { + final parts = val.split('/'); + if (parts.length != 2) { + throw 'Invalid CIDR string'; + } + + ip = parts[0]; + bits = int.parse(parts[1]); + } +} diff --git a/lib/models/Certificate.dart b/lib/models/Certificate.dart new file mode 100644 index 0000000..64bffc2 --- /dev/null +++ b/lib/models/Certificate.dart @@ -0,0 +1,83 @@ +class CertificateInfo { + Certificate cert; + String rawCert; + CertificateValidity validity; + + CertificateInfo.debug({this.rawCert = ""}) + : this.cert = Certificate.debug(), + this.validity = CertificateValidity.debug(); + + CertificateInfo.fromJson(Map json) + : cert = Certificate.fromJson(json['Cert']), + rawCert = json['RawCert'], + validity = CertificateValidity.fromJson(json['Validity']); + + CertificateInfo({this.cert, this.rawCert, this.validity}); + + static List fromJsonList(List list) { + return list.map((v) => CertificateInfo.fromJson(v)); + } +} + +class Certificate { + CertificateDetails details; + String fingerprint; + String signature; + + Certificate.debug() + : this.details = CertificateDetails.debug(), + this.fingerprint = "DEBUG", + this.signature = "DEBUG"; + + Certificate.fromJson(Map json) + : details = CertificateDetails.fromJson(json['details']), + fingerprint = json['fingerprint'], + signature = json['signature']; +} + +class CertificateDetails { + String name; + DateTime notBefore; + DateTime notAfter; + String publicKey; + List groups; + List ips; + List subnets; + bool isCa; + String issuer; + + CertificateDetails.debug() + : this.name = "DEBUG", + notBefore = DateTime.now(), + notAfter = DateTime.now(), + publicKey = "", + groups = [], + ips = [], + subnets = [], + isCa = false, + issuer = "DEBUG"; + + CertificateDetails.fromJson(Map json) + : name = json['name'], + notBefore = DateTime.tryParse(json['notBefore']), + notAfter = DateTime.tryParse(json['notAfter']), + publicKey = json['publicKey'], + groups = List.from(json['groups']), + ips = List.from(json['ips']), + subnets = List.from(json['subnets']), + isCa = json['isCa'], + issuer = json['issuer']; +} + +class CertificateValidity { + bool valid; + String reason; + + CertificateValidity.debug() + : this.valid = true, + this.reason = ""; + + CertificateValidity.fromJson(Map json) + : valid = json['Valid'], + reason = json['Reason']; +} diff --git a/lib/models/HostInfo.dart b/lib/models/HostInfo.dart new file mode 100644 index 0000000..32f008f --- /dev/null +++ b/lib/models/HostInfo.dart @@ -0,0 +1,49 @@ +import 'package:mobile_nebula/models/Certificate.dart'; + +class HostInfo { + String vpnIp; + int localIndex; + int remoteIndex; + List remoteAddresses; + int cachedPackets; + Certificate cert; + UDPAddress currentRemote; + int messageCounter; + + HostInfo.fromJson(Map json) { + vpnIp = json['vpnIp']; + localIndex = json['localIndex']; + remoteIndex = json['remoteIndex']; + cachedPackets = json['cachedPackets']; + + if (json['currentRemote'] != null) { + currentRemote = UDPAddress.fromJson(json['currentRemote']); + } + + if (json['cert'] != null) { + cert = Certificate.fromJson(json['cert']); + } + + List addrs = json['remoteAddrs']; + remoteAddresses = []; + addrs?.forEach((val) { + remoteAddresses.add(UDPAddress.fromJson(val)); + }); + + messageCounter = json['messageCounter']; + } +} + +class UDPAddress { + String ip; + int port; + + @override + String toString() { + return '$ip:$port'; + } + + UDPAddress.fromJson(Map json) + : ip = json['IP'], + port = json['Port']; +} diff --git a/lib/models/Hostmap.dart b/lib/models/Hostmap.dart new file mode 100644 index 0000000..f30cdd6 --- /dev/null +++ b/lib/models/Hostmap.dart @@ -0,0 +1,9 @@ +import 'IPAndPort.dart'; + +class Hostmap { + String nebulaIp; + List destinations; + bool lighthouse; + + Hostmap({this.nebulaIp, this.destinations, this.lighthouse}); +} diff --git a/lib/models/IPAndPort.dart b/lib/models/IPAndPort.dart new file mode 100644 index 0000000..a1aa044 --- /dev/null +++ b/lib/models/IPAndPort.dart @@ -0,0 +1,25 @@ +class IPAndPort { + IPAndPort({this.ip, this.port}); + + String ip; + int port; + + @override + String toString() { + return '$ip:$port'; + } + + String toJson() { + return toString(); + } + + IPAndPort.fromString(String val) { + final parts = val.split(':'); + if (parts.length != 2) { + throw 'Invalid IPAndPort string'; + } + + ip = parts[0]; + port = int.parse(parts[1]); + } +} diff --git a/lib/models/Site.dart b/lib/models/Site.dart new file mode 100644 index 0000000..11a0877 --- /dev/null +++ b/lib/models/Site.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:mobile_nebula/models/HostInfo.dart'; +import 'package:mobile_nebula/models/UnsafeRoute.dart'; +import 'package:uuid/uuid.dart'; +import 'Certificate.dart'; +import 'StaticHosts.dart'; + +var uuid = Uuid(); + +class Site { + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + EventChannel _updates; + + /// Signals that something about this site has changed. onError is called with an error string if there was an error + StreamController _change = StreamController.broadcast(); + + // Identifiers + String name; + String id; + + // static_host_map + Map staticHostmap; + List unsafeRoutes; + + // pki fields + List ca; + CertificateInfo cert; + String key; + + // lighthouse options + int lhDuration; // in seconds + + // listen settings + int port; + int mtu; + + String cipher; + int sortKey; + bool connected; + String status; + String logFile; + String logVerbosity; + + // A list of errors encountered while loading the site + List errors; + + Site( + {this.name, + id, + staticHostmap, + ca, + this.cert, + this.lhDuration = 0, + this.port = 0, + this.cipher = "aes", + this.sortKey, + this.mtu, + this.connected, + this.status, + this.logFile, + this.logVerbosity, + errors, + unsafeRoutes}) + : staticHostmap = staticHostmap ?? {}, + unsafeRoutes = unsafeRoutes ?? [], + errors = errors ?? [], + ca = ca ?? [], + id = id ?? uuid.v4(); + + Site.fromJson(Map json) { + name = json['name']; + id = json['id']; + + Map rawHostmap = json['staticHostmap']; + staticHostmap = {}; + rawHostmap.forEach((key, val) { + staticHostmap[key] = StaticHost.fromJson(val); + }); + + List rawUnsafeRoutes = json['unsafeRoutes']; + unsafeRoutes = []; + if (rawUnsafeRoutes != null) { + rawUnsafeRoutes.forEach((val) { + unsafeRoutes.add(UnsafeRoute.fromJson(val)); + }); + } + + List rawCA = json['ca']; + ca = []; + rawCA.forEach((val) { + ca.add(CertificateInfo.fromJson(val)); + }); + + if (json['cert'] != null) { + cert = CertificateInfo.fromJson(json['cert']); + } + + lhDuration = json['lhDuration']; + port = json['port']; + mtu = json['mtu']; + cipher = json['cipher']; + sortKey = json['sortKey']; + logFile = json['logFile']; + logVerbosity = json['logVerbosity']; + connected = json['connected'] ?? false; + status = json['status'] ?? ""; + + errors = []; + List rawErrors = json["errors"]; + rawErrors.forEach((error) { + errors.add(error); + }); + + _updates = EventChannel('net.defined.nebula/$id'); + _updates.receiveBroadcastStream().listen((d) { + try { + this.status = d['status']; + this.connected = d['connected']; + _change.add(null); + } catch (err) { + //TODO: handle the error + print(err); + } + }, onError: (err) { + var error = err as PlatformException; + this.status = error.details['status']; + this.connected = error.details['connected']; + _change.addError(error.message); + }); + } + + Stream onChange() { + return _change.stream; + } + + Map toJson() { + return { + 'name': name, + 'id': id, + 'staticHostmap': staticHostmap, + 'unsafeRoutes': unsafeRoutes, + 'ca': ca?.map((cert) { + return cert.rawCert; + })?.join('\n') ?? + "", + 'cert': cert?.rawCert, + 'key': key, + 'lhDuration': lhDuration, + 'port': port, + 'mtu': mtu, + 'cipher': cipher, + 'sortKey': sortKey, + 'logVerbosity': logVerbosity, + }; + } + + save() async { + try { + var raw = jsonEncode(this); + await platform.invokeMethod("saveSite", raw); + } on PlatformException catch (err) { + //TODO: fix this message + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + Future renderConfig() async { + try { + var raw = jsonEncode(this); + return await platform.invokeMethod("nebula.renderConfig", raw); + } on PlatformException catch (err) { + //TODO: fix this message + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + start() async { + try { + await platform.invokeMethod("startSite", {"id": id}); + } on PlatformException catch (err) { + //TODO: fix this message + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + stop() async { + try { + await platform.invokeMethod("stopSite", {"id": id}); + } on PlatformException catch (err) { + //TODO: fix this message + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + Future> listHostmap() async { + try { + var ret = await platform.invokeMethod("active.listHostmap", {"id": id}); + + List f = jsonDecode(ret); + List hosts = []; + f.forEach((v) { + hosts.add(HostInfo.fromJson(v)); + }); + + return hosts; + + } on PlatformException catch (err) { + //TODO: fix this message + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + Future> listPendingHostmap() async { + try { + var ret = await platform.invokeMethod("active.listPendingHostmap", {"id": id}); + + List f = jsonDecode(ret); + List hosts = []; + f.forEach((v) { + hosts.add(HostInfo.fromJson(v)); + }); + + return hosts; + + } on PlatformException catch (err) { + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + Future>> listAllHostmaps() async { + try { + var res = await Future.wait([this.listHostmap(), this.listPendingHostmap()]); + return {"active": res[0], "pending": res[1]}; + + } on PlatformException catch (err) { + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + void dispose() { + _change.close(); + } + + Future getHostInfo(String vpnIp, bool pending) async { + try { + var ret = await platform.invokeMethod("active.getHostInfo", {"id": id, "vpnIp": vpnIp, "pending": pending}); + final h = jsonDecode(ret); + if (h == null) { + return null; + } + + return HostInfo.fromJson(h); + + } on PlatformException catch (err) { + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + Future setRemoteForTunnel(String vpnIp, String addr) async { + try { + var ret = await platform.invokeMethod("active.setRemoteForTunnel", {"id": id, "vpnIp": vpnIp, "addr": addr}); + final h = jsonDecode(ret); + if (h == null) { + return null; + } + + return HostInfo.fromJson(h); + + } on PlatformException catch (err) { + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } + + Future closeTunnel(String vpnIp) async { + try { + return await platform.invokeMethod("active.closeTunnel", {"id": id, "vpnIp": vpnIp}); + + } on PlatformException catch (err) { + throw err.details ?? err.message ?? err.toString(); + } catch (err) { + throw err.toString(); + } + } +} diff --git a/lib/models/StaticHosts.dart b/lib/models/StaticHosts.dart new file mode 100644 index 0000000..a55dc0f --- /dev/null +++ b/lib/models/StaticHosts.dart @@ -0,0 +1,28 @@ +import 'IPAndPort.dart'; + +class StaticHost { + bool lighthouse; + List destinations; + + StaticHost({this.lighthouse, this.destinations}); + + StaticHost.fromJson(Map json) { + lighthouse = json['lighthouse']; + + var list = json['destinations'] as List; + var result = List(); + + list.forEach((item) { + result.add(IPAndPort.fromString(item)); + }); + + destinations = result; + } + + Map toJson() { + return { + 'lighthouse': lighthouse, + 'destinations': destinations, + }; + } +} diff --git a/lib/models/UnsafeRoute.dart b/lib/models/UnsafeRoute.dart new file mode 100644 index 0000000..bb53166 --- /dev/null +++ b/lib/models/UnsafeRoute.dart @@ -0,0 +1,18 @@ +class UnsafeRoute { + String route; + String via; + + UnsafeRoute({this.route, this.via}); + + UnsafeRoute.fromJson(Map json) { + route = json['route']; + via = json['via']; + } + + Map toJson() { + return { + 'route': route, + 'via': via, + }; + } +} \ No newline at end of file diff --git a/lib/screens/AboutScreen.dart b/lib/screens/AboutScreen.dart new file mode 100644 index 0000000..6880ea0 --- /dev/null +++ b/lib/screens/AboutScreen.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/gen.versions.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:package_info/package_info.dart'; + +class AboutScreen extends StatefulWidget { + const AboutScreen({Key key}) : super(key: key); + + @override + _AboutScreenState createState() => _AboutScreenState(); +} + +class _AboutScreenState extends State { + bool ready = false; + PackageInfo packageInfo; + + @override + void initState() { + PackageInfo.fromPlatform().then((PackageInfo info) { + packageInfo = info; + setState(() { + ready = true; + }); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (!ready) { + return Center( + child: PlatformCircularProgressIndicator(ios: (_) { + return CupertinoProgressIndicatorData(radius: 50); + }), + ); + } + + return SimplePage( + title: 'About', + child: Column(children: [ + ConfigSection(children: [ + ConfigItem(label: Text('App version'), labelWidth: 150, content: _buildText('${packageInfo.version}-${packageInfo.buildNumber} (sha: $gitSha)')), + ConfigItem(label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')), + ConfigItem(label: Text('Flutter version'), labelWidth: 150, content: _buildText(flutterVersion['frameworkVersion'])), + ConfigItem(label: Text('Dart version'), labelWidth: 150, content: _buildText(flutterVersion['dartSdkVersion'])), + ]), + ConfigSection(children: [ + ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)), + ConfigPageItem(label: Text('Privacy policy'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/privacy-policy', context)), + ConfigPageItem(label: Text('Licenses'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/license', context)), + ]), + Padding(padding: EdgeInsets.only(top: 20), child: Text('Copyright © 2020 Defined Networking, Inc', textAlign: TextAlign.center,)), + ]), + ); + } + + _buildText(String str) { + return Align(alignment: AlignmentDirectional.centerEnd, child: SelectableText(str)); + } +} \ No newline at end of file diff --git a/lib/screens/HostInfoScreen.dart b/lib/screens/HostInfoScreen.dart new file mode 100644 index 0000000..50cab7a --- /dev/null +++ b/lib/screens/HostInfoScreen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Certificate.dart'; +import 'package:mobile_nebula/models/HostInfo.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificateDetailsScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class HostInfoScreen extends StatefulWidget { + const HostInfoScreen({Key key, this.hostInfo, this.isLighthouse, this.pending, this.onChanged, this.site}) + : super(key: key); + + final bool isLighthouse; + final bool pending; + final HostInfo hostInfo; + final Function onChanged; + final Site site; + + @override + _HostInfoScreenState createState() => _HostInfoScreenState(); +} + +//TODO: have a config option to refresh hostmaps on a cadence (applies to 3 screens so far) + +class _HostInfoScreenState extends State { + HostInfo hostInfo; + RefreshController refreshController = RefreshController(initialRefresh: false); + + @override + void initState() { + _setHostInfo(widget.hostInfo); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final title = widget.pending ? 'Pending' : 'Active'; + + return SimplePage( + title: '$title Host Info', + refreshController: refreshController, + onRefresh: () async { + await _getHostInfo(); + refreshController.refreshCompleted(); + }, + leadingAction: Utils.leadingBackWidget(context, onPressed: () { + Navigator.pop(context); + }), + child: Column( + children: [_buildMain(), _buildDetails(), _buildRemotes(), !widget.pending ? _buildClose() : Container()])); + } + + Widget _buildMain() { + return ConfigSection(children: [ + ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)), + hostInfo.cert != null + ? ConfigPageItem( + label: Text('Certificate'), + labelWidth: 150, + content: Text(hostInfo.cert.details.name), + onPressed: () => Utils.openPage( + context, (context) => CertificateDetailsScreen(certificate: CertificateInfo(cert: hostInfo.cert)))) + : Container(), + ]); + } + + Widget _buildDetails() { + return ConfigSection(children: [ + ConfigItem( + label: Text('Lighthouse'), labelWidth: 150, content: SelectableText(widget.isLighthouse ? 'Yes' : 'No')), + ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')), + ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')), + ConfigItem( + label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')), + ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SelectableText('${hostInfo.cachedPackets}')), + ]); + } + + Widget _buildRemotes() { + if (hostInfo.remoteAddresses.length == 0) { + return ConfigSection(label: 'REMOTES', children: [ConfigItem(content: Text('No remote addresses yet'), labelWidth: 0)]); + } + + return widget.pending ? _buildStaticRemotes() : _buildEditRemotes(); + } + + Widget _buildEditRemotes() { + List items = []; + final currentRemote = hostInfo.currentRemote.toString(); + final double ipWidth = + Utils.textSize("000.000.000.000:000000", CupertinoTheme.of(context).textTheme.textStyle).width; + + hostInfo.remoteAddresses.forEach((remoteObj) { + String remote = remoteObj.toString(); + items.add(ConfigCheckboxItem( + key: Key(remote), + label: Text(remote), + labelWidth: ipWidth, + checked: currentRemote == remote, + onChanged: () async { + if (remote == currentRemote) { + return; + } + + try { + final h = await widget.site.setRemoteForTunnel(hostInfo.vpnIp, remote); + if (h != null) { + _setHostInfo(h); + } + } catch (err) { + Utils.popError(context, 'Error while changing the remote', err); + } + }, + )); + }); + + return ConfigSection(label: items.length > 0 ? 'Tap to change the active address' : null, children: items); + } + + Widget _buildStaticRemotes() { + List items = []; + final currentRemote = hostInfo.currentRemote.toString(); + final double ipWidth = + Utils.textSize("000.000.000.000:000000", CupertinoTheme.of(context).textTheme.textStyle).width; + + hostInfo.remoteAddresses.forEach((remoteObj) { + String remote = remoteObj.toString(); + items.add(ConfigCheckboxItem( + key: Key(remote), + label: Text(remote), + labelWidth: ipWidth, + checked: currentRemote == remote, + )); + }); + + return ConfigSection(label: items.length > 0 ? 'REMOTES' : null, children: items); + } + + Widget _buildClose() { + return Padding( + padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformButton( + child: Text('Close Tunnel'), + color: CupertinoColors.systemRed.resolveFrom(context), + onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async { + try { + await widget.site.closeTunnel(hostInfo.vpnIp); + if (widget.onChanged != null) { + widget.onChanged(); + } + Navigator.pop(context); + } catch (err) { + Utils.popError(context, 'Error while trying to close the tunnel', err); + } + }, deleteLabel: 'Close')))); + } + + _getHostInfo() async { + try { + final h = await widget.site.getHostInfo(hostInfo.vpnIp, widget.pending); + if (h == null) { + return Utils.popError(context, '', 'The tunnel for this host no longer exists'); + } + + _setHostInfo(h); + } catch (err) { + Utils.popError(context, 'Failed to refresh host info', err); + } + } + + _setHostInfo(HostInfo h) { + h.remoteAddresses.sort((a, b) { + final diff = Utils.ip2int(a.ip) - Utils.ip2int(b.ip); + return diff == 0 ? a.port - b.port : diff; + }); + + setState(() { + hostInfo = h; + }); + } +} diff --git a/lib/screens/MainScreen.dart b/lib/screens/MainScreen.dart new file mode 100644 index 0000000..d8bc08e --- /dev/null +++ b/lib/screens/MainScreen.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/SiteItem.dart'; +import 'package:mobile_nebula/models/Certificate.dart'; +import 'package:mobile_nebula/models/IPAndPort.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/models/StaticHosts.dart'; +import 'package:mobile_nebula/models/UnsafeRoute.dart'; +import 'package:mobile_nebula/screens/SettingsScreen.dart'; +import 'package:mobile_nebula/screens/SiteDetailScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:uuid/uuid.dart'; + +//TODO: add refresh + +class MainScreen extends StatefulWidget { + const MainScreen({Key key}) : super(key: key); + + @override + _MainScreenState createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + bool ready = false; + List sites; + + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + + @override + void initState() { + _loadSites(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SimplePage( + title: 'Nebula', + scrollable: SimpleScrollable.none, + leadingAction: PlatformIconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.add, size: 28.0), + onPressed: () => Utils.openPage(context, (context) { + return SiteConfigScreen(onSave: (_) { + _loadSites(); + }); + }), + ), + trailingActions: [ + PlatformIconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.menu, size: 28.0), + onPressed: () => Utils.openPage(context, (_) => SettingsScreen()), + ), + ], + bottomBar: kDebugMode ? _debugSave() : null, + child: _buildBody(), + ); + } + + Widget _buildBody() { + if (!ready) { + return Center( + child: PlatformCircularProgressIndicator(ios: (_) { + return CupertinoProgressIndicatorData(radius: 50); + }), + ); + } + + if (sites == null || sites.length == 0) { + return _buildNoSites(); + } + + return _buildSites(); + } + + Widget _buildNoSites() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0), + child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), + ), + Text('You don\'t have any site configurations installed yet. Hit the plus button above to get started.', + textAlign: TextAlign.center), + ], + ), + ); + } + + Widget _buildSites() { + List items = []; + sites.forEach((site) { + items.add(SiteItem( + key: Key(site.id), + site: site, + onPressed: () { + Utils.openPage(context, (context) { + return SiteDetailScreen(site: site, onChanged: () => _loadSites()); + }); + })); + }); + + Widget child = ReorderableListView( + padding: EdgeInsets.symmetric(vertical: 5), + children: items, + onReorder: (oldI, newI) async { + if (oldI < newI) { + // removing the item at oldIndex will shorten the list by 1. + newI -= 1; + } + + setState(() { + final Site moved = sites.removeAt(oldI); + sites.insert(newI, moved); + }); + + for (var i = min(oldI, newI); i <= max(oldI, newI); i++) { + sites[i].sortKey = i; + try { + await sites[i].save(); + } catch (err) { + //TODO: display error at the end + print('ERR ${sites[i].name} - $err'); + } + } + + _loadSites(); + }); + + if (Platform.isIOS) { + child = CupertinoTheme(child: child, data: CupertinoTheme.of(context)); + } + + // The theme here is to remove the hardcoded canvas border reordering forces on us + return Theme( + data: Theme.of(context).copyWith(canvasColor: Colors.transparent), + child: child + ); + } + + Widget _debugSave() { + return CupertinoButton( + key: Key('debug-save'), + child: Text("DEBUG SAVE"), + onPressed: () async { + var uuid = Uuid(); + + var cert = '''-----BEGIN NEBULA CERTIFICATE----- +CmMKBnBpeGVsNBIJiYCEUID+//8PKLqMivcFMKTzjoYGOiB4iANINzCjLdlQJSj/ +vJDd080yggfLgW9hT4a/bhGZekog+W+YEJiV36evX4MueQ+npDzJd3zGg5gialu4 +UNGYBP0SQL5bjEyafC0YtETEbrraSfwuFHMvUoi1Kc4XRzTPPvHsEaq3hNNTZtD7 +Pt3sjH83zTMZfnD/Du3ahsvV0rAXUgc= +-----END NEBULA CERTIFICATE-----'''; + + var ca = '''-----BEGIN NEBULA CERTIFICATE----- +CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT +4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv +mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= +-----END NEBULA CERTIFICATE-----'''; + + var s = Site( + name: "DEBUG TEST", + id: uuid.v4(), + staticHostmap: { + "10.1.0.1": StaticHost(lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242)]) + }, + ca: [CertificateInfo.debug(rawCert: ca)], + cert: CertificateInfo.debug(rawCert: cert), + unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')] + ); + + s.key = "-----BEGIN NEBULA X25519 PRIVATE KEY-----\ndYgPb04Bb1xzfgdCfVsKGZrCYe+u5tDWNXKipQBVZ44=\n-----END NEBULA X25519 PRIVATE KEY-----"; + + var err = await s.save(); + if (err != null) { + Utils.popError(context, "Failed to save the site", err); + } else { + _loadSites(); + } + }, + ); + } + + _loadSites() async { + if (Platform.isAndroid) { + await platform.invokeMethod("android.requestPermissions"); + } + + //TODO: This can throw, we need to show an error dialog + Map rawSites = jsonDecode(await platform.invokeMethod('listSites')); + bool hasErrors = false; + + sites = []; + rawSites.values.forEach((rawSite) { + try { + var site = Site.fromJson(rawSite); + if (site.errors.length > 0) { + hasErrors = true; + } + + //TODO: we need to cancel change listeners when we rebuild + site.onChange().listen((_) { + setState(() {}); + }, onError: (err) { + setState(() {}); + if (ModalRoute.of(context).isCurrent) { + Utils.popError(context, "${site.name} Error", err); + } + }); + + sites.add(site); + } catch (err) { + //TODO: handle error + print(err); + } + }); + + 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) { + return a.sortKey - b.sortKey; + }); + + setState(() { + ready = true; + }); + } +} diff --git a/lib/screens/SettingsScreen.dart b/lib/screens/SettingsScreen.dart new file mode 100644 index 0000000..75e6985 --- /dev/null +++ b/lib/screens/SettingsScreen.dart @@ -0,0 +1,76 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/services/settings.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +import 'AboutScreen.dart'; + +class SettingsScreen extends StatefulWidget { + @override + _SettingsScreenState createState() { + return _SettingsScreenState(); + } +} + +class _SettingsScreenState extends State { + var settings = Settings(); + + @override + void initState() { + //TODO: we need to unregister on dispose? + settings.onChange().listen((_) { + if (this.mounted) { + setState(() {}); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + List colorSection = []; + + colorSection.add(ConfigItem( + label: Text('Use system colors'), + labelWidth: 200, + content: Align( + alignment: Alignment.centerRight, + child: Switch.adaptive( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) { + settings.useSystemColors = value; + }, + value: settings.useSystemColors, + )), + )); + + if (!settings.useSystemColors) { + colorSection.add(ConfigItem( + label: Text('Dark mode'), + content: Align( + alignment: Alignment.centerRight, + child: Switch.adaptive( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) { + settings.darkMode = value; + }, + value: settings.darkMode, + )), + )); + } + + List items = []; + items.add(ConfigSection(children: colorSection)); + items.add(ConfigSection(children: [ConfigPageItem(label: Text('About'), onPressed: () => Utils.openPage(context, (context) => AboutScreen()),)])); + + return SimplePage( + title: 'Settings', + child: Column(children: items), + ); + } +} diff --git a/lib/screens/SiteDetailScreen.dart b/lib/screens/SiteDetailScreen.dart new file mode 100644 index 0000000..160bfb1 --- /dev/null +++ b/lib/screens/SiteDetailScreen.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/HostInfo.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/screens/SiteLogsScreen.dart'; +import 'package:mobile_nebula/screens/SiteTunnelsScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +//TODO: If the site isn't active, don't respond to reloads on hostmaps +//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race) + +class SiteDetailScreen extends StatefulWidget { + const SiteDetailScreen({Key key, this.site, this.onChanged}) : super(key: key); + + final Site site; + final Function onChanged; + + @override + _SiteDetailScreenState createState() => _SiteDetailScreenState(); +} + +class _SiteDetailScreenState extends State { + Site site; + StreamSubscription onChange; + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + bool changed = false; + List activeHosts; + List pendingHosts; + RefreshController refreshController = RefreshController(initialRefresh: false); + bool lastState; + + @override + void initState() { + site = widget.site; + lastState = site.connected; + if (site.connected) { + _listHostmap(); + } + + onChange = site.onChange().listen((_) { + setState(() {}); + if (lastState != site.connected) { + //TODO: connected is set before the nebula object exists leading to a crash race, waiting for "Connected" status is a gross hack but keeps it alive + if (site.status == 'Connected') { + lastState = site.connected; + _listHostmap(); + } else { + lastState = site.connected; + activeHosts = null; + pendingHosts = null; + } + } + }, onError: (err) { + setState(() {}); + Utils.popError(context, "Error", err); + }); + + super.initState(); + } + + @override + void dispose() { + onChange.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SimplePage( + title: site.name, + leadingAction: Utils.leadingBackWidget(context, onPressed: () { + if (changed && widget.onChanged != null) { + widget.onChanged(); + } + Navigator.pop(context); + }), + refreshController: refreshController, + onRefresh: () async { + if (site.connected) { + await _listHostmap(); + } + refreshController.refreshCompleted(); + }, + child: Column(children: [ + _buildErrors(), + _buildConfig(), + site.connected ? _buildHosts() : Container(), + _buildSiteDetails(), + _buildDelete(), + ])); + } + + Widget _buildErrors() { + if (site.errors.length == 0) { + return Container(); + } + + List items = []; + site.errors.forEach((error) { + items.add(ConfigItem( + labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error)))); + }); + + return ConfigSection( + label: 'ERRORS', + borderColor: CupertinoColors.systemRed.resolveFrom(context), + labelColor: CupertinoColors.systemRed.resolveFrom(context), + children: items, + ); + } + + Widget _buildConfig() { + return ConfigSection(children: [ + ConfigItem( + label: Text('Status'), + content: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + Padding( + padding: EdgeInsets.only(right: 5), + child: Text(widget.site.status, + style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)))), + Switch.adaptive( + value: widget.site.connected, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) async { + try { + if (v) { + await widget.site.start(); + } else { + await widget.site.stop(); + } + } catch (error) { + var action = v ? 'start' : 'stop'; + Utils.popError(context, 'Failed to $action the site', error.toString()); + } + }, + ) + ])), + ConfigPageItem( + label: Text('Logs'), + onPressed: () { + Utils.openPage(context, (context) { + return SiteLogsScreen(site: widget.site); + }); + }, + ), + ]); + } + + Widget _buildHosts() { + Widget active, pending; + + if (activeHosts == null) { + active = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator()); + } else { + active = Text(Utils.itemCountFormat(activeHosts.length, singleSuffix: "tunnel", multiSuffix: "tunnels")); + } + + if (pendingHosts == null) { + pending = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator()); + } else { + pending = Text(Utils.itemCountFormat(pendingHosts.length, singleSuffix: "tunnel", multiSuffix: "tunnels")); + } + + return ConfigSection( + label: "TUNNELS", + children: [ + ConfigPageItem( + onPressed: () { + Utils.openPage( + context, + (context) => SiteTunnelsScreen( + pending: false, + tunnels: activeHosts, + site: site, + onChanged: (hosts) { + setState(() { + activeHosts = hosts; + }); + })); + }, + label: Text("Active"), + content: Container(alignment: Alignment.centerRight, child: active)), + ConfigPageItem( + onPressed: () { + Utils.openPage( + context, + (context) => SiteTunnelsScreen( + pending: true, + tunnels: pendingHosts, + site: site, + onChanged: (hosts) { + setState(() { + pendingHosts = hosts; + }); + })); + }, + label: Text("Pending"), + content: Container(alignment: Alignment.centerRight, child: pending)) + ], + ); + } + + Widget _buildSiteDetails() { + return ConfigSection(children: [ + ConfigPageItem( + crossAxisAlignment: CrossAxisAlignment.center, + content: Text('Configuration'), + onPressed: () { + Utils.openPage(context, (context) { + return SiteConfigScreen( + site: widget.site, + onSave: (site) async { + changed = true; + }); + }); + }, + ), + ]); + } + + Widget _buildDelete() { + return Padding( + padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformButton( + child: Text('Delete'), + color: CupertinoColors.systemRed.resolveFrom(context), + onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async { + if (await _deleteSite()) { + Navigator.of(context).pop(); + } + })))); + } + + _listHostmap() async { + try { + var maps = await site.listAllHostmaps(); + activeHosts = maps["active"]; + pendingHosts = maps["pending"]; + setState(() {}); + } catch (err) { + Utils.popError(context, 'Error while fetching hostmaps', err); + } + } + + Future _deleteSite() async { + try { + var err = await platform.invokeMethod("deleteSite", widget.site.id); + if (err != null) { + Utils.popError(context, 'Failed to delete the site', err); + return false; + } + } catch (err) { + Utils.popError(context, 'Failed to delete the site', err.toString()); + return false; + } + + if (widget.onChanged != null) { + widget.onChanged(); + } + return true; + } +} diff --git a/lib/screens/SiteLogsScreen.dart b/lib/screens/SiteLogsScreen.dart new file mode 100644 index 0000000..faefc8a --- /dev/null +++ b/lib/screens/SiteLogsScreen.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:flutter_share/flutter_share.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class SiteLogsScreen extends StatefulWidget { + const SiteLogsScreen({Key key, this.site}) : super(key: key); + + final Site site; + + @override + _SiteLogsScreenState createState() => _SiteLogsScreenState(); +} + +class _SiteLogsScreenState extends State { + String logs = ''; + ScrollController controller = ScrollController(); + RefreshController refreshController = RefreshController(initialRefresh: false); + + @override + void initState() { + loadLogs(); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SimplePage( + title: widget.site.name, + scrollable: SimpleScrollable.both, + scrollController: controller, + onRefresh: () async { + await loadLogs(); + refreshController.refreshCompleted(); + }, + onLoading: () async { + await loadLogs(); + refreshController.loadComplete(); + }, + refreshController: refreshController, + child: Container( + padding: EdgeInsets.all(5), + constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width), + child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))), + bottomBar: _buildBottomBar(), + ); + } + + Widget _buildBottomBar() { + var borderSide = BorderSide( + color: CupertinoColors.separator, + style: BorderStyle.solid, + width: 0.0, + ); + + var padding = Platform.isAndroid ? EdgeInsets.fromLTRB(0, 20, 0, 30) : EdgeInsets.all(10); + + return Container( + decoration: BoxDecoration( + border: Border(top: borderSide), + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Expanded( + child: PlatformIconButton( + padding: padding, + icon: Icon(context.platformIcons.share, size: 30), + onPressed: () { + FlutterShare.shareFile(title: '${widget.site.name} logs', filePath: widget.site.logFile); + }, + )), + Expanded( + child: PlatformIconButton( + padding: padding, + icon: Icon(context.platformIcons.delete, size: Platform.isIOS ? 38 : 30), + onPressed: () { + Utils.confirmDelete(context, 'Are you sure you want to clear all logs?', () => deleteLogs()); + }, + )), + Expanded( + child: PlatformIconButton( + padding: padding, + icon: Icon(context.platformIcons.downArrow, size: 30), + onPressed: () async { + controller.animateTo(controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 500), curve: Curves.linearToEaseOut); + }, + )), + ])); + } + + loadLogs() async { + var file = File(widget.site.logFile); + try { + final v = await file.readAsString(); + + setState(() { + logs = v; + }); + } catch (err) { + Utils.popError(context, 'Error while reading logs', err.toString()); + } + } + + deleteLogs() async { + var file = File(widget.site.logFile); + await file.writeAsBytes([]); + await loadLogs(); + } +} diff --git a/lib/screens/SiteTunnelsScreen.dart b/lib/screens/SiteTunnelsScreen.dart new file mode 100644 index 0000000..d863306 --- /dev/null +++ b/lib/screens/SiteTunnelsScreen.dart @@ -0,0 +1,132 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/HostInfo.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/screens/HostInfoScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class SiteTunnelsScreen extends StatefulWidget { + const SiteTunnelsScreen({Key key, this.site, this.tunnels, this.pending, this.onChanged}) : super(key: key); + + final Site site; + final List tunnels; + final bool pending; + final Function(List) onChanged; + + @override + _SiteTunnelsScreenState createState() => _SiteTunnelsScreenState(); +} + +class _SiteTunnelsScreenState extends State { + Site site; + List tunnels; + RefreshController refreshController = RefreshController(initialRefresh: false); + + @override + void initState() { + site = widget.site; + tunnels = widget.tunnels; + _sortTunnels(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double ipWidth = Utils.textSize("000.000.000.000", CupertinoTheme.of(context).textTheme.textStyle).width + 32; + + List children = []; + tunnels.forEach((hostInfo) { + Widget icon; + + final isLh = site.staticHostmap[hostInfo.vpnIp]?.lighthouse ?? false; + if (isLh) { + icon = Icon(Icons.lightbulb_outline, color: CupertinoColors.placeholderText.resolveFrom(context)); + } else { + icon = Icon(Icons.computer, color: CupertinoColors.placeholderText.resolveFrom(context)); + } + + children.add(ConfigPageItem( + onPressed: () => Utils.openPage( + context, + (context) => HostInfoScreen( + isLighthouse: isLh, + hostInfo: hostInfo, + pending: widget.pending, + site: widget.site, + onChanged: () { + _listHostmap(); + })), + label: Row(children: [Padding(child: icon, padding: EdgeInsets.only(right: 10)), Text(hostInfo.vpnIp)]), + labelWidth: ipWidth, + content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details?.name ?? "")), + )); + }); + + Widget child; + if (children.length == 0) { + child = Center(child: Padding(child: Text('No tunnels to show'), padding: EdgeInsets.only(top: 30))); + } else { + child = ConfigSection(children: children); + } + + final title = widget.pending ? 'Pending' : 'Active'; + + return SimplePage( + title: "$title Tunnels", + leadingAction: Utils.leadingBackWidget(context, onPressed: () { + Navigator.pop(context); + }), + refreshController: refreshController, + onRefresh: () async { + await _listHostmap(); + refreshController.refreshCompleted(); + }, + child: child); + } + + _sortTunnels() { + tunnels.sort((a, b) { + final aLh = _isLighthouse(a.vpnIp), bLh = _isLighthouse(b.vpnIp); + + if (aLh && !bLh) { + return -1; + } else if (!aLh && bLh) { + return 1; + } + + return Utils.ip2int(a.vpnIp) - Utils.ip2int(b.vpnIp); + }); + } + + bool _isLighthouse(String vpnIp) { + return site.staticHostmap[vpnIp]?.lighthouse ?? false; + } + + _listHostmap() async { + try { + if (widget.pending) { + tunnels = await site.listPendingHostmap(); + } else { + tunnels = await site.listHostmap(); + } + + _sortTunnels(); + if (widget.onChanged != null) { + widget.onChanged(tunnels); + } + setState(() {}); + } catch (err) { + Utils.popError(context, 'Error while fetching hostmap', err); + } + } +} diff --git a/lib/screens/siteConfig/AdvancedScreen.dart b/lib/screens/siteConfig/AdvancedScreen.dart new file mode 100644 index 0000000..5e825fa --- /dev/null +++ b/lib/screens/siteConfig/AdvancedScreen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/PlatformTextFormField.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/models/UnsafeRoute.dart'; +import 'package:mobile_nebula/screens/siteConfig/CipherScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/LogVerbosityScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/RenderedConfigScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +import 'UnsafeRoutesScreen.dart'; + +//TODO: form validation (seconds and port) +//TODO: wire up the focus nodes, add a done/next/prev to the keyboard +//TODO: fingerprint blacklist +//TODO: show site id here + +class Advanced { + int lhDuration; + int port; + String cipher; + String verbosity; + List unsafeRoutes; + int mtu; +} + +class AdvancedScreen extends StatefulWidget { + const AdvancedScreen({Key key, this.site, @required this.onSave}) : super(key: key); + + final Site site; + final ValueChanged onSave; + + @override + _AdvancedScreenState createState() => _AdvancedScreenState(); +} + +class _AdvancedScreenState extends State { + var settings = Advanced(); + var changed = false; + + @override + void initState() { + settings.lhDuration = widget.site.lhDuration; + settings.port = widget.site.port; + settings.cipher = widget.site.cipher; + settings.verbosity = widget.site.logVerbosity; + settings.unsafeRoutes = widget.site.unsafeRoutes; + settings.mtu = widget.site.mtu; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: 'Advanced Settings', + changed: changed, + onSave: () { + Navigator.pop(context); + widget.onSave(settings); + }, + child: Column(children: [ + ConfigSection( + children: [ + ConfigItem( + label: Text("Lighthouse interval"), + labelWidth: 200, + //TODO: Auto select on focus? + content: PlatformTextFormField( + initialValue: settings.lhDuration.toString(), + keyboardType: TextInputType.number, + suffix: Text("seconds"), + textAlign: TextAlign.right, + maxLength: 5, + inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], + onSaved: (val) { + setState(() { + settings.lhDuration = int.parse(val); + }); + }, + )), + ConfigItem( + label: Text("Listen port"), + labelWidth: 150, + //TODO: Auto select on focus? + content: PlatformTextFormField( + initialValue: settings.port.toString(), + keyboardType: TextInputType.number, + textAlign: TextAlign.right, + maxLength: 5, + inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], + onSaved: (val) { + setState(() { + settings.port = int.parse(val); + }); + }, + )), + ConfigItem( + label: Text("MTU"), + labelWidth: 150, + content: PlatformTextFormField( + initialValue: settings.mtu.toString(), + keyboardType: TextInputType.number, + textAlign: TextAlign.right, + maxLength: 5, + inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], + onSaved: (val) { + setState(() { + settings.mtu = int.parse(val); + }); + }, + )), + ConfigPageItem( + label: Text('Cipher'), + labelWidth: 150, + content: Text(settings.cipher, textAlign: TextAlign.end), + onPressed: () { + Utils.openPage(context, (context) { + return CipherScreen( + cipher: settings.cipher, + onSave: (cipher) { + setState(() { + settings.cipher = cipher; + changed = true; + }); + }); + }); + }), + ConfigPageItem( + label: Text('Log verbosity'), + labelWidth: 150, + content: Text(settings.verbosity, textAlign: TextAlign.end), + onPressed: () { + Utils.openPage(context, (context) { + return LogVerbosityScreen( + verbosity: settings.verbosity, + onSave: (verbosity) { + setState(() { + settings.verbosity = verbosity; + changed = true; + }); + }); + }); + }), + ConfigPageItem( + label: Text('Unsafe routes'), + labelWidth: 150, + content: Text(Utils.itemCountFormat(settings.unsafeRoutes.length), textAlign: TextAlign.end), + onPressed: () { + Utils.openPage(context, (context) { + return UnsafeRoutesScreen(unsafeRoutes: settings.unsafeRoutes, onSave: (routes) { + setState(() { + settings.unsafeRoutes = routes; + changed = true; + }); + }); + }); + }, + ) + ], + ), + ConfigSection( + children: [ + ConfigPageItem( + content: Text('View rendered config'), + onPressed: () async { + try { + var config = await widget.site.renderConfig(); + Utils.openPage(context, (context) { + return RenderedConfigScreen(config: config); + }); + } catch (err) { + Utils.popError(context, 'Failed to render the site config', err); + } + }, + ) + ], + ) + ])); + } +} diff --git a/lib/screens/siteConfig/CAListScreen.dart b/lib/screens/siteConfig/CAListScreen.dart new file mode 100644 index 0000000..37762ec --- /dev/null +++ b/lib/screens/siteConfig/CAListScreen.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; + +import 'package:barcode_scan/barcode_scan.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/components/config/ConfigTextItem.dart'; +import 'package:mobile_nebula/models/Certificate.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificateDetailsScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +//TODO: wire up the focus nodes, add a done/next/prev to the keyboard +//TODO: you left off at providing the signed cert back. You need to verify it has your public key in it. You likely want to present the cert details before they can save +//TODO: In addition you will want to think about re-generation while the site is still active (This means storing multiple keys in secure storage) + +class CAListScreen extends StatefulWidget { + const CAListScreen({Key key, this.cas, @required this.onSave}) : super(key: key); + + final List cas; + final ValueChanged> onSave; + + @override + _CAListScreenState createState() => _CAListScreenState(); +} + +class _CAListScreenState extends State { + Map cas = {}; + bool changed = false; + var inputType = "paste"; + final pasteController = TextEditingController(); + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + var error = ""; + + @override + void initState() { + widget.cas.forEach((ca) { + cas[ca.cert.fingerprint] = ca; + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + List items = []; + final caItems = _buildCAs(); + + if (caItems.length > 0) { + items.add(ConfigSection(children: caItems)); + } + + items.addAll(_addCA()); + return FormPage( + title: 'Certificate Authorities', + changed: changed, + onSave: () { + if (widget.onSave != null) { + Navigator.pop(context); + widget.onSave(cas.values.map((ca) { + return ca; + }).toList()); + } + }, + child: Column(children: items)); + } + + List _buildCAs() { + List items = []; + cas.forEach((key, ca) { + items.add(ConfigPageItem( + content: Text(ca.cert.details.name), + onPressed: () { + Utils.openPage(context, (context) { + return CertificateDetailsScreen( + certificate: ca, + onDelete: () { + setState(() { + changed = true; + cas.remove(key); + }); + }); + }); + }, + )); + }); + + return items; + } + + _addCAEntry(String ca, ValueChanged callback) async { + String error; + + //TODO: show an error popup + try { + var rawCerts = await platform.invokeMethod("nebula.parseCerts", {"certs": ca}); + List certs = jsonDecode(rawCerts); + certs.forEach((rawCert) { + final info = CertificateInfo.fromJson(rawCert); + cas[info.cert.fingerprint] = info; + }); + + changed = true; + } on PlatformException catch (err) { + //TODO: fix this message + error = err.details ?? err.message; + } + + if (callback != null) { + callback(error); + } + } + + List _addCA() { + List items = [ + Padding( + padding: EdgeInsets.fromLTRB(10, 25, 10, 0), + child: CupertinoSlidingSegmentedControl( + groupValue: inputType, + onValueChanged: (v) { + setState(() { + inputType = v; + }); + }, + children: { + 'paste': Text('Copy/Paste'), + 'file': Text('File'), + 'qr': Text('QR Code'), + }, + )) + ]; + + if (inputType == 'paste') { + items.addAll(_addPaste()); + } else if (inputType == 'file') { + items.addAll(_addFile()); + } else { + items.addAll(_addQr()); + } + + return items; + } + + List _addPaste() { + return [ + ConfigSection( + children: [ + ConfigTextItem( + placeholder: 'CA PEM contents', + controller: pasteController, + ), + ConfigButtonItem( + content: Text('Load CA'), + onPressed: () { + _addCAEntry(pasteController.text, (err) { + print(err); + if (err != null) { + return Utils.popError(context, 'Failed to parse CA content', err); + } + + pasteController.text = ''; + setState(() {}); + }); + }), + ], + ) + ]; + } + + List _addFile() { + return [ + ConfigSection( + children: [ + ConfigButtonItem( + content: Text('Choose a file'), + onPressed: () async { + final file = await FilePicker.getFile(); + if (file == null) { + return; + } + + var content = ""; + try { + content = file.readAsStringSync(); + } catch (err) { + return Utils.popError(context, 'Failed to load CA file', err.toString()); + } + + _addCAEntry(content, (err) { + if (err != null) { + Utils.popError(context, 'Error loading CA file', err); + } else { + setState(() {}); + } + }); + }) + ], + ) + ]; + } + + List _addQr() { + return [ + ConfigSection( + children: [ + ConfigButtonItem( + content: Text('Scan a QR code'), + onPressed: () async { + var options = ScanOptions( + restrictFormat: [BarcodeFormat.qr], + ); + + var result = await BarcodeScanner.scan(options: options); + if (result.rawContent != "") { + _addCAEntry(result.rawContent, (err) { + if (err != null) { + Utils.popError(context, 'Error loading CA content', err); + } else { + setState(() {}); + } + }); + } + }) + ], + ) + ]; + } +} diff --git a/lib/screens/siteConfig/CertificateDetailsScreen.dart b/lib/screens/siteConfig/CertificateDetailsScreen.dart new file mode 100644 index 0000000..8a4e33f --- /dev/null +++ b/lib/screens/siteConfig/CertificateDetailsScreen.dart @@ -0,0 +1,131 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Certificate.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +/// Displays the details of a CertificateInfo object. Respects incomplete objects (missing validity or rawCert) +class CertificateDetailsScreen extends StatefulWidget { + const CertificateDetailsScreen({Key key, this.certificate, this.onDelete}) : super(key: key); + + final CertificateInfo certificate; + final Function onDelete; + + @override + _CertificateDetailsScreenState createState() => _CertificateDetailsScreenState(); +} + +class _CertificateDetailsScreenState extends State { + @override + Widget build(BuildContext context) { + return SimplePage( + title: 'Certificate Details', + child: Column(children: [ + _buildID(), + _buildFilters(), + _buildValid(), + _buildAdvanced(), + _buildDelete(), + ]), + ); + } + + Widget _buildID() { + return ConfigSection(children: [ + ConfigItem(label: Text('Name'), content: SelectableText(widget.certificate.cert.details.name)), + ConfigItem( + label: Text('Type'), + content: Text(widget.certificate.cert.details.isCa ? 'CA certificate' : 'Client certificate')), + ]); + } + + Widget _buildValid() { + var valid = Text('yes'); + if (widget.certificate.validity != null && !widget.certificate.validity.valid) { + valid = Text(widget.certificate.validity.valid ? 'yes' : widget.certificate.validity.reason, + style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(context))); + } + return ConfigSection( + label: 'VALIDITY', + children: [ + ConfigItem(label: Text('Valid?'), content: valid), + ConfigItem( + label: Text('Created'), + content: SelectableText(widget.certificate.cert.details.notBefore.toLocal().toString())), + ConfigItem( + label: Text('Expires'), + content: SelectableText(widget.certificate.cert.details.notAfter.toLocal().toString())), + ], + ); + } + + Widget _buildFilters() { + List items = []; + if (widget.certificate.cert.details.groups.length > 0) { + items.add(ConfigItem( + label: Text('Groups'), content: SelectableText(widget.certificate.cert.details.groups.join(', ')))); + } + + if (widget.certificate.cert.details.ips.length > 0) { + items + .add(ConfigItem(label: Text('IPs'), content: SelectableText(widget.certificate.cert.details.ips.join(', ')))); + } + + if (widget.certificate.cert.details.subnets.length > 0) { + items.add(ConfigItem( + label: Text('Subnets'), content: SelectableText(widget.certificate.cert.details.subnets.join(', ')))); + } + + return items.length > 0 + ? ConfigSection(label: widget.certificate.cert.details.isCa ? 'FILTERS' : 'DETAILS', children: items) + : Container(); + } + + Widget _buildAdvanced() { + return ConfigSection( + children: [ + ConfigItem( + label: Text('Fingerprint'), + content: SelectableText(widget.certificate.cert.fingerprint, + style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), + crossAxisAlignment: CrossAxisAlignment.start), + ConfigItem( + label: Text('Public Key'), + content: SelectableText(widget.certificate.cert.details.publicKey, + style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), + crossAxisAlignment: CrossAxisAlignment.start), + widget.certificate.rawCert != null + ? ConfigItem( + label: Text('PEM Format'), + content: SelectableText(widget.certificate.rawCert, + style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), + crossAxisAlignment: CrossAxisAlignment.start) + : Container(), + ], + ); + } + + Widget _buildDelete() { + if (widget.onDelete == null) { + return Container(); + } + + var title = widget.certificate.cert.details.isCa ? 'Delete CA?' : 'Delete cert?'; + + return Padding( + padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformButton( + child: Text('Delete'), + color: CupertinoColors.systemRed.resolveFrom(context), + onPressed: () => Utils.confirmDelete(context, title, () async { + Navigator.pop(context); + widget.onDelete(); + })))); + } +} diff --git a/lib/screens/siteConfig/CertificateScreen.dart b/lib/screens/siteConfig/CertificateScreen.dart new file mode 100644 index 0000000..8e2ebf5 --- /dev/null +++ b/lib/screens/siteConfig/CertificateScreen.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; + +import 'package:barcode_scan/barcode_scan.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_share/flutter_share.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/components/config/ConfigTextItem.dart'; +import 'package:mobile_nebula/models/Certificate.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificateDetailsScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class CertificateResult { + CertificateInfo cert; + String key; + + CertificateResult({this.cert, this.key}); +} + +class CertificateScreen extends StatefulWidget { + const CertificateScreen({Key key, this.cert, this.onSave}) : super(key: key); + + final CertificateInfo cert; + final ValueChanged onSave; + + @override + _CertificateScreenState createState() => _CertificateScreenState(); +} + +class _CertificateScreenState extends State { + String pubKey; + String privKey; + bool changed = false; + + CertificateInfo cert; + + String inputType = 'paste'; + bool shared = false; + + final pasteController = TextEditingController(); + static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); + + @override + void initState() { + cert = widget.cert; + super.initState(); + } + + @override + Widget build(BuildContext context) { + List items = []; + bool hideSave = true; + + if (cert == null) { + if (pubKey == null) { + items = _buildGenerate(); + } else { + items.addAll(_buildShare()); + items.addAll(_buildLoadCert()); + } + } else { + items.addAll(_buildCertList()); + hideSave = false; + } + + return FormPage( + title: 'Certificate', + changed: changed, + hideSave: hideSave, + onSave: () { + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(CertificateResult(cert: cert, key: privKey)); + } + }, + child: Column(children: items)); + } + + _buildCertList() { + //TODO: generate a full list + return [ + ConfigSection( + children: [ + ConfigPageItem( + content: Text(cert.cert.details.name), + onPressed: () { + Utils.openPage(context, (context) { + //TODO: wire on delete + return CertificateDetailsScreen(certificate: cert); + }); + }, + ) + ], + ) + ]; + } + + List _buildGenerate() { + return [ + ConfigSection(label: 'Please generate a new public and private key', children: [ + ConfigButtonItem( + content: Text('Generate Keys'), + onPressed: () => _generateKeys(), + ) + ]) + ]; + } + + _generateKeys() async { + try { + var kp = await platform.invokeMethod("nebula.generateKeyPair"); + Map keyPair = jsonDecode(kp); + + setState(() { + changed = true; + pubKey = keyPair['PublicKey']; + privKey = keyPair['PrivateKey']; + }); + } on PlatformException catch (err) { + Utils.popError(context, 'Failed to generate key pair', err.details ?? err.message); + } + } + + List _buildShare() { + return [ + ConfigSection( + label: 'Share your public key with a nebula CA so they can sign and return a certificate', + children: [ + ConfigItem( + labelWidth: 0, + content: SelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), + ), + ConfigButtonItem( + content: Text('Share Public Key'), + onPressed: () async { + await FlutterShare.share(title: 'Please sign and return a certificate', text: pubKey); + setState(() { + shared = true; + }); + }, + ), + ]) + ]; + } + + List _buildLoadCert() { + List items = [ + Padding( + padding: EdgeInsets.fromLTRB(10, 25, 10, 0), + child: CupertinoSlidingSegmentedControl( + groupValue: inputType, + onValueChanged: (v) { + setState(() { + inputType = v; + }); + }, + children: { + 'paste': Text('Copy/Paste'), + 'file': Text('File'), + 'qr': Text('QR Code'), + }, + )) + ]; + + if (inputType == 'paste') { + items.addAll(_addPaste()); + } else if (inputType == 'file') { + items.addAll(_addFile()); + } else { + items.addAll(_addQr()); + } + + return items; + } + + List _addPaste() { + return [ + ConfigSection( + children: [ + ConfigTextItem( + placeholder: 'Certificate PEM Contents', + controller: pasteController, + ), + ConfigButtonItem( + content: Center(child: Text('Load Certificate')), + onPressed: () { + _addCertEntry(pasteController.text, (err) { + if (err != null) { + return Utils.popError(context, 'Failed to parse certificate content', err); + } + + pasteController.text = ''; + setState(() {}); + }); + }), + ], + ) + ]; + } + + List _addFile() { + return [ + ConfigSection( + children: [ + ConfigButtonItem( + content: Center(child: Text('Choose a file')), + onPressed: () async { + var file; + try { + await FilePicker.clearTemporaryFiles(); + file = await FilePicker.getFile(); + + if (file == null) { + print('GOT A NULL'); + return; + } + } catch (err) { + print('HEY $err'); + } + + var content = ""; + try { + content = file.readAsStringSync(); + } catch (err) { + print('CAUGH IN READ ${file}'); + return Utils.popError(context, 'Failed to load CA file', err.toString()); + } + + _addCertEntry(content, (err) { + if (err != null) { + Utils.popError(context, 'Error loading certificate file', err); + } else { + setState(() {}); + } + }); + }) + ], + ) + ]; + } + + List _addQr() { + return [ + ConfigSection( + children: [ + ConfigButtonItem( + content: Text('Scan a QR code'), + onPressed: () async { + var options = ScanOptions( + restrictFormat: [BarcodeFormat.qr], + ); + + var result = await BarcodeScanner.scan(options: options); + if (result.rawContent != "") { + _addCertEntry(result.rawContent, (err) { + if (err != null) { + Utils.popError(context, 'Error loading certificate content', err); + } else { + setState(() {}); + } + }); + } + }), + ], + ) + ]; + } + + _addCertEntry(String rawCert, ValueChanged callback) async { + String error; + + try { + var rawCerts = await platform.invokeMethod("nebula.parseCerts", {"certs": rawCert}); + List certs = jsonDecode(rawCerts); + if (certs.length > 0) { + cert = CertificateInfo.fromJson(certs.first); + } + } on PlatformException catch (err) { + error = err.details ?? err.message; + } + + if (callback != null) { + callback(error); + } + } +} diff --git a/lib/screens/siteConfig/CipherScreen.dart b/lib/screens/siteConfig/CipherScreen.dart new file mode 100644 index 0000000..9904b5a --- /dev/null +++ b/lib/screens/siteConfig/CipherScreen.dart @@ -0,0 +1,70 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class CipherScreen extends StatefulWidget { + const CipherScreen({Key key, this.cipher, @required this.onSave}) : super(key: key); + + final String cipher; + final ValueChanged onSave; + + @override + _CipherScreenState createState() => _CipherScreenState(); +} + +class _CipherScreenState extends State { + String cipher; + bool changed = false; + + @override + void initState() { + cipher = widget.cipher; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: 'Cipher Selection', + changed: changed, + onSave: () { + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(cipher); + } + }, + child: Column( + children: [ + ConfigSection(children: [ + ConfigCheckboxItem( + label: Text("aes"), + labelWidth: 150, + checked: cipher == "aes", + onChanged: () { + setState(() { + changed = true; + cipher = "aes"; + }); + }, + ), + ConfigCheckboxItem( + label: Text("chachapoly"), + labelWidth: 150, + checked: cipher == "chachapoly", + onChanged: () { + setState(() { + changed = true; + cipher = "chachapoly"; + }); + }, + ) + ]) + ], + )); + } +} diff --git a/lib/screens/siteConfig/LogVerbosityScreen.dart b/lib/screens/siteConfig/LogVerbosityScreen.dart new file mode 100644 index 0000000..d229b25 --- /dev/null +++ b/lib/screens/siteConfig/LogVerbosityScreen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class LogVerbosityScreen extends StatefulWidget { + const LogVerbosityScreen({Key key, this.verbosity, @required this.onSave}) : super(key: key); + + final String verbosity; + final ValueChanged onSave; + + @override + _LogVerbosityScreenState createState() => _LogVerbosityScreenState(); +} + +class _LogVerbosityScreenState extends State { + String verbosity; + bool changed = false; + + @override + void initState() { + verbosity = widget.verbosity; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: 'Log Verbosity', + changed: changed, + onSave: () { + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(verbosity); + } + }, + child: Column( + children: [ + ConfigSection(children: [ + _buildEntry('debug'), + _buildEntry('info'), + _buildEntry('warning'), + _buildEntry('error'), + _buildEntry('fatal'), + _buildEntry('panic'), + ]) + ], + )); + } + + Widget _buildEntry(String title) { + return ConfigCheckboxItem( + label: Text(title), + labelWidth: 150, + checked: verbosity == title, + onChanged: () { + setState(() { + changed = true; + verbosity = title; + }); + }, + ); + } +} diff --git a/lib/screens/siteConfig/RenderedConfigScreen.dart b/lib/screens/siteConfig/RenderedConfigScreen.dart new file mode 100644 index 0000000..8a11942 --- /dev/null +++ b/lib/screens/siteConfig/RenderedConfigScreen.dart @@ -0,0 +1,21 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_nebula/components/SimplePage.dart'; + +class RenderedConfigScreen extends StatelessWidget { + final String config; + + RenderedConfigScreen({Key key, this.config}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SimplePage( + title: 'Rendered Site Config', + scrollable: SimpleScrollable.both, + child: Container( + padding: EdgeInsets.all(5), + constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width), + child: SelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))), + ); + } +} diff --git a/lib/screens/siteConfig/SiteConfigScreen.dart b/lib/screens/siteConfig/SiteConfigScreen.dart new file mode 100644 index 0000000..c88b20f --- /dev/null +++ b/lib/screens/siteConfig/SiteConfigScreen.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/PlatformTextFormField.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Site.dart'; +import 'package:mobile_nebula/screens/siteConfig/AdvancedScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/CAListScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/CertificateScreen.dart'; +import 'package:mobile_nebula/screens/siteConfig/StaticHostsScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +//TODO: Add a config test mechanism + +class SiteConfigScreen extends StatefulWidget { + const SiteConfigScreen({Key key, this.site, this.onSave}) : super(key: key); + + final Site site; + + // This is called after the target OS has saved the configuration + final ValueChanged onSave; + + @override + _SiteConfigScreenState createState() => _SiteConfigScreenState(); +} + +class _SiteConfigScreenState extends State { + bool changed = false; + bool newSite = false; + bool debug = false; + Site site; + + final nameController = TextEditingController(); + + @override + void initState() { + if (widget.site == null) { + newSite = true; + site = Site(); + } else { + site = widget.site; + nameController.text = site.name; + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: newSite ? 'New Site' : 'Edit Site', + changed: changed, + onSave: () async { + site.name = nameController.text; + try { + await site.save(); + } catch (error) { + return Utils.popError(context, 'Failed to save the site configuration', error.toString()); + } + + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(site); + } + }, + child: Column( + children: [ + _main(), + _keys(), + _hosts(), + _advanced(), + kDebugMode ? _debugConfig() : Container(height: 0), + ], + )); + } + + Widget _debugConfig() { + var data = ""; + try { + final encoder = new JsonEncoder.withIndent(' '); + data = encoder.convert(site); + } catch (err) { + data = err.toString(); + } + + return ConfigSection(label: 'DEBUG', children: [ConfigItem(labelWidth: 0, content: SelectableText(data))]); + } + + Widget _main() { + return ConfigSection(children: [ + ConfigItem( + label: Text("Name"), + content: PlatformTextFormField( + placeholder: 'Required', + controller: nameController, + )) + ]); + } + + Widget _keys() { + final certError = site.cert == null || !site.cert.validity.valid; + var caError = site.ca.length == 0; + if (!caError) { + site.ca.forEach((ca) { + if (!ca.validity.valid) { + caError = true; + } + }); + } + + return ConfigSection( + label: "IDENTITY", + children: [ + ConfigPageItem( + label: Text('Certificate'), + content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: [ + certError + ? Padding( + child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), + padding: EdgeInsets.only(right: 5)) + : Container(), + certError ? Text('Needs attention') : Text(site.cert.cert.details.name) + ]), + onPressed: () { + Utils.openPage(context, (context) { + return CertificateScreen( + cert: site.cert, + onSave: (result) { + setState(() { + changed = true; + site.cert = result.cert; + site.key = result.key; + }); + }); + }); + }, + ), + ConfigPageItem( + label: Text("CA"), + content: + Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: [ + caError + ? Padding( + child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), + padding: EdgeInsets.only(right: 5)) + : Container(), + caError ? Text('Needs attention') : Text(Utils.itemCountFormat(site.ca.length)) + ]), + onPressed: () { + Utils.openPage(context, (context) { + return CAListScreen( + cas: site.ca, + onSave: (ca) { + setState(() { + changed = true; + site.ca = ca; + }); + }); + }); + }) + ], + ); + } + + Widget _hosts() { + return ConfigSection( + label: "Set up static hosts and lighthouses", + children: [ + ConfigPageItem( + label: Text('Hosts'), + content: Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, children: [ + site.staticHostmap.length == 0 + ? Padding( + child: Icon(Icons.error, color: CupertinoColors.systemRed.resolveFrom(context), size: 20), + padding: EdgeInsets.only(right: 5)) + : Container(), + site.staticHostmap.length == 0 + ? Text('Needs attention') + : Text(Utils.itemCountFormat(site.staticHostmap.length)) + ]), + onPressed: () { + Utils.openPage(context, (context) { + return StaticHostsScreen( + hostmap: site.staticHostmap, + onSave: (map) { + setState(() { + changed = true; + site.staticHostmap = map; + }); + }); + }); + }, + ), + ], + ); + } + + Widget _advanced() { + return ConfigSection( + children: [ + ConfigPageItem( + label: Text('Advanced'), + onPressed: () { + Utils.openPage(context, (context) { + return AdvancedScreen( + site: site, + onSave: (settings) { + setState(() { + changed = true; + site.cipher = settings.cipher; + site.lhDuration = settings.lhDuration; + site.port = settings.port; + site.logVerbosity = settings.verbosity; + site.unsafeRoutes = settings.unsafeRoutes; + site.mtu = settings.mtu; + }); + }); + }); + }) + ], + ); + } +} diff --git a/lib/screens/siteConfig/StaticHostmapScreen.dart b/lib/screens/siteConfig/StaticHostmapScreen.dart new file mode 100644 index 0000000..4124e01 --- /dev/null +++ b/lib/screens/siteConfig/StaticHostmapScreen.dart @@ -0,0 +1,197 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/IPAndPortFormField.dart'; +import 'package:mobile_nebula/components/IPFormField.dart'; +import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Hostmap.dart'; +import 'package:mobile_nebula/models/IPAndPort.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class _IPAndPort { + final FocusNode focusNode; + IPAndPort destination; + + _IPAndPort({this.focusNode, this.destination}); +} + +class StaticHostmapScreen extends StatefulWidget { + const StaticHostmapScreen( + {Key key, this.nebulaIp, this.destinations, this.lighthouse = false, this.onDelete, @required this.onSave}) + : super(key: key); + + final List destinations; + final String nebulaIp; + final bool lighthouse; + final ValueChanged onSave; + final Function onDelete; + + @override + _StaticHostmapScreenState createState() => _StaticHostmapScreenState(); +} + +class _StaticHostmapScreenState extends State { + Map _destinations = {}; + String _nebulaIp; + bool _lighthouse; + bool changed = false; + + @override + void initState() { + _nebulaIp = widget.nebulaIp; + _lighthouse = widget.lighthouse; + widget.destinations?.forEach((dest) { + _destinations[UniqueKey()] = _IPAndPort(focusNode: FocusNode(), destination: dest); + }); + + if (_destinations.length == 0) { + _addDestination(); + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: widget.onDelete == null ? 'New Static Host' : 'Edit Static Host', + changed: changed, + onSave: _onSave, + child: Column(children: [ + ConfigSection(label: 'Maps a nebula ip address to multiple real world addresses', children: [ + ConfigItem( + label: Text('Nebula IP'), + labelWidth: 200, + content: IPFormField( + help: "Required", + initialValue: _nebulaIp, + ipOnly: true, + textAlign: TextAlign.end, + crossAxisAlignment: CrossAxisAlignment.end, + textInputAction: TextInputAction.next, + onSaved: (v) { + _nebulaIp = v; + })), + ConfigItem( + label: Text('Lighthouse'), + labelWidth: 200, + content: Container( + alignment: Alignment.centerRight, + child: Switch.adaptive( + value: _lighthouse, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) { + setState(() { + changed = true; + _lighthouse = v; + }); + })), + ), + ]), + ConfigSection( + label: 'List of public ips or dns names where for this host', + children: _buildHosts(), + ), + widget.onDelete != null + ? Padding( + padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformButton( + child: Text('Delete'), + color: CupertinoColors.systemRed.resolveFrom(context), + onPressed: () => Utils.confirmDelete(context, 'Delete host map?', () { + Navigator.of(context).pop(); + widget.onDelete(); + }), + ))) + : Container() + ])); + } + + _onSave() { + Navigator.pop(context); + if (widget.onSave != null) { + var map = Hostmap(nebulaIp: _nebulaIp, destinations: [], lighthouse: _lighthouse); + + _destinations.forEach((_, dest) { + map.destinations.add(dest.destination); + }); + + widget.onSave(map); + } + } + + List _buildHosts() { + List items = []; + + _destinations.forEach((key, dest) { + items.add(ConfigItem( + key: key, + label: Align( + alignment: Alignment.centerLeft, + child: PlatformIconButton( + padding: EdgeInsets.zero, + icon: Icon(Icons.remove_circle, color: CupertinoColors.systemRed.resolveFrom(context)), + onPressed: () => setState(() { + _removeDestination(key); + _dismissKeyboard(); + }))), + labelWidth: 70, + content: Row(children: [ + Expanded( + child: IPAndPortFormField( + ipHelp: 'public ip or name', + ipTextAlign: TextAlign.end, + noBorder: true, + initialValue: dest.destination, + onSaved: (v) { + dest.destination = v; + }, + )), + ]), + )); + }); + + items.add(ConfigButtonItem( + content: Text('Add another'), + onPressed: () => setState(() { + _addDestination(); + _dismissKeyboard(); + }))); + + return items; + } + + _addDestination() { + changed = true; + _destinations[UniqueKey()] = _IPAndPort(focusNode: FocusNode(), destination: IPAndPort()); + // We can't onChanged here because it causes rendering issues on first build due to ensuring there is a single destination + } + + _removeDestination(Key key) { + changed = true; + _destinations.remove(key); + } + + _dismissKeyboard() { + FocusScopeNode currentFocus = FocusScope.of(context); + + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + } + + @override + void dispose() { + _destinations.forEach((key, dest) { + dest.focusNode.dispose(); + }); + + super.dispose(); + } +} diff --git a/lib/screens/siteConfig/StaticHostsScreen.dart b/lib/screens/siteConfig/StaticHostsScreen.dart new file mode 100644 index 0000000..5c2f7c3 --- /dev/null +++ b/lib/screens/siteConfig/StaticHostsScreen.dart @@ -0,0 +1,142 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/Hostmap.dart'; +import 'package:mobile_nebula/models/IPAndPort.dart'; +import 'package:mobile_nebula/models/StaticHosts.dart'; +import 'package:mobile_nebula/screens/siteConfig/StaticHostMapScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +//TODO: wire up the focus nodes, add a done/next/prev to the keyboard + +class _Hostmap { + final FocusNode focusNode; + String nebulaIp; + List destinations; + bool lighthouse; + + _Hostmap({this.focusNode, this.nebulaIp, destinations, this.lighthouse}) + : destinations = destinations ?? List(); +} + +class StaticHostsScreen extends StatefulWidget { + const StaticHostsScreen({Key key, @required this.hostmap, @required this.onSave}) : super(key: key); + + final Map hostmap; + final ValueChanged> onSave; + + @override + _StaticHostsScreenState createState() => _StaticHostsScreenState(); +} + +class _StaticHostsScreenState extends State { + Map _hostmap = {}; + bool changed = false; + + @override + void initState() { + widget.hostmap?.forEach((key, map) { + _hostmap[UniqueKey()] = + _Hostmap(focusNode: FocusNode(), nebulaIp: key, destinations: map.destinations, lighthouse: map.lighthouse); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: 'Static Hosts', + changed: changed, + onSave: _onSave, + child: ConfigSection( + children: _buildHosts(), + )); + } + + _onSave() { + Navigator.pop(context); + if (widget.onSave != null) { + Map map = {}; + _hostmap.forEach((_, host) { + map[host.nebulaIp] = StaticHost(destinations: host.destinations, lighthouse: host.lighthouse); + }); + + widget.onSave(map); + } + } + + List _buildHosts() { + final double ipWidth = Utils.textSize("000.000.000.000", CupertinoTheme.of(context).textTheme.textStyle).width + 32; + List items = []; + _hostmap.forEach((key, host) { + items.add(ConfigPageItem( + label: Row(children: [ + Padding( + child: Icon(host.lighthouse ? Icons.lightbulb_outline : Icons.computer, + color: CupertinoColors.placeholderText.resolveFrom(context)), + padding: EdgeInsets.only(right: 10)), + Text(host.nebulaIp), + ]), + labelWidth: ipWidth, + content: Text(host.destinations.length.toString() + ' items', textAlign: TextAlign.end), + onPressed: () { + Utils.openPage(context, (context) { + return StaticHostmapScreen( + nebulaIp: host.nebulaIp, + destinations: host.destinations, + lighthouse: host.lighthouse, + onSave: (map) { + setState(() { + changed = true; + host.nebulaIp = map.nebulaIp; + host.destinations = map.destinations; + host.lighthouse = map.lighthouse; + }); + }, + onDelete: () { + setState(() { + changed = true; + _hostmap.remove(key); + }); + }); + }); + }, + )); + }); + + items.add(ConfigButtonItem( + content: Text('Add a new entry'), + onPressed: () { + Utils.openPage(context, (context) { + return StaticHostmapScreen(onSave: (map) { + setState(() { + changed = true; + _addHostmap(map); + }); + }); + }); + }, + )); + + return items; + } + + _addHostmap(Hostmap map) { + _hostmap[UniqueKey()] = (_Hostmap( + focusNode: FocusNode(), nebulaIp: map.nebulaIp, destinations: map.destinations, lighthouse: map.lighthouse)); + } + + @override + void dispose() { + _hostmap.forEach((key, host) { + host.focusNode.dispose(); + }); + + super.dispose(); + } +} diff --git a/lib/screens/siteConfig/UnsafeRouteScreen.dart b/lib/screens/siteConfig/UnsafeRouteScreen.dart new file mode 100644 index 0000000..97c3a44 --- /dev/null +++ b/lib/screens/siteConfig/UnsafeRouteScreen.dart @@ -0,0 +1,115 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:mobile_nebula/components/CIDRFormField.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/IPFormField.dart'; +import 'package:mobile_nebula/components/PlatformTextFormField.dart'; +import 'package:mobile_nebula/components/config/ConfigItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/CIDR.dart'; +import 'package:mobile_nebula/models/UnsafeRoute.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:mobile_nebula/validators/mtuValidator.dart'; + +class UnsafeRouteScreen extends StatefulWidget { + const UnsafeRouteScreen({Key key, this.route, this.onDelete, @required this.onSave}) : super(key: key); + + final UnsafeRoute route; + final ValueChanged onSave; + final Function onDelete; + + @override + _UnsafeRouteScreenState createState() => _UnsafeRouteScreenState(); +} + +class _UnsafeRouteScreenState extends State { + UnsafeRoute route; + bool changed = false; + + FocusNode routeFocus = FocusNode(); + FocusNode viaFocus = FocusNode(); + FocusNode mtuFocus = FocusNode(); + + @override + void initState() { + route = widget.route; + super.initState(); + } + + @override + Widget build(BuildContext context) { + var routeCIDR = route?.route == null ? CIDR() : CIDR.fromString(route?.route); + + return FormPage( + title: widget.onDelete == null ? 'New Unsafe Route' : 'Edit Unsafe Route', + changed: changed, + onSave: _onSave, + child: Column(children: [ + ConfigSection(children: [ + ConfigItem( + label: Text('Route'), + content: CIDRFormField( + initialValue: routeCIDR, + textInputAction: TextInputAction.next, + focusNode: routeFocus, + nextFocusNode: viaFocus, + onSaved: (v) { + route.route = v.toString(); + })), + ConfigItem( + label: Text('Via'), + content: IPFormField( + initialValue: route?.via ?? "", + ipOnly: true, + help: 'nebula ip', + textAlign: TextAlign.end, + crossAxisAlignment: CrossAxisAlignment.end, + textInputAction: TextInputAction.next, + focusNode: viaFocus, + nextFocusNode: mtuFocus, + onSaved: (v) { + route.via = v; + })), +//TODO: Android doesn't appear to support route based MTU, figure this out +// ConfigItem( +// label: Text('MTU'), +// content: PlatformTextFormField( +// placeholder: "", +// validator: mtuValidator(false), +// keyboardType: TextInputType.number, +// inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], +// initialValue: route?.mtu.toString(), +// textAlign: TextAlign.end, +// textInputAction: TextInputAction.done, +// focusNode: mtuFocus, +// onSaved: (v) { +// route.mtu = int.tryParse(v); +// })), + ]), + widget.onDelete != null + ? Padding( + padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), + child: SizedBox( + width: double.infinity, + child: PlatformButton( + child: Text('Delete'), + color: CupertinoColors.systemRed.resolveFrom(context), + onPressed: () => Utils.confirmDelete(context, 'Delete unsafe route?', () { + Navigator.of(context).pop(); + widget.onDelete(); + }), + ))) + : Container() + ])); + } + + _onSave() { + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(route); + } + } +} diff --git a/lib/screens/siteConfig/UnsafeRoutesScreen.dart b/lib/screens/siteConfig/UnsafeRoutesScreen.dart new file mode 100644 index 0000000..af5fda7 --- /dev/null +++ b/lib/screens/siteConfig/UnsafeRoutesScreen.dart @@ -0,0 +1,98 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mobile_nebula/components/FormPage.dart'; +import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; +import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; +import 'package:mobile_nebula/components/config/ConfigSection.dart'; +import 'package:mobile_nebula/models/UnsafeRoute.dart'; +import 'package:mobile_nebula/screens/siteConfig/UnsafeRouteScreen.dart'; +import 'package:mobile_nebula/services/utils.dart'; + +class UnsafeRoutesScreen extends StatefulWidget { + const UnsafeRoutesScreen({Key key, @required this.unsafeRoutes, @required this.onSave}) : super(key: key); + + final List unsafeRoutes; + final ValueChanged> onSave; + + @override + _UnsafeRoutesScreenState createState() => _UnsafeRoutesScreenState(); +} + +class _UnsafeRoutesScreenState extends State { + Map unsafeRoutes = {}; + bool changed = false; + + @override + void initState() { + widget.unsafeRoutes.forEach((route) { + unsafeRoutes[UniqueKey()] = route; + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FormPage( + title: 'Unsafe Routes', + changed: changed, + onSave: _onSave, + child: ConfigSection( + children: _buildRoutes(), + )); + } + + _onSave() { + Navigator.pop(context); + if (widget.onSave != null) { + widget.onSave(unsafeRoutes.values.toList()); + } + } + + List _buildRoutes() { + final double ipWidth = Utils.textSize("000.000.000.000/00", CupertinoTheme.of(context).textTheme.textStyle).width; + List items = []; + unsafeRoutes.forEach((key, route) { + items.add(ConfigPageItem( + label: Text(route.route), + labelWidth: ipWidth, + content: Text('via ${route.via}', textAlign: TextAlign.end), + onPressed: () { + Utils.openPage(context, (context) { + return UnsafeRouteScreen( + route: route, + onSave: (route) { + setState(() { + changed = true; + unsafeRoutes[key] = route; + }); + }, + onDelete: () { + setState(() { + changed = true; + unsafeRoutes.remove(key); + }); + }); + }); + }, + )); + }); + + items.add(ConfigButtonItem( + content: Text('Add a new route'), + onPressed: () { + Utils.openPage(context, (context) { + return UnsafeRouteScreen(route: UnsafeRoute(), onSave: (route) { + setState(() { + changed = true; + unsafeRoutes[UniqueKey()] = route; + }); + }); + }); + }, + )); + + return items; + } +} diff --git a/lib/services/settings.dart b/lib/services/settings.dart new file mode 100644 index 0000000..2ee09f7 --- /dev/null +++ b/lib/services/settings.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:mobile_nebula/services/storage.dart'; + +class Settings { + final _storage = Storage(); + StreamController _change = StreamController.broadcast(); + var _ready = Completer(); + var _settings = Map(); + + bool get useSystemColors { + return _getBool('systemDarkMode', true); + } + + set useSystemColors(bool enabled) { + if (!enabled) { + // Clear the dark mode to let the default system config take over, user can override from there + _settings.remove('darkMode'); + } + _set('systemDarkMode', enabled); + } + + bool get darkMode { + return _getBool('darkMode', SchedulerBinding.instance.window.platformBrightness == Brightness.dark); + } + + set darkMode(bool enabled) { + _set('darkMode', enabled); + } + + String _getString(String key, String defaultValue) { + final val = _settings[key]; + if (val is String) { + return val; + } + return defaultValue; + } + + bool _getBool(String key, bool defaultValue) { + final val = _settings[key]; + if (val is bool) { + return val; + } + return defaultValue; + } + + void _set(String key, dynamic value) { + _settings[key] = value; + _save(); + } + + Stream onChange() { + return _change.stream; + } + + void _save() { + final content = jsonEncode(_settings); + //TODO: handle errors + _storage.writeFile("config.json", content).then((_) { + _change.add(null); + }); + } + + static final Settings _instance = Settings._internal(); + + factory Settings() { + return _instance; + } + + Settings._internal() { + _ready = Completer(); + + _storage.readFile("config.json").then((rawConfig) { + if (rawConfig != null) { + _settings = jsonDecode(rawConfig); + } + + _ready.complete(); + _change.add(null); + }); + } + + void dispose() { + _change.close(); + } +} diff --git a/lib/services/storage.dart b/lib/services/storage.dart new file mode 100644 index 0000000..7abbb33 --- /dev/null +++ b/lib/services/storage.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +class Storage { + Future mkdir(String path) async { + final parent = await localPath; + return Directory(p.join(parent, path)).create(recursive: true); + } + + Future> listDir(String path) async { + List list = []; + var parent = await localPath; + + if (path != '') { + parent = p.join(parent, path); + } + + var completer = Completer>(); + + Directory(parent).list().listen((FileSystemEntity entity) { + list.add(entity); + }).onDone(() { + completer.complete(list); + }); + + return completer.future; + } + + Future get localPath async { + final directory = await getApplicationDocumentsDirectory(); + return directory.path; + } + + Future readFile(String path) async { + try { + final parent = await localPath; + final file = File(p.join(parent, path)); + + // Read the file + return await file.readAsString(); + } catch (e) { + // If encountering an error, return 0 + return null; + } + } + + Future writeFile(String path, String contents) async { + final parent = await localPath; + final file = File(p.join(parent, path)); + + // Write the file + return file.writeAsString(contents); + } + + Future delete(String path) async { + var parent = await localPath; + return File(p.join(parent, path)).delete(recursive: true); + } + + Future getFullPath(String path) async { + var parent = await localPath; + return p.join(parent, path); + } +} diff --git a/lib/services/utils.dart b/lib/services/utils.dart new file mode 100644 index 0000000..c09f771 --- /dev/null +++ b/lib/services/utils.dart @@ -0,0 +1,166 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Utils { + /// Minimum size (width or height) of a interactive component + static const double minInteractiveSize = 44; + + /// The background color for a page, this is the furthest back color + static Color pageBackground(BuildContext context) { + return CupertinoColors.systemGroupedBackground.resolveFrom(context); + } + + /// The background color for a config item + static Color configItemBackground(BuildContext context) { + return CupertinoColors.secondarySystemGroupedBackground.resolveFrom(context); + } + + /// The top and bottom border color of a config section + static Color configSectionBorder(BuildContext context) { + return CupertinoColors.secondarySystemFill.resolveFrom(context); + } + + static Size textSize(String text, TextStyle style) { + final TextPainter textPainter = + TextPainter(text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr) + ..layout(minWidth: 0, maxWidth: double.infinity); + return textPainter.size; + } + + static openPage(BuildContext context, WidgetBuilder pageToDisplayBuilder) { + Navigator.push( + context, + platformPageRoute( + context: context, + builder: pageToDisplayBuilder, + ), + ); + } + + static String itemCountFormat(int items, {singleSuffix = "item", multiSuffix = "items"}) { + if (items == 1) { + return items.toString() + " " + singleSuffix; + } + + return items.toString() + " " + multiSuffix; + } + + /// Builds a simple leading widget that pops the current screen. + /// Provide your own onPressed to override that behavior, just remember you have to pop + static Widget leadingBackWidget(BuildContext context, {label = 'Back', Function onPressed}) { + if (Platform.isAndroid) { + return IconButton( + padding: EdgeInsets.zero, + icon: Icon(context.platformIcons.back), + tooltip: label, + onPressed: () { + if (onPressed == null) { + Navigator.pop(context); + } else { + onPressed(); + } + }, + ); + } + + return CupertinoButton( + child: Row(children: [Icon(context.platformIcons.back), Text(label)]), + padding: EdgeInsets.zero, + onPressed: () { + if (onPressed == null) { + Navigator.pop(context); + } else { + onPressed(); + } + }, + ); + } + + static Widget trailingSaveWidget(BuildContext context, Function onPressed) { + return CupertinoButton( + child: Text('Save', style: TextStyle(fontWeight: FontWeight.bold)), + padding: Platform.isAndroid ? null : EdgeInsets.zero, + onPressed: () => onPressed()); + } + + /// Simple cross platform delete confirmation dialog - can also be used to confirm throwing away a change by swapping the deleteLabel + static confirmDelete(BuildContext context, String title, Function onConfirm, + {String deleteLabel = 'Delete', String cancelLabel = 'Cancel'}) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return PlatformAlertDialog( + title: Text(title), + actions: [ + PlatformDialogAction( + child: Text(deleteLabel, + style: + TextStyle(fontWeight: FontWeight.bold, color: CupertinoColors.systemRed.resolveFrom(context))), + onPressed: () { + Navigator.pop(context); + onConfirm(); + }, + ), + PlatformDialogAction( + child: Text(cancelLabel), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + } + + static popError(BuildContext context, String title, String error) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + if (Platform.isAndroid) { + return AlertDialog(title: Text(title), content: Text(error), actions: [ + FlatButton( + child: Text('Ok'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ]); + } + + return CupertinoAlertDialog( + title: Text(title), + content: Text(error), + actions: [ + CupertinoDialogAction( + child: Text('Ok'), + onPressed: () { + Navigator.of(context).pop(); + }, + ) + ], + ); + }); + } + + static launchUrl(String url, BuildContext context) async { + if (await canLaunch(url)) { + await launch(url); + } else { + Utils.popError(context, 'Error', 'Could not launch web view'); + } + } + + static int ip2int(String ip) { + final parts = ip.split('.'); + return int.parse(parts[3]) | int.parse(parts[2]) << 8 | int.parse(parts[1]) << 16 | int.parse(parts[0]) << 24; + } +} diff --git a/lib/validators/dnsValidator.dart b/lib/validators/dnsValidator.dart new file mode 100644 index 0000000..a19a6a2 --- /dev/null +++ b/lib/validators/dnsValidator.dart @@ -0,0 +1,34 @@ +// Inspired by https://github.com/suragch/string_validator/blob/master/lib/src/validator.dart + +bool dnsValidator(str, {requireTld = true, allowUnderscore = false}) { + if (str == null) { + return false; + } + + List parts = str.split('.'); + if (requireTld) { + var tld = parts.removeLast(); + if (parts.isEmpty || !RegExp(r'^[a-z]{2,}$').hasMatch(tld)) { + return false; + } + } + + for (var part, i = 0; i < parts.length; i++) { + part = parts[i]; + if (allowUnderscore) { + if (part.indexOf('__') >= 0) { + return false; + } + } + + if (!RegExp(r'^[a-z\\u00a1-\\uffff0-9-]+$').hasMatch(part)) { + return false; + } + + if (part[0] == '-' || part[part.length - 1] == '-' || part.indexOf('---') >= 0) { + return false; + } + } + + return true; +} diff --git a/lib/validators/ipValidator.dart b/lib/validators/ipValidator.dart new file mode 100644 index 0000000..9a819d7 --- /dev/null +++ b/lib/validators/ipValidator.dart @@ -0,0 +1,17 @@ +// Inspired by https://github.com/suragch/string_validator/blob/master/lib/src/validator.dart + +final _ipv4 = RegExp(r'^(\d?\d?\d)\.(\d?\d?\d)\.(\d?\d?\d)\.(\d?\d?\d)$'); + +bool ipValidator(str) { + if (str == null) { + return false; + } + + if (!_ipv4.hasMatch(str)) { + return false; + } + + var parts = str.split('.'); + parts.sort((a, b) => int.parse(a) - int.parse(b)); + return int.parse(parts[3]) <= 255; +} diff --git a/lib/validators/mtuValidator.dart b/lib/validators/mtuValidator.dart new file mode 100644 index 0000000..9683334 --- /dev/null +++ b/lib/validators/mtuValidator.dart @@ -0,0 +1,14 @@ +Function mtuValidator(bool required) { + return (String str) { + if (str == null || str == "") { + return required ? 'Please fill out this field' : null; + } + + var mtu = int.tryParse(str); + if (mtu == null || mtu < 0 || mtu > 65535) { + return 'Please enter a valid mtu'; + } + + return null; + }; +} diff --git a/nebula/Makefile b/nebula/Makefile new file mode 100644 index 0000000..a643127 --- /dev/null +++ b/nebula/Makefile @@ -0,0 +1,12 @@ +111MODULE = on +export GO111MODULE + +mobileNebula.aar: *.go + gomobile bind -trimpath -v --target=android + +MobileNebula.framework: *.go + gomobile bind -trimpath -v --target=ios + +.DEFAULT_GOAL := mobileNebula.aar + +all: mobileNebula.aar MobileNebula.framework diff --git a/nebula/config.go b/nebula/config.go new file mode 100644 index 0000000..f448c50 --- /dev/null +++ b/nebula/config.go @@ -0,0 +1,204 @@ +package mobileNebula + +type config struct { + PKI configPKI `yaml:"pki"` + StaticHostmap map[string][]string `yaml:"static_host_map"` + Lighthouse configLighthouse `yaml:"lighthouse"` + Listen configListen `yaml:"listen"` + Punchy configPunchy `yaml:"punchy"` + Cipher string `yaml:"cipher"` + LocalRange string `yaml:"local_range"` + SSHD configSSHD `yaml:"sshd"` + Tun configTun `yaml:"tun"` + Logging configLogging `yaml:"logging"` + Stats configStats `yaml:"stats"` + Handshakes configHandshakes `yaml:"handshakes"` + Firewall configFirewall `yaml:"firewall"` +} + +func newConfig() *config { + mtu := 1300 + return &config{ + PKI: configPKI{ + Blacklist: []string{}, + }, + StaticHostmap: map[string][]string{}, + Lighthouse: configLighthouse{ + DNS: configDNS{}, + Interval: 60, + Hosts: []string{}, + RemoteAllowList: map[string]bool{}, + LocalAllowList: map[string]interface{}{}, + }, + Listen: configListen{ + Host: "0.0.0.0", + Port: 4242, + Batch: 64, + }, + Punchy: configPunchy{ + Punch: true, + Delay: "1s", + }, + Cipher: "aes", + SSHD: configSSHD{ + AuthorizedUsers: []configAuthorizedUser{}, + }, + Tun: configTun{ + Dev: "tun1", + DropLocalbroadcast: true, + DropMulticast: true, + TxQueue: 500, + MTU: &mtu, + Routes: []configRoute{}, + UnsafeRoutes: []configUnsafeRoute{}, + }, + Logging: configLogging{ + Level: "info", + Format: "text", + }, + Stats: configStats{}, + Handshakes: configHandshakes{ + TryInterval: "100ms", + Retries: 20, + WaitRotation: 5, + }, + Firewall: configFirewall{ + Conntrack: configConntrack{ + TcpTimeout: "120h", + UdpTimeout: "3m", + DefaultTimeout: "10m", + MaxConnections: 100000, + }, + Outbound: []configFirewallRule{ + { + Port: "any", + Proto: "any", + Host: "any", + }, + }, + Inbound: []configFirewallRule{}, + }, + } +} + +type configPKI struct { + CA string `yaml:"ca"` + Cert string `yaml:"cert"` + Key string `yaml:"key"` + Blacklist []string `yaml:"blacklist"` +} + +type configLighthouse struct { + AmLighthouse bool `yaml:"am_lighthouse"` + ServeDNS bool `yaml:"serve_dns"` + DNS configDNS `yaml:"dns"` + Interval int `yaml:"interval"` + Hosts []string `yaml:"hosts"` + RemoteAllowList map[string]bool `yaml:"remote_allow_list"` + LocalAllowList map[string]interface{} `yaml:"local_allow_list"` // This can be a special "interfaces" object or a bool +} + +type configDNS struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type configListen struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Batch int `yaml:"batch"` + ReadBuffer int64 `yaml:"read_buffer"` + WriteBuffer int64 `yaml:"write_buffer"` +} + +type configPunchy struct { + Punch bool `yaml:"punch"` + Respond bool `yaml:"respond"` + Delay string `yaml:"delay"` +} + +type configSSHD struct { + Enabled bool `yaml:"enabled"` + Listen string `yaml:"listen"` + HostKey string `yaml:"host_key"` + AuthorizedUsers []configAuthorizedUser `yaml:"authorized_users"` +} + +type configAuthorizedUser struct { + Name string `yaml:"name"` + Keys []string `yaml:"keys"` +} + +type configTun struct { + Dev string `yaml:"dev"` + DropLocalbroadcast bool `yaml:"drop_local_broadcast"` + DropMulticast bool `yaml:"drop_multicast"` + TxQueue int `yaml:"tx_queue"` + MTU *int `yaml:"mtu,omitempty"` + Routes []configRoute `yaml:"routes"` + UnsafeRoutes []configUnsafeRoute `yaml:"unsafe_routes"` +} + +type configRoute struct { + MTU int `yaml:"mtu"` + Route string `yaml:"route"` +} + +type configUnsafeRoute struct { + MTU *int `yaml:"mtu,omitempty"` + Route string `yaml:"route"` + Via string `yaml:"via"` +} + +type configLogging struct { + Level string `yaml:"level"` + Format string `yaml:"format"` + TimestampFormat string `yaml:"timestamp_format,omitempty"` +} + +type configStats struct { + Type string `yaml:"type"` + Interval string `yaml:"interval"` + + // Graphite settings + Prefix string `yaml:"prefix"` + Protocol string `yaml:"protocol"` + Host string `yaml:"host"` + + // Prometheus settings + Listen string `yaml:"listen"` + Path string `yaml:"path"` + Namespace string `yaml:"namespace"` + Subsystem string `yaml:"subsystem"` +} + +type configHandshakes struct { + TryInterval string `yaml:"try_interval"` + Retries int `yaml:"retries"` + WaitRotation int `yaml:"wait_rotation"` +} + +type configFirewall struct { + Conntrack configConntrack `yaml:"conntrack"` + Outbound []configFirewallRule `yaml:"outbound"` + Inbound []configFirewallRule `yaml:"inbound"` +} + +type configConntrack struct { + TcpTimeout string `yaml:"tcp_timeout"` + UdpTimeout string `yaml:"udp_timeout"` + DefaultTimeout string `yaml:"default_timeout"` + MaxConnections int `yaml:"max_connections"` +} + +type configFirewallRule struct { + Port string `yaml:"port,omitempty"` + Code string `yaml:"code,omitempty"` + Proto string `yaml:"proto,omitempty"` + Host string `yaml:"host,omitempty"` + Group string `yaml:"group,omitempty"` + Groups []string `yaml:"groups,omitempty"` + CIDR string `yaml:"cidr,omitempty"` + CASha string `yaml:"ca_sha,omitempty"` + CAName string `yaml:"ca_name,omitempty"` +} diff --git a/nebula/control.go b/nebula/control.go new file mode 100644 index 0000000..0f27afe --- /dev/null +++ b/nebula/control.go @@ -0,0 +1,116 @@ +package mobileNebula + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "runtime/debug" + + "github.com/sirupsen/logrus" + "github.com/slackhq/nebula" +) + +type Nebula struct { + c *nebula.Control + l *logrus.Logger +} + +func NewNebula(configData string, key string, logFile string, tunFd int) (*Nebula, error) { + // GC more often, largely for iOS due to extension 15mb limit + debug.SetGCPercent(20) + + yamlConfig, err := RenderConfig(configData, key) + if err != nil { + return nil, err + } + + config := nebula.NewConfig() + err = config.LoadString(yamlConfig) + if err != nil { + return nil, fmt.Errorf("failed to load config: %s", err) + } + + l := logrus.New() + f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return nil, err + } + l.SetOutput(f) + + //TODO: inject our version + c, err := nebula.Main(config, false, "", l, &tunFd) + if err != nil { + switch v := err.(type) { + case nebula.ContextualError: + return nil, v.RealError + default: + return nil, err + } + } + + return &Nebula{c, l}, nil +} + +func (n *Nebula) Start() { + n.c.Start() +} + +func (n *Nebula) ShutdownBlock() { + n.c.ShutdownBlock() +} + +func (n *Nebula) Stop() { + n.c.Stop() +} + +func (n *Nebula) Rebind() { + n.c.RebindUDPServer() +} + +func (n *Nebula) ListHostmap(pending bool) (string, error) { + hosts := n.c.ListHostmap(pending) + b, err := json.Marshal(hosts) + if err != nil { + return "", err + } + + return string(b), nil +} + +func (n *Nebula) GetHostInfoByVpnIp(vpnIp string, pending bool) (string, error) { + b, err := json.Marshal(n.c.GetHostInfoByVpnIp(stringIpToInt(vpnIp), pending)) + if err != nil { + return "", err + } + + return string(b), nil +} + +func (n *Nebula) CloseTunnel(vpnIp string) bool { + return n.c.CloseTunnel(stringIpToInt(vpnIp), false) +} + +func (n *Nebula) SetRemoteForTunnel(vpnIp string, addr string) (string, error) { + udpAddr := nebula.NewUDPAddrFromString(addr) + if udpAddr == nil { + return "", errors.New("could not parse udp address") + } + + b, err := json.Marshal(n.c.SetRemoteForTunnel(stringIpToInt(vpnIp), *udpAddr)) + if err != nil { + return "", err + } + + return string(b), nil +} + +func stringIpToInt(ip string) uint32 { + n := net.ParseIP(ip) + if len(n) == 16 { + return binary.BigEndian.Uint32(n[12:16]) + } + return binary.BigEndian.Uint32(n) +} \ No newline at end of file diff --git a/nebula/go.mod b/nebula/go.mod new file mode 100644 index 0000000..52593e6 --- /dev/null +++ b/nebula/go.mod @@ -0,0 +1,14 @@ +module github.com/DefinedNet/mobile_nebula/nebula + +go 1.14 + +replace github.com/slackhq/nebula => /Users/nate/src/github.com/slackhq/nebula + +require ( + github.com/prometheus/common v0.7.0 + github.com/sirupsen/logrus v1.4.2 + github.com/slackhq/nebula v1.1.1-0.20200303203524-6cdc17c01d30 + golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 + golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007 // indirect + gopkg.in/yaml.v2 v2.2.7 +) diff --git a/nebula/go.sum b/nebula/go.sum new file mode 100644 index 0000000..14674ae --- /dev/null +++ b/nebula/go.sum @@ -0,0 +1,158 @@ +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +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-20190717042225-c3de453c63f4 h1:Hs82Z41s6SdL1CELW+XaDYmOH4hkBN4/N9og/AsOv7E= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432 h1:M5QgkYacWj0Xs8MhpIK/5uwU02icXpEoSo9sM2aRCps= +github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432/go.mod h1:xwIwAxMvYnVrGJPe2FKx5prTrnAjGOD8zvDOnxnrrkM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/flynn/noise v0.0.0-20180327030543-2492fe189ae6 h1:u/UEqS66A5ckRmS4yNpjmVH56sVtS/RfclBAYocb4as= +github.com/flynn/noise v0.0.0-20180327030543-2492fe189ae6/go.mod h1:1i71OnUq3iUe1ma7Lr6yG6/rjvM3emb6yoL7xLFzcVQ= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/service v1.0.0/go.mod h1:8CzDhVuCuugtsHyZoTvsOBuvonN/UDBvl0kH+BUxvbo= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg= +github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nbrownus/go-metrics-prometheus v0.0.0-20180622211546-6e6d5173d99c h1:G/mfx/MWYuaaGlHkZQBBXFAJiYnRt/GaOVxnRHjlxg4= +github.com/nbrownus/go-metrics-prometheus v0.0.0-20180622211546-6e6d5173d99c/go.mod h1:1yMri853KAI2pPAUnESjaqZj9JeImOUM+6A4GuuPmTs= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.2.1 h1:JnMpQc6ppsNgw9QPAGF6Dod479itz7lvlsMzzNayLOI= +github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20191202183732-d1d2010b5bee h1:iBZPTYkGLvdu6+A5TsMUJQkQX9Ad4aCEnSQtdxPuTCQ= +github.com/prometheus/client_model v0.0.0-20191202183732-d1d2010b5bee/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563 h1:dY6ETXrvDG7Sa4vE8ZQG4yqWg6UnOcbqTAahkV813vQ= +github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/slackhq/nebula v1.1.1-0.20200303203524-6cdc17c01d30 h1:7DovgM4NDqKj966SMS0+Hz2gXl7JeRZQ9+9g9Kg/G18= +github.com/slackhq/nebula v1.1.1-0.20200303203524-6cdc17c01d30/go.mod h1:zwkf6kct+HA8sYMNzI5/iEKRAKBa50TXOoRzmuGIRCA= +github.com/slackhq/nebula v1.2.0 h1:44U4G86Ek/5XDVlH3iFtrPoZ0aNP2k/E1RT0daXQaIM= +github.com/slackhq/nebula v1.2.0/go.mod h1:zwkf6kct+HA8sYMNzI5/iEKRAKBa50TXOoRzmuGIRCA= +github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b h1:+y4hCMc/WKsDbAPsOQZgBSaSZ26uh2afyaWeVg/3s/c= +github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +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/vishvananda/netlink v1.0.1-0.20190522153524-00009fb8606a h1:Bt1IVPhiCDMqwGrc2nnbIN4QKvJGx6SK2NzWBmW00ao= +github.com/vishvananda/netlink v1.0.1-0.20190522153524-00009fb8606a/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 h1:/Tl7pH94bvbAAHBdZJT947M/+gp0+CqQXDtMRC0fseo= +golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007 h1:JxsyO7zPDWn1rBZW8FV5RFwCKqYeXnyaS/VQPLpXu6I= +golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST1oJBmxy4QpMMregXVQ= +golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69 h1:yBHHx+XZqXJBm6Exke3N7V9gnlsyXxoCPEb1yVenjfk= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/nebula/mobileNebula.go b/nebula/mobileNebula.go new file mode 100644 index 0000000..06fc365 --- /dev/null +++ b/nebula/mobileNebula.go @@ -0,0 +1,212 @@ +package mobileNebula + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/slackhq/nebula" + "github.com/slackhq/nebula/cert" + "golang.org/x/crypto/curve25519" + "gopkg.in/yaml.v2" +) + +type m map[string]interface{} + +type CIDR struct { + Ip string + MaskCIDR string + MaskSize int + Network string +} + +type Validity struct { + Valid bool + Reason string +} + +type RawCert struct { + RawCert string + Cert *cert.NebulaCertificate + Validity Validity +} + +type KeyPair struct { + PublicKey string + PrivateKey string +} + +func RenderConfig(configData string, key string) (string, error) { + config := newConfig() + var d m + + err := json.Unmarshal([]byte(configData), &d) + if err != nil { + return "", err + } + + config.PKI.CA, _ = d["ca"].(string) + config.PKI.Cert, _ = d["cert"].(string) + config.PKI.Key = key + + i, _ := d["port"].(float64) + config.Listen.Port = int(i) + + config.Cipher, _ = d["cipher"].(string) + // Log verbosity is not required + if val, _ := d["logVerbosity"].(string); val != "" { + config.Logging.Level = val + } + + i, _ = d["lhDuration"].(float64) + config.Lighthouse.Interval = int(i) + + if i, ok := d["mtu"].(float64); ok { + mtu := int(i) + config.Tun.MTU = &mtu + } + + config.Lighthouse.Hosts = make([]string, 0) + staticHostmap := d["staticHostmap"].(map[string]interface{}) + for nebIp, mapping := range staticHostmap { + def := mapping.(map[string]interface{}) + + isLh := def["lighthouse"].(bool) + if isLh { + config.Lighthouse.Hosts = append(config.Lighthouse.Hosts, nebIp) + } + + hosts := def["destinations"].([]interface{}) + realHosts := make([]string, len(hosts)) + + for i, h := range hosts { + realHosts[i] = h.(string) + } + + config.StaticHostmap[nebIp] = realHosts + } + + if unsafeRoutes, ok := d["unsafeRoutes"].([]interface{}); ok { + config.Tun.UnsafeRoutes = make([]configUnsafeRoute, len(unsafeRoutes)) + for i, r := range unsafeRoutes { + rawRoute := r.(map[string]interface{}) + route := &config.Tun.UnsafeRoutes[i] + route.Route = rawRoute["route"].(string) + route.Via = rawRoute["via"].(string) + } + } + + finalConfig, err := yaml.Marshal(config) + if err != nil { + return "", err + } + + return string(finalConfig), nil +} + +func GetConfigSetting(configData string, setting string) string { + config := nebula.NewConfig() + config.LoadString(configData) + return config.GetString(setting, "") +} + +func ParseCIDR(cidr string) (*CIDR, error) { + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + size, _ := ipNet.Mask.Size() + + return &CIDR{ + Ip: ip.String(), + MaskCIDR: fmt.Sprintf("%d.%d.%d.%d", ipNet.Mask[0], ipNet.Mask[1], ipNet.Mask[2], ipNet.Mask[3]), + MaskSize: size, + Network: ipNet.IP.String(), + }, nil +} + +// Returns a JSON representation of 1 or more certificates +func ParseCerts(rawStringCerts string) (string, error) { + var certs []RawCert + var c *cert.NebulaCertificate + var err error + rawCerts := []byte(rawStringCerts) + + for { + c, rawCerts, err = cert.UnmarshalNebulaCertificateFromPEM(rawCerts) + if err != nil { + return "", err + } + + rawCert, err := c.MarshalToPEM() + if err != nil { + return "", err + } + + rc := RawCert{ + RawCert: string(rawCert), + Cert: c, + Validity: Validity{ + Valid: true, + }, + } + + if c.Expired(time.Now()) { + rc.Validity.Valid = false + rc.Validity.Reason = "Certificate is expired" + } + + if rc.Validity.Valid && c.Details.IsCA && !c.CheckSignature(c.Details.PublicKey) { + rc.Validity.Valid = false + rc.Validity.Reason = "Certificate signature did not match" + } + + certs = append(certs, rc) + + if rawCerts == nil || strings.TrimSpace(string(rawCerts)) == "" { + break + } + } + + rawJson, err := json.Marshal(certs) + if err != nil { + return "", err + } + + return string(rawJson), nil +} + +func GenerateKeyPair() (string, error) { + pub, priv, err := x25519Keypair() + if err != nil { + return "", err + } + + kp := KeyPair{} + kp.PublicKey = string(cert.MarshalX25519PublicKey(pub)) + kp.PrivateKey = string(cert.MarshalX25519PrivateKey(priv)) + + rawJson, err := json.Marshal(kp) + if err != nil { + return "", err + } + + return string(rawJson), nil +} + +func x25519Keypair() ([]byte, []byte, error) { + var pubkey, privkey [32]byte + if _, err := io.ReadFull(rand.Reader, privkey[:]); err != nil { + return nil, nil, err + } + curve25519.ScalarBaseMult(&pubkey, &privkey) + return pubkey[:], privkey[:], nil +} + +//func VerifyCertAndKey(cert string, key string) (string, error) { +// +//} diff --git a/nebula/mobileNebula_test.go b/nebula/mobileNebula_test.go new file mode 100644 index 0000000..977ba15 --- /dev/null +++ b/nebula/mobileNebula_test.go @@ -0,0 +1,50 @@ +package mobileNebula + +import ( + "testing" + + "github.com/slackhq/nebula" +) + +func TestParseCerts(t *testing.T) { + jsonConfig := `{ + "name": "Debug Test - unsafe", + "id": "be9d6756-4099-4b25-a901-9d3b773e7d1a", + "staticHostmap": { + "10.1.0.1": { + "lighthouse": true, + "destinations": [ + "10.1.1.53:4242" + ] + } + }, + "unsafeRoutes": [ + { + "route": "10.3.3.3/32", + "via": "10.1.0.1", + "mtu": null + }, + { + "route": "1.1.1.2/32", + "via": "10.1.0.1", + "mtu": null + } + ], + "ca": "-----BEGIN NEBULA CERTIFICATE-----\nCpEBCg9EZWZpbmVkIHJvb3QgMDISE4CAhFCA/v//D4CCoIUMgID8/w8aE4CAgFCA\n/v//D4CAoIUMgID8/w8iBHRlc3QiBmxhcHRvcCIFcGhvbmUiCGVtcGxveWVlIgVh\nZG1pbiiI05z1BTCIuqGEBjogV/nxuQ1/kN12IrYs/H1cpZr3agQUnRs9FqWdJcOa\nJSlAARJA4H1wI3hdfVpIy8Y9IZHqIlMIFObCu5ceM4aELiTKsEGv+g7u8Dn1VY8g\nQPNsuOsqJB3ma8PntddPYn5QgH+qDA==\n-----END NEBULA CERTIFICATE-----\n", + "cert": "-----BEGIN NEBULA CERTIFICATE-----\nCmcKCmNocm9tZWJvb2sSCYmAhFCA/v//DyiR1Zf2BTCHuqGEBjogqtoJL9WKGKLp\nb3BIgTEZnTTusSJOiswuf1DS7jPjMzFKIIstsyPnnccgEYkNflwrYBvZFMCOtgmN\nuc5Jpc5lbzM9EkBACYP3VMFYHk2h5AcpURcG6QwS4iYOgHET7lMbM7WSMj4ZnzLR\ni2HhX58vSTr6evgvKuSPaA23hLUqR65QNRQD\n-----END NEBULA CERTIFICATE-----\n", + "key": null, + "lhDuration": 7200, + "port": 4242, + "mtu": 1300, + "cipher": "aes", + "sortKey": 3, + "logVerbosity": "info" +}` + s, err := RenderConfig(jsonConfig, "") + + config := nebula.NewConfig() + err = config.LoadString(s) + + t.Log(err) + return +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..6465d2c --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,376 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.1" + barcode_scan: + dependency: "direct main" + description: + name: barcode_scan + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.12" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.11" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_platform_widgets: + dependency: "direct main" + description: + name: flutter_platform_widgets + url: "https://pub.dartlang.org" + source: hosted + version: "0.41.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.8" + flutter_share: + dependency: "direct main" + description: + name: flutter_share + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2+1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.12" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.6" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.8" + package_info: + dependency: "direct main" + description: + name: package_info + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.4" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.10" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+1" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4+3" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + platform_detect: + dependency: transitive + description: + name: platform_detect + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.13" + protobuf: + dependency: transitive + description: + name: protobuf + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.4" + pull_to_refresh: + dependency: "direct main" + description: + name: pull_to_refresh + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.15" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.10" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+7" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.7" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+6" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.1" +sdks: + dart: ">=2.6.0 <3.0.0" + flutter: ">=1.17.0 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..17a81cd --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,85 @@ +name: mobile_nebula +description: Mobile Nebula Client + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+30 + +environment: + sdk: ">=2.1.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^0.1.2 + flutter_platform_widgets: ^0.41.0 + path_provider: ^1.6.0 + file_picker: ^1.9.0 + barcode_scan: ^3.0.1 + flutter_share: ^1.0.2 + uuid: ^2.0.4 + package_info: '>=0.4.1 <2.0.0' + url_launcher: ^5.4.10 + pull_to_refresh: ^1.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages + fonts: + - family: RobotoMono + fonts: + - asset: fonts/RobotoMono-Regular.ttf \ No newline at end of file