18
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

動的グローバルIPでもセルフホストを守る ― JenkinsでCloudflare WAFの許可リストを自動追従させる

18
Last updated at Posted at 2026-07-01

自宅やオフィスの回線は、たいていグローバル 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 で「自分だけ通す」をやりたい人の参考になれば。


:sparkles:未経験から学べます!一緒に挑戦していきましょう:sparkles:

noteもやってます↓


18
16
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
18
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?