Node.js + LambdaでDDNSのIP紐づけが切れていないか監視する

#### DDNS(ダイナミックドメインネームシステム)って？

DDNS(ダイナミックドメインネームシステム)とは、動的に割り当てられているIPアドレスとドメイン名を紐づけてくれるものです。

https://ja.wikipedia.org/wiki/%E3%83%80%E3%82%A4%E3%83%8A%E3%83%9F%E3%83%83%E3%82%AF%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%8D%E3%83%BC%E3%83%A0%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0#:~:text=%E3%83%80%E3%82%A4%E3%83%8A%E3%83%9F%E3%83%83%E3%82%AF%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%8D%E3%83%BC%E3%83%A0%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%20(Dynamic,%E7%AE%A1%E7%90%86%E3%81%99%E3%82%8B%E4%BB%95%E7%B5%84%E3%81%BF%E3%81%A7%E3%81%82%E3%82%8B%E3%80%82

これを利用すれば、固定のグローバルIPアドレスを持っていなくても、
自宅サーバー内にたてたWebアプリケーションに自宅外からアクセスできるようになったり、
出先からリモートデスクトップを利用できるようになったりするので、趣味でいろいろと試してみたいようなときに、コストをかけずにできます。
(※もちろん、サーバーを外部に公開するには相応のリスクがあるので、セキュリティ対策はマストです)

私はMyDNSというDDNSサービスを利用しているのですが、継続して利用するためにはユーザーが定期的にIPアドレスの通知を行なうよう、利用規定で定められています。(これは死蔵アカウントが増殖していくのを防ぐための取り決めだと思われます)
１週間通知がされていないとIPとドメインの紐づけが切れ(DNS情報がなくなり)、１か月IPの通知がされていないとアカウントそのものが削除されます。

https://www.mydns.jp/#howtouse

通知の方法は、サイトにログインして画面上でボタンを押してもできますが、APIも用意されているので、スクリプトを作成してタスクスケジューラやcronで自動実行するようにしておけば、毎日毎日手間がかかるということはありません。

#### （ごく個人的に）ちょっと困っていること

ですがたまに、停電や、ブレーカーが落ちた、などでIP通知を送るスクリプトを仕込んだサーバーがシャットダウンしたまま、それに気づかずに放置してしまうということがあります。気づけばアカウント削除の期限である１か月まであと数日だった、ということが何度かありました。

一応、公式からも８日間IP通知が無い場合にはその旨メールでお知らせが届くのですが、結構、外出先でメールを見て、帰ったら対応しておこうと思ってそのまま忘れてしまうということがあったりします。

 **個人的にはもっとしつこくリマインドしてほしい！**(少数意見) 
できれば、切れていたら１日１回くらいはお知らせしてほしい。...
というわけで、前置きが長くなりましたが、その死活監視の機能をAWS Lambdaで作ってみました。

#### やりたいこと
- ドメイン名からIPアドレス取得を試みる。
- 取得できなかったら紐づけが切れている可能性があるとみなしてメール通知する。
- 以上を定期的に自動実行する。
- ランニングコストは無料か、限りなくゼロに近い金額(月間数十円程度まで)でまかなう。

これらを実現するために、以下の手順で構築していきます。

<hr>

#### Lambda関数を作成する
Lambdaは月間100万リクエストまで無料なので、やりたいことにはこれで十分。
ということで利用することにします。

https://aws.amazon.com/jp/lambda/pricing/?loc=ft#Free_Tier

Node.js 18.xで作成します。
AWSマネジメントコンソールのLambdaメニューからすべてデフォルト設定のまま進めていくと、テンプレートが.mjsファイル(ECMA Script形式)で作成されていたので、これをもとにコードを書いていきます。

全体構成
```bash
./
├── Functions      ... 別ファイルへ分割した関数を置くディレクトリ
│   ├── lookup.mjs    ... ドメインからIPアドレスの取得を試みる関数
│   └── notify.mjs    ... メール送信を行なう関数
├── index.mjs      ... エントリポイント
└── settings.mjs   ... 設定ファイル
```

