Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow the user to control which apps can use the VPN (WIP) #56

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>

<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if adding this will trigger a notification to the user when they install it from the Play Store? If you don't know that is ok, I can build a binary on the Internal Testing track and see what it does.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea about this one sorry.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are guidelines available with a required form to submit to be accepted into the Play Store with this permission, but other VPN apps are currently active with it declared, so I see no reason for it to be rejected. I recall no notifications or popups when installing or using said apps on Android 13, and the fact that it is tied to approval may mean there is no need for a user-facing warning. Regardless, it unfortunately seems to be necessary in order to add split tunneling as none of the package queries are broad enough.


<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>

Expand Down
37 changes: 36 additions & 1 deletion android/src/main/java/com/tailscale/ipn/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
import android.os.Handler;
import android.os.Looper;

import android.util.Log;

import android.Manifest;
import android.webkit.MimeTypeMap;

Expand Down Expand Up @@ -86,17 +88,26 @@ public class App extends Application {

public DnsConfig dns = new DnsConfig(this);
public DnsConfig getDnsConfigObj() { return this.dns; }
public AppsConfig acfg = null;

@Override public void onCreate() {
super.onCreate();
Log.v("com.tailscale.ipn", "Loading apps: " + System.currentTimeMillis()/1000L);
try {
acfg = new AppsConfig(this, getEncryptedPrefs());
//acfg.printConfig();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend removing leftover debugging code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I left them there because I was worried of the impact in the starting time on the App depending on the number of apps, as you pointed below loading the apps at the start is far from optimal.

} catch (Exception e) {
Log.e("com.tailscale.ipn", "exception", e);
}
Log.v("com.tailscale.ipn", "Apps loaded: " + System.currentTimeMillis()/1000L);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admittedly this only appears in adb logcat, but if it was just here for your own development recommend removing it (and the one above)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will remove it, thanks.


// Load and initialize the Go library.
Gio.init(this);
registerNetworkCallback();

createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);

}

private void registerNetworkCallback() {
Expand All @@ -123,6 +134,30 @@ public void onLinkPropertiesChanged(Network network, LinkProperties linkProperti
});
}

public void setupApp(String packageName, boolean allowed){
acfg.setApp(packageName, allowed);
}

public int getTotalApps(){
return acfg.getTotalApps();
}

public String getPackageLabel(int i){
return acfg.getPackageLabel(i);
}

public String getPackageName(int i){
return acfg.getPackageName(i);
}

public boolean appIsAllowed(int i){
return acfg.appIsAllowed(i);
}

public String getIcon(String packageName) {
return acfg.getAppIcon(packageName);
}

public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_CONNECT);
Expand Down
271 changes: 271 additions & 0 deletions android/src/main/java/com/tailscale/ipn/AppsConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I copy&pasted that, I will fix it.

// 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.content.Context;
import android.Manifest;
import android.content.SharedPreferences;
import android.util.Base64;
import android.util.Log;
import android.net.VpnService;
import java.lang.reflect.Method;
import android.content.pm.PackageManager;
import android.content.pm.ApplicationInfo;
import android.graphics.drawable.Drawable;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.lang.StringBuilder;

