Amazon Web Services ブログ

セキュアトンネリングを利用して、ウェブブラウザからIoTデバイスに接続する方法

この記事は How to remote access devices from a web browser using secure tunneling の日本語訳です。

IoT デバイスへのアクセスを保護し、セキュリティを確保するためには、ファイアウォールを使用するのが一般的な方法です。しかし、すべての受信トラフィックをブロックするファイアウォールの背後にあるリモート環境に配置されたデバイスにアクセスし、管理することは困難です。デバイスのトラブルシューティングを行うには、現地に技術者を派遣してデバイスに接続する必要があります。これでは、デバイス管理の複雑さとコストが増大します。

セキュアトンネリングは、 AWS IoT Device Management の機能で AWS IoT が管理する安全な接続を介して、お客様がリモートデバイスにアクセスする仕組みを提供します。 Secure Tunneling は、既存のインバウンドファイアウォールルールの更新を必要としないため、リモート環境のファイアウォールルールが提供するセキュリティレベルを同じに保つことができます。

この記事では、セキュアトンネリングを使用して Web アプリケーションからリモートデバイスへの Secure Shell(SSH)セッションを開始する方法を学べます。この接続を利用して、設定、トラブルシューティング、および他の運用タスクを実施することができます。

この実装のサンプルソースコードは、GitHub からダウンロードすることができます。

ソリューションの概要

セキュアトンネリングを使用してリモートデバイスにアクセスするための Web ベースのローカルプロキシを構築する手順を説明します。

ローカルプロキシは、送信元と送信先のデバイス上で動作するソフトウェア・プロキシです。ローカルプロキシは、セキュアトンネリングサービスとデバイスアプリケーションの間で セキュアな WebSocket  接続を介して、データストリームを中継します。

ローカルプロキシは source (送信元)または destination (送信先)モードのどちらでも動作します。送信元は通常、送信先デバイスとのセッションを開始するために使用するラップトップまたはデスクトップコンピュータになります。送信先デバイスは、アクセスしたいリモートデバイスです。

プロセスの概要については、次の図を参照してください。

トンネルを作成すると、1組のトークン(送信元と送信先用)が作成されます。送信元と送信先のデバイスは、これらのトークンを使用して、セキュアトンネリングサービスに接続します。

ローカルプロキシは、使用するモードに応じて source または destination のトークンを使用して、トンネリングサービスとの安全な WebSocket 接続を確立します。トークンは awsiot-tunnel-token という名前の Cookie 、または access-token という HTTP リクエストヘッダによってリクエストに指定されます。

ウェブブラウザ内の WebSocket の実装は、カスタムヘッダをサポートしていません。そのため、セキュアトンネリング プロトコルガイドの説明に従って、awsiot-tunnel-token Cookie を設定する必要があります。

セキュリティ上の理由から、ウェブサイトは自身のドメイン、または所属する上位の DNS ドメインに対してのみCookieを設定することができます。例えば、ウェブアプリケーションのドメイン名が  mylocalproxy.com である場合 data.tunneling.iot.{aws-region}.amazonaws.com という名前のセキュアトンネリングエンドポイントに対して Cookie を設定することはできません。

Amazon API Gateway と AWS Lambda proxy integration を使用して .amazonaws.com ドメインの Cookie を設定することが出来ます。

Cookie は親ドメインと data.tunneling.iot.{aws-region}.amazonaws.com を含むすべてのサブドメイン間で共有されます。

ウェブブラウザは us-east-1.amazonaws.com ドメインがパブリックサフィックスリストにあるため  Cookie を送信しない場合があります。このリストは、ブラウザが Cookie の範囲を制限するために使用されます。us-east-1 リージョンに対する手動での回避策は、ウェブブラウザのコンソールで  Cookie を設定することです。

ソリューションアーキテクチャ

次の図は、セキュアトンネリングを使用して Web アプリケーションから SSH セッションを開始する際の主な手順の概要を示しています。

  1. awsiot-tunnel-tokenという名前の Cookie に、送信元トークンの値を設定します。
  2. Web アプリケーションとトンネリングサービスとの間に安全な WebSocket 接続を開きます。
  3. Protocol Buffers ライブラリを使用してデータを転送します。

