ファイアウォールを超えてデバイスに接続! AWS IoTセキュアトンネリングを試してみた

AWS IoTのIoTセキュアトンネリングを試してみました。ファイアウォールでアクセスできないデバイスに接続できるサービスです。
2020.01.18

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

まいど、大阪の市田です。
昨年のre:Invent直前に、IoT関連サービスで大量アップデートがありましたが、今回はその内の「リモートデバイスへのセキュアトンネリング」を試してみたいと思います。

リモートデバイスへのセキュアトンネリングとは?

工場などにあるセンサーデバイスにプロキシを利用してセキュアにリモートから接続できるサービスです。
センサーデバイスのトラブルシュートをリモートから行うことができるので「担当者を現地に派遣する」といった運用を減らすことができるのではないでしょうか。

接続時の全体イメージは下記のスライド(28スライド目)が分かりやすいと思います。

前提と準備

今回は動作検証のため、EC2インスタンス(Amazon Linux2)をセンサーデバイスに見立てて確認してみます。
セキュアトンネリングを利用する際、接続元と接続先の各マシン/サーバに対して、ローカルプロキシを構築する必要があるので、下記を参考に環境を構築し「localproxy」をインストールします。

Amazon Linux2へのlocalproxyの導入方法は別のエントリにて紹介することにして、今回は「セキュアトンネリング」にフォーカスしたいと思います。

検証する構成としては下記のような形になります。

07-kousei1

また、今回EC2のSecurityGroupには、Outboundについてはデフォルトのままで制限はかけていません。
しかし、接続イメージのスライドにあるとおり、WebSocketでセキュアトンネリングサービスに接続する必要があるので、実際に利用する際は、デバイスが置かれる構内のファイアウォールでインターネット向けの443ポートのトラフィックを許可しておく必要があります。

IoTエージェントの準備

localproxyのインストールが完了したら、作業ディレクトリのbinディレクトリにlocalproxyというバイナリが配置されます。このバイナリを実行することで「localproxy」が起動します。

そのため、直接この「localproxy」を実行してもいいのですが、実際の利用シーンを想定して「特定Topicでメッセージを受け取ればlocalproxyを起動する」ようにしたいと思います。
そのためのプログラムを「IoTエージェント」と呼び、公式ドキュメントではJavaのサンプルコードが掲載されています。

今回は新たにPython用のSDKと「subprocessモジュール」を使って「IoTエージェント」を作成しました。 これは「AWS IoT Device SDK for Python」のサンプルを改修したものになります。主な変更点は20〜27行目でメッセージの受信時にローカルプロキシを起動させる処理を追加しています。それに合わせて本件には邪魔な処理を削除したりしています。

これを「iotAgent.py」という名前で適当なディレクトリに保存します。今回は「basicPubSub」ディレクトリ以下に保存しました。全体的なファイル構成は後で記載しています。

from AWSIoTPythonSDK.MQTTLib import AWSIoTMQTTClient
import logging
import time
import argparse
import json
import subprocess # Added

AllowedActions = ['both', 'publish', 'subscribe']

# Custom MQTT message callback
def customCallback(client, userdata, message):
    print("Received a new message: ")
    print(message.payload)
    print("from topic: ")
    print(message.topic)
    print("MESSAGE: ")
    print(message)
    print("--------------\n\n")

    json_message = json.loads(message.payload.decode('utf-8'))
    if message.topic == "$aws/things/TunnelTestDevice/tunneling/notify":
        subprocess.run([
            "/home/ec2-user/aws-iot-securetunneling-localproxy/build/bin/localproxy",
            "-t", json_message['clientAccessToken'],
            "-r", "ap-northeast-1",
            "-d", "localhost:22"
        ])

# Read in command-line parameters
parser = argparse.ArgumentParser()
parser.add_argument("-e", "--endpoint", action="store", required=True, dest="host", help="Your AWS IoT custom endpoint")
parser.add_argument("-r", "--rootCA", action="store", required=True, dest="rootCAPath", help="Root CA file path")
parser.add_argument("-c", "--cert", action="store", dest="certificatePath", help="Certificate file path")
parser.add_argument("-k", "--key", action="store", dest="privateKeyPath", help="Private key file path")
parser.add_argument("-p", "--port", action="store", dest="port", type=int, help="Port number override")
parser.add_argument("-w", "--websocket", action="store_true", dest="useWebsocket", default=False,
                    help="Use MQTT over WebSocket")
parser.add_argument("-id", "--clientId", action="store", dest="clientId", default="basicPubSub",
                    help="Targeted client id")
parser.add_argument("-t", "--topic", action="store", dest="topic", default="sdk/test/Python", help="Targeted topic")
parser.add_argument("-m", "--mode", action="store", dest="mode", default="both",
                    help="Operation modes: %s"%str(AllowedActions))
parser.add_argument("-M", "--message", action="store", dest="message", default="Hello World!",
                    help="Message to publish")

args = parser.parse_args()
host = args.host
rootCAPath = args.rootCAPath
certificatePath = args.certificatePath
privateKeyPath = args.privateKeyPath
port = args.port
useWebsocket = args.useWebsocket
clientId = args.clientId
#topic = args.topic
topic = "$aws/things/TunnelTestDevice/tunneling/notify"

