├── .swiftformatignore ├── ios ├── Gemfile ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── nebula icon white@2x-20.png │ │ │ ├── nebula icon white@2x-29.png │ │ │ ├── nebula icon white@2x-40.png │ │ │ ├── nebula icon white@2x-76.png │ │ │ ├── nebula icon white@2x-1024.png │ │ │ ├── nebula icon white@2x-20@2x.png │ │ │ ├── nebula icon white@2x-20@3x.png │ │ │ ├── nebula icon white@2x-29@2x.png │ │ │ ├── nebula icon white@2x-29@3x.png │ │ │ ├── nebula icon white@2x-40@2x.png │ │ │ ├── nebula icon white@2x-40@3x.png │ │ │ ├── nebula icon white@2x-60@2x.png │ │ │ ├── nebula icon white@2x-60@3x.png │ │ │ ├── nebula icon white@2x-76@2x.png │ │ │ ├── nebula icon white@2x-83.5@2x.png │ │ │ └── Contents.json │ ├── Runner.entitlements │ ├── PackageInfo.swift │ ├── APIClient.swift │ ├── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ └── DNUpdate.swift ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner.xcodeproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── fastlane │ ├── Appfile │ ├── Matchfile │ └── Fastfile ├── NebulaNetworkExtension │ ├── CtlInfo.h │ ├── NebulaNetworkExtension.entitlements │ ├── Info.plist │ └── Keychain.swift ├── .gitignore ├── Podfile └── Podfile.lock ├── android ├── fastlane │ ├── Appfile │ ├── README.md │ └── Fastfile ├── app │ ├── src │ │ ├── main │ │ │ ├── ic_launcher-playstore.png │ │ │ ├── res │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ ├── ic_launcher_round.png │ │ │ │ │ ├── ic_launcher_background.png │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ ├── xml │ │ │ │ │ └── provider_paths.xml │ │ │ │ ├── values │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ │ └── drawable │ │ │ │ │ └── launch_background.xml │ │ │ ├── kotlin │ │ │ │ └── net │ │ │ │ │ └── defined │ │ │ │ │ └── mobile_nebula │ │ │ │ │ ├── MyApplication.kt │ │ │ │ │ ├── PackageInfo.kt │ │ │ │ │ ├── APIClient.kt │ │ │ │ │ ├── EncFile.kt │ │ │ │ │ └── DNUpdateWorker.kt │ │ │ └── AndroidManifest.xml │ │ ├── debug │ │ │ ├── res │ │ │ │ └── values │ │ │ │ │ └── ic_launcher_background.xml │ │ │ └── AndroidManifest.xml │ │ └── profile │ │ │ └── AndroidManifest.xml │ ├── proguard-rules.pro │ └── build.gradle ├── gradle.properties ├── .gitignore ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── Gemfile ├── build.gradle ├── mobileNebula │ └── build.gradle └── settings.gradle ├── fonts └── RobotoMono-Regular.ttf ├── env.sh.example ├── .git-blame-ignore-revs ├── lib ├── models │ ├── Hostmap.dart │ ├── UnsafeRoute.dart │ ├── CIDR.dart │ ├── IPAndPort.dart │ ├── StaticHosts.dart │ ├── HostInfo.dart │ └── Certificate.dart ├── validators │ ├── mtuValidator.dart │ ├── ipValidator.dart │ └── dnsValidator.dart ├── components │ ├── config │ │ ├── ConfigTextItem.dart │ │ ├── ConfigButtonItem.dart │ │ ├── ConfigHeader.dart │ │ ├── ConfigItem.dart │ │ ├── ConfigCheckboxItem.dart │ │ ├── ConfigSection.dart │ │ └── ConfigPageItem.dart │ ├── SiteTitle.dart │ ├── buttons │ │ └── PrimaryButton.dart │ ├── DangerButton.dart │ ├── SiteItem.dart │ ├── FormPage.dart │ ├── CIDRField.dart │ ├── IPAndPortField.dart │ ├── SimplePage.dart │ ├── SpecialTextField.dart │ ├── IPField.dart │ ├── SpecialButton.dart │ └── IPFormField.dart ├── services │ ├── logs.dart │ ├── result.dart │ ├── storage.dart │ ├── share.dart │ └── settings.dart └── screens │ ├── siteConfig │ ├── RenderedConfigScreen.dart │ ├── LogVerbosityScreen.dart │ ├── CipherScreen.dart │ ├── UnsafeRoutesScreen.dart │ └── UnsafeRouteScreen.dart │ ├── LicensesScreen.dart │ ├── AboutScreen.dart │ ├── SettingsScreen.dart │ └── SiteTunnelsScreen.dart ├── .metadata ├── .github └── workflows │ ├── gofmt.sh │ ├── swiftfmt.yml │ ├── gofmt.yml │ └── fluttercheck.yml ├── images ├── dn-logo-dark.svg └── dn-logo-light.svg ├── nebula ├── Makefile ├── mobileNebula_test.go ├── go.mod ├── control.go └── site.go ├── .gitignore ├── CHANGELOG.md ├── gen-artifacts.sh ├── analysis_options.yaml ├── swift-format.sh ├── README.md ├── pubspec.yaml └── RELEASE.md /.swiftformatignore: -------------------------------------------------------------------------------- 1 | ios/Pods/** -------------------------------------------------------------------------------- /ios/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" -------------------------------------------------------------------------------- /android/fastlane/Appfile: -------------------------------------------------------------------------------- 1 | package_name("net.defined.mobile_nebula") 2 | json_key_file(ENV['GOOGLE_PLAY_API_JWT_PATH']) -------------------------------------------------------------------------------- /fonts/RobotoMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/fonts/RobotoMono-Regular.ttf -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /env.sh.example: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Ensure your go and flutter bin folders are here 4 | export PATH="$PATH:/path/to/go/bin:/path/to/flutter/bin" -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Keep our class names for gson 2 | -keep class net.defined.mobile_nebula.** { *; } 3 | -keep class androidx.security.crypto.** { *; } -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/xml/provider_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4096M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.nonTransitiveRClass=false 5 | android.nonFinalResIds=false 6 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /android/app/src/debug/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #f2c10d 4 | 5 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-20.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-29.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-40.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-76.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-1024.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DefinedNet/mobile_nebula/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/nebula icon white@2x-83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | /build/build-attribution/ 9 | /mobileNebula/mobileNebula.aar 10 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Big flutter format run 2 | 9934f226e3e79c3567ce07dbab9e9f6443e7afc5 3 | 4 | # Another big flutter format run 5 | ed348ab126160e64ba09899c946383ca9e54768c 6 | 7 | # Start formatting with swift-format 8 | 4621cbc0006b3c64c8948d920f69b0dc3f503565 -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip 6 | -------------------------------------------------------------------------------- /lib/models/Hostmap.dart: -------------------------------------------------------------------------------- 1 | import 'IPAndPort.dart'; 2 | 3 | class Hostmap { 4 | String nebulaIp; 5 | List destinations; 6 | bool lighthouse; 7 | 8 | Hostmap({required this.nebulaIp, required this.destinations, required this.lighthouse}); 9 | } 10 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("net.defined.mobileNebula") # The bundle identifier of your app 2 | itc_team_id("633953") # App Store Connect Team ID 3 | team_id("576H3XS7FP") # Developer Portal Team ID 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | -------------------------------------------------------------------------------- /android/Gemfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | 5 | source "https://rubygems.org" 6 | 7 | gem 'fastlane' 8 | 9 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 10 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 0b8abb4724aa590dd0f429683339b1e045a1594d 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /.github/workflows/gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$1" ]; then 4 | rm -f ./gofmterr 5 | find . -iname '*.go' ! -name '*.pb.go' -exec "$0" {} \; 6 | [ -f ./gofmterr ] && exit 1 7 | exit 0 8 | fi 9 | 10 | OUT="$(./nebula/goimports -d "$1" | awk '{printf "%s%%0A",$0}')" 11 | if [ -n "$OUT" ]; then 12 | echo "::error file=$1::$OUT" 13 | touch ./gofmterr 14 | fi -------------------------------------------------------------------------------- /lib/models/UnsafeRoute.dart: -------------------------------------------------------------------------------- 1 | class UnsafeRoute { 2 | String? route; 3 | String? via; 4 | 5 | UnsafeRoute({this.route, this.via}); 6 | 7 | factory UnsafeRoute.fromJson(Map json) { 8 | return UnsafeRoute(route: json['route'], via: json['via']); 9 | } 10 | 11 | Map toJson() { 12 | return {'route': route, 'via': via}; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | 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. -------------------------------------------------------------------------------- /lib/validators/mtuValidator.dart: -------------------------------------------------------------------------------- 1 | Function mtuValidator(bool required) { 2 | return (String str) { 3 | if (str == "") { 4 | return required ? 'Please fill out this field' : null; 5 | } 6 | 7 | var mtu = int.tryParse(str); 8 | if (mtu == null || mtu < 0 || mtu > 65535) { 9 | return 'Please enter a valid mtu'; 10 | } 11 | 12 | return null; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = '../build' 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | project.evaluationDependsOn(':app') 14 | } 15 | 16 | tasks.register("clean", Delete) { 17 | delete rootProject.buildDir 18 | } 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /images/dn-logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /images/dn-logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /ios/NebulaNetworkExtension/CtlInfo.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | /* */ 5 | #define CTLIOCGINFO 0xc0644e03UL 6 | struct ctl_info { 7 | u_int32_t ctl_id; 8 | char ctl_name[96]; 9 | }; 10 | struct sockaddr_ctl { 11 | u_char sc_len; 12 | u_char sc_family; 13 | u_int16_t ss_sysaddr; 14 | u_int32_t sc_id; 15 | u_int32_t sc_unit; 16 | u_int32_t sc_reserved[5]; 17 | }; 18 | -------------------------------------------------------------------------------- /android/mobileNebula/build.gradle: -------------------------------------------------------------------------------- 1 | configurations.maybeCreate("default") 2 | 3 | def sdkDir 4 | def ndkPath 5 | 6 | rootProject.project(':app') { p -> 7 | sdkDir = p.android.sdkDirectory 8 | ndkPath = p.android.ndkDirectory 9 | } 10 | 11 | exec { 12 | workingDir '../../' 13 | commandLine './gen-artifacts.sh', 'android' 14 | environment("ANDROID_HOME", sdkDir) 15 | environment("ANDROID_NDK_HOME", ndkPath) 16 | } 17 | 18 | artifacts.add("default", file('mobileNebula.aar')) -------------------------------------------------------------------------------- /lib/models/CIDR.dart: -------------------------------------------------------------------------------- 1 | class CIDR { 2 | CIDR({this.ip = '', this.bits = 0}); 3 | 4 | String ip; 5 | int bits; 6 | 7 | @override 8 | String toString() { 9 | return '$ip/$bits'; 10 | } 11 | 12 | String toJson() { 13 | return toString(); 14 | } 15 | 16 | factory CIDR.fromString(String val) { 17 | final parts = val.split('/'); 18 | if (parts.length != 2) { 19 | throw 'Invalid CIDR string'; 20 | } 21 | 22 | return CIDR(ip: parts[0], bits: int.parse(parts[1])); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/swiftfmt.yml: -------------------------------------------------------------------------------- 1 | name: Swift format 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | paths: 8 | - ".github/workflows/swiftfmt.yml" 9 | - "**.swift" 10 | jobs: 11 | swiftfmt: 12 | name: Run swift format 13 | runs-on: macos-26 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 17 | with: 18 | show-progress: false 19 | 20 | - name: Check formating 21 | run: ./swift-format.sh check 22 | -------------------------------------------------------------------------------- /lib/validators/ipValidator.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | bool ipValidator(String? str, bool enableIPV6) { 4 | if (str == null) { 5 | return false; 6 | } 7 | 8 | final ia = InternetAddress.tryParse(str); 9 | if (ia == null) { 10 | return false; 11 | } 12 | 13 | switch (ia.type) { 14 | case InternetAddressType.IPv6: 15 | { 16 | if (enableIPV6) { 17 | return true; 18 | } 19 | } 20 | break; 21 | 22 | case InternetAddressType.IPv4: 23 | { 24 | return true; 25 | } 26 | } 27 | 28 | return false; 29 | } 30 | -------------------------------------------------------------------------------- /ios/fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("https://github.com/DefinedNet/mobile_nebula_match.git") 2 | 3 | storage_mode("git") 4 | 5 | type("appstore") # The default type, can be: appstore, adhoc, enterprise or development 6 | 7 | app_identifier(["net.defined.mobileNebula", "net.defined.mobileNebula.NebulaNetworkExtension"]) 8 | 9 | # username("user@fastlane.tools") # Your Apple Developer Portal username 10 | 11 | # For all available options run `fastlane match --help` 12 | # Remove the # in the beginning of the line to enable the other options 13 | 14 | # The docs are available on https://docs.fastlane.tools/actions/match 15 | -------------------------------------------------------------------------------- /nebula/Makefile: -------------------------------------------------------------------------------- 1 | 111MODULE = on 2 | export GO111MODULE 3 | 4 | unexport SWIFT_DEBUG_INFORMATION_VERSION 5 | unexport SWIFT_DEBUG_INFORMATION_FORMAT 6 | 7 | clean: 8 | rm -rf mobileNebula.aar MobileNebula.xcframework 9 | 10 | mobileNebula.aar: *.go go.sum 11 | go get -d golang.org/x/mobile/cmd/gomobile 12 | gomobile bind -trimpath -v --target=android -androidapi=26 13 | 14 | MobileNebula.xcframework: *.go go.sum 15 | go get -d golang.org/x/mobile/cmd/gomobile 16 | gomobile bind -trimpath -v -target=ios 17 | 18 | .DEFAULT_GOAL := mobileNebula.aar 19 | 20 | .PHONY: clean 21 | all: mobileNebula.aar MobileNebula.framework 22 | -------------------------------------------------------------------------------- /ios/NebulaNetworkExtension/NebulaNetworkExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.networking.networkextension 6 | 7 | packet-tunnel-provider 8 | 9 | com.apple.security.application-groups 10 | 11 | group.net.defined.mobileNebula 12 | 13 | keychain-access-groups 14 | 15 | $(AppIdentifierPrefix)group.net.defined.mobileNebula 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/models/IPAndPort.dart: -------------------------------------------------------------------------------- 1 | class IPAndPort { 2 | String? ip; 3 | int? port; 4 | 5 | IPAndPort({this.ip, this.port}); 6 | 7 | @override 8 | String toString() { 9 | if (ip != null && ip!.contains(':')) { 10 | return '[$ip]:$port'; 11 | } 12 | 13 | return '$ip:$port'; 14 | } 15 | 16 | String toJson() { 17 | return toString(); 18 | } 19 | 20 | factory IPAndPort.fromString(String val) { 21 | //TODO: Uri.parse is as close as I could get to parsing both ipv4 and v6 addresses with a port without bringing a whole mess of code into here 22 | final uri = Uri.parse("ugh://$val"); 23 | 24 | return IPAndPort(ip: uri.host, port: uri.port); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/models/StaticHosts.dart: -------------------------------------------------------------------------------- 1 | import 'IPAndPort.dart'; 2 | 3 | class StaticHost { 4 | bool lighthouse; 5 | List destinations; 6 | 7 | StaticHost({required this.lighthouse, required this.destinations}); 8 | 9 | factory StaticHost.fromJson(Map json) { 10 | var list = json['destinations'] as List; 11 | var result = []; 12 | 13 | for (var item in list) { 14 | result.add(IPAndPort.fromString(item)); 15 | } 16 | 17 | return StaticHost(lighthouse: json['lighthouse'], destinations: result); 18 | } 19 | 20 | Map toJson() { 21 | return {'lighthouse': lighthouse, 'destinations': destinations}; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/components/config/ConfigTextItem.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | 3 | class ConfigTextItem extends StatelessWidget { 4 | const ConfigTextItem({ 5 | super.key, 6 | this.placeholder, 7 | this.controller, 8 | this.style = const TextStyle(fontFamily: 'RobotoMono'), 9 | }); 10 | 11 | final String? placeholder; 12 | final TextEditingController? controller; 13 | final TextStyle style; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return CupertinoTextFormFieldRow( 18 | autocorrect: false, 19 | minLines: 3, 20 | maxLines: 10, 21 | placeholder: placeholder, 22 | style: style, 23 | controller: controller, 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | /NebulaNetworkExtension/MobileNebula.framework/ 34 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/net/defined/mobile_nebula/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package net.defined.mobile_nebula 2 | 3 | import io.flutter.embedding.engine.loader.FlutterLoader 4 | import android.app.Application 5 | import androidx.work.Configuration 6 | import androidx.work.WorkManager 7 | 8 | class MyApplication : Application() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | 12 | // In order to use the WorkManager from the nebulaVpnBg process (i.e. NebulaVpnService) 13 | // we must explicitly initialize this rather than using the default initializer. 14 | val myConfig = Configuration.Builder().build() 15 | WorkManager.initialize(this, myConfig) 16 | 17 | FlutterLoader().startInitialization(applicationContext) 18 | } 19 | } -------------------------------------------------------------------------------- /ios/Runner/Runner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.associated-domains 6 | 7 | applinks:api.defined.net 8 | 9 | com.apple.developer.networking.networkextension 10 | 11 | packet-tunnel-provider 12 | 13 | com.apple.security.application-groups 14 | 15 | group.net.defined.mobileNebula 16 | 17 | keychain-access-groups 18 | 19 | $(AppIdentifierPrefix)group.net.defined.mobileNebula 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /ios/Runner/PackageInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class PackageInfo { 4 | func getVersion() -> String { 5 | let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" 6 | let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String 7 | 8 | if buildNumber == nil { 9 | return version 10 | } 11 | 12 | return "\(version)-\(buildNumber!)" 13 | } 14 | 15 | func getName() -> String { 16 | return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? Bundle.main 17 | .infoDictionary?["CFBundleName"] as? String ?? "Nebula" 18 | } 19 | 20 | func getSystemVersion() -> String { 21 | let osVersion = ProcessInfo.processInfo.operatingSystemVersion 22 | return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "org.gradle.toolchains.foojay-resolver-convention" version "0.8.0" 21 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 22 | id "com.android.application" version '8.13.2' apply false 23 | id "org.jetbrains.kotlin.android" version "2.0.20" apply false 24 | } 25 | 26 | include ':app', ':mobileNebula' -------------------------------------------------------------------------------- /lib/components/config/ConfigButtonItem.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:mobile_nebula/components/SpecialButton.dart'; 4 | import 'package:mobile_nebula/services/utils.dart'; 5 | 6 | // A config item that detects tapping and calls back on a tap 7 | class ConfigButtonItem extends StatelessWidget { 8 | const ConfigButtonItem({super.key, this.content, this.onPressed}); 9 | 10 | final Widget? content; 11 | final void Function()? onPressed; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return SpecialButton( 16 | color: Utils.configItemBackground(context), 17 | onPressed: onPressed, 18 | useButtonTheme: true, 19 | child: Container( 20 | constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), 21 | child: Center(child: content), 22 | ), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/validators/dnsValidator.dart: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/suragch/string_validator/blob/master/lib/src/validator.dart 2 | 3 | bool dnsValidator(str, {requireTld = true, allowUnderscore = false}) { 4 | if (str == null) { 5 | return false; 6 | } 7 | 8 | List parts = str.split('.'); 9 | if (requireTld) { 10 | var tld = parts.removeLast(); 11 | if (parts.isEmpty || !RegExp(r'^[a-z]{2,}$').hasMatch(tld)) { 12 | return false; 13 | } 14 | } 15 | 16 | for (var part, i = 0; i < parts.length; i++) { 17 | part = parts[i]; 18 | if (allowUnderscore) { 19 | if (part.indexOf('__') >= 0) { 20 | return false; 21 | } 22 | } 23 | 24 | if (!RegExp(r'^[a-z\\u00a1-\\uffff0-9-]+$').hasMatch(part)) { 25 | return false; 26 | } 27 | 28 | if (part[0] == '-' || part[part.length - 1] == '-' || part.indexOf('---') >= 0) { 29 | return false; 30 | } 31 | } 32 | 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /lib/components/SiteTitle.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_svg/svg.dart'; 3 | 4 | import '../models/Site.dart'; 5 | 6 | class SiteTitle extends StatelessWidget { 7 | const SiteTitle({super.key, required this.site}); 8 | 9 | final Site site; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final dnIcon = 14 | Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg'; 15 | 16 | return IntrinsicWidth( 17 | child: Padding( 18 | padding: EdgeInsets.symmetric(horizontal: 16), 19 | child: Row( 20 | children: [ 21 | site.managed 22 | ? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) 23 | : Container(), 24 | Expanded(child: Text(site.name, overflow: TextOverflow.ellipsis)), 25 | ], 26 | ), 27 | ), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/components/config/ConfigHeader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | TextStyle basicTextStyle(BuildContext context) => 7 | Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.titleMedium!; 8 | 9 | const double _headerFontSize = 13.0; 10 | 11 | class ConfigHeader extends StatelessWidget { 12 | const ConfigHeader({super.key, required this.label, this.color}); 13 | 14 | final String label; 15 | final Color? color; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Container( 20 | padding: const EdgeInsets.only(left: 10.0, top: 30.0, bottom: 5.0, right: 10.0), 21 | child: Text( 22 | label, 23 | style: basicTextStyle( 24 | context, 25 | ).copyWith(color: color ?? CupertinoColors.secondaryLabel.resolveFrom(context), fontSize: _headerFontSize), 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/services/logs.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:mobile_nebula/services/result.dart'; 5 | 6 | class LogsNotFoundException implements Exception { 7 | String error() => 'No logs found. Logs will be available after starting the site for the first time.'; 8 | } 9 | 10 | class LogsNotifier extends ChangeNotifier { 11 | Result? logsResult; 12 | 13 | LogsNotifier(); 14 | 15 | loadLogs({required String logFile}) async { 16 | final file = File(logFile); 17 | try { 18 | logsResult = Result.ok(await file.readAsString()); 19 | notifyListeners(); 20 | } on FileSystemException { 21 | logsResult = Result.error(LogsNotFoundException()); 22 | notifyListeners(); 23 | } on Exception catch (err) { 24 | logsResult = Result.error(err); 25 | notifyListeners(); 26 | } catch (err) { 27 | logsResult = Result.error(Exception(err)); 28 | notifyListeners(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /android/fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## Android 17 | 18 | ### android release_build_number 19 | 20 | ```sh 21 | [bundle exec] fastlane android release_build_number 22 | ``` 23 | 24 | 25 | 26 | ### android release 27 | 28 | ```sh 29 | [bundle exec] fastlane android release 30 | ``` 31 | 32 | Deploy a new version to the Google Play 33 | 34 | ---- 35 | 36 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 37 | 38 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 39 | 40 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 41 | -------------------------------------------------------------------------------- /.github/workflows/gofmt.yml: -------------------------------------------------------------------------------- 1 | name: gofmt 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | paths: 8 | - '.github/workflows/gofmt.yml' 9 | - '.github/workflows/gofmt.sh' 10 | - '**.go' 11 | jobs: 12 | 13 | gofmt: 14 | name: Run gofmt 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 19 | with: 20 | show-progress: false 21 | 22 | - name: Set up Go 1.22 23 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 #5.3.0 24 | with: 25 | go-version: '1.22' 26 | cache-dependency-path: nebula/go.sum 27 | 28 | - name: Install goimports 29 | working-directory: nebula 30 | run: | 31 | go get golang.org/x/tools/cmd/goimports 32 | go build golang.org/x/tools/cmd/goimports 33 | 34 | - name: gofmt 35 | run: $GITHUB_WORKSPACE/.github/workflows/gofmt.sh 36 | -------------------------------------------------------------------------------- /lib/components/buttons/PrimaryButton.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | class PrimaryButton extends StatelessWidget { 7 | const PrimaryButton({super.key, required this.child, this.onPressed}); 8 | 9 | final Widget child; 10 | final GestureTapCallback? onPressed; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | if (Platform.isAndroid) { 15 | return FilledButton( 16 | onPressed: onPressed, 17 | style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.primary), 18 | child: child, 19 | ); 20 | } else { 21 | // Workaround for https://github.com/flutter/flutter/issues/161590 22 | final themeData = CupertinoTheme.of(context); 23 | return CupertinoTheme( 24 | data: themeData.copyWith(primaryColor: CupertinoColors.white), 25 | child: CupertinoButton( 26 | onPressed: onPressed, 27 | color: CupertinoColors.secondaryLabel.resolveFrom(context), 28 | child: child, 29 | ), 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/components/DangerButton.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | class DangerButton extends StatelessWidget { 7 | const DangerButton({super.key, required this.child, this.onPressed}); 8 | 9 | final Widget child; 10 | final GestureTapCallback? onPressed; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | if (Platform.isAndroid) { 15 | return FilledButton( 16 | onPressed: onPressed, 17 | style: FilledButton.styleFrom( 18 | backgroundColor: Theme.of(context).colorScheme.error, 19 | foregroundColor: Theme.of(context).colorScheme.onError, 20 | ), 21 | child: child, 22 | ); 23 | } else { 24 | // Workaround for https://github.com/flutter/flutter/issues/161590 25 | final themeData = CupertinoTheme.of(context); 26 | return CupertinoTheme( 27 | data: themeData.copyWith(primaryColor: CupertinoColors.white), 28 | child: CupertinoButton( 29 | onPressed: onPressed, 30 | color: CupertinoColors.systemRed.resolveFrom(context), 31 | child: child, 32 | ), 33 | ); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ios/NebulaNetworkExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | NebulaNetworkExtension 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionPointIdentifier 26 | com.apple.networkextension.packet-tunnel 27 | NSExtensionPrincipalClass 28 | $(PRODUCT_MODULE_NAME).PacketTunnelProvider 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | android/app/.cxx 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Exceptions to above rules. 38 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 39 | /nebula/local.settings 40 | /nebula/MobileNebula.framework/ 41 | /nebula/mobileNebula-sources.jar 42 | /nebula/vendor/ 43 | /android/app/src/main/libs/mobileNebula.aar 44 | /nebula/mobileNebula.aar 45 | /android/key.properties 46 | /env.sh 47 | /lib/gen.versions.dart 48 | /lib/.gen.versions.dart 49 | /lib/oss_licenses.dart 50 | /ios/Flutter/.last_build_id 51 | /local.properties 52 | /.gradle/ 53 | *.keystore 54 | /nebula/MobileNebula.xcframework/ 55 | /ios/MobileNebula.xcframework/ 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.0.41] - 2021-06-09 11 | 12 | ### Added 13 | 14 | - Added an option to wrap logs in the hamburger menu. (#10) 15 | 16 | - IPv6 and better roaming support. (#24) 17 | 18 | - Certificates can now be replaced. (#33) 19 | 20 | ### Changed 21 | 22 | - Upgraded to Flutter 2. (#26) 23 | 24 | - Upgraded core Nebula to 1.4.1. (#41) 25 | 26 | ### Fixed 27 | 28 | - iOS: Reworked vpn process IPC for more reliable communication. (#28) 29 | 30 | - Android: Detecting the active vpn site on app boot is now more reliable. (#29) 31 | 32 | - Android: Quickly toggling site connection status no longer presents an error. (#16) 33 | 34 | - Android: Better vpn shutdown support. (#34) 35 | 36 | - Android: System DNS will continue to work when moving between IPv4 only and IPv6 networks. (#40) 37 | 38 | ## [0.0.38] - 2020-09-25 39 | 40 | ### Added 41 | 42 | - Initial public release. 43 | 44 | [0.0.38]: https://github.com/DefinedNet/mobile_nebula/releases/tag/v0.0.38 45 | [0.0.41]: https://github.com/DefinedNet/mobile_nebula/releases/tag/v0.0.41 -------------------------------------------------------------------------------- /.github/workflows/fluttercheck.yml: -------------------------------------------------------------------------------- 1 | name: Flutter check 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | paths: 8 | - '.github/workflows/fluttercheck.yml' 9 | - '**.dart' 10 | jobs: 11 | flutterfmt: 12 | name: Run flutter format 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Install flutter 16 | uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0 17 | with: 18 | flutter-version: '3.29.2' 19 | 20 | - name: Check out code 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 22 | with: 23 | show-progress: false 24 | 25 | - name: Check formating 26 | run: dart format -l120 lib/ --set-exit-if-changed --suppress-analytics --output none 27 | flutterlint: 28 | name: Run flutter lint 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Install flutter 32 | uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff #v2.18.0 33 | with: 34 | flutter-version: '3.29.2' 35 | 36 | - name: Check out code 37 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #4.2.2 38 | with: 39 | show-progress: false 40 | 41 | - name: Check linting 42 | run: dart fix --dry-run 43 | -------------------------------------------------------------------------------- /lib/screens/siteConfig/RenderedConfigScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 3 | import 'package:mobile_nebula/components/SimplePage.dart'; 4 | import 'package:mobile_nebula/services/share.dart'; 5 | 6 | class RenderedConfigScreen extends StatelessWidget { 7 | final String config; 8 | final String name; 9 | 10 | const RenderedConfigScreen({super.key, required this.config, required this.name}); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return SimplePage( 15 | title: Text('Rendered Site Config'), 16 | scrollable: SimpleScrollable.both, 17 | trailingActions: [ 18 | Builder( 19 | builder: (BuildContext context) { 20 | return PlatformIconButton( 21 | padding: EdgeInsets.zero, 22 | icon: Icon(context.platformIcons.share, size: 28.0), 23 | onPressed: () => Share.share(context, title: '$name.yaml', text: config, filename: '$name.yaml'), 24 | ); 25 | }, 26 | ), 27 | ], 28 | child: Container( 29 | padding: EdgeInsets.all(5), 30 | constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width), 31 | child: SelectableText(config, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), 32 | ), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/net/defined/mobile_nebula/PackageInfo.kt: -------------------------------------------------------------------------------- 1 | package net.defined.mobile_nebula 2 | 3 | import android.content.Context 4 | import android.content.pm.ApplicationInfo 5 | import android.content.pm.PackageInfo 6 | import android.content.pm.PackageManager 7 | import android.os.Build 8 | 9 | class PackageInfo(private val context: Context) { 10 | private val pInfo: PackageInfo = 11 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 12 | context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0)) 13 | else 14 | @Suppress("DEPRECATION") 15 | context.packageManager.getPackageInfo(context.packageName, 0) 16 | 17 | private val appInfo: ApplicationInfo = context.applicationInfo 18 | 19 | fun getVersion(): String { 20 | val version: String? = pInfo.versionName 21 | val build: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) 22 | pInfo.longVersionCode 23 | else 24 | @Suppress("DEPRECATION") 25 | pInfo.versionCode.toLong() 26 | return "%s-%d".format(version, build) 27 | } 28 | 29 | fun getName(): String { 30 | val stringId = appInfo.labelRes 31 | return if (stringId == 0) appInfo.nonLocalizedLabel.toString() else context.getString(stringId) 32 | } 33 | 34 | fun getSystemVersion(): String { 35 | return Build.VERSION.RELEASE 36 | } 37 | } -------------------------------------------------------------------------------- /lib/components/config/ConfigItem.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:mobile_nebula/services/utils.dart'; 6 | 7 | class ConfigItem extends StatelessWidget { 8 | const ConfigItem({ 9 | super.key, 10 | this.label, 11 | required this.content, 12 | this.labelWidth = 100, 13 | this.crossAxisAlignment = CrossAxisAlignment.center, 14 | }); 15 | 16 | final Widget? label; 17 | final Widget content; 18 | final double labelWidth; 19 | final CrossAxisAlignment crossAxisAlignment; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | TextStyle textStyle; 24 | if (Platform.isAndroid) { 25 | textStyle = Theme.of(context).textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal); 26 | } else { 27 | textStyle = CupertinoTheme.of(context).textTheme.textStyle; 28 | } 29 | 30 | return Container( 31 | color: Utils.configItemBackground(context), 32 | padding: EdgeInsets.symmetric(vertical: 6, horizontal: 15), 33 | constraints: BoxConstraints(minHeight: Utils.minInteractiveSize), 34 | child: Row( 35 | crossAxisAlignment: crossAxisAlignment, 36 | children: [ 37 | SizedBox(width: labelWidth, child: DefaultTextStyle(style: textStyle, child: Container(child: label))), 38 | Expanded(child: DefaultTextStyle(style: textStyle, child: Container(child: content))), 39 | ], 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/services/result.dart: -------------------------------------------------------------------------------- 1 | /// Utility class that simplifies handling errors. 2 | /// 3 | /// Return a [Result] from a function to indicate success or failure. 4 | /// 5 | /// A [Result] is either an [Ok] with a value of type [T] 6 | /// or an [Error] with an [Exception]. 7 | /// 8 | /// Use [Result.ok] to create a successful result with a value of type [T]. 9 | /// Use [Result.error] to create an error result with an [Exception]. 10 | /// 11 | /// Evaluate the result using a switch statement: 12 | /// ```dart 13 | /// switch (result) { 14 | /// case Ok(): { 15 | /// print(result.value); 16 | /// } 17 | /// case Error(): { 18 | /// print(result.error); 19 | /// } 20 | /// } 21 | /// ``` 22 | sealed class Result { 23 | const Result(); 24 | 25 | /// Creates a successful [Result], completed with the specified [value]. 26 | const factory Result.ok(T value) = Ok._; 27 | 28 | /// Creates an error [Result], completed with the specified [error]. 29 | const factory Result.error(Exception error) = Error._; 30 | } 31 | 32 | /// A successful [Result] with a returned [value]. 33 | final class Ok extends Result { 34 | const Ok._(this.value); 35 | 36 | /// The returned value of this result. 37 | final T value; 38 | 39 | @override 40 | String toString() => 'Result<$T>.ok($value)'; 41 | } 42 | 43 | /// An error [Result] with a resulting [error]. 44 | final class Error extends Result { 45 | const Error._(this.error); 46 | 47 | /// The resulting error of this result. 48 | final Exception error; 49 | 50 | @override 51 | String toString() => 'Result<$T>.error($error)'; 52 | } 53 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/net/defined/mobile_nebula/APIClient.kt: -------------------------------------------------------------------------------- 1 | package net.defined.mobile_nebula 2 | 3 | import android.content.Context 4 | import com.google.gson.Gson 5 | 6 | class InvalidCredentialsException: Exception("Invalid credentials") 7 | 8 | class APIClient(context: Context) { 9 | private val packageInfo = PackageInfo(context) 10 | private val client = mobileNebula.MobileNebula.newAPIClient( 11 | "MobileNebula/%s (Android %s)".format( 12 | packageInfo.getVersion(), 13 | packageInfo.getSystemVersion(), 14 | )) 15 | private val gson = Gson() 16 | 17 | fun enroll(code: String): IncomingSite { 18 | val res = client.enroll(code) 19 | return decodeIncomingSite(res.site) 20 | } 21 | 22 | fun tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Long, trustedKeys: String): IncomingSite? { 23 | val res: mobileNebula.TryUpdateResult 24 | try { 25 | res = client.tryUpdate(siteName, hostID, privateKey, counter, trustedKeys) 26 | } catch (e: Exception) { 27 | // type information from Go is not available, use string matching instead 28 | if (e.message == "invalid credentials") { 29 | throw InvalidCredentialsException() 30 | } 31 | 32 | throw e 33 | } 34 | 35 | if (res.fetchedUpdate) { 36 | return decodeIncomingSite(res.site) 37 | } 38 | 39 | return null 40 | } 41 | 42 | private fun decodeIncomingSite(jsonSite: String): IncomingSite { 43 | return gson.fromJson(jsonSite, IncomingSite::class.java) 44 | } 45 | } -------------------------------------------------------------------------------- /gen-artifacts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | . ./env.sh 6 | 7 | # Generate gomobile nebula bindings 8 | cd nebula 9 | 10 | if [ "$1" = "ios" ]; then 11 | # Build for nebula for iOS 12 | make MobileNebula.xcframework 13 | rm -rf ../ios/MobileNebula.xcframework 14 | cp -r MobileNebula.xcframework ../ios/ 15 | 16 | elif [ "$1" = "android" ]; then 17 | # Build nebula for android 18 | make mobileNebula.aar 19 | mkdir -p ../android/mobileNebula 20 | rm -rf ../android/mobileNebula/mobileNebula.aar 21 | cp mobileNebula.aar ../android/mobileNebula/mobileNebula.aar 22 | 23 | else 24 | echo "Error: unsupported target os $1" 25 | exit 1 26 | fi 27 | 28 | cd .. 29 | 30 | # Generate version info to display in about 31 | { 32 | # Get the flutter and dart versions 33 | printf "const flutterVersion = " 34 | flutter --version --machine 35 | echo ";" 36 | 37 | # Get our current git sha 38 | git rev-parse --short HEAD | sed -e "s/\(.*\)/const gitSha = '\1';/" 39 | 40 | # Get the nebula version 41 | cd nebula 42 | NEBULA_VERSION="$(go list -m -f "{{.Version}}" github.com/slackhq/nebula | cut -f1 -d'-' | cut -c2-)" 43 | echo "const nebulaVersion = '$NEBULA_VERSION';" 44 | cd .. 45 | 46 | # Get our golang version 47 | echo "const goVersion = '$(go version | awk '{print $3}')';" 48 | } > lib/.gen.versions.dart 49 | 50 | # Try and avoid issues with building by moving into place after we are complete 51 | #TODO: this might be a parallel build of deps issue in kotlin, might need to solve there 52 | mv lib/.gen.versions.dart lib/gen.versions.dart 53 | 54 | # Generate licenses library 55 | flutter pub run flutter_oss_licenses:generate.dart 56 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/tools/linter-rules. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/tools/analysis 29 | formatter: 30 | page_width: 120 31 | 32 | analyzer: 33 | exclude: 34 | # This is a generated file, let's ignore it. 35 | - lib/services/theme.dart 36 | -------------------------------------------------------------------------------- /android/fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:android) 17 | 18 | platform :android do 19 | lane :release_build_number do 20 | nextCode = sprintf("%s", latest_googleplay_version_code + 1) 21 | File.write("../../release_build_number", nextCode) 22 | end 23 | 24 | desc "Deploy a new version to the Google Play" 25 | lane :release do 26 | upload_to_play_store( 27 | track: 'internal', 28 | aab: '../build/app/outputs/bundle/release/app-release.aab' 29 | ) 30 | end 31 | end 32 | 33 | def latest_googleplay_version_code 34 | productionVersionCodes = google_play_track_version_codes(track: 'production') 35 | #NOTE: we do not have a beta track right now 36 | #betaVersionCodes = google_play_track_version_codes(track: 'beta') 37 | alphaVersionCodes = google_play_track_version_codes(track: 'alpha') 38 | internalVersionCodes = google_play_track_version_codes(track: 'internal') 39 | 40 | # puts version codes from all tracks into the same array 41 | versionCodes = [ 42 | productionVersionCodes, 43 | #betaVersionCodes, 44 | alphaVersionCodes, 45 | internalVersionCodes 46 | ].reduce([], :concat) 47 | 48 | # returns the highest version code from array 49 | return versionCodes.max 50 | end -------------------------------------------------------------------------------- /lib/components/config/ConfigCheckboxItem.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:mobile_nebula/components/SpecialButton.dart'; 3 | import 'package:mobile_nebula/services/utils.dart'; 4 | 5 | class ConfigCheckboxItem extends StatelessWidget { 6 | const ConfigCheckboxItem({ 7 | super.key, 8 | this.label, 9 | this.content, 10 | this.labelWidth = 100, 11 | this.onChanged, 12 | this.checked = false, 13 | }); 14 | 15 | final Widget? label; 16 | final Widget? content; 17 | final double labelWidth; 18 | final bool checked; 19 | final Function? onChanged; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | Widget item = Container( 24 | padding: EdgeInsets.symmetric(horizontal: 15), 25 | constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), 26 | child: Row( 27 | crossAxisAlignment: CrossAxisAlignment.center, 28 | children: [ 29 | label != null ? SizedBox(width: labelWidth, child: label) : Container(), 30 | Expanded(child: Container(padding: EdgeInsets.only(right: 10), child: content)), 31 | checked 32 | ? Icon(CupertinoIcons.check_mark, color: CupertinoColors.systemBlue.resolveFrom(context)) 33 | : Container(), 34 | ], 35 | ), 36 | ); 37 | 38 | if (onChanged != null) { 39 | return SpecialButton( 40 | color: Utils.configItemBackground(context), 41 | child: item, 42 | onPressed: () { 43 | if (onChanged != null) { 44 | onChanged!(); 45 | } 46 | }, 47 | ); 48 | } else { 49 | return item; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ios/Runner/APIClient.swift: -------------------------------------------------------------------------------- 1 | import MobileNebula 2 | 3 | enum APIClientError: Error { 4 | case invalidCredentials 5 | } 6 | 7 | class APIClient { 8 | let apiClient: MobileNebulaAPIClient 9 | let json = JSONDecoder() 10 | 11 | init() { 12 | let packageInfo = PackageInfo() 13 | apiClient = MobileNebulaNewAPIClient( 14 | "MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")! 15 | } 16 | 17 | func enroll(code: String) throws -> IncomingSite { 18 | let res = try apiClient.enroll(code) 19 | return try decodeIncomingSite(jsonSite: res.site) 20 | } 21 | 22 | func tryUpdate( 23 | siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String 24 | ) throws -> IncomingSite? { 25 | let res: MobileNebulaTryUpdateResult 26 | do { 27 | res = try apiClient.tryUpdate( 28 | siteName, 29 | hostID: hostID, 30 | privateKey: privateKey, 31 | counter: counter, 32 | trustedKeys: trustedKeys) 33 | } catch { 34 | // type information from Go is not available, use string matching instead 35 | if error.localizedDescription == "invalid credentials" { 36 | throw APIClientError.invalidCredentials 37 | } 38 | 39 | throw error 40 | } 41 | 42 | if res.fetchedUpdate { 43 | return try decodeIncomingSite(jsonSite: res.site) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite { 50 | do { 51 | return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!) 52 | } catch { 53 | print("decodeIncomingSite: \(error)") 54 | throw error 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/components/config/ConfigSection.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:mobile_nebula/services/utils.dart'; 3 | 4 | import 'ConfigHeader.dart'; 5 | 6 | class ConfigSection extends StatelessWidget { 7 | const ConfigSection({super.key, this.label, required this.children, this.borderColor, this.labelColor}); 8 | 9 | final List children; 10 | final String? label; 11 | final Color? borderColor; 12 | final Color? labelColor; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final border = BorderSide(color: borderColor ?? Utils.configSectionBorder(context)); 17 | 18 | List mappedChildren = []; 19 | final len = children.length; 20 | 21 | for (var i = 0; i < len; i++) { 22 | mappedChildren.add(children[i]); 23 | 24 | if (i < len - 1) { 25 | double pad = 15; 26 | if (children[i + 1].runtimeType.toString() == 'ConfigButtonItem') { 27 | pad = 0; 28 | } 29 | mappedChildren.add( 30 | Padding( 31 | padding: EdgeInsets.only(left: pad), 32 | child: Divider(height: 1, color: Utils.configSectionBorder(context)), 33 | ), 34 | ); 35 | } 36 | } 37 | 38 | return Column( 39 | crossAxisAlignment: CrossAxisAlignment.start, 40 | children: [ 41 | label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20), 42 | Container( 43 | decoration: BoxDecoration( 44 | border: Border(top: border, bottom: border), 45 | color: Utils.configItemBackground(context), 46 | ), 47 | child: Column(children: mappedChildren), 48 | ), 49 | ], 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /nebula/mobileNebula_test.go: -------------------------------------------------------------------------------- 1 | package mobileNebula 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | nebcfg "github.com/slackhq/nebula/config" 8 | ) 9 | 10 | func TestParseCerts(t *testing.T) { 11 | jsonConfig := `{ 12 | "name": "Debug Test - unsafe", 13 | "id": "be9d6756-4099-4b25-a901-9d3b773e7d1a", 14 | "staticHostmap": { 15 | "10.1.0.1": { 16 | "lighthouse": true, 17 | "destinations": [ 18 | "10.1.1.53:4242" 19 | ] 20 | } 21 | }, 22 | "unsafeRoutes": [ 23 | { 24 | "route": "10.3.3.3/32", 25 | "via": "10.1.0.1", 26 | "mtu": null 27 | }, 28 | { 29 | "route": "1.1.1.2/32", 30 | "via": "10.1.0.1", 31 | "mtu": null 32 | } 33 | ], 34 | "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", 35 | "cert": "-----BEGIN NEBULA CERTIFICATE-----\nCmcKCmNocm9tZWJvb2sSCYmAhFCA/v//DyiR1Zf2BTCHuqGEBjogqtoJL9WKGKLp\nb3BIgTEZnTTusSJOiswuf1DS7jPjMzFKIIstsyPnnccgEYkNflwrYBvZFMCOtgmN\nuc5Jpc5lbzM9EkBACYP3VMFYHk2h5AcpURcG6QwS4iYOgHET7lMbM7WSMj4ZnzLR\ni2HhX58vSTr6evgvKuSPaA23hLUqR65QNRQD\n-----END NEBULA CERTIFICATE-----\n", 36 | "key": null, 37 | "lhDuration": 7200, 38 | "port": 4242, 39 | "mtu": 1300, 40 | "cipher": "aes", 41 | "sortKey": 3, 42 | "logVerbosity": "info" 43 | }` 44 | s, err := RenderConfig(jsonConfig, "") 45 | 46 | config := nebcfg.NewC(logrus.New()) 47 | err = config.LoadString(s) 48 | 49 | t.Log(err) 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/services/storage.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:path_provider/path_provider.dart'; 5 | import 'package:path/path.dart' as p; 6 | 7 | class Storage { 8 | Future mkdir(String path) async { 9 | final parent = await localPath; 10 | return Directory(p.join(parent, path)).create(recursive: true); 11 | } 12 | 13 | Future> listDir(String path) async { 14 | List list = []; 15 | var parent = await localPath; 16 | 17 | if (path != '') { 18 | parent = p.join(parent, path); 19 | } 20 | 21 | var completer = Completer>(); 22 | 23 | Directory(parent) 24 | .list() 25 | .listen((FileSystemEntity entity) { 26 | list.add(entity); 27 | }) 28 | .onDone(() { 29 | completer.complete(list); 30 | }); 31 | 32 | return completer.future; 33 | } 34 | 35 | Future get localPath async { 36 | final directory = await getApplicationDocumentsDirectory(); 37 | return directory.path; 38 | } 39 | 40 | Future readFile(String path) async { 41 | try { 42 | final parent = await localPath; 43 | final file = File(p.join(parent, path)); 44 | 45 | // Read the file 46 | return await file.readAsString(); 47 | } catch (e) { 48 | return null; 49 | } 50 | } 51 | 52 | Future writeFile(String path, String contents) async { 53 | final parent = await localPath; 54 | final file = File(p.join(parent, path)); 55 | 56 | // Write the file 57 | return file.writeAsString(contents); 58 | } 59 | 60 | Future delete(String path) async { 61 | var parent = await localPath; 62 | return File(p.join(parent, path)).delete(recursive: true); 63 | } 64 | 65 | Future getFullPath(String path) async { 66 | var parent = await localPath; 67 | return p.join(parent, path); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/screens/siteConfig/LogVerbosityScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:mobile_nebula/components/FormPage.dart'; 5 | import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart'; 6 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 7 | 8 | class LogVerbosityScreen extends StatefulWidget { 9 | const LogVerbosityScreen({super.key, required this.verbosity, required this.onSave}); 10 | 11 | final String verbosity; 12 | final ValueChanged onSave; 13 | 14 | @override 15 | _LogVerbosityScreenState createState() => _LogVerbosityScreenState(); 16 | } 17 | 18 | class _LogVerbosityScreenState extends State { 19 | late String verbosity; 20 | bool changed = false; 21 | 22 | @override 23 | void initState() { 24 | verbosity = widget.verbosity; 25 | super.initState(); 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return FormPage( 31 | title: 'Log Verbosity', 32 | changed: changed, 33 | onSave: () { 34 | Navigator.pop(context); 35 | widget.onSave(verbosity); 36 | }, 37 | child: Column( 38 | children: [ 39 | ConfigSection( 40 | children: [ 41 | _buildEntry('debug'), 42 | _buildEntry('info'), 43 | _buildEntry('warning'), 44 | _buildEntry('error'), 45 | _buildEntry('fatal'), 46 | _buildEntry('panic'), 47 | ], 48 | ), 49 | ], 50 | ), 51 | ); 52 | } 53 | 54 | Widget _buildEntry(String title) { 55 | return ConfigCheckboxItem( 56 | label: Text(title), 57 | labelWidth: 150, 58 | checked: verbosity == title, 59 | onChanged: () { 60 | setState(() { 61 | changed = true; 62 | verbosity = title; 63 | }); 64 | }, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '14' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | Pod::PICKER_MEDIA = false 31 | Pod::PICKER_AUDIO = false 32 | 33 | target 'Runner' do 34 | use_frameworks! 35 | use_modular_headers! 36 | 37 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 38 | pod 'SwiftyJSON', '~> 5.0' 39 | end 40 | 41 | target 'NebulaNetworkExtension' do 42 | use_frameworks! 43 | pod 'SwiftyJSON', '~> 5.0' 44 | end 45 | 46 | post_install do |installer| 47 | installer.generated_projects.each do |project| 48 | project.targets.each do |target| 49 | target.build_configurations.each do |config| 50 | if Gem::Version.new('12.0') > Gem::Version.new(config.build_settings['IPHONEOS_DEPLOYMENT_TARGET']) 51 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' 52 | end 53 | end 54 | end 55 | end 56 | 57 | installer.pods_project.targets.each do |target| 58 | flutter_additional_ios_build_settings(target) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /swift-format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the Swift.org open source project 5 | ## 6 | ## Copyright (c) 2024 Apple Inc. and the Swift project authors 7 | ## Licensed under Apache License v2.0 with Runtime Library Exception 8 | ## 9 | ## See https://swift.org/LICENSE.txt for license information 10 | ## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 11 | ## 12 | ##===----------------------------------------------------------------------===## 13 | 14 | # Vendored from while is open. 15 | 16 | # This file has been modified to only check formatting, with no linting, and to require a `check` command flag to fail when formatting was performed. 17 | 18 | set -euo pipefail 19 | 20 | log() { printf -- "** %s\n" "$*" >&2; } 21 | error() { printf -- "** ERROR: %s\n" "$*" >&2; } 22 | fatal() { error "$@"; exit 1; } 23 | 24 | 25 | if [[ -f .swiftformatignore ]]; then 26 | log "Found swiftformatignore file..." 27 | 28 | log "Running swift format format..." 29 | tr '\n' '\0' < .swiftformatignore| xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files -z '*.swift' | xargs -0 xcrun swift-format --parallel --recursive --in-place 30 | 31 | # log "Running swift format lint..." 32 | 33 | # tr '\n' '\0' < .swiftformatignore | xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel 34 | else 35 | log "Running swift format format..." 36 | git ls-files -z '*.swift' | xargs -0 xcrun swift-format --parallel --recursive --in-place 37 | 38 | # log "Running swift format lint..." 39 | 40 | # git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel 41 | fi 42 | 43 | 44 | if [ "${1-default}" = "check" ]; then 45 | log "Checking for modified files..." 46 | 47 | GIT_PAGER='' git diff --exit-code '*.swift' 48 | 49 | log "✅ Found no formatting issues." 50 | fi -------------------------------------------------------------------------------- /lib/screens/siteConfig/CipherScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:mobile_nebula/components/FormPage.dart'; 5 | import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart'; 6 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 7 | 8 | class CipherScreen extends StatefulWidget { 9 | const CipherScreen({super.key, required this.cipher, required this.onSave}); 10 | 11 | final String cipher; 12 | final ValueChanged onSave; 13 | 14 | @override 15 | _CipherScreenState createState() => _CipherScreenState(); 16 | } 17 | 18 | class _CipherScreenState extends State { 19 | late String cipher; 20 | bool changed = false; 21 | 22 | @override 23 | void initState() { 24 | cipher = widget.cipher; 25 | super.initState(); 26 | } 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return FormPage( 31 | title: 'Cipher Selection', 32 | changed: changed, 33 | onSave: () { 34 | Navigator.pop(context); 35 | widget.onSave(cipher); 36 | }, 37 | child: Column( 38 | children: [ 39 | ConfigSection( 40 | children: [ 41 | ConfigCheckboxItem( 42 | label: Text("aes"), 43 | labelWidth: 150, 44 | checked: cipher == "aes", 45 | onChanged: () { 46 | setState(() { 47 | changed = true; 48 | cipher = "aes"; 49 | }); 50 | }, 51 | ), 52 | ConfigCheckboxItem( 53 | label: Text("chachapoly"), 54 | labelWidth: 150, 55 | checked: cipher == "chachapoly", 56 | onChanged: () { 57 | setState(() { 58 | changed = true; 59 | cipher = "chachapoly"; 60 | }); 61 | }, 62 | ), 63 | ], 64 | ), 65 | ], 66 | ), 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ios/NebulaNetworkExtension/Keychain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let groupName = "group.net.defined.mobileNebula" 4 | 5 | class KeyChain { 6 | class func save(key: String, data: Data, managed: Bool) -> Bool { 7 | var query: [String: Any] = [ 8 | kSecClass as String: kSecClassGenericPassword as String, 9 | kSecAttrAccount as String: key, 10 | kSecValueData as String: data, 11 | kSecAttrAccessGroup as String: groupName, 12 | ] 13 | 14 | if managed { 15 | query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock 16 | } 17 | 18 | // Attempt to delete an existing key to allow for an overwrite 19 | _ = self.delete(key: key) 20 | return SecItemAdd(query as CFDictionary, nil) == 0 21 | } 22 | 23 | class func load(key: String) -> Data? { 24 | let query: [String: Any] = [ 25 | kSecClass as String: kSecClassGenericPassword, 26 | kSecAttrAccount as String: key, 27 | kSecReturnData as String: kCFBooleanTrue!, 28 | kSecMatchLimit as String: kSecMatchLimitOne, 29 | kSecAttrAccessGroup as String: groupName, 30 | ] 31 | 32 | var dataTypeRef: AnyObject? = nil 33 | 34 | let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) 35 | 36 | if status == noErr { 37 | return dataTypeRef as! Data? 38 | } else { 39 | return nil 40 | } 41 | } 42 | 43 | class func delete(key: String) -> Bool { 44 | let query: [String: Any] = [ 45 | kSecClass as String: kSecClassGenericPassword as String, 46 | kSecAttrAccount as String: key, 47 | kSecAttrAccessGroup as String: groupName, 48 | ] 49 | 50 | return SecItemDelete(query as CFDictionary) == 0 51 | } 52 | } 53 | 54 | extension Data { 55 | 56 | init(from value: T) { 57 | var value = value 58 | var data = Data() 59 | withUnsafePointer( 60 | to: &value, 61 | { (ptr: UnsafePointer) -> Void in 62 | data = Data(buffer: UnsafeBufferPointer(start: ptr, count: 1)) 63 | }) 64 | self.init(data) 65 | } 66 | 67 | func to(type: T.Type) -> T { 68 | return self.withUnsafeBytes { $0.load(as: T.self) } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/net/defined/mobile_nebula/EncFile.kt: -------------------------------------------------------------------------------- 1 | package net.defined.mobile_nebula 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.security.crypto.EncryptedFile 6 | import androidx.security.crypto.MasterKeys 7 | import java.io.* 8 | import java.security.KeyStore 9 | 10 | class EncFile(private val context: Context) { 11 | companion object { 12 | // Borrowed from androidx.security.crypto.MasterKeys 13 | private const val ANDROID_KEYSTORE = "AndroidKeyStore" 14 | 15 | // Borrowed from androidx.security.crypto.EncryptedFile 16 | private const val KEYSET_PREF_NAME = "__androidx_security_crypto_encrypted_file_pref__" 17 | } 18 | 19 | private val scheme = EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB 20 | private val spec = MasterKeys.AES256_GCM_SPEC 21 | private var master: String = MasterKeys.getOrCreate(spec) 22 | 23 | fun openRead(file: File): BufferedReader { 24 | // We may fail to decrypt the file, in which case we'll raise an exception. 25 | // Callers should handle this exception by deleting the invalid file. 26 | return build(file).openFileInput().bufferedReader() 27 | } 28 | 29 | fun openWrite(file: File): BufferedWriter { 30 | return try { 31 | build(file).openFileOutput().bufferedWriter() 32 | } catch (e: Exception) { 33 | // If we fail to open the file, it's likely because the master key no longer works. 34 | // We'll try to reset the master key and try again. 35 | resetMasterKey() 36 | 37 | build(file).openFileOutput().bufferedWriter() 38 | } 39 | } 40 | 41 | private fun build(file: File): EncryptedFile { 42 | return EncryptedFile.Builder(file, context, master, scheme).build() 43 | } 44 | 45 | fun resetMasterKey() { 46 | // Reset the master key 47 | KeyStore.getInstance(ANDROID_KEYSTORE).apply { 48 | load(null) 49 | deleteEntry(master) 50 | } 51 | // And reset the shared preference containing the file encryption key 52 | context.deleteSharedPreferences(KEYSET_PREF_NAME) 53 | 54 | // Re-create the master key now so future calls don't fail 55 | master = MasterKeys.getOrCreate(spec) 56 | } 57 | } -------------------------------------------------------------------------------- /lib/screens/LicensesScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 4 | import 'package:mobile_nebula/components/SimplePage.dart'; 5 | import 'package:mobile_nebula/services/utils.dart'; 6 | import '../../oss_licenses.dart'; 7 | 8 | String capitalize(String input) { 9 | return input[0].toUpperCase() + input.substring(1); 10 | } 11 | 12 | class LicensesScreen extends StatelessWidget { 13 | const LicensesScreen({super.key}); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return SimplePage( 18 | title: const Text("Licences"), 19 | scrollable: SimpleScrollable.none, 20 | child: ListView.builder( 21 | itemCount: allDependencies.length, 22 | itemBuilder: (_, index) { 23 | var dep = allDependencies[index]; 24 | return Padding( 25 | padding: const EdgeInsets.all(8), 26 | child: PlatformListTile( 27 | onTap: () { 28 | Utils.openPage(context, (_) => LicenceDetailPage(title: capitalize(dep.name), licence: dep.license!)); 29 | }, 30 | title: Text(capitalize(dep.name)), 31 | subtitle: Text(dep.description), 32 | trailing: Icon(context.platformIcons.forward, size: 18), 33 | ), 34 | ); 35 | }, 36 | ), 37 | ); 38 | } 39 | } 40 | 41 | //detail page for the licence 42 | class LicenceDetailPage extends StatelessWidget { 43 | final String title, licence; 44 | const LicenceDetailPage({super.key, required this.title, required this.licence}); 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | return SimplePage( 49 | title: Text(title), 50 | scrollable: SimpleScrollable.none, 51 | child: Padding( 52 | padding: const EdgeInsets.all(8.0), 53 | child: Container( 54 | padding: const EdgeInsets.all(5), 55 | decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), 56 | child: SingleChildScrollView( 57 | physics: const BouncingScrollPhysics(), 58 | child: Column(children: [Text(licence, style: const TextStyle(fontSize: 15))]), 59 | ), 60 | ), 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | Nebula 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleSignature 22 | ???? 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleTypeRole 27 | Viewer 28 | CFBundleURLName 29 | mailto 30 | CFBundleURLSchemes 31 | 32 | mailto 33 | 34 | 35 | 36 | CFBundleVersion 37 | $(CURRENT_PROJECT_VERSION) 38 | ITSAppUsesNonExemptEncryption 39 | 40 | LSRequiresIPhoneOS 41 | 42 | NSCameraUsageDescription 43 | Camera permission is required for qr code scanning. 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Main 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | UIViewControllerBasedStatusBarAppearance 62 | 63 | UIApplicationSupportsIndirectInputEvents 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /lib/services/share.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:flutter/widgets.dart'; 5 | import 'package:path_provider/path_provider.dart'; 6 | import 'package:share_plus/share_plus.dart' as sp; 7 | import 'package:path/path.dart' as p; 8 | 9 | class Share { 10 | /// Transforms a string of text into a file and shares that file 11 | /// - title: Title of message or subject if sending an email 12 | /// - text: The text to share 13 | /// - filename: The filename to use if sending over airdrop for example 14 | static Future share( 15 | BuildContext context, { 16 | required String title, 17 | required String text, 18 | required String filename, 19 | }) async { 20 | assert(title.isNotEmpty); 21 | assert(text.isNotEmpty); 22 | assert(filename.isNotEmpty); 23 | 24 | final tmpDir = await getTemporaryDirectory(); 25 | final file = File(p.join(tmpDir.path, filename)); 26 | var res = false; 27 | 28 | try { 29 | file.writeAsStringSync(text, flush: true); 30 | res = await Share.shareFile(context, title: title, filePath: file.path); 31 | } catch (err) { 32 | // Ignoring file write errors 33 | } 34 | 35 | file.delete(); 36 | return res; 37 | } 38 | 39 | /// Shares a local file 40 | /// - title: Title of message or subject if sending an email 41 | /// - filePath: Path to the file to share 42 | /// - filename: An optional filename to override the existing file 43 | static Future shareFile( 44 | BuildContext context, { 45 | required String title, 46 | required String filePath, 47 | String? filename, 48 | }) async { 49 | assert(title.isNotEmpty); 50 | assert(filePath.isNotEmpty); 51 | 52 | final box = context.findRenderObject() as RenderBox?; 53 | 54 | //NOTE: the filename used to specify the name of the file in gmail/slack/etc but no longer works that way 55 | // If we want to support that again we will need to save the file to a temporary directory, share that, 56 | // and then delete it 57 | final xFile = sp.XFile(filePath, name: filename); 58 | final result = await sp.Share.shareXFiles( 59 | [xFile], 60 | subject: title, 61 | sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, 62 | ); 63 | return result.status == sp.ShareResultStatus.success; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/components/SiteItem.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_svg/svg.dart'; 4 | import 'package:mobile_nebula/components/SpecialButton.dart'; 5 | import 'package:mobile_nebula/models/Site.dart'; 6 | import 'package:mobile_nebula/services/utils.dart'; 7 | 8 | class SiteItem extends StatelessWidget { 9 | const SiteItem({super.key, required this.site, this.onPressed}); 10 | 11 | final Site site; 12 | final void Function()? onPressed; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final borderColor = 17 | site.errors.isNotEmpty 18 | ? CupertinoColors.systemRed.resolveFrom(context) 19 | : site.connected 20 | ? CupertinoColors.systemGreen.resolveFrom(context) 21 | : CupertinoColors.systemGrey2.resolveFrom(context); 22 | final border = BorderSide(color: borderColor, width: 10); 23 | 24 | return Container( 25 | margin: EdgeInsets.symmetric(vertical: 6), 26 | decoration: BoxDecoration(border: Border(left: border)), 27 | child: _buildContent(context), 28 | ); 29 | } 30 | 31 | Widget _buildContent(BuildContext context) { 32 | final border = BorderSide(color: Utils.configSectionBorder(context)); 33 | final dnIcon = 34 | Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg'; 35 | 36 | return SpecialButton( 37 | decoration: BoxDecoration( 38 | border: Border(top: border, bottom: border), 39 | color: Utils.configItemBackground(context), 40 | ), 41 | onPressed: onPressed, 42 | child: Padding( 43 | padding: EdgeInsets.fromLTRB(10, 10, 5, 10), 44 | child: Row( 45 | crossAxisAlignment: CrossAxisAlignment.center, 46 | children: [ 47 | site.managed 48 | ? Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) 49 | : Container(), 50 | Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold))), 51 | Padding(padding: EdgeInsets.only(right: 10)), 52 | Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18), 53 | ], 54 | ), 55 | ), 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/components/config/ConfigPageItem.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:mobile_nebula/components/SpecialButton.dart'; 6 | import 'package:mobile_nebula/services/utils.dart'; 7 | 8 | class ConfigPageItem extends StatelessWidget { 9 | const ConfigPageItem({ 10 | super.key, 11 | this.label, 12 | this.content, 13 | this.labelWidth = 100, 14 | this.onPressed, 15 | this.disabled = false, 16 | this.crossAxisAlignment = CrossAxisAlignment.center, 17 | }); 18 | 19 | final Widget? label; 20 | final Widget? content; 21 | final double labelWidth; 22 | final CrossAxisAlignment crossAxisAlignment; 23 | final void Function()? onPressed; 24 | final bool disabled; 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | dynamic theme; 29 | 30 | if (Platform.isAndroid) { 31 | final origTheme = Theme.of(context); 32 | theme = origTheme.copyWith( 33 | textTheme: origTheme.textTheme.copyWith( 34 | labelLarge: origTheme.textTheme.labelLarge!.copyWith(fontWeight: FontWeight.normal), 35 | ), 36 | ); 37 | return Theme(data: theme, child: _buildContent(context)); 38 | } else { 39 | final origTheme = CupertinoTheme.of(context); 40 | theme = origTheme.copyWith(primaryColor: CupertinoColors.label.resolveFrom(context)); 41 | return CupertinoTheme(data: theme, child: _buildContent(context)); 42 | } 43 | } 44 | 45 | Widget _buildContent(BuildContext context) { 46 | return SpecialButton( 47 | onPressed: disabled ? null : onPressed, 48 | color: Utils.configItemBackground(context), 49 | child: Container( 50 | padding: EdgeInsets.symmetric(vertical: 6, horizontal: 15), 51 | constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity), 52 | child: Row( 53 | crossAxisAlignment: crossAxisAlignment, 54 | children: [ 55 | label != null ? SizedBox(width: labelWidth, child: label) : Container(), 56 | Expanded(child: Container(padding: EdgeInsets.only(right: 10), child: content)), 57 | disabled 58 | ? Container() 59 | : Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18), 60 | ], 61 | ), 62 | ), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/models/HostInfo.dart: -------------------------------------------------------------------------------- 1 | import 'package:mobile_nebula/models/Certificate.dart'; 2 | 3 | class HostInfo { 4 | List vpnAddrs; 5 | int localIndex; 6 | int remoteIndex; 7 | List remoteAddresses; 8 | Certificate? cert; 9 | UDPAddress? currentRemote; 10 | int messageCounter; 11 | 12 | HostInfo({ 13 | required this.vpnAddrs, 14 | required this.localIndex, 15 | required this.remoteIndex, 16 | required this.remoteAddresses, 17 | required this.messageCounter, 18 | this.cert, 19 | this.currentRemote, 20 | }); 21 | 22 | factory HostInfo.fromJson(Map json) { 23 | UDPAddress? currentRemote; 24 | if (json['currentRemote'] != "") { 25 | currentRemote = UDPAddress.fromJson(json['currentRemote']); 26 | } 27 | 28 | Certificate? cert; 29 | if (json['cert'] != null) { 30 | cert = Certificate.fromJson(json['cert']); 31 | } 32 | 33 | List? addrs = json['remoteAddrs']; 34 | List remoteAddresses = []; 35 | addrs?.forEach((val) { 36 | remoteAddresses.add(UDPAddress.fromJson(val)); 37 | }); 38 | 39 | addrs = json['vpnAddrs']; 40 | List vpnAddrs = []; 41 | addrs?.forEach((val) { 42 | if (val is String) { 43 | vpnAddrs.add(val); 44 | } 45 | }); 46 | 47 | return HostInfo( 48 | vpnAddrs: vpnAddrs, 49 | localIndex: json['localIndex'], 50 | remoteIndex: json['remoteIndex'], 51 | remoteAddresses: remoteAddresses, 52 | messageCounter: json['messageCounter'], 53 | cert: cert, 54 | currentRemote: currentRemote, 55 | ); 56 | } 57 | } 58 | 59 | class UDPAddress { 60 | String ip; 61 | int port; 62 | 63 | UDPAddress({required this.ip, required this.port}); 64 | 65 | @override 66 | String toString() { 67 | // Simple check on if nebula told us about a v4 or v6 ip address 68 | if (ip.contains(':')) { 69 | return '[$ip]:$port'; 70 | } 71 | 72 | return '$ip:$port'; 73 | } 74 | 75 | factory UDPAddress.fromJson(String json) { 76 | // IPv4 Address 77 | if (json.contains('.')) { 78 | var ip = json.split(':')[0]; 79 | var port = int.parse(json.split(':')[1]); 80 | return UDPAddress(ip: ip, port: port); 81 | } 82 | 83 | // IPv6 Address 84 | var ip = json.split(']')[0].substring(1); 85 | var port = int.parse(json.split(']')[1].split(':')[1]); 86 | return UDPAddress(ip: ip, port: port); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /nebula/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/DefinedNet/mobile_nebula/nebula 2 | 3 | go 1.25 4 | 5 | // replace github.com/slackhq/nebula => /Volumes/T7/nate/src/github.com/slackhq/nebula 6 | 7 | require ( 8 | github.com/DefinedNet/dnapi v0.0.0-20251210211559-8ae1e6743199 9 | github.com/sirupsen/logrus v1.9.3 10 | github.com/slackhq/nebula v1.10.1-0.20251210163936-3ec527e42cec 11 | golang.org/x/crypto v0.46.0 12 | gopkg.in/yaml.v2 v2.4.0 13 | ) 14 | 15 | require ( 16 | dario.cat/mergo v1.0.2 // indirect 17 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 18 | github.com/armon/go-radix v1.0.0 // indirect 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432 // indirect 22 | github.com/flynn/noise v1.1.0 // indirect 23 | github.com/gaissmai/bart v0.26.0 // indirect 24 | github.com/gogo/protobuf v1.3.2 // indirect 25 | github.com/google/gopacket v1.1.19 // indirect 26 | github.com/miekg/dns v1.1.68 // indirect 27 | github.com/miekg/pkcs11 v1.1.2-0.20231115102856-9078ad6b9d4b // indirect 28 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 29 | github.com/nbrownus/go-metrics-prometheus v0.0.0-20210712211119-974a6260965f // indirect 30 | github.com/prometheus/client_golang v1.23.2 // indirect 31 | github.com/prometheus/client_model v0.6.2 // indirect 32 | github.com/prometheus/common v0.67.2 // indirect 33 | github.com/prometheus/procfs v0.19.1 // indirect 34 | github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect 35 | github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect 36 | github.com/vishvananda/netlink v1.3.1 // indirect 37 | github.com/vishvananda/netns v0.0.5 // indirect 38 | go.yaml.in/yaml/v2 v2.4.3 // indirect 39 | go.yaml.in/yaml/v3 v3.0.4 // indirect 40 | golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect 41 | golang.org/x/mod v0.31.0 // indirect 42 | golang.org/x/net v0.48.0 // indirect 43 | golang.org/x/sync v0.19.0 // indirect 44 | golang.org/x/sys v0.39.0 // indirect 45 | golang.org/x/term v0.38.0 // indirect 46 | golang.org/x/tools v0.40.0 // indirect 47 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 48 | golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect 49 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 50 | google.golang.org/protobuf v1.36.10 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mobile Nebula 2 | 3 | [Play Store](https://play.google.com/store/apps/details?id=net.defined.mobile_nebula&hl=en_US&gl=US) | [App Store](https://apps.apple.com/us/app/mobile-nebula/id1509587936) 4 | 5 | ## Setting up dev environment 6 | 7 | Install all of the following things: 8 | 9 | - [`xcode`](https://apps.apple.com/us/app/xcode/) - use the version specified by `xcode_select` in `/ios/fastlane/Fastfile` 10 | - [`android-studio`](https://developer.android.com/studio) 11 | - [`flutter` 3.29.2](https://docs.flutter.dev/get-started/install) 12 | - [`gomobile`](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile) 13 | - [Flutter Android Studio Extension](https://docs.flutter.dev/get-started/editor?tab=androidstudio) 14 | 15 | Ensure your path is set up correctly to execute flutter 16 | 17 | Run `flutter doctor` and fix everything it complains before proceeding 18 | 19 | *NOTE* on iOS, always open `Runner.xcworkspace` and NOT the `Runner.xccodeproj` 20 | 21 | ### Before first compile 22 | 23 | - Copy `env.sh.example` and set it up for your machine 24 | - Ensure you have run `gomobile init` 25 | - In Android Studio, make sure you have the current ndk installed by going to Tools -> SDK Manager, go to the SDK Tools tab, check the `Show package details` box, expand the NDK section and select `28.2.13676358` version. 26 | - Ensure you have downloaded an ndk via android studio, this is likely not the default one and you need to check the 27 | `Show package details` box to select the correct version. The correct version comes from the error when you try and compile 28 | - Make sure you have `gem` installed with `sudo gem install` 29 | - If on MacOS arm, `sudo gem install ffi -- --enable-libffi-alloc` 30 | 31 | If you are having issues with iOS pods, try blowing it all away! `cd ios && rm -rf Pods/ Podfile.lock && pod install --repo-update` 32 | 33 | # Formatting 34 | 35 | `dart format` can be used to format the code in `lib` and `test`. We use a line-length of 120 characters. 36 | 37 | Use: 38 | ```sh 39 | dart format lib/ test/ -l 120 40 | ``` 41 | 42 | In Android Studio, set the line length using Preferences -> Editor -> Code Style -> Dart -> Line length, set it to 120. Enable auto-format with Preferences -> Languages & Frameworks -> Flutter -> Format code on save. 43 | 44 | `./swift-format.sh` can be used to format Swift code in the repo. 45 | 46 | Once `swift-format` supports ignoring directories (), we can move to a method of running it more like what describes. -------------------------------------------------------------------------------- /ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - file_picker (0.0.1): 3 | - Flutter 4 | - Flutter (1.0.0) 5 | - mobile_scanner (7.0.0): 6 | - Flutter 7 | - FlutterMacOS 8 | - package_info_plus (0.4.5): 9 | - Flutter 10 | - path_provider_foundation (0.0.1): 11 | - Flutter 12 | - FlutterMacOS 13 | - Sentry/HybridSDK (8.46.0) 14 | - sentry_flutter (8.14.2): 15 | - Flutter 16 | - FlutterMacOS 17 | - Sentry/HybridSDK (= 8.46.0) 18 | - share_plus (0.0.1): 19 | - Flutter 20 | - SwiftyJSON (5.0.2) 21 | - url_launcher_ios (0.0.1): 22 | - Flutter 23 | 24 | DEPENDENCIES: 25 | - file_picker (from `.symlinks/plugins/file_picker/ios`) 26 | - Flutter (from `Flutter`) 27 | - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) 28 | - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 29 | - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 30 | - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) 31 | - share_plus (from `.symlinks/plugins/share_plus/ios`) 32 | - SwiftyJSON (~> 5.0) 33 | - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 34 | 35 | SPEC REPOS: 36 | trunk: 37 | - Sentry 38 | - SwiftyJSON 39 | 40 | EXTERNAL SOURCES: 41 | file_picker: 42 | :path: ".symlinks/plugins/file_picker/ios" 43 | Flutter: 44 | :path: Flutter 45 | mobile_scanner: 46 | :path: ".symlinks/plugins/mobile_scanner/darwin" 47 | package_info_plus: 48 | :path: ".symlinks/plugins/package_info_plus/ios" 49 | path_provider_foundation: 50 | :path: ".symlinks/plugins/path_provider_foundation/darwin" 51 | sentry_flutter: 52 | :path: ".symlinks/plugins/sentry_flutter/ios" 53 | share_plus: 54 | :path: ".symlinks/plugins/share_plus/ios" 55 | url_launcher_ios: 56 | :path: ".symlinks/plugins/url_launcher_ios/ios" 57 | 58 | SPEC CHECKSUMS: 59 | file_picker: c79185e70b9b45728cde2a8d8da454e0cb43f287 60 | Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 61 | mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e 62 | package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 63 | path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 64 | Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 65 | sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b 66 | share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f 67 | SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a 68 | url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe 69 | 70 | PODFILE CHECKSUM: b44d9de9944d89118a4ff4bfffe1c2dab91de156 71 | 72 | COCOAPODS: 1.16.2 73 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | id "kotlin-android" 4 | id "dev.flutter.flutter-gradle-plugin" 5 | } 6 | 7 | def localProperties = new Properties() 8 | def localPropertiesFile = rootProject.file('local.properties') 9 | if (localPropertiesFile.exists()) { 10 | localPropertiesFile.withReader('UTF-8') { reader -> 11 | localProperties.load(reader) 12 | } 13 | } 14 | 15 | 16 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 17 | if (flutterVersionCode == null) { 18 | flutterVersionCode = '1' 19 | } 20 | 21 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 22 | if (flutterVersionName == null) { 23 | flutterVersionName = '1.0' 24 | } 25 | 26 | android { 27 | namespace "net.defined.mobile_nebula" 28 | 29 | compileSdkVersion 36 30 | 31 | // default ndk version for AGP 8.7: https://developer.android.com/build/releases/past-releases/agp-8-7-0-release-notes 32 | ndkVersion "28.2.13676358" 33 | 34 | sourceSets { 35 | main.java.srcDirs += 'src/main/kotlin' 36 | } 37 | 38 | defaultConfig { 39 | applicationId "net.defined.mobile_nebula" 40 | minSdkVersion 26 //flutter.minSdkVersion 41 | targetSdkVersion 36 //flutter.targetSdkVersion 42 | versionCode flutterVersionCode.toInteger() 43 | versionName flutterVersionName 44 | } 45 | 46 | signingConfigs { 47 | release { 48 | keyAlias 'key' 49 | storeFile System.getenv('GOOGLE_PLAY_KEYSTORE_PATH') ? file(System.getenv('GOOGLE_PLAY_KEYSTORE_PATH')) : null 50 | keyPassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD') 51 | storePassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD') 52 | } 53 | } 54 | 55 | buildTypes { 56 | release { 57 | signingConfig signingConfigs.release 58 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 59 | resValue 'string', 'app_name', '"Nebula"' 60 | } 61 | 62 | debug { 63 | resValue 'string', 'app_name', '"Nebula-DEBUG"' 64 | applicationIdSuffix '.debug' 65 | } 66 | } 67 | } 68 | 69 | java { 70 | toolchain { 71 | languageVersion = JavaLanguageVersion.of(17) 72 | } 73 | } 74 | 75 | flutter { 76 | source '../..' 77 | } 78 | 79 | dependencies { 80 | def workVersion = "2.9.1" 81 | implementation "androidx.security:security-crypto:1.0.0" 82 | implementation "androidx.work:work-runtime-ktx:$workVersion" 83 | implementation 'com.google.code.gson:gson:2.11.0' 84 | implementation "com.google.guava:guava:31.0.1-android" 85 | implementation project(':mobileNebula') 86 | 87 | } 88 | 89 | -------------------------------------------------------------------------------- /lib/services/settings.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/scheduler.dart'; 5 | import 'package:mobile_nebula/services/storage.dart'; 6 | import 'package:sentry_flutter/sentry_flutter.dart'; 7 | 8 | bool DEFAULT_LOG_WRAP = false; 9 | bool DEFAULT_TRACK_ERRORS = true; 10 | 11 | class Settings { 12 | final _storage = Storage(); 13 | final StreamController _change = StreamController.broadcast(); 14 | var _settings = {}; 15 | 16 | bool get useSystemColors { 17 | return _getBool('systemDarkMode', true); 18 | } 19 | 20 | set useSystemColors(bool enabled) { 21 | if (!enabled) { 22 | // Clear the dark mode to let the default system config take over, user can override from there 23 | _settings.remove('darkMode'); 24 | } 25 | _set('systemDarkMode', enabled); 26 | } 27 | 28 | bool get darkMode { 29 | return _getBool('darkMode', SchedulerBinding.instance.platformDispatcher.platformBrightness == Brightness.dark); 30 | } 31 | 32 | set darkMode(bool enabled) { 33 | _set('darkMode', enabled); 34 | } 35 | 36 | bool get logWrap { 37 | return _getBool('logWrap', DEFAULT_LOG_WRAP); 38 | } 39 | 40 | set logWrap(bool enabled) { 41 | _set('logWrap', enabled); 42 | } 43 | 44 | bool get trackErrors { 45 | return _getBool('trackErrors', DEFAULT_TRACK_ERRORS); 46 | } 47 | 48 | set trackErrors(bool enabled) { 49 | _set('trackErrors', enabled); 50 | 51 | // Side-effect: Disable Sentry immediately 52 | if (!enabled) { 53 | Sentry.close(); 54 | } 55 | } 56 | 57 | String _getString(String key, String defaultValue) { 58 | final val = _settings[key]; 59 | if (val is String) { 60 | return val; 61 | } 62 | return defaultValue; 63 | } 64 | 65 | bool _getBool(String key, bool defaultValue) { 66 | final val = _settings[key]; 67 | if (val is bool) { 68 | return val; 69 | } 70 | return defaultValue; 71 | } 72 | 73 | void _set(String key, dynamic value) { 74 | _settings[key] = value; 75 | _save(); 76 | } 77 | 78 | Stream onChange() { 79 | return _change.stream; 80 | } 81 | 82 | void _save() { 83 | final content = jsonEncode(_settings); 84 | //TODO: handle errors 85 | _storage.writeFile("config.json", content).then((_) { 86 | _change.add(null); 87 | }); 88 | } 89 | 90 | static final Settings _instance = Settings._internal(); 91 | 92 | factory Settings() { 93 | return _instance; 94 | } 95 | 96 | Settings._internal() { 97 | _storage.readFile("config.json").then((rawConfig) { 98 | if (rawConfig != null) { 99 | _settings = jsonDecode(rawConfig); 100 | } 101 | 102 | _change.add(null); 103 | }); 104 | } 105 | 106 | void dispose() { 107 | _change.close(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /ios/fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:ios) 17 | 18 | platform :ios do 19 | desc "Push a new beta build to TestFlight" 20 | 21 | before_all do 22 | xcode_select("/Applications/Xcode_26.0.1.app") 23 | end 24 | 25 | 26 | lane :build do 27 | # Do some things like setting up a temporary keystore to host secrets in CI 28 | setup_ci 29 | 30 | # # Authenticate with Apple app store connect 31 | # app_store_connect_api_key 32 | 33 | # Change signing behavior to work in CI 34 | update_code_signing_settings( 35 | # Automatic signing seems to be a good thing to have on in dev but will not work in CI 36 | use_automatic_signing: false, 37 | # The default value for this is iOS Development which is not appropriate for release 38 | code_sign_identity: "Apple Distribution", 39 | ) 40 | 41 | # Find our signing certs and profiles, these come from a private repository and managed by `fastlane match` 42 | match(type: 'appstore', app_identifier: ["net.defined.mobileNebula","net.defined.mobileNebula.NebulaNetworkExtension"], readonly: true) 43 | 44 | # Update our main program to have the correct provisioning profile from Apple 45 | update_project_provisioning( 46 | xcodeproj: "Runner.xcodeproj", 47 | target_filter: "Runner", 48 | # This comes from match() above 49 | profile:ENV["sigh_net.defined.mobileNebula_appstore_profile-path"], 50 | build_configuration: "Release" 51 | ) 52 | 53 | # Update our network extension to have the correct provisioning profile from Apple 54 | update_project_provisioning( 55 | xcodeproj: "Runner.xcodeproj", 56 | target_filter: "NebulaNetworkExtension", 57 | # This comes from match() above 58 | profile:ENV["sigh_net.defined.mobileNebula.NebulaNetworkExtension_appstore_profile-path"], 59 | build_configuration: "Release" 60 | ) 61 | 62 | increment_build_number( 63 | xcodeproj: "Runner.xcodeproj", 64 | build_number: ENV['BUILD_NUMBER'] 65 | ) 66 | 67 | increment_version_number( 68 | xcodeproj: "Runner.xcodeproj", 69 | version_number: ENV['BUILD_NAME'] 70 | ) 71 | 72 | build_app( 73 | output_name: "MobileNebula.ipa", 74 | workspace: "Runner.xcworkspace", 75 | scheme: "Runner", 76 | export_method: "app-store", 77 | ) 78 | end 79 | 80 | lane :release do 81 | # Do some things like setting up a temporary keystore to host secrets in CI 82 | setup_ci 83 | 84 | # Authenticate with Apple app store connect 85 | app_store_connect_api_key 86 | 87 | upload_to_testflight(skip_waiting_for_build_processing: true) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "nebula icon white@2x-20@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "nebula icon white@2x-20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "nebula icon white@2x-29@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "nebula icon white@2x-29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "nebula icon white@2x-40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "nebula icon white@2x-40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "nebula icon white@2x-60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "nebula icon white@2x-60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "nebula icon white@2x-20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "nebula icon white@2x-20@2x.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "nebula icon white@2x-29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "nebula icon white@2x-29@2x.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "nebula icon white@2x-40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "nebula icon white@2x-40@2x.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "nebula icon white@2x-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "nebula icon white@2x-76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "nebula icon white@2x-83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "nebula icon white@2x-1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /lib/components/FormPage.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | import 'package:mobile_nebula/components/SimplePage.dart'; 4 | import 'package:mobile_nebula/services/utils.dart'; 5 | 6 | /// SimplePage with a form and built in validation and confirmation to discard changes if any are made 7 | class FormPage extends StatefulWidget { 8 | const FormPage({ 9 | super.key, 10 | required this.title, 11 | required this.child, 12 | required this.onSave, 13 | required this.changed, 14 | this.hideSave = false, 15 | this.scrollController, 16 | }); 17 | 18 | final String title; 19 | final Function onSave; 20 | final Widget child; 21 | final ScrollController? scrollController; 22 | 23 | /// If you need the page to progress to a certain point before saving, control it here 24 | final bool hideSave; 25 | 26 | /// Useful if you have a non form field that can change, overrides the internal changed state if true 27 | final bool changed; 28 | 29 | @override 30 | _FormPageState createState() => _FormPageState(); 31 | } 32 | 33 | class _FormPageState extends State { 34 | var changed = false; 35 | final _formKey = GlobalKey(); 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | changed = widget.changed || changed; 40 | 41 | return PopScope( 42 | canPop: !changed, 43 | onPopInvokedWithResult: (bool didPop, Object? result) async { 44 | if (didPop) { 45 | return; 46 | } 47 | final NavigatorState navigator = Navigator.of(context); 48 | 49 | Utils.confirmDelete( 50 | context, 51 | 'Discard changes?', 52 | () { 53 | navigator.pop(); 54 | }, 55 | deleteLabel: 'Yes', 56 | cancelLabel: 'No', 57 | ); 58 | }, 59 | child: SimplePage( 60 | leadingAction: _buildLeader(context), 61 | trailingActions: _buildTrailer(context), 62 | scrollController: widget.scrollController, 63 | title: Text(widget.title), 64 | child: Form( 65 | key: _formKey, 66 | onChanged: 67 | () => setState(() { 68 | changed = true; 69 | }), 70 | child: widget.child, 71 | ), 72 | ), 73 | ); 74 | } 75 | 76 | Widget _buildLeader(BuildContext context) { 77 | return Utils.leadingBackWidget( 78 | context, 79 | label: changed ? 'Cancel' : 'Back', 80 | onPressed: () { 81 | if (changed) { 82 | Utils.confirmDelete( 83 | context, 84 | 'Discard changes?', 85 | () { 86 | changed = false; 87 | Navigator.pop(context); 88 | }, 89 | deleteLabel: 'Yes', 90 | cancelLabel: 'No', 91 | ); 92 | } else { 93 | Navigator.pop(context); 94 | } 95 | }, 96 | ); 97 | } 98 | 99 | List _buildTrailer(BuildContext context) { 100 | if (!changed || widget.hideSave) { 101 | return []; 102 | } 103 | 104 | return [ 105 | Utils.trailingSaveWidget(context, () { 106 | if (_formKey.currentState == null) { 107 | return; 108 | } 109 | 110 | if (!_formKey.currentState!.validate()) { 111 | return; 112 | } 113 | 114 | _formKey.currentState!.save(); 115 | widget.onSave(); 116 | }), 117 | ]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: mobile_nebula 2 | description: Mobile Nebula Client 3 | 4 | # The following defines the version and build number for your application. 5 | # A version number is three numbers separated by dots, like 1.2.43 6 | # followed by an optional build number separated by a +. 7 | # Both the version and the builder number may be overridden in flutter 8 | # build by specifying --build-name and --build-number, respectively. 9 | # In Android, build-name is used as versionName while build-number used as versionCode. 10 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 11 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 12 | # Read more about iOS versioning at 13 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 14 | version: 0.1.0+54 15 | 16 | environment: 17 | sdk: ^3.7.0 18 | 19 | dependencies: 20 | flutter: 21 | sdk: flutter 22 | flutter_web_plugins: 23 | sdk: flutter 24 | 25 | # The following adds the Cupertino Icons font to your application. 26 | # Use with the CupertinoIcons class for iOS style icons. 27 | cupertino_icons: ^1.0.2 28 | flutter_platform_widgets: ^7.0.1 29 | path_provider: ^2.0.11 30 | file_picker: ^8.1.2 31 | google_fonts: ^6.2.1 32 | uuid: ^4.4.2 33 | package_info_plus: ^8.0.2 34 | url_launcher: ^6.1.6 35 | pull_to_refresh: ^2.0.0 36 | flutter_svg: ^2.0.10+1 37 | intl: ^0.19.0 38 | share_plus: ^10.0.2 39 | sentry_flutter: ^8.14.2 40 | sentry_dart_plugin: ^2.4.1 41 | mobile_scanner: ^7.0.1 42 | path: ^1.9.1 43 | 44 | dev_dependencies: 45 | flutter_test: 46 | sdk: flutter 47 | flutter_oss_licenses: ^3.0.4 48 | flutter_lints: ^5.0.0 49 | 50 | # For information on the generic Dart part of this file, see the 51 | # following page: https://dart.dev/tools/pub/pubspec 52 | 53 | # The following section is specific to Flutter. 54 | flutter: 55 | # The following line ensures that the Material Icons font is 56 | # included with your application, so that you can use the icons in 57 | # the material Icons class. 58 | uses-material-design: true 59 | 60 | # To add assets to your application, add an assets section, like this: 61 | # assets: 62 | # - images/a_dot_burr.jpeg 63 | # - images/a_dot_ham.jpeg 64 | assets: 65 | - images/dn-logo-light.svg 66 | - images/dn-logo-dark.svg 67 | 68 | # An image asset can refer to one or more resolution-specific "variants", see 69 | # https://flutter.dev/assets-and-images/#resolution-aware. 70 | 71 | # For details regarding adding assets from package dependencies, see 72 | # https://flutter.dev/assets-and-images/#from-packages 73 | 74 | # To add custom fonts to your application, add a fonts section here, 75 | # in this "flutter" section. Each entry in this list should have a 76 | # "family" key with the font family name, and a "fonts" key with a 77 | # list giving the asset and other descriptors for the font. For 78 | # example: 79 | # fonts: 80 | # - family: Schyler 81 | # fonts: 82 | # - asset: fonts/Schyler-Regular.ttf 83 | # - asset: fonts/Schyler-Italic.ttf 84 | # style: italic 85 | # - family: Trajan Pro 86 | # fonts: 87 | # - asset: fonts/TrajanPro.ttf 88 | # - asset: fonts/TrajanPro_Bold.ttf 89 | # weight: 700 90 | # 91 | # For details regarding fonts from package dependencies, 92 | # see https://flutter.dev/custom-fonts/#from-packages 93 | fonts: 94 | - family: RobotoMono 95 | fonts: 96 | - asset: fonts/RobotoMono-Regular.ttf 97 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | Releasing an update requires that we submit apps to the internal testing tracks for each store first and promote them to production later. 4 | 5 | ## Pre-release AKA internal testing 6 | 7 | **NOTE** Tagging a release from the non `main` branch is fine, as long as that release does not get deployed to production. 8 | 9 | 1. Select a version and a build number, eg. `v0.6.2-0`. 10 | - The version number uses [semver](https://semver.org/). Generally we will just bump the `MINOR` version from the previous release. It is unusual to change the version during multiple pre-release iterations. 11 | - The build number is typically a number that starts at 0 for a given version and increments until 12 | the final release-able build has been created. This number doesn't matter for much but it needs to be unique for each build within a given version. 13 | 14 | 1. Ideally, if this is expected to be the final build for this version, update the `CHANGELOG.md` to reflect any interesting changes, make sure it is committed. and merged to `main`. 15 | 16 | 1. Ensure your working directly is at the correct branch and sha for the release. This should almost always be the `main` branch, certainly if the goal is to publish to production. 17 | 18 | ```sh 19 | git checkout main 20 | git pull 21 | ``` 22 | 23 | 1. Tag the release locally, replace `!VERSION` with the version for this release, eg. `0.6.2-0`. 24 | 25 | ```sh 26 | git tag -a v!VERSION -m "!VERSION Release" 27 | ``` 28 | 29 | The tag has a `v` prepended while the message does not, this is on purpose. 30 | 31 | 1. Push the tag to Github, again replacing `!VERSION`. 32 | 33 | ```sh 34 | git push origin v!VERSION 35 | ``` 36 | 37 | 1. This will eventually lead to a draft release in Github with links to download the apps. It will also submit the app to the app stores internal testing tracks. 38 | 39 | 1. Test and repeat if further changes are required. Move to [Release](#release-1) when ready. 40 | 41 | ## Release 42 | 43 | **NOTE** Production releases should be tagged from `main`. 44 | 45 | If the release was tagged from a branch, get the branch merged to `main` and repeat the [Pre-release](#pre-release-aka-internal-testing) steps from `main`. 46 | 47 | 1. Follow the steps in [Google](https://play.google.com/console) and [Apple](https://appstoreconnect.apple.com/) stores to submit a new version to production. 48 | - Apple is the little blue + sign next to the heading `iOS App` while in the app details page. 49 | - Google is the `Create a new release` button under `Test and release` > `Production` in the app details page. 50 | - Make sure the version number matches, only the digits are used, eg. `0.6.2`. 51 | - Copy the interesting bits out of the changelog for this release and paste them into the "What's new in this update" section for each store. 52 | 53 | 1. Make sure you have actually submitted the draft releases for review, this is typically many button clicks. 54 | 55 | 1. Wait for both stores to approve the release. 56 | 57 | 1. Convert the draft release in Github to a published release. 58 | - Edit the tag to drop the build number, the final tag should be similar to `v0.6.2` 59 | - Change the release title to be `Release v!VERSION`, eg. `Release v0.6.2` 60 | - Copy the changelog bits for this release from `CHANGELOG.md` to the release notes. If we didn't 61 | craft a changelog use the `Generate release notes` as a fallback. 62 | - Make sure `Set as the latest release` is checked. 63 | - Publish 64 | 65 | 1. Publish the release in the app stores 66 | 67 | 1. Go over to `api` and update the latest version for mobile. -------------------------------------------------------------------------------- /lib/screens/siteConfig/UnsafeRoutesScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:mobile_nebula/components/FormPage.dart'; 3 | import 'package:mobile_nebula/components/config/ConfigButtonItem.dart'; 4 | import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; 5 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 6 | import 'package:mobile_nebula/models/UnsafeRoute.dart'; 7 | import 'package:mobile_nebula/screens/siteConfig/UnsafeRouteScreen.dart'; 8 | import 'package:mobile_nebula/services/utils.dart'; 9 | 10 | class UnsafeRoutesScreen extends StatefulWidget { 11 | const UnsafeRoutesScreen({super.key, required this.unsafeRoutes, required this.onSave}); 12 | 13 | final List unsafeRoutes; 14 | final ValueChanged>? onSave; 15 | 16 | @override 17 | _UnsafeRoutesScreenState createState() => _UnsafeRoutesScreenState(); 18 | } 19 | 20 | class _UnsafeRoutesScreenState extends State { 21 | late Map unsafeRoutes; 22 | bool changed = false; 23 | 24 | @override 25 | void initState() { 26 | unsafeRoutes = {}; 27 | for (var route in widget.unsafeRoutes) { 28 | unsafeRoutes[UniqueKey()] = route; 29 | } 30 | 31 | super.initState(); 32 | } 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return FormPage( 37 | title: 'Unsafe Routes', 38 | changed: changed, 39 | onSave: _onSave, 40 | child: ConfigSection(children: _buildRoutes()), 41 | ); 42 | } 43 | 44 | _onSave() { 45 | Navigator.pop(context); 46 | if (widget.onSave != null) { 47 | widget.onSave!(unsafeRoutes.values.toList()); 48 | } 49 | } 50 | 51 | List _buildRoutes() { 52 | final double ipWidth = Utils.textSize("000.000.000.000/00", CupertinoTheme.of(context).textTheme.textStyle).width; 53 | List items = []; 54 | unsafeRoutes.forEach((key, route) { 55 | items.add( 56 | ConfigPageItem( 57 | disabled: widget.onSave == null, 58 | label: Text(route.route ?? ''), 59 | labelWidth: ipWidth, 60 | content: Text('via ${route.via}', textAlign: TextAlign.end), 61 | onPressed: () { 62 | Utils.openPage(context, (context) { 63 | return UnsafeRouteScreen( 64 | route: route, 65 | onSave: (route) { 66 | setState(() { 67 | changed = true; 68 | unsafeRoutes[key] = route; 69 | }); 70 | }, 71 | onDelete: () { 72 | setState(() { 73 | changed = true; 74 | unsafeRoutes.remove(key); 75 | }); 76 | }, 77 | ); 78 | }); 79 | }, 80 | ), 81 | ); 82 | }); 83 | 84 | if (widget.onSave != null) { 85 | items.add( 86 | ConfigButtonItem( 87 | content: Text('Add a new route'), 88 | onPressed: () { 89 | Utils.openPage(context, (context) { 90 | return UnsafeRouteScreen( 91 | route: UnsafeRoute(), 92 | onSave: (route) { 93 | setState(() { 94 | changed = true; 95 | unsafeRoutes[UniqueKey()] = route; 96 | }); 97 | }, 98 | ); 99 | }); 100 | }, 101 | ), 102 | ); 103 | } 104 | 105 | return items; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/models/Certificate.dart: -------------------------------------------------------------------------------- 1 | class CertificateInfo { 2 | Certificate cert; 3 | String? rawCert; 4 | CertificateValidity? validity; 5 | 6 | CertificateInfo.debug({this.rawCert = ""}) : cert = Certificate.debug(), validity = CertificateValidity.debug(); 7 | 8 | CertificateInfo.fromJson(Map json) 9 | : cert = Certificate.fromJson(json['Cert']), 10 | rawCert = json['RawCert'], 11 | validity = CertificateValidity.fromJson(json['Validity']); 12 | 13 | CertificateInfo({required this.cert, this.rawCert, this.validity}); 14 | 15 | static List fromJsonList(List list) { 16 | return list.map((v) => CertificateInfo.fromJson(v)).toList(); 17 | } 18 | } 19 | 20 | class Certificate { 21 | int version; 22 | String name; 23 | List networks; 24 | List unsafeNetworks; 25 | List groups; 26 | bool isCa; 27 | DateTime notBefore; 28 | DateTime notAfter; 29 | String issuer; 30 | String publicKey; 31 | String curve; 32 | String fingerprint; 33 | String signature; 34 | 35 | Certificate.debug() 36 | : version = 2, 37 | name = "DEBUG", 38 | networks = [], 39 | unsafeNetworks = [], 40 | groups = [], 41 | isCa = false, 42 | notBefore = DateTime.now(), 43 | notAfter = DateTime.now(), 44 | issuer = "DEBUG", 45 | publicKey = "", 46 | curve = "", 47 | fingerprint = "DEBUG", 48 | signature = "DEBUG"; 49 | 50 | factory Certificate.fromJson(Map json) { 51 | Map details = json; 52 | String publicKey; 53 | String curve; 54 | if (json.containsKey("details")) { 55 | details = json["details"]; 56 | //TODO: currently swift and kotlin flatten the certificate structure but 57 | // nebula outputs cert json in the nested format 58 | switch (json["version"]) { 59 | case 1: 60 | // In V1 the public key was under details 61 | publicKey = details["publicKey"]; 62 | curve = details["curve"]; 63 | break; 64 | case 2: 65 | // In V2 the public key moved to the top level 66 | publicKey = json["publicKey"]; 67 | curve = json["curve"]; 68 | break; 69 | default: 70 | throw Exception('Unknown certificate version'); 71 | } 72 | } else { 73 | // This is a flattened certificate format, publicKey is at the top 74 | publicKey = json["publicKey"]; 75 | curve = json["curve"]; 76 | } 77 | 78 | return Certificate( 79 | json["version"], 80 | details["name"], 81 | List.from(details['networks'] ?? []), 82 | List.from(details['unsafeNetworks'] ?? []), 83 | List.from(details['groups']), 84 | details['isCa'], 85 | DateTime.parse(details['notBefore']), 86 | DateTime.parse(details['notAfter']), 87 | details['issuer'], 88 | publicKey, 89 | curve, 90 | json['fingerprint'], 91 | json['signature'], 92 | ); 93 | } 94 | 95 | Certificate( 96 | this.version, 97 | this.name, 98 | this.networks, 99 | this.unsafeNetworks, 100 | this.groups, 101 | this.isCa, 102 | this.notBefore, 103 | this.notAfter, 104 | this.issuer, 105 | this.publicKey, 106 | this.curve, 107 | this.fingerprint, 108 | this.signature, 109 | ); 110 | } 111 | 112 | class CertificateValidity { 113 | bool valid; 114 | String reason; 115 | 116 | CertificateValidity.debug() : valid = true, reason = ""; 117 | 118 | CertificateValidity.fromJson(Map json) : valid = json['Valid'], reason = json['Reason']; 119 | } 120 | -------------------------------------------------------------------------------- /lib/components/CIDRField.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:mobile_nebula/components/SpecialTextField.dart'; 4 | import 'package:mobile_nebula/models/CIDR.dart'; 5 | 6 | import '../services/utils.dart'; 7 | import 'IPField.dart'; 8 | 9 | //TODO: Support initialValue 10 | class CIDRField extends StatefulWidget { 11 | const CIDRField({ 12 | super.key, 13 | this.ipHelp = "ip address", 14 | this.autoFocus = false, 15 | this.focusNode, 16 | this.nextFocusNode, 17 | this.onChanged, 18 | this.textInputAction, 19 | this.ipController, 20 | this.bitsController, 21 | }); 22 | 23 | final String ipHelp; 24 | final bool autoFocus; 25 | final FocusNode? focusNode; 26 | final FocusNode? nextFocusNode; 27 | final ValueChanged? onChanged; 28 | final TextInputAction? textInputAction; 29 | final TextEditingController? ipController; 30 | final TextEditingController? bitsController; 31 | 32 | @override 33 | _CIDRFieldState createState() => _CIDRFieldState(); 34 | } 35 | 36 | //TODO: if the keyboard is open on the port field and you switch to dark mode, it crashes 37 | //TODO: maybe add in a next/done step for numeric keyboards 38 | //TODO: rig up focus node and next node 39 | //TODO: rig up textInputAction 40 | class _CIDRFieldState extends State { 41 | final bitsFocus = FocusNode(); 42 | final cidr = CIDR(); 43 | 44 | @override 45 | void initState() { 46 | //TODO: this won't track external controller changes appropriately 47 | cidr.ip = widget.ipController?.text ?? ""; 48 | cidr.bits = int.tryParse(widget.bitsController?.text ?? "") ?? 0; 49 | super.initState(); 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | var textStyle = CupertinoTheme.of(context).textTheme.textStyle; 55 | 56 | return Row( 57 | children: [ 58 | Expanded( 59 | child: Padding( 60 | padding: EdgeInsets.fromLTRB(6, 6, 2, 6), 61 | child: IPField( 62 | help: widget.ipHelp, 63 | ipOnly: true, 64 | textPadding: EdgeInsets.all(0), 65 | textInputAction: TextInputAction.next, 66 | textAlign: TextAlign.end, 67 | focusNode: widget.focusNode, 68 | nextFocusNode: bitsFocus, 69 | onChanged: (val) { 70 | if (widget.onChanged == null) { 71 | return; 72 | } 73 | 74 | cidr.ip = val; 75 | widget.onChanged!(cidr); 76 | }, 77 | controller: widget.ipController, 78 | ), 79 | ), 80 | ), 81 | Text("/"), 82 | Container( 83 | width: Utils.textSize("bits", textStyle).width + 12, 84 | padding: EdgeInsets.fromLTRB(2, 6, 6, 6), 85 | child: SpecialTextField( 86 | keyboardType: TextInputType.number, 87 | focusNode: bitsFocus, 88 | nextFocusNode: widget.nextFocusNode, 89 | controller: widget.bitsController, 90 | onChanged: (val) { 91 | if (widget.onChanged == null) { 92 | return; 93 | } 94 | 95 | cidr.bits = int.tryParse(val) ?? 0; 96 | widget.onChanged!(cidr); 97 | }, 98 | maxLength: 2, 99 | inputFormatters: [FilteringTextInputFormatter.digitsOnly], 100 | textInputAction: widget.textInputAction ?? TextInputAction.done, 101 | placeholder: 'bits', 102 | ), 103 | ), 104 | ], 105 | ); 106 | } 107 | 108 | @override 109 | void dispose() { 110 | bitsFocus.dispose(); 111 | super.dispose(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /lib/components/IPAndPortField.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:mobile_nebula/components/SpecialTextField.dart'; 4 | import 'package:mobile_nebula/models/IPAndPort.dart'; 5 | 6 | import '../services/utils.dart'; 7 | import 'IPField.dart'; 8 | 9 | //TODO: Support initialValue 10 | class IPAndPortField extends StatefulWidget { 11 | const IPAndPortField({ 12 | super.key, 13 | this.ipOnly = false, 14 | this.ipHelp = "ip address", 15 | this.autoFocus = false, 16 | this.focusNode, 17 | this.nextFocusNode, 18 | required this.onChanged, 19 | this.textInputAction, 20 | this.noBorder = false, 21 | this.ipTextAlign, 22 | this.ipController, 23 | this.portController, 24 | }); 25 | 26 | final String ipHelp; 27 | final bool ipOnly; 28 | final bool autoFocus; 29 | final FocusNode? focusNode; 30 | final FocusNode? nextFocusNode; 31 | final ValueChanged onChanged; 32 | final TextInputAction? textInputAction; 33 | final bool noBorder; 34 | final TextAlign? ipTextAlign; 35 | final TextEditingController? ipController; 36 | final TextEditingController? portController; 37 | 38 | @override 39 | _IPAndPortFieldState createState() => _IPAndPortFieldState(); 40 | } 41 | 42 | //TODO: if the keyboard is open on the port field and you switch to dark mode, it crashes 43 | //TODO: maybe add in a next/done step for numeric keyboards 44 | //TODO: rig up focus node and next node 45 | //TODO: rig up textInputAction 46 | class _IPAndPortFieldState extends State { 47 | final _portFocus = FocusNode(); 48 | final _ipAndPort = IPAndPort(); 49 | 50 | @override 51 | void initState() { 52 | //TODO: this won't track external controller changes appropriately 53 | _ipAndPort.ip = widget.ipController?.text ?? ""; 54 | _ipAndPort.port = int.tryParse(widget.portController?.text ?? ""); 55 | super.initState(); 56 | } 57 | 58 | @override 59 | Widget build(BuildContext context) { 60 | var textStyle = CupertinoTheme.of(context).textTheme.textStyle; 61 | 62 | return Row( 63 | children: [ 64 | Expanded( 65 | child: Padding( 66 | padding: EdgeInsets.fromLTRB(6, 6, 2, 6), 67 | child: IPField( 68 | help: widget.ipHelp, 69 | ipOnly: widget.ipOnly, 70 | nextFocusNode: _portFocus, 71 | textPadding: EdgeInsets.all(0), 72 | textInputAction: TextInputAction.next, 73 | focusNode: widget.focusNode, 74 | onChanged: (val) { 75 | _ipAndPort.ip = val; 76 | widget.onChanged(_ipAndPort); 77 | }, 78 | textAlign: widget.ipTextAlign, 79 | controller: widget.ipController, 80 | ), 81 | ), 82 | ), 83 | Text(":"), 84 | Container( 85 | width: Utils.textSize("00000", textStyle).width + 12, 86 | padding: EdgeInsets.fromLTRB(2, 6, 6, 6), 87 | child: SpecialTextField( 88 | keyboardType: TextInputType.number, 89 | focusNode: _portFocus, 90 | nextFocusNode: widget.nextFocusNode, 91 | controller: widget.portController, 92 | onChanged: (val) { 93 | _ipAndPort.port = int.tryParse(val); 94 | widget.onChanged(_ipAndPort); 95 | }, 96 | maxLength: 5, 97 | inputFormatters: [FilteringTextInputFormatter.digitsOnly], 98 | textInputAction: TextInputAction.done, 99 | placeholder: 'port', 100 | ), 101 | ), 102 | ], 103 | ); 104 | } 105 | 106 | @override 107 | void dispose() { 108 | _portFocus.dispose(); 109 | super.dispose(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/screens/AboutScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 3 | import 'package:mobile_nebula/components/SimplePage.dart'; 4 | import 'package:mobile_nebula/components/config/ConfigItem.dart'; 5 | import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; 6 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 7 | import 'package:mobile_nebula/gen.versions.dart'; 8 | import 'package:mobile_nebula/screens/LicensesScreen.dart'; 9 | import 'package:mobile_nebula/services/utils.dart'; 10 | import 'package:package_info_plus/package_info_plus.dart'; 11 | 12 | class AboutScreen extends StatefulWidget { 13 | const AboutScreen({super.key}); 14 | 15 | @override 16 | _AboutScreenState createState() => _AboutScreenState(); 17 | } 18 | 19 | class _AboutScreenState extends State { 20 | bool ready = false; 21 | PackageInfo? packageInfo; 22 | 23 | @override 24 | void initState() { 25 | PackageInfo.fromPlatform().then((PackageInfo info) { 26 | packageInfo = info; 27 | setState(() { 28 | ready = true; 29 | }); 30 | }); 31 | 32 | super.initState(); 33 | } 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | // packageInfo is null until ready is true 38 | if (!ready) { 39 | return Center( 40 | child: PlatformCircularProgressIndicator( 41 | cupertino: (_, __) { 42 | return CupertinoProgressIndicatorData(radius: 50); 43 | }, 44 | ), 45 | ); 46 | } 47 | 48 | return SimplePage( 49 | title: Text('About'), 50 | child: Column( 51 | children: [ 52 | ConfigSection( 53 | children: [ 54 | ConfigItem( 55 | label: Text('App version'), 56 | labelWidth: 150, 57 | content: _buildText('${packageInfo!.version}-${packageInfo!.buildNumber} (sha: $gitSha)'), 58 | ), 59 | ConfigItem( 60 | label: Text('Nebula version'), 61 | labelWidth: 150, 62 | content: _buildText('$nebulaVersion ($goVersion)'), 63 | ), 64 | ConfigItem( 65 | label: Text('Flutter version'), 66 | labelWidth: 150, 67 | content: _buildText(flutterVersion['frameworkVersion'] ?? 'Unknown'), 68 | ), 69 | ConfigItem( 70 | label: Text('Dart version'), 71 | labelWidth: 150, 72 | content: _buildText(flutterVersion['dartSdkVersion'] ?? 'Unknown'), 73 | ), 74 | ], 75 | ), 76 | ConfigSection( 77 | children: [ 78 | //TODO: wire up these other pages 79 | // ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)), 80 | ConfigPageItem( 81 | label: Text('Privacy policy'), 82 | labelWidth: 300, 83 | onPressed: () => Utils.launchUrl('https://www.defined.net/privacy/', context), 84 | ), 85 | ConfigPageItem( 86 | label: Text('Licenses'), 87 | labelWidth: 300, 88 | onPressed: 89 | () => Utils.openPage(context, (context) { 90 | return LicensesScreen(); 91 | }), 92 | ), 93 | ], 94 | ), 95 | Padding( 96 | padding: EdgeInsets.only(top: 20), 97 | child: Text('Copyright © 2024 Defined Networking, Inc', textAlign: TextAlign.center), 98 | ), 99 | ], 100 | ), 101 | ); 102 | } 103 | 104 | _buildText(String str) { 105 | return Align(alignment: AlignmentDirectional.centerEnd, child: SelectableText(str)); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/components/SimplePage.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 3 | import 'package:pull_to_refresh/pull_to_refresh.dart'; 4 | 5 | enum SimpleScrollable { none, vertical, horizontal, both } 6 | 7 | class SimplePage extends StatelessWidget { 8 | const SimplePage({ 9 | super.key, 10 | required this.title, 11 | required this.child, 12 | this.leadingAction, 13 | this.trailingActions = const [], 14 | this.scrollable = SimpleScrollable.vertical, 15 | this.scrollbar = true, 16 | this.scrollController, 17 | this.bottomBar, 18 | this.onRefresh, 19 | this.onLoading, 20 | this.alignment, 21 | this.refreshController, 22 | }); 23 | 24 | final Widget title; 25 | final Widget child; 26 | final SimpleScrollable scrollable; 27 | final ScrollController? scrollController; 28 | final AlignmentGeometry? alignment; 29 | 30 | /// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorder-able listviews 31 | /// This is set to true if you have any scrollable other than none 32 | final bool scrollbar; 33 | final Widget? bottomBar; 34 | 35 | /// If no leading action is provided then a default "Back" widget than pops the page will be provided 36 | final Widget? leadingAction; 37 | final List trailingActions; 38 | 39 | final VoidCallback? onRefresh; 40 | final VoidCallback? onLoading; 41 | final RefreshController? refreshController; 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | Widget realChild = child; 46 | var addScrollbar = scrollbar; 47 | 48 | if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) { 49 | realChild = SingleChildScrollView( 50 | scrollDirection: Axis.vertical, 51 | controller: refreshController == null ? scrollController : null, 52 | child: realChild, 53 | ); 54 | addScrollbar = true; 55 | } 56 | 57 | if (scrollable == SimpleScrollable.horizontal || scrollable == SimpleScrollable.both) { 58 | realChild = SingleChildScrollView(scrollDirection: Axis.horizontal, child: realChild); 59 | addScrollbar = true; 60 | } 61 | 62 | if (refreshController != null) { 63 | realChild = RefreshConfiguration( 64 | headerTriggerDistance: 100, 65 | footerTriggerDistance: -100, 66 | maxUnderScrollExtent: 100, 67 | child: SmartRefresher( 68 | scrollController: scrollController, 69 | onRefresh: onRefresh, 70 | onLoading: onLoading, 71 | controller: refreshController!, 72 | enablePullUp: onLoading != null, 73 | enablePullDown: onRefresh != null, 74 | footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading), 75 | child: realChild, 76 | ), 77 | ); 78 | addScrollbar = true; 79 | } 80 | 81 | if (addScrollbar) { 82 | realChild = Scrollbar(controller: scrollController, child: realChild); 83 | } 84 | 85 | if (alignment != null) { 86 | realChild = Align(alignment: alignment!, child: realChild); 87 | } 88 | 89 | if (bottomBar != null) { 90 | realChild = Column(children: [Expanded(child: realChild), bottomBar!]); 91 | } 92 | 93 | return PlatformScaffold( 94 | backgroundColor: Theme.of(context).scaffoldBackgroundColor, 95 | appBar: PlatformAppBar( 96 | title: title, 97 | leading: leadingAction, 98 | trailingActions: trailingActions, 99 | cupertino: 100 | (_, __) => CupertinoNavigationBarData( 101 | transitionBetweenRoutes: false, 102 | // TODO: set title on route, show here instead of just "Back" 103 | previousPageTitle: 'Back', 104 | padding: EdgeInsetsDirectional.only(end: 8.0), 105 | ), 106 | ), 107 | body: SafeArea(child: realChild), 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 26 | 27 | 28 | 29 | 31 | 32 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 58 | 61 | 62 | 67 | 68 | 72 | 73 | 74 | 76 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /lib/components/SpecialTextField.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; 4 | 5 | /// A normal TextField or CupertinoTextField that looks the same on all platforms 6 | class SpecialTextField extends StatefulWidget { 7 | const SpecialTextField({ 8 | super.key, 9 | this.placeholder, 10 | this.suffix, 11 | this.controller, 12 | this.focusNode, 13 | this.nextFocusNode, 14 | this.autocorrect, 15 | this.minLines, 16 | this.maxLines, 17 | this.maxLength, 18 | this.maxLengthEnforcement, 19 | this.style, 20 | this.keyboardType, 21 | this.textInputAction, 22 | this.textCapitalization, 23 | this.textAlign, 24 | this.autofocus, 25 | this.onChanged, 26 | this.enabled, 27 | this.expands, 28 | this.keyboardAppearance, 29 | this.textAlignVertical, 30 | this.inputFormatters, 31 | }); 32 | 33 | final String? placeholder; 34 | final TextEditingController? controller; 35 | final FocusNode? focusNode; 36 | final FocusNode? nextFocusNode; 37 | final bool? autocorrect; 38 | final int? minLines; 39 | final int? maxLines; 40 | final int? maxLength; 41 | final MaxLengthEnforcement? maxLengthEnforcement; 42 | final Widget? suffix; 43 | final TextStyle? style; 44 | final TextInputType? keyboardType; 45 | final Brightness? keyboardAppearance; 46 | 47 | final TextInputAction? textInputAction; 48 | final TextCapitalization? textCapitalization; 49 | final TextAlign? textAlign; 50 | final TextAlignVertical? textAlignVertical; 51 | 52 | final bool? autofocus; 53 | final ValueChanged? onChanged; 54 | final bool? enabled; 55 | final List? inputFormatters; 56 | final bool? expands; 57 | 58 | @override 59 | _SpecialTextFieldState createState() => _SpecialTextFieldState(); 60 | } 61 | 62 | class _SpecialTextFieldState extends State { 63 | List formatters = []; 64 | 65 | @override 66 | void initState() { 67 | if (widget.inputFormatters == null || formatters.isEmpty) { 68 | formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))]; 69 | } else { 70 | formatters = widget.inputFormatters!; 71 | } 72 | 73 | super.initState(); 74 | } 75 | 76 | @override 77 | Widget build(BuildContext context) { 78 | return PlatformTextField( 79 | autocorrect: widget.autocorrect, 80 | minLines: widget.minLines, 81 | maxLines: widget.maxLines, 82 | maxLength: widget.maxLength, 83 | maxLengthEnforcement: widget.maxLengthEnforcement, 84 | keyboardType: widget.keyboardType, 85 | keyboardAppearance: widget.keyboardAppearance, 86 | textInputAction: widget.textInputAction, 87 | textCapitalization: widget.textCapitalization, 88 | textAlign: widget.textAlign, 89 | textAlignVertical: widget.textAlignVertical, 90 | autofocus: widget.autofocus, 91 | focusNode: widget.focusNode, 92 | onChanged: widget.onChanged, 93 | enabled: widget.enabled ?? true, 94 | onSubmitted: (_) { 95 | if (widget.nextFocusNode != null) { 96 | FocusScope.of(context).requestFocus(widget.nextFocusNode); 97 | } 98 | }, 99 | expands: widget.expands, 100 | inputFormatters: formatters, 101 | material: 102 | (_, __) => MaterialTextFieldData( 103 | decoration: InputDecoration( 104 | border: InputBorder.none, 105 | contentPadding: EdgeInsets.zero, 106 | isDense: true, 107 | hintText: widget.placeholder, 108 | counterText: '', 109 | suffix: widget.suffix, 110 | ), 111 | ), 112 | cupertino: 113 | (_, __) => CupertinoTextFieldData( 114 | decoration: BoxDecoration(), 115 | padding: EdgeInsets.zero, 116 | placeholder: widget.placeholder, 117 | suffix: widget.suffix, 118 | ), 119 | style: widget.style, 120 | controller: widget.controller, 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/components/IPField.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:mobile_nebula/components/SpecialTextField.dart'; 4 | 5 | import '../services/utils.dart'; 6 | 7 | class IPField extends StatelessWidget { 8 | final String help; 9 | final bool ipOnly; 10 | final bool autoFocus; 11 | final FocusNode? focusNode; 12 | final FocusNode? nextFocusNode; 13 | final ValueChanged? onChanged; 14 | final EdgeInsetsGeometry textPadding; 15 | final TextInputAction? textInputAction; 16 | final controller; 17 | final textAlign; 18 | 19 | const IPField({ 20 | super.key, 21 | this.ipOnly = false, 22 | this.help = "ip address", 23 | this.autoFocus = false, 24 | this.focusNode, 25 | this.nextFocusNode, 26 | this.onChanged, 27 | this.textPadding = const EdgeInsets.all(6.0), 28 | this.textInputAction, 29 | this.controller, 30 | this.textAlign = TextAlign.center, 31 | }); 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | var textStyle = CupertinoTheme.of(context).textTheme.textStyle; 36 | final double? ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null; 37 | 38 | return SizedBox( 39 | width: ipWidth, 40 | child: SpecialTextField( 41 | keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true, signed: true) : null, 42 | textAlign: textAlign, 43 | autofocus: autoFocus, 44 | focusNode: focusNode, 45 | nextFocusNode: nextFocusNode, 46 | controller: controller, 47 | onChanged: onChanged, 48 | maxLength: ipOnly ? 15 : null, 49 | maxLengthEnforcement: ipOnly ? MaxLengthEnforcement.enforced : MaxLengthEnforcement.none, 50 | inputFormatters: ipOnly ? [IPTextInputFormatter()] : [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))], 51 | textInputAction: textInputAction, 52 | placeholder: help, 53 | ), 54 | ); 55 | } 56 | } 57 | 58 | class IPTextInputFormatter extends TextInputFormatter { 59 | final Pattern whitelistedPattern = RegExp(r'[\d\.,]+'); 60 | 61 | @override 62 | TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { 63 | return _selectionAwareTextManipulation(newValue, (String substring) { 64 | return whitelistedPattern 65 | .allMatches(substring) 66 | .map((Match match) => match.group(0)!) 67 | .join() 68 | .replaceAll(RegExp(r','), '.'); 69 | }); 70 | } 71 | } 72 | 73 | TextEditingValue _selectionAwareTextManipulation( 74 | TextEditingValue value, 75 | String Function(String substring) substringManipulation, 76 | ) { 77 | final int selectionStartIndex = value.selection.start; 78 | final int selectionEndIndex = value.selection.end; 79 | String manipulatedText; 80 | TextSelection? manipulatedSelection; 81 | if (selectionStartIndex < 0 || selectionEndIndex < 0) { 82 | manipulatedText = substringManipulation(value.text); 83 | } else { 84 | final String beforeSelection = substringManipulation(value.text.substring(0, selectionStartIndex)); 85 | final String inSelection = substringManipulation(value.text.substring(selectionStartIndex, selectionEndIndex)); 86 | final String afterSelection = substringManipulation(value.text.substring(selectionEndIndex)); 87 | manipulatedText = beforeSelection + inSelection + afterSelection; 88 | if (value.selection.baseOffset > value.selection.extentOffset) { 89 | manipulatedSelection = value.selection.copyWith( 90 | baseOffset: beforeSelection.length + inSelection.length, 91 | extentOffset: beforeSelection.length, 92 | ); 93 | } else { 94 | manipulatedSelection = value.selection.copyWith( 95 | baseOffset: beforeSelection.length, 96 | extentOffset: beforeSelection.length + inSelection.length, 97 | ); 98 | } 99 | } 100 | return TextEditingValue( 101 | text: manipulatedText, 102 | selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1), 103 | composing: manipulatedText == value.text ? value.composing : TextRange.empty, 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /nebula/control.go: -------------------------------------------------------------------------------- 1 | package mobileNebula 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/netip" 8 | "os" 9 | "runtime" 10 | "runtime/debug" 11 | 12 | "github.com/sirupsen/logrus" 13 | "github.com/slackhq/nebula" 14 | nc "github.com/slackhq/nebula/config" 15 | "github.com/slackhq/nebula/overlay" 16 | "github.com/slackhq/nebula/util" 17 | ) 18 | 19 | type Nebula struct { 20 | c *nebula.Control 21 | l *logrus.Logger 22 | config *nc.C 23 | } 24 | 25 | func init() { 26 | // Reduces memory utilization according to https://twitter.com/felixge/status/1355846360562589696?s=20 27 | runtime.MemProfileRate = 0 28 | } 29 | 30 | func NewNebula(configData string, key string, logFile string, tunFd int) (*Nebula, error) { 31 | // GC more often, largely for iOS due to extension 15mb limit 32 | debug.SetGCPercent(20) 33 | 34 | yamlConfig, err := RenderConfig(configData, key) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | l := logrus.New() 40 | f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 41 | if err != nil { 42 | return nil, err 43 | } 44 | l.SetOutput(f) 45 | 46 | c := nc.NewC(l) 47 | err = c.LoadString(yamlConfig) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to load config: %s", err) 50 | } 51 | 52 | //TODO: inject our version 53 | ctrl, err := nebula.Main(c, false, "", l, overlay.NewFdDeviceFromConfig(&tunFd)) 54 | if err != nil { 55 | switch v := err.(type) { 56 | case *util.ContextualError: 57 | v.Log(l) 58 | return nil, v.Unwrap() 59 | default: 60 | l.WithError(err).Error("Failed to start") 61 | return nil, err 62 | } 63 | } 64 | 65 | return &Nebula{ctrl, l, c}, nil 66 | } 67 | 68 | func (n *Nebula) Log(v string) { 69 | n.l.Println(v) 70 | } 71 | 72 | func (n *Nebula) Start() { 73 | n.c.Start() 74 | } 75 | 76 | func (n *Nebula) ShutdownBlock() { 77 | n.c.ShutdownBlock() 78 | } 79 | 80 | func (n *Nebula) Stop() { 81 | n.c.Stop() 82 | } 83 | 84 | func (n *Nebula) Rebind(reason string) { 85 | n.l.Debugf("Rebinding UDP listener and updating lighthouses due to %s", reason) 86 | n.c.RebindUDPServer() 87 | } 88 | 89 | func (n *Nebula) Reload(configData string, key string) error { 90 | n.l.Info("Reloading Nebula") 91 | yamlConfig, err := RenderConfig(configData, key) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | return n.config.ReloadConfigString(yamlConfig) 97 | } 98 | 99 | func (n *Nebula) ListHostmap(pending bool) (string, error) { 100 | hosts := n.c.ListHostmapHosts(pending) 101 | b, err := json.Marshal(hosts) 102 | if err != nil { 103 | return "", err 104 | } 105 | 106 | return string(b), nil 107 | } 108 | 109 | func (n *Nebula) ListIndexes(pending bool) (string, error) { 110 | indexes := n.c.ListHostmapIndexes(pending) 111 | b, err := json.Marshal(indexes) 112 | if err != nil { 113 | return "", err 114 | } 115 | 116 | return string(b), nil 117 | } 118 | 119 | func (n *Nebula) GetHostInfoByVpnIp(vpnIp string, pending bool) (string, error) { 120 | netVpnIp, err := netip.ParseAddr(vpnIp) 121 | if err != nil { 122 | return "", err 123 | } 124 | 125 | b, err := json.Marshal(n.c.GetHostInfoByVpnAddr(netVpnIp, pending)) 126 | if err != nil { 127 | return "", err 128 | } 129 | 130 | return string(b), nil 131 | } 132 | 133 | func (n *Nebula) CloseTunnel(vpnIp string) bool { 134 | netVpnIp, err := netip.ParseAddr(vpnIp) 135 | if err != nil { 136 | return false 137 | } 138 | 139 | return n.c.CloseTunnel(netVpnIp, false) 140 | } 141 | 142 | func (n *Nebula) SetRemoteForTunnel(vpnIp string, addr string) (string, error) { 143 | udpAddr, err := netip.ParseAddrPort(addr) 144 | if err != nil { 145 | return "", errors.New("could not parse udp address") 146 | } 147 | 148 | netVpnIp, err := netip.ParseAddr(vpnIp) 149 | if err != nil { 150 | return "", errors.New("could not parse vpnIp") 151 | } 152 | 153 | b, err := json.Marshal(n.c.SetRemoteForTunnel(netVpnIp, udpAddr)) 154 | if err != nil { 155 | return "", err 156 | } 157 | 158 | return string(b), nil 159 | } 160 | 161 | func (n *Nebula) Sleep() { 162 | if closed := n.c.CloseAllTunnels(true); closed > 0 { 163 | n.l.WithField("tunnels", closed).Info("Sleep called, closed non lighthouse tunnels") 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /nebula/site.go: -------------------------------------------------------------------------------- 1 | package mobileNebula 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/DefinedNet/dnapi/keys" 8 | "gopkg.in/yaml.v2" 9 | ) 10 | 11 | // Site represents an IncomingSite in Kotlin/Swift. 12 | type site struct { 13 | Name string `json:"name"` 14 | ID string `json:"id"` 15 | StaticHostmap map[string]staticHost `json:"staticHostmap"` 16 | UnsafeRoutes *[]unsafeRoute `json:"unsafeRoutes"` 17 | Cert string `json:"cert"` 18 | CA string `json:"ca"` 19 | LHDuration int `json:"lhDuration"` 20 | Port int `json:"port"` 21 | MTU *int `json:"mtu"` 22 | Cipher string `json:"cipher"` 23 | SortKey *int `json:"sortKey"` 24 | LogVerbosity *string `json:"logVerbosity"` 25 | Key *string `json:"key"` 26 | Managed jsonTrue `json:"managed"` 27 | LastManagedUpdate *time.Time `json:"lastManagedUpdate"` 28 | RawConfig *string `json:"rawConfig"` 29 | DNCredentials *dnCredentials `json:"dnCredentials"` 30 | } 31 | 32 | type staticHost struct { 33 | Lighthouse bool `json:"lighthouse"` 34 | Destinations []string `json:"destinations"` 35 | } 36 | 37 | type unsafeRoute struct { 38 | Route string `json:"route"` 39 | Via string `json:"via"` 40 | MTU *int `json:"mtu"` 41 | } 42 | 43 | type dnCredentials struct { 44 | HostID string `json:"hostID"` 45 | PrivateKey string `json:"privateKey"` 46 | Counter int `json:"counter"` 47 | TrustedKeys string `json:"trustedKeys"` 48 | } 49 | 50 | // jsonTrue always marshals to true. 51 | type jsonTrue bool 52 | 53 | func (f jsonTrue) MarshalJSON() ([]byte, error) { 54 | return json.Marshal(true) 55 | } 56 | 57 | func newDNSite(name string, rawCfg []byte, key string, creds keys.Credentials) (*site, error) { 58 | // Convert YAML Nebula config to a JSON Site 59 | var cfg config 60 | if err := yaml.Unmarshal(rawCfg, &cfg); err != nil { 61 | return nil, err 62 | } 63 | 64 | strCfg := string(rawCfg) 65 | 66 | // build static hostmap 67 | shm := map[string]staticHost{} 68 | for vpnIP, remoteIPs := range cfg.StaticHostmap { 69 | sh := staticHost{Destinations: remoteIPs} 70 | shm[vpnIP] = sh 71 | } 72 | for _, vpnIP := range cfg.Lighthouse.Hosts { 73 | if sh, ok := shm[vpnIP]; ok { 74 | sh.Lighthouse = true 75 | shm[vpnIP] = sh 76 | } else { 77 | shm[vpnIP] = staticHost{Lighthouse: true} 78 | } 79 | } 80 | 81 | // build unsafe routes 82 | ur := []unsafeRoute{} 83 | for _, canon := range cfg.Tun.UnsafeRoutes { 84 | ur = append(ur, unsafeRoute{ 85 | Route: canon.Route, 86 | Via: canon.Via, 87 | MTU: canon.MTU, 88 | }) 89 | } 90 | 91 | // log verbosity is nullable 92 | var logVerb *string 93 | if cfg.Logging.Level != "" { 94 | v := cfg.Logging.Level 95 | logVerb = &v 96 | } 97 | 98 | // TODO the mobile app requires an explicit cipher or it will display an error 99 | cipher := cfg.Cipher 100 | if cipher == "" { 101 | cipher = "aes" 102 | } 103 | 104 | now := time.Now() 105 | 106 | pkm, err := creds.PrivateKey.MarshalPEM() 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | tkm, err := keys.TrustedKeysToPEM(creds.TrustedKeys) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | return &site{ 117 | Name: name, 118 | ID: creds.HostID, 119 | StaticHostmap: shm, 120 | UnsafeRoutes: &ur, 121 | Cert: cfg.PKI.Cert, 122 | CA: cfg.PKI.CA, 123 | LHDuration: cfg.Lighthouse.Interval, 124 | Port: cfg.Listen.Port, 125 | MTU: cfg.Tun.MTU, 126 | Cipher: cipher, 127 | SortKey: nil, 128 | LogVerbosity: logVerb, 129 | Key: &key, 130 | Managed: true, 131 | LastManagedUpdate: &now, 132 | RawConfig: &strCfg, 133 | DNCredentials: &dnCredentials{ 134 | HostID: creds.HostID, 135 | PrivateKey: string(pkm), 136 | Counter: int(creds.Counter), 137 | TrustedKeys: string(tkm), 138 | }, 139 | }, nil 140 | } 141 | -------------------------------------------------------------------------------- /lib/components/SpecialButton.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | // This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to 7 | class SpecialButton extends StatefulWidget { 8 | const SpecialButton({ 9 | super.key, 10 | this.child, 11 | this.color, 12 | this.onPressed, 13 | this.useButtonTheme = false, 14 | this.decoration, 15 | }); 16 | 17 | final Widget? child; 18 | final Color? color; 19 | final bool useButtonTheme; 20 | final BoxDecoration? decoration; 21 | 22 | final GestureTapCallback? onPressed; 23 | 24 | @override 25 | _SpecialButtonState createState() => _SpecialButtonState(); 26 | } 27 | 28 | class _SpecialButtonState extends State with SingleTickerProviderStateMixin { 29 | @override 30 | Widget build(BuildContext context) { 31 | return Platform.isAndroid ? _buildAndroid() : _buildGeneric(); 32 | } 33 | 34 | Widget _buildAndroid() { 35 | TextStyle? textStyle; 36 | if (widget.useButtonTheme) { 37 | textStyle = Theme.of(context).textTheme.labelLarge; 38 | } 39 | 40 | return Material( 41 | textStyle: textStyle, 42 | child: Ink( 43 | decoration: widget.decoration, 44 | color: widget.color, 45 | child: InkWell(onTap: widget.onPressed, child: widget.child), 46 | ), 47 | ); 48 | } 49 | 50 | Widget _buildGeneric() { 51 | var textStyle = CupertinoTheme.of(context).textTheme.textStyle; 52 | if (widget.useButtonTheme) { 53 | textStyle = CupertinoTheme.of(context).textTheme.actionTextStyle; 54 | } 55 | 56 | return Container( 57 | decoration: widget.decoration, 58 | child: GestureDetector( 59 | behavior: HitTestBehavior.opaque, 60 | onTapDown: _handleTapDown, 61 | onTapUp: _handleTapUp, 62 | onTapCancel: _handleTapCancel, 63 | onTap: widget.onPressed, 64 | child: Semantics( 65 | button: true, 66 | child: FadeTransition( 67 | opacity: _opacityAnimation!, 68 | child: DefaultTextStyle(style: textStyle, child: Container(color: widget.color, child: widget.child)), 69 | ), 70 | ), 71 | ), 72 | ); 73 | } 74 | 75 | // Eyeballed values. Feel free to tweak. 76 | static const Duration kFadeOutDuration = Duration(milliseconds: 10); 77 | static const Duration kFadeInDuration = Duration(milliseconds: 100); 78 | final Tween _opacityTween = Tween(begin: 1.0); 79 | 80 | AnimationController? _animationController; 81 | Animation? _opacityAnimation; 82 | 83 | @override 84 | void initState() { 85 | super.initState(); 86 | _animationController = AnimationController(duration: const Duration(milliseconds: 200), value: 0.0, vsync: this); 87 | _opacityAnimation = _animationController!.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween); 88 | _setTween(); 89 | } 90 | 91 | @override 92 | void didUpdateWidget(SpecialButton old) { 93 | super.didUpdateWidget(old); 94 | _setTween(); 95 | } 96 | 97 | void _setTween() { 98 | _opacityTween.end = 0.4; 99 | } 100 | 101 | @override 102 | void dispose() { 103 | _animationController?.dispose(); 104 | super.dispose(); 105 | } 106 | 107 | bool _buttonHeldDown = false; 108 | 109 | void _handleTapDown(TapDownDetails event) { 110 | if (!_buttonHeldDown) { 111 | _buttonHeldDown = true; 112 | _animate(); 113 | } 114 | } 115 | 116 | void _handleTapUp(TapUpDetails event) { 117 | if (_buttonHeldDown) { 118 | _buttonHeldDown = false; 119 | _animate(); 120 | } 121 | } 122 | 123 | void _handleTapCancel() { 124 | if (_buttonHeldDown) { 125 | _buttonHeldDown = false; 126 | _animate(); 127 | } 128 | } 129 | 130 | void _animate() { 131 | if (_animationController == null || _animationController!.isAnimating) { 132 | return; 133 | } 134 | 135 | final bool wasHeldDown = _buttonHeldDown; 136 | final TickerFuture ticker = 137 | _buttonHeldDown 138 | ? _animationController!.animateTo(1.0, duration: kFadeOutDuration) 139 | : _animationController!.animateTo(0.0, duration: kFadeInDuration); 140 | 141 | ticker.then((void value) { 142 | if (mounted && wasHeldDown != _buttonHeldDown) { 143 | _animate(); 144 | } 145 | }); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 11 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 33 | 39 | 40 | 41 | 42 | 43 | 48 | 49 | 55 | 56 | 57 | 58 | 59 | 60 | 71 | 73 | 79 | 80 | 81 | 82 | 88 | 90 | 96 | 97 | 98 | 99 | 101 | 102 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /lib/screens/SettingsScreen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:mobile_nebula/components/SimplePage.dart'; 5 | import 'package:mobile_nebula/components/config/ConfigItem.dart'; 6 | import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; 7 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 8 | import 'package:mobile_nebula/screens/EnrollmentScreen.dart'; 9 | import 'package:mobile_nebula/services/settings.dart'; 10 | import 'package:mobile_nebula/services/utils.dart'; 11 | 12 | import 'AboutScreen.dart'; 13 | 14 | class SettingsScreen extends StatefulWidget { 15 | final StreamController stream; 16 | 17 | const SettingsScreen(this.stream, {super.key}); 18 | 19 | @override 20 | _SettingsScreenState createState() => _SettingsScreenState(); 21 | } 22 | 23 | class _SettingsScreenState extends State { 24 | var settings = Settings(); 25 | 26 | @override 27 | void initState() { 28 | //TODO: we need to unregister on dispose? 29 | settings.onChange().listen((_) { 30 | if (mounted) { 31 | setState(() {}); 32 | } 33 | }); 34 | super.initState(); 35 | } 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | List colorSection = []; 40 | 41 | colorSection.add( 42 | ConfigItem( 43 | label: Text('Use system colors'), 44 | labelWidth: 200, 45 | content: Align( 46 | alignment: Alignment.centerRight, 47 | child: Switch.adaptive( 48 | materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, 49 | onChanged: (value) { 50 | settings.useSystemColors = value; 51 | }, 52 | value: settings.useSystemColors, 53 | ), 54 | ), 55 | ), 56 | ); 57 | 58 | if (!settings.useSystemColors) { 59 | colorSection.add( 60 | ConfigItem( 61 | label: Text('Dark mode'), 62 | content: Align( 63 | alignment: Alignment.centerRight, 64 | child: Switch.adaptive( 65 | materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, 66 | onChanged: (value) { 67 | settings.darkMode = value; 68 | }, 69 | value: settings.darkMode, 70 | ), 71 | ), 72 | ), 73 | ); 74 | } 75 | 76 | List items = []; 77 | items.add(ConfigSection(children: colorSection)); 78 | items.add( 79 | ConfigItem( 80 | label: Text('Wrap log output'), 81 | labelWidth: 200, 82 | content: Align( 83 | alignment: Alignment.centerRight, 84 | child: Switch.adaptive( 85 | materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, 86 | value: settings.logWrap, 87 | onChanged: (value) { 88 | setState(() { 89 | settings.logWrap = value; 90 | }); 91 | }, 92 | ), 93 | ), 94 | ), 95 | ); 96 | 97 | items.add( 98 | ConfigSection( 99 | children: [ 100 | ConfigItem( 101 | label: Text('Report errors automatically'), 102 | labelWidth: 250, 103 | content: Align( 104 | alignment: Alignment.centerRight, 105 | child: Switch.adaptive( 106 | materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, 107 | value: settings.trackErrors, 108 | onChanged: (value) { 109 | setState(() { 110 | settings.trackErrors = value; 111 | }); 112 | }, 113 | ), 114 | ), 115 | ), 116 | ], 117 | ), 118 | ); 119 | 120 | items.add( 121 | ConfigSection( 122 | children: [ 123 | ConfigPageItem( 124 | label: Text('Enroll with Managed Nebula'), 125 | labelWidth: 250, 126 | onPressed: 127 | () => 128 | Utils.openPage(context, (context) => EnrollmentScreen(stream: widget.stream, allowCodeEntry: true)), 129 | ), 130 | ], 131 | ), 132 | ); 133 | 134 | items.add( 135 | ConfigSection( 136 | children: [ 137 | ConfigPageItem(label: Text('About'), onPressed: () => Utils.openPage(context, (context) => AboutScreen())), 138 | ], 139 | ), 140 | ); 141 | 142 | return SimplePage(title: Text('Settings'), child: Column(children: items)); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /ios/Runner/DNUpdate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | class DNUpdater { 5 | private let apiClient = APIClient() 6 | private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes 7 | private let log = Logger(subsystem: "net.defined.mobileNebula", category: "DNUpdater") 8 | 9 | func updateAll(onUpdate: @escaping (Site) -> Void) { 10 | _ = SiteList { (sites, _) -> Void in 11 | // NEVPN seems to force us onto the main thread and we are about to make network calls that 12 | // could block for a while. Push ourselves onto another thread to avoid blocking the UI. 13 | Task.detached(priority: .userInitiated) { 14 | sites?.values.forEach { site in 15 | if site.connected == true { 16 | // The vpn service is in charge of updating the currently connected site 17 | return 18 | } 19 | 20 | self.updateSite(site: site, onUpdate: onUpdate) 21 | } 22 | } 23 | } 24 | } 25 | 26 | func updateAllLoop(onUpdate: @escaping (Site) -> Void) { 27 | timer.eventHandler = { 28 | self.updateAll(onUpdate: onUpdate) 29 | } 30 | timer.resume() 31 | } 32 | 33 | func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> Void) { 34 | timer.eventHandler = { 35 | self.updateSite(site: site, onUpdate: onUpdate) 36 | } 37 | timer.resume() 38 | } 39 | 40 | func updateSite(site: Site, onUpdate: @escaping (Site) -> Void) { 41 | do { 42 | if !site.managed { 43 | return 44 | } 45 | 46 | let credentials = try site.getDNCredentials() 47 | 48 | let newSite: IncomingSite? 49 | do { 50 | newSite = try apiClient.tryUpdate( 51 | siteName: site.name, 52 | hostID: credentials.hostID, 53 | privateKey: credentials.privateKey, 54 | counter: credentials.counter, 55 | trustedKeys: credentials.trustedKeys 56 | ) 57 | } catch (APIClientError.invalidCredentials) { 58 | if !credentials.invalid { 59 | try site.invalidateDNCredentials() 60 | log.notice("Invalidated credentials in site: \(site.name, privacy: .public)") 61 | } 62 | 63 | return 64 | } 65 | 66 | let siteManager = site.manager 67 | let shouldSaveToManager = 68 | siteManager != nil 69 | || ProcessInfo().isOperatingSystemAtLeast( 70 | OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)) 71 | 72 | newSite?.save(manager: site.manager, saveToManager: shouldSaveToManager) { error in 73 | if error != nil { 74 | self.log.error("failed to save update: \(error!.localizedDescription, privacy: .public)") 75 | } 76 | 77 | // reload nebula even if we couldn't save the vpn profile 78 | onUpdate(Site(incoming: newSite!)) 79 | } 80 | 81 | if credentials.invalid { 82 | try site.validateDNCredentials() 83 | log.notice("Revalidated credentials in site \(site.name, privacy: .public)") 84 | } 85 | 86 | } catch { 87 | log.error( 88 | "Error while updating \(site.name, privacy: .public): \(error.localizedDescription, privacy: .public)" 89 | ) 90 | } 91 | } 92 | } 93 | 94 | // From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9 95 | class RepeatingTimer { 96 | 97 | let timeInterval: TimeInterval 98 | 99 | init(timeInterval: TimeInterval) { 100 | self.timeInterval = timeInterval 101 | } 102 | 103 | private lazy var timer: any DispatchSourceTimer = { 104 | let t = DispatchSource.makeTimerSource() 105 | t.schedule(deadline: .now(), repeating: self.timeInterval) 106 | t.setEventHandler(handler: { [weak self] in 107 | self?.eventHandler?() 108 | }) 109 | return t 110 | }() 111 | 112 | var eventHandler: (() -> Void)? 113 | 114 | private enum State { 115 | case suspended 116 | case resumed 117 | } 118 | 119 | private var state: State = .suspended 120 | 121 | deinit { 122 | timer.setEventHandler {} 123 | timer.cancel() 124 | /* 125 | If the timer is suspended, calling cancel without resuming 126 | triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902 127 | */ 128 | resume() 129 | eventHandler = nil 130 | } 131 | 132 | func resume() { 133 | if state == .resumed { 134 | return 135 | } 136 | state = .resumed 137 | timer.resume() 138 | } 139 | 140 | func suspend() { 141 | if state == .suspended { 142 | return 143 | } 144 | state = .suspended 145 | timer.suspend() 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/net/defined/mobile_nebula/DNUpdateWorker.kt: -------------------------------------------------------------------------------- 1 | package net.defined.mobile_nebula 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.util.Log 6 | import androidx.work.Worker 7 | import androidx.work.WorkerParameters 8 | import java.io.Closeable 9 | import java.nio.channels.FileChannel 10 | import java.nio.file.Paths 11 | import java.nio.file.StandardOpenOption 12 | 13 | class DNUpdateWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) { 14 | 15 | companion object { 16 | private const val TAG = "DNUpdateWorker" 17 | } 18 | 19 | private val context = applicationContext 20 | private val apiClient: APIClient = APIClient(ctx) 21 | private val updater = DNSiteUpdater(context, apiClient) 22 | private val sites = SiteList(context) 23 | 24 | override fun doWork(): Result { 25 | var failed = false 26 | 27 | sites.getSites().values.forEach { site -> 28 | try { 29 | updateSite(site) 30 | } catch (e: Exception) { 31 | failed = true 32 | Log.e(TAG, "Error while updating site ${site.id}: ${e.stackTraceToString()}") 33 | return@forEach 34 | } 35 | } 36 | 37 | return if (failed) Result.failure() else Result.success() 38 | } 39 | 40 | private fun updateSite(site: Site) { 41 | try { 42 | DNUpdateLock(site).use { 43 | val res = updater.updateSite(site) 44 | 45 | // Reload Nebula if this is the currently active site 46 | if (res == DNSiteUpdater.Result.CONFIG_UPDATED) { 47 | Intent().also { intent -> 48 | intent.setPackage(context.getPackageName()) 49 | intent.action = NebulaVpnService.ACTION_RELOAD 50 | intent.putExtra("id", site.id) 51 | context.sendBroadcast(intent) 52 | } 53 | } 54 | 55 | // Update the UI on any change 56 | if (res != DNSiteUpdater.Result.NOOP) { 57 | Intent().also { intent -> 58 | intent.setPackage(context.getPackageName()) 59 | intent.action = MainActivity.ACTION_REFRESH_SITES 60 | context.sendBroadcast(intent) 61 | } 62 | } 63 | } 64 | } catch (e: java.nio.channels.OverlappingFileLockException) { 65 | Log.w(TAG, "Can't lock site ${site.name}, skipping it...") 66 | } 67 | } 68 | } 69 | 70 | class DNUpdateLock(site: Site): Closeable { 71 | private val fileChannel = FileChannel.open( 72 | Paths.get(site.path+"/update.lock"), 73 | StandardOpenOption.CREATE, 74 | StandardOpenOption.WRITE, 75 | ) 76 | private val fileLock = fileChannel.tryLock() 77 | 78 | override fun close() { 79 | fileLock.close() 80 | fileChannel.close() 81 | } 82 | } 83 | 84 | class DNSiteUpdater( 85 | private val context: Context, 86 | private val apiClient: APIClient, 87 | ) { 88 | enum class Result { 89 | CONFIG_UPDATED, CREDENTIALS_UPDATED, NOOP 90 | } 91 | 92 | fun updateSite(site: Site): Result { 93 | if (!site.managed) { 94 | return Result.NOOP 95 | } 96 | 97 | val credentials = site.getDNCredentials(context) 98 | 99 | val newSite: IncomingSite? 100 | try { 101 | newSite = apiClient.tryUpdate( 102 | site.name, 103 | credentials.hostID, 104 | credentials.privateKey, 105 | credentials.counter.toLong(), 106 | credentials.trustedKeys, 107 | ) 108 | } catch (e: InvalidCredentialsException) { 109 | if (!credentials.invalid) { 110 | site.invalidateDNCredentials(context) 111 | Log.d(TAG, "Invalidated credentials in site ${site.name}") 112 | return Result.CREDENTIALS_UPDATED 113 | } 114 | return Result.NOOP 115 | } 116 | 117 | if (newSite != null) { 118 | newSite.save(context) 119 | Log.d(TAG, "Updated site ${site.id}: ${site.name}") 120 | return Result.CONFIG_UPDATED 121 | } 122 | 123 | if (credentials.invalid) { 124 | site.validateDNCredentials(context) 125 | Log.d(TAG, "Revalidated credentials in site ${site.id}: ${site.name}") 126 | return Result.CREDENTIALS_UPDATED 127 | } 128 | 129 | return Result.NOOP 130 | } 131 | } -------------------------------------------------------------------------------- /lib/screens/siteConfig/UnsafeRouteScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:mobile_nebula/components/CIDRFormField.dart'; 3 | import 'package:mobile_nebula/components/DangerButton.dart'; 4 | import 'package:mobile_nebula/components/FormPage.dart'; 5 | import 'package:mobile_nebula/components/IPFormField.dart'; 6 | import 'package:mobile_nebula/components/config/ConfigItem.dart'; 7 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 8 | import 'package:mobile_nebula/models/CIDR.dart'; 9 | import 'package:mobile_nebula/models/UnsafeRoute.dart'; 10 | import 'package:mobile_nebula/services/utils.dart'; 11 | 12 | class UnsafeRouteScreen extends StatefulWidget { 13 | const UnsafeRouteScreen({super.key, required this.route, required this.onSave, this.onDelete}); 14 | 15 | final UnsafeRoute route; 16 | final ValueChanged onSave; 17 | final Function? onDelete; 18 | 19 | @override 20 | _UnsafeRouteScreenState createState() => _UnsafeRouteScreenState(); 21 | } 22 | 23 | class _UnsafeRouteScreenState extends State { 24 | late UnsafeRoute route; 25 | bool changed = false; 26 | 27 | FocusNode routeFocus = FocusNode(); 28 | FocusNode viaFocus = FocusNode(); 29 | FocusNode mtuFocus = FocusNode(); 30 | 31 | @override 32 | void initState() { 33 | route = widget.route; 34 | super.initState(); 35 | } 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | var routeCIDR = route.route == null ? CIDR() : CIDR.fromString(route.route!); 40 | 41 | return FormPage( 42 | title: widget.onDelete == null ? 'New Unsafe Route' : 'Edit Unsafe Route', 43 | changed: changed, 44 | onSave: _onSave, 45 | child: Column( 46 | children: [ 47 | ConfigSection( 48 | children: [ 49 | ConfigItem( 50 | label: Text('Route'), 51 | content: CIDRFormField( 52 | initialValue: routeCIDR, 53 | textInputAction: TextInputAction.next, 54 | focusNode: routeFocus, 55 | nextFocusNode: viaFocus, 56 | onSaved: (v) { 57 | route.route = v.toString(); 58 | }, 59 | ), 60 | ), 61 | ConfigItem( 62 | label: Text('Via'), 63 | content: IPFormField( 64 | initialValue: route.via ?? '', 65 | ipOnly: true, 66 | help: 'nebula ip', 67 | textAlign: TextAlign.end, 68 | crossAxisAlignment: CrossAxisAlignment.end, 69 | textInputAction: TextInputAction.next, 70 | focusNode: viaFocus, 71 | nextFocusNode: mtuFocus, 72 | onSaved: (v) { 73 | if (v != null) { 74 | route.via = v; 75 | } 76 | }, 77 | ), 78 | ), 79 | //TODO: Android doesn't appear to support route based MTU, figure this out 80 | // ConfigItem( 81 | // label: Text('MTU'), 82 | // content: PlatformTextFormField( 83 | // placeholder: "", 84 | // validator: mtuValidator(false), 85 | // keyboardType: TextInputType.number, 86 | // inputFormatters: [WhitelistingTextInputFormatter.digitsOnly], 87 | // initialValue: route?.mtu.toString(), 88 | // textAlign: TextAlign.end, 89 | // textInputAction: TextInputAction.done, 90 | // focusNode: mtuFocus, 91 | // onSaved: (v) { 92 | // route.mtu = int.tryParse(v); 93 | // })), 94 | ], 95 | ), 96 | widget.onDelete != null 97 | ? Padding( 98 | padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10), 99 | child: SizedBox( 100 | width: double.infinity, 101 | child: DangerButton( 102 | child: Text('Delete'), 103 | onPressed: 104 | () => Utils.confirmDelete(context, 'Delete unsafe route?', () { 105 | Navigator.of(context).pop(); 106 | widget.onDelete!(); 107 | }), 108 | ), 109 | ), 110 | ) 111 | : Container(), 112 | ], 113 | ), 114 | ); 115 | } 116 | 117 | _onSave() { 118 | Navigator.pop(context); 119 | widget.onSave(route); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /lib/components/IPFormField.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:mobile_nebula/validators/dnsValidator.dart'; 3 | import 'package:mobile_nebula/validators/ipValidator.dart'; 4 | 5 | import 'IPField.dart'; 6 | 7 | //TODO: reset doesn't update the ui but clears the field 8 | 9 | class IPFormField extends FormField { 10 | //TODO: validator, auto-validate, enabled? 11 | IPFormField({ 12 | super.key, 13 | ipOnly = false, 14 | enableIPV6 = false, 15 | help = "ip address", 16 | autoFocus = false, 17 | focusNode, 18 | nextFocusNode, 19 | ValueChanged? onChanged, 20 | super.onSaved, 21 | textPadding = const EdgeInsets.all(6.0), 22 | textInputAction, 23 | initialValue, 24 | this.controller, 25 | crossAxisAlignment = CrossAxisAlignment.center, 26 | textAlign = TextAlign.center, 27 | }) : super( 28 | initialValue: initialValue, 29 | validator: (ip) { 30 | if (ip == null || ip == "") { 31 | return "Please fill out this field"; 32 | } 33 | 34 | if (!ipValidator(ip, enableIPV6) || (!ipOnly && !dnsValidator(ip))) { 35 | print(ip); 36 | return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name'; 37 | } 38 | 39 | return null; 40 | }, 41 | builder: (FormFieldState field) { 42 | final _IPFormField state = field as _IPFormField; 43 | 44 | void onChangedHandler(String value) { 45 | if (onChanged != null) { 46 | onChanged(value); 47 | } 48 | field.didChange(value); 49 | } 50 | 51 | return Column( 52 | crossAxisAlignment: crossAxisAlignment, 53 | children: [ 54 | IPField( 55 | ipOnly: ipOnly, 56 | help: help, 57 | autoFocus: autoFocus, 58 | focusNode: focusNode, 59 | nextFocusNode: nextFocusNode, 60 | onChanged: onChangedHandler, 61 | textPadding: textPadding, 62 | textInputAction: textInputAction, 63 | controller: state._effectiveController, 64 | textAlign: textAlign, 65 | ), 66 | field.hasError 67 | ? Text( 68 | field.errorText!, 69 | style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13), 70 | textAlign: textAlign, 71 | ) 72 | : Container(height: 0), 73 | ], 74 | ); 75 | }, 76 | ); 77 | 78 | final TextEditingController? controller; 79 | 80 | @override 81 | _IPFormField createState() => _IPFormField(); 82 | } 83 | 84 | class _IPFormField extends FormFieldState { 85 | TextEditingController? _controller; 86 | 87 | TextEditingController get _effectiveController => widget.controller ?? _controller!; 88 | 89 | @override 90 | IPFormField get widget => super.widget as IPFormField; 91 | 92 | @override 93 | void initState() { 94 | super.initState(); 95 | if (widget.controller == null) { 96 | _controller = TextEditingController(text: widget.initialValue); 97 | } else { 98 | widget.controller!.addListener(_handleControllerChanged); 99 | } 100 | } 101 | 102 | @override 103 | void didUpdateWidget(IPFormField oldWidget) { 104 | super.didUpdateWidget(oldWidget); 105 | if (widget.controller != oldWidget.controller) { 106 | oldWidget.controller?.removeListener(_handleControllerChanged); 107 | widget.controller?.addListener(_handleControllerChanged); 108 | 109 | if (oldWidget.controller != null && widget.controller == null) { 110 | _controller = TextEditingController.fromValue(oldWidget.controller!.value); 111 | } 112 | if (widget.controller != null) { 113 | setValue(widget.controller!.text); 114 | if (oldWidget.controller == null) _controller = null; 115 | } 116 | } 117 | } 118 | 119 | @override 120 | void dispose() { 121 | widget.controller?.removeListener(_handleControllerChanged); 122 | super.dispose(); 123 | } 124 | 125 | @override 126 | void reset() { 127 | super.reset(); 128 | setState(() { 129 | _effectiveController.text = widget.initialValue ?? ""; 130 | }); 131 | } 132 | 133 | void _handleControllerChanged() { 134 | // Suppress changes that originated from within this class. 135 | // 136 | // In the case where a controller has been passed in to this widget, we 137 | // register this change listener. In these cases, we'll also receive change 138 | // notifications for changes originating from within this class -- for 139 | // example, the reset() method. In such cases, the FormField value will 140 | // already have been set. 141 | if (_effectiveController.text != value) didChange(_effectiveController.text); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /lib/screens/SiteTunnelsScreen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:mobile_nebula/components/SimplePage.dart'; 4 | import 'package:mobile_nebula/components/config/ConfigPageItem.dart'; 5 | import 'package:mobile_nebula/components/config/ConfigSection.dart'; 6 | import 'package:mobile_nebula/models/HostInfo.dart'; 7 | import 'package:mobile_nebula/models/Site.dart'; 8 | import 'package:mobile_nebula/screens/HostInfoScreen.dart'; 9 | import 'package:mobile_nebula/services/utils.dart'; 10 | import 'package:pull_to_refresh/pull_to_refresh.dart'; 11 | 12 | class SiteTunnelsScreen extends StatefulWidget { 13 | const SiteTunnelsScreen({ 14 | super.key, 15 | required this.site, 16 | required this.tunnels, 17 | required this.pending, 18 | required this.onChanged, 19 | required this.supportsQRScanning, 20 | }); 21 | 22 | final Site site; 23 | final List tunnels; 24 | final bool pending; 25 | final Function(List)? onChanged; 26 | 27 | final bool supportsQRScanning; 28 | 29 | @override 30 | _SiteTunnelsScreenState createState() => _SiteTunnelsScreenState(); 31 | } 32 | 33 | class _SiteTunnelsScreenState extends State { 34 | late Site site; 35 | late List tunnels; 36 | RefreshController refreshController = RefreshController(initialRefresh: false); 37 | 38 | @override 39 | void initState() { 40 | site = widget.site; 41 | tunnels = widget.tunnels; 42 | _sortTunnels(); 43 | super.initState(); 44 | } 45 | 46 | @override 47 | void dispose() { 48 | refreshController.dispose(); 49 | super.dispose(); 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | final List children = 55 | tunnels.map((hostInfo) { 56 | final isLh = _isLighthouse(hostInfo.vpnAddrs); 57 | final icon = switch (isLh) { 58 | true => Icon(Icons.lightbulb_outline, color: CupertinoColors.placeholderText.resolveFrom(context)), 59 | false => Icon(Icons.computer, color: CupertinoColors.placeholderText.resolveFrom(context)), 60 | }; 61 | 62 | return (ConfigPageItem( 63 | onPressed: 64 | () => Utils.openPage( 65 | context, 66 | (context) => HostInfoScreen( 67 | isLighthouse: isLh, 68 | hostInfo: hostInfo, 69 | pending: widget.pending, 70 | site: widget.site, 71 | onChanged: () { 72 | _listHostmap(); 73 | }, 74 | supportsQRScanning: widget.supportsQRScanning, 75 | ), 76 | ), 77 | content: Container( 78 | alignment: Alignment.centerLeft, 79 | child: Row( 80 | children: [ 81 | Padding(padding: EdgeInsets.only(right: 10), child: icon), 82 | Text(hostInfo.cert?.name ?? hostInfo.vpnAddrs[0]), 83 | ], 84 | ), 85 | ), 86 | )); 87 | }).toList(); 88 | 89 | final Widget child = switch (children.length) { 90 | 0 => Center(child: Padding(padding: EdgeInsets.only(top: 30), child: Text('No tunnels to show'))), 91 | _ => ConfigSection(children: children), 92 | }; 93 | 94 | final title = widget.pending ? 'Pending' : 'Active'; 95 | 96 | return SimplePage( 97 | title: Text('$title Tunnels'), 98 | refreshController: refreshController, 99 | onRefresh: () async { 100 | await _listHostmap(); 101 | refreshController.refreshCompleted(); 102 | }, 103 | child: child, 104 | ); 105 | } 106 | 107 | _sortTunnels() { 108 | tunnels.sort((a, b) { 109 | final aLh = _isLighthouse(a.vpnAddrs), bLh = _isLighthouse(b.vpnAddrs); 110 | 111 | if (aLh && !bLh) { 112 | return -1; 113 | } else if (!aLh && bLh) { 114 | return 1; 115 | } 116 | 117 | final aName = a.cert?.name ?? ""; 118 | final bName = b.cert?.name ?? ""; 119 | final name = aName.compareTo(bName); 120 | if (name != 0) { 121 | return name; 122 | } 123 | 124 | return a.vpnAddrs[0].compareTo(b.vpnAddrs[0]); 125 | }); 126 | } 127 | 128 | bool _isLighthouse(List vpnAddrs) { 129 | var isLh = false; 130 | for (var vpnAddr in vpnAddrs) { 131 | if (site.staticHostmap[vpnAddr]?.lighthouse ?? false) { 132 | isLh = true; 133 | break; 134 | } 135 | } 136 | return isLh; 137 | } 138 | 139 | _listHostmap() async { 140 | try { 141 | if (widget.pending) { 142 | tunnels = await site.listPendingHostmap(); 143 | } else { 144 | tunnels = await site.listHostmap(); 145 | } 146 | 147 | _sortTunnels(); 148 | if (widget.onChanged != null) { 149 | widget.onChanged!(tunnels); 150 | } 151 | setState(() {}); 152 | } catch (err) { 153 | Utils.popError(context, 'Error while fetching hostmap', err.toString()); 154 | } 155 | } 156 | } 157 | --------------------------------------------------------------------------------