このブログでは、トンネルの開き方から始めて、これらの3つのステップを詳しく説明します。トンネルを開いたら、まずローカルマシンから安全な WebSocket 接続を開く方法を説明し HTTP ヘッダーで送信元のアクセストークンを設定します。

次に Protocol Buffers ライブラリを使用して、送信元と送信先の間でデータを転送する方法について説明します。

最後に .amazonaws.com ドメインの Cookie を設定し、ウェブアプリケーションがこの Cookie を介して安全な WebSocket  接続を開くことができるようにする解決策を説明します。

前提条件

この記事は、以下を完了していることを前提としています。

チュートリアル

ステップ1:セキュアトンネリングに接続する

最初のステップは、トンネルを開き、リモートデバイスへの SSH セッションを開始しますで説明されているように、送信元と送信先のアクセストークンをダウンロードすることです。

  • a) ローカルマシンにフォルダを作成します。このフォルダに移動し connect.js という名前のファイルを作成します。
  • b) 新しく作成したconnect.jsファイルに、以下のNode.jsスクリプトをコピーします。 token の値を、ダウンロードした送信元のアクセストークンに置き換えます。aws_region の値を、トンネルが開かれているAWSリージョンに置き換えます。送信元のアクセストークンは、ローカルマシンとトンネリングサービス間の WebSocket 接続を開くために使用されます。
// connect.js
const WebSocket = require('ws')
const token = 'REPLACE WITH THE SOURCE TOKEN'
const aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'
const mode = 'source'

let url = `wss://data.tunneling.iot.${aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=${mode}` 

const connection = new WebSocket(url, `aws.iot.securetunneling-2.0`, {
    headers: { 
            'access-token': token
    }
})

connection.onopen = async () => {
    console.log('Source is connected to the tunneling service')
}
  • c) Node.js のライブラリ ws を、以下のコマンドでインストールします。
npm i —save ws
  • d) スクリプトを実行します。
node connect.js

ターミナルに Source is connected to the tunneling service が表示されるのが確認できます。

  • e) AWS IoT コンソールで、トンネルを選択し、送信元が接続されていることを確認します。

  • f) 送信先をトンネリングサービスに接続するには、このステップを繰り返します。変数 mode の値を destination に置き換えます。token の値を送信先のアクセストークンに置き換えます。

ステップ2:トンネルを介したデータ送信

送信元と送信先をトンネリングサービスに接続する方法がわかったので、データを送信してみましょう。セキュアトンネリングは、送信元と送信先の間のデータ転送に Protocol Buffers を使用します。

Protocol Buffers は、構造化されたデータをシリアライズするための仕組みです。 Protocol Buffers を使用すると .proto ファイルでデータのスキーマを指定することができます。

  • a) 手順1で作成したフォルダ内に schema.proto という名前のファイルを作成します。 このファイルに以下の内容をコピーします。
// schema.proto 

syntax = "proto3";

package com.amazonaws.iot.securedtunneling;

option java_outer_classname = "Protobuf";
option optimize_for = LITE_RUNTIME;

message Message {
    Type    type         = 1;
    int32   streamId     = 2;
    bool    ignorable    = 3;
    bytes   payload      = 4;
    string  serviceId    = 5;
    repeated string availableServiceIds = 6;
    
    enum Type {
        UNKNOWN = 0;
        DATA = 1;
        STREAM_START = 2;
        STREAM_RESET = 3;
        SESSION_RESET = 4;
        SERVICE_IDS = 5;
    }
}

このスキーマでは typestreamId , ignorable , payload , serviceId , availableServiceIds の6つのフィールドを持つデータのメッセージフォーマットを定義しています。

payloadのフィールドには、転送するデータの binary blob が格納されます。詳細については、リファレンス実装ガイド V2WebSocketProtocolGuide を参照してください。

  • b) 同じフォルダに、スキーマの読み込みとメッセージのエンコード/デコードに使用するライブラリ protobufjs をインストールします。
