all: initial commit

Signed-off-by: Elias Naur <mail@eliasnaur.com>
pull/2/head
Elias Naur 5 years ago
commit 5109987e18

8
.gitignore vendored

@ -0,0 +1,8 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build
# The destination for the Go Android archive.
android/libs

@ -0,0 +1,27 @@
Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Tailscale Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

@ -0,0 +1,31 @@
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
AAR=android/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
all: $(APK)
aar:
mkdir -p android/libs
go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -o $(AAR) tailscale.com/tailscale-android/cmd/tailscale
$(DEBUG_APK): aar
(cd android && ./gradlew assembleDebug)
mv android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): aar
(cd android && ./gradlew bundleRelease)
mv ./android/build/outputs/bundle/release/android-release.aab $@
release: $(RELEASE_AAB)
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
install: $(DEBUG_APK)
adb install -r $(DEBUG_APK)
clean:
rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR)
.PHONY: all clean install aar release

@ -0,0 +1,24 @@
Additional IP Rights Grant (Patents)
"This implementation" means the copyrightable works distributed by
Tailscale Inc. as part of the Tailscale project.
Tailscale Inc. hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated
in this section) patent license to make, have made, use, offer to
sell, sell, import, transfer and otherwise run, modify and propagate
the contents of this implementation of Tailscale, where such license
applies only to those patent claims, both currently owned or
controlled by Tailscale Inc. and acquired in the future, licensable
by Tailscale Inc. that are necessarily infringed by this
implementation of Tailscale. This grant does not include claims that
would be infringed only as a consequence of further modification of
this implementation. If you or your agent or exclusive licensee
institute or order or agree to the institution of patent litigation
against any entity (including a cross-claim or counterclaim in a
lawsuit) alleging that this implementation of Tailscale or any code
incorporated within this implementation of Tailscale constitutes
direct or contributory patent infringement, or inducement of patent
infringement, then any patent rights granted to you under this License
for this implementation of Tailscale shall terminate as of the date
such litigation is filed.

@ -0,0 +1,53 @@
# Tailscale Android Client
https://tailscale.com
Private WireGuard® networks made easy
## Overview
This repository contains the open source Tailscale Android client.
## Using
Available on [Play Store](https://play.google.com/store/apps/details?id=com.tailscale.ipn).
## Building
[Go](https://golang.org), the [Android
SDK](https://developer.android.com/studio/releases/platform-tools),
the [Android NDK](https://developer.android.com/ndk) are required.
```
$ make tailscale-debug.apk
$ adb install -r tailscale-debug.apk
```
We only guarantee to support the latest Go release and any Go beta or
release candidate builds (currently Go 1.14) in module mode. It might
work in earlier Go versions or in GOPATH mode, but we're making no
effort to keep those working.
## Bugs
Please file any issues about this code or the hosted service on
[the tailscale issue tracker](https://github.com/tailscale/tailscale/issues).
## Contributing
`under_construction.gif`
PRs welcome, but we are still working out our contribution process and
tooling.
We require [Developer Certificate of
Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
`Signed-off-by` lines in commits.
## About Us
We are apenwarr, bradfitz, crawshaw, danderson, dfcarney,
from Tailscale Inc.
You can learn more about us from [our website](https://tailscale.com).
WireGuard is a registered trademark of Jason A. Donenfeld.

@ -0,0 +1,43 @@
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.0'
}
}
allprojects {
repositories {
google()
jcenter()
flatDir {
dirs 'libs'
}
}
}
apply plugin: 'com.android.application'
android {
compileSdkVersion 29
defaultConfig {
minSdkVersion 23
targetSdkVersion 29
versionCode 2
versionName "0.1"
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
dependencies {
//implementation 'com.google.android.gms:play-services-auth:18.0.0'
implementation "androidx.core:core:1.2.0"
implementation "androidx.browser:browser:1.2.0"
implementation "androidx.security:security-crypto:1.0.0-rc01"
implementation ':ipn@aar'
}

@ -0,0 +1 @@
android.useAndroidX=true

Binary file not shown.

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

183
android/gradlew vendored

@ -0,0 +1,183 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

103
android/gradlew.bat vendored

@ -0,0 +1,103 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tailscale.ipn">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:name=".App">
<activity android:name="org.gioui.GioActivity"
android:label="Tailscale"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@ -0,0 +1,81 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.BroadcastReceiver;
import android.net.ConnectivityManager;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.security.GeneralSecurityException;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKeys;
import org.gioui.Gio;
public class App extends Application {
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
registerNetworkCallback();
}
private void registerNetworkCallback() {
BroadcastReceiver connectivityChanged = new BroadcastReceiver() {
@Override public void onReceive(Context ctx, Intent intent) {
boolean noconn = intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
onConnectivityChanged(!noconn);
}
};
registerReceiver(connectivityChanged, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_CONNECT);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_DISCONNECT);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
return EncryptedSharedPreferences.create(
"secret_shared_prefs",
masterKeyAlias,
this,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
private static native void onConnectivityChanged(boolean connected);
}

@ -0,0 +1,102 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.os.Build;
import android.app.PendingIntent;
import android.app.NotificationChannel;
import android.content.Intent;
import android.net.VpnService;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
private static final String STATUS_CHANNEL_ID = "tailscale-status";
private static final String STATUS_CHANNEL_NAME = "VPN Status";
private static final int STATUS_NOTIFICATION_ID = 1;
private static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
private static final String NOTIFY_CHANNEL_NAME = "Notifications";
private static final int NOTIFY_NOTIFICATION_ID = 2;
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
close();
return START_NOT_STICKY;
}
connect();
return START_STICKY;
}
private void close() {
stopForeground(true);
disconnect();
}
@Override public void onDestroy() {
close();
super.onDestroy();
}
@Override public void onRevoke() {
close();
super.onRevoke();
}
private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, GioActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
}
protected VpnService.Builder newBuilder() {
return new VpnService.Builder().setConfigureIntent(configIntent());
}
public void notify(String title, String message) {
createNotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_DEFAULT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(NOTIFY_NOTIFICATION_ID, builder.build());
}
public void updateStatusNotification(String title, String message) {
createNotificationChannel(STATUS_CHANNEL_ID, STATUS_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_LOW);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW);
startForeground(STATUS_NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
private native void connect();
private native void disconnect();
}

@ -0,0 +1,125 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.Fragment;
import android.app.DialogFragment;
import android.content.Intent;
import android.net.Uri;
import android.net.VpnService;
import android.os.Bundle;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.browser.customtabs.CustomTabsIntent;
/*import com.google.android.gms.auth.api.signin.GoogleSignIn;
import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
import com.google.android.gms.auth.api.signin.GoogleSignInClient;
import com.google.android.gms.auth.api.signin.GoogleSignInOptions;*/
public class Peer extends Fragment {
//private final static int REQUEST_SIGNIN = 1001;
private final static int REQUEST_PREPARE_VPN = 1002;
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
switch (requestCode) {
/*case REQUEST_SIGNIN:
if (resultCode == Activity.RESULT_OK) {
GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(getActivity());
android.util.Log.i("gio", "Account: " + acc.getId());
onSignin();
return;
}*/
case REQUEST_PREPARE_VPN:
if (resultCode == Activity.RESULT_OK) {
onVPNPrepared();
return;
}
}
super.onActivityResult(requestCode, resultCode, data);
}
@Override public void onCreate(Bundle b) {
super.onCreate(b);
fragmentCreated();
}
@Override public void onDestroy() {
fragmentDestroyed();
super.onDestroy();
}
/*public void googleSignIn() {
GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.build();
GoogleSignInClient client = GoogleSignIn.getClient(getActivity(), gso);
Intent signInIntent = client.getSignInIntent();
startActivityForResult(signInIntent, REQUEST_SIGNIN);
}*/
public void prepareVPN() {
Intent intent = VpnService.prepare(getActivity());
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(intent, REQUEST_PREPARE_VPN);
}
}
public void showURLActionView(String url) {
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse(url));
startActivity(i);
}
public void showURLCustomTabs(String url) {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
CustomTabsIntent intent = builder.build();
intent.launchUrl(getActivity(), Uri.parse(url));
}
public void showURLWebView(String url) {
DialogFragment f = new WebViewFragment();
Bundle args = new Bundle();
args.putString("url", url);
f.setArguments(args);
f.show(getFragmentManager(), "urldialog");
}
private native void fragmentCreated();
private native void fragmentDestroyed();
private native void onSignin();
private native void onVPNPrepared();
public static class WebViewFragment extends DialogFragment {
@Override public Dialog onCreateDialog(Bundle savedInstanceState) {
String url = getArguments().getString("url");
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
WebView wv = new WebView(builder.getContext()) {
@Override public boolean onCheckIsTextEditor() {
// Force the soft keyboard to appear when a text
// input is focused.
return true;
}
};
wv.setFocusable(true);
wv.setFocusableInTouchMode(true);
wv.getSettings().setJavaScriptEnabled(true);
// Work around Google OAuth refusing to work in embedded
// browsers.
final String USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:61.0) Gecko/20100101 Firefox/61.0";
wv.getSettings().setUserAgentString(USER_AGENT);
wv.setWebViewClient(new WebViewClient() {
});
wv.loadUrl(url);
return builder.setView(wv).create();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1F2125</color>
</resources>

@ -0,0 +1,10 @@
#!/bin/sh
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
set -e
mkdir -p android/libs
go run gioui.org/cmd/gogio -buildmode archive -target android -appid com.tailscale.ipn -o android/libs/ipn.aar tailscale.com/tailscale-android/cmd/tailscale
(cd android && ./gradlew assembleDebug)

@ -0,0 +1,287 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"errors"
"fmt"
"log"
"path/filepath"
"reflect"
"time"
"github.com/tailscale/wireguard-go/device"
"github.com/tailscale/wireguard-go/tun"
"golang.org/x/sys/unix"
"tailscale.com/ipn"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/logtail/filch"
"tailscale.com/tailscale-android/jni"
"tailscale.com/types/logger"
"tailscale.com/wgengine"
"tailscale.com/wgengine/router"
"tailscale.com/wgengine/tstun"
)
type backend struct {
engine wgengine.Engine
backend *ipn.LocalBackend
logger logtail.Logger
devices *multiTUN
settings func(*router.Config) error
lastCfg *router.Config
jvm jni.JVM
}
type androidRouter struct {
backend *backend
}
const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go
// errVPNNotPrepared is used when VPNService.Builder.establish returns
// null, either because the VPNService is not yet prepared or because
// VPN status was revoked.
var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked")
func newBackend(dataDir string, jvm jni.JVM, store *stateStore, settings func(*router.Config) error) (*backend, error) {
logf := wgengine.RusagePrefixLog(log.Printf)
pol := logpolicy.New("tailnode.log.tailscale.io")
b := &backend{
jvm: jvm,
devices: newTUNDevices(),
settings: settings,
}
genRouter := func(logf logger.Logf, wgdev *device.Device, tundev tun.Device) (router.Router, error) {
return &androidRouter{backend: b}, nil
}
var logID logtail.PrivateID
logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000"))
const logPrefKey = "privatelogid"
storedLogID, err := store.read(logPrefKey)
// In all failure cases we ignore any errors and continue with the dead value above.
if err != nil || storedLogID == nil {
// Read failed or there was no previous log id.
newLogID, err := logtail.NewPrivateID()
if err == nil {
logID = newLogID
enc, err := newLogID.MarshalText()
if err == nil {
store.write(logPrefKey, enc)
}
}
} else {
logID.UnmarshalText([]byte(storedLogID))
}
b.SetupLogs(dataDir, logID)
engine, err := wgengine.NewUserspaceEngineAdvanced(logf, tstun.WrapTUN(logf, b.devices), genRouter, 0)
if err != nil {
return nil, fmt.Errorf("runBackend: NewUserspaceEngineAdvanced: %v", err)
}
local, err := ipn.NewLocalBackend(logf, pol.PublicID.String(), store, engine)
if err != nil {
engine.Close()
return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err)
}
b.engine = engine
b.backend = local
return b, nil
}
func (b *backend) Start(notify func(n ipn.Notify)) error {
return b.backend.Start(ipn.Options{
StateKey: "ipn-android",
Notify: notify,
})
}
func (b *backend) LinkChange() {
if b.engine != nil {
b.engine.LinkChange(false)
}
}
func (r *androidRouter) Up() error {
return nil
}
func (r *androidRouter) Set(cfg *router.Config) error {
return r.backend.setCfg(cfg)
}
func (r *androidRouter) Close() error {
return nil
}
func (b *backend) setCfg(cfg *router.Config) error {
return b.settings(cfg)
}
func (b *backend) updateTUN(service jni.Object, cfg *router.Config) error {
if reflect.DeepEqual(cfg, b.lastCfg) {
return nil
}
err := jni.Do(b.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, service)
m := jni.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;")
builder, err := jni.CallObjectMethod(env, service, m)
if err != nil {
return fmt.Errorf("IPNService.newBuilder: %v", err)
}
bcls := jni.GetObjectClass(env, builder)
// builder.setMtu.
setMtu := jni.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;")
const mtu = defaultMTU
if _, err := jni.CallObjectMethod(env, builder, setMtu, jni.Value(mtu)); err != nil {
return fmt.Errorf("VpnService.Builder.setMtu: %v", err)
}
// builder.addDnsServer
addDnsServer := jni.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
for _, dns := range cfg.DNS {
_, err = jni.CallObjectMethod(env,
builder,
addDnsServer,
jni.Value(jni.JavaString(env, dns.String())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addDnsServer: %v", err)
}
}
// builder.addSearchDomain.
addSearchDomain := jni.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
for _, dom := range cfg.DNSDomains {
_, err = jni.CallObjectMethod(env,
builder,
addSearchDomain,
jni.Value(jni.JavaString(env, dom)),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addSearchDomain: %v", err)
}
}
// builder.addRoute.
addRoute := jni.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, route := range cfg.Routes {
_, err = jni.CallObjectMethod(env,
builder,
addRoute,
jni.Value(jni.JavaString(env, route.IP.String())),
jni.Value(route.Bits),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addRoute: %v", err)
}
}
// builder.addAddress.
addAddress := jni.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, addr := range cfg.LocalAddrs {
_, err = jni.CallObjectMethod(env,
builder,
addAddress,
jni.Value(jni.JavaString(env, addr.IP.String())),
jni.Value(addr.Bits),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addAddress: %v", err)
}
}
// builder.establish.
establish := jni.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;")
parcelFD, err := jni.CallObjectMethod(env, builder, establish)
if err != nil {
return fmt.Errorf("VpnService.Builder.establish: %v", err)
}
if parcelFD == 0 {
return errVPNNotPrepared
}
// detachFd.
parcelCls := jni.GetObjectClass(env, parcelFD)
detachFd := jni.GetMethodID(env, parcelCls, "detachFd", "()I")
tunFD, err := jni.CallIntMethod(env, parcelFD, detachFd)
if err != nil {
return fmt.Errorf("detachFd: %v", err)
}
// Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil {
unix.Close(int(tunFD))
return err
}
b.devices.add(tunDev)
return nil
})
if err != nil {
b.lastCfg = cfg
b.CloseTUNs()
return err
}
b.lastCfg = cfg
return nil
}
// CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() {
b.lastCfg = nil
b.devices.Shutdown()
}
// SetupLogs sets up remote logging.
func (b *backend) SetupLogs(logDir string, logID logtail.PrivateID) {
logcfg := logtail.Config{
Collection: "tailnode.log.tailscale.io",
PrivateID: logID,
}
logcfg.LowMemory = true
drainCh := make(chan struct{})
logcfg.DrainLogs = drainCh
go func() {
// Upload logs infrequently. Interval chosen arbitrarily.
// The objective is to reduce phone power use.
t := time.NewTicker(2 * time.Minute)
for range t.C {
select {
case drainCh <- struct{}{}:
default:
}
}
}()
filchOpts := filch.Options{
ReplaceStderr: true,
}
var filchErr error
if logDir != "" {
logPath := filepath.Join(logDir, "ipn.log.")
logcfg.Buffer, filchErr = filch.New(logPath, filchOpts)
}
logf := wgengine.RusagePrefixLog(log.Printf)
b.logger = logtail.Log(logcfg, logf)
log.SetFlags(0)
log.SetOutput(b.logger)
log.Printf("goSetupLogs: success")
if logDir == "" {
log.Printf("SetupLogs: no logDir, storing logs in memory")
}
if filchErr != nil {
log.Printf("SetupLogs: filch setup failed: %v", filchErr)
}
}