public class AppsConfig {
private class AppConfig {
boolean allowed;
String icon;
String packageName;
String label;
}

private static final String PREFS_NAME = "disallowedApps";

private Context ctx;
private List<AppConfig> config;
private SharedPreferences sp;

public AppsConfig(Context ctx, SharedPreferences sp) {
this.ctx = ctx;
this.sp = sp;
PackageManager pm = this.ctx.getPackageManager();

config = new ArrayList<AppConfig>();
List<ApplicationInfo> instApps = getInstalledApps(pm);

for (int i = 0;i < instApps.size(); i++) {
ApplicationInfo appinfo = instApps.get(i);
AppConfig ac = new AppConfig();
ac.packageName = appinfo.packageName;
ac.allowed = true;
ac.icon = retrieveAppIcon(pm,appinfo);
CharSequence label = pm.getApplicationLabel(appinfo);
if(label != null){
ac.label = label.toString();
} else {
ac.label = ac.packageName;
}
config.add(ac);
}
readAppsConfig();
}

public VpnService.Builder build(VpnService.Builder b){
if(b == null)
return b;

for (int i = 0;i < config.size(); i++) {
AppConfig ac = config.get(i);
if(ac.allowed == false){
try {
b.addDisallowedApplication(ac.packageName);
} catch (PackageManager.NameNotFoundException e) {

}
}
}

return b;
}

public int getTotalApps(){
return config.size();
}

public boolean appIsAllowed(int i){
AppConfig ac = config.get(i);
if(ac != null){
return ac.allowed;
}

return false;
}

public String getPackageLabel(int i){
AppConfig ac = config.get(i);
if(ac != null){
return ac.label;
}

return null;
}

public String getPackageName(int i){
AppConfig ac = config.get(i);
if(ac != null){
return ac.packageName;
}

return null;
}

// Get the icon as a png encoded as a base64 string
public String getAppIcon(String packageName) {
if(config == null)
return null;

for(int i = 0;i < config.size(); i++) {
AppConfig ac = config.get(i);
if(ac.packageName.equals(packageName)){
return ac.icon;
}
}

return null;
}

// Allow,Disallow application to connect to the VPN
public void setApp(String packageName, boolean allowed) {
if(config == null || packageName == null || packageName.length() == 0)
return;

Log.d("com.tailscale.ipn", "Disabling " + packageName);
AppConfig ac = null;

Log.d("com.tailscale.ipn", "size: " + config.size());
for (int i = 0;i < config.size(); i++) {
ac = config.get(i);
if(ac.packageName.equals(packageName) &&
ac.allowed != allowed){
//Log.d("com.tailscale.ipn", packageName);
ac.allowed = allowed;
config.set(i, ac);
writeAppsConfig();
return;
}
}
}

//returns a String like packageName;packageName;... or ""
private String getDisallowedApps() {
StringBuilder sb = new StringBuilder();

for (int i = 0;i < config.size(); i++) {
AppConfig ac = config.get(i);
if(ac.allowed == false){
sb.append(ac.packageName);
sb.append(";");
}
}

//Log.d("com.tailscale.ipn", sb.toString());
return sb.toString();
}

// persists blacklisted apps in the settings
public void writeAppsConfig() {
//Log.d("com.tailscale.ipn", "writeAppsConfig");
if(sp == null)
return;
//Log.d("com.tailscale.ipn", getDisallowedApps());
sp.edit().putString(PREFS_NAME, getDisallowedApps()).apply();
}

// read apps blacklisted in the settings
private void readAppsConfig() {
if(sp == null)
return;

String disallowedApps = sp.getString(PREFS_NAME, "");

if(disallowedApps.length() != 0){
List<String> strapps =
Arrays.asList(disallowedApps.split(";"));

if(strapps.size() > 0){
for(int i = 0; i < strapps.size(); i++){
for (int j = 0;j < config.size(); j++) {
AppConfig ac = config.get(j);
if(ac.packageName.equals(strapps.get(i))){
ac.allowed = false;
config.set(j, ac);
}
}
}
}
}
}

// Just for debug purposes prints all the apps and state
public void printConfig() {
if (config != null) {
for (int i = 0;i < config.size(); i++) {
AppConfig ac = config.get(i);
Log.v("com.tailscale.ipn",
ac.packageName + " " + ac.allowed);
Log.v("com.tailscale.ipn", ac.icon);
}
}
}

// Retrieves the icon in png encoded in base64 from android using a PackageManager
private String retrieveAppIcon(PackageManager pm, ApplicationInfo info) {
Drawable d = pm.getApplicationIcon(info);
Bitmap mutableBitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(mutableBitmap);
d.setBounds(0, 0, 48, 48);
d.draw(canvas);
//Bitmap to png to ByteArrayOutputStream to byte[] to base64 String
ByteArrayOutputStream baos = new ByteArrayOutputStream();
//100 is the quality, but it is ignored for PNG
mutableBitmap.compress(Bitmap.CompressFormat.PNG, 100, baos);
//Return the png encoded as a base64 string
return Base64.encodeToString(baos.toByteArray(),Base64.DEFAULT);
}

// Returns a sorted list of installed apps with access to The Internet
private List<ApplicationInfo> getInstalledApps(PackageManager pm) {
if(this.ctx == null)
return null;

if(pm == null)
pm = this.ctx.getPackageManager();

// Initialize variables
List<ApplicationInfo> ret = null;
List<ApplicationInfo> insApps =
pm.getInstalledApplications(PackageManager.GET_META_DATA);

// This is done in the OpenVPN code
int androidSystemUid = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like Settings_Allowed_Apps.java from OpenVPN, which is GPL. This app is BSD licensed.

Did most of this file come from OpenVPN?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not the whole class, but the logic of that method (getInstalledApps) is doing the same basically, not that there are much more alternative ways of doing it that I am aware of anyway. Also a TODO for me here, I guess it makes sense to filter tailscale from the list of apps itself, it doesn't really makes much sense to keep it in the list.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the implications of this in regards to implementation? Should I be looking to start from scratch to avoid license issues?

ret = new ArrayList<ApplicationInfo>();

try {
ApplicationInfo system =
pm.getApplicationInfo("android", PackageManager.GET_META_DATA);
if(system != null){
ret.add(system);
androidSystemUid = system.uid;
}
} catch (PackageManager.NameNotFoundException e) {
// it's ok if the "android" app doesn't exists
}

for (int i = 0;i < insApps.size(); i++){
ApplicationInfo app = insApps.get(i);
if (pm.checkPermission(
Manifest.permission.INTERNET,
app.packageName)
== PackageManager.PERMISSION_GRANTED
&& app.uid != androidSystemUid){
if(app.packageName != "android")
ret.add(app);
}
}

Collections.sort(ret, new ApplicationInfo.DisplayNameComparator(pm));
return ret;
}
}
22 changes: 10 additions & 12 deletions android/src/main/java/com/tailscale/ipn/IPNService.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,6 @@ private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
}

private void disallowApp(VpnService.Builder b, String name) {
try {
b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) {
return;
}
}

protected VpnService.Builder newBuilder() {
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent())
Expand All @@ -65,18 +57,24 @@ protected VpnService.Builder newBuilder() {
b.setMetered(false); // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
b.setUnderlyingNetworks(null); // Use all available networks.

App app = (App) this.getApplication();

// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
this.disallowApp(b, "com.google.android.apps.messaging");
app.setupApp("com.google.android.apps.messaging", false);

// Stadia https://github.com/tailscale/tailscale/issues/3460
this.disallowApp(b, "com.google.stadia.android");
app.setupApp("com.google.stadia.android", false);

// Android Auto https://github.com/tailscale/tailscale/issues/3828
this.disallowApp(b, "com.google.android.projection.gearhead");
app.setupApp("com.google.android.projection.gearhead", false);

// GoPro https://github.com/tailscale/tailscale/issues/2554
this.disallowApp(b, "com.gopro.smarty");
app.setupApp("com.gopro.smarty", false);

// Apply changes
b = app.acfg.build(b);
//app.acfg.printConfig();

return b;
}
Expand Down
Loading