npm i —save protobufjs
  • c) 2つのファイルを作成します。1つのファイルを source.js と名付けます。もう一つのファイルは destination.js と名付けます。送信先をトンネリングサービスに接続し destination.js で受信メッセージをデコードします。送信元をトンネリングサービスに接続し source.js で送信先にメッセージを送信します。
  • d) destination.js ファイル内の以下の内容をコピーし token および aws_region の値を置き換えます。
// destination.js 

const WebSocket = require('ws')
const {load} = require('protobufjs')

const token = 'REPLACE WITH THE DESTINATION TOKEN'
const aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'

const mode = 'destination'
const protopath = './schema.proto'

let url = `wss://data.tunneling.iot.${aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=${mode}`
let Message

const connection = new WebSocket(url, `aws.iot.securetunneling-2.0`, {
    headers: { 
            'access-token': token
    }
})

connection.onopen = async () => {
    console.log('Destination is connected to the tunneling service')
    Message = await load(protopath)
    Message = Message.root.lookupType('Message')
}

connection.onmessage = async ({data}) => {
    try {
        let decoded_message = Message?.decode(data)
        if(decoded_message?.payload){
            console.log(decoded_message.payload.toString('utf-8'))
        }
    } catch (e) {
        console.log(e)
    }
} 
  • e) source.js ファイルを開き、以下のコードをコピーします。tokenaws_region の値を置き換えてください。
const WebSocket = require('ws')
const {load} = require('protobufjs')

const token = 'REPLACE WITH THE SOURCE TOKEN'
const aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'

const mode = 'source'
const protopath = './schema.proto'

let url = `wss://data.tunneling.iot.${aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=${mode}`
let Message

const hello = 'Hello from the source'

const connection = new WebSocket(url, `aws.iot.securetunneling-2.0`, {
    headers: { 
            'access-token': token
    }
})

connection.onopen = async () => {
    console.log('Source is connected to the tunneling service')
    Message = await load(protopath)
    Message = Message.root.lookupType('Message')

    // start the stream 
    let tunnel_message = {
        type: 2, // Stream Start
        streamId: Math.floor(Math.random() * 1000), 
        ignorable: false,
        payload: null // We don't send data yet as we only start the stream
    }
    sendData(tunnel_message)

    // send the data 
    tunnel_message.type = 1 // DATA
    tunnel_message.payload = Buffer.from(hello, 'utf-8')
    sendData(tunnel_message)
}

connection.onmessage = async ({data}) => {
    try {
        let decoded_message = Message?.decode(data)
        if(decoded_message?.payload){
            console.log(decoded_message.payload.toString('utf-8'))
        }
    } catch (e) {
        console.log(e)
    }
}

const sendData = (data) => {
    try {
            let protoMessage = Message.verify(data)
            let encodedMessage = Message.encode(data).finish()
            let arrayWrapper  = new Uint8Array( 2 + encodedMessage.byteLength );
            arrayWrapper.set( new Uint8Array( [ Math.floor(encodedMessage.byteLength / 256), encodedMessage.byteLength % 256 ] ))
            arrayWrapper.set(encodedMessage, 2);
            connection.send(arrayWrapper)
        
    } catch (e) {
        console.log(e)
    }
}
  • f) 送信先用のターミナルを開きます。送信先のターミナルで destination.js スクリプトを実行します。
node destination.js
  • g) 送信元用のターミナルをもうひとつ開きます。送信元用のターミナルで source.js スクリプトを実行します。
node source.js

送信元から送信されたメッセージ Hello from the source が、送信先で受信されるのがわかります。

このステップでは、送信元と送信先の間で簡単なテキストを転送しました。SSH セッションがあった場合 protobuf メッセージのペイロードは SSH ストリームを含むことになります。

ステップ 3: Cookie を設定する REST API を作成する

接続とデータ転送の方法がわかったところで、最後に Web ブラウザからトンネリングサービスに接続する方法を説明します。Web ブラウザ内の WebSocket の実装は、カスタムヘッダをサポートしていないので、セキュアトンネリングプロトコルガイド で説明されているように Cookie を設定する必要があります。

新しい WebSocket 接続を作成する際に、認証用の送信元トークンを渡すために Cookie を設定するには Amazon API Gateway と AWS Lambda proxy integration を使用して REST API を作成します。