@ -0,0 +1,92 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
// JNI implementations of Java native callback methods.
import (
"sync/atomic"
"unsafe"
"tailscale.com/tailscale-android/jni"
)
// #include <jni.h>
import "C"
var (
vpnPrepared = make(chan struct{}, 1)
// onConnect receives global IPNService references when
// a VPN connection is requested.
onConnect = make(chan jni.Object)
// onDisconnect receives global IPNService references when
// disconnecting.
onDisconnect = make(chan jni.Object)
// onConnectivityChange is notified every time the network
// conditions change.
onConnectivityChange = make(chan struct{}, 1)
// onPeerCreated receives global instances of Java Peer
// instances being created.
onPeerCreated = make(chan jni.Object)
// onPeerDestroyed receives new global instances of Java Peer
// instances about to be destroyed
onPeerDestroyed = make(chan jni.Object)
)
var (
connected atomic.Value
)
func init() {
connected.Store(true)
}
//export Java_com_tailscale_ipn_Peer_fragmentCreated
func Java_com_tailscale_ipn_Peer_fragmentCreated(env *C.JNIEnv, this C.jobject) {
jenv := jni.EnvFor(uintptr(unsafe.Pointer(env)))
onPeerCreated <- jni.NewGlobalRef(jenv, jni.Object(this))
}
//export Java_com_tailscale_ipn_Peer_fragmentDestroyed
func Java_com_tailscale_ipn_Peer_fragmentDestroyed(env *C.JNIEnv, this C.jobject) {
jenv := jni.EnvFor(uintptr(unsafe.Pointer(env)))
onPeerDestroyed <- jni.NewGlobalRef(jenv, jni.Object(this))
}
//export Java_com_tailscale_ipn_Peer_onVPNPrepared
func Java_com_tailscale_ipn_Peer_onVPNPrepared(env *C.JNIEnv, this C.jobject) {
select {
case vpnPrepared <- struct{}{}:
default:
}
}
//export Java_com_tailscale_ipn_Peer_onSignin
func Java_com_tailscale_ipn_Peer_onSignin(env *C.JNIEnv, this C.jobject) {
// TODO(eliasnaur)
}
//export Java_com_tailscale_ipn_IPNService_connect
func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) {
jenv := jni.EnvFor(uintptr(unsafe.Pointer(env)))
onConnect <- jni.NewGlobalRef(jenv, jni.Object(this))
}
//export Java_com_tailscale_ipn_IPNService_disconnect
func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) {
jenv := jni.EnvFor(uintptr(unsafe.Pointer(env)))
onDisconnect <- jni.NewGlobalRef(jenv, jni.Object(this))
}
//export Java_com_tailscale_ipn_App_onConnectivityChanged
func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls C.jclass, newConnected C.jboolean) {
connected.Store(newConnected == C.JNI_TRUE)
select {
case onConnectivityChange <- struct{}{}:
default:
}
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,526 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"log"
"sort"
"strings"
"sync"
"time"
"gioui.org/app"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/font/gofont"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tailscale-android/jni"
"tailscale.com/wgengine/router"
)
//go:generate go run github.com/go-bindata/go-bindata/go-bindata -nocompress -o logo.go tailscale.png
type App struct {
jvm jni.JVM
appCtx jni.Object
appDir string
store *stateStore
// updates is notifies whenever netState or browseURL changes.
updates chan struct{}
// vpnClosed is notified when the VPNService is closed while
// logged in.
vpnClosed chan struct{}
// mu protects the following fields.
mu sync.Mutex
// netState is the most recent network state.
netState NetworkState
// browseURL is set whenever the backend wants to
// browse.
browseURL *string
}
type clientState struct {
browseURL string
net NetworkState
// query is the search query, in lowercase.
query string
Peers []UIPeer
// WantsEnabled is the desired state of the VPN: enabled or
// disabled.
WantsEnabled bool
}
type NetworkState struct {
State ipn.State
NetworkMap *controlclient.NetworkMap
HasInternet bool
}
// UIEvent is an event flowing from the UI to the backend.
type UIEvent interface{}
type ConnectEvent struct {
Enable bool
}
type CopyEvent struct {
Text string
}
type SearchEvent struct {
Query string
}
type ReauthEvent struct{}
type LogoutEvent struct{}
const enabledKey = "ipn_enabled"
func main() {
a := &App{
jvm: jni.JVMFor(app.JavaVM()),
appCtx: jni.Object(app.AppContext()),
updates: make(chan struct{}, 1),
vpnClosed: make(chan struct{}, 1),
}
appDir, err := app.DataDir()
if err != nil {
fatalErr(err)
}
a.appDir = appDir
a.store = newStateStore(a.appDir, a.jvm, a.appCtx)
events := make(chan UIEvent)
go func() {
if err := a.runBackend(events); err != nil {
fatalErr(err)
}
}()
go func() {
if err := a.runUI(events); err != nil {
fatalErr(err)
}
}()
app.Main()
}
func (a *App) runBackend(events <-chan UIEvent) error {
var cfg *router.Config
var state NetworkState
var service jni.Object
var b *backend
b, err := newBackend(a.appDir, a.jvm, a.store, func(s *router.Config) error {
cfg = s
if b == nil || service == 0 || cfg == nil {
return nil
}
return b.updateTUN(service, cfg)
})
if err != nil {
return err
}
defer b.CloseTUNs()
var timer *time.Timer
var alarmChan <-chan time.Time
alarm := func(t *time.Timer) {
if timer != nil {
timer.Stop()
}
timer = t
if timer != nil {
alarmChan = timer.C
}
}
err = b.Start(func(n ipn.Notify) {
if s := n.State; s != nil {
oldState := state.State
state.State = *s
if service != 0 {
a.updateNotification(service, state.State)
}
if service != 0 {
if cfg != nil && state.State >= ipn.Starting {
if err := b.updateTUN(service, cfg); err != nil {
a.notifyVPNClosed()
}
} else {
b.CloseTUNs()
}
}
// Stop VPN if we logged out.
if oldState > ipn.NeedsLogin && state.State <= ipn.NeedsLogin {
if err := a.callVoidMethod(a.appCtx, "stopVPN", "()V"); err != nil {
fatalErr(err)
}
}
a.notify(state)
}
if u := n.BrowseToURL; u != nil {
a.setURL(*u)
}
if m := n.NetMap; m != nil {
state.NetworkMap = m
a.notify(state)
if service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
}
}
})
if err != nil {
return err
}
for {
select {
case <-alarmChan:
if m := state.NetworkMap; m != nil && service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
}
case e := <-events:
switch e.(type) {
case ReauthEvent:
b.backend.StartLoginInteractive()
case LogoutEvent:
b.backend.Logout()
}
case s := <-onConnect:
jni.Do(a.jvm, func(env jni.Env) error {
if jni.IsSameObject(env, s, service) {
// We already have a reference.
jni.DeleteGlobalRef(env, s)
return nil
}
if service != 0 {
jni.DeleteGlobalRef(env, service)
}
service = s
return nil
})
a.updateNotification(service, state.State)
if m := state.NetworkMap; m != nil {
alarm(a.notifyExpiry(service, m.Expiry))
}
if cfg != nil && state.State >= ipn.Starting {
if err := b.updateTUN(service, cfg); err != nil {
a.notifyVPNClosed()
}
}
case <-onConnectivityChange:
state.HasInternet = connected.Load().(bool)
if b != nil {
b.LinkChange()
}
a.notify(state)
case s := <-onDisconnect:
b.CloseTUNs()
jni.Do(a.jvm, func(env jni.Env) error {
defer jni.DeleteGlobalRef(env, s)
if jni.IsSameObject(env, service, s) {
jni.DeleteGlobalRef(env, service)
service = 0
}
return nil
})
if state.State >= ipn.Starting {
a.notifyVPNClosed()
}
}
}
}
// updateNotification updates the foreground persistent status notification.
func (a *App) updateNotification(service jni.Object, state ipn.State) error {
var msg, title string
switch state {
case ipn.Starting:
title, msg = "Connecting...", ""
case ipn.Running:
title, msg = "Connected", ""
default:
return nil
}
return jni.Do(a.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, service)
update := jni.GetMethodID(env, cls, "updateStatusNotification", "(Ljava/lang/String;Ljava/lang/String;)V")
jtitle := jni.JavaString(env, title)
jmessage := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, service, update, jni.Value(jtitle), jni.Value(jmessage))
})
}
// notifyExpiry notifies the user of imminent session expiry and
// returns a new timer that triggers when the user should be notified
// again.
func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer {
d := time.Until(expiry)
var title string
const msg = "Reauthenticate to maintain the connection to your network."
var t *time.Timer
const (
aday = 24 * time.Hour
soon = 5 * time.Minute
)
switch {
case d <= 0:
title = "Your authentication has expired!"
case d <= soon:
title = "Your authentication expires soon!"
t = time.NewTimer(d)
case d <= aday:
title = "Your authentication expires in a day."
t = time.NewTimer(d - soon)
default:
return time.NewTimer(d - aday)
}
err := jni.Do(a.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, service)
notify := jni.GetMethodID(env, cls, "notify", "(Ljava/lang/String;Ljava/lang/String;)V")
jtitle := jni.JavaString(env, title)
jmessage := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, service, notify, jni.Value(jtitle), jni.Value(jmessage))
})
if err != nil {
fatalErr(err)
}
return t
}
func (a *App) notifyVPNClosed() {
select {
case a.vpnClosed <- struct{}{}:
default:
}
}
func (a *App) notify(state NetworkState) {
a.mu.Lock()
a.netState = state
a.mu.Unlock()
select {
case a.updates <- struct{}{}:
default:
}
}
func (a *App) setURL(url string) {
a.mu.Lock()
a.browseURL = &url
a.mu.Unlock()
select {
case a.updates <- struct{}{}:
default:
}
}
func (a *App) runUI(backend chan<- UIEvent) error {
w := app.NewWindow()
gofont.Register()
ui, err := newUI(a.store)
if err != nil {
return err
}
// Register an Android Fragment instance for lifecycle tracking
// of our Activity.
w.RegisterFragment("com.tailscale.ipn.Peer")
var ops op.Ops
state := new(clientState)
var peer jni.Object
state.WantsEnabled, _ = a.store.ReadBool(enabledKey, true)
ui.enabled.Value = state.WantsEnabled
for {
select {
case <-a.vpnClosed:
state.WantsEnabled = false
w.Invalidate()
case <-a.updates:
a.mu.Lock()
oldState := state.net.State
state.net = a.netState
if a.browseURL != nil {
state.browseURL = *a.browseURL
a.browseURL = nil
}
a.mu.Unlock()
a.updateState(peer, state)
w.Invalidate()
if peer != 0 {
newState := state.net.State
// Start VPN if we just logged in.
if state.WantsEnabled && oldState <= ipn.NeedsLogin && newState > ipn.NeedsLogin {
if err := a.callVoidMethod(peer, "prepareVPN", "()V"); err != nil {
fatalErr(err)
}
}
}
case peer = <-onPeerCreated:
w.Invalidate()
a.setVPNState(peer, state)
case p := <-onPeerDestroyed:
jni.Do(a.jvm, func(env jni.Env) error {
defer jni.DeleteGlobalRef(env, p)
if jni.IsSameObject(env, peer, p) {
jni.DeleteGlobalRef(env, peer)
peer = 0
}
return nil
})
case <-vpnPrepared:
if err := a.callVoidMethod(a.appCtx, "startVPN", "()V"); err != nil {
return err
}
case e := <-w.Events():
switch e := e.(type) {
case system.DestroyEvent:
return e.Err
case system.FrameEvent:
gtx := layout.NewContext(&ops, e.Queue, e.Config, e.Size)
events := ui.layout(gtx, e.Insets, state)
e.Frame(gtx.Ops)
a.processUIEvents(backend, w, events, peer, state)
}
}
}
}
func (a *App) updateState(javaPeer jni.Object, state *clientState) {
if javaPeer != 0 && state.browseURL != "" {
a.browseToURL(javaPeer, state.browseURL)
state.browseURL = ""
}
state.Peers = nil
netMap := state.net.NetworkMap
if netMap == nil {
return
}
// Split into sections.
users := make(map[tailcfg.UserID]struct{})
var peers []UIPeer
for _, p := range netMap.Peers {
if q := state.query; q != "" {
// Filter peers according to search query.
host := strings.ToLower(p.Hostinfo.Hostname)
name := strings.ToLower(p.Name)
var addr string
if len(p.Addresses) > 0 {
addr = p.Addresses[0].IP.String()
}
if !strings.Contains(host, q) && !strings.Contains(name, q) && !strings.Contains(addr, q) {
continue
}
}
users[p.User] = struct{}{}
peers = append(peers, UIPeer{
Owner: p.User,
Peer: p,
})
}
// Add section (user) headers.
for u := range users {
name := netMap.UserProfiles[u].DisplayName
name = strings.ToUpper(name)
peers = append(peers, UIPeer{Owner: u, Name: name})
}
myID := state.net.NetworkMap.User
sort.Slice(peers, func(i, j int) bool {
lhs, rhs := peers[i], peers[j]
if lu, ru := lhs.Owner, rhs.Owner; ru != lu {
// Sort own peers first.
if lu == myID {
return true
}
if ru == myID {
return false
}
return lu < ru
}
lp, rp := lhs.Peer, rhs.Peer
// Sort headers first.
if lp == nil {
return true
}
if rp == nil {
return false
}
return lp.Hostinfo.Hostname < rp.Hostinfo.Hostname ||
lp.Hostinfo.Hostname == rp.Hostinfo.Hostname && lp.ID < rp.ID
})
state.Peers = peers
}
func (a *App) processUIEvents(backend chan<- UIEvent, w *app.Window, events []UIEvent, peer jni.Object, state *clientState) {
for _, e := range events {
switch e := e.(type) {
case ReauthEvent:
go func() {
backend <- e
}()
case LogoutEvent:
go func() {
backend <- e
}()
case CopyEvent:
w.WriteClipboard(e.Text)
case SearchEvent:
state.query = strings.ToLower(e.Query)
a.updateState(peer, state)
case ConnectEvent:
if e.Enable == state.WantsEnabled {
return
}
if e.Enable && peer == 0 {
return
}
state.WantsEnabled = e.Enable
a.store.WriteBool(enabledKey, e.Enable)
a.updateState(peer, state)
a.setVPNState(peer, state)
}
}
}
func (a *App) setVPNState(peer jni.Object, state *clientState) {
var err error
if state.WantsEnabled && state.net.State > ipn.NeedsLogin {
err = a.callVoidMethod(peer, "prepareVPN", "()V")
} else {
err = a.callVoidMethod(a.appCtx, "stopVPN", "()V")
}
if err != nil {
fatalErr(err)
}
}
func (a *App) browseToURL(peer jni.Object, url string) {
err := jni.Do(a.jvm, func(env jni.Env) error {
jurl := jni.JavaString(env, url)
return a.callVoidMethod(peer, "showURLCustomTabs", "(Ljava/lang/String;)V", jni.Value(jurl))
})
if err != nil {
fatalErr(err)
}
}
func (a *App) callVoidMethod(obj jni.Object, name, sig string, args ...jni.Value) error {
if obj == 0 {
panic("invalid object")
}
return jni.Do(a.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, obj)
m := jni.GetMethodID(env, cls, name, sig)
return jni.CallVoidMethod(env, obj, m, args...)
})
}
func fatalErr(err error) {
log.Print(err)
}

