From 0f2637d9947d476979e448cec59261c2ae446f84 Mon Sep 17 00:00:00 2001 From: Samuel Holland Date: Fri, 24 Nov 2017 21:13:55 -0600 Subject: 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. --- app/src/main/AndroidManifest.xml | 12 +- .../com/wireguard/android/BaseConfigActivity.java | 1 + .../com/wireguard/android/BaseConfigFragment.java | 1 + .../wireguard/android/BootCompletedReceiver.java | 2 + .../java/com/wireguard/android/ConfigActivity.java | 1 + .../com/wireguard/android/ConfigEditFragment.java | 1 + .../com/wireguard/android/ConfigListFragment.java | 1 + .../wireguard/android/ConfigListPreference.java | 2 + .../com/wireguard/android/QuickTileService.java | 1 + .../main/java/com/wireguard/android/RootShell.java | 86 ---- .../java/com/wireguard/android/VpnService.java | 484 --------------------- .../com/wireguard/android/backends/RootShell.java | 86 ++++ .../com/wireguard/android/backends/VpnService.java | 484 +++++++++++++++++++++ app/src/main/res/layout/config_list_item.xml | 2 +- 14 files changed, 588 insertions(+), 576 deletions(-) delete mode 100644 app/src/main/java/com/wireguard/android/RootShell.java delete mode 100644 app/src/main/java/com/wireguard/android/VpnService.java create mode 100644 app/src/main/java/com/wireguard/android/backends/RootShell.java create mode 100644 app/src/main/java/com/wireguard/android/backends/VpnService.java (limited to 'app/src/main') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1563d312..3ba0a77d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,11 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.DarkActionBar"> + + @@ -19,10 +24,6 @@ - @@ -44,12 +45,13 @@ + diff --git a/app/src/main/java/com/wireguard/android/BaseConfigActivity.java b/app/src/main/java/com/wireguard/android/BaseConfigActivity.java index 17ca3b6a..14a9384c 100644 --- a/app/src/main/java/com/wireguard/android/BaseConfigActivity.java +++ b/app/src/main/java/com/wireguard/android/BaseConfigActivity.java @@ -8,6 +8,7 @@ import android.content.ServiceConnection; import android.os.Bundle; import android.os.IBinder; +import com.wireguard.android.backends.VpnService; import com.wireguard.config.Config; /** diff --git a/app/src/main/java/com/wireguard/android/BaseConfigFragment.java b/app/src/main/java/com/wireguard/android/BaseConfigFragment.java index 42912648..c92d127e 100644 --- a/app/src/main/java/com/wireguard/android/BaseConfigFragment.java +++ b/app/src/main/java/com/wireguard/android/BaseConfigFragment.java @@ -3,6 +3,7 @@ package com.wireguard.android; import android.app.Fragment; import android.os.Bundle; +import com.wireguard.android.backends.VpnService; import com.wireguard.config.Config; /** diff --git a/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java b/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java index 68cb5f1f..0bf28912 100644 --- a/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java +++ b/app/src/main/java/com/wireguard/android/BootCompletedReceiver.java @@ -4,6 +4,8 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import com.wireguard.android.backends.VpnService; + public class BootCompletedReceiver extends BroadcastReceiver { @Override diff --git a/app/src/main/java/com/wireguard/android/ConfigActivity.java b/app/src/main/java/com/wireguard/android/ConfigActivity.java index 881f3f9a..439f77da 100644 --- a/app/src/main/java/com/wireguard/android/ConfigActivity.java +++ b/app/src/main/java/com/wireguard/android/ConfigActivity.java @@ -8,6 +8,7 @@ import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; +import com.wireguard.android.backends.VpnService; import com.wireguard.config.Config; /** diff --git a/app/src/main/java/com/wireguard/android/ConfigEditFragment.java b/app/src/main/java/com/wireguard/android/ConfigEditFragment.java index 1edae9cc..f29f20fe 100644 --- a/app/src/main/java/com/wireguard/android/ConfigEditFragment.java +++ b/app/src/main/java/com/wireguard/android/ConfigEditFragment.java @@ -14,6 +14,7 @@ import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; +import com.wireguard.android.backends.VpnService; import com.wireguard.android.databinding.ConfigEditFragmentBinding; import com.wireguard.config.Config; diff --git a/app/src/main/java/com/wireguard/android/ConfigListFragment.java b/app/src/main/java/com/wireguard/android/ConfigListFragment.java index 88d12745..1067dc73 100644 --- a/app/src/main/java/com/wireguard/android/ConfigListFragment.java +++ b/app/src/main/java/com/wireguard/android/ConfigListFragment.java @@ -13,6 +13,7 @@ import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.ListView; +import com.wireguard.android.backends.VpnService; import com.wireguard.android.bindings.ObservableMapAdapter; import com.wireguard.android.databinding.ConfigListFragmentBinding; import com.wireguard.config.Config; diff --git a/app/src/main/java/com/wireguard/android/ConfigListPreference.java b/app/src/main/java/com/wireguard/android/ConfigListPreference.java index 38421610..131b0a43 100644 --- a/app/src/main/java/com/wireguard/android/ConfigListPreference.java +++ b/app/src/main/java/com/wireguard/android/ConfigListPreference.java @@ -4,6 +4,8 @@ import android.content.Context; import android.preference.ListPreference; import android.util.AttributeSet; +import com.wireguard.android.backends.VpnService; + import java.util.Set; /** diff --git a/app/src/main/java/com/wireguard/android/QuickTileService.java b/app/src/main/java/com/wireguard/android/QuickTileService.java index ddadf29d..9f97c6c4 100644 --- a/app/src/main/java/com/wireguard/android/QuickTileService.java +++ b/app/src/main/java/com/wireguard/android/QuickTileService.java @@ -13,6 +13,7 @@ import android.preference.PreferenceManager; import android.service.quicksettings.Tile; import android.service.quicksettings.TileService; +import com.wireguard.android.backends.VpnService; import com.wireguard.config.Config; @TargetApi(Build.VERSION_CODES.N) diff --git a/app/src/main/java/com/wireguard/android/RootShell.java b/app/src/main/java/com/wireguard/android/RootShell.java deleted file mode 100644 index 973a5d0c..00000000 --- a/app/src/main/java/com/wireguard/android/RootShell.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.wireguard.android; - -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 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/VpnService.java b/app/src/main/java/com/wireguard/android/VpnService.java deleted file mode 100644 index 0a828bc0..00000000 --- a/app/src/main/java/com/wireguard/android/VpnService.java +++ /dev/null @@ -1,484 +0,0 @@ -package com.wireguard.android; - -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.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; - - public static VpnService getInstance() { - return instance; - } - - private final IBinder binder = new Binder(); - private final ObservableTreeMap configurations = new ObservableTreeMap<>(); - private final Set enabledConfigs = new HashSet<>(); - private SharedPreferences preferences; - private String primaryName; - private RootShell rootShell; - - /** - * 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 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 { - 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 { - 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> { - @Override - protected List doInBackground(final Uri... uris) { - final ContentResolver contentResolver = getContentResolver(); - final List 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 files) { - new ConfigLoader().execute(files.toArray(new File[files.size()])); - } - } - - private class ConfigLoader extends AsyncTask> { - @Override - protected List doInBackground(final File... files) { - final List configs = new LinkedList<>(); - final List 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 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 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 { - 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 { - private Config knownConfig; - private final Config newConfig; - private final String newName; - private final String oldName; - private final Boolean shouldConnect; - - 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(); - } - } -} 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 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 configurations = new ObservableTreeMap<>(); + private final Set 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 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 { + 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 { + 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> { + @Override + protected List doInBackground(final Uri... uris) { + final ContentResolver contentResolver = getContentResolver(); + final List 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 files) { + new ConfigLoader().execute(files.toArray(new File[files.size()])); + } + } + + private class ConfigLoader extends AsyncTask> { + @Override + protected List doInBackground(final File... files) { + final List configs = new LinkedList<>(); + final List 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 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 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 { + 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 { + 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(); + } + } +} diff --git a/app/src/main/res/layout/config_list_item.xml b/app/src/main/res/layout/config_list_item.xml index cdc0723a..578db833 100644 --- a/app/src/main/res/layout/config_list_item.xml +++ b/app/src/main/res/layout/config_list_item.xml @@ -5,7 +5,7 @@ - +