AWS IoTのMQTT over WebSocketにHTMLから接続してみた

2016.02.15

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

はじめに

先日のアップデートでAWS IoT Device GatewayがWebSocketに対応しました。
このアップデートにより、JavaScriptで動作するMQTTクライアントを用いてWebページ上からAWS IoT Device GatewayにPub/Subすることが可能になりました。(MQTT over WebSockets)
今回はローカルのHTMLをIoT Device Gatewayと連携させる方法をご紹介します。

使ってみる

Pub/Sub可能なIAMユーザーの作成

ブラウザからMQTT over WebSocketsを使うには、今のところPub/Sub権限を持ったIAMユーザーのクレデンシャル(アクセスキーとシークレットキー)が必要です。
公開したときに悪用されないために、最低限の権限を持ったIAMユーザーを作成しましょう。

スクリーンショット 2016-02-15 17.18.59

今回はweb-chat-iotというユーザーを作成しました。

スクリーンショット 2016-02-15 17.21.50

作成したユーザーにはAWSIoTDataAccessポリシーのみをアタッチします。
今回はテストなので管理ポリシーをアタッチしていますが、もし実際に本番で使うような事がある場合は、インラインポリシーでより限定的なカスタムポリシーを定義してください。

コード(HTML)の前提

MQTTクライアントとしてPaho JavaScript Clientを使います。
今回利用するライブラリは下記のとおりです。

名称 利用用途
Crypto-JS 署名バージョン4の生成で使用
Moment.js 署名バージョン4の生成で使用
Vue.js サンプル画面のレンダリング
Paho JavaScript Client ブラウザ上で動作するMQTTクライアント

上記のうち、Paho JavaScript ClientについてはCDNによる提供がありません。
Paho JavaScript ClientDownload から mqttws31.js をダウンロードしておいてください。

サンプルコード

<html lang="ja">
<body>
  <ul id="chat">
    <li v-for="m in messages">{{ m }}</li>
  </ul>
  <input type="text" name="say" id="say" placeholder="Input a message here...">
  <button id="send">Send</button>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.16/vue.min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.2/moment.min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/components/core-min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/components/hmac-min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/components/sha256-min.js" type="text/javascript"></script>
  <script src="./mqttws31.js" type="text/javascript"></script>
  <script type="text/javascript">
    var data = {
      messages: []
    };

    new Vue({
      el: '#chat',
      data: data
    });

    document.getElementById('send').addEventListener('click', function (e) {
      var say = document.getElementById('say')
      send(say.value);
      say.value = '';
    });

    function SigV4Utils(){}

    SigV4Utils.sign = function(key, msg) {
      var hash = CryptoJS.HmacSHA256(msg, key);
      return hash.toString(CryptoJS.enc.Hex);
    };

    SigV4Utils.sha256 = function(msg) {
      var hash = CryptoJS.SHA256(msg);
      return hash.toString(CryptoJS.enc.Hex);
    };

    SigV4Utils.getSignatureKey = function(key, dateStamp, regionName, serviceName) {
      var kDate = CryptoJS.HmacSHA256(dateStamp, 'AWS4' + key);
      var kRegion = CryptoJS.HmacSHA256(regionName, kDate);
      var kService = CryptoJS.HmacSHA256(serviceName, kRegion);
      var kSigning = CryptoJS.HmacSHA256('aws4_request', kService);
      return kSigning;
    };

    function createEndpoint(regionName, awsIotEndpoint, accessKey, secretKey) {
      var time = moment.utc();
      var dateStamp = time.format('YYYYMMDD');
      var amzdate = dateStamp + 'T' + time.format('HHmmss') + 'Z';
      var service = 'iotdevicegateway';
      var region = regionName;
      var secretKey = secretKey;
      var accessKey = accessKey;
      var algorithm = 'AWS4-HMAC-SHA256';
      var method = 'GET';
      var canonicalUri = '/mqtt';
      var host = awsIotEndpoint;

      var credentialScope = dateStamp + '/' + region + '/' + service + '/' + 'aws4_request';
      var canonicalQuerystring = 'X-Amz-Algorithm=AWS4-HMAC-SHA256';
      canonicalQuerystring += '&X-Amz-Credential=' + encodeURIComponent(accessKey + '/' + credentialScope);
      canonicalQuerystring += '&X-Amz-Date=' + amzdate;
      canonicalQuerystring += '&X-Amz-SignedHeaders=host';

      var canonicalHeaders = 'host:' + host + '\n';
      var payloadHash = SigV4Utils.sha256('');
      var canonicalRequest = method + '\n' + canonicalUri + '\n' + canonicalQuerystring + '\n' + canonicalHeaders + '\nhost\n' + payloadHash;

      var stringToSign = algorithm + '\n' +  amzdate + '\n' +  credentialScope + '\n' +  SigV4Utils.sha256(canonicalRequest);
      var signingKey = SigV4Utils.getSignatureKey(secretKey, dateStamp, region, service);
      var signature = SigV4Utils.sign(signingKey, stringToSign);

      canonicalQuerystring += '&X-Amz-Signature=' + signature;
      return 'wss://' + host + canonicalUri + '?' + canonicalQuerystring;
    }

    var endpoint = createEndpoint(
        'ap-northeast-1', // Your Region
        'yourendpoint.iot.ap-northeast-1.amazonaws.com', // Require 'lowercamelcase'!!
        'YOUR_AWS_ACCESS_KEY',
        'YOUR_AWS_SECRET_ACCESS_KEY');
    var clientId = Math.random().toString(36).substring(7);
    var client = new Paho.MQTT.Client(endpoint, clientId);
    var connectOptions = {
      useSSL: true,
      timeout: 3,
      mqttVersion: 4,
      onSuccess: subscribe
    };
    client.connect(connectOptions);
    client.onMessageArrived = onMessage;
    client.onConnectionLost = function(e) { console.log(e) };

    function subscribe() {
      client.subscribe("Test/chat");
      console.log("subscribed");
    }

    function send(content) {
      var message = new Paho.MQTT.Message(content);
      message.destinationName = "Test/chat";
      client.send(message);
      console.log("sent");
    }

    function onMessage(message) {
      data.messages.push(message.payloadString);
      console.log("message received: " + message.payloadString);
    }
  </script>