@ -0,0 +1,287 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"os"
"github.com/tailscale/wireguard-go/tun"
)
// multiTUN implements a tun.Device that supports multiple
// underlying devices. This is necessary because Android VPN devices
// have static configurations and wgengine.NewUserspaceEngineAdvanced
// assumes a single static tun.Device.
type multiTUN struct {
// devices is for adding new devices.
devices chan tun.Device
// event is the combined event channel from all active devices.
events chan tun.Event
close chan struct{}
closeErr chan error
reads chan ioRequest
writes chan ioRequest
flushes chan chan error
mtus chan chan mtuReply
names chan chan nameReply
shutdowns chan struct{}
}
// tunDevice wraps and drives a single run.Device.
type tunDevice struct {
dev tun.Device
// close closes the device.
close chan struct{}
closeDone chan error
// readDone is notified when the read goroutine is done.
readDone chan struct{}
}
type ioRequest struct {
data []byte
offset int
reply chan<- ioReply
}
type ioReply struct {
bytes int
err error
}
type mtuReply struct {
mtu int
err error
}
type nameReply struct {
name string
err error
}
func newTUNDevices() *multiTUN {
d := &multiTUN{
devices: make(chan tun.Device),
events: make(chan tun.Event),
close: make(chan struct{}),
closeErr: make(chan error),
reads: make(chan ioRequest),
writes: make(chan ioRequest),
flushes: make(chan chan error),
mtus: make(chan chan mtuReply),
names: make(chan chan nameReply),
shutdowns: make(chan struct{}),
}
go d.run()
return d
}
func (d *multiTUN) run() {
var devices []*tunDevice
// readDone is the readDone channel of the device being read from.
var readDone chan struct{}
// runDone is the closeDone channel of the device being written to.
var runDone chan error
for {
select {
case <-readDone:
// The oldest device has reached EOF, replace it.
n := copy(devices, devices[1:])
devices = devices[:n]
if len(devices) > 0 {
// Start reading from the next device.
dev := devices[0]
readDone = dev.readDone
go d.readFrom(dev)
}
case <-runDone:
// A device completed runDevice, replace it.
if len(devices) > 0 {
dev := devices[len(devices)-1]
runDone = dev.closeDone
go d.runDevice(dev)
}
case <-d.shutdowns:
// Shut down all devices.
for _, dev := range devices {
close(dev.close)
<-dev.closeDone
<-dev.readDone
}
devices = nil
case <-d.close:
var derr error
for _, dev := range devices {
if err := <-dev.closeDone; err != nil {
derr = err
}
}
d.closeErr <- derr
return
case dev := <-d.devices:
if len(devices) > 0 {
// Ask the most recent device to stop.
prev := devices[len(devices)-1]
close(prev.close)
}
wrap := &tunDevice{
dev: dev,
close: make(chan struct{}),
closeDone: make(chan error),
readDone: make(chan struct{}, 1),
}
if len(devices) == 0 {
// Start using this first device.
readDone = wrap.readDone
go d.readFrom(wrap)
runDone = wrap.closeDone
go d.runDevice(wrap)
}
devices = append(devices, wrap)
case f := <-d.flushes:
var err error
if len(devices) > 0 {
dev := devices[len(devices)-1]
err = dev.dev.Flush()
}
f <- err
case m := <-d.mtus:
r := mtuReply{mtu: defaultMTU}
if len(devices) > 0 {
dev := devices[len(devices)-1]
r.mtu, r.err = dev.dev.MTU()
}
m <- r
case n := <-d.names:
var r nameReply
if len(devices) > 0 {
dev := devices[len(devices)-1]
r.name, r.err = dev.dev.Name()
}
n <- r
}
}
}
func (d *multiTUN) readFrom(dev *tunDevice) {
defer func() {
dev.readDone <- struct{}{}
}()
for {
select {
case r := <-d.reads:
n, err := dev.dev.Read(r.data, r.offset)
stop := false
if err != nil {
select {
case <-dev.close:
stop = true
err = nil
default:
}
}
r.reply <- ioReply{n, err}
if stop {
return
}
case <-d.close:
return
}
}
}
func (d *multiTUN) runDevice(dev *tunDevice) {
defer func() {
// The documentation for https://developer.android.com/reference/android/net/VpnService.Builder#establish()
// states that "Therefore, after draining the old file
// descriptor...", but pending Reads are never unblocked
// when a new descriptor is created.
//
// Close it instead and hope that no packets are lost.
dev.closeDone <- dev.dev.Close()
}()
// Pump device events.
go func() {
for {
select {
case e := <-dev.dev.Events():
d.events <- e
case <-dev.close:
return
}
}
}()
for {
select {
case w := <-d.writes:
n, err := dev.dev.Write(w.data, w.offset)
w.reply <- ioReply{n, err}
case <-dev.close:
// Device closed.
return
case <-d.close:
// Multi-device closed.
return
}
}
}
func (d *multiTUN) add(dev tun.Device) {
d.devices <- dev
}
func (d *multiTUN) File() *os.File {
// The underlying file descriptor is not constant on Android.
// Let's hope no-one uses it.
panic("not available on Android")
}
func (d *multiTUN) Read(data []byte, offset int) (int, error) {
r := make(chan ioReply)
d.reads <- ioRequest{data, offset, r}
rep := <-r
return rep.bytes, rep.err
}
func (d *multiTUN) Write(data []byte, offset int) (int, error) {
r := make(chan ioReply)
d.writes <- ioRequest{data, offset, r}
rep := <-r
return rep.bytes, rep.err
}
func (d *multiTUN) Flush() error {
r := make(chan error)
d.flushes <- r
return <-r
}
func (d *multiTUN) MTU() (int, error) {
r := make(chan mtuReply)
d.mtus <- r
rep := <-r
return rep.mtu, rep.err
}
func (d *multiTUN) Name() (string, error) {
r := make(chan nameReply)
d.names <- r
rep := <-r
return rep.name, rep.err
}
func (d *multiTUN) Events() chan tun.Event {
return d.events
}
func (d *multiTUN) Shutdown() {
d.shutdowns <- struct{}{}
}
func (d *multiTUN) Close() error {
close(d.close)
return <-d.closeErr
}