settings.js
```js
export const conf ={
 "domain":"{%MyDNSに登録しているドメイン名%}",
 "sesRegion":"ap-northeast-1",
 "mailTo":"{%メール通知の宛先アドレス%}",
 "mailFrom":"{%メール通知の送信元アドレス%}",
 "mailBody":"MyDNS.JPへ登録されているドメイン({%MyDNSに登録しているドメイン名%})とIPアドレスの紐づけが確認できませんでした。設定を確認してください。",
 "mailSubject":"【MyDNS死活監視】設定を確認してください。"
};

export const status = {
    "Unmodified":{"code":"200","description":"変更なし"},
    "Notified":{"code":"201","description":"変更あり(メール通知済み)"},
    "FailedToNotify":{"code":"503","description":"変更あり(メール通知失敗)"},
    "Unhandled":{"code":"500","description":"不明なエラー"}
};
```

index.mjs
```js
import { conf, status } from './settings.mjs';
import lookup from './Functions/lookup.mjs';
import notify from './Functions/notify.mjs';

export const handler = async (event) => {

  try {
    
    //ドメイン名から現在のIPアドレスを取得
    const result_lookup = await lookup(conf.domain);
    console.log("STEP_LOOKUP\n" + JSON.stringify(result_lookup));

    //取得に失敗したらDNS情報がなくなっている可能性があるのでメール通知する
    let result_notify = null;
    if (result_lookup.code !== 200) {
      result_notify = await notify();
      console.log("STEP_NOTIFY\n" + JSON.stringify(result_notify));
    }

    //処理結果のレスポンスを送信
    let result = null;
    if (result_notify === null) {
      result = status.Unmodified;
    } else {
      if (result_notify.code === 200) {
        result = status.Notified;
      } else {
        result = status.FailedToNotify;
      }
    }

    return makeResponse(result);
    
  } catch (err) {
    console.error("UNHANDLED_ERROR_OCCURED\n" + JSON.stringify(err));
    return makeResponse(status.Unhandled);
  }
};

function makeResponse(result) {

  console.log("RESULT\n" + JSON.stringify(result));

  return {
    resultCode: result.code,
    result: result.description
  };
}
```

Functions/lookup.mjs
```js
import mod_dns from 'dns';

/**
 * [async function]
 * ドメイン名をもとにグローバルIPアドレスを取得する
 * <param name="domain">ドメイン名</param>
 * <return>
 *  <property name="code">結果コード(200:成功 500:失敗)</property>
 *  <property name="data">
 *    <property name="domain">ドメイン名</property>
 *    <property name="address">成功した場合、取得したグローバルIPアドレス</property>
 *    <property name="error">エラーが発生した場合、Errorオブジェクトのインスタンス</property>
 *  </property>
 * </return>
 */
export default async function lookup(domain) {

 try {

    let result = { "code": null, "data": { "domain": domain, "address": null, "error": null } };

    return new Promise((resolve, reject) => {

      mod_dns.lookup(domain, { "family": 4 }, (err, address, family) => {

        result.code = err === null ? 200 : 500;
        result.data.address = address;
        result.data.error = err;
        if (err === null) {
          resolve(result);
        } else {
          //※何らかの理由で取得できなかった、という結果を元に
          // 呼出元の処理は続けたいので、ここではresolveとする
          resolve(result);
        }
      });
    });
 }
 catch (err) {
    throw err;
 }
}
```

Functions/notify.mjs
```js
import { conf } from '../settings.mjs';
import { SESClient as mod_ses_client,SendEmailCommand as mod_ses_command } from "@aws-sdk/client-ses";

/**
 * [async function]
 * メール送信を行なう
 * <param name="domain">ドメイン名</param>
 * <return>
 *  <property name="code">結果コード(200:成功 500:失敗)</property>
 *  <property name="data">
 *    <property name="error">エラーが発生した場合、Errorオブジェクトのインスタンス</property>
 *  </property>
 * </return>
 */
export default async function notify() {
  
  let result = { "code": null, "data": { "error": null } };

  try {

    return new Promise(async (resolve, reject) => {

      // メール送信設定
      const send_params = {
        Destination: {
          ToAddresses: [conf.mailTo]
        },
        Message: {
          Body: { Text: { Data: conf.mailBody } },
          Subject: { Data: conf.mailSubject }
        },
        Source: conf.mailFrom
      };

      const ses_command = new mod_ses_command(send_params);
      const ses_client = new mod_ses_client({"region":conf.sesRegion});

      // メール送信
       try {
             await ses_client.send(ses_command);
             result.code =200;
        } catch (err) {
             result.code =500;
             result.data.error = err;
        }

      if(result.code === 200){
        resolve(result);
      }else{
        reject(result);
      }
    });
  } 
  catch (err) {
    throw err;
  }
}
```

