diff options
author | Samuel Holland <samuel@sholland.org> | 2017-11-24 21:13:55 -0600 |
---|---|---|
committer | Samuel Holland <samuel@sholland.org> | 2017-11-24 21:16:37 -0600 |
commit | 50a7a12de279574dd15f4db5cf5ea7aa984b7c80 (patch) | |
tree | dd1d5cad145048cb912195902e2a5fd364964a17 /app/src/main/java/com/wireguard/android/backends | |
parent | 69d4fe9a8120fd10e144bac0bc5f7083ee1a283a (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.java | 86 | ||||
-rw-r--r-- | app/src/main/java/com/wireguard/android/backends/VpnService.java | 484 |
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(); + } + } +} |