@ -0,0 +1,18 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build pprof
package main
import (
"net/http"
_ "net/http/pprof"
)
func init() {
go func() {
http.ListenAndServe(":6060", nil)
}()
}

@ -0,0 +1,121 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"encoding/base64"
"tailscale.com/ipn"
"tailscale.com/tailscale-android/jni"
)
// stateStore is the Go interface for a persistent storage
// backend by androidx.security.crypto.EncryptedSharedPreferences (see
// App.java).
type stateStore struct {
dataDir string
jvm jni.JVM
// appCtx is the global Android app context.
appCtx jni.Object
// Cached method ids on appCtx.
encrypt jni.MethodID
decrypt jni.MethodID
}
func newStateStore(dataDir string, jvm jni.JVM, appCtx jni.Object) *stateStore {
s := &stateStore{
dataDir: dataDir,
jvm: jvm,
appCtx: appCtx,
}
jni.Do(jvm, func(env jni.Env) error {
appCls := jni.GetObjectClass(env, appCtx)
s.encrypt = jni.GetMethodID(
env, appCls,
"encryptToPref", "(Ljava/lang/String;Ljava/lang/String;)V",
)
s.decrypt = jni.GetMethodID(
env, appCls,
"decryptFromPref", "(Ljava/lang/String;)Ljava/lang/String;",
)
return nil
})
return s
}
func prefKeyFor(id ipn.StateKey) string {
return "statestore-" + string(id)
}
func (s *stateStore) ReadBool(key string, def bool) (bool, error) {
data, err := s.read(key)
if err != nil {
return def, err
}
if data == nil {
return def, nil
}
return string(data) == "true", nil
}
func (s *stateStore) WriteBool(key string, val bool) error {
data := []byte("false")
if val {
data = []byte("true")
}
return s.write(key, data)
}
func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) {
state, err := s.read(prefKeyFor(id))
if err != nil {
return nil, err
}
if state == nil {
return nil, ipn.ErrStateNotExist
}
return state, nil
}
func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error {
prefKey := prefKeyFor(id)
return s.write(prefKey, bs)
}
func (s *stateStore) read(key string) ([]byte, error) {
var data []byte
err := jni.Do(s.jvm, func(env jni.Env) error {
jfile := jni.JavaString(env, key)
plain, err := jni.CallObjectMethod(env, s.appCtx, s.decrypt,
jni.Value(jfile))
if err != nil {
return err
}
b64 := jni.GoString(env, jni.String(plain))
if b64 == "" {
return nil
}
data, err = base64.RawStdEncoding.DecodeString(b64)
return err
})
return data, err
}
func (s *stateStore) write(key string, value []byte) error {
bs64 := base64.RawStdEncoding.EncodeToString(value)
err := jni.Do(s.jvm, func(env jni.Env) error {
jfile := jni.JavaString(env, key)
jplain := jni.JavaString(env, bs64)
err := jni.CallVoidMethod(env, s.appCtx, s.encrypt,
jni.Value(jfile), jni.Value(jplain))
if err != nil {
return err
}
return nil
})
return err
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@ -0,0 +1,794 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"fmt"
"image"
"image/color"
"strings"
"time"
"gioui.org/f32"
"gioui.org/io/pointer"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"golang.org/x/exp/shiny/materialdesign/icons"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
_ "image/png"
)
type UI struct {
theme *material.Theme
store *stateStore
// root is the scrollable list of the main UI.
root layout.List
// enabled is the switch for enabling or disabling the VPN.
enabled widget.Bool
search widget.Editor
signin widget.Clickable
self widget.Clickable
peers []widget.Clickable
intro struct {
start widget.Clickable
show bool
}
menu struct {
open widget.Clickable
dismiss Dismiss
show bool
copy widget.Clickable
reauth widget.Clickable
logout widget.Clickable
}
// The current pop-up message, if any
message struct {
text string
// t0 is the time when the most recent message appeared.
t0 time.Time
}
icons struct {
search *widget.Icon
more *widget.Icon
logo paint.ImageOp
}
events []UIEvent
}
// An UIPeer is either a peer or a section header
// with the user information.
type UIPeer struct {
// Owner of the peer.
Owner tailcfg.UserID
// Name is the owner's name in all caps (for section headers).
Name string
// Peer is nil for section headers.
Peer *tailcfg.Node
}
const (
headerColor = 0x496495
infoColor = 0x3a517b
white = 0xffffff
)
const (
keyShowIntro = "ui.showintro"
)
type (
C = layout.Context
D = layout.Dimensions
)
func newUI(store *stateStore) (*UI, error) {
searchIcon, err := widget.NewIcon(icons.ActionSearch)
if err != nil {
return nil, err
}
moreIcon, err := widget.NewIcon(icons.NavigationMoreVert)
if err != nil {
return nil, err
}
logoData, err := tailscalePngBytes()
if err != nil {
return nil, err
}
logo, _, err := image.Decode(bytes.NewReader(logoData))
if err != nil {
return nil, err
}
ui := &UI{
theme: material.NewTheme(),
store: store,
}
ui.intro.show, _ = store.ReadBool(keyShowIntro, true)
ui.icons.search = searchIcon
ui.icons.more = moreIcon
ui.icons.logo = paint.NewImageOp(logo)
ui.icons.more.Color = rgb(white)
ui.icons.search.Color = ui.theme.Color.Hint
ui.root.Axis = layout.Vertical
ui.search.SingleLine = true
return ui, nil
}
func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent {
ui.events = nil
if ui.enabled.Changed() {
ui.events = append(ui.events, ConnectEvent{Enable: ui.enabled.Value})
}
ui.enabled.Value = state.WantsEnabled
for _, e := range ui.search.Events() {
if _, ok := e.(widget.ChangeEvent); ok {
ui.events = append(ui.events, SearchEvent{Query: ui.search.Text()})
break
}
}
for ui.menu.open.Clicked() {
ui.menu.show = !ui.menu.show
}
netmap := state.net.NetworkMap
var localName, localAddr string
var expiry time.Time
if netmap != nil {
expiry = netmap.Expiry
localName = netmap.Hostinfo.Hostname
if addrs := netmap.Addresses; len(addrs) > 0 {
localAddr = addrs[0].IP.String()
}
}
if ui.signin.Clicked() {
ui.events = append(ui.events, ReauthEvent{})
}
if ui.menuClicked(&ui.menu.copy) && localAddr != "" {
ui.copyAddress(gtx, localAddr)
}
if ui.menuClicked(&ui.menu.reauth) {
ui.events = append(ui.events, ReauthEvent{})
}
if ui.menuClicked(&ui.menu.logout) {
ui.events = append(ui.events, LogoutEvent{})
}
for len(ui.peers) < len(state.Peers) {
ui.peers = append(ui.peers, widget.Clickable{})
}
if max := len(state.Peers); len(ui.peers) > max {
ui.peers = ui.peers[:max]
}
const numHeaders = 5
n := numHeaders + len(state.Peers)
needsLogin := state.net.State == ipn.NeedsLogin
ui.root.Layout(gtx, n, func(gtx C, idx int) D {
var in layout.Inset
if idx == n-1 {
// The last list element includes the bottom system
// inset.
in.Bottom = sysIns.Bottom
}
return in.Layout(gtx, func(gtx C) D {
switch idx {
case 0:
return ui.layoutTop(gtx, sysIns, &state.net)
case 1:
if netmap == nil {
return D{}
}
return ui.layoutLocal(gtx, sysIns, localName, localAddr)
case 2:
if state.net.State <= ipn.NeedsLogin {
return D{}
}
return ui.layoutSearchbar(gtx, sysIns)
case 3:
if !needsLogin {
return D{}
}
return ui.layoutSignIn(gtx)
case 4:
if needsLogin || state.net.HasInternet {
return D{}
}
return ui.layoutDisconnected(gtx)
default:
if needsLogin {
return D{}
}
pidx := idx - numHeaders
p := &state.Peers[pidx]
if p.Peer == nil {
name := p.Name
if p.Owner == netmap.User {
name = "MY DEVICES"
}
return ui.layoutSection(gtx, sysIns, name)
} else {
clk := &ui.peers[pidx]
return ui.layoutPeer(gtx, sysIns, p, clk)
}
}
})
})
// "Copied" message.
ui.layoutMessage(gtx, sysIns)
// 3-dots menu.
if ui.menu.show {
ui.layoutMenu(gtx, sysIns, expiry)
}
// "Get started".
if ui.intro.show {
if ui.intro.start.Clicked() {
ui.store.WriteBool(keyShowIntro, false)
ui.intro.show = false
}
ui.layoutIntro(gtx)
}
return ui.events
}
// Dismiss is a widget that detects pointer presses.
type Dismiss struct {
}
func (d *Dismiss) Add(gtx layout.Context) {
var stack op.StackOp
stack.Push(gtx.Ops)
defer stack.Pop()
pointer.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Add(gtx.Ops)
pointer.InputOp{Tag: d}.Add(gtx.Ops)
}
func (d *Dismiss) Dismissed(gtx layout.Context) bool {
for _, e := range gtx.Events(d) {
if e, ok := e.(pointer.Event); ok {
if e.Type == pointer.Press {
return true
}
}
}
return false
}
// layoutSignIn lays out the sign in button(s).
func (ui *UI) layoutSignIn(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(48)}.Layout(gtx, func(gtx C) D {
return layout.Center.Layout(gtx, func(gtx C) D {
signin := material.Button(ui.theme, &ui.signin, "Sign In")
signin.Background = rgb(headerColor)
return signin.Layout(gtx)
})
})
}
// layoutDisconnected lays out the "please connect to the internet"
// message.
func (ui *UI) layoutDisconnected(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
title := material.H6(ui.theme, "No internet connection")
title.Alignment = text.Middle
return title.Layout(gtx)
})
}),
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
msg := material.Body2(ui.theme, "Tailscale is paused while your device is offline. Please reconnect to the internet.")
msg.Alignment = text.Middle
return msg.Layout(gtx)
})
}),
)
})
}
// layoutIntro lays out the intro page with the logo and terms.
func (ui *UI) layoutIntro(gtx layout.Context) {
fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max)
layout.Flex{Axis: layout.Vertical}.Layout(gtx,
// 9 dot logo.
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(80), Bottom: unit.Dp(48)}.Layout(gtx, func(gtx C) D {
return layout.N.Layout(gtx, func(gtx C) D {
sz := gtx.Px(unit.Dp(72))
drawLogo(gtx.Ops, sz)
return layout.Dimensions{Size: image.Pt(sz, sz)}
})
})
}),
// "tailscale".
layout.Rigid(func(gtx C) D {
return layout.N.Layout(gtx, func(gtx C) D {
img := ui.icons.logo
img.Add(gtx.Ops)
sz := img.Size()
aspect := float32(sz.Y) / float32(sz.X)
w := gtx.Px(unit.Dp(200))
h := int(float32(w)*aspect + .5)
paint.PaintOp{Rect: f32.Rectangle{Max: f32.Pt(float32(w), float32(h))}}.Add(gtx.Ops)
return layout.Dimensions{Size: image.Pt(w, h)}
})
}),
// Terms.
layout.Rigid(func(gtx C) D {
return layout.Inset{
Top: unit.Dp(48),
Left: unit.Dp(32),
Right: unit.Dp(32),
}.Layout(gtx, func(gtx C) D {
terms := material.Body2(ui.theme, termsText)
terms.Color = rgb(0xbfbfbf)
terms.Alignment = text.Middle
return terms.Layout(gtx)
})
}),
// "Get started".
layout.Rigid(func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
start := material.Button(ui.theme, &ui.intro.start, "Get Started")
start.Inset = layout.UniformInset(unit.Dp(16))
start.CornerRadius = unit.Dp(16)
start.Background = rgb(0x496495)
start.TextSize = unit.Sp(20)
return start.Layout(gtx)
})
}),
)
}
// menuClicked is like btn.Clicked, but also closes the menu if true.
func (ui *UI) menuClicked(btn *widget.Clickable) bool {
cl := btn.Clicked()
if cl {
ui.menu.show = false
}
return cl
}
// layoutMenu lays out the menu activated by the 3 dots button.
func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.Time) {
ui.menu.dismiss.Add(gtx)
if ui.menu.dismiss.Dismissed(gtx) {
ui.menu.show = false
}
layout.Inset{
Top: unit.Add(gtx, sysIns.Top, unit.Dp(2)),
Right: unit.Add(gtx, sysIns.Right, unit.Dp(2)),
}.Layout(gtx, func(gtx C) D {
return layout.NE.Layout(gtx, func(gtx C) D {
return Background{Color: argb(0x33000000), CornerRadius: unit.Dp(2)}.Layout(gtx, func(gtx C) D {
return layout.UniformInset(unit.Px(1)).Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0xfafafa), CornerRadius: unit.Px(4)}.Layout(gtx, func(gtx C) D {
menu := &ui.menu
items := []struct {
btn *widget.Clickable
title string
}{
{title: "Copy My IP Address", btn: &menu.copy},
{title: "Reauthenticate", btn: &menu.reauth},
{title: "Log out", btn: &menu.logout},
}
// Lay out menu items twice; once for
// measuring the widest item, once for actual layout.
var maxWidth int
var minWidth int
children := []layout.FlexChild{
layout.Rigid(func(gtx C) D {
return layout.Inset{
Top: unit.Dp(16),
Right: unit.Dp(16),
Left: unit.Dp(16),
Bottom: unit.Dp(4),
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = minWidth
var expiryStr string
const fmtStr = time.Stamp
switch {
case expiry.IsZero():
expiryStr = "Expires: (never)"
case time.Now().After(expiry):
expiryStr = fmt.Sprintf("Expired: %s", expiry.Format(fmtStr))
default:
expiryStr = fmt.Sprintf("Expires: %s", expiry.Format(fmtStr))
}
l := material.Caption(ui.theme, expiryStr)
l.Color = rgb(0x8f8f8f)
dims := l.Layout(gtx)
if w := dims.Size.X; w > maxWidth {
maxWidth = w
}
return dims
})
}),
}
for i := 0; i < len(items); i++ {
it := &items[i]
children = append(children, layout.Rigid(func(gtx C) D {
return material.Clickable(gtx, it.btn, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = minWidth
dims := material.Body1(ui.theme, it.title).Layout(gtx)
if w := dims.Size.X; w > maxWidth {
maxWidth = w
}
return dims
})
})
}))
}
f := layout.Flex{Axis: layout.Vertical}
// First pass: record and discard operations
// and determine widest item.
var m op.MacroOp
m.Record(gtx.Ops)
f.Layout(gtx, children...)
m.Stop()
// Second pass: layout items with equal width.
minWidth = maxWidth
return f.Layout(gtx, children...)
})
})
})
})
})
}
func (ui *UI) layoutMessage(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
s := ui.message.text
if s == "" {
return D{}
}
now := gtx.Now()
d := now.Sub(ui.message.t0)
rem := 4*time.Second - d
if rem < 0 {
return D{}
}
op.InvalidateOp{At: now.Add(rem)}.Add(gtx.Ops)
return layout.S.Layout(gtx, func(gtx C) D {
return layout.Inset{Bottom: unit.Add(gtx, sysIns.Bottom, unit.Dp(8))}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0x323232), CornerRadius: unit.Dp(5)}.Layout(gtx, func(gtx C) D {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D {
l := material.Body2(ui.theme, s)
l.Color = rgb(0xdddddd)
return l.Layout(gtx)
})
})
})
})
}
func (ui *UI) showMessage(gtx layout.Context, msg string) {
ui.message.text = msg
ui.message.t0 = gtx.Now()
op.InvalidateOp{}.Add(gtx.Ops)
}
// layoutPeer lays out a peer name and IP address (e.g.
// "localhost\n100.100.100.101")
func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *UIPeer, clk *widget.Clickable) layout.Dimensions {
for clk.Clicked() {
if addrs := p.Peer.Addresses; len(addrs) > 0 {
ui.copyAddress(gtx, addrs[0].IP.String())
}
}
return material.Clickable(gtx, clk, func(gtx C) D {
return layout.Inset{
Top: unit.Dp(8),
Right: unit.Max(gtx, sysIns.Right, unit.Dp(16)),
Left: unit.Max(gtx, sysIns.Left, unit.Dp(16)),
Bottom: unit.Dp(8),
}.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
name := p.Peer.Hostinfo.Hostname
if name == "" {
name = p.Peer.ID.String()
}
return material.H6(ui.theme, name).Layout(gtx)
})
}),
layout.Rigid(func(gtx C) D {
var addrs []string
for _, addr := range p.Peer.Addresses {
addrs = append(addrs, addr.IP.String())
}
l := material.Body2(ui.theme, strings.Join(addrs, ","))
l.Color = rgb(0x434343)
return l.Layout(gtx)
}),
)
})
})
}
// layoutSection lays out a section title (e.g. "My devices").
func (ui *UI) layoutSection(gtx layout.Context, sysIns system.Insets, title string) layout.Dimensions {
return Background{Color: rgb(0xe1e0e9)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Top: unit.Dp(16),
Right: unit.Max(gtx, sysIns.Right, unit.Dp(16)),
Left: unit.Max(gtx, sysIns.Left, unit.Dp(16)),
Bottom: unit.Dp(16),
}.Layout(gtx, func(gtx C) D {
l := material.Body1(ui.theme, title)
l.Color = rgb(0x6f797d)
return l.Layout(gtx)
})
})
}
// layoutTop lays out the top controls: toggle, status and menu dots.
func (ui *UI) layoutTop(gtx layout.Context, sysIns system.Insets, state *NetworkState) layout.Dimensions {
in := layout.Inset{
Top: unit.Dp(16),
Bottom: unit.Dp(16),
}
return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Top: sysIns.Top,
Right: unit.Max(gtx, sysIns.Right, unit.Dp(8)),
Left: unit.Max(gtx, sysIns.Left, unit.Dp(16)),
}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return in.Layout(gtx, func(gtx C) D {
sw := material.Switch(ui.theme, &ui.enabled)
sw.Color = rgb(white)
return sw.Layout(gtx)
})
}),
layout.Flexed(1, func(gtx C) D {
return in.Layout(gtx, func(gtx C) D {
return layout.Inset{Left: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
lbl := material.Body1(ui.theme, statusString(state.State))
lbl.Color = rgb(0xffffff)
return lbl.Layout(gtx)
})
})
}),
layout.Rigid(func(gtx C) D {
if state.State <= ipn.NeedsLogin {
return D{}
}
return material.Clickable(gtx, &ui.menu.open, func(gtx C) D {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D {
return ui.icons.more.Layout(gtx, unit.Dp(24))
})
})
}),
)
})
})
}
func statusString(state ipn.State) string {
switch state {
case ipn.Stopped:
return "Stopped"
case ipn.Starting:
return "Starting..."
case ipn.Running:
return "Active"
case ipn.NeedsMachineAuth:
return "Awaiting Approval"
case ipn.NeedsLogin:
return "Needs Authentication"
default:
return "Loading..."
}
}
func (ui *UI) copyAddress(gtx layout.Context, addr string) {
ui.events = append(ui.events, CopyEvent{Text: addr})
ui.showMessage(gtx, fmt.Sprintf("Copied %s", addr))
}
// layoutLocal lays out the information box about the local node's
// name and IP address.
func (ui *UI) layoutLocal(gtx layout.Context, sysIns system.Insets, host, addr string) layout.Dimensions {
for ui.self.Clicked() {
ui.copyAddress(gtx, addr)
}
return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Right: unit.Max(gtx, sysIns.Right, unit.Dp(8)),
Left: unit.Max(gtx, sysIns.Left, unit.Dp(8)),
Bottom: unit.Dp(8),
}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(infoColor), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
return material.Clickable(gtx, &ui.self, func(gtx C) D {
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
name := material.H6(ui.theme, host)
name.Color = ui.theme.Color.InvText
return name.Layout(gtx)
})
}),
layout.Rigid(func(gtx C) D {
name := material.Body2(ui.theme, addr)
name.Color = rgb(0xc5ccd9)
return name.Layout(gtx)
}),
)
})
})
})
})
})
}
func (ui *UI) layoutSearchbar(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
return Background{Color: rgb(0xf0eff6)}.Layout(gtx, func(gtx C) D {
return layout.Inset{
Top: unit.Dp(8),
Right: unit.Max(gtx, sysIns.Right, unit.Dp(8)),
Left: unit.Max(gtx, sysIns.Left, unit.Dp(8)),
Bottom: unit.Dp(8),
}.Layout(gtx, func(gtx C) D {
return Background{Color: rgb(0xe3e2ea), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return ui.icons.search.Layout(gtx, unit.Dp(24))
}),
layout.Flexed(1,
material.Editor(ui.theme, &ui.search, "Search by hostname...").Layout,
),
)
})
})
})
})
}
// drawLogo draws the Tailscale logo using vector operations.
func drawLogo(ops *op.Ops, size int) {
scale := float32(size) / 680
discDia := 170 * scale
off := 172 * 1.5 * scale
tx := op.TransformOp{}.Offset(f32.Pt(off, 0))
ty := op.TransformOp{}.Offset(f32.Pt(0, off))
var st op.StackOp
st.Push(ops)
defer st.Pop()
var row op.StackOp
// First row of discs.
row.Push(ops)
drawDisc(ops, discDia, rgb(0x54514d))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0x54514d))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0x54514d))
row.Pop()
ty.Add(ops)
// Second row.
row.Push(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
row.Pop()
ty.Add(ops)
// Third row.
row.Push(ops)
drawDisc(ops, discDia, rgb(0x54514d))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0xfffdfa))
tx.Add(ops)
drawDisc(ops, discDia, rgb(0x54514d))
row.Pop()
}
func drawDisc(ops *op.Ops, radius float32, col color.RGBA) {
var st op.StackOp
st.Push(ops)
defer st.Pop()
r2 := radius * .5
dr := f32.Rectangle{Max: f32.Pt(radius, radius)}
clip.Rect{
Rect: dr,
NE: r2, NW: r2, SE: r2, SW: r2,
}.Op(ops).Add(ops)
paint.ColorOp{Color: col}.Add(ops)
paint.PaintOp{Rect: dr}.Add(ops)
}
// background lays out a widget and draws a color background behind
// it.
type Background struct {
Color color.RGBA
CornerRadius unit.Value
}
func (b Background) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
var m op.MacroOp
m.Record(gtx.Ops)
dims := w(gtx)
sz := dims.Size
m.Stop()
// Clip corners, if any.
if r := gtx.Px(b.CornerRadius); r > 0 {
rr := float32(r)
clip.Rect{
Rect: f32.Rectangle{Max: f32.Point{
X: float32(sz.X),
Y: float32(sz.Y),
}},
NE: rr, NW: rr, SE: rr, SW: rr,
}.Op(gtx.Ops).Add(gtx.Ops)
}
fill{b.Color}.Layout(gtx, sz)
m.Add()
return dims
}
type fill struct {
col color.RGBA
}
func (f fill) Layout(gtx layout.Context, sz image.Point) layout.Dimensions {
var st op.StackOp
st.Push(gtx.Ops)
defer st.Pop()
dr := f32.Rectangle{Max: layout.FPt(sz)}
paint.ColorOp{Color: f.col}.Add(gtx.Ops)
paint.PaintOp{Rect: dr}.Add(gtx.Ops)
return layout.Dimensions{Size: sz}
}
func rgb(c uint32) color.RGBA {
return argb((0xff << 24) | c)
}
func argb(c uint32) color.RGBA {
return color.RGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}
const termsText = `Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.
We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.`

