通知メールを出すSaaS向けのSPFを追加していたら、ルックアップ回数制限の上限に達してしまいました。将来的にはSPFフラット化対応するためのSaaSとかを検討すべきかとは思いますが、今すぐSPFフラット化する方法をご紹介。
全体の流れ
- 事前にSPFlatten.pyの実行環境をセットアップ
- _spf.mydomain.comにフラット化前のSPFを設定し、
フラット化された値はmydomain.comに設定 - 定期的にmydomain.comに設定されたフラット化済みのSPFと
_spf.mydoamin.comのフラット化した結果を比較
もし変更があれば、SPF設定を更新します。
SPFフラット化スクリプト
SPFフラット化には、こちらのPythonスクリプトを使いました。
https://github.com/0x9090/SPFlatten
ただこちらのコードではフラット化されたIPアドレスが毎回違った順番で出力されて、文字列の比較と相性がわるかったため、結果がソートされるこちらを使いました。
https://github.com/0x9090/SPFlatten/blob/e5361df9b23ab3ad05d2d5e46079c6a9d8599170/SPFlatten.py
SPFlatten.pyの実行環境セットアップ
専用のディレクトリを作り、SPFlatten.pyを保存。
次に仮想環境を作って、依存モジュールをインストール
$ python3 -m venv venv
$ source ./venv/bin/activate
$ pip install dnspython
ファイル構成
SPFlatten/
├── checkspf.sh
├── formatspf.sh
├── SPFlatten.py
└── venv
SPF設定チェックスクリプト
mydomain.comに設定されたフラット化済みのSPFとスクリプト実行時にフラット化された_spf.mydoamin.comのSPFを比較するスクリプトcheckspf.shを作りました。
#!/usr/bin/env bash
# This script checks for changes in SPF records and sends notifications.
# It's designed to be run manually on Linux bash or from cron
# and also manually on macOS (zsh),
# then send report/notification via webhook.
# --- Script Environment ---
# Set a reliable PATH for cron jobs.
export PATH="/usr/local/bin:/usr/bin:/bin"
# Set locale to C for consistent sort order.
export LC_ALL=C
# Exit on error, undefined variable, or pipe failure.
set -euo pipefail
# --- Configuration ---
# Webhook URLs for Google Chat
ALERT_URL="<your_webhook_URL>"
STATUS_URL="<your_webhook_URL>"
# Domain to check and the host with the unflattened SPF record
MYDOMAIN='yourdomain.com'
SPFHOST='_spf.yourdomain.com'
# --- Script Setup ---
# Create temporary files for diff output and JSON payload.
# These will be cleaned up automatically on script exit.
DIFF_FILE=$(mktemp)
JSON_FILE=$(mktemp)
trap 'rm -f "${DIFF_FILE}" "${JSON_FILE}"' EXIT
# Get the absolute path of the script's directory and cd into it.
# This ensures that all paths are relative to the script's location.
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "${SCRIPT_DIR}"
# --- Paths to Executables ---
# Define python executable and script paths (relative to SCRIPT_DIR).
PYTHON_EXEC="./venv/bin/python3"
SPFLATTEN_PY="./SPFlatten.py"
# --- Helper Functions ---
show_help() {
cat << EOF
Usage: $(basename "$0") [options]
This script checks for changes in SPF records.
Options:
-r, --run Run the SPF check and send notifications on changes.
-t, --test, --dry-run Perform a dry run. It fetches and compares SPF records
but does not send notifications.
-x Enable debug mode for verbose output. Must be used with -r or -t.
For example : ./checkspf.sh -x -t
-h, --help Display this help message and exit.
EOF
}
# --- Argument Parsing ---
RUN_MODE=0
DRY_RUN=0
DEBUG=0
if [ $# -eq 0 ]; then
show_help
exit 0
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-r|--run)
RUN_MODE=1
shift
;;
-t|--test|--dry-run)
DRY_RUN=1
shift
;;
-x)
DEBUG=1
# set -x # Enable shell debugging
shift
;;
-h|--help)
show_help
exit 0
;;
*)
echo "Error: Unknown option '$1'"
show_help
exit 1
;;
esac
done
# A mode must be selected
if [ "${RUN_MODE}" -eq 0 ] && [ "${DRY_RUN}" -eq 0 ]; then
echo "Error: You must specify an operation, either --run or --test."
show_help
exit 1
fi
# --- Helper Functions ---
# Sends a JSON payload to a URL via curl, respecting DRY_RUN.
# It provides explicit error handling for the curl command.
# Usage: send_notification "json_payload_file" "url" "channel_name"
send_notification() {
local json_file="$1"
local url="$2"
local channel_name="$3"
if [ "${DRY_RUN}" -eq 1 ]; then
# In dry-run mode, do not send notifications.
# The main body of the script will print the diff output.
return
fi
if [ "${RUN_MODE}" -eq 1 ]; then
echo "Attempting to send notification to ${channel_name}..."
# The pipe's exit code is curl's exit code.
if curl -sSf -H 'Content-Type: application/json' --data-binary "@${json_file}" "${url}"; then
echo "Notification sent successfully to ${channel_name}."
else
local exit_code=$?
# set -e is active, but the 'if' statement prevents immediate exit.
# This allows for a custom error message before exiting.
echo "Error: Failed to send notification to ${channel_name}. curl exited with code ${exit_code}." >&2
exit "${exit_code}"
fi
fi
}
# Processes a raw SPF string into a sorted, newline-separated list of mechanisms.
# Usage: process_spf "spf_string"
process_spf() {
# Filters for v=, include:, and ~all/~all mechanisms.
echo "$1" | tr ' ' '\n' | grep '^[vi~]' | sort
}
# --- Sanity Checks ---
if [ ! -x "${PYTHON_EXEC}" ]; then
echo "Error: Python executable not found or not executable at ${PYTHON_EXEC}" >&2
exit 1
fi
if [ ! -f "${SPFLATTEN_PY}" ]; then
echo "Error: SPFlatten.py not found at ${SPFLATTEN_PY}" >&2
exit 1
fi
# --- Main Execution ---
echo "--- Running checkspf.sh at $(date) ---"
# Get the current and new SPF values by executing the Python script.
CURRENT_SPF=$("${PYTHON_EXEC}" "${SPFLATTEN_PY}" "${MYDOMAIN}")
NEW_SPF=$("${PYTHON_EXEC}" "${SPFLATTEN_PY}" "${SPFHOST}")
# --- Value Checks ---
if [ -z "${CURRENT_SPF}" ]; then
echo "Error: Failed to get current SPF for ${MYDOMAIN}. The command returned an empty string." >&2
exit 1
fi
if [ -z "${NEW_SPF}" ]; then
echo "Error: Failed to get new SPF for ${SPFHOST}. The command returned an empty string." >&2
exit 1
fi
# Compare the processed SPF lists and capture the differences into a file.
# '|| true' prevents the script from exiting if grep finds no matches.
diff -y -W 46 <(process_spf "${CURRENT_SPF}") <(process_spf "${NEW_SPF}") | grep -v "${MYDOMAIN}" | grep --color=never '[<|>]' > "${DIFF_FILE}" || true
# --- Reporting ---
# Check if the diff file has content.
if [ -s "${DIFF_FILE}" ]; then
# SPF records have changed.
# Use Python to correctly format the JSON payload, escaping special characters.
"${PYTHON_EXEC}" -c 'import json
import sys
domain = sys.argv[1]
diff_file = sys.argv[2]
json_file = sys.argv[3]
with open(diff_file, "r") as f:
diff = f.read()
payload = {"text": f"""```
SPF values changed for {domain}
{diff}
```"""}
with open(json_file, "w") as f:
json.dump(payload, f)' "${MYDOMAIN}" "${DIFF_FILE}" "${JSON_FILE}"
if [ "${DRY_RUN}" -eq 1 ]; then
echo "SPF values changed for ${MYDOMAIN} (Dry Run):"
cat "${DIFF_FILE}"
else
echo "SPF values changed. Sending notifications."
send_notification "${JSON_FILE}" "${ALERT_URL}" "Alert"
send_notification "${JSON_FILE}" "${STATUS_URL}" "Update Status"
fi
else
# No changes detected.
# Use Python to correctly format the JSON payload.
"${PYTHON_EXEC}" -c 'import json
import sys
domain = sys.argv[1]
json_file = sys.argv[2]
payload = {"text": f"""```
SPF values for {domain} are unchanged.
```"""}
with open(json_file, "w") as f:
json.dump(payload, f)' "${MYDOMAIN}" "${JSON_FILE}"
if [ "${DRY_RUN}" -eq 1 ]; then
echo "SPF values for ${MYDOMAIN} are unchanged. (Dry Run)"
else
echo "SPF values are unchanged. Sending status update."
send_notification "${JSON_FILE}" "${STATUS_URL}" "Update Status"
fi
fi
if [ "${DEBUG}" -eq 1 ]; then
set +x # Disable shell debugging
fi
echo "--- checkspf.sh finished at $(date) ---"
こちらを実行すると、SPFのチェックのみ実行し、通知は送信しません。
$ ./checkspf.sh --test
こちらを実行すると、結果をチャットに通知します。
$ ./checkspf.sh --run
手動確認は面倒なので、cronで自動実行するようにしました。
Value Domain用の設定
フラット化された値をValue domain用に手作業で整形は面倒なので、こちらを使っています。
Google Workspaceを利用しているので、GoogleのSPFだけはフラット化しないようにしました。
#!/bin/sh
# Set domain, and unflattened SPF host
SPFHOST='_spf.mydomain.com'
# Get the absolute path of the script's directory
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
${SCRIPT_DIR}/venv/bin/python3 "${SCRIPT_DIR}/SPFlatten.py" "${SPFHOST}" \
| grep ^v=spf \
| sed 's/v=spf1/txt @ v=spf1 include:_spf.google.com/'
まとめ
これでSPFフラット化の暫定処置は完了。
とはいっても手作業での作業が一部残ってしまっているので、早めに本対応が必要ですね。