summaryrefslogtreecommitdiffhomepage
path: root/app/src/main/java/com/wireguard/android/backends
diff options
context:
space:
mode:
authorSamuel Holland <samuel@sholland.org>2017-11-24 21:13:55 -0600
committerSamuel Holland <samuel@sholland.org>2017-11-24 21:16:37 -0600
commit50a7a12de279574dd15f4db5cf5ea7aa984b7c80 (patch)
treedd1d5cad145048cb912195902e2a5fd364964a17 /app/src/main/java/com/wireguard/android/backends
parent69d4fe9a8120fd10e144bac0bc5f7083ee1a283a (diff)
VpnService: Move it to a backends package
It should be split into two pieces: configuration file management (loading/saving/renaming/deleting) and calling into wg-quick via RootShell. The configuration file management part should then go back into the main package. This is in preparation for adding additional backends based on wg(8) and wireguard-go. Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
Diffstat (limited to 'app/src/main/java/com/wireguard/android/backends')
-rw-r--r--app/src/main/java/com/wireguard/android/backends/RootShell.java86
-rw-r--r--app/src/main/java/com/wireguard/android/backends/VpnService.java484
2 files changed, 570 insertions, 0 deletions
diff --git a/app/src/main/java/com/wireguard/android/backends/RootShell.java b/app/src/main/java/com/wireguard/android/backends/RootShell.java
new file mode 100644
index 00000000..0b529065
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/backends/RootShell.java
@@ -0,0 +1,86 @@
+package com.wireguard.android.backends;
+
+import android.content.Context;
+import android.util.Log;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * Helper class for running commands as root.
+ */
+
+class RootShell {
+ /**
+ * Setup commands that are run at the beginning of each root shell. The trap command ensures
+ * access to the return value of the last command, since su itself always exits with 0.
+ */
+ private static final String SETUP_TEMPLATE = "export TMPDIR=%s\ntrap 'echo $?' EXIT\n";
+ private static final String TAG = "RootShell";
+
+ private final byte[] setupCommands;
+ private final String shell;
+
+ RootShell(final Context context) {
+ this(context, "su");
+ }
+
+ RootShell(final Context context, final String shell) {
+ final String tmpdir = context.getCacheDir().getPath();
+ setupCommands = String.format(SETUP_TEMPLATE, tmpdir).getBytes(StandardCharsets.UTF_8);
+ this.shell = shell;
+ }
+
+ /**
+ * Run a series of commands in a root shell. These commands are all sent to the same shell
+ * process, so they can be considered a shell script.
+ *
+ * @param output Lines read from stdout and stderr are appended to this list. Pass null if the
+ * output from the shell is not important.
+ * @param commands One or more commands to run as root (each element is a separate line).
+ * @return The exit value of the last command run, or -1 if there was an internal error.
+ */
+ int run(final List<String> output, final String... commands) {
+ if (commands.length < 1)
+ throw new IndexOutOfBoundsException("At least one command must be supplied");
+ int exitValue = -1;
+ try {
+ final ProcessBuilder builder = new ProcessBuilder().redirectErrorStream(true);
+ final Process process = builder.command(shell).start();
+ final OutputStream stdin = process.getOutputStream();
+ stdin.write(setupCommands);
+ for (final String command : commands)
+ stdin.write(command.concat("\n").getBytes(StandardCharsets.UTF_8));
+ stdin.close();
+ Log.d(TAG, "Sent " + commands.length + " command(s), now reading output");
+ final InputStream stdout = process.getInputStream();
+ final BufferedReader stdoutReader =
+ new BufferedReader(new InputStreamReader(stdout, StandardCharsets.UTF_8));
+ String line;
+ String lastLine = null;
+ while ((line = stdoutReader.readLine()) != null) {
+ Log.v(TAG, line);
+ lastLine = line;
+ if (output != null)
+ output.add(line);
+ }
+ process.waitFor();
+ process.destroy();
+ if (lastLine != null) {
+ // Remove the exit value line from the output
+ if (output != null)
+ output.remove(output.size() - 1);
+ exitValue = Integer.parseInt(lastLine);
+ }
+ Log.d(TAG, "Session completed with exit value " + exitValue);
+ } catch (IOException | InterruptedException | NumberFormatException e) {
+ Log.w(TAG, "Session failed with exception", e);
+ }
+ return exitValue;
+ }
+}
diff --git a/app/src/main/java/com/wireguard/android/backends/VpnService.java b/app/src/main/java/com/wireguard/android/backends/VpnService.java
new file mode 100644
index 00000000..25eb4ade
--- /dev/null
+++ b/app/src/main/java/com/wireguard/android/backends/VpnService.java
@@ -0,0 +1,484 @@
+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.net.Uri;
+import android.os.AsyncTask;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
+import android.service.quicksettings.TileService;
+import android.util.Log;
+
+import com.wireguard.android.QuickTileService;
+import com.wireguard.android.bindings.ObservableSortedMap;
+import com.wireguard.android.bindings.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 = "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) {
+ if (!result)
+ return;
+ config.setIsEnabled(false);
+ 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, Boolean> {
+ private final Config config;
+
+ private ConfigEnabler(final Config config) {
+ this.config = config;
+ }
+
+ @Override
+ protected Boolean doInBackground(final Void... voids) {
+ Log.i(TAG, "Running wg-quick up for " + config.getName());
+ final File configFile = new File(getFilesDir(), config.getName() + ".conf");
+ return rootShell.run(null, "wg-quick up '" + configFile.getPath() + "'") == 0;
+ }
+
+ @Override
+ protected void onPostExecute(final Boolean result) {
+ if (!result)
+ return;
+ config.setIsEnabled(true);
+ enabledConfigs.add(config.getName());
+ preferences.edit().putStringSet(KEY_ENABLED_CONFIGS, enabledConfigs).apply();
+ if (config.getName().equals(primaryName))
+ updateTile();
+ }
+ }
+
+ private class ConfigImporter extends AsyncTask<Uri, Void, 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 = Uri.decode(uri.getLastPathSegment());
+ if (name.indexOf('/') >= 0)
+ name = name.substring(name.lastIndexOf('/'));
+ if (!name.endsWith(".conf"))
+ name = name + ".conf";
+ final File output = new File(getFilesDir(), name);
+ if (output.exists()) {
+ Log.w(getClass().getSimpleName(), "Config file for " + uri + " 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);
+ }
+ }
+ return files;
+ }
+
+ @Override
+ protected void onPostExecute(final List<File> files) {
+ new ConfigLoader().execute(files.toArray(new File[files.size()]));
+ }
+ }
+
+ private class ConfigLoader extends AsyncTask<File, Void, 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) {
+ Log.w(TAG, "Failed to load config from " + fileName, e);
+ }
+ }
+ return configs;
+ }
+
+ @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 must have a valid keypair");
+ 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();
+ }
+ }
+}