Claude Media
RDAPでドメインの有効期限台帳を作る — Claude Codeで更新対象を洗い出す

RDAPでドメインの有効期限台帳を作る — Claude Codeで更新対象を洗い出す

ドメイン一覧にRDAPで問い合わせ、expirationイベントの日付から60日以内の更新対象を拾う台帳の作り方です。問い合わせ先の引き方、statusの読み方、Claude Codeへの任せ方まで扱います。

自社で持つドメインが増えると、更新日が誰の管理画面にあるのか分からなくなります。期限切れでWebサイトやメールが止まる事故を避けるには、全ドメインの有効期限を1枚の一覧に集め、期限が近い順に並べておくのが確実です。

その一覧は、RDAP(Registration Data Access Protocol)で作れます。RDAPは、ドメインの登録情報をJSONで返す問い合わせの仕組みです。返ってくるJSONの events に expiration というイベントがあり、その日付がドメインの有効期限になります。この記事では、ドメイン一覧をRDAPで引いて残り日数で並べるスクリプトと、結果の読み方、Claude Codeに任せる範囲を順に書きます。

RDAPの有効期限は「expiration」イベントで取る

RDAPのドメイン応答は、events という配列に出来事を持ちます。要素は eventAction(出来事の種類)と eventDate(日時)の組で、RFC 9083は両方を必須としています。

期限を表すのは、eventAction が expiration の要素です。RFC 9083はこの値を「オブジェクトが、あらかじめ決まった日時にレジストリから削除される(または削除された)こと」と定めています。同じ節に、イベントは未来の日付でもよく、その使い道の1つがオブジェクトの失効日を示すことだと書かれています。

つまり、期限日を取る処理は次の2点だけです。

  • events の中から eventAction が expiration の要素を探す
  • その eventDate を日時として読む

注意が1つあります。events の並び順は仕様で決まっていません。手元で .com と .org の応答を比べると、.com では registration が先頭で、.org では expiration が先頭でした。先頭要素や添字で読まず、eventAction で探します。

eventAction の値は「RDAP JSON Values」というIANAのレジストリに登録されています。同じ語彙でTLDをまたいで読めるので、1本のコードで全ドメインを処理できます。

台帳に持つ列と、60日以内を拾う条件

台帳の列は、問い合わせの結果から機械的に埋められるものと、人が持つものに分けます。

列中身埋める方法
domain中身ドメイン名埋める方法入力の一覧
expiration中身期限日(UTC)埋める方法RDAPの expiration
days_left中身実行日からの残り日数埋める方法スクリプトで計算
registrar中身管理しているレジストラ名埋める方法RDAPの registrar ロールのエンティティ
status中身登録状態の一覧埋める方法RDAPの status
note中身要更新、取得失敗の理由埋める方法スクリプトが記入

更新対象の条件は「残り日数が60日以内」とします。60日は台帳の運用上の目安で、RDAPの仕様に出てくる数字ではありません。社内の稟議や支払いの締めに合わせ、スクリプトの定数 LIMIT_DAYS で変えられるようにしておきます。期限を過ぎたドメインは残り日数が負になるので、同じ条件に自然に入ります。

レジストラ名は、entities の中で roles に registrar を含む要素の vcardArray から fn(表示名)を取ります。自動更新の有無、請求先、更新の受付方法のように、レジストラごとに違う運用情報は、台帳に備考列を足して人が持つ情報として管理します。

IANAのbootstrapで問い合わせ先を引くスクリプト

RDAPは、TLDごとに問い合わせ先のサーバーが違います。自分で一覧を持たなくても、IANAが公開する https://data.iana.org/rdap/dns.json に対応表があります。これはRFC 9224で定められた「bootstrap」の仕組みで、ドメイン名とラベル単位で右から最長一致するエントリを探し、そこに並ぶ基本URLを使います。基本URLが複数あるときは、HTTPSを先に試します。

問い合わせのURLは、基本URLに domain/<ドメイン名> を付けた形です(RFC 9082)。たとえば .com なら https://rdap.verisign.com/com/v1/domain/example.com になります。

次のスクリプトは、Python標準ライブラリだけで動きます。domains.txt に1行1ドメインで書き、# で始まる行は読み飛ばします。

#!/usr/bin/env python3
"""domains.txt の各ドメインを RDAP で引き、expiration の日付を CSV にする。"""
import csv, json, sys, time, urllib.request, urllib.error
from datetime import datetime, timezone
 
BOOTSTRAP = "https://data.iana.org/rdap/dns.json"
LIMIT_DAYS = 60
 
def load_bootstrap():
    with urllib.request.urlopen(BOOTSTRAP, timeout=30) as r:
        services = json.load(r)["services"]
    return {t: urls for tlds, urls in services for t in tlds}
 
def base_url(table, domain):
    labels = domain.lower().rstrip(".").split(".")
    for i in range(len(labels)):  # ラベル単位の最長一致
        urls = table.get(".".join(labels[i:]))
        if urls:
            return next((u for u in urls if u.startswith("https://")), urls[0])
    return None
 