Web アプリケーションは API Gateway のエンドポイントにトークンを提供する HTTP POST リクエストを送信します。Lambda 関数は、提供されたトークンを使用して Cookie を作成します。 POST API リクエストに Set-Cookie HTTP レスポンスヘッダで応答し Cookie を Web アプリケーションに送信します。

作成する API のエンドポイント、トンネリングサービスに接続するエンドポイントは、いずれも .amazonaws.com のサブドメインとなります。

ステップ3.1: Cookie を設定する Lambda 関数を作成する

Lambda コンソールを使用して Node.js の Lambda 関数を作成します。

  • a) Lambda コンソールでFunctionsのページを開きます。
  • b) Create function を選択します。
  • c) Basic information で、以下を実行します。
    • Function name set_cookie_lambda と入力します。
    • Runtime Node.js 14.x が選択されていることを確認します。
  • d) Create function を選択します。
  • e) Function code の下にあるインラインコードエディターで、以下のコードを貼り付けます。
// set_cookie_lambda Lambda function

exports.handler = async (event) => {

    const body = JSON.parse(event.body)
    const token = body.token
    const origin = event.headers['origin']

    let d = new Date()
    d.setTime(d.getTime() + (2*60*60*1000))

    let cookie = `awsiot-tunnel-token=${token}; path=/tunnel; expires=${d}; domain=.amazonaws.com; SameSite=None; Secure; HttpOnly`

    const response = {
        headers: {
            'Set-Cookie': cookie,
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Credentials': true
        },
        statusCode: 200,
        body: JSON.stringify({message: 'Success'})
    };
    return response
}
  • f) Deploy を選択します。

ステップ3.2:CORS を有効にするための Lambda 関数を作成する

API が Cookie を設定できるようにするためには、CORS(cross-origin resource sharing) を有効にする必要があります。 CORS はブラウザのセキュリティ機能で、ブラウザで実行されているスクリプトから開始されるクロスオリジンの HTTP リクエストを制限するものです。

認証情報を含む CORS リクエストでは Access-Control-Allow-Origin ヘッダーの値でワイルドカード“*”を使用することができません。代わりに、オリジンを指定する必要があります。

したがって CORS をサポートするには REST API リソースに OPTIONS メソッドを実装し、少なくとも次の応答ヘッダーで OPTIONS プリフライトリクエストに応答できるようにする必要があります。具体的には、Access-Control-Request-MethodAccess-Control-Request-Headers、および Origin ヘッダーです。

そのためには API の OPTIONS メソッドから Web アプリケーションのオリジンを取得し、この特定のオリジンに対して CORS を有効にする別の Lambda 関数を作成することになります。

ステップ 3.1 で説明した手順を繰り返し enable_cors_lambda という名前の Node.js Lambda 関数を作成します。

Lambda コンソールを使用して Node.js の Lambda 関数を作成します。

  • a) Lambda コンソールで Functions ページを開きます。
  • b) Create function を選択します。
  • c) Basic information で、以下を実行します。
    • Function name set_cookie_lambda と入力します。
    • Runtime Node.js 14.x が選択されていることを確認します。
  • d) Create function を選択します。
  • e) Function code の下にあるインラインコードエディターで、以下のコードを貼り付けます。
// enable_cors_lambda Lambda function
exports.handler = async (event) = {

    const origin = event.headers['origin']
    const response = {
        headers: {
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Credentials': true,
            'Access-Control-Allow-Methods': 'OPTIONS,GET, POST',
            'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
            
        },
        statusCode: 200,
        body: JSON.stringify({message: 'Success'})
    };
    return response;
}
  • f) Deploy を選択します。

ステップ3.3:  Cookie を設定するための REST API を作成する