@ -0,0 +1,12 @@
module tailscale.com/tailscale-android
go 1.14
require (
gioui.org v0.0.0-20200524174833-ad93e3212824
gioui.org/cmd v0.0.0-20200508063126-0ad8f85c05e7 // indirect
github.com/tailscale/wireguard-go v0.0.0-20200515231107-62868271d710
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e
tailscale.com v0.98.1-0.20200524053159-e6b84f2159ba
)

187
go.sum

@ -0,0 +1,187 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gioui.org v0.0.0-20200503190452-8d9612f9aa46/go.mod h1:AHI9rFr6AEEHCb8EPVtb/p5M+NMJRKH58IOp8O3Je04=
gioui.org v0.0.0-20200524174833-ad93e3212824 h1:vQP8qwWQXun8lXmj707eZcsuem2X6aF3w0z8CnrL8PI=
gioui.org v0.0.0-20200524174833-ad93e3212824/go.mod h1:AHI9rFr6AEEHCb8EPVtb/p5M+NMJRKH58IOp8O3Je04=
gioui.org/cmd v0.0.0-20200502185633-fa7f9d3ba897 h1:o/P0x46brgIfnKt9QpQs51o30wxAGWgnq1kECJgJ+s4=
gioui.org/cmd v0.0.0-20200502185633-fa7f9d3ba897/go.mod h1:MA/AKwBq+dTw6ajU3Vtju89BJ6a/zWIg8JLXC2nja08=
gioui.org/cmd v0.0.0-20200508063126-0ad8f85c05e7 h1:gziosxqkR5CpjUTPWQKmkLFwpUMRGNuMcmCvurF58hg=
gioui.org/cmd v0.0.0-20200508063126-0ad8f85c05e7/go.mod h1:pSCbdDbqNf8zuUHR3zv2om4R2946FUJBOI618vt5AoE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/coreos/go-iptables v0.4.5 h1:DpHb9vJrZQEFMcVLFKAAGMUVX0XoRC0ptCthinRYm38=
github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-bindata/go-bindata v1.0.0 h1:DZ34txDXWn1DyWa+vQf7V9ANc2ILTtrEjtlsdJRF26M=
github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE=
github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg=
github.com/goreleaser/nfpm v1.1.10/go.mod h1:oOcoGRVwvKIODz57NUfiRwFWGfn00NXdgnn6MrYtO5k=
github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4 h1:nwOc1YaOrYJ37sEBrtWZrdqzK22hiJs3GpDmP3sR2Yw=
github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.9.8 h1:VMAMUUOh+gaxKTMk+zqbjsSjsIcUcL/LF4o63i82QyA=
github.com/klauspost/compress v1.9.8/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
github.com/mdlayher/netlink v1.1.0 h1:mpdLgm+brq10nI9zM1BpX1kpDbh3NLl3RSnVq6ZSkfg=
github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pborman/getopt v0.0.0-20190409184431-ee0cd42419d3/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
github.com/peterbourgon/ff v1.7.0/go.mod h1:/KKxnU5cBj4w21jEMj4Rway/kslRP6XAOHh7CH8AyAM=
github.com/peterbourgon/ff/v2 v2.0.0/go.mod h1:xjwr+t+SjWm4L46fcj/D+Ap+6ME7+HqFzaP22pP5Ggk=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tailscale/hujson v0.0.0-20190930033718-5098e564d9b3/go.mod h1:STqf+YV0ADdzk4ejtXFsGqDpATP9JoL0OB+hiFQbkdE=
github.com/tailscale/winipcfg-go v0.0.0-20200213045944-185b07f8233f/go.mod h1:x880GWw5fvrl2DVTQ04ttXQD4DuppTt1Yz6wLibbjNE=
github.com/tailscale/winipcfg-go v0.0.0-20200413171540-609dcf2df55f h1:uFj5bslHsMzxIM8UTjAhq4VXeo6GfNW91rpoh/WMJaY=
github.com/tailscale/winipcfg-go v0.0.0-20200413171540-609dcf2df55f/go.mod h1:x880GWw5fvrl2DVTQ04ttXQD4DuppTt1Yz6wLibbjNE=
github.com/tailscale/wireguard-go v0.0.0-20200317013323-239518935266 h1:Dhtc6KmHWCBWukI47jywK+9vIxFQxFIL5qxSIgg7QdQ=
github.com/tailscale/wireguard-go v0.0.0-20200317013323-239518935266/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4=
github.com/tailscale/wireguard-go v0.0.0-20200416194755-23aababa2084 h1:8FolyyuEIqny/MUD2VrTE8Damx0bG+UGix7OXXm0EeY=
github.com/tailscale/wireguard-go v0.0.0-20200416194755-23aababa2084/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4=
github.com/tailscale/wireguard-go v0.0.0-20200424121617-8d10f231531a h1:HMkTFyhcvZaKf7+7T76rks4HqB83fptUemBIfLGI6TM=
github.com/tailscale/wireguard-go v0.0.0-20200424121617-8d10f231531a/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4=
github.com/tailscale/wireguard-go v0.0.0-20200515231107-62868271d710 h1:I6aq3tOYbZob9uwhGpr7R266qTeU9PFqS6NnpfCqEzo=
github.com/tailscale/wireguard-go v0.0.0-20200515231107-62868271d710/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
go4.org/mem v0.0.0-20200411205429-f77f31c81751 h1:sgGPu7KkyLjyOYOwKFHCtnfosdSuM5q2Gud23Y/+nzw=
go4.org/mem v0.0.0-20200411205429-f77f31c81751/go.mod h1:NEYvpHWemiG/E5UWfaN5QAIGZeT1sa0Z2UNk6oeMb/k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4 h1:QmwruyY+bKbDDL0BaglrbZABEali68eoMFhTZpCjYVA=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200317142112-1b76d66859c6 h1:TjszyFsQsyZNHwdVdZ5m7bjmreu0znc2kRYsEml9/Ww=
golang.org/x/crypto v0.0.0-20200317142112-1b76d66859c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs=
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191003212358-c178f38b412c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200316230553-a7d97aace0b0 h1:4Khi5GeNOkZS5DqSBRn4Sy7BE6GuxwOqARPqfurkdNk=
golang.org/x/sys v0.0.0-20200316230553-a7d97aace0b0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d h1:62ap6LNOjDU6uGmKXHJbSfciMoV+FeI1sRXx/pLDL44=
golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e h1:hq86ru83GdWTlfQFZGO4nZJTU4Bs2wfHl8oFHRaXsfc=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e h1:1xWUkZQQ9Z9UuZgNaIR6OQOE7rUFglXUUBZlO+dGg6I=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d h1:/iIZNFGxc/a7C3yWjGcnboV+Tkc7mxr+p6fDztwoxuM=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gortc.io/stun v1.22.1 h1:96mOdDATYRqhYB+TZdenWBg4CzL2Ye5kPyBXQ8KAB+8=
gortc.io/stun v1.22.1/go.mod h1:XD5lpONVyjvV3BgOyJFNo0iv6R2oZB4L+weMqxts+zg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
inet.af/netaddr v0.0.0-20200417213433-f9e5bcc2d6ea h1:DpXewrGVf9+vvYQFrNGj9v34bXMuTVQv+2wuULTNV8I=
inet.af/netaddr v0.0.0-20200417213433-f9e5bcc2d6ea/go.mod h1:qqYzz/2whtrbWJvt+DNWQyvekNN4ePQZcg2xc2/Yjww=
inet.af/netaddr v0.0.0-20200430175045-5aaf2097c7fc h1:We3b/z+7i9LV4Ls0yWve5vYIlnAPSPeqxKVgZseRDBs=
inet.af/netaddr v0.0.0-20200430175045-5aaf2097c7fc/go.mod h1:qqYzz/2whtrbWJvt+DNWQyvekNN4ePQZcg2xc2/Yjww=
rsc.io/goversion v1.2.0 h1:SPn+NLTiAG7w30IRK/DKp1BjvpWabYgxlLp/+kx5J8w=
rsc.io/goversion v1.2.0/go.mod h1:Eih9y/uIBS3ulggl7KNJ09xGSLcuNaLgmvvqa07sgfo=
tailscale.com v0.96.2-0.20200501084119-0068e574073e h1:Jaw1TwXru/ulQmsP9b51o2eYoDBYoMaZP6e/yJc0ZaI=
tailscale.com v0.96.2-0.20200501084119-0068e574073e/go.mod h1:lSEmuWEgZd+EAwk+m6d4Zmk7I5UsEBUGyBBzpf90XV8=
tailscale.com v0.96.2-0.20200501140524-7b901fdbbc51 h1:JlkWBx63N3Su6NnEu0+hlNzud+wqprlR/QxHhmySAzg=
tailscale.com v0.96.2-0.20200501140524-7b901fdbbc51/go.mod h1:lSEmuWEgZd+EAwk+m6d4Zmk7I5UsEBUGyBBzpf90XV8=
tailscale.com v0.98.1-0.20200524053159-e6b84f2159ba h1:P+BSN4UNWBuehhB9s17WDynwjVxgQqV+asPThTiMOwg=
tailscale.com v0.98.1-0.20200524053159-e6b84f2159ba/go.mod h1:qhqIOURjwBji/8sD4E3HmTou3eyp/2HfFuPLdjs7ge8=

