diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-01 21:14:58 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-01 21:14:58 +0900 |
| commit | c55ae2d5daf229b12556d6283ba4e5e8161a5eb5 (patch) | |
| tree | adb08f555e56ece4792421dfdb40b457c8f75140 /mail | |
| parent | 5bf46bbac5feda06ec10575c6293974d80b2e3df (diff) | |
Add Postfix+OpenDKIM MTA with txt-based domain management
- mail/ ディレクトリ追加: Postfix + OpenDKIM の Docker コンテナ
- mail/domains.txt で送信ドメインを管理(現在: golive.yyamashita.com)
- install.sh が domains.txt から KeyTable/SigningTable を動的生成
- setup-route53.py で SPF/DKIM/DMARC を Route 53 に一括設定
- post-receive フックに mail/deploy.sh を追加
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'mail')
| -rw-r--r-- | mail/Dockerfile | 18 | ||||
| -rwxr-xr-x | mail/deploy.sh | 5 | ||||
| -rwxr-xr-x | mail/dns/setup-route53.py | 144 | ||||
| -rw-r--r-- | mail/docker-compose.yml | 19 | ||||
| -rw-r--r-- | mail/domains.txt | 3 | ||||
| -rwxr-xr-x | mail/entrypoint.sh | 32 | ||||
| -rwxr-xr-x | mail/install.sh | 87 | ||||
| -rw-r--r-- | mail/opendkim/opendkim.conf | 17 | ||||
| -rw-r--r-- | mail/postfix/main.cf | 34 |
9 files changed, 359 insertions, 0 deletions
diff --git a/mail/Dockerfile b/mail/Dockerfile new file mode 100644 index 0000000..f35efcf --- /dev/null +++ b/mail/Dockerfile @@ -0,0 +1,18 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + postfix \ + opendkim \ + opendkim-tools \ + rsyslog \ + && rm -rf /var/lib/apt/lists/* + +COPY postfix/main.cf /etc/postfix/main.cf +COPY opendkim/opendkim.conf /etc/opendkim.conf + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 25 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/mail/deploy.sh b/mail/deploy.sh new file mode 100755 index 0000000..6d00560 --- /dev/null +++ b/mail/deploy.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +docker network create web 2>/dev/null || true +docker compose up -d --build 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() diff --git a/mail/docker-compose.yml b/mail/docker-compose.yml new file mode 100644 index 0000000..bcc6c89 --- /dev/null +++ b/mail/docker-compose.yml @@ -0,0 +1,19 @@ +services: + mail: + build: . + container_name: mail + hostname: hetzner.yyamashita.com + volumes: + - /etc/opendkim:/etc/opendkim:ro + - mail_queue:/var/spool/postfix + restart: unless-stopped + networks: + - web + +networks: + web: + external: true + name: web + +volumes: + mail_queue: diff --git a/mail/domains.txt b/mail/domains.txt new file mode 100644 index 0000000..40fe38f --- /dev/null +++ b/mail/domains.txt @@ -0,0 +1,3 @@ +# メール送信ドメイン(1行1ドメイン、# はコメント) +# 変更後は push → ssh root@localhost 'bash /app/infra/mail/install.sh' を手動実行 +golive.yyamashita.com diff --git a/mail/entrypoint.sh b/mail/entrypoint.sh new file mode 100755 index 0000000..2dc8f26 --- /dev/null +++ b/mail/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -e + +KEY_FILE=/etc/opendkim/keys/mail.private + +# Wait for DKIM key (mounted from host) +for i in $(seq 1 30); do + [[ -f "$KEY_FILE" ]] && break + echo "Waiting for DKIM key at $KEY_FILE ... (run mail/install.sh on host)" + sleep 2 +done +if [[ ! -f "$KEY_FILE" ]]; then + echo "ERROR: DKIM key not found at $KEY_FILE" >&2 + exit 1 +fi + +chmod 600 "$KEY_FILE" +chown opendkim:opendkim "$KEY_FILE" + +# Start rsyslog for Postfix logging +rsyslogd + +# Start OpenDKIM (inet socket on localhost:8891) +opendkim -x /etc/opendkim.conf +sleep 2 + +# Start Postfix +postfix start + +# Keep container alive, forward mail log to stdout +touch /var/log/mail.log +exec tail -f /var/log/mail.log diff --git a/mail/install.sh b/mail/install.sh new file mode 100755 index 0000000..a913938 --- /dev/null +++ b/mail/install.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# サーバー上で root として手動実行する +# domains.txt からドメインを読み取り、DKIM 鍵生成と OpenDKIM テーブルを生成して +# コンテナを起動する。ドメインを追加・削除するたびに再実行する。 +set -euo pipefail + +MAIL_DIR="$(cd "$(dirname "$0")" && pwd)" +DOMAINS_FILE="$MAIL_DIR/domains.txt" +OPENDKIM_DIR=/etc/opendkim +KEY_DIR="$OPENDKIM_DIR/keys" +SELECTOR=mail +PRIMARY=hetzner.yyamashita.com + +if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: root として実行してください" >&2 + exit 1 +fi + +# domains.txt から有効なドメインを読み込む +mapfile -t SERVICE_DOMAINS < <(grep -vE '^\s*(#|$)' "$DOMAINS_FILE") + +echo "=== 対象ドメイン ===" +for d in "${SERVICE_DOMAINS[@]}"; do echo " $d"; done +echo " $PRIMARY (プライマリ)" + +# DKIM 鍵生成(初回のみ) +echo "" +echo "=== DKIM 鍵 ===" +mkdir -p "$KEY_DIR" +chmod 700 "$KEY_DIR" + +if [[ -f "$KEY_DIR/${SELECTOR}.private" ]]; then + echo " 既存の鍵を使用: $KEY_DIR/${SELECTOR}.private" +else + echo " 2048bit RSA 鍵を生成中..." + docker run --rm -v "${KEY_DIR}:/keys" debian:bookworm-slim bash -c " + apt-get update -qq && apt-get install -y -qq opendkim-tools 2>/dev/null + cd /keys && opendkim-genkey -b 2048 -s ${SELECTOR} -d yyamashita.com + chmod 600 /keys/${SELECTOR}.private + " + echo " 生成完了" +fi + +# OpenDKIM テーブル生成 +echo "" +echo "=== OpenDKIM テーブル生成 ===" +mkdir -p "$OPENDKIM_DIR" + +{ + for d in "${SERVICE_DOMAINS[@]}"; do + echo "${SELECTOR}._domainkey.${d} ${d}:${SELECTOR}:${KEY_DIR}/${SELECTOR}.private" + done + echo "${SELECTOR}._domainkey.${PRIMARY} ${PRIMARY}:${SELECTOR}:${KEY_DIR}/${SELECTOR}.private" +} > "$OPENDKIM_DIR/KeyTable" +echo " KeyTable : $OPENDKIM_DIR/KeyTable" + +{ + for d in "${SERVICE_DOMAINS[@]}"; do + echo "*@${d} ${SELECTOR}._domainkey.${d}" + done + echo "*@${PRIMARY} ${SELECTOR}._domainkey.${PRIMARY}" +} > "$OPENDKIM_DIR/SigningTable" +echo " SigningTable: $OPENDKIM_DIR/SigningTable" + +cat > "$OPENDKIM_DIR/TrustedHosts" << 'EOF' +127.0.0.1 +::1 +172.16.0.0/12 +10.0.0.0/8 +EOF +echo " TrustedHosts: $OPENDKIM_DIR/TrustedHosts" + +# コンテナ起動 +echo "" +echo "=== コンテナ起動 ===" +bash "$MAIL_DIR/deploy.sh" + +# 公開鍵を表示 +echo "" +echo "=== DKIM 公開鍵 (Route 53 各ドメインに設定) ===" +echo "セレクター: ${SELECTOR}._domainkey.<ドメイン>" +echo "---" +cat "$KEY_DIR/${SELECTOR}.txt" +echo "---" +echo "" +echo "Done." +echo "次: python3 $MAIL_DIR/dns/setup-route53.py" diff --git a/mail/opendkim/opendkim.conf b/mail/opendkim/opendkim.conf new file mode 100644 index 0000000..81335f4 --- /dev/null +++ b/mail/opendkim/opendkim.conf @@ -0,0 +1,17 @@ +Syslog yes +SyslogSuccess yes +LogWhy yes + +Canonicalization relaxed/simple +Mode sv +OversignHeaders From + +UserID opendkim +UMask 022 +Socket inet:8891@localhost +PidFile /var/run/opendkim/opendkim.pid + +KeyTable /etc/opendkim/KeyTable +SigningTable refile:/etc/opendkim/SigningTable +ExternalIgnoreList /etc/opendkim/TrustedHosts +InternalHosts /etc/opendkim/TrustedHosts diff --git a/mail/postfix/main.cf b/mail/postfix/main.cf new file mode 100644 index 0000000..4098668 --- /dev/null +++ b/mail/postfix/main.cf @@ -0,0 +1,34 @@ +# Identity +myhostname = hetzner.yyamashita.com +mydomain = yyamashita.com +myorigin = $myhostname +append_dot_mydomain = no +compatibility_level = 3.6 + +# Accept connections from Docker internal networks only +inet_interfaces = all +inet_protocols = ipv4 +mynetworks = 127.0.0.0/8, 172.16.0.0/12, 10.0.0.0/8 + +# No local delivery — relay only +mydestination = +local_recipient_maps = +local_transport = error:local delivery disabled + +# Relay restrictions: only forward mail from internal networks +smtpd_relay_restrictions = permit_mynetworks, reject +smtpd_recipient_restrictions = permit_mynetworks, reject + +# Opportunistic TLS on outbound SMTP +smtp_tls_security_level = may +smtp_tls_loglevel = 1 + +# DKIM signing via OpenDKIM milter on localhost:8891 +milter_default_action = accept +milter_protocol = 6 +smtpd_milters = inet:localhost:8891 +non_smtpd_milters = inet:localhost:8891 + +# Queue timeouts +maximal_queue_lifetime = 1d +bounce_queue_lifetime = 1d |