ここで、Lambda 関数 set_cookie_lambdaenable_cors_lambda を呼び出す POSTOPTIONS メソッドを持つ REST API を作成します。

  • a) API Gateway コンソールで、SetCookieApiという名前の REST API を作成します。
  • b) POST メソッドを作成します。
    • Integration type Lambda Function に設定したままにしておきます。
    • Use Lambda Proxy integration を選択します。
    • Lambda Region ドロップダウン・メニューから set_cookie_lambda  Lambda 関数を作成した region を選択します。
    • Lambda Function フィールドに任意の文字を入力し、ドロップダウンメニューから set_cookie_lambda を選択します。
    • Save を選択します。
    • Add Permission to Lambda Function と表示されたら OK を選択します。

  • c) OPTIONS メソッドを作成します。
    • Integration type Lambda Function にしたままにしておきます。
    • Use Lambda Proxy integration を選択します。
    • Lambda Region ドロップダウンメニューから enable_cors_lambda Lambda 関数を作成したリージョンを選択します。
    • Lambda Function フィールドに任意の文字を入力し、ドロップダウンメニューから enable_cors_lambda を選択します。
    • Save を選択します。
    • Add Permission to Lambda Function と表示されたら OK を選択します。

ステップ3.4: API をデプロイする

  • Actions ドロップダウンメニューから Deploy API を選択します。
  • デプロイメントステージは、新規ステージを選択します。
  • Stage name には api  と入力します。
  • Deploy を選択します。
  • API の Invoke URL をメモします。

Invoke URL に対して、本文にトークンを指定した POST リクエストを送信することができます。

API はレスポンスで Cookie を送信します。トンネリングサービスとの WebSocket 接続を開くと、その Cookie はトンネリングサービスとの認証に使用されます。

ステップ4:Web アプリケーションからトンネリングサービスに接続する

これで、Web アプリケーションで SetCookieApi API を使用して、トンネリングサービスに接続できるようになりました。

次の Angular  ウェブアプリケーションのコードスニペットは REST API を使用して Cookie を設定する方法を示しています。

  • HTTP POST リクエストを SetCookieApi API に送信し、本文にトークンを入力します。
  • API はレスポンスで Cookie を設定します。
  • 最後に、トンネリングサービスを使って安全な WebSocket 接続を開きます。
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http'

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit{
  
  token = 'REPLACE WITH THE SOURCE TOKEN'
  aws_region = 'REPLACE WITH THE AWS REGION IN WHICH THE TUNNEL IS OPEN'
  url_api_set_cookie = 'REPLACE WITH THE SetCookieApi URL'
  tunneling_url = `wss://data.tunneling.iot.${this.aws_region}.amazonaws.com:443/tunnel?local-proxy-mode=source`
  constructor(private http: HttpClient){}

  async ngOnInit() {
    // SET THE COOKIE 
    await this.http.post(this.url_api_set_cookie, {token: this.token}, {withCredentials: true, }).toPromise()
    
    // Connect to the tunneling service
    let socket = new WebSocket(this.tunneling_url, 'aws.iot.securetunneling-2.0')

  }

}

WebSocket 接続が確立されると Web アプリケーションから直接 SSH ストリームのようにデータを転送することができます。

aws-iot-securetunneling-web-ssh GitHub リポジトリで Web ベースのローカルプロキシの実装を見ることができます。

また、オンラインデモを使用してテストすることができます。デモのユーザー名とパスワードはどちらも iotcore です。

クリーンアップ

今後の課金が発生しないように、このチュートリアルで作成したリソースを削除してください。

まとめ

セキュアトンネリングは、AWS IoT と直接統合して、どこからでも IoT デバイスにリモートで安全にアクセスできるようにするソリューションを提供します。

このブログでは、このAWS IoT Device Management の機能を使用して Web アプリケーションからリモートデバイスにアクセスする方法について学びました。これにより、設定を簡素化し、ファイアウォールの内側にあるデバイスのトラブルシューティングにかかる時間を短縮することができます。

この実装を使用して、デバイスのフリートを表示、対話、および接続するためのデバイス管理 Web アプリケーションを構築または拡張できます。aws-iot-securetunneling-web-ssh GitHub リポジトリで提供される実装をカスタマイズして、ニーズに合ったソリューションを構築することができます。

また、オンラインデモを使用してテストすることができます。デモのユーザー名とパスワードは、いずれもiotcoreです。

この記事はソリューションアーキテクトの市川が翻訳しました。