if args.mode not in AllowedActions:
    parser.error("Unknown --mode option %s. Must be one of %s" % (args.mode, str(AllowedActions)))
    exit(2)

if args.useWebsocket and args.certificatePath and args.privateKeyPath:
    parser.error("X.509 cert authentication and WebSocket are mutual exclusive. Please pick one.")
    exit(2)

if not args.useWebsocket and (not args.certificatePath or not args.privateKeyPath):
    parser.error("Missing credentials for authentication.")
    exit(2)

# Port defaults
if args.useWebsocket and not args.port:  # When no port override for WebSocket, default to 443
    port = 443
if not args.useWebsocket and not args.port:  # When no port override for non-WebSocket, default to 8883
    port = 8883

# Configure logging
logger = logging.getLogger("AWSIoTPythonSDK.core")
logger.setLevel(logging.DEBUG)
streamHandler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
streamHandler.setFormatter(formatter)
logger.addHandler(streamHandler)

# Init AWSIoTMQTTClient
myAWSIoTMQTTClient = None
if useWebsocket:
    myAWSIoTMQTTClient = AWSIoTMQTTClient(clientId, useWebsocket=True)
    myAWSIoTMQTTClient.configureEndpoint(host, port)
    myAWSIoTMQTTClient.configureCredentials(rootCAPath)
else:
    myAWSIoTMQTTClient = AWSIoTMQTTClient(clientId)
    myAWSIoTMQTTClient.configureEndpoint(host, port)
    myAWSIoTMQTTClient.configureCredentials(rootCAPath, privateKeyPath, certificatePath)

# AWSIoTMQTTClient connection configuration
myAWSIoTMQTTClient.configureAutoReconnectBackoffTime(1, 32, 20)
myAWSIoTMQTTClient.configureOfflinePublishQueueing(-1)  # Infinite offline Publish queueing
myAWSIoTMQTTClient.configureDrainingFrequency(2)  # Draining: 2 Hz
myAWSIoTMQTTClient.configureConnectDisconnectTimeout(10)  # 10 sec
myAWSIoTMQTTClient.configureMQTTOperationTimeout(5)  # 5 sec

# Connect and subscribe to AWS IoT
myAWSIoTMQTTClient.connect()
if args.mode == 'both' or args.mode == 'subscribe':
    myAWSIoTMQTTClient.subscribe(topic, 1, customCallback)
time.sleep(2)

loopCount = 0
while True:
    loopCount += 1
    time.sleep(5)

このスクリプトを疑似デバイス用のEC2に配置します。

モノの登録

次にAWS IoTレジストリにモノ(thing)を登録します。モノの名前は「TunnelTestDevice」にしました。

03-iot-registory

登録時にダウンロードしたデバイス証明書など必要なファイルを「疑似デバイスEC2」に保存しておきます。
「IoTエージェント」はSDKサンプルをベースにしているため下記フォルダ構成で証明書を参照します。そのため下記のように「cert」ディレクトリ以下にまとめて配置しておきます。

├── basicPubSub
│   └── iotAgent.py
├── cert
│   ├── xxxxxxxxxx-certificate.pem.crt
│   ├── xxxxxxxxxx-private.pem.key
│   ├── xxxxxxxxxx-public.pem.key
│   └── rootCA.pem

トンネルを開く

準備ができたら、まずはセキュアトンネリングを開きます。開くときは「OpenTunnel」というAPIを利用します。注意点としてトンネルごとに6ドルのAWS利用費が発生するので、むやみにこのAPIを実行しないように注意しましょう。(記事の最後にもう少し詳しく補足しています)

今回はMacからAWS CLIを使います。「疑似デバイスEC2」にSSH接続する想定なので、下記コマンドを実行します。

aws iotsecuretunneling open-tunnel \
--destination-config thingName=TunnelTestDevice,services=ssh

--destination-configオプションは、thingName=string,services=string,stringという形で指定します。今回IoTレジストリに登録したデバイス名は「TunnelTestDevice」です。またSSH接続する想定なので上記の通り指定しています。

実行すると下記のように、いくつかの情報が返ってきます。この中に認証に必要なアクセストークンも含まれているので、ひかえておきましょう。

{
    "tunnelId": "xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "tunnelArn": "arn:aws:iot:ap-northeast-1:xxxxxxxxxxxx:tunnel/xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "sourceAccessToken": "SourceAcccessTokenが入ります",
    "destinationAccessToken": "DestinationAccessTokenが入ります"
}

デバイス側ローカルプロキシの起動

次にデバイス側(疑似デバイスEC2)のローカルプロキシを起動してみます。今回は先に書いた「IoTエージェント」経由で実施するので、IoTエージェントを起動させておきます。
各種証明書はIoTレジストリにデバイスを登録した際にダウンロードしたものを指定しましょう。

python IotAgent.py \
--endpoint xxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com \
--rootCA ../cert/rootCA.pem  \
--cert ../cert/xxxxxxxxxx-certificate.pem.crt  \
--key ../cert/xxxxxxxxxx-private.pem.key

