diff options
Diffstat (limited to 'mail/dns/setup-route53.py')
| -rwxr-xr-x | mail/dns/setup-route53.py | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/mail/dns/setup-route53.py b/mail/dns/setup-route53.py new file mode 100755 index 0000000..a3c69a4 --- /dev/null +++ b/mail/dns/setup-route53.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Route 53 DNS 設定スクリプト: SPF / DKIM / DMARC + +使い方(サーバー上で実行): + pip3 install boto3 + export AWS_ACCESS_KEY_ID=... + export AWS_SECRET_ACCESS_KEY=... + export HOSTED_ZONE_ID=Z... # Route 53 ホストゾーン ID (yyamashita.com) + export SERVER_IP=<HetznerサーバーIP> # 自動検出不可の場合のみ + python3 /app/infra/mail/dns/setup-route53.py + +domains.txt に記載されたサービスドメインと hetzner.yyamashita.com に対して +SPF / DKIM / DMARC TXT レコードを UPSERT する。 +""" + +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")) +HOSTED_ZONE_ID = os.environ.get("HOSTED_ZONE_ID", "") +AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") +SELECTOR = "mail" +PRIMARY = "hetzner.yyamashita.com" + + +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 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 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(): + if not HOSTED_ZONE_ID: + print("ERROR: HOSTED_ZONE_ID が未設定です", 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=<Hetznerサーバー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"ホストゾーン: {HOSTED_ZONE_ID}") + print(f"ドメイン : {[PRIMARY] + service_domains}") + print() + + changes = [] + + # プライマリドメイン (IP 直指定 SPF) + 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 します...") + for c in changes: + rrs = c["ResourceRecordSet"] + print(f" {rrs['Type']} {rrs['Name']}") + + client = boto3.client("route53", region_name=AWS_REGION) + resp = client.change_resource_record_sets( + HostedZoneId=HOSTED_ZONE_ID, + ChangeBatch={ + "Comment": "Mail auth records (SPF/DKIM/DMARC) via hetzner-infra", + "Changes": changes, + }, + ) + info = resp["ChangeInfo"] + print() + print(f"Change ID : {info['Id']}") + print(f"Status : {info['Status']}") + print() + print("確認コマンド (数分後):") + for domain in [PRIMARY] + service_domains: + print(f" dig TXT {SELECTOR}._domainkey.{domain} +short") + + +if __name__ == "__main__": + main() |
