351 lines
11 KiB
Dart
351 lines
11 KiB
Dart
import 'dart:async';
|
|
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';
|
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
|
import 'package:mobile_nebula/components/SiteItem.dart';
|
|
import 'package:mobile_nebula/models/Certificate.dart';
|
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
|
import 'package:mobile_nebula/models/Site.dart';
|
|
import 'package:mobile_nebula/models/StaticHosts.dart';
|
|
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
|
import 'package:mobile_nebula/screens/SettingsScreen.dart';
|
|
import 'package:mobile_nebula/screens/SiteDetailScreen.dart';
|
|
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
|
|
import 'package:mobile_nebula/services/utils.dart';
|
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
/// Contains an expired CA and certificate
|
|
const badDebugSave = {
|
|
'name': 'Bad Site',
|
|
'cert': '''-----BEGIN NEBULA CERTIFICATE-----
|
|
CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa
|
|
A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp
|
|
VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU
|
|
Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ==
|
|
-----END NEBULA CERTIFICATE-----''',
|
|
'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
|
rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
|
-----END NEBULA X25519 PRIVATE KEY-----''',
|
|
'ca': '''-----BEGIN NEBULA CERTIFICATE-----
|
|
CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
|
|
4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv
|
|
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
|
|
-----END NEBULA CERTIFICATE-----''',
|
|
};
|
|
|
|
/// Contains an expired CA and certificate
|
|
const goodDebugSave = {
|
|
'name': 'Good Site',
|
|
'cert': '''-----BEGIN NEBULA CERTIFICATE-----
|
|
CmcKCmRlYnVnIGhvc3QSCYKAhFCA/v//DyiX0ZaaBjDjjPf5ETogyYzKdlRh7pW6
|
|
yOd8+aMQAFPha2wuYixuq53ru9+qXC9KIJd3ow6qIiaHInT1dgJvy+122WK7g86+
|
|
Z8qYtTZnox1cEkBYpC0SySrCp6jd/zeAFEJM6naPYgc6rmy/H/qveyQ6WAtbgLpK
|
|
tM3EXbbOE9+fV/Ma6Oilf1SixO3ZBo30nRYL
|
|
-----END NEBULA CERTIFICATE-----''',
|
|
'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
|
vu9t0mNy8cD5x3CMVpQ/cdKpjdz46NBlcRqvJAQpO44=
|
|
-----END NEBULA X25519 PRIVATE KEY-----''',
|
|
'ca': '''-----BEGIN NEBULA CERTIFICATE-----
|
|
CjcKBWRlYnVnKOTQlpoGMOSM9/kROiCWNJUs7c4ZRzUn2LbeAEQrz2PVswnu9dcL
|
|
Sn/2VNNu30ABEkCQtWxmCJqBr5Yd9vtDWCPo/T1JQmD3stBozcM6aUl1hP3zjURv
|
|
MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI
|
|
-----END NEBULA CERTIFICATE-----''',
|
|
};
|
|
|
|
class MainScreen extends StatefulWidget {
|
|
const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key);
|
|
|
|
final StreamController dnEnrollStream;
|
|
|
|
@override
|
|
_MainScreenState createState() => _MainScreenState();
|
|
}
|
|
|
|
class _MainScreenState extends State<MainScreen> {
|
|
List<Site>? sites;
|
|
// A set of widgets to display in a column that represents an error blocking us from moving forward entirely
|
|
List<Widget>? error;
|
|
|
|
bool supportsQRScanning = false;
|
|
|
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
|
RefreshController refreshController = RefreshController();
|
|
ScrollController scrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
_loadSites();
|
|
|
|
widget.dnEnrollStream.stream.listen((_) {
|
|
_loadSites();
|
|
});
|
|
|
|
platform.setMethodCallHandler(handleMethodCall);
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
scrollController.dispose();
|
|
refreshController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<dynamic> handleMethodCall(MethodCall call) async {
|
|
switch (call.method) {
|
|
case "refreshSites":
|
|
_loadSites();
|
|
break;
|
|
default:
|
|
print("ERR: Unexpected method call ${call.method}");
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget? debugSite;
|
|
|
|
if (kDebugMode) {
|
|
debugSite = Row(
|
|
children: [
|
|
_debugSave(badDebugSave),
|
|
_debugSave(goodDebugSave),
|
|
_debugClearKeys(),
|
|
],
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
);
|
|
}
|
|
|
|
// Determine whether the device supports QR scanning. For example, some
|
|
// Chromebooks do not have camera support.
|
|
if (Platform.isAndroid) {
|
|
platform
|
|
.invokeMethod("android.deviceHasCamera")
|
|
.then((hasCamera) => setState(() => supportsQRScanning = hasCamera));
|
|
} else {
|
|
supportsQRScanning = true;
|
|
}
|
|
|
|
return SimplePage(
|
|
title: Text('Nebula'),
|
|
scrollable: SimpleScrollable.vertical,
|
|
scrollController: scrollController,
|
|
leadingAction: PlatformIconButton(
|
|
padding: EdgeInsets.zero,
|
|
icon: Icon(Icons.add, size: 28.0),
|
|
onPressed: () => Utils.openPage(context, (context) {
|
|
return SiteConfigScreen(
|
|
onSave: (_) {
|
|
_loadSites();
|
|
},
|
|
supportsQRScanning: supportsQRScanning);
|
|
}),
|
|
),
|
|
refreshController: refreshController,
|
|
onRefresh: () {
|
|
_loadSites();
|
|
refreshController.refreshCompleted();
|
|
},
|
|
trailingActions: <Widget>[
|
|
PlatformIconButton(
|
|
padding: EdgeInsets.zero,
|
|
icon: Icon(Icons.menu, size: 28.0),
|
|
onPressed: () => Utils.openPage(context, (_) => SettingsScreen(widget.dnEnrollStream)),
|
|
),
|
|
],
|
|
bottomBar: debugSite,
|
|
child: _buildBody(),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody() {
|
|
if (error != null) {
|
|
return Center(
|
|
child: Padding(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: error!,
|
|
),
|
|
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10)));
|
|
}
|
|
|
|
return _buildSites();
|
|
}
|
|
|
|
Widget _buildNoSites() {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Center(
|
|
child: Column(
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0),
|
|
child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
|
),
|
|
Text('You don\'t have any site configurations installed yet. Hit the plus button above to get started.',
|
|
textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
));
|
|
}
|
|
|
|
Widget _buildSites() {
|
|
if (sites == null || sites!.length == 0) {
|
|
return _buildNoSites();
|
|
}
|
|
|
|
List<Widget> items = [];
|
|
sites!.forEach((site) {
|
|
items.add(SiteItem(
|
|
key: Key(site.id),
|
|
site: site,
|
|
onPressed: () {
|
|
Utils.openPage(context, (context) {
|
|
return SiteDetailScreen(
|
|
site: site,
|
|
onChanged: () => _loadSites(),
|
|
supportsQRScanning: supportsQRScanning,
|
|
);
|
|
});
|
|
}));
|
|
});
|
|
|
|
Widget child = ReorderableListView(
|
|
shrinkWrap: true,
|
|
scrollController: scrollController,
|
|
padding: EdgeInsets.symmetric(vertical: 5),
|
|
children: items,
|
|
onReorder: (oldI, newI) async {
|
|
if (oldI < newI) {
|
|
// removing the item at oldIndex will shorten the list by 1.
|
|
newI -= 1;
|
|
}
|
|
|
|
setState(() {
|
|
final Site moved = sites!.removeAt(oldI);
|
|
sites!.insert(newI, moved);
|
|
});
|
|
|
|
for (var i = 0; i < sites!.length; i++) {
|
|
if (sites![i].sortKey == i) {
|
|
continue;
|
|
}
|
|
|
|
sites![i].sortKey = i;
|
|
try {
|
|
await sites![i].save();
|
|
} catch (err) {
|
|
//TODO: display error at the end
|
|
print('ERR ${sites![i].name} - $err');
|
|
}
|
|
}
|
|
|
|
_loadSites();
|
|
});
|
|
|
|
if (Platform.isIOS) {
|
|
child = CupertinoTheme(child: child, data: CupertinoTheme.of(context));
|
|
}
|
|
|
|
// The theme here is to remove the hardcoded canvas border reordering forces on us
|
|
return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child);
|
|
}
|
|
|
|
Widget _debugSave(Map<String, String> 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(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 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'];
|
|
|
|
var err = await s.save();
|
|
if (err != null) {
|
|
Utils.popError(context, "Failed to save the site", err);
|
|
} else {
|
|
_loadSites();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _debugClearKeys() {
|
|
return CupertinoButton(
|
|
child: Text("Clear Keys"),
|
|
onPressed: () async {
|
|
await platform.invokeMethod("debug.clearKeys", null);
|
|
},
|
|
);
|
|
}
|
|
|
|
_loadSites() async {
|
|
//TODO: This can throw, we need to show an error dialog
|
|
Map<String, dynamic> rawSites = jsonDecode(await platform.invokeMethod('listSites'));
|
|
bool hasErrors = false;
|
|
|
|
sites = [];
|
|
rawSites.forEach((id, rawSite) {
|
|
try {
|
|
var site = Site.fromJson(rawSite);
|
|
if (site.errors.length > 0) {
|
|
hasErrors = true;
|
|
}
|
|
|
|
//TODO: we need to cancel change listeners when we rebuild
|
|
site.onChange().listen((_) {
|
|
setState(() {});
|
|
}, onError: (err) {
|
|
setState(() {});
|
|
if (ModalRoute.of(context)!.isCurrent) {
|
|
Utils.popError(context, "${site.name} Error", err);
|
|
}
|
|
});
|
|
|
|
sites!.add(site);
|
|
} catch (err) {
|
|
//TODO: handle error
|
|
print("$err site config: $rawSite");
|
|
// Sometimes it is helpful to just nuke these is dev
|
|
// platform.invokeMethod('deleteSite', id);
|
|
}
|
|
});
|
|
|
|
if (Platform.isAndroid) {
|
|
// Android suffers from a race to discover the active site and attach site specific event listeners
|
|
platform.invokeMethod("android.registerActiveSite");
|
|
}
|
|
|
|
sites!.sort((a, b) {
|
|
if (a.sortKey == b.sortKey) {
|
|
return a.name.compareTo(b.name);
|
|
}
|
|
|
|
return a.sortKey - b.sortKey;
|
|
});
|
|
|
|
setState(() {});
|
|
}
|
|
}
|