summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-01 21:14:58 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-01 21:14:58 +0900
commitc55ae2d5daf229b12556d6283ba4e5e8161a5eb5 (patch)
treeadb08f555e56ece4792421dfdb40b457c8f75140
parent5bf46bbac5feda06ec10575c6293974d80b2e3df (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>
-rw-r--r--CLAUDE.md21
-rwxr-xr-xgit/hooks/hetzner-infra/post-receive1
-rw-r--r--mail/Dockerfile18
-rwxr-xr-xmail/deploy.sh5
-rwxr-xr-xmail/dns/setup-route53.py144
-rw-r--r--mail/docker-compose.yml19
-rw-r--r--mail/domains.txt3
-rwxr-xr-xmail/entrypoint.sh32
-rwxr-xr-xmail/install.sh87
-rw-r--r--mail/opendkim/opendkim.conf17
-rw-r--r--mail/postfix/main.cf34
11 files changed, 380 insertions, 1 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 3373a9c..28124bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,16 +9,18 @@ caddy/ リバースプロキシ(Caddyfile, docker-compose.yml, deploy.sh)
cgit/ cgit Web UI(Dockerfile, lighttpd.conf, cgitrc, deploy.sh)
git/ ベアリポジトリ・フック管理(repos.txt, hooks/, install.sh)
claude/ Claude Code セッション管理(sessions.txt, systemd unit, sync.sh)
+mail/ MTA(Postfix + OpenDKIM, docker-compose.yml, install.sh, dns/setup-route53.py)
server/ サーバー共通設定(authorized_keys, requirements.md)
```
## デプロイの仕組み
-- `git push origin master` → post-receive フック → `caddy/deploy.sh` と `cgit/deploy.sh` を実行
+- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` を実行
- cgit の `lighttpd.conf` / `Dockerfile` 変更はイメージ再ビルドが必要(`deploy.sh` で `--build` するため push だけで反映される)
- cgit の `cgitrc` はボリュームマウントのため push だけで反映される
- フック・リポジトリの変更は push 後に `bash /app/infra/git/install.sh` を手動実行
- Claude セッションの変更は push 後に `bash /app/infra/claude/sync.sh` を手動実行
+- mail ドメイン変更(`mail/domains.txt`)は push 後に `bash /app/infra/mail/install.sh` を手動実行
## 管理対象リポジトリ
@@ -54,4 +56,21 @@ ssh root@localhost 'bash /app/infra/claude/sync.sh'
vi server/authorized_keys
git push origin master
ssh root@localhost 'bash /app/infra/git/install.sh'
+
+# MTA(mail)初回セットアップ(サーバー上で root として実行)
+ssh root@<server> 'bash /app/infra/mail/install.sh'
+# → DKIM 鍵生成 + コンテナ起動。公開鍵が表示される。
+
+# Route 53 DNS 設定(サーバー上で実行)
+ssh root@<server>
+export AWS_ACCESS_KEY_ID=...
+export AWS_SECRET_ACCESS_KEY=...
+export HOSTED_ZONE_ID=Z...
+export SERVER_IP=<HetznerサーバーのパブリックIP>
+python3 /app/infra/mail/dns/setup-route53.py
+
+# mail ドメイン追加(golive → whoisband も追加する例)
+vi mail/domains.txt
+git push origin master
+ssh root@localhost 'bash /app/infra/mail/install.sh' # テーブル再生成 + 再起動
```
diff --git a/git/hooks/hetzner-infra/post-receive b/git/hooks/hetzner-infra/post-receive
index 9d57ad5..3448b17 100755
--- a/git/hooks/hetzner-infra/post-receive
+++ b/git/hooks/hetzner-infra/post-receive
@@ -3,4 +3,5 @@ set -e
GIT_WORK_TREE=/app/infra git checkout -f
bash /app/infra/caddy/deploy.sh
bash /app/infra/cgit/deploy.sh
+bash /app/infra/mail/deploy.sh
echo "Deploy complete: hetzner-infra"
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