#!/usr/bin/env python3 """ Route 53 DNS 設定スクリプト: SPF / DKIM / DMARC 使い方(サーバー上で実行): pip3 install boto3 export AWS_ACCESS_KEY_ID=... export AWS_SECRET_ACCESS_KEY=... python3 /app/infra/mail/dns/setup-route53.py HOSTED_ZONE_ID は yyamashita.com のゾーンを自動検出する。 SERVER_IP は自動取得(必要に応じて環境変数で上書き可能)。 """ import os import re import sys import urllib.request try: import boto3 except ImportError: print("pip3 install boto3 を実行してください", file=sys.stderr) sys.exit(1) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DOMAINS_FILE = os.environ.get("DOMAINS_FILE", os.path.join(SCRIPT_DIR, "..", "domains.txt")) SELECTOR = "mail" PRIMARY = "hetzner.yyamashita.com" ZONE_DOMAIN = "yyamashita.com" # hetzner.yyamashita.com への CNAME を設定するサービスサブドメイン WEB_SUBDOMAINS = [ "golive", "whoisband", "code", "git", "hetzner-admin", "blog", "vpn", ] def get_server_ip(): if ip := os.environ.get("SERVER_IP", ""): return ip for url in ["https://api.ipify.org", "https://ifconfig.me/ip"]: try: with urllib.request.urlopen(url, timeout=5) as r: return r.read().decode().strip() except Exception: continue return "" def get_hosted_zone_id(client): if zone_id := os.environ.get("HOSTED_ZONE_ID", ""): return zone_id paginator = client.get_paginator("list_hosted_zones") for page in paginator.paginate(): for zone in page["HostedZones"]: if zone["Name"].rstrip(".") == ZONE_DOMAIN: return zone["Id"].split("/")[-1] return "" def read_domains(path): domains = [] with open(path) as f: for line in f: line = line.strip() if line and not line.startswith("#"): domains.append(line) return domains def read_dkim_pubkey(path): with open(path) as f: content = f.read() parts = re.findall(r'"([^"]*)"', content) return "".join(parts) def a_record(fqdn, ip): name = fqdn if fqdn.endswith(".") else fqdn + "." return { "Action": "UPSERT", "ResourceRecordSet": { "Name": name, "Type": "A", "TTL": 300, "ResourceRecords": [{"Value": ip}], }, } def cname_record(fqdn, target): name = fqdn if fqdn.endswith(".") else fqdn + "." target = target if target.endswith(".") else target + "." return { "Action": "UPSERT", "ResourceRecordSet": { "Name": name, "Type": "CNAME", "TTL": 300, "ResourceRecords": [{"Value": target}], }, } def txt_record(fqdn, raw_value): chunks = [raw_value[i:i + 255] for i in range(0, len(raw_value), 255)] txt_value = " ".join(f'"{c}"' for c in chunks) name = fqdn if fqdn.endswith(".") else fqdn + "." return { "Action": "UPSERT", "ResourceRecordSet": { "Name": name, "Type": "TXT", "TTL": 300, "ResourceRecords": [{"Value": txt_value}], }, } def main(): client = boto3.client("route53") zone_id = get_hosted_zone_id(client) if not zone_id: print(f"ERROR: {ZONE_DOMAIN} のホストゾーンが見つかりません", file=sys.stderr) print(" export HOSTED_ZONE_ID=Z... で手動指定してください", file=sys.stderr) sys.exit(1) server_ip = get_server_ip() if not server_ip: print("ERROR: SERVER_IP を自動取得できませんでした", file=sys.stderr) print(" export SERVER_IP=", file=sys.stderr) sys.exit(1) dkim_key_file = "/etc/opendkim/keys/mail.txt" try: dkim_value = read_dkim_pubkey(dkim_key_file) except FileNotFoundError: print(f"ERROR: DKIM 公開鍵ファイルが見つかりません: {dkim_key_file}", file=sys.stderr) print("先に mail/install.sh を実行してください", file=sys.stderr) sys.exit(1) service_domains = read_domains(DOMAINS_FILE) print(f"サーバー IP : {server_ip}") print(f"ホストゾーン: {zone_id} ({ZONE_DOMAIN})") print(f"ドメイン : {[PRIMARY] + service_domains}") print() changes = [] # プライマリドメイン: A レコード changes.append(a_record(PRIMARY, server_ip)) # Web サービス: CNAME → hetzner.yyamashita.com for sub in WEB_SUBDOMAINS: changes.append(cname_record(f"{sub}.{ZONE_DOMAIN}", PRIMARY)) # プライマリドメイン: メール認証 changes.append(txt_record(PRIMARY, f"v=spf1 ip4:{server_ip} -all")) changes.append(txt_record(f"{SELECTOR}._domainkey.{PRIMARY}", dkim_value)) changes.append(txt_record(f"_dmarc.{PRIMARY}", "v=DMARC1; p=none")) # サービスドメイン: メール認証 (include SPF) for domain in service_domains: changes.append(txt_record(domain, f"v=spf1 include:{PRIMARY} -all")) changes.append(txt_record(f"{SELECTOR}._domainkey.{domain}", dkim_value)) changes.append(txt_record(f"_dmarc.{domain}", "v=DMARC1; p=none")) print(f"{len(changes)} 件の DNS レコードを個別に UPSERT します...") ok, skipped = 0, 0 for c in changes: rrs = c["ResourceRecordSet"] name = rrs["Name"] rtype = rrs["Type"] try: client.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ "Comment": "DNS records via hetzner-infra", "Changes": [c], }, ) print(f" OK {rtype} {name}") ok += 1 except client.exceptions.InvalidChangeBatch as e: msg = str(e) if "already exists" in msg or "conflicting" in msg: print(f" SKIP {rtype} {name} (競合または既存レコード)") skipped += 1 else: raise print() print(f"完了: {ok} 件成功 / {skipped} 件スキップ") print() print("確認コマンド (数分後):") print(f" dig A {PRIMARY} +short") for sub in WEB_SUBDOMAINS: print(f" dig CNAME {sub}.{ZONE_DOMAIN} +short") for domain in [PRIMARY] + service_domains: print(f" dig TXT {SELECTOR}._domainkey.{domain} +short") if __name__ == "__main__": main()