@ -0,0 +1,25 @@
__attribute__ ((visibility ("hidden"))) jint _jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version);
__attribute__ ((visibility ("hidden"))) jint _jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args);
__attribute__ ((visibility ("hidden"))) jint _jni_DetachCurrentThread(JavaVM *vm);
__attribute__ ((visibility ("hidden"))) jclass _jni_FindClass(JNIEnv *env, const char *name);
__attribute__ ((visibility ("hidden"))) jthrowable _jni_ExceptionOccurred(JNIEnv *env);
__attribute__ ((visibility ("hidden"))) void _jni_ExceptionClear(JNIEnv *env);
__attribute__ ((visibility ("hidden"))) jclass _jni_GetObjectClass(JNIEnv *env, jobject obj);
__attribute__ ((visibility ("hidden"))) jmethodID _jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig);
__attribute__ ((visibility ("hidden"))) jmethodID _jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig);
__attribute__ ((visibility ("hidden"))) jsize _jni_GetStringLength(JNIEnv *env, jstring str);
__attribute__ ((visibility ("hidden"))) const jchar *_jni_GetStringChars(JNIEnv *env, jstring str);
__attribute__ ((visibility ("hidden"))) jstring _jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len);
__attribute__ ((visibility ("hidden"))) jboolean _jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2);
__attribute__ ((visibility ("hidden"))) jobject _jni_NewGlobalRef(JNIEnv *env, jobject obj);
__attribute__ ((visibility ("hidden"))) void _jni_DeleteGlobalRef(JNIEnv *env, jobject obj);
__attribute__ ((visibility ("hidden"))) jint _jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args);
__attribute__ ((visibility ("hidden"))) jobject _jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args);
__attribute__ ((visibility ("hidden"))) void _jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args);
__attribute__ ((visibility ("hidden"))) jobject _jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args);
__attribute__ ((visibility ("hidden"))) jint _jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args);
__attribute__ ((visibility ("hidden"))) void _jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args);
__attribute__ ((visibility ("hidden"))) jbyteArray _jni_NewByteArray(JNIEnv *env, jsize length);
__attribute__ ((visibility ("hidden"))) jbyte *_jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr);
__attribute__ ((visibility ("hidden"))) void _jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode);
__attribute__ ((visibility ("hidden"))) jsize _jni_GetArrayLength(JNIEnv *env, jarray arr);