このスクリプトでは、サブスクライブしているトピックをハードコードしていますが、そのトピックに先程取得したアクセストークンをパブリッシュして、ローカルプロキシを起動させます。

尚、トピックは「$aws/things/TunnelTestDevice/tunneling/notify」という予約済みトピックになります。形式は「$aws/things/<thing-name>/tunnels/notify」です。

それでは下記の内容でメッセージをパブリッシュしてみましょう。
接続したいデバイスにメッセージを投げるので、利用するのは2種類あるトークンのうちの「destinationAccessToken」になります。

{
  "clientAccessToken": "destinationAccessTokenの中身"
}

今回は、コンソール上からパブリッシュしました。

05-token-publish

下記のようなログが出ていればOKです。これでデバイス側のローカルプロキシが起動しました。セキュアトンネリングサービスにWebSocketで接続していることが分かりますね。

[2020-01-08T08:30:36.887547]{2932}[info]    Successfully established websocket connection with proxy server: wss://data.tunneling.iot.ap-northeast-1.amazonaws.com:443

接続元ローカルプロキシの起動

次は接続元でローカルプロキシを起動させます。MacやWindowsなど普段業務を行っているPC上で起動することになると思います。今回は動作確認だけしたかったので、「疑似端末用のPC」として、こちらもEC2で代用しました。
(「疑似デバイスEC2」のAMIを取得して複製しました)

この「疑似端末EC2」にSSHでログインして、ローカルプロキシを手動で起動します。
接続元インスタンスで起動するので-sオプションを指定します。また、ローカルの10022ポートで起動するように指定しています。

./localproxy -r ap-northeast-1 -s 10022 -t <sourceAccessTokenの中身>

トンネリング接続の確認

これで準備ができましたので接続の確認をしてみます。
試しに「デバイス用EC2」のSecurityGroupには、「接続元EC2」からのSSHは許可しないようにしておきます。

下記の通り「疑似デバイスEC2」には、作業用の拠点IPからの接続だけを許可しています。
SecurityGroupで許可しているIP以外の「疑似端末EC2」からSSHできることを確認します。

06-securitygroup

「疑似端末EC2」上で「疑似デバイスEC2」にSSHしてみます。-pオプションでローカルプロキシのポートを指定します。

ssh -i 疑似デバイスEC2の秘密鍵 -p 10022 ec2-user@localhost

下記の通り、「疑似端末EC2(IP:10.0.0.21)」から「疑似デバイスEC2(IP:10.60.4.63)」へSSH接続できました!
確かにファイアウォール(SecurityGroup)を超えてデバイス(EC2)に接続することができました。

02-ssh-tunnel-3

ということで、セキュアトンネリングを使えばSecurityGroupの設定に関係なく、SSH接続できることが確認できました。

08-kousei2

トンネルを閉じる

必要な作業が終わればトンネルを閉じます。指定した時間の経過後に閉じることもできますし、明示的に閉じることも可能です。
時間の指定は、トンネルを開く時に指定可能でデフォルトは12時間です。

明示的に閉じるときは、CloseTunnelというAPIを使います。マネジメントコンソールから閉じることも可能です。

トンネルを閉じるときはトンネルIDで閉じたいトンネルを指定します。トンネルを開いた時に出力されていたIDになります。

aws iotsecuretunneling close-tunnel \
--tunnel-id xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

トンネルIDは、ListTunnelsで確認可能な他、コンソール上でも確認可能です。

04-tunnel-id

セキュアトンネリングの利用料金

IoTセキュアトンネリングのAWS料金は、新しいトンネルを開くたびに「$6」が発生します。
同じトンネルに対して、トンネルを閉じなければ追加費用なしに何度でも切断と接続が可能です。

新規に開くたびに「$6」発生するので、何度もトンネルの開閉を繰り返すと、そこそこの金額が発生するので注意しましょう。

AWS IoT Device Management Pricing - Amazon Web Services

最後に

ローカルプロキシが動く環境構築に手間がかかりますが、ローカルデバイスに対してファイアウォールを気にせずアクセスできるのは、トラブルシューティングや運用面で役に立つことがあるかなと感じました。

ただし、ローカルプロキシを動かそうと思うと、それなりのマシンリソースが要求されたので結構リッチな環境が必要だと感じました。(t3.microではメモリ不足となりインストール作業が途中で失敗しました)

まとめると下記のような注意点があるので、これを踏まえて利用可否を検討いただければと思います。(細かい点を挙げれば他にもありますが)

  • セキュアトンネリングの利用料金
  • ローカルプロキシがインストール可能なデバイススペックが必要
  • 443ポートのアウトバウンドのトラフィックを構内のファイアウォールで許可する必要あり

余談

今回は、とりあえず動かして試してみることが目的でしたが気になる点もありました。そちらは改めて確認していきたいと思います。

EC2へのローカルプロキシのインストールは下記の記事にてご紹介していますので、こちらもあわせてご覧いただけますと幸いです。

AWS IoT セキュアトンネリングの「ローカルプロキシ」をAmazon Linux2にインストールする

以上です。