自宅やオフィスの回線は、たいていグローバル IP が固定されていません。
セルフホストしたサービス(Zabbix・SonarQube・Jenkins…)を Cloudflare の WAF で
「自分の IP だけ許可」しても、回線の IP が変わった瞬間に自分が締め出されます。
そこで 10 分ごとに現在のグローバル IP を調べ、変わっていたら Cloudflare の
WAF 許可リスト(カスタムルール)を自動で書き換える Jenkins パイプラインを作りました。
単純そうに見えて、使い捨て Pod での状態管理・誤通知・IP を列挙できない受信
Webhook・IPv6 の現実と、地味な難所が詰まっていたので共有します。
やりたいこと
ルールの考え方はシンプルで、対象ホストに対して
「許可 IP 集合に入っていない送信元は block」 という式を Cloudflare の
カスタムルールに持たせ、その許可 IP 集合をジョブが毎回メンテします。
((http.host eq "zabbix-cli.example.info" or http.host eq "jenkins-cli.example.info" ...)
and not ip.src in {<許可IP群>}
and not (<常時許可したい host+path>))
難所 1: 使い捨て Pod では「前回の IP」を覚えられない
最初はローカルのステートファイル(prev_ip.txt)に前回 IP を保存し、差分を見て
いました。が、実行環境は 毎回作り捨ての Kubernetes Pod。ファイルは次回には消えて
います。これだと「毎回 IP が変わった」ように見えてしまいます。
解決は発想の転換で、Cloudflare 側の現在のルールを真実の情報源(source of truth)に
することでした。ルール式の {…} に入っている単一 IP を抜き出し、そこに今の自分の
IP が含まれているかで「実際に切り替わったか」を判定します。
// Cloudflare ルール式の {…} から、CIDR でない単一 IPv4 を抜き出す。
// GitHub Webhook の IP は CIDR(/付き)、IPv6 は : を含むので、
// 単一 IPv4 = このジョブが許可した「自分のグローバル IPv4」とみなせる。
def extractSingleIPv4s(String expression) {
def start = expression.indexOf('{')
def end = expression.indexOf('}')
if (start < 0 || end < 0 || end <= start) { return [] }
return expression.substring(start + 1, end).tokenize().findAll {
it ==~ /^(\d{1,3}\.){3}\d{1,3}$/
}
}
外部のステートに依存せず、実体(Cloudflare のルール)そのものを見て冪等に動くように
なりました。差分が無ければ(oldExpr == newExpr)更新もしません。
難所 2: 誤通知を出さない
「IP が変わったら Slack 通知」も、ローカル state ベースだと使い捨て Pod では毎回
「変更あり」になり、10 分ごとに誤通知が飛びます。
通知のトリガーを、**「Cloudflare API の更新が実際に成功した(updatedCount > 0)」かつ
「今の IP が以前のルールに無かった(本当に新規)」**の AND に絞りました。
これで「ルールの取りこぼしを直しただけ」のときは黙り、本当に IP が切り替わった
ときだけ鳴ります。
if (updatedCount > 0 && currentIpNewlyAllowed) {
sendCloudflareNotifications('グローバル IP が変更されました', "...")
} else {
echo "通知スキップ(実際の IP 切り替えがないため)"
}
難所 3: 切り替え時の瞬断を防ぐ
IP が A → B に変わった瞬間に許可リストを「B だけ」にすると、まだ DNS や既存接続が
A に残っているタイミングで自分を締め出すことがあります。そこで直前 IP も一時的に
許可へ残し、さらに GitHub の Webhook IP レンジ(https://api.github.com/meta)も
許可に含めて、CI 連携が切れないようにしています。
def baseIps = [env.CURRENT_IP, env.PREV_IP, env.CURRENT_IPV6]
def allowedIps = (baseIps + githubIps + FIXED_ALLOW_IPS).findAll { it?.trim() }.unique()
難所 4: 送信元 IP を列挙できない受信 Webhook
Discord のような受信 Webhook は公式の固定 IP リストがありません。IP で許可しようが
ないので、特定の **host + path だけ IP 制限から除外(バイパス)**する例外を式に足します。
alwaysAllow: [
'http.host eq "n8n.example.info" and starts_with(http.request.uri.path, "/webhook/discord-...")',
],
ただしパスを開ける以上、そのパスの推測困難性(ランダムな長い文字列)と、受信側
(n8n)での署名・シークレット検証で守る前提です。「IP で守れないものは別レイヤで
守る」という割り切りです。
難所 5: IPv6 ― 実装は済んでいるのに動かない、という現実
実行 Pod は IPv6 で外に出られないため、IPv6 を持つ別ノードへ一時的にホップして
グローバル IPv6 を取得する作りにしています。ノードに届かなければ timeout +
try/catch で黙ってスキップし、IPv4 だけで処理を続けます(IPv6 追加は任意)。
try {
timeout(time: 2, unit: 'MINUTES') {
node(env.IPV6_AGENT_LABEL) { // 既定: v6 到達可能なノード
def ip6 = sh(script: "curl -6 -fsS --max-time 10 '${env.IP6_SOURCE_URL}' || true",
returnStdout: true).trim()
if (ip6 && ip6.contains(':') && (ip6 ==~ /^[0-9a-fA-F:]+$/)) {
env.CURRENT_IPV6 = ip6
}
}
}
} catch (Exception e) {
echo "IPv6 取得をスキップします(ノードに到達できません): ${e.getMessage()}"
}
で、ここが正直な話なのですが ―― コードは完成しているのに、今のところ IPv6 は
取得できていません。原因を切り分けると、
- ホップ先ノードにはグローバル v6 アドレスが付いている(RA/SLAAC)。
- v6 のデフォルトルートもある。
- なのに TCP/ICMP は即不達、
tracepathは hop1 以降すべて応答なし。
つまり ルータ/ISP が v6 を上流へ転送していない(アドレスが付く ≠ 疎通できる)。
Cloudflare 自体に v6 で到達できないので、仮にローカルの v6 を許可リストへ入れても
意味がありません。これはコードではなくネットワーク側の問題で、回線側が直れば
コードは無改修でそのまま v6 を拾い始めます。
「実装した機能が、自分の手の届かない下のレイヤの都合で休眠している」というのは
セルフホスト運用ではよくあります。**graceful skip(失敗しても全体は止めない)**で
作っておくと、こういうとき本筋を壊さずに済みます。
地味だが効く小ネタ
-
多重実行防止: 10 分 cron は処理が長引くと重なります。直前ビルドが実行中なら
今回はNOT_BUILTにして丸ごとスキップします。 -
認証情報をログに出さない: Cloudflare API トークンや Slack トークンは Groovy 補間
("${...}")でcurlに埋め込まず、withCredentialsで環境変数に束縛して
シェル側で$CF_API_TOKENとして参照します(マスク漏れ防止)。 -
資格情報 ID の揺れを吸収:
CF_API_TOKEN/cf-api-tokenのように候補を順に試し、
見つかった方を使う(環境ごとの命名差を吸収)。 -
トークン検証を先にやる: いきなりルール更新せず、トークン → Zone → Ruleset の
アクセス可否を段階チェックしてから本処理に入ると、失敗時の原因切り分けが速い。 -
失敗は n8n へ:
post { failure { notifyN8nFailure() } }で別記事の
n8n 失敗通知へ流します。
まとめ
「動的 IP に WAF 許可リストを追従させる」だけのジョブですが、堅くするための勘所が
いくつもありました。
- 使い捨て Pod では外部状態に頼らず、実体(Cloudflare ルール)を真実の情報源にする。
- 通知は「本当に変わったとき」だけ。誤通知は運用で無視され、いざという時に効かなくなる。
- 切り替えの瞬断は直前 IP の一時許可で防ぐ。
- IP で守れない受信 Webhook は **host+path 例外 + 別レイヤ(署名・推測困難性)**で守る。
- 届かないかもしれない処理(IPv6 ホップ)は graceful skip で本筋を止めない。
セルフホスト + Cloudflare で「自分だけ通す」をやりたい人の参考になれば。
未経験から学べます!一緒に挑戦していきましょう![]()
noteもやってます↓