#### SES(Simple Email Service)を設定する
紐づけが切れていた際にメールで通知する機能を持たせるため、SES(Simple Email Service)の設定をします。
今回は自分のメールアドレスから自分のメールアドレスへ送れさえすればよいので、サンドボックス環境内から出さないまま使用することとします。
(サンドボックスから出して使用する必要がある場合は下記の手順で行ないます)

https://docs.aws.amazon.com/ja_jp/ses/latest/dg/request-production-access.html

AWSマネジメントコンソールでSESのメニューに入り、「最初のEメールの送信」 > 「IDの作成」ボタンを押します。
![SES設定1-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/18255bde-cbdf-a235-86e7-b3ddf7a03f95.png)

「Eメールアドレス」に自分のメールアドレスを入力して「IDの作成」ボタンを押します。メールアドレスはGmailなどでも大丈夫です。
![SES設定1-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/3cce4b90-81b6-eedf-57c3-5eccdeb8ceb4.png)


#### ロールにSESの使用権限を付与する
Lambda関数を作成する際に、デフォルトの設定では実行ロールの項目は「基本的な Lambda アクセス権限で新しいロールを作成」が選択されているので、上の手順でLambda関数を作成した際に、この関数のためのロールが作成されています。
このロールに、SESでのメール送信を許可する設定を追加します。

AWSマネジメントコンソールでLambdaのメニューに入り、「設定」 > 「アクセス権限」を開きます。
ロール名がリンクになっているので、クリックします。
![ロールへの権限追加1-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/05402af8-7895-84b1-720c-ec362ce06a4d.png)

ポリシーの設定画面が開かれるので、
「許可ポリシー」 > 「ポリシー名」のアコーディオンブロックを開き、「編集」ボタンを押します。
![ロールへの権限追加1-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/0dfd0b7d-33ab-0fcd-d013-320b6026f122.png)

ロールにアタッチされているポリシーに、SESによるメール送信の権限を追加して、保存します。
![ロールへの権限追加1-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/13880888-c30c-0b86-ada2-6a9525542585.png)

#### トリガーを設定する
Event Bridgeで定期的に実行するためのトリガーを作成します。
ここではテストのために短めの間隔で設定しました。

AWSマネジメントコンソールでLambdaのメニューに入り、「関数の概要」 > 「トリガーを追加」ボタンを押します。
![トリガー設定1-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/80b6f308-9395-8ac4-b9a9-409d81109f77.png)

「ソースを選択」で「EventBridge」を選択するとLambda関数をどのようなスケジュールで実行するか設定する画面が表示されます。
「Create a new rule」、「Schedule expression」を選択し、
最下部のテキストボックスにcron書式でスケジュール設定を入力します。
ここでは15分間隔で関数が実行されるように設定しました。
「Rule name」へは任意の名称を入力します。選択、入力できたら「追加」ボタンを押します。
![トリガー設定1-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/98345e4c-24e2-c39e-2e33-548a56ec88e2.png)

<hr>

ここまで作って、試してみます。
MyDNSのサイトへログインして、紐づけの設定を手動で解除します。「0.0.0.0」を設定すると期限を待たず意図的に解除することができます。
![MyDNS画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/ff9202a4-ac70-2103-f120-3372ace77ff5.png)

紐づけが切れた状態でLambda関数が実行されると、このように通知メールが送られてきました。
![通知メール確認.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3527651/04935810-4e86-806e-ec46-0c2ef8c8ea68.png)