diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8776b72b..94bfa531 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,9 @@ jobs: echo "BUILD_NAME=$BUILD_NAME" >> $GITHUB_ENV - name: flutter test - run: flutter test + run: | + ./gen-artifacts.sh skip + flutter test - name: Build iOS env: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index ab8a28b3..70d48415 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -46,7 +46,9 @@ jobs: touch env.sh - name: flutter test - run: flutter test + run: | + ./gen-artifacts.sh skip + flutter test - name: Build Android debug run: flutter build appbundle --debug @@ -123,7 +125,9 @@ jobs: touch env.sh - name: flutter test - run: flutter test + run: | + ./gen-artifacts.sh skip + flutter test - name: Build iOS run: | diff --git a/gen-artifacts.sh b/gen-artifacts.sh index 3374a14f..d177b84e 100755 --- a/gen-artifacts.sh +++ b/gen-artifacts.sh @@ -20,7 +20,7 @@ elif [ "$1" = "android" ]; then rm -rf ../android/mobileNebula/mobileNebula.aar cp mobileNebula.aar ../android/mobileNebula/mobileNebula.aar -else +elif [ "$1" != "skip" ]; then echo "Error: unsupported target os $1" exit 1 fi diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 2194383c..6058c017 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -44,6 +44,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> json) : cert = Certificate.fromJson(json['Cert']), @@ -34,50 +36,50 @@ class Certificate { Certificate.debug() : version = 2, - name = "DEBUG", + name = 'DEBUG', networks = [], unsafeNetworks = [], groups = [], isCa = false, notBefore = DateTime.now(), notAfter = DateTime.now(), - issuer = "DEBUG", - publicKey = "", - curve = "", - fingerprint = "DEBUG", - signature = "DEBUG"; + issuer = 'DEBUG', + publicKey = '', + curve = '', + fingerprint = 'DEBUG', + signature = 'DEBUG'; factory Certificate.fromJson(Map json) { Map details = json; String publicKey; String curve; - if (json.containsKey("details")) { - details = json["details"]; + if (json.containsKey('details')) { + details = json['details']; //TODO: currently swift and kotlin flatten the certificate structure but // nebula outputs cert json in the nested format - switch (json["version"]) { + switch (json['version']) { case 1: // In V1 the public key was under details - publicKey = details["publicKey"]; - curve = details["curve"]; + publicKey = details['publicKey']; + curve = details['curve']; break; case 2: // In V2 the public key moved to the top level - publicKey = json["publicKey"]; - curve = json["curve"]; + publicKey = json['publicKey']; + curve = json['curve']; break; default: - throw Exception('Unknown certificate version'); + throw ParseError('unknown certificate version: ${json['version']}'); } } else { // This is a flattened certificate format, publicKey is at the top - publicKey = json["publicKey"]; - curve = json["curve"]; + publicKey = json['publicKey']; + curve = json['curve']; } return Certificate( - json["version"], - details["name"], + json['version'], + details['name'], List.from(details['networks'] ?? []), List.from(details['unsafeNetworks'] ?? []), List.from(details['groups'] ?? []), @@ -113,7 +115,7 @@ class CertificateValidity { bool valid; String reason; - CertificateValidity.debug() : valid = true, reason = ""; + CertificateValidity.debug() : valid = true, reason = ''; CertificateValidity.fromJson(Map json) : valid = json['Valid'], reason = json['Reason']; } diff --git a/lib/models/site.dart b/lib/models/site.dart index 8382dab6..2538926b 100644 --- a/lib/models/site.dart +++ b/lib/models/site.dart @@ -3,9 +3,14 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; +import 'package:mobile_nebula/errors/parse_error.dart'; import 'package:mobile_nebula/models/hostinfo.dart'; +import 'package:mobile_nebula/models/ip_and_port.dart'; import 'package:mobile_nebula/models/unsafe_route.dart'; +import 'package:mobile_nebula/services/utils.dart'; +import 'package:mobile_nebula/validators/ip_validator.dart'; import 'package:uuid/uuid.dart'; +import 'package:yaml/yaml.dart'; import 'certificate.dart'; import 'static_hosts.dart'; @@ -13,6 +18,9 @@ import 'static_hosts.dart'; var uuid = Uuid(); final _log = Logger('site'); +final _validLogLevels = ['panic', 'fatal', 'error', 'warning', 'info', 'debug']; +final _validCiphers = ['aes', 'chachapoly']; + class Site { static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); late EventChannel _updates; @@ -86,23 +94,28 @@ class Site { this.unsafeRoutes = unsafeRoutes ?? []; this.dnsResolvers = dnsResolvers ?? []; - _updates = EventChannel('net.defined.nebula/${this.id}'); - _updateSubscription = _updates.receiveBroadcastStream().listen( - (d) { - try { - _updateFromJson(d); - _change.add(null); - } catch (err, stackTrace) { - //TODO: handle the error - _log.severe("Got an error on the broadcast stream", err, stackTrace); - } - }, - onError: (err) { - _updateFromJson(err.details); - var error = err as PlatformException; - _change.addError(error.message ?? 'An unexpected error occurred'); - }, - ); + //TODO: I think this plays well with new saved sites because we should be recreating it on the main screen + // However it might not work on the site details page with the logs button. + // Basically we might need to make this a function and have save() call it on success + if (id != null) { + _updates = EventChannel('net.defined.nebula/${this.id}'); + _updateSubscription = _updates.receiveBroadcastStream().listen( + (d) { + try { + _updateFromJson(d); + _change.add(null); + } catch (err, stackTrace) { + //TODO: handle the error + _log.severe("Got an error on the broadcast stream", err, stackTrace); + } + }, + onError: (err) { + _updateFromJson(err.details); + var error = err as PlatformException; + _change.addError(error.message ?? 'An unexpected error occurred'); + }, + ); + } } factory Site.fromJson(Map json) { @@ -131,6 +144,26 @@ class Site { ); } + static Future fromYaml(dynamic yaml) async { + if (yaml is! YamlMap) { + throw ParseError('site config was not a yaml map'); + } + + final site = Site(); + var lighthouses = _fromYamlLighthouse(site, yaml); + _fromYamlStaticHostmap(site, lighthouses, yaml); + _fromYamlUnsafeRoutes(site, yaml); + _fromYamlCipher(site, yaml); + _fromYamlTun(site, yaml); + _fromYamlListen(site, yaml); + _fromYamlLogging(site, yaml); + await _fromYamlPki(site, platform, yaml); + + //TODO: dns resolvers aren't a thing in nebula config today, should we support them here? + //TODO: any lighthouses that weren't added to site.staticHostmap should be added now with 0 destinations + return site; + } + void _updateFromJson(String json) { var decoded = Site._fromJson(jsonDecode(json)); name = decoded["name"]; @@ -398,3 +431,270 @@ class Site { } } } + +List _fromYamlLighthouse(Site site, YamlMap yaml) { + List lighthouses = []; + + if (!yaml.containsKey('lighthouse')) { + return []; + } + + if (yaml['lighthouse'] is! YamlMap) { + site.errors.add('lighthouse was not a yaml map'); + return []; + } + + final yamlLighthouse = yaml['lighthouse'] as YamlMap; + if (yamlLighthouse.containsKey('interval')) { + final (duration, ok) = Utils.dynamicToInt(yamlLighthouse['interval']); + if (ok) { + site.lhDuration = duration; + } else { + site.errors.add('lighthouse.interval could not be parsed as an integer'); + } + } + + if (yamlLighthouse.containsKey('hosts')) { + if (yamlLighthouse['hosts'] is YamlList) { + final yamlLighthouseHosts = yamlLighthouse['hosts'] as YamlList; + for (var s in yamlLighthouseHosts) { + if (s is String) { + final (valid, _) = ipValidator(s); + if (valid) { + lighthouses.add(s); + } else { + site.errors.add('lighthouse.hosts entry was not a valid ip address: $s'); + } + } else { + site.errors.add('lighthouse.hosts entry was not a string: $s'); + } + } + } else { + site.errors.add('lighthouse.hosts was not a yaml list'); + } + } + + return lighthouses; +} + +void _fromYamlStaticHostmap(Site site, List lighthouses, YamlMap yaml) { + if (!yaml.containsKey('static_host_map')) { + return; + } + + if (yaml['static_host_map'] is! YamlMap) { + site.errors.add('static_host_map was not a yaml map'); + return; + } + + final yamlStaticHostMap = yaml['static_host_map'] as YamlMap; + yamlStaticHostMap.forEach((yamlVpnAddr, yamlDestinations) { + String vpnAddr = ''; + if (yamlVpnAddr is String) { + final (valid, _) = ipValidator(yamlVpnAddr); + if (!valid) { + site.errors.add('invalid vpn address in static_host_map: $yamlVpnAddr'); + return; + } + vpnAddr = yamlVpnAddr; + } else { + site.errors.add('static_host_map key was not a string: $yamlVpnAddr'); + return; + } + + List destinations = []; + if (yamlDestinations is YamlList) { + for (var hostPort in yamlDestinations) { + if (hostPort is String) { + try { + destinations.add(IPAndPort.fromString(hostPort)); + } on ParseError catch (err) { + site.errors.add('static_host_map destination $hostPort for $vpnAddr was not valid: ${err.message}'); + } + } else { + site.errors.add('static_host_map destination for $vpnAddr was not a string: $hostPort'); + } + } + } else { + site.errors.add('static_host_map destinations for $vpnAddr was not a list of strings'); + } + + site.staticHostmap[vpnAddr] = StaticHost(lighthouse: lighthouses.contains(vpnAddr), destinations: destinations); + }); +} + +Future _fromYamlPki(Site site, MethodChannel platform, YamlMap yaml) async { + if (!yaml.containsKey('pki')) { + return; + } + + if (yaml['pki'] is! YamlMap) { + site.errors.add('pki was not a yaml map'); + return; + } + + final yamlPki = yaml['pki'] as YamlMap; + if (yamlPki.containsKey('key')) { + if (yamlPki['key'] is String) { + site.key = yamlPki['key'] as String; + } else { + site.errors.add('pki.key was not a string'); + } + } + + if (yamlPki.containsKey('ca')) { + if (yamlPki['ca'] is String) { + try { + var rawCaInfo = await platform.invokeMethod("nebula.parseCerts", { + "certs": yamlPki['ca'] as String, + }); + List rawCas = jsonDecode(rawCaInfo); + var i = 0; + for (var rawCa in rawCas) { + i++; + try { + site.ca.add(CertificateInfo.fromJson(rawCa)); + } on ParseError catch (err) { + site.errors.add('skipping ca $i due to error: ${err.message}'); + } + } + } on PlatformException catch (err) { + site.errors.add('could not parse pki.ca: ${err.message}'); + } + } else { + site.errors.add('pki.ca was not a string'); + } + } + + if (yamlPki.containsKey('cert')) { + if (yamlPki['cert'] is String) { + try { + var rawCertInfo = await platform.invokeMethod("nebula.parseCerts", { + "certs": yamlPki['cert'] as String, + }); + List rawCerts = jsonDecode(rawCertInfo); + for (var rawCert in rawCerts) { + try { + site.certInfo = CertificateInfo.fromJson(rawCert); + } on ParseError catch (err) { + site.errors.add('skipping cert due to error: ${err.message}'); + } + } + } on PlatformException catch (err) { + site.errors.add('could not parse pki.cert: ${err.message}'); + } + } else { + site.errors.add('pki.cert was not a string'); + } + } +} + +void _fromYamlUnsafeRoutes(Site site, YamlMap yaml) { + if (!yaml.containsKey('unsafe_routes')) { + return; + } + + if (yaml['unsafe_routes'] is! YamlList) { + site.errors.add('unsafe_routes was not a yaml list'); + return; + } + + final yamlUnsafeRoutes = yaml['unsafe_routes'] as YamlList; + var i = 0; + for (var yamlRoute in yamlUnsafeRoutes) { + i++; + try { + site.unsafeRoutes.add(UnsafeRoute.fromYaml(yamlRoute)); + } on ParseError catch (err) { + site.errors.add('failed to parse unsafe route $i: ${err.message}'); + } + } +} + +void _fromYamlCipher(Site site, YamlMap yaml) { + if (!yaml.containsKey('cipher')) { + return; + } + + if (yaml['cipher'] is! String) { + site.errors.add('cipher was not a string'); + return; + } + + final yamlCipher = (yaml['cipher'] as String).toLowerCase(); + if (_validCiphers.contains(yamlCipher)) { + site.cipher = yamlCipher; + } else { + site.errors.add('cipher was not valid: $yamlCipher'); + } +} + +void _fromYamlTun(Site site, YamlMap yaml) { + if (!yaml.containsKey('tun')) { + return; + } + + if (yaml['tun'] is! YamlMap) { + site.errors.add('tun was not a yaml map'); + return; + } + + final yamlTun = yaml['tun'] as YamlMap; + if (yamlTun.containsKey('mtu')) { + final (mtu, valid) = Utils.dynamicToInt(yamlTun['mtu']); + if (valid) { + site.mtu = mtu; + } else { + site.errors.add('tun.mtu was not a number: ${yamlTun['mtu']}'); + } + } +} + +void _fromYamlListen(Site site, YamlMap yaml) { + if (!yaml.containsKey('listen')) { + return; + } + + if (yaml['listen'] is! YamlMap) { + site.errors.add('listen was not a yaml map'); + return; + } + + final yamlListen = yaml['listen'] as YamlMap; + if (yamlListen.containsKey('port')) { + final (port, valid) = Utils.dynamicToInt(yamlListen['port']); + if (valid) { + site.port = port; + } else { + site.errors.add('listen.port was not a number: ${yamlListen['port']}'); + } + } +} + +void _fromYamlLogging(Site site, YamlMap yaml) { + if (!yaml.containsKey('logging')) { + return; + } + + if (yaml['logging'] is! YamlMap) { + site.errors.add('logging was not a yaml map'); + return; + } + + final yamlLogging = yaml['logging'] as YamlMap; + if (!yamlLogging.containsKey('level')) { + return; + } + + if (yamlLogging['level'] is! String) { + site.errors.add('logging.level was not a string'); + return; + } + + final yamlLevel = (yamlLogging['level'] as String).toLowerCase(); + if (_validLogLevels.contains(yamlLevel)) { + site.logVerbosity = yamlLevel; + } else { + site.errors.add('logging.level was not valid: $yamlLevel'); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 29b1f420..2d753e44 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; @@ -15,6 +16,14 @@ import 'package:mobile_nebula/screens/siteConfig/site_config_screen.dart'; import 'package:mobile_nebula/screens/site_detail_screen.dart'; import 'package:mobile_nebula/services/utils.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart'; +import 'package:uuid/uuid.dart'; +import 'package:yaml/yaml.dart'; + +import '../models/certificate.dart'; +import '../models/ip_and_port.dart'; +import '../models/static_hosts.dart'; +import '../models/unsafe_route.dart'; +import 'enrollment_screen.dart'; final _log = Logger('main_screen'); @@ -86,14 +95,14 @@ class MainScreenState extends State { leadingAction: PlatformIconButton( padding: EdgeInsets.zero, icon: Icon(Icons.add, size: 28.0), - onPressed: () => Utils.openPage(context, (context) { - return SiteConfigScreen( - onSave: (_) { - _loadSites(); - }, - supportsQRScanning: supportsQRScanning, - ); - }), + onPressed: () => showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + isScrollControlled: true, + builder: _buildAddSite, + ), ), refreshController: refreshController, onRefresh: () { @@ -104,7 +113,7 @@ class MainScreenState extends State { PlatformIconButton( padding: EdgeInsets.zero, icon: Icon(Icons.adaptive.more, size: 28.0), - onPressed: () => Utils.openPage(context, (_) => SettingsScreen(widget.dnEnrollStream, () => _loadSites())), + onPressed: () => Utils.openPage(context, (_) => SettingsScreen()), ), ], child: _buildBody(), @@ -139,7 +148,7 @@ class MainScreenState extends State { child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), ), Text( - 'You don\'t have any site configurations installed yet. Hit the plus button above to get started.', + 'You don\'t have any site configurations installed yet. Tap the plus button above to get started.', textAlign: TextAlign.center, ), ], @@ -217,6 +226,192 @@ class MainScreenState extends State { ); } + Widget _buildAddSite(BuildContext context) { + final arrowIcon = Icon( + Icons.arrow_forward_ios, + size: 18, + color: Theme.of(context).listTileTheme.leadingAndTrailingTextStyle!.color, + ); + + final outerContext = this.context; + final children = [ + ListTile( + title: Text('From scratch'), + subtitle: Text('Manually configure new network'), + trailing: arrowIcon, + onTap: () { + // Remove the modal + Navigator.pop(context); + + // Open the new site page + Utils.openPage(context, (context) { + return SiteConfigScreen( + onSave: (_) { + _loadSites(); + }, + supportsQRScanning: supportsQRScanning, + ); + }); + }, + ), + ListTile( + title: Text('From file'), + subtitle: Text('Import YAML configuration'), + trailing: arrowIcon, + onTap: () async { + try { + // Remove the modal + Navigator.pop(context); + + final rawContent = await Utils.pickFile(context); + if (rawContent == null) { + return Utils.popError('Load YAML config', 'File was empty'); + } + final yaml = loadYaml(rawContent); + final site = await Site.fromYaml(yaml); + if (!outerContext.mounted) { + return; + } + + Utils.openPage(outerContext, (context) { + return SiteConfigScreen( + site: site, + onSave: (_) { + _loadSites(); + }, + supportsQRScanning: supportsQRScanning, + startChanged: true, + ); + }); + } catch (err) { + return Utils.popError('Load YAML config', err.toString()); + } + }, + ), + ListTile( + title: Text('Enroll with defined.net'), + subtitle: Text('Join your organizations network'), + trailing: arrowIcon, + onTap: () => + Utils.openPage(context, (context) => EnrollmentScreen(stream: widget.dnEnrollStream, allowCodeEntry: true)), + ), + ]; + + if (kDebugMode) { + children.add(_debugSave(badDebugSave)); + children.add(_debugSave(goodDebugSave)); + children.add(_debugSave(goodDebugSaveV2)); + children.add(_debugSave(goodDebugSaveV2P256)); + children.add(_debugClearKeys()); + } + + final borderColor = Theme.of(context).colorScheme.outlineVariant; + + return DraggableScrollableSheet( + expand: false, + builder: (context, scrollController) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsetsGeometry.all(32), + child: Text('Add Site', style: Theme.of(context).listTileTheme.titleTextStyle!.copyWith(fontSize: 18)), + ), + Flexible( + child: SafeArea( + child: ListView( + controller: scrollController, + padding: EdgeInsetsGeometry.fromLTRB(32, 0, 32, 32), + children: List.generate(children.length, (index) { + final borderSide = BorderSide(color: borderColor); + if (index == 0) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + border: Border(top: borderSide, left: borderSide, right: borderSide), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: children[index], + ); + } + + if (index == children.length - 1) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + border: Border.fromBorderSide(borderSide), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: children[index], + ); + } + + // return children[index]; + return Container( + clipBehavior: Clip.antiAlias, // and here + decoration: BoxDecoration( + border: Border(top: borderSide, left: borderSide, right: borderSide), + ), + child: children[index], + ); + }), + ), + ), + ), + ], + ); + }, + ); + } + + ListTile _debugSave(Map siteConfig) { + return ListTile( + title: Text(siteConfig['name']!), + onTap: () async { + var uuid = Uuid(); + + var s = Site( + name: siteConfig['name']!, + id: uuid.v4(), + staticHostmap: { + "10.1.0.1": StaticHost( + lighthouse: true, + destinations: [IPAndPort('10.1.1.53', 4242), IPAndPort('1::1', 4242)], + ), + }, + ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])], + certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']), + unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')], + ); + + s.key = siteConfig['key']; + + try { + await s.save(); + _loadSites(); + } catch (err) { + Utils.popError("Failed to save the site", err.toString()); + } + }, + ); + } + + ListTile _debugClearKeys() { + return ListTile( + title: Text("Clear Keys"), + onTap: () async { + await platform.invokeMethod("debug.clearKeys", null); + _loadSites(); + }, + ); + } + Future _loadSites() async { //TODO: This can throw, we need to show an error dialog Map rawSites = jsonDecode(await platform.invokeMethod('listSites')); @@ -264,3 +459,85 @@ class MainScreenState extends State { setState(() {}); } } + +/// Contains an expired v1 CA and certificate +const badDebugSave = { + 'name': 'Bad Site', + 'cert': '''-----BEGIN NEBULA CERTIFICATE----- +CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa +A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp +VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU +Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ== +-----END NEBULA CERTIFICATE-----''', + 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- +rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= +-----END NEBULA X25519 PRIVATE KEY-----''', + 'ca': '''-----BEGIN NEBULA CERTIFICATE----- +CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT +4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv +mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= +-----END NEBULA CERTIFICATE-----''', +}; + +/// Contains a non-expired v1 CA and certificate +const goodDebugSave = { + 'name': 'Good Site', + 'cert': '''-----BEGIN NEBULA CERTIFICATE----- +CmcKCmRlYnVnIGhvc3QSCYKAhFCA/v//DyiX0ZaaBjDjjPf5ETogyYzKdlRh7pW6 +yOd8+aMQAFPha2wuYixuq53ru9+qXC9KIJd3ow6qIiaHInT1dgJvy+122WK7g86+ +Z8qYtTZnox1cEkBYpC0SySrCp6jd/zeAFEJM6naPYgc6rmy/H/qveyQ6WAtbgLpK +tM3EXbbOE9+fV/Ma6Oilf1SixO3ZBo30nRYL +-----END NEBULA CERTIFICATE-----''', + 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- +vu9t0mNy8cD5x3CMVpQ/cdKpjdz46NBlcRqvJAQpO44= +-----END NEBULA X25519 PRIVATE KEY-----''', + 'ca': '''-----BEGIN NEBULA CERTIFICATE----- +CjcKBWRlYnVnKOTQlpoGMOSM9/kROiCWNJUs7c4ZRzUn2LbeAEQrz2PVswnu9dcL +Sn/2VNNu30ABEkCQtWxmCJqBr5Yd9vtDWCPo/T1JQmD3stBozcM6aUl1hP3zjURv +MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI +-----END NEBULA CERTIFICATE-----''', +}; + +/// Contains a non-expired v2 CA and certificate +const goodDebugSaveV2 = { + 'name': 'Good Site V2', + 'cert': '''-----BEGIN NEBULA CERTIFICATE V2----- +MIIBHKCBtYAMVjIgVGVzdCBIb3N0oTQEBQoBAAIQBAXAqAECGAQR/ZgAAAAAAAAA +AAAAAAAAAkAEEf2Z7u7u7u7uqqq7u8zM//JAohoEBQAAAAAABBEAAAAAAAAAAAAA +AAAAAAAAAKMlDAt0ZXN0LWdyb3VwMQwLdGVzdC1ncm91cDIMCWZvb2JhcmJheoUE +aQUSxIYEauZGOYcganAHTUvQcytewZBsfkiAhruIuQgoJ0vSpRK180ipgQuCIG06 +ZRKG32WKsCKEls5eENf5QkUO6pzaGGgCdLl3rbRJg0Db4EhAHpvtNbumzMs2lamb +zkFjSWHl6qTvhA/3ZaKuD09wp9NEHhkL8l9uwz9KfSB6wHsZDC55i/HBo9YKCPIB +-----END NEBULA CERTIFICATE V2-----''', + 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- +QyQYT2IxfdtDGUirKjhUMIT5O6W8CE/JzJquqQRZhFU= +-----END NEBULA X25519 PRIVATE KEY-----''', + 'ca': '''-----BEGIN NEBULA CERTIFICATE V2----- +MIHeoHiAClYyIFRlc3QgQ0GhNAQFCgEAABAEBcCoAQAYBBH9mAAAAAAAAAAAAAAA +AAABQAQR/Znu7u7u7u4AAAAAAAAAAECjJQwLdGVzdC1ncm91cDEMC3Rlc3QtZ3Jv +dXAyDAlmb29iYXJiYXqEAf+FBGkFErqGBGrmRjqCIB1M/UJegMPjdpCkNV4spaH6 +48Zrc6EF6PgB0dmTjsGug0DHOiCTMm/fRkD1R3E+gtI53eTJk/gaRyphMvSJUuyJ +Yd6DdoCpAMXb7cpgDfW8PGkU/77HWjLhu5HM28YHlioC +-----END NEBULA CERTIFICATE V2-----''', +}; + +/// Contains a non-expired v2 CA and certificate using curve P256 +const goodDebugSaveV2P256 = { + 'name': 'Good Site V2 P256', + 'cert': '''-----BEGIN NEBULA CERTIFICATE V2----- +MIHhoFGABHRlc3ShGgQFZEAAARgEEf2ZEjQAAAAAAAAAAAAAAAFAhQRpliGlhgUB +QCnFnocg11j0TjKY5XTo6kTiHsdkHbKMgvY06sGJqj7q8IhdUAyBAQGCQQTZNYsI +x73Zk+2pddHdP2j5DbA4EweyIgSLaGHaxCy3CfWXUl91Nkm2UIsVztCNbVA1EZk0 +hqotegK6OR0rIy8Gg0YwRAIgTb8NMfYsGGGUEt3R3wyRT+OojoQoelQ3+kdUZcc8 +uvwCIFwKsKHPV4z2Thluktp0a1BkVE686aiSE6DJsw7dcP0b +-----END NEBULA CERTIFICATE V2-----''', + 'key': '''-----BEGIN NEBULA P256 PRIVATE KEY----- +/xIA9C3sS+xHiC2gsdgPITdvApS9zXPB98teFJr0Xho= +-----END NEBULA P256 PRIVATE KEY-----''', + 'ca': '''-----BEGIN NEBULA CERTIFICATE V2----- +MIGmoBaABHRlc3SEAf+FBGmWIZ+GBQFAKcWfgQEBgkEEs/i7EPQ1EEKzxtMNiCpY +S/PfnqOGvyvSk96N/TeuqtjYostx9V1yBCR27MT74jFM5RSgroSfuatcyJvXeSD3 +TINGMEQCIC719bsgIqPMEk/c/x6bVfec9OmBac2Za1TLVRny4VsSAiBjb5IfFxde +dkbm61ltqb21JyWqsqfDbpcaCEECc20oZQ== +-----END NEBULA CERTIFICATE V2-----''', +}; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1e9264d5..bd75bdf3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,91 +1,16 @@ -import 'dart:async'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mobile_nebula/components/config/config_item.dart'; import 'package:mobile_nebula/components/config/config_page_item.dart'; import 'package:mobile_nebula/components/config/config_section.dart'; import 'package:mobile_nebula/components/simple_page.dart'; -import 'package:mobile_nebula/screens/enrollment_screen.dart'; import 'package:mobile_nebula/services/settings.dart'; import 'package:mobile_nebula/services/utils.dart'; -import 'package:uuid/uuid.dart'; -import '../models/certificate.dart'; -import '../models/ip_and_port.dart'; -import '../models/site.dart'; -import '../models/static_hosts.dart'; -import '../models/unsafe_route.dart'; import 'about_screen.dart'; -/// Contains an expired v1 CA and certificate -const badDebugSave = { - 'name': 'Bad Site', - 'cert': '''-----BEGIN NEBULA CERTIFICATE----- -CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa -A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp -VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU -Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ== ------END NEBULA CERTIFICATE-----''', - 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- -rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= ------END NEBULA X25519 PRIVATE KEY-----''', - 'ca': '''-----BEGIN NEBULA CERTIFICATE----- -CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT -4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv -mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= ------END NEBULA CERTIFICATE-----''', -}; - -/// Contains a non-expired v1 CA and certificate -const goodDebugSave = { - 'name': 'Good Site', - 'cert': '''-----BEGIN NEBULA CERTIFICATE----- -CmcKCmRlYnVnIGhvc3QSCYKAhFCA/v//DyiX0ZaaBjDjjPf5ETogyYzKdlRh7pW6 -yOd8+aMQAFPha2wuYixuq53ru9+qXC9KIJd3ow6qIiaHInT1dgJvy+122WK7g86+ -Z8qYtTZnox1cEkBYpC0SySrCp6jd/zeAFEJM6naPYgc6rmy/H/qveyQ6WAtbgLpK -tM3EXbbOE9+fV/Ma6Oilf1SixO3ZBo30nRYL ------END NEBULA CERTIFICATE-----''', - 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- -vu9t0mNy8cD5x3CMVpQ/cdKpjdz46NBlcRqvJAQpO44= ------END NEBULA X25519 PRIVATE KEY-----''', - 'ca': '''-----BEGIN NEBULA CERTIFICATE----- -CjcKBWRlYnVnKOTQlpoGMOSM9/kROiCWNJUs7c4ZRzUn2LbeAEQrz2PVswnu9dcL -Sn/2VNNu30ABEkCQtWxmCJqBr5Yd9vtDWCPo/T1JQmD3stBozcM6aUl1hP3zjURv -MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI ------END NEBULA CERTIFICATE-----''', -}; - -/// Contains a non-expired v2 CA and certificate -const goodDebugSaveV2 = { - 'name': 'Good Site V2', - 'cert': '''-----BEGIN NEBULA CERTIFICATE V2----- -MIIBHKCBtYAMVjIgVGVzdCBIb3N0oTQEBQoBAAIQBAXAqAECGAQR/ZgAAAAAAAAA -AAAAAAAAAkAEEf2Z7u7u7u7uqqq7u8zM//JAohoEBQAAAAAABBEAAAAAAAAAAAAA -AAAAAAAAAKMlDAt0ZXN0LWdyb3VwMQwLdGVzdC1ncm91cDIMCWZvb2JhcmJheoUE -aQUSxIYEauZGOYcganAHTUvQcytewZBsfkiAhruIuQgoJ0vSpRK180ipgQuCIG06 -ZRKG32WKsCKEls5eENf5QkUO6pzaGGgCdLl3rbRJg0Db4EhAHpvtNbumzMs2lamb -zkFjSWHl6qTvhA/3ZaKuD09wp9NEHhkL8l9uwz9KfSB6wHsZDC55i/HBo9YKCPIB ------END NEBULA CERTIFICATE V2-----''', - 'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY----- -QyQYT2IxfdtDGUirKjhUMIT5O6W8CE/JzJquqQRZhFU= ------END NEBULA X25519 PRIVATE KEY-----''', - 'ca': '''-----BEGIN NEBULA CERTIFICATE V2----- -MIHeoHiAClYyIFRlc3QgQ0GhNAQFCgEAABAEBcCoAQAYBBH9mAAAAAAAAAAAAAAA -AAABQAQR/Znu7u7u7u4AAAAAAAAAAECjJQwLdGVzdC1ncm91cDEMC3Rlc3QtZ3Jv -dXAyDAlmb29iYXJiYXqEAf+FBGkFErqGBGrmRjqCIB1M/UJegMPjdpCkNV4spaH6 -48Zrc6EF6PgB0dmTjsGug0DHOiCTMm/fRkD1R3E+gtI53eTJk/gaRyphMvSJUuyJ -Yd6DdoCpAMXb7cpgDfW8PGkU/77HWjLhu5HM28YHlioC ------END NEBULA CERTIFICATE V2-----''', -}; - class SettingsScreen extends StatefulWidget { - final StreamController stream; - final Function? onDebugChanged; - - const SettingsScreen(this.stream, this.onDebugChanged, {super.key}); + const SettingsScreen({super.key}); @override SettingsScreenState createState() => SettingsScreenState(); @@ -109,14 +34,6 @@ class SettingsScreenState extends State { @override Widget build(BuildContext context) { List colorSection = []; - Widget? debugSite; - - if (kDebugMode) { - debugSite = Wrap( - alignment: WrapAlignment.center, - children: [_debugSave(badDebugSave), _debugSave(goodDebugSave), _debugSave(goodDebugSaveV2), _debugClearKeys()], - ); - } colorSection.add( ConfigItem( @@ -197,19 +114,6 @@ class SettingsScreenState extends State { ), ); - items.add( - ConfigSection( - children: [ - ConfigPageItem( - label: Text('Enroll with Managed Nebula'), - labelWidth: 250, - onPressed: () => - Utils.openPage(context, (context) => EnrollmentScreen(stream: widget.stream, allowCodeEntry: true)), - ), - ], - ), - ); - items.add( ConfigSection( children: [ @@ -220,50 +124,7 @@ class SettingsScreenState extends State { return SimplePage( title: Text('Settings'), - bottomBar: debugSite, child: Column(children: items), ); } - - Widget _debugSave(Map siteConfig) { - return CupertinoButton( - child: Text(siteConfig['name']!), - onPressed: () async { - var uuid = Uuid(); - - var s = Site( - name: siteConfig['name']!, - id: uuid.v4(), - staticHostmap: { - "10.1.0.1": StaticHost( - lighthouse: true, - destinations: [IPAndPort('10.1.1.53', 4242), IPAndPort('1::1', 4242)], - ), - }, - ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])], - certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']), - unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')], - ); - - s.key = siteConfig['key']; - - try { - await s.save(); - widget.onDebugChanged?.call(); - } catch (err) { - Utils.popError("Failed to save the site", err.toString()); - } - }, - ); - } - - Widget _debugClearKeys() { - return CupertinoButton( - child: Text("Clear Keys"), - onPressed: () async { - await platform.invokeMethod("debug.clearKeys", null); - widget.onDebugChanged?.call(); - }, - ); - } } diff --git a/lib/screens/siteConfig/site_config_screen.dart b/lib/screens/siteConfig/site_config_screen.dart index a5975a18..a686de73 100644 --- a/lib/screens/siteConfig/site_config_screen.dart +++ b/lib/screens/siteConfig/site_config_screen.dart @@ -19,19 +19,24 @@ import 'package:mobile_nebula/screens/siteConfig/certificate_details_screen.dart import 'package:mobile_nebula/screens/siteConfig/static_hosts_screen.dart'; import 'package:mobile_nebula/services/utils.dart'; -//TODO: Add a config test mechanism -//TODO: Enforce a name - class SiteConfigScreen extends StatefulWidget { - const SiteConfigScreen({super.key, this.site, required this.onSave, required this.supportsQRScanning}); + const SiteConfigScreen({ + super.key, + this.site, + required this.onSave, + required this.supportsQRScanning, + this.startChanged = false, + }); final Site? site; // This is called after the target OS has saved the configuration final ValueChanged onSave; - final bool supportsQRScanning; + // startChanged is currently only used for loading a site from yaml, we need to start by showing the save button. + final bool startChanged; + @override SiteConfigScreenState createState() => SiteConfigScreenState(); } @@ -59,6 +64,10 @@ class SiteConfigScreenState extends State { nameController.text = site.name; } + if (widget.startChanged) { + changed = true; + } + super.initState(); } @@ -92,6 +101,7 @@ class SiteConfigScreenState extends State { }, child: Column( children: [ + _errors(), _main(), _keys(), _hosts(), @@ -118,6 +128,29 @@ class SiteConfigScreenState extends State { ); } + Widget _errors() { + if (site.errors.isEmpty) { + return Container(); + } + + List items = []; + for (var error in site.errors) { + items.add( + ConfigItem( + labelWidth: 0, + content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error)), + ), + ); + } + + return ConfigSection( + label: 'ERRORS', + borderColor: CupertinoColors.systemRed.resolveFrom(context), + labelColor: CupertinoColors.systemRed.resolveFrom(context), + children: items, + ); + } + Widget _main() { return ConfigSection( children: [ diff --git a/lib/services/theme.dart b/lib/services/theme.dart index 91589aed..197a9f38 100644 --- a/lib/services/theme.dart +++ b/lib/services/theme.dart @@ -28,11 +28,11 @@ class MaterialTheme { onError: Color(4294967295), errorContainer: Color(4294957782), onErrorContainer: Color(4287823882), - surface: Color.fromARGB(255, 226, 229, 233), - onSurface: Color(4280032032), - onSurfaceVariant: Color(4282926414), + surface: Color.fromRGBO(226, 229, 233, 1), + onSurface: Colors.black, + onSurfaceVariant: Color.fromRGBO(138, 151, 168, 1), outline: Color(4286150015), - outlineVariant: Color(4291478735), + outlineVariant: Color.fromRGBO(226, 229, 233, 1), shadow: Color(4278190080), scrim: Color(4278190080), inverseSurface: Color(4281478965), @@ -83,11 +83,11 @@ class MaterialTheme { onError: Color(4294967295), errorContainer: Color(4291767335), onErrorContainer: Color(4294967295), - surface: Color.fromARGB(255, 226, 229, 233), - onSurface: Color(4279373846), - onSurfaceVariant: Color(4281873725), + surface: Color.fromRGBO(226, 229, 233, 1), + onSurface: Colors.black, + onSurfaceVariant: Color.fromRGBO(138, 151, 168, 1), outline: Color(4283715930), - outlineVariant: Color(4285492085), + outlineVariant: Color.fromRGBO(226, 229, 233, 1), shadow: Color(4278190080), scrim: Color(4278190080), inverseSurface: Color(4281478965), @@ -141,11 +141,11 @@ class MaterialTheme { onError: Color(4294967295), errorContainer: Color(4288151562), onErrorContainer: Color(4294967295), - surface: Color.fromARGB(255, 226, 229, 233), - onSurface: Color(4278190080), - onSurfaceVariant: Color(4278190080), + surface: Color.fromRGBO(226, 229, 233, 1), + onSurface: Colors.black, + onSurfaceVariant: Color.fromRGBO(138, 151, 168, 1), outline: Color(4281150259), - outlineVariant: Color(4283123793), + outlineVariant: Color.fromRGBO(226, 229, 233, 1), shadow: Color(4278190080), scrim: Color(4278190080), inverseSurface: Color(4281478965), @@ -199,11 +199,11 @@ class MaterialTheme { onError: Color(4285071365), errorContainer: Color(4287823882), onErrorContainer: Color(4294957782), - surface: Color.fromARGB(255, 22, 25, 29), - onSurface: Color(4293321193), - onSurfaceVariant: Color(4291478735), + surface: Color.fromRGBO(22, 25, 29, 1), + onSurface: Colors.white, + onSurfaceVariant: Color.fromRGBO(138, 151, 168, 1), outline: Color(4287860633), - outlineVariant: Color(4282926414), + outlineVariant: Color.fromRGBO(65, 75, 88, 1), shadow: Color(4278190080), scrim: Color(4278190080), inverseSurface: Color(4293321193), @@ -233,7 +233,7 @@ class MaterialTheme { ThemeData dark() { return theme( darkScheme(), - BadgeThemeData(backgroundColor: Color.fromARGB(255, 93, 34, 221), textColor: Color.fromARGB(255, 223, 211, 248)), + BadgeThemeData(backgroundColor: Color.fromRGBO(93, 34, 221, 1), textColor: Color.fromRGBO(223, 211, 248, 1)), ); } @@ -258,10 +258,10 @@ class MaterialTheme { errorContainer: Color(4294923337), onErrorContainer: Color(4278190080), surface: Color(4279505688), - onSurface: Color(4294967295), - onSurfaceVariant: Color(4292926181), + onSurface: Colors.white, + onSurfaceVariant: Color.fromRGBO(138, 151, 168, 1), outline: Color(4290097339), - outlineVariant: Color(4287860377), + outlineVariant: Color.fromRGBO(65, 75, 88, 1), shadow: Color(4278190080), scrim: Color(4278190080), inverseSurface: Color(4293321193), @@ -291,7 +291,7 @@ class MaterialTheme { ThemeData darkMediumContrast() { return theme( darkScheme(), - BadgeThemeData(backgroundColor: Color.fromARGB(255, 93, 34, 221), textColor: Color.fromARGB(255, 223, 211, 248)), + BadgeThemeData(backgroundColor: Color.fromRGBO(93, 34, 221, 1), textColor: Color.fromRGBO(223, 211, 248, 1)), ); } @@ -315,11 +315,11 @@ class MaterialTheme { onError: Color(4278190080), errorContainer: Color(4294946468), onErrorContainer: Color(4280418305), - surface: Color(4279505688), - onSurface: Color(4294967295), - onSurfaceVariant: Color(4294967295), + surface: Color.fromRGBO(22, 25, 29, 1), + onSurface: Colors.white, + onSurfaceVariant: Color.fromRGBO(138, 151, 168, 1), outline: Color(4294242041), - outlineVariant: Color(4291215563), + outlineVariant: Color.fromRGBO(65, 75, 88, 1), shadow: Color(4278190080), scrim: Color(4278190080), inverseSurface: Color(4293321193), @@ -349,7 +349,7 @@ class MaterialTheme { ThemeData darkHighContrast() { return theme( darkScheme(), - BadgeThemeData(backgroundColor: Color.fromARGB(255, 93, 34, 221), textColor: Color.fromARGB(255, 223, 211, 248)), + BadgeThemeData(backgroundColor: Color.fromRGBO(93, 34, 221, 1), textColor: Color.fromRGBO(223, 211, 248, 1)), ); } @@ -369,6 +369,15 @@ class MaterialTheme { value: (_) => const FadeForwardsPageTransitionsBuilder(), ), ), + listTileTheme: ListTileThemeData( + titleTextStyle: TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w500, fontSize: 16), + subtitleTextStyle: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w500, fontSize: 14), + leadingAndTrailingTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), ); List get extendedColors => []; diff --git a/lib/services/utils.dart b/lib/services/utils.dart index d76bc769..04737f09 100644 --- a/lib/services/utils.dart +++ b/lib/services/utils.dart @@ -186,4 +186,18 @@ class Utils { ); return textTheme; } + + static (int, bool) dynamicToInt(dynamic d) { + if (d is String) { + final i = int.tryParse(d); + if (i == null) { + return (0, false); + } + return (i, true); + } else if (d is num) { + return (d.toInt(), true); + } + + return (0, false); + } } diff --git a/test/models/site_test.dart b/test/models/site_test.dart new file mode 100644 index 00000000..231ee464 --- /dev/null +++ b/test/models/site_test.dart @@ -0,0 +1,446 @@ +import 'package:mobile_nebula/models/site.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + group('Site.fromYaml', () { + test('empty config', () async { + final site = await Site.fromYaml(loadYaml('{}')); + expect(site.lhDuration, 0); + expect(site.staticHostmap, isEmpty); + expect(site.unsafeRoutes, isEmpty); + expect(site.cipher, 'aes'); + expect(site.mtu, 1300); + expect(site.port, 0); + expect(site.logVerbosity, 'info'); + expect(site.errors, isEmpty); + }); + + group('lighthouse', () { + test('parses interval', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + interval: 120 +'''), + ); + expect(site.lhDuration, 120); + expect(site.errors, isEmpty); + }); + + test('parses string interval', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + interval: "120" +'''), + ); + expect(site.lhDuration, 120); + expect(site.errors, isEmpty); + }); + + test('errors on non-numeric interval', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + interval: abc +'''), + ); + expect(site.lhDuration, 0); + expect(site.errors, contains('lighthouse.interval could not be parsed as an integer')); + }); + + test('errors on invalid lighthouse host ip', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + hosts: + - 999.999.999.999 +'''), + ); + expect(site.errors, contains('lighthouse.hosts entry was not a valid ip address: 999.999.999.999')); + }); + + test('errors on non-string lighthouse host entry', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + hosts: + - 123 +'''), + ); + expect(site.errors, contains('lighthouse.hosts entry was not a string: 123')); + }); + }); + + group('static_host_map', () { + test('parses valid static hosts', () async { + final site = await Site.fromYaml( + loadYaml(''' +static_host_map: + '1.1.1.1': + - 10.1.1.1:8444 + '2.2.2.2': + - 10.2.2.2:8444 + - 10.2.2.3:8444 +'''), + ); + expect(site.staticHostmap.length, 2); + expect(site.staticHostmap['1.1.1.1']!.destinations.length, 1); + expect(site.staticHostmap['1.1.1.1']!.destinations[0].ip, '10.1.1.1'); + expect(site.staticHostmap['1.1.1.1']!.destinations[0].port, 8444); + expect(site.staticHostmap['1.1.1.1']!.lighthouse, false); + expect(site.staticHostmap['2.2.2.2']!.destinations.length, 2); + expect(site.errors, isEmpty); + }); + + test('marks lighthouse hosts', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + hosts: + - 1.1.1.1 +static_host_map: + '1.1.1.1': + - 10.1.1.1:8444 + '2.2.2.2': + - 10.2.2.2:8444 +'''), + ); + expect(site.staticHostmap['1.1.1.1']!.lighthouse, true); + expect(site.staticHostmap['2.2.2.2']!.lighthouse, false); + expect(site.errors, isEmpty); + }); + + test('errors on invalid vpn address', () async { + final site = await Site.fromYaml( + loadYaml(''' +static_host_map: + 'not-an-ip': + - 10.1.1.1:8444 +'''), + ); + expect(site.staticHostmap, isEmpty); + expect(site.errors, contains('invalid vpn address in static_host_map: not-an-ip')); + }); + + test('errors on non-string destination', () async { + final site = await Site.fromYaml( + loadYaml(''' +static_host_map: + '1.1.1.1': + - 123 +'''), + ); + expect(site.staticHostmap['1.1.1.1']!.destinations, isEmpty); + expect(site.errors, contains('static_host_map destination for 1.1.1.1 was not a string: 123')); + }); + + test('errors on non-list destinations', () async { + final site = await Site.fromYaml( + loadYaml(''' +static_host_map: + '1.1.1.1': not-a-list +'''), + ); + expect(site.errors, contains('static_host_map destinations for 1.1.1.1 was not a list of strings')); + }); + + test('errors on invalid host:port string', () async { + final site = await Site.fromYaml( + loadYaml(''' +static_host_map: + '1.1.1.1': + - 'bad-host-port' +'''), + ); + expect(site.staticHostmap['1.1.1.1']!.destinations, isEmpty); + expect(site.errors, isNotEmpty); + }); + }); + + group('unsafe_routes', () { + test('parses valid unsafe routes', () async { + final site = await Site.fromYaml( + loadYaml(''' +unsafe_routes: + - route: 10.0.0.0/24 + via: 192.168.1.1 +'''), + ); + expect(site.unsafeRoutes.length, 1); + expect(site.unsafeRoutes[0].route, '10.0.0.0/24'); + expect(site.unsafeRoutes[0].via, '192.168.1.1'); + expect(site.errors, isEmpty); + }); + + test('parses multiple unsafe routes', () async { + final site = await Site.fromYaml( + loadYaml(''' +unsafe_routes: + - route: 10.0.0.0/24 + via: 192.168.1.1 + - route: 172.16.0.0/16 + via: 192.168.1.2 +'''), + ); + expect(site.unsafeRoutes.length, 2); + expect(site.errors, isEmpty); + }); + + test('errors on invalid route CIDR', () async { + final site = await Site.fromYaml( + loadYaml(''' +unsafe_routes: + - route: not-a-cidr + via: 192.168.1.1 +'''), + ); + expect(site.unsafeRoutes, isEmpty); + expect( + site.errors, + contains('failed to parse unsafe route 1: unable to parse CIDR from route: missing / separator'), + ); + }); + + test('errors on missing via', () async { + final site = await Site.fromYaml( + loadYaml(''' +unsafe_routes: + - route: 10.0.0.0/24 +'''), + ); + expect(site.unsafeRoutes, isEmpty); + expect(site.errors, contains('failed to parse unsafe route 1: via was not a string')); + }); + + test('errors on non-map entry', () async { + final site = await Site.fromYaml( + loadYaml(''' +unsafe_routes: + - not-a-map +'''), + ); + expect(site.unsafeRoutes, isEmpty); + expect(site.errors, contains('failed to parse unsafe route 1: unsafe route was not a map')); + }); + }); + + group('pki', () { + test('parses key', () async { + final site = await Site.fromYaml( + loadYaml(''' +pki: + key: "test-key-data" +'''), + ); + expect(site.key, 'test-key-data'); + expect(site.errors, isEmpty); + }); + + test('ignores non-string key', () async { + final site = await Site.fromYaml( + loadYaml(''' +pki: + key: 123 +'''), + ); + expect(site.key, isNull); + }); + }); + + group('cipher', () { + test('parses aes', () async { + final site = await Site.fromYaml(loadYaml('cipher: aes')); + expect(site.cipher, 'aes'); + expect(site.errors, isEmpty); + }); + + test('parses chachapoly', () async { + final site = await Site.fromYaml(loadYaml('cipher: chachapoly')); + expect(site.cipher, 'chachapoly'); + expect(site.errors, isEmpty); + }); + + test('is case insensitive', () async { + final site = await Site.fromYaml(loadYaml('cipher: AES')); + expect(site.cipher, 'aes'); + expect(site.errors, isEmpty); + }); + + test('errors on invalid cipher', () async { + final site = await Site.fromYaml(loadYaml('cipher: blowfish')); + expect(site.cipher, 'aes'); + expect(site.errors, contains('cipher was not valid: blowfish')); + }); + }); + + group('tun', () { + test('parses mtu', () async { + final site = await Site.fromYaml( + loadYaml(''' +tun: + mtu: 1400 +'''), + ); + expect(site.mtu, 1400); + expect(site.errors, isEmpty); + }); + + test('parses string mtu', () async { + final site = await Site.fromYaml( + loadYaml(''' +tun: + mtu: "1400" +'''), + ); + expect(site.mtu, 1400); + expect(site.errors, isEmpty); + }); + + test('errors on non-numeric mtu', () async { + final site = await Site.fromYaml( + loadYaml(''' +tun: + mtu: abc +'''), + ); + expect(site.mtu, 1300); + expect(site.errors, contains('tun.mtu was not a number: abc')); + }); + }); + + group('listen', () { + test('parses port', () async { + final site = await Site.fromYaml( + loadYaml(''' +listen: + port: 4242 +'''), + ); + expect(site.port, 4242); + expect(site.errors, isEmpty); + }); + + test('parses string port', () async { + final site = await Site.fromYaml( + loadYaml(''' +listen: + port: "4242" +'''), + ); + expect(site.port, 4242); + expect(site.errors, isEmpty); + }); + + test('errors on non-numeric port', () async { + final site = await Site.fromYaml( + loadYaml(''' +listen: + port: abc +'''), + ); + expect(site.port, 0); + expect(site.errors, contains('listen.port was not a number: abc')); + }); + }); + + group('logging', () { + test('parses all valid log levels', () async { + for (final level in ['panic', 'fatal', 'error', 'warning', 'info', 'debug']) { + final site = await Site.fromYaml( + loadYaml(''' +logging: + level: $level +'''), + ); + expect(site.logVerbosity, level, reason: 'level $level should be valid'); + expect(site.errors, isEmpty, reason: 'level $level should not produce errors'); + } + }); + + test('is case insensitive', () async { + final site = await Site.fromYaml( + loadYaml(''' +logging: + level: DEBUG +'''), + ); + expect(site.logVerbosity, 'debug'); + expect(site.errors, isEmpty); + }); + + test('errors on invalid log level', () async { + final site = await Site.fromYaml( + loadYaml(''' +logging: + level: trace +'''), + ); + expect(site.logVerbosity, 'info'); + expect(site.errors, contains('logging.level was not valid: trace')); + }); + }); + + test('full config parses all fields together', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + interval: 60 + hosts: + - 1.1.1.1 +static_host_map: + '1.1.1.1': + - 10.1.1.1:8444 + '2.2.2.2': + - 10.2.2.2:8444 +unsafe_routes: + - route: 10.0.0.0/24 + via: 192.168.1.1 +pki: + key: "my-key" +cipher: chachapoly +tun: + mtu: 1400 +listen: + port: 4242 +logging: + level: debug +'''), + ); + expect(site.lhDuration, 60); + expect(site.staticHostmap.length, 2); + expect(site.staticHostmap['1.1.1.1']!.lighthouse, true); + expect(site.staticHostmap['2.2.2.2']!.lighthouse, false); + expect(site.unsafeRoutes.length, 1); + expect(site.key, 'my-key'); + expect(site.cipher, 'chachapoly'); + expect(site.mtu, 1400); + expect(site.port, 4242); + expect(site.logVerbosity, 'debug'); + expect(site.errors, isEmpty); + }); + + test('accumulates multiple errors', () async { + final site = await Site.fromYaml( + loadYaml(''' +lighthouse: + interval: abc + hosts: + - not-an-ip +static_host_map: + 'bad-vpn': + - 10.1.1.1:8444 +cipher: invalid +tun: + mtu: xyz +listen: + port: xyz +logging: + level: trace +'''), + ); + expect(site.errors.length, greaterThanOrEqualTo(6)); + }); + }); +}