0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SPFフラット化と変更チェックスクリプト

Last updated at Posted at 2025-08-07

通知メールを出すSaaS向けのSPFを追加していたら、ルックアップ回数制限の上限に達してしまいました。将来的にはSPFフラット化対応するためのSaaSとかを検討すべきかとは思いますが、今すぐSPFフラット化する方法をご紹介。

全体の流れ

  1. 事前にSPFlatten.pyの実行環境をセットアップ
  2. _spf.mydomain.comにフラット化前のSPFを設定し、
    フラット化された値はmydomain.comに設定
  3. 定期的に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を作りました。

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だけはフラット化しないようにしました。

formatspf.sh
#!/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フラット化の暫定処置は完了。

とはいっても手作業での作業が一部残ってしまっているので、早めに本対応が必要ですね。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?