@ -0,0 +1,101 @@
#include <jni.h>
jint _jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) {
return (*vm)->AttachCurrentThread(vm, p_env, thr_args);
}
jint _jni_DetachCurrentThread(JavaVM *vm) {
return (*vm)->DetachCurrentThread(vm);
}
jint _jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version) {
return (*vm)->GetEnv(vm, (void **)env, version);
}
jclass _jni_FindClass(JNIEnv *env, const char *name) {
return (*env)->FindClass(env, name);
}
jthrowable _jni_ExceptionOccurred(JNIEnv *env) {
return (*env)->ExceptionOccurred(env);
}
void _jni_ExceptionClear(JNIEnv *env) {
(*env)->ExceptionClear(env);
}
jclass _jni_GetObjectClass(JNIEnv *env, jobject obj) {
return (*env)->GetObjectClass(env, obj);
}
jmethodID _jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetMethodID(env, clazz, name, sig);
}
jmethodID _jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetStaticMethodID(env, clazz, name, sig);
}
jsize _jni_GetStringLength(JNIEnv *env, jstring str) {
return (*env)->GetStringLength(env, str);
}
const jchar *_jni_GetStringChars(JNIEnv *env, jstring str) {
return (*env)->GetStringChars(env, str, NULL);
}
jstring _jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
return (*env)->NewString(env, unicodeChars, len);
}
jboolean _jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2) {
return (*env)->IsSameObject(env, ref1, ref2);
}
jobject _jni_NewGlobalRef(JNIEnv *env, jobject obj) {
return (*env)->NewGlobalRef(env, obj);
}
void _jni_DeleteGlobalRef(JNIEnv *env, jobject obj) {
(*env)->DeleteGlobalRef(env, obj);
}
void _jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) {
(*env)->CallStaticVoidMethodA(env, cls, method, args);
}
jint _jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) {
return (*env)->CallStaticIntMethodA(env, cls, method, args);
}
jobject _jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) {
return (*env)->CallStaticObjectMethodA(env, cls, method, args);
}
jobject _jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallObjectMethodA(env, obj, method, args);
}
jint _jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallIntMethodA(env, obj, method, args);
}
void _jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
(*env)->CallVoidMethodA(env, obj, method, args);
}
jbyteArray _jni_NewByteArray(JNIEnv *env, jsize length) {
return (*env)->NewByteArray(env, length);
}
jbyte *_jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr) {
return (*env)->GetByteArrayElements(env, arr, NULL);
}
void _jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode) {
(*env)->ReleaseByteArrayElements(env, arr, elems, mode);
}
jsize _jni_GetArrayLength(JNIEnv *env, jarray arr) {
return (*env)->GetArrayLength(env, arr);
}

