ドローンとクラウドの通信をSSH port forwardingで秘匿化する

# はじめに

[SENSYN Robotics](https://www.sensyn-robotics.com/)(センシンロボティクス)の中山です。
Webアプリやそのインフラ周りと、Web側とドローンの接続を行うデバイスドライバ的な部分を担当しています。

今回は[SENSYN DRONE HUB](https://www.sensyn-robotics.com/solution/sensyndronehub)がWebアプリと通信する部分をSSH port forwardingで秘匿化[^1]する話です。

# DRONE HUBとWebアプリの通信

[SENSYN DRONE HUB](https://www.sensyn-robotics.com/solution/sensyndronehub)はLTEを使って通信するドローンです。日本でLTEをドローンに載せて通信を行うには、総務省への申請を行い承認を得る必要があります。詳しくは電波法を参照してください。

さて、LTEを使って通信できるということは、クラウド上のサーバと直接通信できるということです。[SENSYN DRONE HUB](https://www.sensyn-robotics.com/solution/sensyndronehub)はIoTでよく使われるMQTTというプロトコルを使って、クラウド上のMQTT Brokerと通信し、ドローンの状態をレポートしたり、コマンドや飛行計画を受信したりしています。

このMQTTですが、仕様にはTLSによる通信の秘匿化が盛り込まれており、多くのライブラリやMQTT Brokerが対応しています。当然、本来ならばTLSを使って秘匿化を実現するべきなのですが、諸々の事情で[SENSYN DRONE HUB](https://www.sensyn-robotics.com/solution/sensyndronehub)は平文のMQTTを使っていました。

平文でMQTTを使うということは、MQTT BrokerのIPアドレスとポート番号さえ知っていれば、ドローンがどこを飛んでいるかという情報を第三者が盗み見たり、ドローンの制御を奪い取ったりといった攻撃が可能になるということです。

これでは安全に[SENSYN DRONE HUB](https://www.sensyn-robotics.com/solution/sensyndronehub)を使うことはできません。そこで製品化前に秘匿化の方法を検討しました。

# どうやって秘匿化するか

本来ならばMQTTのTLSサポートを使って秘匿化するのが筋ですが、諸々の事情によりこれはできません。既存のコードに手を入れず透過的に、かつ、既存コードが動く環境に影響を与えないよう、できるだけ設定変更やライブラリのインストール等を減らす必要があります。

[SENSYN DRONE HUB](https://www.sensyn-robotics.com/solution/sensyndronehub)内部ではARM64上のUbuntu Linuxが動いており、実現方法としては大まかに二つ考えられます。

A. VPNを使う
B. SSH port forwardingを使う

当初はA案のVPNを使うつもりで調査を進めていたのですが、VPNクライアントを入れると依存関係による環境の更新がそれなりに発生すること、クラウド上のVPNサーバの運用にコストが掛かること[^2]から保留し、まずはB案のSSH port forwardingで実現することにしました。

## クラウド側

MQTT Brokerは[Kubernetes](https://qiita.com/t-noguchi/items/660974083da23f868acb)上のコンテナとして動いているので、同じpodの中にsshdを立てて、ドローンからのssh接続を受け付けるようにしました。ドローンからsshで接続すると、その後の通信をMQTT Brokerに転送します。

## ドローン側

sshで[Kubernetes](https://qiita.com/t-noguchi/items/660974083da23f868acb)上のsshdにport forwardingする設定で接続します。そして、設定を変更し、クラウド上のMQTT Brokerへのを参照をlocalhost(127.0.0.1)への参照に変更します。

これでドローン内部で動いているソフトウェアには手を入れず、通信を秘匿化することに成功しました。各ドローンの公開鍵をクラウド側の`authorized_keys`に追加する部分に若干の手作業が残っていますが、運用もおおむね自動化できています。そのうち、残っている手作業部分もGitlab APIを使って自動化する予定です。

# トラブル発生

これでめでたし、めでだし、とはいきませんでした。クラウド側で修正すべき箇所が一点残っているのを、すっかり忘れていたのです。具体的には、[Azure Functions](https://azure.microsoft.com/ja-jp/services/functions/)の一つがMQTT Brokerに対してメッセージを発行しているのですが、これがSSH化によって動かなくなりました。

Azure FunctionsはKubernetesと同じく、Azure内のプライベートなネットワークで動いているため、sshを使わずにMQTT Brokerに直接アクセスするのがベストの解決方法です。ただ、これを簡単に実装する方法が調査時点では見つからなかった[^3]ため、Auzre Functions内でSSH port forwardingして実現することにしました。

今回問題になったAzure FunctionsはNode.jsで書かれています。Node.jsであれば[tunnel-ssh](https://www.npmjs.com/package/tunnel-ssh)が使えそうでした。

実際にやってみると、秘密鍵をAzure Functionsに渡すのに苦労しましたが、それ以外はすんなり動きました。

* 秘密鍵の改行をカンマ(`,`)区切りでAzure Functionsに設定してコード側で改行に置き換える
* 同一ホストで複数起動してもポートが被らないように、ランダムなポート番号を使う

といった細かい工夫はありますが、[tunnel-ssh](https://www.npmjs.com/package/tunnel-ssh)自体は非常に使いやすいライブラリでした。欲を言えば、async/await構文を活用するために、最初からPromiseベースのAPIが欲しいところではあります[^4]。

```javascript

'use strict';

const mqtt = require('mqtt');
const tunnel = require('tunnel-ssh');
const util = require('util');
const uuid = require('uuid/v4');

module.exports = async (context, item) => {
    const localPort = Math.floor(Math.random() * 10000 + 10000);
    const config = {
        username: process.env['SSH_USER_NAME'],
        port: parseInt(process.env['MQTT_PORT'], 10),
        host: process.env['MQTT_HOST'],
        privateKey: process.env['PRIVATE_KEY'].split(',').join("\n"),
        passphrase: '',
        dstPort: 1883,
        dstHost: '127.0.0.1',
        localHost: '127.0.0.1',
        localPort: localPort
    };

    const tnl = await util.promisify(tunnel)(config);
    try {
        await sendToMqttBroker(item, localPort);
        context.done();
    } catch (e) {
        context.log(e);
        context.done(e);
    } finally {
        tnl.close();
    }
};

function sendToMqttBroker(item, localPort) {
    return new Promise((resolve, reject) => {
        const clientId = uuid();
        const config = {
            host: '127.0.0.1',
            port: localPort,
            protocol: 'mqtt',
            clientId: clientId,
        };

        const client = mqtt.connect(config);
        const to = setTimeout(() => {
            client.end();
            clearTimeout(to);
            reject('timeout')
        }, 30 * 1000);

        client.on('connect', connack => {
            client.publish(item.topic, item.content, {
                qos: 0,
                retain: false,
            }, err => {
                if (err) {
                    reject(err);
                }
                client.end();
                clearTimeout(to);
                resolve(item);
            });
        });
        client.on('error', e => {
            client.end();
            clearTimeout(to);
            reject(e);
        });
    });
}
```

# まとめ

* 既存コードに手を入れずに平文のMQTTを秘匿化する手段として、SSH port forwardingが使える
* Node.js内部でSSH port forwardingするときは、tunnel-sshが便利に使える
* 修正忘れには注意しよう…

[^1]: 大抵は暗号化という形を取ります
[^2]: [Microsoft Azureのマネージドサービス](https://azure.microsoft.com/ja-jp/services/vpn-gateway/)を検討しました
[^3]: 面倒な方法ならたぶんあります
[^4]: `util.promisify()`で済むので、なくても困らないといえば困らないのですが