diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afbdab3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..f20af10 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +LocalVPN \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..217af47 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..e7bedf3 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..e206d70 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..fe865d3 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..58ff01f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + 1.7 + + + + + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2a3d89d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/.idea/scopes/scope_settings.xml b/.idea/scopes/scope_settings.xml new file mode 100644 index 0000000..922003b --- /dev/null +++ b/.idea/scopes/scope_settings.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..def6a6a --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/LocalVPN.iml b/LocalVPN.iml new file mode 100644 index 0000000..2a02201 --- /dev/null +++ b/LocalVPN.iml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/app.iml b/app/app.iml new file mode 100644 index 0000000..c6c55c4 --- /dev/null +++ b/app/app.iml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..3d253f2 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,25 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 21 + buildToolsVersion "21.1.1" + + defaultConfig { + applicationId "xyz.hexene.localvpn" + minSdkVersion 14 + targetSdkVersion 21 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile 'com.android.support:appcompat-v7:21.0.3' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..0419ded --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/i069076/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d4ad4e9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/xyz/hexene/localvpn/ByteBufferPool.java b/app/src/main/java/xyz/hexene/localvpn/ByteBufferPool.java new file mode 100644 index 0000000..96e09d4 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/ByteBufferPool.java @@ -0,0 +1,29 @@ +package xyz.hexene.localvpn; + +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class ByteBufferPool +{ + private static final int BUFFER_SIZE = 16384; // XXX: Is this ideal? + private static ConcurrentLinkedQueue pool = new ConcurrentLinkedQueue<>(); + + public static ByteBuffer acquire() + { + ByteBuffer buffer = pool.poll(); + if (buffer == null) + buffer = ByteBuffer.allocateDirect(BUFFER_SIZE); // Using DirectBuffer for zero-copy + return buffer; + } + + public static void release(ByteBuffer buffer) + { + buffer.clear(); + pool.offer(buffer); + } + + public static void clear() + { + pool.clear(); + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/LRUCache.java b/app/src/main/java/xyz/hexene/localvpn/LRUCache.java new file mode 100644 index 0000000..e1b5c06 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/LRUCache.java @@ -0,0 +1,33 @@ +package xyz.hexene.localvpn; + +import java.util.LinkedHashMap; + +public class LRUCache extends LinkedHashMap +{ + private int maxSize; + private CleanupCallback callback; + + public LRUCache(int maxSize, CleanupCallback callback) + { + super(maxSize + 1, 1, true); + + this.maxSize = maxSize; + this.callback = callback; + } + + @Override + protected boolean removeEldestEntry(Entry eldest) + { + if (size() > maxSize) + { + callback.cleanup(eldest); + return true; + } + return false; + } + + public static interface CleanupCallback + { + public void cleanup(Entry eldest); + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/LocalVPN.java b/app/src/main/java/xyz/hexene/localvpn/LocalVPN.java new file mode 100644 index 0000000..bdb3c12 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/LocalVPN.java @@ -0,0 +1,87 @@ +package xyz.hexene.localvpn; + +import android.content.Intent; +import android.net.VpnService; +import android.support.v7.app.ActionBarActivity; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; + + +public class LocalVPN extends ActionBarActivity +{ + private static final int VPN_REQUEST_CODE = 0x0F; + + private boolean serviceStarting = false; + + @Override + protected void onCreate(Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_local_vpn); + final Button vpnButton = (Button)findViewById(R.id.vpn); + vpnButton.setOnClickListener(new View.OnClickListener() + { + @Override + public void onClick(View v) + { + startVPN(); + } + }); + } + + private void startVPN() + { + Intent vpnIntent = VpnService.prepare(this); + if (vpnIntent != null) + startActivityForResult(vpnIntent, VPN_REQUEST_CODE); + else + onActivityResult(VPN_REQUEST_CODE, RESULT_OK, null); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) + { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == VPN_REQUEST_CODE && resultCode == RESULT_OK) + { + serviceStarting = true; + startService(new Intent(this, LocalVPNService.class)); + enableButton(false); + } + } + + @Override + protected void onResume() { + super.onResume(); + + if (serviceStarting) + { + enableButton(false); + } + else if (LocalVPNService.isRunning()) + { + serviceStarting = false; + enableButton(false); + } + else + { + enableButton(true); + } + } + + private void enableButton(boolean enable) + { + final Button vpnButton = (Button) findViewById(R.id.vpn); + if (enable) + { + vpnButton.setEnabled(true); + vpnButton.setText(R.string.start_vpn); + } + else + { + vpnButton.setEnabled(false); + vpnButton.setText(R.string.stop_vpn); + } + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/LocalVPNService.java b/app/src/main/java/xyz/hexene/localvpn/LocalVPNService.java new file mode 100644 index 0000000..f914009 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/LocalVPNService.java @@ -0,0 +1,193 @@ +package xyz.hexene.localvpn; + +import android.app.PendingIntent; +import android.content.Intent; +import android.net.VpnService; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.Selector; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class LocalVPNService extends VpnService +{ + private static final String TAG = LocalVPNService.class.getSimpleName(); + private static final String VPN_ADDRESS = "10.0.0.2"; // Only IPv4 support for now + private static final String VPN_ROUTE = "0.0.0.0"; // Intercept everything + + private static boolean isRunning = false; + + private ParcelFileDescriptor vpnInterface = null; + + private PendingIntent pendingIntent; + + private ConcurrentLinkedQueue deviceToNetworkUDPQueue; + private ConcurrentLinkedQueue deviceToNetworkTCPQueue; + private ConcurrentLinkedQueue networkToDeviceQueue; + private ExecutorService executorService; + + private Selector udpSelector; + private Selector tcpSelector; + + @Override + public void onCreate() + { + super.onCreate(); + isRunning = true; + setupVPN(); + try + { + udpSelector = Selector.open(); + tcpSelector = Selector.open(); + deviceToNetworkUDPQueue = new ConcurrentLinkedQueue<>(); + deviceToNetworkTCPQueue = new ConcurrentLinkedQueue<>(); + networkToDeviceQueue = new ConcurrentLinkedQueue<>(); + + executorService = Executors.newFixedThreadPool(5); + executorService.submit(new UDPInput(networkToDeviceQueue, udpSelector)); + executorService.submit(new UDPOutput(deviceToNetworkUDPQueue, udpSelector, this)); + executorService.submit(new TCPInput(networkToDeviceQueue, tcpSelector)); + executorService.submit(new TCPOutput(deviceToNetworkTCPQueue, networkToDeviceQueue, tcpSelector, this)); + executorService.submit(new VPNRunnable(vpnInterface.getFileDescriptor(), + deviceToNetworkUDPQueue, deviceToNetworkTCPQueue, networkToDeviceQueue)); + Log.i(TAG, "Started"); + } + catch (IOException e) + { + Log.e(TAG, e.toString(), e); + } + } + + private void setupVPN() + { + if (vpnInterface == null) + { + Builder builder = new Builder(); + builder.addAddress(VPN_ADDRESS, 32); + builder.addRoute(VPN_ROUTE, 0); + vpnInterface = builder.setSession(getString(R.string.app_name)).setConfigureIntent(pendingIntent).establish(); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) + { + return START_STICKY; + } + + public static boolean isRunning() + { + return isRunning; + } + + @Override + public void onDestroy() + { + super.onDestroy(); + isRunning = false; + executorService.shutdownNow(); + ByteBufferPool.clear(); + try + { + udpSelector.close(); + vpnInterface.close(); + } + catch (IOException e) + { + //Ignore + } + Log.i(TAG, "Stopped"); + } + + private static class VPNRunnable implements Runnable + { + private static final String TAG = VPNRunnable.class.getSimpleName(); + + private FileDescriptor vpnFileDescriptor; + + private ConcurrentLinkedQueue deviceToNetworkUDPQueue; + private ConcurrentLinkedQueue deviceToNetworkTCPQueue; + private ConcurrentLinkedQueue networkToDeviceQueue; + + public VPNRunnable(FileDescriptor vpnFileDescriptor, + ConcurrentLinkedQueue deviceToNetworkUDPQueue, + ConcurrentLinkedQueue deviceToNetworkTCPQueue, + ConcurrentLinkedQueue networkToDeviceQueue) + { + this.vpnFileDescriptor = vpnFileDescriptor; + this.deviceToNetworkUDPQueue = deviceToNetworkUDPQueue; + this.deviceToNetworkTCPQueue = deviceToNetworkTCPQueue; + this.networkToDeviceQueue = networkToDeviceQueue; + } + + @Override + public void run() + { + Log.i(TAG, "Started"); + try + { + FileChannel vpnInput = new FileInputStream(vpnFileDescriptor).getChannel(); + FileChannel vpnOutput = new FileOutputStream(vpnFileDescriptor).getChannel(); + + ByteBuffer bufferToNetwork = null; + boolean dataSent = true; + boolean dataReceived; + while (!Thread.interrupted()) + { + if (dataSent) + bufferToNetwork = ByteBufferPool.acquire(); + + // TODO: Block when not connected + int readBytes = vpnInput.read(bufferToNetwork); + if (readBytes > 0) + { + bufferToNetwork.flip(); + Packet packet = new Packet(bufferToNetwork); + if (packet.isUDP()) + deviceToNetworkUDPQueue.offer(packet); + else if (packet.isTCP()) + deviceToNetworkTCPQueue.offer(packet); + else + Log.w(TAG, "Unknown packet type"); + dataSent = true; + } + else + { + dataSent = false; + } + + ByteBuffer bufferFromNetwork = networkToDeviceQueue.poll(); + if (bufferFromNetwork != null) + { + bufferFromNetwork.flip(); + vpnOutput.write(bufferFromNetwork); + dataReceived = true; + + ByteBufferPool.release(bufferFromNetwork); + } + else + { + dataReceived = false; + } + + // TODO: Sleep-looping is not very battery-friendly, consider blocking instead + // Confirm if throughput with ConcurrentQueue is really higher compared to BlockingQueue + if (!dataSent && !dataReceived) + Thread.sleep(10); + } + } + catch (Exception/*|InterruptedException|IOException*/ e) + { + Log.w(TAG, e.toString(), e); + } + } + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/Packet.java b/app/src/main/java/xyz/hexene/localvpn/Packet.java new file mode 100644 index 0000000..df21a6a --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/Packet.java @@ -0,0 +1,473 @@ +package xyz.hexene.localvpn; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; + +/** + * Representation of an IP Packet + */ +// TODO: Reduce public mutability +public class Packet +{ + public static final int IP4_HEADER_SIZE = 20; + public static final int TCP_HEADER_SIZE = 20; + public static final int UDP_HEADER_SIZE = 8; + + public IP4Header ip4Header; + public TCPHeader tcpHeader; + public UDPHeader udpHeader; + public ByteBuffer backingBuffer; + + private boolean isTCP; + private boolean isUDP; + + public Packet(ByteBuffer buffer) throws UnknownHostException { + this.ip4Header = new IP4Header(buffer); + if (this.ip4Header.protocol == IP4Header.TransportProtocol.TCP) { + this.tcpHeader = new TCPHeader(buffer); + this.isTCP = true; + } else if (ip4Header.protocol == IP4Header.TransportProtocol.UDP) { + this.udpHeader = new UDPHeader(buffer); + this.isUDP = true; + } + this.backingBuffer = buffer; + } + + @Override + public String toString() + { + final StringBuilder sb = new StringBuilder("Packet{"); + sb.append("ip4Header=").append(ip4Header); + if (isTCP) sb.append(", tcpHeader=").append(tcpHeader); + else if (isUDP) sb.append(", udpHeader=").append(udpHeader); + sb.append(", payloadSize=").append(backingBuffer.limit() - backingBuffer.position()); + sb.append('}'); + return sb.toString(); + } + + public boolean isTCP() + { + return isTCP; + } + + public boolean isUDP() + { + return isUDP; + } + + public void swapSourceAndDestination() + { + InetAddress newSourceAddress = ip4Header.destinationAddress; + ip4Header.destinationAddress = ip4Header.sourceAddress; + ip4Header.sourceAddress = newSourceAddress; + + if (isUDP) + { + int newSourcePort = udpHeader.destinationPort; + udpHeader.destinationPort = udpHeader.sourcePort; + udpHeader.sourcePort = newSourcePort; + } + else if (isTCP) + { + int newSourcePort = tcpHeader.destinationPort; + tcpHeader.destinationPort = tcpHeader.sourcePort; + tcpHeader.sourcePort = newSourcePort; + } + } + + public void updateTCPBuffer(ByteBuffer buffer, byte flags, long sequenceNum, long ackNum, int payloadSize) + { + buffer.position(0); + fillHeader(buffer); + backingBuffer = buffer; + + tcpHeader.flags = flags; + backingBuffer.put(IP4_HEADER_SIZE + 13, flags); + + tcpHeader.sequenceNumber = sequenceNum; + backingBuffer.putInt(IP4_HEADER_SIZE + 4, (int) sequenceNum); + + tcpHeader.acknowledgementNumber = ackNum; + backingBuffer.putInt(IP4_HEADER_SIZE + 8, (int) ackNum); + + // Reset header size, since we don't need options + byte dataOffset = (byte) (TCP_HEADER_SIZE << 2); + tcpHeader.dataOffsetAndReserved = dataOffset; + backingBuffer.put(IP4_HEADER_SIZE + 12, dataOffset); + + updateTCPChecksum(payloadSize); + + int ip4TotalLength = IP4_HEADER_SIZE + TCP_HEADER_SIZE + payloadSize; + backingBuffer.putShort(2, (short) ip4TotalLength); + ip4Header.totalLength = ip4TotalLength; + + updateIP4Checksum(); + } + + public void updateUDPBuffer(ByteBuffer buffer, int payloadSize) + { + buffer.position(0); + fillHeader(buffer); + backingBuffer = buffer; + + int udpTotalLength = UDP_HEADER_SIZE + payloadSize; + backingBuffer.putShort(IP4_HEADER_SIZE + 4, (short) udpTotalLength); + udpHeader.length = udpTotalLength; + + // Disable UDP checksum validation + backingBuffer.putShort(IP4_HEADER_SIZE + 6, (short) 0); + udpHeader.checksum = 0; + + int ip4TotalLength = IP4_HEADER_SIZE + udpTotalLength; + backingBuffer.putShort(2, (short) ip4TotalLength); + ip4Header.totalLength = ip4TotalLength; + + updateIP4Checksum(); + } + + private void updateIP4Checksum() + { + ByteBuffer buffer = backingBuffer.duplicate(); + buffer.position(0); + + // Clear previous checksum + buffer.putShort(10, (short) 0); + + int ipLength = ip4Header.headerLength; + int sum = 0; + while (ipLength > 0) + { + sum += BitUtils.getUnsignedShort(buffer.getShort()); + ipLength -= 2; + } + while (sum >> 16 > 0) + sum = (sum & 0xFFFF) + (sum >> 16); + + sum = ~sum; + ip4Header.headerChecksum = sum; + backingBuffer.putShort(10, (short) sum); + } + + private void updateTCPChecksum(int payloadSize) + { + int sum = 0; + int tcpLength = TCP_HEADER_SIZE + payloadSize; + + // Calculate pseudo-header checksum + ByteBuffer buffer = ByteBuffer.wrap(ip4Header.sourceAddress.getAddress()); + sum = BitUtils.getUnsignedShort(buffer.getShort()) + BitUtils.getUnsignedShort(buffer.getShort()); + + buffer = ByteBuffer.wrap(ip4Header.destinationAddress.getAddress()); + sum += BitUtils.getUnsignedShort(buffer.getShort()) + BitUtils.getUnsignedShort(buffer.getShort()); + + sum += IP4Header.TransportProtocol.TCP.getNumber() + tcpLength; + + buffer = backingBuffer.duplicate(); + // Clear previous checksum + buffer.putShort(IP4_HEADER_SIZE + 16, (short) 0); + + // Calculate TCP segment checksum + buffer.position(IP4_HEADER_SIZE); + while (tcpLength > 1) + { + sum += BitUtils.getUnsignedShort(buffer.getShort()); + tcpLength -= 2; + } + if (tcpLength > 0) + sum += BitUtils.getUnsignedByte(buffer.get()) << 8; + + while (sum >> 16 > 0) + sum = (sum & 0xFFFF) + (sum >> 16); + + sum = ~sum; + tcpHeader.checksum = sum; + backingBuffer.putShort(IP4_HEADER_SIZE + 16, (short) sum); + } + + private void fillHeader(ByteBuffer buffer) + { + ip4Header.fillHeader(buffer); + if (isUDP) + udpHeader.fillHeader(buffer); + else if (isTCP) + tcpHeader.fillHeader(buffer); + } + + public static class IP4Header + { + public byte version; + public byte IHL; + public int headerLength; + public short typeOfService; + public int totalLength; + + public int identificationAndFlagsAndFragmentOffset; + + public short TTL; + public TransportProtocol protocol; + public int headerChecksum; + + public InetAddress sourceAddress; + public InetAddress destinationAddress; + + public int optionsAndPadding; + + private enum TransportProtocol + { + TCP(6), + UDP(17), + Other(0xFF); + + private int protocolNumber; + + TransportProtocol(int protocolNumber) + { + this.protocolNumber = protocolNumber; + } + + private static TransportProtocol numberToEnum(int protocolNumber) + { + if (protocolNumber == 6) + return TCP; + else if (protocolNumber == 17) + return UDP; + else + return Other; + } + + public int getNumber() + { + return this.protocolNumber; + } + } + + private IP4Header(ByteBuffer buffer) throws UnknownHostException + { + byte versionAndIHL = buffer.get(); + this.version = (byte) (versionAndIHL >> 4); + this.IHL = (byte) (versionAndIHL & 0x0F); + this.headerLength = this.IHL << 2; + + this.typeOfService = BitUtils.getUnsignedByte(buffer.get()); + this.totalLength = BitUtils.getUnsignedShort(buffer.getShort()); + + this.identificationAndFlagsAndFragmentOffset = buffer.getInt(); + + this.TTL = BitUtils.getUnsignedByte(buffer.get()); + this.protocol = TransportProtocol.numberToEnum(BitUtils.getUnsignedByte(buffer.get())); + this.headerChecksum = BitUtils.getUnsignedShort(buffer.getShort()); + + byte[] addressBytes = new byte[4]; + buffer.get(addressBytes, 0, 4); + this.sourceAddress = InetAddress.getByAddress(addressBytes); + + buffer.get(addressBytes, 0, 4); + this.destinationAddress = InetAddress.getByAddress(addressBytes); + + //this.optionsAndPadding = buffer.getInt(); + } + + public void fillHeader(ByteBuffer buffer) + { + buffer.put((byte) (this.version << 4 | this.IHL)); + buffer.put((byte) this.typeOfService); + buffer.putShort((short) this.totalLength); + + buffer.putInt(this.identificationAndFlagsAndFragmentOffset); + + buffer.put((byte) this.TTL); + buffer.put((byte) this.protocol.getNumber()); + buffer.putShort((short) this.headerChecksum); + + buffer.put(this.sourceAddress.getAddress()); + buffer.put(this.destinationAddress.getAddress()); + } + + @Override + public String toString() + { + final StringBuilder sb = new StringBuilder("IP4Header{"); + sb.append("version=").append(version); + sb.append(", totalLength=").append(totalLength); + sb.append(", protocol=").append(protocol); + sb.append(", headerChecksum=").append(headerChecksum); + sb.append(", sourceAddress=").append(sourceAddress.getHostAddress()); + sb.append(", destinationAddress=").append(destinationAddress.getHostAddress()); + sb.append('}'); + return sb.toString(); + } + } + + public static class TCPHeader + { + public static final int FIN = 0x01; + public static final int SYN = 0x02; + public static final int RST = 0x04; + public static final int PSH = 0x08; + public static final int ACK = 0x10; + public static final int URG = 0x20; + + public int sourcePort; + public int destinationPort; + + public long sequenceNumber; + public long acknowledgementNumber; + + public byte dataOffsetAndReserved; + public int headerLength; + public byte flags; + public int window; + + public int checksum; + public int urgentPointer; + + public byte[] optionsAndPadding; + + private TCPHeader(ByteBuffer buffer) + { + this.sourcePort = BitUtils.getUnsignedShort(buffer.getShort()); + this.destinationPort = BitUtils.getUnsignedShort(buffer.getShort()); + + this.sequenceNumber = BitUtils.getUnsignedInt(buffer.getInt()); + this.acknowledgementNumber = BitUtils.getUnsignedInt(buffer.getInt()); + + this.dataOffsetAndReserved = buffer.get(); + this.headerLength = (this.dataOffsetAndReserved & 0xF0) >> 2; + this.flags = buffer.get(); + this.window = BitUtils.getUnsignedShort(buffer.getShort()); + + this.checksum = BitUtils.getUnsignedShort(buffer.getShort()); + this.urgentPointer = BitUtils.getUnsignedShort(buffer.getShort()); + + int optionsLength = this.headerLength - TCP_HEADER_SIZE; + optionsAndPadding = new byte[optionsLength]; + buffer.get(optionsAndPadding, 0, optionsLength); + } + + public boolean isFIN() + { + return (flags & FIN) == FIN; + } + + public boolean isSYN() + { + return (flags & SYN) == SYN; + } + + public boolean isRST() + { + return (flags & RST) == RST; + } + + public boolean isPSH() + { + return (flags & PSH) == PSH; + } + + public boolean isACK() + { + return (flags & ACK) == ACK; + } + + public boolean isURG() + { + return (flags & URG) == URG; + } + + private void fillHeader(ByteBuffer buffer) + { + buffer.putShort((short) sourcePort); + buffer.putShort((short) destinationPort); + + buffer.putInt((int) sequenceNumber); + buffer.putInt((int) acknowledgementNumber); + + buffer.put(dataOffsetAndReserved); + buffer.put(flags); + buffer.putShort((short) window); + + buffer.putShort((short) checksum); + buffer.putShort((short) urgentPointer); + } + + @Override + public String toString() + { + final StringBuilder sb = new StringBuilder("TCPHeader{"); + sb.append("sourcePort=").append(sourcePort); + sb.append(", destinationPort=").append(destinationPort); + sb.append(", sequenceNumber=").append(sequenceNumber); + sb.append(", acknowledgementNumber=").append(acknowledgementNumber); + sb.append(", headerLength=").append(headerLength); + sb.append(", window=").append(window); + sb.append(", checksum=").append(checksum); + sb.append(", flags="); + if (isFIN()) sb.append(" FIN"); + if (isSYN()) sb.append(" SYN"); + if (isRST()) sb.append(" RST"); + if (isPSH()) sb.append(" PSH"); + if (isACK()) sb.append(" ACK"); + if (isURG()) sb.append(" URG"); + sb.append('}'); + return sb.toString(); + } + } + + public static class UDPHeader + { + public int sourcePort; + public int destinationPort; + + public int length; + public int checksum; + + private UDPHeader(ByteBuffer buffer) + { + this.sourcePort = BitUtils.getUnsignedShort(buffer.getShort()); + this.destinationPort = BitUtils.getUnsignedShort(buffer.getShort()); + + this.length = BitUtils.getUnsignedShort(buffer.getShort()); + this.checksum = BitUtils.getUnsignedShort(buffer.getShort()); + } + + private void fillHeader(ByteBuffer buffer) + { + buffer.putShort((short) this.sourcePort); + buffer.putShort((short) this.destinationPort); + + buffer.putShort((short) this.length); + buffer.putShort((short) this.checksum); + } + + @Override + public String toString() + { + final StringBuilder sb = new StringBuilder("UDPHeader{"); + sb.append("sourcePort=").append(sourcePort); + sb.append(", destinationPort=").append(destinationPort); + sb.append(", length=").append(length); + sb.append(", checksum=").append(checksum); + sb.append('}'); + return sb.toString(); + } + } + + private static class BitUtils + { + private static short getUnsignedByte(byte value) + { + return (short)(value & 0xFF); + } + + private static int getUnsignedShort(short value) + { + return value & 0xFFFF; + } + + private static long getUnsignedInt(int value) + { + return value & 0xFFFFFFFFL; + } + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/TCB.java b/app/src/main/java/xyz/hexene/localvpn/TCB.java new file mode 100644 index 0000000..cc65339 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/TCB.java @@ -0,0 +1,96 @@ +package xyz.hexene.localvpn; + +import java.io.IOException; +import java.nio.channels.SelectionKey; +import java.nio.channels.SocketChannel; +import java.util.Map; + +/** + * Transmission Control Block + */ +public class TCB +{ + public String ipAndPort; + + public long mySequenceNum, theirSequenceNum; + public long myAcknowledgementNum, theirAcknowledgementNum; + public TCBStatus status; + + // TCP has more states, but we need only these + public enum TCBStatus + { + SYN_RECEIVED, + ESTABLISHED, + CLOSE_WAIT, + LAST_ACK, + } + + public Packet referencePacket; + + public SocketChannel channel; + public boolean waitingForNetworkData; + public SelectionKey selectionKey; + + private static final int MAX_CACHE_SIZE = 50; // XXX: Is this ideal? + private static LRUCache tcbCache = + new LRUCache<>(MAX_CACHE_SIZE, new LRUCache.CleanupCallback() + { + @Override + public void cleanup(Map.Entry eldest) + { + eldest.getValue().closeChannel(); + } + }); + + public static TCB getTCB(String ipAndPort) + { + synchronized (tcbCache) + { + return tcbCache.get(ipAndPort); + } + } + + public static void putTCB(String ipAndPort, TCB tcb) + { + synchronized (tcbCache) + { + tcbCache.put(ipAndPort, tcb); + } + } + + public TCB(String ipAndPort, long mySequenceNum, long theirSequenceNum, long myAcknowledgementNum, long theirAcknowledgementNum, + SocketChannel channel, Packet referencePacket) + { + this.ipAndPort = ipAndPort; + + this.mySequenceNum = mySequenceNum; + this.theirSequenceNum = theirSequenceNum; + this.myAcknowledgementNum = myAcknowledgementNum; + this.theirAcknowledgementNum = theirAcknowledgementNum; + + this.channel = channel; + this.referencePacket = referencePacket; + this.status = TCBStatus.SYN_RECEIVED; + } + + public static void closeTCB(TCB tcb) + { + tcb.closeChannel(); + synchronized (tcbCache) + { + tcbCache.remove(tcb.ipAndPort); + } + } + + private void closeChannel() + { + try + { + channel.close(); + } + catch (IOException e) + { + // Ignore + } + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/TCPInput.java b/app/src/main/java/xyz/hexene/localvpn/TCPInput.java new file mode 100644 index 0000000..46d24b2 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/TCPInput.java @@ -0,0 +1,112 @@ +package xyz.hexene.localvpn; + +import android.util.Log; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.SocketChannel; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; + +import xyz.hexene.localvpn.TCB.TCBStatus; + +public class TCPInput implements Runnable +{ + private static final String TAG = TCPInput.class.getSimpleName(); + private static final int HEADER_SIZE = Packet.IP4_HEADER_SIZE + Packet.TCP_HEADER_SIZE; + + private ConcurrentLinkedQueue outputQueue; + private Selector selector; + + public TCPInput(ConcurrentLinkedQueue outputQueue, Selector selector) + { + this.outputQueue = outputQueue; + this.selector = selector; + } + + @Override + public void run() + { + try + { + Log.d(TAG, "Started"); + while (!Thread.interrupted()) + { + int readyChannels = selector.select(); + + if (readyChannels == 0) { + Thread.sleep(10); + continue; + } + + Set keys = selector.selectedKeys(); + Iterator keyIterator = keys.iterator(); + + while (keyIterator.hasNext() && !Thread.interrupted()) + { + SelectionKey key = keyIterator.next(); + if (key.isValid() && key.isReadable()) + { + keyIterator.remove(); + ByteBuffer receiveBuffer = ByteBufferPool.acquire(); + // Leave space for the header + receiveBuffer.position(HEADER_SIZE); + + TCB tcb = (TCB) key.attachment(); + synchronized (tcb) + { + Packet referencePacket = tcb.referencePacket; + SocketChannel inputChannel = (SocketChannel) key.channel(); + int readBytes; + try + { + readBytes = inputChannel.read(receiveBuffer); + } + catch (IOException e) + { + Log.e(TAG, "Network read error: " + tcb.ipAndPort); + referencePacket.updateTCPBuffer(receiveBuffer, (byte) Packet.TCPHeader.RST, 0, tcb.myAcknowledgementNum, 0); + outputQueue.offer(receiveBuffer); + TCB.closeTCB(tcb); + continue; + } + + if (readBytes == -1) + { + // End of stream, stop waiting until we push more data + key.interestOps(0); + tcb.waitingForNetworkData = false; + + if (tcb.status != TCBStatus.CLOSE_WAIT) + { + ByteBufferPool.release(receiveBuffer); + continue; + } + + tcb.status = TCBStatus.LAST_ACK; + referencePacket.updateTCPBuffer(receiveBuffer, (byte) Packet.TCPHeader.FIN, tcb.mySequenceNum, tcb.myAcknowledgementNum, 0); + tcb.mySequenceNum++; // FIN counts as a byte + } + else + { + // XXX: We should ideally be splitting segments by MTU/MSS, but this seems to work without + referencePacket.updateTCPBuffer(receiveBuffer, (byte) (Packet.TCPHeader.PSH | Packet.TCPHeader.ACK), + tcb.mySequenceNum, tcb.myAcknowledgementNum, readBytes); + tcb.mySequenceNum += readBytes; // Next sequence number + receiveBuffer.position(HEADER_SIZE + readBytes); + } + } + outputQueue.offer(receiveBuffer); + } + } + } + } + catch (Exception/*InterruptedException|IOException*/ e) + { + Log.w(TAG, e.toString(), e); + } + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/TCPOutput.java b/app/src/main/java/xyz/hexene/localvpn/TCPOutput.java new file mode 100644 index 0000000..216ca69 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/TCPOutput.java @@ -0,0 +1,229 @@ +package xyz.hexene.localvpn; + +import android.util.Log; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.SocketChannel; +import java.util.Random; +import java.util.concurrent.ConcurrentLinkedQueue; + +import xyz.hexene.localvpn.Packet.TCPHeader; +import xyz.hexene.localvpn.TCB.TCBStatus; + +public class TCPOutput implements Runnable +{ + private static final String TAG = TCPOutput.class.getSimpleName(); + + private LocalVPNService vpnService; + private ConcurrentLinkedQueue inputQueue; + private ConcurrentLinkedQueue outputQueue; + private Selector selector; + + private Random random = new Random(); + public TCPOutput(ConcurrentLinkedQueue inputQueue, ConcurrentLinkedQueue outputQueue, + Selector selector, LocalVPNService vpnService) + { + this.inputQueue = inputQueue; + this.outputQueue = outputQueue; + this.selector = selector; + this.vpnService = vpnService; + } + + @Override + public void run() + { + Log.i(TAG, "Started"); + try + { + + Thread currentThread = Thread.currentThread(); + while (true) + { + Packet currentPacket; + // TODO: Block when not connected + do + { + currentPacket = inputQueue.poll(); + if (currentPacket != null) + break; + Thread.sleep(10); + } while (!currentThread.isInterrupted()); + + if (currentThread.isInterrupted()) + break; + + ByteBuffer payloadBuffer = currentPacket.backingBuffer; + currentPacket.backingBuffer = null; + ByteBuffer responseBuffer = ByteBufferPool.acquire(); + + InetAddress destinationAddress = currentPacket.ip4Header.destinationAddress; + + TCPHeader tcpHeader = currentPacket.tcpHeader; + int destinationPort = tcpHeader.destinationPort; + int sourcePort = tcpHeader.sourcePort; + + String ipAndPort = destinationAddress.getHostAddress() + ":" + + destinationPort + ":" + sourcePort; + TCB tcb = TCB.getTCB(ipAndPort); + if (tcb == null) + initializeConnection(ipAndPort, destinationAddress, destinationPort, + currentPacket, tcpHeader, responseBuffer); + else if (tcpHeader.isSYN()) + sendRST(tcb, 1, responseBuffer); + else if (tcpHeader.isRST()) + closeCleanly(tcb, responseBuffer); + else if (tcpHeader.isFIN()) + processFIN(tcb, tcpHeader, responseBuffer); + else if (tcpHeader.isACK()) + processACK(tcb, tcpHeader, payloadBuffer, responseBuffer); + + ByteBufferPool.release(payloadBuffer); + } + } + catch (Exception e) + { + Log.i(TAG, e.toString(), e); + } + } + + private void initializeConnection(String ipAndPort, InetAddress destinationAddress, int destinationPort, + Packet currentPacket, TCPHeader tcpHeader, ByteBuffer responseBuffer) + throws IOException + { + currentPacket.swapSourceAndDestination(); + if (tcpHeader.isSYN()) + { + SocketChannel outputChannel = SocketChannel.open(); + vpnService.protect(outputChannel.socket()); + + boolean connected = false; + try + { + connected = outputChannel.connect(new InetSocketAddress(destinationAddress, destinationPort)); + } + catch (IOException e) + { + Log.e(TAG, "Connection error: " + ipAndPort); + } + + if (connected) + { + TCB tcb = new TCB(ipAndPort, random.nextLong(), tcpHeader.sequenceNumber, tcpHeader.sequenceNumber + 1, + tcpHeader.acknowledgementNumber, outputChannel, currentPacket); + TCB.putTCB(ipAndPort, tcb); + // TODO: Set MSS for receiving larger packets from the device + currentPacket.updateTCPBuffer(responseBuffer, (byte) (TCPHeader.SYN | TCPHeader.ACK), + tcb.mySequenceNum, tcb.myAcknowledgementNum, 0); + tcb.mySequenceNum++; // SYN counts as a byte + + outputChannel.configureBlocking(false); + } + else + { + currentPacket.updateTCPBuffer(responseBuffer, (byte) TCPHeader.RST, + 0, tcpHeader.sequenceNumber + 1, 0); + outputChannel.close(); + } + } + else + { + currentPacket.updateTCPBuffer(responseBuffer, (byte) TCPHeader.RST, + 0, tcpHeader.sequenceNumber + 1, 0); + } + outputQueue.offer(responseBuffer); + } + + private void processFIN(TCB tcb, TCPHeader tcpHeader, ByteBuffer responseBuffer) + { + synchronized (tcb) + { + Packet referencePacket = tcb.referencePacket; + tcb.myAcknowledgementNum = tcpHeader.sequenceNumber + 1; + tcb.theirAcknowledgementNum = tcpHeader.acknowledgementNumber; + + if (tcb.waitingForNetworkData) + { + tcb.status = TCBStatus.CLOSE_WAIT; + referencePacket.updateTCPBuffer(responseBuffer, (byte) TCPHeader.ACK, + tcb.mySequenceNum, tcb.myAcknowledgementNum, 0); + } + else + { + tcb.status = TCBStatus.LAST_ACK; + referencePacket.updateTCPBuffer(responseBuffer, (byte) (TCPHeader.FIN | TCPHeader.ACK), + tcb.mySequenceNum, tcb.myAcknowledgementNum, 0); + tcb.mySequenceNum++; // FIN counts as a byte + } + } + outputQueue.offer(responseBuffer); + } + + private void processACK(TCB tcb, TCPHeader tcpHeader, ByteBuffer payloadBuffer, ByteBuffer responseBuffer) throws IOException + { + int payloadSize = payloadBuffer.limit() - payloadBuffer.position(); + + synchronized (tcb) + { + SocketChannel outputChannel = tcb.channel; + if (tcb.status == TCBStatus.SYN_RECEIVED) + { + tcb.status = TCBStatus.ESTABLISHED; + + selector.wakeup(); + tcb.selectionKey = outputChannel.register(selector, SelectionKey.OP_READ, tcb); + tcb.waitingForNetworkData = true; + } + else if (tcb.status == TCBStatus.LAST_ACK) + { + closeCleanly(tcb, responseBuffer); + return; + } + + if (payloadSize == 0) return; // Empty ACK, ignore + + if (!tcb.waitingForNetworkData) + { + tcb.selectionKey.interestOps(SelectionKey.OP_READ); + tcb.waitingForNetworkData = true; + } + + // Forward to remote server + try + { + while (payloadBuffer.hasRemaining()) + outputChannel.write(payloadBuffer); + } + catch (IOException e) + { + Log.e(TAG, "Network write error: " + tcb.ipAndPort); + sendRST(tcb, payloadSize, responseBuffer); + return; + } + + // TODO: We don't expect out-of-order packets, but verify + tcb.myAcknowledgementNum = tcpHeader.sequenceNumber + payloadSize; + tcb.theirAcknowledgementNum = tcpHeader.acknowledgementNumber; + Packet referencePacket = tcb.referencePacket; + referencePacket.updateTCPBuffer(responseBuffer, (byte) TCPHeader.ACK, tcb.mySequenceNum, tcb.myAcknowledgementNum, 0); + } + outputQueue.offer(responseBuffer); + } + + private void sendRST(TCB tcb, int prevPayloadSize, ByteBuffer buffer) + { + tcb.referencePacket.updateTCPBuffer(buffer, (byte) TCPHeader.RST, 0, tcb.myAcknowledgementNum + prevPayloadSize, 0); + outputQueue.offer(buffer); + TCB.closeTCB(tcb); + } + + private void closeCleanly(TCB tcb, ByteBuffer buffer) + { + ByteBufferPool.release(buffer); + TCB.closeTCB(tcb); + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/UDPInput.java b/app/src/main/java/xyz/hexene/localvpn/UDPInput.java new file mode 100644 index 0000000..1007ca9 --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/UDPInput.java @@ -0,0 +1,76 @@ +package xyz.hexene.localvpn; + +import android.util.Log; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.DatagramChannel; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class UDPInput implements Runnable +{ + private static final String TAG = UDPInput.class.getSimpleName(); + private static final int HEADER_SIZE = Packet.IP4_HEADER_SIZE + Packet.UDP_HEADER_SIZE; + + private Selector selector; + private ConcurrentLinkedQueue outputQueue; + + public UDPInput(ConcurrentLinkedQueue outputQueue, Selector selector) + { + this.outputQueue = outputQueue; + this.selector = selector; + } + + @Override + public void run() + { + try + { + Log.i(TAG, "Started"); + while (!Thread.interrupted()) + { + int readyChannels = selector.select(); + + if (readyChannels == 0) { + Thread.sleep(10); + continue; + } + + Set keys = selector.selectedKeys(); + Iterator keyIterator = keys.iterator(); + + while (keyIterator.hasNext() && !Thread.interrupted()) + { + SelectionKey key = keyIterator.next(); + if (key.isValid() && key.isReadable()) + { + keyIterator.remove(); + + ByteBuffer receiveBuffer = ByteBufferPool.acquire(); + // Leave space for the header + receiveBuffer.position(HEADER_SIZE); + + DatagramChannel inputChannel = (DatagramChannel) key.channel(); + // XXX: We should handle any IOExceptions here immediately, + // but that probably won't happen with UDP + int readBytes = inputChannel.read(receiveBuffer); + + Packet referencePacket = (Packet) key.attachment(); + referencePacket.updateUDPBuffer(receiveBuffer, readBytes); + receiveBuffer.position(HEADER_SIZE + readBytes); + + outputQueue.offer(receiveBuffer); + } + } + } + } + catch (InterruptedException|IOException e) + { + Log.w(TAG, e.toString(), e); + } + } +} diff --git a/app/src/main/java/xyz/hexene/localvpn/UDPOutput.java b/app/src/main/java/xyz/hexene/localvpn/UDPOutput.java new file mode 100644 index 0000000..c40caca --- /dev/null +++ b/app/src/main/java/xyz/hexene/localvpn/UDPOutput.java @@ -0,0 +1,116 @@ +package xyz.hexene.localvpn; + +import android.util.Log; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.channels.DatagramChannel; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class UDPOutput implements Runnable +{ + private static final String TAG = UDPOutput.class.getSimpleName(); + private static final int MAX_CACHE_SIZE = 50; + + private LocalVPNService vpnService; + private ConcurrentLinkedQueue inputQueue; + private Selector selector; + + public UDPOutput(ConcurrentLinkedQueue inputQueue, Selector selector, LocalVPNService vpnService) + { + this.inputQueue = inputQueue; + this.selector = selector; + this.vpnService = vpnService; + } + + @Override + public void run() + { + Log.i(TAG, "Started"); + try + { + LRUCache channelCache = + new LRUCache<>(MAX_CACHE_SIZE, new LRUCache.CleanupCallback() + { + @Override + public void cleanup(Map.Entry eldest) + { + try + { + eldest.getValue().close(); + } + catch (IOException e) + { + // Ignore + } + } + }); + + Thread currentThread = Thread.currentThread(); + while (true) + { + Packet currentPacket; + // TODO: Block when not connected + do + { + currentPacket = inputQueue.poll(); + if (currentPacket != null) + break; + Thread.sleep(10); + } while (!currentThread.isInterrupted()); + + if (currentThread.isInterrupted()) + break; + + InetAddress destinationAddress = currentPacket.ip4Header.destinationAddress; + int destinationPort = currentPacket.udpHeader.destinationPort; + int sourcePort = currentPacket.udpHeader.sourcePort; + + String ipAndPort = destinationAddress.getHostAddress() + ":" + destinationPort + ":" + sourcePort; + DatagramChannel outputChannel = channelCache.get(ipAndPort); + if (outputChannel == null) { + outputChannel = DatagramChannel.open(); + try + { + outputChannel.connect(new InetSocketAddress(destinationAddress, destinationPort)); + } + catch (IOException e) + { + Log.e(TAG, "Connection error: " + ipAndPort); + outputChannel.close(); + continue; + } + outputChannel.configureBlocking(false); + currentPacket.swapSourceAndDestination(); + + selector.wakeup(); + outputChannel.register(selector, SelectionKey.OP_READ, currentPacket); + + vpnService.protect(outputChannel.socket()); + + channelCache.put(ipAndPort, outputChannel); + } + + try + { + outputChannel.write(currentPacket.backingBuffer); + } + catch (Exception e) + { + Log.e(TAG, "Network write error: " + ipAndPort); + channelCache.remove(ipAndPort); + outputChannel.close(); + continue; + } + } + } + catch (InterruptedException|IOException e) + { + Log.i(TAG, e.toString(), e); + } + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_launcher.png b/app/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..96a442e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..359047d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..71c6d76 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4df1894 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/layout/activity_local_vpn.xml b/app/src/main/res/layout/activity_local_vpn.xml new file mode 100644 index 0000000..78cc363 --- /dev/null +++ b/app/src/main/res/layout/activity_local_vpn.xml @@ -0,0 +1,12 @@ + + +