summaryrefslogtreecommitdiff
path: root/mail/dns/setup-route53.py
diff options
context:
space:
mode:
Diffstat (limited to 'mail/dns/setup-route53.py')
-rwxr-xr-xmail/dns/setup-route53.py144
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()