Fail2banのログにUnbanがあるのに、CloudflareのIP Access ruleが残っていました。解除時の検索と同じ条件でGETを比較すると、-Gなしでは別のIPを含む4件、-Gありでは対象IPの1件が返りました。どちらもHTTP 200です。
この解除定義は検索結果の先頭IDをDELETEに使います。対象以外のIPも返ると、解除したいルールが残るだけでなく、別IPのルールを削除する可能性があります。後者は処理の構造から分かるリスクで、過去に実際の誤削除が起きたと確定したものではありません。
実機で欠けていた-Gを永続設定と稼働中5 jailへ追加し、既存BANを維持したうえでIPv4・IPv6の通常登録・解除を確認しました。
CloudflareとFail2banを連携し、「BAN一覧から消えたのにルールが残る」「HTTP 200なのに検索結果がおかしい」ときの切り分けをまとめます。対象IPの検索、設定の修正、連携先まで含めた解除確認を紹介します。
まず解除時の検索定義を確認する
配布ファイル中のGET検索に-Gまたは--getがあるかを確認します。これは定義の入口を確認するコマンドで、APIやBAN状態を変更しません。
grep -n -E 'actionunban|curl.*GET' /etc/fail2ban/action.d/cloudflare-token.conf
.localでの上書きやjailごとの指定があれば、そちらも確認します。.confだけの確認では稼働中の定義まで判断できません。認証情報を含む.local全体や、トークンが展開された稼働定義を、そのまま公開しないようにします。
確認した環境
2026年10月30日に確認した環境です。以下のサーバーログと操作時刻は日本時間(JST、UTC+09:00)で、CloudflareのUTC日時も日本時間へ換算して比較しています。
| 項目 | 確認結果 |
|---|---|
| OS | Ubuntu 26.04.1 LTS |
| Fail2banクライアント/稼働サーバー | ともに1.1.0 |
| Nginx | 1.28.3(Ubuntu) |
| UFW | 0.36.2 |
| 稼働jail数 | 7 |
| 使用アクション |
ufw、cloudflare-token
|
版情報は次のコマンドで確認しました。
. /etc/os-release
printf 'OS: %s\n' "$PRETTY_NAME"
fail2ban-client --version
sudo fail2ban-client version
nginx -v 2>&1
sudo ufw --version
件数は4件ずつでも、IPの内訳が違った
Fail2banとCloudflareを照合すると、対象jailのBANもCloudflareのルールも4 IPでした。しかし、jail注記・IP・blockという条件で対応させると、次の差がありました。
| 照合対象 | Fail2ban | Cloudflare | UFW |
|---|---|---|---|
| 共通するIPv4 3 IP | 登録あり | 登録あり | 登録あり |
| 解除されたIPv4のIP A | 登録なし | blockルールが残存 | 登録なし |
| 現在BAN中のIPv6のIP B | 登録あり | 当該jail注記のblockルールなし | 登録あり |
Cloudflareはページ情報と取得件数を確認して全件を読み取り、API取得の前後で対象jailのBAN一覧が変わっていないことを確認しました。UFWは別時刻に番号付き表示から照合し、IPv4 3件・IPv6 1件がFail2banと全件一致しました。
IP AのCloudflareルール作成日時は、日本時間で11:02:32でした。同じIPのログには11:02:30のRestore Banと15:08:02のUnbanがあり、15:28時点でもCloudflareルールが残っていました。
IPv6用のcftarget = ip6も実機に存在していました。件数だけでは、解除済みIPv4の残存とBAN中IPv6の不足が相殺されて見えます。IP単位で対応を確認する必要がありました。
解除するIPを検索したのに、別のIPも返っていた
実機のcloudflare-token.confと.localを読み、解除時の検索に-Gまたは--getがないことを確認しました。
そのうえで、残存しているIP Aを対象に、同じAPI・認証・検索条件を使い、検索条件の渡し方だけを変えました。DELETEは呼んでいません。
mode=block
notes=Fail2Ban <jail名>
configuration.target=ip
configuration.value=<IP A>
| GETの送り方 | HTTP | 返却ルール数 | 内容 |
|---|---|---|---|
-X GETと--data-urlencode、-Gなし |
200 | 4件 | 対象以外のIPを含む |
同じ指定に-Gを追加 |
200 | 1件 | 対象IPと一致 |
補足に掲載したBashコードでも、別の既存IPv4を指定して4件対1件を再確認しました。configuration.target=ipを指定したにもかかわらず、-Gなしの4件にはIPv6ルールも含まれていました。
HTTP 200は、検索条件どおりの結果を得られた保証ではありませんでした。 戻り値の件数とIPを照合することで、条件が効いていないことを確認できました。
-X GETは、データをURLへ移す指定ではない
curlの-X GETはHTTPメソッドを指定します。--data-urlencodeで渡すデータをURLのクエリへ付けるには、-G/--getを使います。curl公式マニュアルにも、その動作が説明されています。
論点だけを抜き出した構文比較です。認証などは省いています。
# GET本文へ条件を渡す形
curl -X GET "$API_URL" \
--data-urlencode "configuration.value=$IP"
# URLのクエリとして条件を渡す形
curl -G -X GET "$API_URL" \
--data-urlencode "configuration.value=$IP"
公式1.1.0のcloudflare-tokenには、-Gのない検索と、返されたIDの先頭を使う処理があります。別IPも返る状態では、対象IPのIDを取得できたと判断できません。
上流の修正は、2025年3月1日のコミットc9b5e845です。検索条件をURLへ付けるために-Gを追加しています。
-actionunban = id=$(curl -s -X GET "<_cf_api_url>" \
+actionunban = id=$(curl -s -G -X GET "<_cf_api_url>" \
対応するIssue #3952も公開されています。
修正を含む正式版とUbuntu配布版
2026年10月30日の確認では、上流の正式リリース1.1.1(2026年8月15日公開)が存在し、1.1.1タグの解除定義に-Gが含まれていました。修正は開発ブランチだけのものではありません。
一方、Ubuntu 26.04の公開パッケージ索引では1.1.0-9が掲載されていました。今回の実機では-Gがないことを確認しましたが、インストール済みパッケージの完全なリビジョンと、配布元パッチの対応までは記録していません。Ubuntu配布版全般について、バックポートの有無を断定しません。
更新を選ぶ場合は配布元の変更履歴と対象ファイルを確認します。上流1.1.1でも、既存.localに古いactionunbanを残すと上書きが続くため、版番号と有効な定義を分けて確認します。配布元の定義で修正を確認できたら、今回追加した.localのactionunban上書きだけを外し、認証情報などを残して設定検査・稼働反映・読み戻しを行います。本記事で実施したのは次節の局所修正で、パッケージ更新の実証ではありません。
解除時の検索を修正し、稼働中の定義にも反映した
今回の変更は、解除時の検索へ-Gを追加することに絞りました。配布元の.confへ直接書く代わりに、/etc/fail2ban/action.d/cloudflare-token.localの[Definition]でactionunbanを上書きしました。既存の[Init]にある認証情報や、他の設定は維持しています。
以下は上流修正後の解除定義です。既に[Definition]がある場合は、同じセクションへ統合し、actionunbanを重複させません。環境固有の変更がある場合は、元の解除処理全体を確認してから差分を適用します。
[Definition]
actionunban = id=$(curl -s -G -X GET "<_cf_api_url>" \
--data-urlencode "mode=<cfmode>" --data-urlencode "notes=<notes>" --data-urlencode "configuration.target=<cftarget>" --data-urlencode "configuration.value=<ip>" \
<_cf_api_prms> \
| awk -F"[,:}]" '{for(i=1;i<=NF;i++){if($i~/'id'\042/){print $(i+1)}}}' \
| tr -d ' "' \
| head -n 1)
if [ -z "$id" ]; then echo "<name>: id for <ip> cannot be found using target <cftarget>"; exit 0; fi; \
curl -s -X DELETE "<_cf_api_url>/$id" \
<_cf_api_prms> \
--data '{"cascade": "none"}'
永続設定を保存した後、設定検査を行いました。
sudo fail2ban-client -t
設定ファイルへの保存だけでは、稼働中のアクションが更新されたとは限りません。
対象jailに既存BANがなく、保守時間に新規検知を含めた影響を管理できる環境では、設定検査後に対象jailを再起動して読み直す経路があります。これは本件で実証した反映方法とは分けて示します。
JAIL='nginx-botsearch'
sudo fail2ban-client status "$JAIL"
# 現在BANが0件であることと、再起動の影響を確認してから実行
sudo fail2ban-client -t
sudo fail2ban-client reload --restart "$JAIL"
sudo fail2ban-client get "$JAIL" actions
アクション名の表示だけでは解除定義の内容までは確認できません。実際に有効になった検索指定と、その後の登録解除を確認します。今回の初回試験では復元完了と直後の解除を分離できなかったため、BAN中のIPがあるjailについて再起動前後のBAN保持を保証する手順としては示しません。
今回は既存BANがあるため、再起動やreload --restartを使わず、Fail2banのset <JAIL> action <ACT> <PROPERTY> <VALUE>で稼働中のactionunbanを更新しました。
稼働中の更新では、永続設定の保存・設定検査・対象定義の取得・-Gだけの追加・書込み・読み戻し・BAN一覧の前後比較を行うPythonスクリプトを実行しました。ローカルソケットのCSocket経由で、クライアントのset操作を送っています。再起動・reload・BAN追加・解除は呼んでいません。
この稼働反映は設定ファイルの保存とは別の操作です。内部APIに依存する実装の説明は補足へ分け、本編では実機で確認した結果を示します。
実機では設定検査が成功し、Cloudflareアクションを持つ5 jailすべてで読み戻しが一致しました。対象BAN一覧の前後一致もTrueでした。以後のプロセス再起動に備えた永続設定と、現在動いている定義の両方を更新した形です。
IPv4・IPv6の登録と解除を、両側で確認した
2026年10月30日17:09(日本時間)の追加試験では、修正後のnginx-botsearchで、テスト用IPv4 192.0.2.1とIPv6 2001:db8::1を1件ずつ試しました。開始前に、両IPが全jail・Cloudflare・UFWへ登録されていないことを確認しています。
まず、未登録を確認したテストIPだけをBANします。次はIPv4の例です。IPv6はIPを2001:db8::1へ替え、同じ段階を確認しました。
JAIL='nginx-botsearch'
IP='192.0.2.1'
sudo fail2ban-client set "$JAIL" banip "$IP"
sudo fail2ban-client get "$JAIL" banip
sudo env LC_ALL=C ufw status numbered
補足のGET比較例の-Gあり側などでCloudflareも取得し、対象IPのルールが1件、target・block・jail注記が一致することを確認します。Fail2ban・Cloudflare・UFWの三者の登録がそろってから、解除します。
sudo fail2ban-client set "$JAIL" unbanip "$IP"
sudo fail2ban-client get "$JAIL" banip
sudo env LC_ALL=C ufw status numbered
解除後もCloudflareをGETし、Fail2ban一覧から消え、Cloudflare・UFWともそのIPのルールが0件になることを確認します。APIの取得失敗を0件として扱わず、後片付けに失敗した場合は次の試験へ進みません。
実際の試験はPythonから操作し、各段階で最大8回・2秒間隔で照合しました。取得エラーは「ルールなし」と区別し、登録確認に失敗しても、そのテストIPの後片付けを行う構成です。jailの再起動は挟んでいません。
| テストIP | 登録後のFail2ban | Cloudflare/UFW | 解除後のFail2ban | Cloudflare/UFW | 他IPのルール・BAN一覧 |
|---|---|---|---|---|---|
192.0.2.1 |
登録あり | 1件/1件 | 登録なし | 0件/0件 | 試験前と一致 |
2001:db8::1 |
登録あり | 1件/1件 | 登録なし | 0件/0件 | 試験前と一致 |
両方とも登録・解除が成功し、テストルールは残っていません。他IPのCloudflareルールは試験前に4件あり、両試験の照合で件数・ID・属性が一致しました。他IPのUFWルールと全jailのBAN一覧も試験前と一致しています。この試験では他IPへの差分がないことまで確認して、修正後の通常登録・解除が成立したと判断しました。
確認範囲
今回確認したのは、GETによる検索不良、永続設定と稼働中5 jailの修正、1 jailでのIPv4・IPv6の通常登録解除と他IPへの差分がないこと、既存差分2件の是正です。実通信での遮断応答、自然な期限切れによる解除、API障害時、5 jail全件での個別試験、将来のプロセス再起動後の読込みは確認していません。
| 未確認事項 | 今回の扱い |
|---|---|
| 別IPの誤削除とIPv6ルール不足の履歴上の原因 | DELETE要求・応答や削除イベントで裏付けていないため未確定 |
| 最初の再起動試験でUFWにも残った理由 | 未解明。CloudflareのGET検索不良では説明できない |
| 復元処理と即時解除の競合 | 仮説にとどまる。修正後の再起動直後の解除は再試験していない |
最初の試験では、復元完了を確認せずに解除したため、操作の影響を分離できていません。今回確認した通常の登録解除の成功を、再起動直後の動作にも広げて解釈しません。
解除連携を見るときは、Unbanログや件数だけでなく、検索が対象IPに絞れているか、削除後にそのルールがなくなったかまで追います。今回の差分はcurlの-G一つでしたが、完了の判断にはFail2ban・Cloudflare・UFWの三者を照合する必要がありました。
補足
GETだけで検索条件を比較するコード(IPv4用)
以下の掲載コードを、2026年10月30日に実機で実行しました。既存のIPv4ルールを指定すると、-GなしはHTTP 200で4件中1件だけが対象IPと一致し、IPv6ルールも含まれていました。-GありはHTTP 200で返却・総件数とも1件、対象IPと一致しました。Pythonからcurlを呼び出した初回比較と同じ結果を確認しています。
既に存在するルールのIPを指定します。トークンは端末から非表示で入力し、curl設定を標準入力へ渡します。トークンをコマンド履歴やcurlの引数へ直接書きません。
bash <<'BASH'
set +x
set -euo pipefail
command -v curl >/dev/null
command -v jq >/dev/null
read -r -s -p 'Cloudflare API token: ' CF_TOKEN </dev/tty
printf '\n' >/dev/tty
read -r -p 'Zone ID: ' CF_ZONE </dev/tty
read -r -p 'Jail名: ' JAIL </dev/tty
read -r -p '照合する既存IP: ' IP </dev/tty
[[ "$CF_TOKEN" =~ ^[A-Za-z0-9_-]+$ ]] || exit 1
[[ "$CF_ZONE" =~ ^[a-fA-F0-9]{32}$ ]] || exit 1
[[ -n "$JAIL" && -n "$IP" ]] || exit 1
URL="https://api.cloudflare.com/client/v4/zones/$CF_ZONE/firewall/access_rules/rules"
FILTERS=(
--data-urlencode 'mode=block'
--data-urlencode "notes=Fail2Ban $JAIL"
--data-urlencode 'configuration.target=ip'
--data-urlencode "configuration.value=$IP"
)
compare() {
local label="$1"
shift
local response http body
response=$(
printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' "$CF_TOKEN" |
curl --silent --show-error --max-time 20 --config - \
--request GET "$URL" "${FILTERS[@]}" "$@" \
--write-out '\n%{http_code}'
)
http=${response##*$'\n'}
body=${response%$'\n'*}
printf '%s HTTP=%s\n' "$label" "$http"
[[ "$http" == 200 ]] || return 1
jq -e '.success == true and (.result | type == "array")' \
<<<"$body" >/dev/null
jq --arg ip "$IP" '{
returned: (.result | length),
total: .result_info.total_count,
rules: [.result[] | {
value: .configuration.value,
target: .configuration.target,
mode: .mode,
notes: .notes,
target_matches: (.configuration.value == $ip)
}]
}' <<<"$body"
}
compare 'A: -Gなし'
compare 'B: -Gあり' --get
unset CF_TOKEN
BASH
この比較例はIPv4用です。IPv6ではtargetをip6に合わせるほか、後述するアドレス表記の正規化も必要です。複数ページがある場合は、表示した配列の長さと全件数を同一視せず、全ページを取得して照合します。
DELETEにつなぐ場合は、検索結果の先頭IDをそのまま使わず、IP・target・mode・jail注記を返却JSONで照合し、期待する1件だけであることを確認します。
再起動なしの稼働反映と、既存ルール・IPv6の補足
稼働定義の反映方法
以下は実機で実行した修正スクリプトの全文です。sudo python3で実行する構成で、cloudflare-token.localが既に存在し、認証情報が設定済みの環境を対象にしています。
import configparser
import os
from pathlib import Path
import re
import shlex
import stat
import subprocess
import tempfile
from fail2ban.client.csocket import CSocket
stage = "事前確認"
applied = []
def ask(*args):
client = CSocket(timeout=20)
try:
result = client.send(list(args))
finally:
client.close()
if not isinstance(result, (tuple, list)) or len(result) != 2 or result[0] != 0:
raise RuntimeError("Fail2banへの設定・取得要求が失敗")
return result[1]
def fixed(command):
lookup = command.split("|", 1)[0].replace("\\\n", " ")
if "--data-urlencode" not in lookup or "configuration.value" not in lookup:
raise RuntimeError("想定した解除検索の形式と異なるため変更せず停止")
words = shlex.split(lookup)
if "-G" in words or "--get" in words:
return command
result, count = re.subn(
r"\bcurl\s+-s\s+-X\s+GET\b", "curl -s -G -X GET", command, count=1)
if count != 1:
raise RuntimeError("変更箇所を一意に特定できないため停止")
return result
def save(path, data, metadata):
fd, name = tempfile.mkstemp(prefix=".cloudflare-token.", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
os.fchown(stream.fileno(), metadata.st_uid, metadata.st_gid)
os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
try:
path = Path("/etc/fail2ban/action.d/cloudflare-token.local")
if path.is_symlink() or not path.is_file():
raise RuntimeError("設定ファイルの形式が想定外")
old = path.read_bytes()
metadata = path.stat()
cfg = configparser.RawConfigParser()
cfg.read([str(path.with_suffix(".conf")), str(path)])
desired = fixed(cfg.get("Definition", "actionunban"))
status = dict(ask("status"))
jails = [name.strip() for name in status["Jail list"].split(",")]
targets = []
for jail in jails:
if "cloudflare-token" in ask("get", jail, "actions"):
current = ask("get", jail, "action", "cloudflare-token", "actionunban")
targets.append((jail, current, fixed(current)))
if not targets:
raise RuntimeError("稼働中の対象アクションが見つからないため停止")
before = {jail: set(ask("get", jail, "banip")) for jail, _, _ in targets}
print("対象jail数:", len(targets), flush=True)
local = configparser.RawConfigParser()
local.read_string(old.decode("utf-8"))
new = old
if cfg.get("Definition", "actionunban") != desired:
if local.has_option("Definition", "actionunban"):
raise RuntimeError("既存のローカル解除定義があるため自動上書きせず停止")
text = old.decode("utf-8")
entry = "actionunban = " + desired.replace("\n", "\n ") + "\n"
if local.has_section("Definition"):
pattern = r"(?m)^\[Definition\][ \t]*(?:[#;].*)?$"
text, count = re.subn(pattern, lambda m: m[0] + "\n" + entry, text, count=1)
if count != 1:
raise RuntimeError("Definition節の位置を特定できないため停止")
else:
text = text.rstrip() + "\n\n[Definition]\n" + entry
check = configparser.RawConfigParser()
check.read_string(text)
if check.get("Definition", "actionunban") != desired:
raise RuntimeError("保存する解除定義の照合不成立")
new = text.encode("utf-8")
stage = "ローカル設定の保存・検査"
if new != old:
if path.read_bytes() != old:
raise RuntimeError("作業中に設定ファイルが変化したため停止")
save(path, new, metadata)
validation = subprocess.run(
["fail2ban-client", "-t"], capture_output=True, text=True, timeout=60)
if validation.returncode != 0:
if new != old:
save(path, old, metadata)
raise RuntimeError("設定検査が失敗。今回のファイル変更は元に戻しました")
print("永続設定: -Gあり/設定検査成功", flush=True)
stage = "稼働中アクションへの反映"
for jail, original, patched in targets:
current = ask("get", jail, "action", "cloudflare-token", "actionunban")
if current != original:
raise RuntimeError("作業中に稼働定義が変化したため残りの反映を停止")
if patched != original:
ask("set", jail, "action", "cloudflare-token", "actionunban", patched)
if ask("get", jail, "action", "cloudflare-token", "actionunban") != patched:
raise RuntimeError("稼働定義の読戻し照合が不成立")
applied.append(jail)
print(jail, "稼働定義: -Gあり/読戻し一致", flush=True)
after = {jail: set(ask("get", jail, "banip")) for jail, _, _ in targets}
print("対象jailのBAN一覧が前後で一致:", before == after)
print("反映確認済みjail数:", len(applied))
print("再起動・reload・BAN追加・解除・Cloudflareルール操作は実行していません。")
except Exception as error:
print("停止段階:", stage)
print("反映確認済みjail:", ", ".join(applied) or "なし")
print("エラー種別:", type(error).__name__)
if isinstance(error, RuntimeError):
print(str(error))
print("再実行せず、この出力を報告してください。")
raise SystemExit(1)
対象環境では5 jailの更新・読み戻し一致とBAN一覧の前後一致を確認しました。既に修正済みの定義を再度書き換える試験はしていません。CSocketの利用はFail2ban 1.1.0のクライアント実装に依存するため、一般的な更新用APIとして扱いません。
既存ルールの是正とIPv6の照合
検索を直しても、既に発生していた不整合が自動で消えるとは限りません。そこで、現在BAN中のIPv6 IP Bについて、対象jailとUFWに登録があることを確認し、Cloudflareに不足していたblockルール1件を補いました。
次に、解除済みIPv4 IP Aが全jailでBANされておらず、UFWにも拒否登録がないことを操作直前に確認しました。CloudflareではIP・target・mode・jail注記が一致する1件だけをルールIDで削除し、再取得で0件になったことを確認しています。他IPのルールは変更していません。
IPv6の照合では、次のような表記差にも注意が必要でした。以下は説明用アドレスです。
import ipaddress
requested = "2001:db8::1"
returned = "2001:0db8:0000:0000:0000:0000:0000:0001"
assert ipaddress.ip_address(requested) == ipaddress.ip_address(returned)
実機でもCloudflareがIPv6を省略しない形で返し、文字列の完全一致では照合が失敗しました。正規化後は同一アドレスで、target=ip6・block・jail注記も一致しています。登録APIの成功と、その後の照合失敗を分け、登録を重ねずGETで確認しました。
また、Webサイト本体のサーバー(オリジン)のセキュリティグループでIPv6を許可していなくても、IPv6の訪問者がCloudflareへ接続し、CloudflareがIPv4でオリジンへ接続する経路があります。CloudflareのIPv6互換性を踏まえ、オリジンのIPv6拒否だけを理由に、訪問者IPv6の遮断を不要とは扱いません。