</body>
</html>

動作画面

わあ。いまさらですが、これサーバーレスなんですよ。 test

解説

AWSのドキュメントがとても詳細に記述されているので、それに従えばOKです。
手順としては

  • WebSocketコネクション要求時に発生するHTTPリクエストのモックを作成
  • リクエストのモックを元に署名バージョン4のQueryStringを作成
  • AWS IoT Device GatewayのMQTT over WebSocketsエンドポイントに上記のQueryStringを付加してWSS接続

という感じです。
また、チャット部分の実装は最小限のものなのでPahoのドキュメントを参考に読んでいただければと思います。

さて、基本的にはAWSドキュメント通りに記述すればOKなのですが、一点ハマるかもしれないポイントがあります。
署名バージョン4の作成時ですが、HTTPリクエストのモックを作成するために host として AWS IoTのエンドポイント を渡してやる必要があります。
このAWS IoTのエンドポイントは、aws-cliのiot describe-endpointコマンドで取得可能です。

$ aws iot describe-endpoint
{
    "endpointAddress": "YOURENDPOINTID.iot.ap-northeast-1.amazonaws.com"
}

上記のように、大文字小文字混じりでエンドポイントが返されます。
さて、このエンドポイントをそのままモックHTTPリクエスト作成に使用すると、正しい署名バージョン4が作成されません。
理由は、実際のHTTPリクエストの host はすべて小文字に変換された yourendpoint.iot.ap-northeast-1.amazonaws.com になるためです。
そのため host はlowercaseで渡してやる必要があります。
一応これはAWSの署名バージョン4に関するドキュメントには記述されているのですが

8. Create a digest (hash) of the canonical request with the same algorithm that you used to hash the payload.

というように、"ペイロードをハッシュした時と同じアルゴリズムで生成して下さい"という表現になっているため(そのアルゴリズムに小文字への変換が含まれている)見落としてしまいがちかもしれません。ご注意を!

まとめ

  • 署名バージョン4の「正規リクエストのハッシュ」を生成する際には、ホスト名がlowercaseになっていることを確認します。
  • ブラウザ上で動くMQTTのクライアントがAWS IotとPub/Subするために必要な権限は以下の通りです。
    • iot:Connect
    • iot:Publish
    • iot:Subscribe
    • iot:Receive
  • 2015/02現在はNode.jsとiOSのみIoT Data用のSDKが用意されています。
    • アプリケーションサーバなどからPublishしたい場合には、Node.jsで実装したAWS Lambda Functionを介すと楽ちんです。
    • もちろん各言語用のMQTTクライアントと証明書を用いて、自前で接続してPublishしたりもできます。

スケールするマネージドなWeb Socket Secureのエンドポイントって本当に凄いです。
第一報を聞いた時にはコーヒー吹き出しました。というわけでガンガン使って行きましょう、ではまた!