mobile_nebula/lib/screens/MainScreen.dart

372 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 Stream 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;
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
RefreshController refreshController = RefreshController();
ScrollController scrollController = ScrollController();
@override
void initState() {
_loadSites();
widget.dnEnrollStream.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),
_debugDNEnroll(),
],
mainAxisAlignment: MainAxisAlignment.center,
);
}
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();
});
}),
),
refreshController: refreshController,
onRefresh: () {
print("onRefresh");
_loadSites();
refreshController.refreshCompleted();
},
trailingActions: <Widget>[
PlatformIconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.menu, size: 28.0),
onPressed: () => Utils.openPage(context, (_) => SettingsScreen()),
),
],
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());
});
}));
});
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 _debugDNEnroll() {
return CupertinoButton(
child: Text('DN Enroll'),
onPressed: () => Utils.openPage(context, (context) {
return EnrollmentScreen(allowCodeEntry: true);
}),
);
}
_loadSites() async {
if (Platform.isAndroid) {
try {
await platform.invokeMethod("android.requestPermissions");
} on PlatformException catch (err) {
if (err.code == "PERMISSIONS") {
setState(() {
error = [
Text("Permissions Required", style: TextStyle(fontWeight: FontWeight.bold)),
Text(
"VPN permissions are required for nebula to run, click the button below request and accept the appropriate permissions.",
textAlign: TextAlign.center),
ElevatedButton(
onPressed: () {
error = null;
_loadSites();
},
child: Text("Request Permissions")),
];
});
} else {
setState(() {
error = [
Text("Unknown Error", style: TextStyle(fontWeight: FontWeight.bold)),
Text(err.message ?? 'An unknown error occurred', textAlign: TextAlign.center)
];
});
}
} catch (err) {
setState(() {
error = [
Text("Unknown Error", style: TextStyle(fontWeight: FontWeight.bold)),
Text(err.toString(), textAlign: TextAlign.center)
];
});
}
}
//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(() {});
}
}