@ -0,0 +1,267 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package jni
// Package jni implements various helper functions for communicating with the Android JVM
// though JNI.
import (
"errors"
"fmt"
"reflect"
"runtime"
"unicode/utf16"
"unsafe"
)
/*
#cgo CFLAGS: -Wall
#include <jni.h>
#include <stdlib.h>
#include "gojni.h"
*/
import "C"
type JVM struct {
jvm *C.JavaVM
}
type Env struct {
env *C.JNIEnv
}
type (
Class C.jclass
Object C.jobject
MethodID C.jmethodID
String C.jstring
ByteArray C.jbyteArray
Value uint64 // All JNI types fit into 64-bits.
)
func JVMFor(jvmPtr uintptr) JVM {
return JVM{
jvm: (*C.JavaVM)(unsafe.Pointer(jvmPtr)),
}
}
func EnvFor(envPtr uintptr) Env {
return Env{
env: (*C.JNIEnv)(unsafe.Pointer(envPtr)),
}
}
// Do invokes a function with a temporary JVM environment. The
// environment is not valid after the function returns.
func Do(vm JVM, f func(env Env) error) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var env *C.JNIEnv
var detach bool
if res := C._jni_GetEnv(vm.jvm, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C._jni_AttachCurrentThread(vm.jvm, &env, nil) != C.JNI_OK {
panic(errors.New("runInJVM: AttachCurrentThread failed"))
}
detach = true
}
if detach {
defer func() {
C._jni_DetachCurrentThread(vm.jvm)
}()
}
return f(Env{env})
}
func varArgs(args []Value) *C.jvalue {
if len(args) == 0 {
return nil
}
return (*C.jvalue)(unsafe.Pointer(&args[0]))
}
func IsSameObject(e Env, ref1, ref2 Object) bool {
same := C._jni_IsSameObject(e.env, C.jobject(ref1), C.jobject(ref2))
return same == C.JNI_TRUE
}
func CallStaticIntMethod(e Env, cls Class, method MethodID, args ...Value) (int, error) {
res := C._jni_CallStaticIntMethodA(e.env, C.jclass(cls), C.jmethodID(method), varArgs(args))
return int(res), exception(e)
}
func CallStaticVoidMethod(e Env, cls Class, method MethodID, args ...Value) error {
C._jni_CallStaticVoidMethodA(e.env, C.jclass(cls), C.jmethodID(method), varArgs(args))
return exception(e)
}
func CallVoidMethod(e Env, obj Object, method MethodID, args ...Value) error {
C._jni_CallVoidMethodA(e.env, C.jobject(obj), C.jmethodID(method), varArgs(args))
return exception(e)
}
func CallStaticObjectMethod(e Env, cls Class, method MethodID, args ...Value) (Object, error) {
res := C._jni_CallStaticObjectMethodA(e.env, C.jclass(cls), C.jmethodID(method), varArgs(args))
return Object(res), exception(e)
}
func CallObjectMethod(e Env, obj Object, method MethodID, args ...Value) (Object, error) {
res := C._jni_CallObjectMethodA(e.env, C.jobject(obj), C.jmethodID(method), varArgs(args))
return Object(res), exception(e)
}
func CallIntMethod(e Env, obj Object, method MethodID, args ...Value) (int32, error) {
res := C._jni_CallIntMethodA(e.env, C.jobject(obj), C.jmethodID(method), varArgs(args))
return int32(res), exception(e)
}
// GetByteArrayElements returns the contents of the array.
func GetByteArrayElements(e Env, jarr ByteArray) []byte {
size := C._jni_GetArrayLength(e.env, C.jarray(jarr))
elems := C._jni_GetByteArrayElements(e.env, C.jbyteArray(jarr))
defer C._jni_ReleaseByteArrayElements(e.env, C.jbyteArray(jarr), elems, 0)
backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:size:size]
s := make([]byte, len(backing))
copy(s, backing)
return s
}
// NewByteArray allocates a Java byte array with the content. It
// panics if the allocation fails.
func NewByteArray(e Env, content []byte) ByteArray {
jarr := C._jni_NewByteArray(e.env, C.jsize(len(content)))
if jarr == 0 {
panic(fmt.Errorf("jni: NewByteArray(%d) failed", len(content)))
}
elems := C._jni_GetByteArrayElements(e.env, jarr)
defer C._jni_ReleaseByteArrayElements(e.env, jarr, elems, 0)
backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:len(content):len(content)]
copy(backing, content)
return ByteArray(jarr)
}
// ClassLoader returns a reference to the Java ClassLoader associated
// with obj.
func ClassLoaderFor(e Env, obj Object) Object {
cls := GetObjectClass(e, obj)
getClassLoader := GetMethodID(e, cls, "getClassLoader", "()Ljava/lang/ClassLoader;")
clsLoader, err := CallObjectMethod(e, Object(obj), getClassLoader)
if err != nil {
// Class.getClassLoader should never fail.
panic(err)
}
return Object(clsLoader)
}
// LoadClass invokes the underlying ClassLoader's loadClass method and
// returns the class.
func LoadClass(e Env, loader Object, class string) (Class, error) {
cls := GetObjectClass(e, loader)
loadClass := GetMethodID(e, cls, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;")
name := JavaString(e, class)
loaded, err := CallObjectMethod(e, loader, loadClass, Value(name))
if err != nil {
return 0, err
}
return Class(loaded), exception(e)
}
// exception returns an error corresponding to the pending
// exception, and clears it. exceptionError returns nil if no
// exception is pending.
func exception(e Env) error {
thr := C._jni_ExceptionOccurred(e.env)
if thr == 0 {
return nil
}
C._jni_ExceptionClear(e.env)
cls := GetObjectClass(e, Object(thr))
toString := GetMethodID(e, cls, "toString", "()Ljava/lang/String;")
msg, err := CallObjectMethod(e, Object(thr), toString)
if err != nil {
return err
}
return errors.New(GoString(e, String(msg)))
}
// GetObjectClass returns the Java Class for an Object.
func GetObjectClass(e Env, obj Object) Class {
if obj == 0 {
panic("null object")
}
cls := C._jni_GetObjectClass(e.env, C.jobject(obj))
if err := exception(e); err != nil {
// GetObjectClass should never fail.
panic(err)
}
return Class(cls)
}
// GetStaticMethodID returns the id for a static method. It panics if the method
// wasn't found.
func GetStaticMethodID(e Env, cls Class, name, signature string) MethodID {
mname := C.CString(name)
defer C.free(unsafe.Pointer(mname))
msig := C.CString(signature)
defer C.free(unsafe.Pointer(msig))
m := C._jni_GetStaticMethodID(e.env, C.jclass(cls), mname, msig)
if err := exception(e); err != nil {
panic(err)
}
return MethodID(m)
}
// GetMethodID returns the id for a method. It panics if the method
// wasn't found.
func GetMethodID(e Env, cls Class, name, signature string) MethodID {
mname := C.CString(name)
defer C.free(unsafe.Pointer(mname))
msig := C.CString(signature)
defer C.free(unsafe.Pointer(msig))
m := C._jni_GetMethodID(e.env, C.jclass(cls), mname, msig)
if err := exception(e); err != nil {
panic(err)
}
return MethodID(m)
}
func NewGlobalRef(e Env, obj Object) Object {
return Object(C._jni_NewGlobalRef(e.env, C.jobject(obj)))
}
func DeleteGlobalRef(e Env, obj Object) {
C._jni_DeleteGlobalRef(e.env, C.jobject(obj))
}
// JavaString converts the string to a JVM jstring.
func JavaString(e Env, str string) String {
if str == "" {
return 0
}
utf16Chars := utf16.Encode([]rune(str))
res := C._jni_NewString(e.env, (*C.jchar)(unsafe.Pointer(&utf16Chars[0])), C.int(len(utf16Chars)))
return String(res)
}
// GoString converts the JVM jstring to a Go string.
func GoString(e Env, str String) string {
if str == 0 {
return ""
}
strlen := C._jni_GetStringLength(e.env, C.jstring(str))
chars := C._jni_GetStringChars(e.env, C.jstring(str))
var utf16Chars []uint16
hdr := (*reflect.SliceHeader)(unsafe.Pointer(&utf16Chars))
hdr.Data = uintptr(unsafe.Pointer(chars))
hdr.Cap = int(strlen)
hdr.Len = int(strlen)
utf8 := utf16.Decode(utf16Chars)
return string(utf8)
}
Loading…
Cancel
Save