DDNSを自分でやる

ダイナミックDNSを自分でやります。
以下のような思いを持っている方は是非ご検討ください。

1. 世の中にあるDDNSサービスで自身のサーバを晒したくない
2. 自分でAWSのRoute53ですでに自身のサーバのDNSを登録している
3. よくルータに割り当てられたIPアドレスが変わる

最近ルータに割り当てられたIPアドレスがころころ変わっているように思います。
なので、いざ外出先から接続しようとすると接続できないことが多くなってきました。
かといって、世の中にあるDDNSに自身のサーバのIPアドレスを登録して世間に晒したくないので、自分でDDNS相当をやってみました。

すでに、自分のドメイン名を持っていて、AWSを使っている方を対象としています。
また、言語はNode.jsを使っています。

ソースコード一式をGitHubに上げておきました。

https://github.com/poruruba/MyDDNS

# 仕組み

仕組みとしては、以下の通りです。

1. 定期的にルータ下のローカルネットワークからAWSにHTTP呼び出しをします。
2. AWSのLambdaで呼び出しを受け取り、呼び出し元IPアドレスを取得します。
3. AWS Route53にあるホスト名のAレコードの値と一致していなければ更新します。

1.では、ローカルネットワークからインターネットにアクセスしていますが、ルータ越しにアクセスしてきているため、2.のAWSから見ると呼び出し元はルータのWAN側IPアドレスになっているわけです。

# 利用するAWSサービス

・Route53

DNSとしてAWS Route53を使います。自身のドメイン名のドメインのホストゾーンの登録と、登録したいサーバのFQDNがAレコードとして登録されている前提で進めます。とりあえず、現時点でのルータのWAN側IPアドレスを値として登録しておきます。

・Lambda

HTTP呼び出しを受け付けるロジックの部分になります。言語としてNode.jsを選びました。
以前であれば、フロントエンドにAPI Gatewayが必要だったのですが、最近は関数URLを使うことでLambda単独で外部に公開することができます。

・IAM

Lambdaの実行権限に加えて、Route53へのアクセス権も設定します。

# Route53の設定

DDNSとして特別なことはなく、単にドメイン名やホスト名が解決できる状態にします。
ドメイン名の取得や設定がまだの場合は、以下も参考にしてみてください。

(参考)
[Nginx Proxy Managerを使ってワイルドカードSSL証明書を作成する](https://qiita.com/poruruba/items/13ec1d93b27a43359ea5)

NSレコードに記載されている値を上位のレジストラーに登録すればDNS解決をたどってくれます。
Aレコードが今回のルータのIPアドレスを更新する対象です。
実は、別のホスト名も同じルータのIPアドレスに割り当てるようにするのですが、上記のAレコードに対するCNAME（別名のこと）にすることで、AレコードのIPアドレスを編集すればCNAMEも同じIPアドレスをさすようになります。

![route53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/de5743a0-3655-f361-8251-35df0333b0f4.png)

ここで設定したホストゾーンのホストゾーンIDは後で使うので覚えておきます。ホストゾーン詳細のところに記載されています。

# IAMの設定

後で作成するLambdaのための実行権限であるロールを設定します。

必要なのは以下の通りです。

・AWSLambdaBasicExecutionRole
・カスタムインラインポリシー

カスタムインラインポリシーは、Route53のレコードの内容を参照したり更新するための権限です。Route53の設定時にメモったホストゾーンIDも指定します。これらは、AWSコンソール上でビジュアルエディタで簡単に作れます。

![iam.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f24af064-592f-c2dc-d425-f2a95b205257.png)

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets",
                "route53:ListResourceRecordSets"
            ],
            "Resource": "arn:aws:route53:::hostedzone/[ホストゾーンID]"
        }
    ]
}
```

今回作成したロール名は後で使うので覚えておきます。

# Lambdaの作成

肝心のLambbdaを作成します。

適当な名前のLambdaを作成します。
実行ロールは、「既存のロールを使用する」を選択して、IAMで作成したロールを選択します。

![lambda1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/75e7c9c4-f5e0-9c9c-90ac-e80f34538f16.png)

また、詳細設定のところで、「関数URLを有効化」をチェックします。認証タイプはNONEにします。後でLambdaの実装でAPIキー的な実装で代用します。

![lambda2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e4e1f258-74e6-d7d7-a46f-1975accc79a2.png)

※ 1点お伝えすることがありまして、サポートが切れているNode.js v16とAWS SDK for Javascsript v2を使っています。余力がありましたら、Node.js v20、AWS SDK for Javascript v3に対応してみてください。

メインの「index.js」は以下の通りです。
event.requestContext.http.sourceIpのところに、接続元IPアドレスが入って呼び出されます。
route53.listResourceRecordSetsでRoute53に現在設定されているIPアドレスを取得して、その値と異なっていれば、route53.changeResourceRecordSetsを呼び出して更新します。

```js:api/controllers/ddns-api/index.js
'use strict';

