Dynamic DNSっぽいものをAWS Lambdaの関数URLで作成してみた

# はじめに

エンジニアの皆さんは自宅に検証用のサーバを持っていたりすることも多いと思います。
かくいう私も自宅で検証用サーバが1台だけあり、そこにVMを立てて個人用途でOSSのアプリケーションを動かしており、外部からもDNSで名前解決してアクセスできるようにしています。


# 発端

そんな状況で先日引っ越しをしまして、グローバルIPが変更になりDNSのレコード更新が必要になりました。

グローバルIPも瞬断程度であれば変わることはあまりないですし、固定IPの契約も料金払うほどのモチベーションはないです（プロバイダの固定IPサービスは約5000円/月!?）。
だけどグローバルIPが変わる都度自宅からグローバルIPを確認して、毎回DNSレコード変更するのも面倒です。
そういったことを解決するのにルーターのメーカーが提供しているDynamic DNS（以降DDNS）なんてものもあるようですが、調べるのが面倒です。

それならDDNSを自作すればいいんじゃね？となりGithub Copilot君に手伝ってもらいながらLambdaで作成してみました。


# 要件
- 自宅の検証用サーバから定期的に自作DDNSにポーリング（何らかの手段でAWSのAPIをたたくイメージ）
- ポーリングされたときのグローバルIPが現在のレコードのIPと異なっていたら、LambdaからRoute53のレコードを更新する
- **あまりエンドポイントの管理をしたくない（セキュリティ的な意味で）**
    - API Gatewayを立てたりアクセス元IP制限とか面倒なのしたくない


## （没案） Amazon EventBridgeで特定のユーザからのイベントを監視

エンドポイントの管理をしたくないというところから、AWS CLIで適当に`aws sts get-caller-identity`とかをDDNS専用ユーザーが定期的に実行し、EventBridgeからIPアドレス情報取得とLambdaをキックする方法を思いつきました。
ちょこっとだけ検証して、あとはLambda実行すればいいところまでやりましたが、冷静に考えてなんかめんどくさい構成になってね？そもそもAWS CLI実行するんならそのままLambdaを実行すればよくね？となり没案としました。

※以下はその時に考えたイベントパターンの供養です
```json
{
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["sts.amazonaws.com"],
    "eventName": ["GetCallerIdentity"],
    "userIdentity": {
      "arn": ["<DDNS用IAMユーザのARN>"]
    }
  }
}
```

## （採用案）Lambdaの関数URLをIAM認証でアクセスする案

Lambdaをcurlで簡単に実行できる方法ありかなーと思い、調べたら関数URLというのがありました。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/urls-configuration.html

IAM認証もできそうなので、これでエンドポイントを変に管理しなくてもよさそう。
というわけでこちらの案を採用します。

# 実装周り

## Lambdaのコード

以下が実装するLambdaのPythonスクリプトです。
もし使いたい人がいたら`hosted_zone_id`、`records_to_update`の部分修正して使ってください。
関数のCPU/メモリなどのリソースはデフォルト（最小）で、3秒過ぎるくらいなので、タイムアウト値は10秒とかにすればいいと思います。

```python
import json
import boto3

def lambda_handler(event, context):
    # Route53の設定
    hosted_zone_id = 'YOUR_HOSTED_ZONE_ID'
    record_type = 'A'
    
    # 更新したいレコードのリスト
    records_to_update = [
        'YOUR_RECORD_NAME_1',
        'YOUR_RECORD_NAME_2'
    ]
    
    # Route53クライアントを作成
    route53 = boto3.client('route53')
    
    # アクセス元のIPアドレスを取得
    source_ip = event['requestContext']['http']['sourceIp']
    
    # 更新結果を格納するリスト
    update_results = []
    
    for record_name in records_to_update:
        try:
            # Route53のレコードを取得
            response = route53.list_resource_record_sets(
                HostedZoneId=hosted_zone_id,
                StartRecordName=record_name,
                StartRecordType=record_type,
                MaxItems="1"
            )
            
            # 現在のレコードのIPアドレスを取得
            current_ip = response['ResourceRecordSets'][0]['ResourceRecords'][0]['Value']
            
            # IPアドレスが異なる場合、Route53のレコードを更新
            if current_ip != source_ip:
                route53.change_resource_record_sets(
                    HostedZoneId=hosted_zone_id,
                    ChangeBatch={
                        'Changes': [
                            {
                                'Action': 'UPSERT',
                                'ResourceRecordSet': {
                                    'Name': record_name,
                                    'Type': record_type,
                                    'TTL': 300,
                                    'ResourceRecords': [{'Value': source_ip}]
                                }
                            }
                        ]
                    }
                )
                update_results.append(f"{record_name}({record_type}) is updated from {current_ip} to {source_ip}")
            else:
                update_results.append(f"No change for {record_name} (Current IP: {current_ip})")
        except Exception as e:
            return {
                'statusCode': 500,
                'body': json.dumps(f"Error updating records: {str(e)}", ensure_ascii=False, indent=4)
            }
    
    return {
        'statusCode': 200,
        'body': json.dumps(update_results, ensure_ascii=False, indent=4)
    }

```

