wireguard-android/app/src/main/java/com/wireguard/android/backends/VpnService.java
Samuel Holland b7196e328a Rename package bindings -> databinding
Signed-off-by: Samuel Holland <samuel@sholland.org>
2018-01-06 04:08:01 -06:00

560 lines
23 KiB
Java

package com.wireguard.android.backends;
import android.app.Service;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Build;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.provider.OpenableColumns;
import android.service.quicksettings.TileService;
import android.system.OsConstants;
import android.util.Log;
import android.widget.Toast;
import com.wireguard.android.NotSupportedActivity;
import com.wireguard.android.QuickTileService;
import com.wireguard.android.R;
import com.wireguard.android.databinding.ObservableSortedMap;
import com.wireguard.android.databinding.ObservableTreeMap;
import com.wireguard.config.Config;
import com.wireguard.config.Peer;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/**
* Service that handles config state coordination and all background processing for the application.
*/
public class VpnService extends Service
implements SharedPreferences.OnSharedPreferenceChangeListener {
public static final String KEY_ENABLED_CONFIGS = "enabled_configs";
public static final String KEY_PRIMARY_CONFIG = "primary_config";
public static final String KEY_RESTORE_ON_BOOT = "restore_on_boot";
private static final String TAG = "WireGuard/VpnService";
private static VpnService instance;
private final IBinder binder = new Binder();
private final ObservableTreeMap<String, Config> configurations = new ObservableTreeMap<>();
private final Set<String> enabledConfigs = new HashSet<>();
private SharedPreferences preferences;
private String primaryName;
private RootShell rootShell;
public static VpnService getInstance() {
return instance;
}
/**
* Add a new configuration to the set of known configurations. The configuration will initially
* be disabled. The configuration's name must be unique within the set of known configurations.
*
* @param config The configuration to add.
*/
public void add(final Config config) {
new ConfigUpdater(null, config, false).execute();
}
/**
* Attempt to disable and tear down an interface for this configuration. The configuration's
* enabled state will be updated the operation is successful. If this configuration is already
* disconnected, or it is not a known configuration, no changes will be made.
*
* @param name The name of the configuration (in the set of known configurations) to disable.
*/
public void disable(final String name) {
final Config config = configurations.get(name);
if (config == null || !config.isEnabled())
return;
new ConfigDisabler(config).execute();
}
/**
* Attempt to set up and enable an interface for this configuration. The configuration's enabled
* state will be updated if the operation is successful. If this configuration is already
* enabled, or it is not a known configuration, no changes will be made.
*
* @param name The name of the configuration (in the set of known configurations) to enable.
*/
public void enable(final String name) {
final Config config = configurations.get(name);
if (config == null || config.isEnabled())
return;
new ConfigEnabler(config).execute();
}
/**
* Retrieve a configuration known and managed by this service. The returned object must not be
* modified directly.
*
* @param name The name of the configuration (in the set of known configurations) to retrieve.
* @return An object representing the configuration. This object must not be modified.
*/
public Config get(final String name) {
return configurations.get(name);
}
/**
* Retrieve the set of configurations known and managed by the service. Configurations in this
* set must not be modified directly. If a configuration is to be updated, first create a copy
* of it by calling getCopy().
*
* @return The set of known configurations.
*/
public ObservableSortedMap<String, Config> getConfigs() {
return configurations;
}
public void importFrom(final Uri... uris) {
new ConfigImporter().execute(uris);
}
@Override
public IBinder onBind(final Intent intent) {
instance = this;
return binder;
}
@Override
public void onCreate() {
// Ensure the service sticks around after being unbound. This only needs to happen once.
startService(new Intent(this, getClass()));
rootShell = new RootShell(this);
new ConfigLoader().execute(getFilesDir().listFiles(new FilenameFilter() {
@Override
public boolean accept(final File dir, final String name) {
return name.endsWith(".conf");
}
}));
preferences = PreferenceManager.getDefaultSharedPreferences(this);
preferences.registerOnSharedPreferenceChangeListener(this);
}
@Override
public void onDestroy() {
preferences.unregisterOnSharedPreferenceChangeListener(this);
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences preferences,
final String key) {
if (!KEY_PRIMARY_CONFIG.equals(key))
return;
boolean changed = false;
final String newName = preferences.getString(key, null);
if (primaryName != null && !primaryName.equals(newName)) {
final Config oldConfig = configurations.get(primaryName);
if (oldConfig != null)
oldConfig.setIsPrimary(false);
changed = true;
}
if (newName != null && !newName.equals(primaryName)) {
final Config newConfig = configurations.get(newName);
if (newConfig != null)
newConfig.setIsPrimary(true);
else
preferences.edit().remove(KEY_PRIMARY_CONFIG).apply();
changed = true;
}
primaryName = newName;
if (changed)
updateTile();
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
instance = this;
return START_STICKY;
}
/**
* Remove a configuration from being managed by the service. If it is currently enabled, the
* the configuration will be disabled before removal. If successful, the configuration will be
* removed from persistent storage. If the configuration is not known to the service, no changes
* will be made.
*
* @param name The name of the configuration (in the set of known configurations) to remove.
*/
public void remove(final String name) {
final Config config = configurations.get(name);
if (config == null)
return;
if (config.isEnabled())
new ConfigDisabler(config).execute();
new ConfigRemover(config).execute();
}
/**
* Update the attributes of the named configuration. If the configuration is currently enabled,
* it will be disabled before the update, and the service will attempt to re-enable it
* afterward. If successful, the updated configuration will be saved to persistent storage.
*
* @param name The name of an existing configuration to update.
* @param config A copy of the configuration, with updated attributes.
*/
public void update(final String name, final Config config) {
if (name == null)
return;
if (configurations.containsValue(config))
throw new IllegalArgumentException("Config " + config.getName() + " modified directly");
final Config oldConfig = configurations.get(name);
if (oldConfig == null)
return;
final boolean wasEnabled = oldConfig.isEnabled();
if (wasEnabled)
new ConfigDisabler(oldConfig).execute();
new ConfigUpdater(oldConfig, config, wasEnabled).execute();
}
private void updateTile() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
return;
Log.v(TAG, "Requesting quick tile update");
TileService.requestListeningState(this, new ComponentName(this, QuickTileService.class));
}
private class ConfigDisabler extends AsyncTask<Void, Void, Boolean> {
private final Config config;
private ConfigDisabler(final Config config) {
this.config = config;
}
@Override
protected Boolean doInBackground(final Void... voids) {
Log.i(TAG, "Running wg-quick down for " + config.getName());
final File configFile = new File(getFilesDir(), config.getName() + ".conf");
return rootShell.run(null, "wg-quick down '" + configFile.getPath() + "'") == 0;
}
@Override
protected void onPostExecute(final Boolean result) {
config.setIsEnabled(!result);
if (!result) {
Toast.makeText(getApplicationContext(), getString(R.string.error_down),
Toast.LENGTH_SHORT).show();
return;
}
enabledConfigs.remove(config.getName());
preferences.edit().putStringSet(KEY_ENABLED_CONFIGS, enabledConfigs).apply();
if (config.getName().equals(primaryName))
updateTile();
}
}
private class ConfigEnabler extends AsyncTask<Void, Void, Integer> {
private final Config config;
private ConfigEnabler(final Config config) {
this.config = config;
}
@Override
protected Integer doInBackground(final Void... voids) {
if (!new File("/sys/module/wireguard").exists())
return -0xfff0001;
if (!existsInPath("su"))
return -0xfff0002;
Log.i(TAG, "Running wg-quick up for " + config.getName());
final File configFile = new File(getFilesDir(), config.getName() + ".conf");
final int ret = rootShell.run(null, "wg-quick up '" + configFile.getPath() + "'");
if (ret == OsConstants.EACCES)
return -0xfff0002;
return ret;
}
private boolean existsInPath(final String file) {
final String pathEnv = System.getenv("PATH");
if (pathEnv == null)
return false;
final String[] paths = pathEnv.split(":");
for (final String path : paths)
if (new File(path, file).exists())
return true;
return false;
}
@Override
protected void onPostExecute(final Integer ret) {
config.setIsEnabled(ret == 0);
if (ret != 0) {
if (ret == -0xfff0001) {
startActivity(new Intent(getApplicationContext(), NotSupportedActivity.class));
} else if (ret == -0xfff0002) {
Toast.makeText(getApplicationContext(), getString(R.string.error_su),
Toast.LENGTH_LONG).show();
} else {
Toast.makeText(getApplicationContext(), getString(R.string.error_up),
Toast.LENGTH_SHORT).show();
}
return;
}
enabledConfigs.add(config.getName());
preferences.edit().putStringSet(KEY_ENABLED_CONFIGS, enabledConfigs).apply();
if (config.getName().equals(primaryName))
updateTile();
}
}
private class ConfigImporter extends AsyncTask<Uri, String, List<File>> {
@Override
protected List<File> doInBackground(final Uri... uris) {
final ContentResolver contentResolver = getContentResolver();
final List<File> files = new ArrayList<>(uris.length);
for (final Uri uri : uris) {
if (isCancelled())
return null;
String name = null;
if ("file".equals(uri.getScheme())) {
name = uri.getLastPathSegment();
} else {
final String[] columns = {OpenableColumns.DISPLAY_NAME};
try (final Cursor cursor =
getContentResolver().query(uri, columns, null, null, null)) {
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0);
Log.v(getClass().getSimpleName(), "Got name via cursor");
}
}
if (name == null) {
name = Uri.decode(uri.getLastPathSegment());
if (name.indexOf('/') >= 0)
name = name.substring(name.lastIndexOf('/') + 1);
Log.v(getClass().getSimpleName(), "Got name from urlencoded path");
}
}
if (!name.endsWith(".conf"))
name = name + ".conf";
if (!Config.isNameValid(name.substring(0, name.length() - 5))) {
Log.v(getClass().getSimpleName(), "Detected name is not valid: " + name);
publishProgress(name + ": Invalid config filename");
continue;
}
Log.d(getClass().getSimpleName(), "Mapped URI " + uri + " to file name " + name);
final File output = new File(getFilesDir(), name);
if (output.exists()) {
Log.w(getClass().getSimpleName(), "Config file " + name + " already exists");
publishProgress(name + " already exists");
continue;
}
try (final InputStream in = contentResolver.openInputStream(uri);
final OutputStream out = new FileOutputStream(output, false)) {
if (in == null)
throw new IOException("Failed to open input");
// FIXME: This is a rather arbitrary size.
final byte[] buffer = new byte[4096];
int bytes;
while ((bytes = in.read(buffer)) != -1)
out.write(buffer, 0, bytes);
files.add(output);
} catch (final IOException e) {
Log.w(getClass().getSimpleName(), "Failed to import config from " + uri, e);
publishProgress(name + ": " + e.getMessage());
}
}
return files;
}
@Override
protected void onProgressUpdate(final String... errors) {
Toast.makeText(getApplicationContext(), errors[0], Toast.LENGTH_SHORT).show();
}
@Override
protected void onPostExecute(final List<File> files) {
new ConfigLoader().execute(files.toArray(new File[files.size()]));
}
}
private class ConfigLoader extends AsyncTask<File, String, List<Config>> {
@Override
protected List<Config> doInBackground(final File... files) {
final List<Config> configs = new LinkedList<>();
final List<String> interfaces = new LinkedList<>();
final String command = "wg show interfaces";
if (rootShell.run(interfaces, command) == 0 && interfaces.size() == 1) {
// wg puts all interface names on the same line. Split them into separate elements.
final String nameList = interfaces.get(0);
Collections.addAll(interfaces, nameList.split(" "));
interfaces.remove(0);
} else {
interfaces.clear();
Log.w(TAG, "No existing WireGuard interfaces found. Maybe they are all disabled?");
}
for (final File file : files) {
if (isCancelled())
return null;
final String fileName = file.getName();
final String configName = fileName.substring(0, fileName.length() - 5);
Log.v(TAG, "Attempting to load config " + configName);
try {
final Config config = new Config();
config.parseFrom(openFileInput(fileName));
config.setIsEnabled(interfaces.contains(configName));
config.setName(configName);
configs.add(config);
} catch (IllegalArgumentException | IOException e) {
if (!file.delete()) {
Log.e(TAG, "Could not delete configuration for config " + configName);
}
Log.w(TAG, "Failed to load config from " + fileName, e);
publishProgress(fileName + ": " + e.getMessage());
}
}
return configs;
}
@Override
protected void onProgressUpdate(final String... errors) {
Toast.makeText(getApplicationContext(), errors[0], Toast.LENGTH_SHORT).show();
}
@Override
protected void onPostExecute(final List<Config> configs) {
if (configs == null)
return;
for (final Config config : configs)
configurations.put(config.getName(), config);
// Run the handler to avoid duplicating the code here.
onSharedPreferenceChanged(preferences, KEY_PRIMARY_CONFIG);
if (preferences.getBoolean(KEY_RESTORE_ON_BOOT, false)) {
final Set<String> configsToEnable =
preferences.getStringSet(KEY_ENABLED_CONFIGS, null);
if (configsToEnable != null) {
for (final String name : configsToEnable) {
final Config config = configurations.get(name);
if (config != null && !config.isEnabled())
new ConfigEnabler(config).execute();
}
}
}
}
}
private class ConfigRemover extends AsyncTask<Void, Void, Boolean> {
private final Config config;
private ConfigRemover(final Config config) {
this.config = config;
}
@Override
protected Boolean doInBackground(final Void... voids) {
Log.i(TAG, "Removing config " + config.getName());
final File configFile = new File(getFilesDir(), config.getName() + ".conf");
if (configFile.delete()) {
return true;
} else {
Log.e(TAG, "Could not delete configuration for config " + config.getName());
return false;
}
}
@Override
protected void onPostExecute(final Boolean result) {
if (!result)
return;
configurations.remove(config.getName());
if (config.getName().equals(primaryName)) {
// This will get picked up by the preference change listener.
preferences.edit().remove(KEY_PRIMARY_CONFIG).apply();
}
}
}
private class ConfigUpdater extends AsyncTask<Void, Void, Boolean> {
private final Config newConfig;
private final String newName;
private final String oldName;
private final Boolean shouldConnect;
private Config knownConfig;
private ConfigUpdater(final Config knownConfig, final Config newConfig,
final Boolean shouldConnect) {
this.knownConfig = knownConfig;
this.newConfig = newConfig.copy();
newName = newConfig.getName();
// When adding a config, "old file" and "new file" are the same thing.
oldName = knownConfig != null ? knownConfig.getName() : newName;
this.shouldConnect = shouldConnect;
if (newName == null || !Config.isNameValid(newName))
throw new IllegalArgumentException("This configuration does not have a valid name");
if (isAddOrRename() && configurations.containsKey(newName))
throw new IllegalStateException("Configuration " + newName + " already exists");
if (newConfig.getInterface().getPublicKey() == null)
throw new IllegalArgumentException("This configuration needs a valid private key");
for (final Peer peer : newConfig.getPeers())
if (peer.getPublicKey() == null || peer.getPublicKey().isEmpty())
throw new IllegalArgumentException("Each peer must have a valid public key");
}
@Override
protected Boolean doInBackground(final Void... voids) {
Log.i(TAG, (knownConfig == null ? "Adding" : "Updating") + " config " + newName);
final File newFile = new File(getFilesDir(), newName + ".conf");
final File oldFile = new File(getFilesDir(), oldName + ".conf");
if (isAddOrRename() && newFile.exists()) {
Log.w(TAG, "Refusing to overwrite existing config configuration");
return false;
}
try {
final FileOutputStream stream = openFileOutput(oldFile.getName(), MODE_PRIVATE);
stream.write(newConfig.toString().getBytes(StandardCharsets.UTF_8));
stream.close();
} catch (final IOException e) {
Log.e(TAG, "Could not save configuration for config " + oldName, e);
return false;
}
if (isRename() && !oldFile.renameTo(newFile)) {
Log.e(TAG, "Could not rename " + oldFile.getName() + " to " + newFile.getName());
return false;
}
return true;
}
private boolean isAddOrRename() {
return knownConfig == null || !newName.equals(oldName);
}
private boolean isRename() {
return knownConfig != null && !newName.equals(oldName);
}
@Override
protected void onPostExecute(final Boolean result) {
if (!result)
return;
if (knownConfig != null)
configurations.remove(oldName);
if (knownConfig == null)
knownConfig = new Config();
knownConfig.copyFrom(newConfig);
knownConfig.setIsEnabled(false);
knownConfig.setIsPrimary(oldName != null && oldName.equals(primaryName));
configurations.put(newName, knownConfig);
if (isRename() && oldName != null && oldName.equals(primaryName))
preferences.edit().putString(KEY_PRIMARY_CONFIG, newName).apply();
if (shouldConnect)
new ConfigEnabler(knownConfig).execute();
}
}
}