MF99 coding 💻

keep learning; keep coding;

App 內建 VPN/DNS service

f:id:mouseface99:20191202135111p:plain

我們偶爾會有需要使用到 VPN 服務,可能是要存取公司內部網路、模擬成外國IP用來切換帳號,或甚至是在強國需要翻牆的時候。 VPN 服務提供了一個中介層,把所有的網路請求透過這個中介層來做轉發

一來可以模擬一些資訊(國家/地區),二來也可以保護本地的真實資料不會暴露在網路上

不過這次的需求比較特殊,因為工作需要,我們的App可能會處於一個 DNS 被竄改的網路環境,所以我們App所使用的 domain 可能會被 hijack 而被導向我們不預期的IP。 所以這時候我們就透過了 Android 內建的 VPNService 來在我們的 App 中建立一個 VPN Service,一來是把App中所有的流量集中從這個 VPNService 出去,二來從中強迫指定 DNS Server 的規則以及真實 IP,以保護我們 App 中所有網路請求的 DNS 轉換都是可信任的。

具體作法的架構如下 f:id:mouseface99:20191121130114p:plain

建立一個 MyVPNService, 然後與系統的網路層綁定,讓系統自動將 My Application 中所有 Network 的流量,都先通過配置好的 MyVPNService 出去。 其中這個自定義的 MyVPNService 就包含了我們指定的 DNS server,而不去用系統內建的。

具體實作步驟:

  1. 建立 MyVPNService 並繼承 android.net.VPNService
  2. 在 AndroidMenifest.xml 中註冊 MyVPNService
  3. 在我們 App 初始化的時候,啟動這個 Service,並且在 service init 的時候做相對應的配置跟綁定。
  4. 記得在 App 退出的時候,停掉這個 Service

MyVPNService.java

public class MyVPNService extends VpnService {
   private VpnService.Builder builder = new VpnService.Builder();
   private ParcelFileDescriptor fileDescriptor;
   private Thread mThread;
   private boolean shouldRun = true;
   private DatagramChannel tunnel;

   public void stopThisService() {
       this.shouldRun = false;
       stopSelf();
   }

   @Override
   public int onStartCommand(final Intent paramIntent, int p1, int p2) {
       mThread = new Thread(new Runnable() {
           public void run() {
               try {
                   fileDescriptor = builder.setSession("MyVPNService")
                                   .addAddress("192.168.0.1", 24)
                                   .addDnsServer("8.8.8.8")
                                   .addDnsServer("8.8.4.4")
                                   .establish();

                   tunnel = DatagramChannel.open();
                   tunnel.connect(new InetSocketAddress("127.0.0.1", 8087));
                   protect(tunnel.socket());

                   while (shouldRun)
                       Thread.sleep(100L);
               } catch (Exception exception) {} 
              finally {
                   if (fileDescriptor != null) {
                       try {
                           fileDescriptor.close();
                           fileDescriptor = null;
                       } catch (IOException e) {}
                   }
               }
            }
       }, "DNS Changer");
       mThread.start();
       return Service.START_NOT_STICKY;
   }

   @Override
   public IBinder onBind(Intent intent) {
       return mBinder;
   }

   private final IBinder mBinder = new LocalBinder();

   public class LocalBinder extends Binder {
       public MyVPNService getService() {
           return MyVPNService.this;
       }
   }
}

AndroidManifest.xml

宣告 Service 的同時,也要記得加上 BIND_VPN_SERVICE 的權限!

...
<service
       android:name=".MyVPNService"
       android:permission="android.permission.BIND_VPN_SERVICE" />
...

MainActivity.java

public class MainActivity extends Activity {
   final int REQUEST_CONNECT = 21;
   private MyVPNService mService = null;
   private Intent serviceIntent;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       serviceIntent = new Intent(this, MyVPNService.class);
       startDNS();
   }

   private void startDNS(){
       Intent intent = VpnService.prepare(this);
       if (intent != null)
           startActivityForResult(intent, REQUEST_CONNECT);
       else
           onActivityResult(REQUEST_CONNECT, RESULT_OK, null);
   }

   private ServiceConnection connection = new ServiceConnection(){
       @Override
       public void onServiceConnected(ComponentName name, IBinder service) {
           if(service != null && service instanceof MyVPNService.LocalBinder){
               mService = ((MyVPNService.LocalBinder) service).getService();
           }
       }

       @Override
       public void onServiceDisconnected(ComponentName name) {}
   };

   protected void onActivityResult(int requestCode, int resultCode, Intent data) {
       if (resultCode == RESULT_OK) {
           startService(serviceIntent);
           bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE);
       }
       super.onActivityResult(requestCode, resultCode, data);
   }

   @Override
   protected void onStop() {
       super.onStop();
       if(mService != null)
           mService.stopThisService();
       unbindService(connection);
   }
}

解說

首先這個 Service 必需繼承 Androidandroid.net.VpnService

public class MyVPNService extends VpnService

然後在 Service 啟動時開啟一個 background thread 來做初始化

首先先建立這個 VPN Service 的描述檔 addAddress:指定虛擬接口的 IP addDnsServer:指定接口的 DNS Server

...
VpnService.Builder builder = new VpnService.Builder();
ParcelFileDescriptor fileDescriptor;
...
fileDescriptor = builder.setSession("MyVPNService")
                .addAddress("192.168.0.1", 24)
                .addDnsServer("8.8.8.8")
                .addDnsServer("8.8.4.4")
                .establish();

當然還有像是 setMtu addRoute addSearchDomain 功能,但是目前的應用主要是以指定 DNS 的需求

接下來就是建立一個Socket,並指定到本地的 8087 port

...
DatagramChannel tunnel;
...
tunnel = DatagramChannel.open();
tunnel.connect(new InetSocketAddress("127.0.0.1", 8087));

最後,透過 VpnService 中的 protect 讓系統進行 Socket 的設置以及確保流量會透過我們建立的 Socket 出去

protect(tunnel.socket());

建立好 VPNService 後,還要在 App 開啟時(或是任何需要的時候)去啟動它

要啟動時,首先因為 VPN Service 的開啟,就像 Runtime permission 一樣,必須要明確的用戶確認後才能使用。 所以,要先確認用戶是否已經同意/開啟過

private void startDNS(){
        Intent intent = VpnService.prepare(this);
        if (intent != null)
                startActivityForResult(intent, REQUEST_CONNECT);
        else
                onActivityResult(REQUEST_CONNECT, RESULT_OK, null);
}

如果 VpnService.prepare(this) 有拿到 intent,表示用戶還沒有同意過,所以系統會給你一個對應的 intent 來取得用戶同意

開啟這個 intent 後,系統會跳出一個視窗來詢問使用者是否同意我們的 App 開啟一個背景 VPN

f:id:mouseface99:20191121130117p:plain

在用戶同意後,未來啟動的時候,VpnService.prepare(this) 就不會拿到 intent,這時候就可以直接啟動 Service

最後,在 App 裏開的時候,記得要停掉這個 VPNService,不然這個 Service 會一直在背景開啟,並且一直轉導用戶的網路流量(就變成真正的 VPN服務,或是感覺就像是監控間諜軟體 XD)

@Override
protected void onStop() {
        super.onStop();
        if(mService != null)
            mService.stopThisService();
        unbindService(connection);
}

參考資料

  1. VPNService 原理及使用介紹:https://blog.csdn.net/roland_sun/article/details/46337171
  2. Sample project : https://github.com/msayan/star-dns-changer
  3. Public DNS 服務:https://www.lifewire.com/free-and-public-dns-servers-2626062