/* * Copyright (c) 2013, Psiphon Inc. * All rights reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ /* * Based on : briar-android/src/net/sf/briar/plugins/tor/TorPluginFactory.java * * Copyright (C) 2013 Sublime Software Ltd * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package ca.psiphon.ploggy; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.Socket; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Scanner; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.ZipInputStream; import net.freehaven.tor.control.TorControlConnection; import android.content.Context; import android.os.Build; /** * Wrapper for Tor child process. * * Derived from Briar Project's TorPlugin.java. Also uses Briar project's custom TorControlConnection. * Supports two modes: run Tor services (local SOCKS proxy for clients, Hidden Server in front of web * server); and generate key material only. * Allows Tor to select listen port for control interface and local SOCKS proxy. Uses file monitoring * to monitor initial startup of Tor process, and control interface for monitoring Tor bootstrap * progress. * Supports multiple simultaneous Tor instances (for testing). Use distinct instance names for * simultaneous distinct, Tor instances, each with its own persistent data. */ public class TorWrapper implements net.freehaven.tor.control.EventHandler { private static final String LOG_TAG = "Tor"; public enum Mode { MODE_GENERATE_KEY_MATERIAL, MODE_RUN_SERVICES } // Hidden Service authentication cookies for the Tor client public static class HiddenServiceAuth { public final String mHostname; public final String mAuthCookie; public HiddenServiceAuth(String hostname, String authCookie) { mHostname = hostname; mAuthCookie = authCookie; } } private final Mode mMode; private String mInstanceName; private final List<HiddenServiceAuth> mHiddenServiceAuth; private HiddenService.KeyMaterial mKeyMaterial; private int mWebServerPort = -1; private final File mRootDirectory; private final File mDataDirectory; private final File mHiddenServiceDirectory; private final File mExecutableFile; private final File mConfigFile; private final File mControlPortFile; private final File mControlAuthCookieFile; private final File mPidFile; private final File mHiddenServiceHostnameFile; private final File mHiddenServicePrivateKeyFile; private final File mHiddenServiceClientKeysFile; private Thread mStartupThread = null; private Utils.ApplicationError mStartupError = null; private Process mProcess = null; private int mPid = -1; private int mControlPort = -1; private int mSocksProxyPort = -1; private Socket mControlSocket = null; private TorControlConnection mControlConnection = null; private CountDownLatch mCircuitEstablishedLatch = null; private static final int CONTROL_INITIALIZED_TIMEOUT_MILLISECONDS = 90000; private static final int HIDDEN_SERVICE_INITIALIZED_TIMEOUT_MILLISECONDS = 90000; private static final int CIRCUIT_ESTABLISHED_TIMEOUT_MILLISECONDS = 90000; public TorWrapper(Mode mode) { this(mode, null, null, -1); } public TorWrapper( Mode mode, List<HiddenServiceAuth> hiddenServiceAuth, HiddenService.KeyMaterial keyMaterial, int webServerPort) { this(mode, null, hiddenServiceAuth, keyMaterial, webServerPort); } public TorWrapper( Mode mode, String instanceName, List<HiddenServiceAuth> hiddenServiceAuth, HiddenService.KeyMaterial keyMaterial, int webServerPort) { mMode = mode; mInstanceName = instanceName; if (mInstanceName == null) { mInstanceName = mMode.toString(); } mHiddenServiceAuth = hiddenServiceAuth; mKeyMaterial = keyMaterial; mWebServerPort = webServerPort; Context context = Utils.getApplicationContext(); String rootDirectory = String.format((Locale)null, "tor-%s", mInstanceName); mRootDirectory = context.getDir(rootDirectory, Context.MODE_PRIVATE); mDataDirectory = new File(mRootDirectory, "data"); mHiddenServiceDirectory = new File(mRootDirectory, "hidden_service"); // Note: calling the executable/process "ploggy-tor" instead of "tor" to avoid conflict // with Orbot, which in some cases appears to want to adopt any process named "tor" // as its own -- which causes Orbot to fail because our control interface is not // running on the expected port and Orbot can't get our control auth cookie. // (this appears to only occur in Orbot's root mode; tested with Orbot 12.x and 13.1) mExecutableFile = new File(mRootDirectory, "ploggy-tor"); mConfigFile = new File(mRootDirectory, "config"); mControlPortFile = new File(mDataDirectory, "control_port_file"); mControlAuthCookieFile = new File(mDataDirectory, "control_auth_cookie"); mPidFile = new File(mDataDirectory, "pid"); mHiddenServiceHostnameFile = new File(mHiddenServiceDirectory, "hostname"); mHiddenServicePrivateKeyFile = new File(mHiddenServiceDirectory, "private_key"); mHiddenServiceClientKeysFile = new File(mHiddenServiceDirectory, "client_keys"); } private String logTag() { return String.format("%s [%s]", LOG_TAG, mInstanceName); } public void start() { stop(); // Performs start sequence asynchronously, in a background thread Runnable startTask = new Runnable() { @Override public void run() { try { if (mMode == Mode.MODE_GENERATE_KEY_MATERIAL) { startGenerateKeyMaterial(); } else if (mMode == Mode.MODE_RUN_SERVICES) { startRunServices(); } } catch (Utils.ApplicationError e) { Log.addEntry(logTag(), "failed to start Tor"); // Save this to throw from awaitStarted mStartupError = e; } } }; mStartupThread = new Thread(startTask); mStartupThread.start(); } public void awaitStarted() throws Utils.ApplicationError { if (mStartupThread != null) { try { mStartupThread.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } if (mStartupError != null) { throw mStartupError; } } } private void startGenerateKeyMaterial() throws Utils.ApplicationError { try { // TODO: don't need two copies of the executable writeExecutableFile(); writeGenerateKeyMaterialConfigFile(); mHiddenServiceDirectory.mkdirs(); if (mHiddenServiceHostnameFile.exists() && !mHiddenServiceHostnameFile.delete()) { throw new Utils.ApplicationError(logTag(), "failed to delete existing hidden service hostname file"); } if (mHiddenServicePrivateKeyFile.exists() && !mHiddenServicePrivateKeyFile.delete()) { throw new Utils.ApplicationError(logTag(), "failed to delete existing hidden service private key file"); } if (mHiddenServiceClientKeysFile.exists() && !mHiddenServiceClientKeysFile.delete()) { throw new Utils.ApplicationError(logTag(), "failed to delete existing hidden service client keys file"); } Utils.FileInitializedObserver hiddenServiceInitializedObserver = new Utils.FileInitializedObserver( mHiddenServiceDirectory, mHiddenServiceHostnameFile.getName(), mHiddenServicePrivateKeyFile.getName(), mHiddenServiceClientKeysFile.getName()); hiddenServiceInitializedObserver.startWatching(); startDaemon(false); if (!hiddenServiceInitializedObserver.await(HIDDEN_SERVICE_INITIALIZED_TIMEOUT_MILLISECONDS)) { throw new Utils.ApplicationError(logTag(), "timeout waiting for Tor hidden service initialization"); } mKeyMaterial = parseHiddenServiceFiles(); } catch (IOException e) { Log.addEntry(logTag(), "failed to start Tor"); throw new Utils.ApplicationError(logTag(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // This mode stops its Tor process stop(); mHiddenServiceHostnameFile.delete(); mHiddenServicePrivateKeyFile.delete(); } } private void startRunServices() throws Utils.ApplicationError { boolean startCompleted = false; try { writeExecutableFile(); writeRunServicesConfigFile(); writeHiddenServiceFiles(); startDaemon(true); mSocksProxyPort = getPortValue(mControlConnection.getInfo("net/listeners/socks").replaceAll("\"", "")); startCompleted = true; } catch (IOException e) { Log.addEntry(logTag(), "failed to start Tor"); throw new Utils.ApplicationError(logTag(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (!startCompleted) { stop(); } } } private void startDaemon(boolean awaitFirstCircuit) throws Utils.ApplicationError, IOException, InterruptedException { try { mDataDirectory.mkdirs(); mCircuitEstablishedLatch = new CountDownLatch(1); mControlAuthCookieFile.delete(); Utils.FileInitializedObserver controlInitializedObserver = new Utils.FileInitializedObserver( mDataDirectory, mPidFile.getName(), mControlPortFile.getName(), mControlAuthCookieFile.getName()); controlInitializedObserver.startWatching(); ProcessBuilder processBuilder = new ProcessBuilder( mExecutableFile.getAbsolutePath(), "--hush", "-f", mConfigFile.getAbsolutePath()); processBuilder.environment().put("HOME", mRootDirectory.getAbsolutePath()); processBuilder.directory(mRootDirectory); mProcess = processBuilder.start(); Scanner stdout = new Scanner(mProcess.getInputStream()); while(stdout.hasNextLine()) { Log.addEntry(logTag(), stdout.nextLine()); } stdout.close(); // TODO: i18n errors (string resources); combine logging with throwing Utils.ApplicationError int exit = mProcess.waitFor(); if (exit != 0) { throw new Utils.ApplicationError(logTag(), String.format("Tor exited with error %d", exit)); } if (!controlInitializedObserver.await(CONTROL_INITIALIZED_TIMEOUT_MILLISECONDS)) { throw new Utils.ApplicationError(logTag(), "timeout waiting for Tor control initialization"); } mPid = Utils.readFileToInt(mPidFile); mControlPort = getPortValue(Utils.readFileToString(mControlPortFile).trim()); mControlSocket = new Socket("127.0.0.1", mControlPort); mControlConnection = new TorControlConnection(mControlSocket); mControlConnection.authenticate(Utils.readFileToBytes(mControlAuthCookieFile)); mControlConnection.setEventHandler(this); mControlConnection.setEvents(Arrays.asList("STATUS_CLIENT", "WARN", "ERR")); if (awaitFirstCircuit) { mCircuitEstablishedLatch.await(CIRCUIT_ESTABLISHED_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS); } } finally { if (mProcess != null) { try { mProcess.getOutputStream().close(); mProcess.getInputStream().close(); mProcess.getErrorStream().close(); } catch (IOException e) { } } } } public void stop() { if (mStartupThread != null) { mStartupThread.interrupt(); try { awaitStarted(); } catch (Utils.ApplicationError e) { Log.addEntry(logTag(), "failed to stop gracefully"); } mStartupThread = null; mStartupError = null; } try { if (mControlConnection != null) { mControlConnection.shutdownTor("TERM"); } if (mControlSocket != null) { mControlSocket.close(); } } catch (IOException e) { Log.addEntry(logTag(), e.getMessage()); Log.addEntry(logTag(), "failed to stop gracefully"); } if (mProcess != null) { mProcess.destroy(); } if (mPid == -1 && mPidFile.exists()) { // TODO: use output of ps command when missing pid file...(but don't interfere with other instances)? try { mPid = Utils.readFileToInt(mPidFile); } catch (IOException e) { } } if (mPid != -1) { android.os.Process.killProcess(mPid); } mSocksProxyPort = -1; mControlPort = -1; mControlConnection = null; mControlSocket = null; mProcess = null; mPid = -1; mCircuitEstablishedLatch = null; } public HiddenService.KeyMaterial getKeyMaterial() { return mKeyMaterial; } public int getSocksProxyPort() { return mSocksProxyPort; } public boolean isCircuitEstablished() { try { return mCircuitEstablishedLatch != null && mCircuitEstablishedLatch.await(0, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } private void writeExecutableFile() throws IOException { if (0 != Build.CPU_ABI.compareTo("armeabi-v7a")) { throw new IOException("no Tor binary for this CPU"); } mExecutableFile.delete(); InputStream zippedAsset = Utils.getApplicationContext().getResources().openRawResource(R.raw.tor_arm7); ZipInputStream zipStream = new ZipInputStream(zippedAsset); zipStream.getNextEntry(); Utils.copyStream(zipStream, new FileOutputStream(mExecutableFile)); if (!mExecutableFile.setExecutable(true)) { throw new IOException("failed to set Tor as executable"); } } private void writeGenerateKeyMaterialConfigFile() throws IOException { String configuration = String.format( (Locale)null, "DataDirectory %s\n" + "RunAsDaemon 1\n" + "PidFile %s\n" + "ControlPort auto\n" + "ControlPortWriteToFile %s\n" + "CookieAuthentication 1\n" + "CookieAuthFile %s\n" + "SocksPort 0\n" + "HiddenServiceDir %s\n" + // TODO: FIX! won't generate without HiddenServicePort set... ensure not published? run non-responding server? "HiddenServicePort 443 localhost:7\n" + "HiddenServiceAuthorizeClient basic friend\n", mDataDirectory.getAbsolutePath(), mPidFile.getAbsolutePath(), mControlPortFile.getAbsolutePath(), mControlAuthCookieFile.getAbsolutePath(), mHiddenServiceDirectory.getAbsolutePath()); Utils.copyStream( new ByteArrayInputStream(configuration.getBytes("UTF-8")), new FileOutputStream(mConfigFile)); } private void writeRunServicesConfigFile() throws IOException { StringBuilder hiddenServiceAuthLines = new StringBuilder(); for (HiddenServiceAuth hiddenServiceAuth : mHiddenServiceAuth) { hiddenServiceAuthLines.append( String.format( (Locale)null, "HidServAuth %s %s\n", hiddenServiceAuth.mHostname, hiddenServiceAuth.mAuthCookie)); } String configuration = String.format( (Locale)null, "DataDirectory %s\n" + "RunAsDaemon 1\n" + "PidFile %s\n" + "ControlPort auto\n" + "ControlPortWriteToFile %s\n" + "CookieAuthentication 1\n" + "CookieAuthFile %s\n" + "SocksPort auto\n" + "HiddenServiceDir %s\n" + "HiddenServicePort 443 localhost:%d\n" + "HiddenServiceAuthorizeClient basic friend\n" + "%s", mDataDirectory.getAbsolutePath(), mPidFile.getAbsolutePath(), mControlPortFile.getAbsolutePath(), mControlAuthCookieFile.getAbsolutePath(), mHiddenServiceDirectory.getAbsolutePath(), mWebServerPort, hiddenServiceAuthLines.toString()); Utils.copyStream( new ByteArrayInputStream(configuration.getBytes("UTF-8")), new FileOutputStream(mConfigFile)); } private HiddenService.KeyMaterial parseHiddenServiceFiles() throws Utils.ApplicationError, IOException { String hostnameFileContent = Utils.readFileToString(mHiddenServiceHostnameFile); // Expected format: "gv69mnyyrwinum7l.onion WSdmfwVn8ewrCLKAwVyhCT # client: friend\n" String[] hostnameFileFields = hostnameFileContent.split(" "); if (hostnameFileFields.length < 2) { throw new Utils.ApplicationError(logTag(), "unexpected fields in hidden service hostname file"); } String hostname = hostnameFileFields[0]; String authCookie = hostnameFileFields[1]; String privateKey = Utils.readFileToString(mHiddenServicePrivateKeyFile); return new HiddenService.KeyMaterial( hostname, authCookie, Utils.encodeBase64(privateKey.getBytes())); } private void writeHiddenServiceFiles() throws Utils.ApplicationError, IOException { mHiddenServiceDirectory.mkdirs(); String hostnameFileContent = mKeyMaterial.mHostname + " " + mKeyMaterial.mAuthCookie + "\n"; Utils.writeStringToFile( hostnameFileContent, mHiddenServiceHostnameFile); Utils.writeStringToFile( new String(Utils.decodeBase64(mKeyMaterial.mPrivateKey)), mHiddenServicePrivateKeyFile); // Format (as per rend_service_load_auth_keys in Tor's rendservice.c): // client-name friend // descriptor-cookie WSdmfwVn8ewrCLKAwVyhCT== String clientKeysFileContent = "client-name friend\ndescriptor-cookie " + mKeyMaterial.mAuthCookie + "==\n"; Utils.writeStringToFile( clientKeysFileContent, mHiddenServiceClientKeysFile); } private int getPortValue(String data) throws Utils.ApplicationError { try { // Expected format is "PORT=127.0.0.1:<port>\n" String[] tokens = data.trim().split(":"); if (tokens.length != 2) { throw new Utils.ApplicationError(logTag(), "unexpected port value format"); } return Integer.parseInt(tokens[1]); } catch (NumberFormatException e) { throw new Utils.ApplicationError(logTag(), e); } } @Override public void circuitStatus(String status, String circID, String path) { } @Override public void streamStatus(String status, String streamID, String target) { } @Override public void orConnStatus(String status, String orName) { } @Override public void bandwidthUsed(long read, long written) { } @Override public void newDescriptors(List<String> orList) { } @Override public void message(String severity, String message) { Log.addEntry(logTag(), message); } @Override public void unrecognized(String type, String message) { if (type.equals("STATUS_CLIENT") && message.equals("NOTICE CIRCUIT_ESTABLISHED")) { if (mCircuitEstablishedLatch != null) { mCircuitEstablishedLatch.countDown(); } Log.addEntry(logTag(), "circuit established"); Events.post(new Events.TorCircuitEstablished()); } if (type.equals("STATUS_CLIENT") && message.startsWith("NOTICE BOOTSTRAP")) { Pattern pattern = Pattern.compile(".*PROGRESS=(\\d+).*SUMMARY=\"(.+)\""); Matcher matcher = pattern.matcher(message); if (matcher.find() && matcher.groupCount() == 2) { Log.addEntry(logTag(), "bootstrap " + matcher.group(1) + "%: " + matcher.group(2)); } } } }