def registrar_name(data):
    for ent in data.get("entities", []):
        if "registrar" in ent.get("roles", []):
            for item in ent.get("vcardArray", [None, []])[1]:
                if item[0] == "fn":
                    return item[3]
    return ""
 
def lookup(domain, table):
    row = {"domain": domain, "expiration": "", "status": "",
           "registrar": "", "note": ""}
    base = base_url(table, domain)
    if not base:
        row["note"] = "bootstrap に該当 TLD なし"
        return row
    req = urllib.request.Request(
        base.rstrip("/") + "/domain/" + domain,
        headers={"Accept": "application/rdap+json",
                 "User-Agent": "domain-ledger/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            data = json.load(r)
    except urllib.error.HTTPError as e:
        row["note"] = f"HTTP {e.code}"
        return row
    except Exception as e:
        row["note"] = f"取得失敗: {type(e).__name__}"
        return row
    row["status"] = ";".join(data.get("status", []))
    for ev in data.get("events", []):
        if ev.get("eventAction") == "expiration":
            row["expiration"] = ev["eventDate"]
    row["registrar"] = registrar_name(data)
    if not row["expiration"]:
        row["note"] = "expiration イベントなし"
    return row
 
def main(path):
    table = load_bootstrap()
    now = datetime.now(timezone.utc)
    rows = []
    for line in open(path, encoding="utf-8"):
        domain = line.strip()
        if not domain or domain.startswith("#"):
            continue
        row = lookup(domain, table)
        if row["expiration"]:
            exp = datetime.fromisoformat(row["expiration"].replace("Z", "+00:00"))
            row["days_left"] = (exp - now).days
            if row["days_left"] <= LIMIT_DAYS:
                row["note"] = "要更新"
        else:
            row["days_left"] = ""
        rows.append(row)
        time.sleep(1)  # 相手のサーバーへの負荷を抑える
    rows.sort(key=lambda r: (r["days_left"] == "", r["days_left"] or 0))
    w = csv.DictWriter(sys.stdout, ["domain", "expiration", "days_left",
                                    "registrar", "status", "note"])
    w.writeheader()
    w.writerows(rows)
 
if __name__ == "__main__":
    main(sys.argv[1])

エラーになった行も一覧から落とさず、note に理由を書いて残しています。取得できなかったドメインが台帳から消えると、期限管理の穴がそのまま見えなくなるためです。並び順は、日付のある行を残り日数の少ない順に先頭へ、日付の無い行を末尾にしています。

実行結果の読み方と、引けない行の切り分け

実行は、一覧のファイルを渡してCSVを受け取るだけです。

python3 rdap_expiry.py domains.txt > ledger.csv
column -s, -t ledger.csv

予約ドメインの example.com example.net example.org、bootstrapに載らないTLDの example.jp、存在しない名前を並べて実行した出力は、次のとおりでした(Python 3.12。days_left は実行日で変わります)。

domain,expiration,days_left,registrar,status,note
example.com,2027-08-13T04:00:00Z,297,RESERVED-Internet Assigned Numbers Authority,client delete prohibited;...,
example.net,2027-08-30T04:00:00Z,314,RESERVED-Internet Assigned Numbers Authority,client delete prohibited;...,
example.org,2027-08-30T04:00:00Z,314,ICANN,server delete prohibited;...,
example.jp,,,,,bootstrap に該当 TLD なし
nonexistent-zzq81.com,,,,,HTTP 404

日付のある行は期限順に並び、取得できなかった2行は末尾にnoteつきで残っています。registrar 列は、実在のドメインならレジストラの表示名が入ります。

落とし穴

Pythonの既定のままだと、サーバーによっては403が返る

Pythonの urllib は既定で Python-urllib/3.x を User-Agent として送ります。この既定値で example.org を問い合わせるとHTTP 403が返り、User-Agent: domain-ledger/1.0 のような独自の値を付けると200で応答が返って expiration も取れます。応答本文に403の理由は書かれていません。取得失敗の行が特定のTLDに偏るときは、ヘッダーの指定を最初に疑う価値があります。

行ごとの note が空でないとき、原因は次の順に切り分けます。

切り分け

取得できなかった行の切り分け

  1. Qnoteは「bootstrap に該当 TLD なし」か
    答えがはいのとき

    そのTLDはIANAの対応表に載っていません。上の出力では example.jp がこれで、取得したdns.jsonに jp のエントリはありませんでした。RDAPでは引けないので、レジストラの管理画面かWHOISで期限を確かめ、台帳の備考に手入力します。自分のTLDが載っているかは、python3 -c でdns.jsonを開いて確かめられます。

    答えがいいえ
  2. Qnoteは「HTTP 404」か
    答えがはいのとき

    サーバーがその名前を知らないという応答です。綴りの誤り、未登録、すでに削除された名前のどれかです。入力の一覧に誤記がないか見直します。

    答えがいいえ
  3. Qnoteは「HTTP 403」などのその他のコードか
    答えがはいのとき

    ヘッダーと問い合わせ間隔を見直します。スクリプトは1件ごとに1秒空けています。数百件を並列で投げると、サーバー側に制限される要因になります。

    答えがいいえ

expiration イベントなし

応答は返っているのに期限の要素が無いケースです。status の列と、レジストラの管理画面の表示を突き合わせます。

期限が近いドメインのstatusを読む

status の列は、期限日と合わせて読むと状況が分かります。RFC 9083とRFC 8056(EPPの状態をRDAPの語に対応づけた仕様)が定義する語のうち、期限まわりで目に入りやすいものを抜き出します。

status意味(仕様の定義)
auto renew period意味(仕様の定義)登録期間が切れたあと、サーバーが自動で延長する猶予期間
client hold / server hold意味(仕様の定義)DNSの委任情報を公開しない状態
redemption period意味(仕様の定義)削除要求を受けたが、復元して取り消せる余地が残っている状態
pending restore意味(仕様の定義)redemption periodを経て、復元の処理が進んでいる状態
pending delete意味(仕様の定義)削除要求を受けたが処理が終わっていない状態。ドメインではDNSで公開されなくなっても、レジストリのデータベースから消えていない場合がある

client transfer prohibited client delete prohibited のように prohibited が付く語は、更新や移管の操作を拒否する設定です。上の出力で .com の予約ドメインに並んでいたのも、この種の語でした。期限とは別の話で、付いているから更新できないという意味にはなりません。

台帳の見方として、期限日が過ぎているのに redemption period や client hold が出ている行は、最初に見る行です。猶予期間の長さや、猶予中の更新に追加費用がかかるかどうかは、レジストリとレジストラの案内で確かめます。

Claude Codeに任せる範囲と設定

台帳の作成そのものはスクリプトで決まるので、Claude Codeには「実行して結果を読み、人に渡す一覧を整える」ところを任せます。ドメインの一覧をHostingerのようなレジストラから取るところは、Hostinger MCPでドメインとDNSを管理する手順と組み合わせられます。

まず、実行をいちいち確認されないように、スクリプトの呼び出しだけを許可します。ルールの書き方は、Bash(npm run *) のようにコマンドと * を並べる形です。.claude/settings.json に次を置きます。

{
  "permissions": {
    "allow": [
      "Bash(python3 rdap_expiry.py *)"
    ]
  }
}

次に、CLAUDE.mdに扱いの規約を書きます。CLAUDE.mdはセッションの開始時に読み込まれる永続的な指示で、台帳の作業に共通する決まりを毎回伝え直さずに済みます。

# ドメイン台帳
- 台帳の更新は `python3 rdap_expiry.py domains.txt > ledger.csv` で行う
- 期限日は RDAP の expiration の値だけを使い、推測で補わない
- note が空でない行は、理由ごとに分けて報告する
- domains.txt を書き換えるときは、追加・削除したドメインを先に示す

実行後の確認は、Claude Codeに次の順で頼みます。

  1. ledger.csv の行数が、domains.txt のコメントと空行を除いた行数と一致するかを数えさせる
  2. days_left が60以下の行を、期限が近い順に表で出させる
  3. noteが空でない行を、前節の切り分けの分類ごとにまとめさせる

1つ目の数え直しが、取りこぼしを見つける検査になります。一覧のドメイン数と台帳の行数が合わなければ、スクリプトか入力ファイルのどちらかに欠落があります。

人が持つのは、閾値の決定、bootstrap に該当 TLD なし の行の手入力、レジストラ別の更新手順の備考です。更新の操作そのものはClaude Codeに任せず、人が行います。台帳は「いつ、誰が、何を更新するか」を決める材料で、決定は人に残します。

台帳をサポート終了日で作る方法はendoflife.date APIとClaude Codeの突合に、ドメインの漏えい確認はHave I Been Pwnedとの連携にあります。期限と同じ一覧に並べると、ドメインごとの点検が1か所で回ります。

定期実行にして更新漏れを避ける

台帳は1回作って終わりでは意味がありません。残り日数は実行日で変わるので、週に1回ほど自動で再生成します。cronなら次の1行です。

0 9 * * 1 cd /path/to/ledger && python3 rdap_expiry.py domains.txt > ledger.csv

出力のCSVはGitで管理すると、期限日が前回から動いた行(更新された行)が差分として見えます。期限日が伸びていれば更新済み、動いていなければ未更新です。60日以内の行が出たら、担当者に知らせる運用を足します。

まとめ

ドメインの有効期限は、RDAPの応答の events から eventAction が expiration の要素を探して取ります。問い合わせ先はIANAのdns.jsonから引き、引けないTLDや取得失敗の行は、一覧から落とさず理由つきで残します。

Claude Codeに渡すのは、スクリプトの実行許可とCLAUDE.mdの規約、行数の突き合わせです。期限の判断と、RDAPで引けないドメインの手入力は人が持ちます。

この記事を共有:XはてブLinkedIn