[iOS] CocoaAsyncSocketでTCPトンネルを作成してみた

2016.08.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

1 はじめに

CocoaAsyncSocketは、MacとiOSで動作する強力な非同期ソケットライブラリです。

Cocoapodで導入が可能であり、次のヘッダを追加することで利用できます。

pod 'CocoaAsyncSocket'

Using Objective-C:

// When using iOS 8+ frameworks
@import CocoaAsyncSocket; 

// OR when not using frameworks, targeting iOS 7 or below
#import "GCDAsyncSocket.h" // for TCP
#import "GCDAsyncUdpSocket.h" // for UDP

Using Swift:

import CocoaAsyncSocket

今回は、このCocoaAsyncSocketを利用させて頂いて、TCPパケットをトンネルするアプリを作成してみました。

2 トンネル

TCPトンネルでは、到着したパケットを反対側に投げることで、TCPパケットの中継を行います。

下図を例に取れば、10.0.0.1のサーバでポート80で公開されているサービスに、TCPトンネルが動作している、192.168.0.1のポート8080にアクセスすることで透過的に利用できるようにしています。

クライアントが、サーバに直接接続できる場合は、何の意味もありませんが、特別な状況でネットワークが分断されている場合に、必要となります。

003

下は、今回のサンプルをシュミレーターで起動して、ブラウザから127.0.0.1の8080番にアクセスしていますが、トンネルアプリが動作している時だけアクセスできている様子が確認できます。

001 002

3 実装

実装しなければならないロジックは、次の5つです。

(1) Listen

パケットの待ち受け状態(Listen)にするには、待ち受けポート番号を指定して、acceptOnPort:メソッドを使用します。

また、停止は、デリゲートの削除とdisconnectで行います。

- (void) start {
    NSError *error = nil;
    self.listenSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
    [self.listenSocket acceptOnPort:self.listenPort error:&error];
}

- (void) stop {
    [self.listenSocket setDelegate:nil delegateQueue:NULL];
    [self.listenSocket disconnect];
    self.listenSocket = nil;
}

(2) Accept

Listen状態のポートのアクセスがあり、コネクションが成立すると、didAcceptNewSocket:メソッドがコールされます。

ここでは、最終的な接続先であるサーバに対して新しいコネクションを張り、クライアント側とサーバ側の2つのソケットを、このコネクションの情報として記録しておきます。

この記録を元に、今後パケットの到着があった際に、反対側にデータを流していくわけです。

// Accept
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket
{
    NSLog(@"Accept:%p",sock);

    // 新しいソケットの作成
    GCDAsyncSocket *toSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
    NSError *error = nil;
    // サーバへの接続
    if (![toSocket connectToHost:self.host onPort:self.port error:&error]) {
        NSLog(@"Error: %@", error);
    }
    else {
        // コネクション情報の記録
        Connection *connection = [Connection new]; 
        connection.fromSocket = newSocket; // クライアント側のソケット
        connection.toSocket = toSocket; // サーバ側のソケット
        @synchronized(self) {
            [self.connections addObject:connection]; // コネクション情報の追加
        }
    }
}

(3) Connect

サーバへのコネクションが完成すると、didConnectToHost:メソッドがコールされます。

この時点で、クライアント側とサーバ側の両方のソケットが揃ったので、readDataWithTimeoutで、双方に読み込み開始の合図を送ります。

didConnectToHost:でパラメータとして受け取れるソケット情報は、サーバ側のものだけですので、コネクション情報から検索することで、対となるクライアント側のソケットを得ています。

// Connect
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(UInt16)port

{
    NSLog(@"Connect:%p",sock);

    // 接続リストから検索して、読み込みを開始する
    for (Connection *connection in self.connections) {
        if ([connection.toSocket.description isEqualToString:sock.description]) {
            [connection.fromSocket readDataWithTimeout:3 tag:0];
            [connection.toSocket readDataWithTimeout:3 tag:0];
            break;
        }
    }
}

(4) ReadData

データが到着すると、didReadData:メソッドがコールされます。 ここでの処理は、受け取ったデータを、そのまま反対側のソケットに送るだけです。

なお、ここでも、パラメータとして受け取れるソケット情報は、データが来た側のものだけですので、やはりコネクション情報から検索して、反対側のソケットを得ています。

// ReadData
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
    NSLog(@"ReadData:%p %@",sock,[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);

    // 対象のトンネルを検索し、反対側のソケットに、そのまま書き込む
    for (Connection *connection in self.connections) {
        if ([connection.fromSocket.description isEqualToString:sock.description]) {
            [connection.toSocket writeData:data withTimeout:3.0 tag:0];
            break;
        }
        if ([connection.toSocket.description isEqualToString:sock.description]) {
            [connection.fromSocket writeData:data withTimeout:3.0 tag:0];
            break;
        }
    }
    // 受信は継続する
    [sock readDataWithTimeout:3 tag:0];
}

(5) Disconnect

ソケットが切断されると、socketDidDisconnect:メソッドがコールされます。

切断は、サーバ側とクライアント側のどちらから起こるか分かりません。しかし、どちらでも一方が切れると、もうトンネルとしての存在価値は全く無くなるので、直ちに反対側のソケットを切断し、コネクション情報も削除しています。

// Disconnect
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
    NSLog(@"Disconnect:%p",sock);
    // 接続リストから検索して、反対側のソケットを切断し、リストから削除する
    for (Connection *connection in self.connections) {
        if ([connection.fromSocket.description isEqualToString:sock.description]) {
            [connection.toSocket disconnect];
            [self.connections removeObject:connection];
            break;
        }
        if ([connection.toSocket.description isEqualToString:sock.description]) {
            [connection.fromSocket disconnect];
            [self.connections removeObject:connection];
            break;
        }
    }
}

4 ソケットの識別

アプリ内でのソケットの識別は、通常、「送信元アドレス・送信元ポート・宛先アドレス・宛先ポートの4つの諸元のセット」、もしくは、作成のたびにインクリメントされる「ソケット識別子」を使用するのが一般的なのですが、GCDAsyncSocketでは、そのどちらも、このオブジェクトのプロパティから見つけることができませんでした。

しかし、descriptionで表示される、streamConnectionの情報に一位性があるようでしたので、サンプルでは、これを利用しました。

004

接続情報のデータベースの、それぞれのソケットのdescriptionを文字列として比較しています。

for (Connection *connection in self.connections) {
    if ([connection.fromSocket.description isEqualToString:sock.description]) {
        // 当該ソケットに対する処理
        break;
    }
}

5 最後に

今回は、TCPのトンネルを作成してみましたが、GCDAsyncSocketでは、UDPパケットの操作も用意されていますので、UDPトンネルも可能でしょう。ただし、UDPでは、コネクションはありませんので、単純に1パケット単位で反対側に投げるような感じになるのでしょうか。

また、今回のように通信を単純にTCPストリームとして扱った場合、どのようなTCPプロトコルも通ることができますが、逆に、動的にデータストリームを生成する必要があるFTPなどは、通過することが出来ません。 アプリ層に上がって、プロトコル解釈するのであれば、データ受信要求の際にreadDataToData:を使用して、行単位で受信することも可能なので、こちらを利用するのが簡単でしょう。

[socket readDataToData:[AsyncSocket LFData] withTimeout:-1 tag:0];

コードは下記に置きました。きになるところが有りましたら、ぜひ教えてやってください。


github [GitHub] https://github.com/furuya02/TcpTunnelSample

6 参考資料

https://github.com/robbiehanson/CocoaAsyncSocket