## Lambda用IAMロール

やりたい操作はRoute53でレコードのリストとレコードの更新だけができればいいだけです。
LambdaのログをCloudWatch Logsに出力するためにlogsのActionが少しありますが、AWS管理ポリシーの`AWSLambdaBasicExecutionRole`を追加で付与するだけでもいいと思います。
```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowRoute53Operations",
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets",
                "route53:ListResourceRecordSets"
            ],
            "Resource": "arn:aws:route53:::hostedzone/<YOUR_HOSTED_ZONE_ID>"
        },
        {
            "Sid": "AWSLambdaBasicExecutionRole1",
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:ap-northeast-1:<YOUR_AWS_ACCOUNT_ID>:*"
        },
        {
            "Sid": "AWSLambdaBasicExecutionRole2",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:<YOUR_AWS_ACCOUNT_ID>:log-group:/aws/lambda/<LAMBDA_FUNCTION_NAME>:*"
            ]
        }
    ]
}

```

## IAM認証用ユーザ作成

自宅サーバから関数URLにアクセスするため、IAMアクセスキーが必要です。
そのためIAM認証用のユーザを作成し、アクセスキーを発行します。
対象ユーザには関数URLから実行できる権限をのみを付与します。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "lambda:InvokeFunctionUrl",
            "Resource": "arn:aws:lambda:ap-northeast-1:<YOUR_AWS_ACCOUNT_ID>:function:<YOUR_FUNCTION_NAME>"
        }
    ]
}
```


# 使い方

自宅のサーバからcronやらなんやらで定期的に以下コマンドを実行します。
`--aws-sigv4`オプションが必要なため、curlは7.75以上のバージョンが必要っぽいです。
```shell
curl "<YOUR_LAMBDA_FUNCTION_URL>" \
 --aws-sigv4 "aws:amz:ap-northeast-1:lambda" \
 --user "<YOUR_ACCESS_KEY>:<YOUR_SECRET_ACCESS_KEY>"
```

`aws configure`が設定されていれば以下でも大丈夫です。
```shell
curl "<YOUR_LAMBDA_FUNCTION_URL>" \
 --aws-sigv4 "aws:amz:ap-northeast-1:lambda" \
 --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)"
```
※2024/12/16追記：IPoEの設定などをした際、アクセス元IPがIPv6アドレスになって失敗したことがあったので、curlにオプションで`-4`とかつけたほうが間違いなさそうです


## 実行時のレスポンス

IPアドレス載せられないので味気ないですが、、、

- 変更時
```
[
    "<YOUR_RECORD_NAME_1>(A) is updated from <SOURCE_IP> to <CURRENT_IP>",
    "<YOUR_RECORD_NAME_2>(A) is updated from <SOURCE_IP> to <CURRENT_IP>"
]
```
- 変更なし
```
[
    "No change for <YOUR_RECORD_NAME_1> (Current IP: <CURRENT_IP>)",
    "No change for <YOUR_RECORD_NAME_2> (Current IP: <CURRENT_IP>)"
]
```

# おわりに

これで今後はグローバルIPを気にしなくてよくなりました。
この記事作成の直前に実装したので運用上の問題などはまだ分かってないですが、問題があった時にグローバルIP周りの観点を忘れそうなのがちょっと心配です。失敗したときの通知設定とかしたいですね。
また最近Terraform触ってたのでIaC化までして載せたかったですが全然時間がありませんでした。気が向いたら載せます（これは面倒になってやらないパターン）。