const Response = require('./response');

const AWS = require("aws-sdk");
AWS.config.update({
  region: "ap-northeast-1",
});
const route53 = new AWS.Route53();

const HOSTEDZONEID = "[ホストゾーンID]";
const HOSTNAME = "[ホスト名]";
const API_KEY = "[任意のAPIキー]";

exports.handler = async (event) => {
  const body = JSON.parse(event.body);
  console.log(body);

  if( event.headers["x-api-key"] != API_KEY )
    throw new Error("apikey invalid");
    
  if( event.rawPath == "/ddns-sync" ){
    const sourceIp = event.requestContext.http.sourceIp;
    console.log("sourceIp=" + sourceIp);
  
    const params_list = {
      HostedZoneId: HOSTEDZONEID,
      StartRecordType: "A",
      StartRecordName: HOSTNAME,
      MaxItems: "1"
    };
    const result_list = await route53.listResourceRecordSets(params_list).promise();
    console.log(JSON.stringify(result_list));
    
    let status;
    const item = result_list.ResourceRecordSets.find(item => item.Name == HOSTNAME );
    console.log(item);
    if( item && item.ResourceRecords.find(item => item.Value == sourceIp ) ){
      console.log("same value found");
      status = "Not Change";
    }else{
      const params_change = {
        HostedZoneId: HOSTEDZONEID,
        ChangeBatch: {
          Changes:[
            {
              Action: "UPSERT",
              ResourceRecordSet:{
                Name: HOSTNAME,
                Type: "A",
                TTL: 300,
                ResourceRecords: [
                  {
                    Value: sourceIp
                  }
                ]
              }
            }
          ] 
        }
      };
      const result_change = await route53.changeResourceRecordSets(params_change).promise();
      console.log(result_change);
      status = result_change.ChangeInfo.Status;
    }
  
    return new Response({
      sourceIp: sourceIp,
      status: status
    });
  }else
  {
    throw new Error("unknown endpoint");
  }
};
```

以下の部分をご自身の環境に合わせて書き換えてください。
注意点として、ホスト名には、最後に「.」（ドット）を忘れずにつけてください。

```
const HOSTEDZONEID = "[ホストゾーンID]";
const HOSTNAME = "[ホスト名]";
const API_KEY = "[任意のAPIキー]";
```

ユーティリティクラス「response.js」は以下の通りです。

```js:api/controllers/ddns-api/response.js
class Response{
    constructor(context, statusCode = 200){
        this.statusCode = statusCode;
        this.headers = {'Access-Control-Allow-Origin' : '*'};
        if( context )
            this.set_body(context);
        else
            this.body = "{}";
    }

    set_error(error){
        this.body = JSON.stringify({"err": error});
        return this;
    }

    set_body(content){
        this.body = JSON.stringify(content);        
        return this;
    }
    
    get_body(){
        return JSON.parse(this.body);
    }
}

module.exports = Response;
```

Lambdaを作成すると、このLambdaに以下のような関数URLが割り当たります。

```https://[乱数文字列].lambda-url.ap-northeast-1.on.aws/```

index.jsの実装にもありますが、以下のように呼び出すことで、実行できるようになっています。

HTTP JSON Post呼び出し

```https://[乱数文字列].lambda-url.ap-northeast-1.on.aws/ddns-sync```

ヘッダー

```X-API-KEY: [任意のAPIキー]```

# ローカルネット内からの呼び出し

ローカルネット内から呼び出すコードです。

```js:api/controllers/ddns-cron/index.js
'use strict';

const base_url = "[作成した関数URL]";
const API_KEY = "[任意のAPIキー]";

const fetch = require('node-fetch');
const Headers = fetch.Headers;

exports.handler = async (event, context, callback) => {
  var result = await do_post(base_url + "/ddns-sync", {}, API_KEY);
  console.log(result);
};

function do_post(url, body, apikey) {
  const headers = new Headers({ "Content-Type": "application/json", "X-API-KEY": apikey });

  return fetch(url, {
      method: 'POST',
      body: JSON.stringify(body),
      headers: headers
    })
    .then((response) => {
      if (!response.ok)
        throw new Error('status is not 200');
      return response.json();
    });
}
```

npmモジュールであるnode-fetch@2.7.0を使わせていただきました。

以下の部分をご自身の環境に合わせて書き換えます。

```
const base_url = "[作成した関数URL]";
const API_KEY = "[任意のAPIキー]";
```

上記をnode-cronで1時間ごとに呼び出すようにしました。

```json:api/controllers/ddns-cron/cron.js
[
  {
    "enable": true,
    "schedule": "0 0 * * * *"
  }
]
```

以上