<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Firewall on Tarragon</title><link>https://tarrragon.github.io/blog/tags/firewall/</link><description>Recent content in Firewall on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 26 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/firewall/index.xml" rel="self" type="application/rss+xml"/><item><title>GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/</guid><description>&lt;p>實測日期是 2026-10-24，在 GCP 測試專案 &lt;code>blog-verify-20261024&lt;/code>（zone &lt;code>asia-east1-b&lt;/code>，&lt;code>default&lt;/code> 網路）上，對一台 Debian 12 的 e2-micro 與一台 Windows Server 2022 的 e2-medium，用本機的 &lt;code>gcloud&lt;/code> 照這一篇的順序操作；IAM 條件與 OS Login 角色另外用第二個 Google 帳號登入驗證。&lt;/p>
&lt;p>這篇記錄把 VM 的管理員登入從防火牆對 &lt;code>0.0.0.0/0&lt;/code> 開放 22 與 3389 port，改成走 IAP TCP forwarding 的實際操作。內容依操作的順序是：切換前盤點依賴舊規則的連線；準備並實際登入一次救援入口（Serial Console 與本機救援帳號）；啟用 IAP API、開啟 IAP 的稽核紀錄、建立只放行 IAP 來源的防火牆規則與 network tag；授予開通道的 IAM 權限；停用舊規則、用 IAP 驗證新規則；刪除對外開放的規則；收斂 &lt;code>default-allow-internal&lt;/code> 與 &lt;code>default-allow-icmp&lt;/code>；啟用 OS Login；建 Cloud NAT 並移除外部 IP。每一步寫出這次的取值與實測結果，以及沒做或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。IAP 的連線路徑、預設規則為什麼要換、切換的順序為什麼這樣排、IAM 授權的範圍、連不上時的依層判讀、OS Login 與 Cloud NAT 的機制，寫在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding&lt;/a>（下文稱概念篇），這裡不重寫。&lt;/p>
&lt;p>這一篇的指令以單台 VM 為對象。由 MIG 或 GKE 管理的 VM，同樣的設定改寫在 instance template 與 node pool 上，做法在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/" data-link-title="GCP 上 MIG 與 GKE 管理存取的實測紀錄：instance template 與 canary 滾動更新、重建與主機金鑰、kubectl debug 的 namespace、node pool 設定與控制平面端點的切換" data-link-desc="要把 IAP 的 tag、OS Login 與移除外部 IP 套到 MIG 或 GKE 上，想照一次實跑過的順序做、看每一步換一種做法的結果時讀；也用在 MIG 重建之後 SSH 出現 REMOTE HOST IDENTIFICATION HAS CHANGED、kubectl debug 停在等待，或關掉 GKE 控制平面的 IP 端點之前要確認每個人都改連的時候">GCP 上 MIG 與 GKE 管理存取的實測紀錄&lt;/a>；Shared VPC 的 service project、用 Terraform 管理的專案，指令要改寫在別的位置；新專案從零開始則可以跳過盤點與停用驗證。這幾種環境的調整見概念篇開場之後「另外四種環境要先調整做法再往下讀」那一段。&lt;/p>
&lt;h2 id="切換前的盤點依賴舊規則的連線">切換前的盤點：依賴舊規則的連線&lt;/h2>
&lt;p>&lt;code>default-allow-ssh&lt;/code> 放行的不只是管理員本人的 SSH：CI 或部署腳本用外部 IP 直連 VM、Ansible 這類&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理&lt;/a>工具、監控程式的探測、第三方廠商的維護連線，都可能經過這條規則，而預設規則沒有開 logging，手上沒有紀錄可以查出它們。盤點分兩步。先對舊規則開啟 logging，列出經過它的候選來源：&lt;/p></description><content:encoded><![CDATA[<p>實測日期是 2026-10-24，在 GCP 測試專案 <code>blog-verify-20261024</code>（zone <code>asia-east1-b</code>，<code>default</code> 網路）上，對一台 Debian 12 的 e2-micro 與一台 Windows Server 2022 的 e2-medium，用本機的 <code>gcloud</code> 照這一篇的順序操作；IAM 條件與 OS Login 角色另外用第二個 Google 帳號登入驗證。</p>
<p>這篇記錄把 VM 的管理員登入從防火牆對 <code>0.0.0.0/0</code> 開放 22 與 3389 port，改成走 IAP TCP forwarding 的實際操作。內容依操作的順序是：切換前盤點依賴舊規則的連線；準備並實際登入一次救援入口（Serial Console 與本機救援帳號）；啟用 IAP API、開啟 IAP 的稽核紀錄、建立只放行 IAP 來源的防火牆規則與 network tag；授予開通道的 IAM 權限；停用舊規則、用 IAP 驗證新規則；刪除對外開放的規則；收斂 <code>default-allow-internal</code> 與 <code>default-allow-icmp</code>；啟用 OS Login；建 Cloud NAT 並移除外部 IP。每一步寫出這次的取值與實測結果，以及沒做或換一種做法時的結果；替代做法與沒做時的結果沒有另外標註的是這次實測看到的，照官方文件推出的寫出文件，推論的寫明沒有實跑。IAP 的連線路徑、預設規則為什麼要換、切換的順序為什麼這樣排、IAM 授權的範圍、連不上時的依層判讀、OS Login 與 Cloud NAT 的機制，寫在 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding</a>（下文稱概念篇），這裡不重寫。</p>
<p>這一篇的指令以單台 VM 為對象。由 MIG 或 GKE 管理的 VM，同樣的設定改寫在 instance template 與 node pool 上，做法在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/" data-link-title="GCP 上 MIG 與 GKE 管理存取的實測紀錄：instance template 與 canary 滾動更新、重建與主機金鑰、kubectl debug 的 namespace、node pool 設定與控制平面端點的切換" data-link-desc="要把 IAP 的 tag、OS Login 與移除外部 IP 套到 MIG 或 GKE 上，想照一次實跑過的順序做、看每一步換一種做法的結果時讀；也用在 MIG 重建之後 SSH 出現 REMOTE HOST IDENTIFICATION HAS CHANGED、kubectl debug 停在等待，或關掉 GKE 控制平面的 IP 端點之前要確認每個人都改連的時候">GCP 上 MIG 與 GKE 管理存取的實測紀錄</a>；Shared VPC 的 service project、用 Terraform 管理的專案，指令要改寫在別的位置；新專案從零開始則可以跳過盤點與停用驗證。這幾種環境的調整見概念篇開場之後「另外四種環境要先調整做法再往下讀」那一段。</p>
<h2 id="切換前的盤點依賴舊規則的連線">切換前的盤點：依賴舊規則的連線</h2>
<p><code>default-allow-ssh</code> 放行的不只是管理員本人的 SSH：CI 或部署腳本用外部 IP 直連 VM、Ansible 這類<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具、監控程式的探測、第三方廠商的維護連線，都可能經過這條規則，而預設規則沒有開 logging，手上沒有紀錄可以查出它們。盤點分兩步。先對舊規則開啟 logging，列出經過它的候選來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 對舊規則開啟 logging，之後每一次放行都會寫進 Cloud Logging</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules update default-allow-ssh --enable-logging
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute firewall-rules update default-allow-rdp --enable-logging
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 觀察一段時間後，列出經由舊規則連進來的來源 IP 與次數（RDP 把規則名稱換成 default-allow-rdp）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;logName:&#34;compute.googleapis.com%2Ffirewall&#34; AND jsonPayload.rule_details.reference:&#34;default-allow-ssh&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>7d --format<span class="o">=</span><span class="s2">&#34;value(jsonPayload.connection.src_ip)&#34;</span> <span class="p">|</span> sort <span class="p">|</span> uniq -c</span></span></code></pre></div><p><code>--freshness=7d</code> 是查詢往回看的時間長度，依自己的部署週期調整，要涵蓋到一次完整的部署與排程工作。實際依賴這條規則的連線，要從 VM 上的成功登入紀錄找（理由見概念篇〈切換到 IAP 的順序〉）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 Linux VM 上列出這段期間成功的 SSH 登入：帳號、來源 IP 與金鑰指紋</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo journalctl -u ssh -u sshd --since <span class="s2">&#34;7 days ago&#34;</span> <span class="p">|</span> grep <span class="s2">&#34;Accepted publickey&#34;</span></span></span></code></pre></div><p>每一行的格式是 <code>Accepted publickey for &lt;user&gt; from &lt;ip&gt; ... &lt;金鑰指紋&gt;</code>，用帳號與金鑰指紋就認得出是哪個人或哪個自動化工具。Windows VM 看事件檢視器的安全性紀錄裡事件 ID 4624、登入類型 10（遠端互動，也就是 RDP）的事件。找出來的非人員來源要先改走 IAP 才能停用舊規則：CI 與自動化工具用服務帳號開 IAP 通道，一般 <code>ssh</code> 的工具照概念篇〈IAP TCP forwarding 的連線路徑與來源範圍〉的 <code>ProxyCommand</code> 改設定。</p>
<ul>
<li><strong>沒做時</strong>：停用舊規則的那一刻，依賴它的 CI、監控與廠商連線跟管理員的一起被拒；這些失敗出現在部署或排程執行時，不一定在切換當下被看到（從規則的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：觀察期縮短或省略，換到的是切換提早，付出的是只盤點到觀察期內發生過的連線，一個月跑一次的排程會漏掉；只看防火牆紀錄、不看 VM 上的登入紀錄，換到的是不必登入每一台 VM，付出的是 CI 這類來源 IP 每次不同的連線認不出來（兩者都從紀錄的形狀推論，這次沒有實跑）。</li>
</ul>
<h2 id="準備救援入口serial-console-與本機救援帳號">準備救援入口：Serial Console 與本機救援帳號</h2>
<p><a href="/blog/devops/infra/knowledge-cards/serial-console/" data-link-title="Serial Console（序列主控台）" data-link-desc="SSH 因為防火牆、sshd 設定或磁碟滿而連不進主機，或要規劃管理通道斷掉時的備用路徑時，查序列主控台與頻外管理介面的用法與它本身的暴露面">Serial Console</a> 透過 VM 的虛擬序列埠提供登入，不經過 VM 的網路設定與 sshd，在防火牆 tag 漏加、sshd 設定改壞、OS Login 設錯、磁碟滿導致 sshd 起不來時當作救援入口。它用 VM 內的本機帳號密碼登入，與 metadata 金鑰、OS Login 都無關，所以救援帳號要在 VM 還正常時就建好：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在每台 Linux VM 上建一個設了密碼的本機救援帳號（RHEL 系把 sudo 群組換成 wheel）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo useradd -m rescue <span class="o">&amp;&amp;</span> sudo passwd rescue <span class="o">&amp;&amp;</span> sudo usermod -aG sudo rescue
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認 sshd 仍然只收金鑰，救援帳號的密碼只在 Serial Console 上有效</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sudo sshd -T <span class="p">|</span> grep -i passwordauthentication</span></span></code></pre></div><p><code>rescue</code> 是自己取的帳號名稱。密碼用密碼管理工具逐台隨機產生、存在團隊的密碼庫，不同 VM 不共用同一組；持有救援密碼的人離開團隊時，逐台輪替。<code>sshd -T</code> 印出 sshd 實際生效的設定，那一行要是 <code>passwordauthentication no</code>，否則救援帳號的密碼也能從 SSH 登入。Windows VM 的序列主控台（Special Administration Console）用 Windows 的本機管理員帳號登入，要事先確認那個帳號的密碼在手上。</p>
<p>Serial Console 的互動登入預設關閉，平時保持關閉，救援時才對單一 VM 開啟：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 救援時對單一 VM 開啟 Serial Console 並連上</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata serial-port-enable<span class="o">=</span>TRUE
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute connect-to-serial-port &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt;
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 救援結束後關回去</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata serial-port-enable<span class="o">=</span>FALSE</span></span></code></pre></div><p><code>&lt;instance-name&gt;</code> 與 <code>&lt;zone&gt;</code> 用 <code>gcloud compute instances list</code> 查，第一欄是 VM 名稱、第二欄是 zone。建好救援帳號之後，對一台 VM 照上面的指令開啟、用 <code>rescue</code> 登入並執行一次 <code>sudo -v</code> 確認 sudo 可用，再關回去；這次在 Debian 12 的 VM 上實跑，登入與 sudo 都成功。Windows VM 的 SAC 這次沒有實跑。</p>
<ul>
<li><strong>沒做時</strong>：停用舊規則、或之後啟用 OS Login 時任何一項設錯，VM 沒有不經過網路與 sshd 的入口；剩下的路是在 metadata 設 <code>startup-script</code>（Linux）或 <code>windows-startup-script-ps1</code>（Windows）、重開機以 root 或 SYSTEM 執行來建帳號或改回設定，或把開機磁碟卸下掛到另一台 VM 修改，兩條都要重開機（Compute Engine 的啟動腳本與救援文件，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>救援帳號寫進自訂映像檔，換到的是新建的 VM 一開始就有，付出的是密碼雜湊在每台 VM 上相同、要輪替就要重做映像（推論，這次沒有實跑）。</li>
<li>靠 startup-script 臨時建帳號，換到的是平時不留密碼，付出的是救援時要重開機，而且 startup-script 存在 metadata 裡，有讀取權限的人都看得到內容（Compute Engine 的 metadata 文件，這次沒有實跑）。</li>
<li>組織強制了 <code>compute.disableSerialPortAccess</code> 時，救援名單要有能對專案解除這條政策的人，見 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道</a> 的〈組織層級的護欄：Organization Policy 與階層式防火牆〉。</li>
</ul>
</li>
</ul>
<h2 id="啟用-iap-api開啟稽核紀錄並建立只放行-iap-來源的防火牆規則">啟用 IAP API、開啟稽核紀錄並建立只放行 IAP 來源的防火牆規則</h2>
<p>IAP 端點以 API 的形式在專案上開關，沒有啟用時，<code>--tunnel-through-iap</code> 的連線在 IAP 端點那一步就開不起來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 啟用 IAP API（每個專案一次）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud services <span class="nb">enable</span> iap.googleapis.com</span></span></code></pre></div><p>同一步在 Google Cloud 控制台的「IAM 與管理 &gt; 稽核記錄」為 Cloud Identity-Aware Proxy 開啟 Data Access 紀錄裡的「管理員讀取」（<code>ADMIN_READ</code>）類型。開通道時 IAP 檢查的權限 <code>iap.tunnelInstances.accessViaIAP</code> 歸在這個類型，每一次開通道會留下一筆方法名稱為 <code>AuthorizeUser</code> 的紀錄。開啟之後經由 IAP 連一次，查到這次連線的紀錄才算開對了類型（<code>protoPayload.resourceName</code> 是 VM 的執行個體 ID）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="s1">&#39;protoPayload.serviceName=&#34;iap.googleapis.com&#34; AND protoPayload.methodName=&#34;AuthorizeUser&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1h --format<span class="o">=</span><span class="s2">&#34;value(timestamp,protoPayload.authenticationInfo.principalEmail,protoPayload.resourceName)&#34;</span></span></span></code></pre></div><p>也在同一步確認所有要開通道的 Google 帳號都已強制兩步驟驗證，理由見概念篇〈IAP 的 IAM 授權範圍：誰拿得到通道〉最後兩段。</p>
<ul>
<li><strong>沒做時</strong>（不開 <code>ADMIN_READ</code>）：開通道的帳號與目標 VM 沒有 VM 以外的紀錄，事後調查只剩 Admin Activity 裡的設定變更與 VM 上的 sshd 紀錄；開啟之前的通道紀錄查不回來（Cloud Audit Logs 文件，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：只開「資料讀取」（<code>DATA_READ</code>）實測過：連了十次左右，紀錄零筆。兩種都開，換到的是不必判斷權限歸在哪一類，付出的是紀錄量與費用增加（推論，這次沒有量）。</li>
</ul>
<p>新規則只開需要的 port，並用 target tag 限定作用的 VM：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 放行 IAP 的 IPv4 來源連到帶指定 tag 的 Linux VM 的 22 port</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud compute firewall-rules create allow-ssh-from-iap <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --action<span class="o">=</span>allow <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --rules<span class="o">=</span>tcp:22 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --source-ranges<span class="o">=</span>35.235.240.0/20 <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --target-tags<span class="o">=</span>&lt;ssh-via-iap-tag&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --enable-logging
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 有 Windows VM 時，另建一條放行 3389 port 的規則，用另一個 tag</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">gcloud compute firewall-rules create allow-rdp-from-iap <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --action<span class="o">=</span>allow <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --rules<span class="o">=</span>tcp:3389 <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --source-ranges<span class="o">=</span>35.235.240.0/20 <span class="se">\
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="se"></span>  --target-tags<span class="o">=</span>&lt;rdp-via-iap-tag&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="se"></span>  --enable-logging</span></span></code></pre></div><ul>
<li><code>allow-ssh-from-iap</code>、<code>allow-rdp-from-iap</code>：規則名稱，自己取，同一個專案裡不能重複。</li>
<li><code>&lt;network-name&gt;</code>：VM 所在的 VPC 名稱，預設網路就是 <code>default</code>，自建網路用 <code>gcloud compute networks list</code> 查。</li>
<li><code>&lt;ssh-via-iap-tag&gt;</code>、<code>&lt;rdp-via-iap-tag&gt;</code>：自己取的 <a href="/blog/devops/infra/knowledge-cards/network-tag/" data-link-title="Network Tag（GCP 網路標籤）" data-link-desc="GCP 防火牆規則套不到某台 VM、新建的 VM 連不上，或要決定防火牆規則用 tag 還是服務帳號選 VM 時，查 network tag 怎麼讓防火牆規則選出要套用的 VM">network tag</a> 名稱，例如 <code>ssh-iap</code>、<code>rdp-iap</code>。SSH 與 RDP 用不同的 tag，Linux VM 就不會被順便開了 3389。</li>
<li><code>--enable-logging</code>：讓防火牆把這條規則的每一次放行寫進 Cloud Logging，驗證新規則與之後稽核都要用到。</li>
<li><code>--source-ranges=35.235.240.0/20</code>：Google 公開的 IAP 來源位址段，每個專案都相同；<code>--rules</code> 依通道填要開的 port（SSH 是 <code>tcp:22</code>、RDP 是 <code>tcp:3389</code>）。這兩條規則沒有寫 <code>--priority</code>，預設是 1000（實測），比 <code>default-allow-ssh</code> 的 65534 先被評估。</li>
</ul>
<p>自建的 custom-mode VPC 有 IPv6 VM 時，再建一條名稱不同（例如 <code>allow-ssh-from-iap-v6</code>）、<code>--source-ranges=2600:2d00:1:7::/64</code>、其餘引數相同的規則。接著對每台 VM 加上對應的 tag，VM 分布在不同 zone 時逐台以各自的 <code>--zone</code> 執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute instances add-tags &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tags<span class="o">=</span>&lt;ssh-via-iap-tag&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances add-tags &lt;windows-instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tags<span class="o">=</span>&lt;rdp-via-iap-tag&gt;</span></span></code></pre></div><ul>
<li><strong>沒做時</strong>（漏加 tag）：停用舊規則之後，IAP 回 <code>4003</code>（failed to connect to backend），Connectivity Tests 指出擋下它的是 <code>default-deny-ingress</code>。</li>
<li><strong>替代做法</strong>：
<ul>
<li>省略 <code>--target-tags</code>，換到的是不必逐台加 tag，付出的是 IAP 來源連得到網路裡每一台 VM 的 22 port（照規則的作用推論，這次沒有實跑）。</li>
<li>把 <code>--rules</code> 寫成 <code>all</code>，換到的是不必列 port，付出的是 IAP 來源連得到每一台 VM 的每一個 port，等於把 <code>default-allow-internal</code> 的寬度複製給 IAP（照規則的作用推論，這次沒有實跑）。</li>
<li>改用 <code>--target-service-accounts</code> 以服務帳號選 VM，換到的是 tag 不能被持有 VM 修改權限的人隨手加上（能編輯 VM 的人改得了它的 tag），付出的是同一個服務帳號的 VM 全部一起套用，而且 VM 換服務帳號要停機（VPC 防火牆規則文件，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="授予開通道的-iam-權限">授予開通道的 IAM 權限</h2>
<p>開通道的權限是 IAM role <code>roles/iap.tunnelResourceAccessor</code>。下面的指令把它綁在專案層級，並用 IAM Conditions（在角色綁定上附加條件，條件成立時這個綁定才生效）限定可以連的 port 或期限；條件的寫法依環境選一種：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 只有 Linux VM：條件限定只能連 22 port</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22,title=iap-ssh-only&#39;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 同時有 Windows VM：條件放行 22 與 3389</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22 || destination.port == 3389,title=iap-ssh-rdp&#39;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 只允許連特定一台 VM 的 22 port</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22 &amp;&amp; destination.ip == &#34;&lt;vm-internal-ip&gt;&#34;,title=iap-ssh-one-vm&#39;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 外包或臨時存取：到期之後綁定自動失效</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;contractor-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="se"></span>  --role<span class="o">=</span>roles/iap.tunnelResourceAccessor <span class="se">\
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="se"></span>  --condition<span class="o">=</span><span class="s1">&#39;expression=destination.port == 22 &amp;&amp; request.time &lt; timestamp(&#34;&lt;expiry-time&gt;&#34;),title=iap-ssh-until-expiry&#39;</span></span></span></code></pre></div><ul>
<li><code>&lt;project-id&gt;</code>：VM 所在的專案 ID，用 <code>gcloud config get-value project</code> 查目前設定的是哪一個。</li>
<li><code>&lt;admin-email&gt;</code>、<code>&lt;contractor-email&gt;</code>：要給權限的人的 Google 帳號；給一群人時把 <code>user:</code> 換成 <code>group:</code> 加 Google Group 的位址，CI 這類機器身分換成 <code>serviceAccount:</code> 加服務帳號的位址。</li>
<li><code>&lt;vm-internal-ip&gt;</code>：VM 的內部 IP，<code>gcloud compute instances list</code> 的 <code>INTERNAL_IP</code> 欄查得到。</li>
<li><code>&lt;expiry-time&gt;</code>：綁定失效的時間，RFC 3339 格式，例如 <code>2027-03-31T00:00:00Z</code>。</li>
<li><code>title</code>：這個條件的顯示名稱，自己取，會出現在 IAM 頁面上。</li>
</ul>
<p>這次用第二個帳號實測：沒有這個角色時開通道回 <code>4033</code>；條件只放行 22 時連 3389 回 <code>4033</code>（Owner 帳號對照回 <code>4003</code>，代表 Owner 不受條件限制、而是被防火牆擋下）；限定單一 VM 與過期的條件連續三分鐘回 <code>4033</code>，正對照在 75 秒內放行。IAM 綁定從送出到生效約 60 到 120 秒，這段時間內的 <code>4033</code> 不代表條件寫錯。</p>
<p>Linux VM 的 <code>gcloud compute ssh</code> 另外要 VM 端的登入權限：還沒啟用 OS Login 時，<code>gcloud compute ssh</code> 把公鑰寫進專案或 VM 的 metadata（實測寫進專案 metadata），所以非 Owner 的帳號要有修改 metadata 的權限（例如 <code>roles/compute.instanceAdmin.v1</code>），VM 掛著服務帳號時另外要那個服務帳號的 <code>roles/iam.serviceAccountUser</code>；已經啟用 OS Login 時，改由〈啟用 OS Login：先授角色、單台先行、再做專案層級〉的登入角色決定。兩種情形都要讀取 VM 與專案資訊的權限（例如 <code>roles/compute.viewer</code>）。</p>
<ul>
<li><strong>沒做時</strong>：非 Owner 的管理員開通道回 <code>4033</code>。Owner 不需要這一步，所以團隊全員都是 Owner 時，這一步看起來不必做，而 IAM Conditions 對 Owner 不生效，條件寫了也限不住任何人（Owner 連 3389 回 <code>4003</code> 而不是 <code>4033</code>）。</li>
<li><strong>替代做法</strong>：不加條件，換到的是指令短、不必維護條件，付出的是持有角色的人連得到專案裡每台 VM 的每個 port（只要防火牆放行）；綁給 <code>domain:</code> 或大型群組，換到的是不必逐人授權，付出的是整個組織的帳號都能開通道（兩者都從角色的作用推論，這次沒有實跑）。取捨的判斷見概念篇〈IAP 的 IAM 授權範圍：誰拿得到通道〉。</li>
</ul>
<h2 id="停用舊規則用-iap-驗證新規則">停用舊規則，用 IAP 驗證新規則</h2>
<p>先在舊規則還在時，每一位管理員各自用自己的帳號連一次，確認 IAM 權限：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Linux：透過 IAP 通道 SSH 進 VM</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute ssh &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tunnel-through-iap
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Windows：把 VM 的 3389 port 經 IAP 轉到本機的 13389，再用 RDP 用戶端連 localhost:13389</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud compute start-iap-tunnel &lt;windows-instance-name&gt; <span class="m">3389</span> --local-host-port<span class="o">=</span>localhost:13389 --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><p>加上 <code>--tunnel-through-iap</code> 時，<code>gcloud</code> 改走 IAP、不連 VM 的外部 IP；VM 還有外部 IP 時，少了這個參數的連線走的是舊路徑。這一次連線驗證的是 IAM：IAP 端點會拒絕沒有權限的帳號。防火牆規則與 tag 這一次驗不到，因為舊規則的 <code>0.0.0.0/0</code> 也放行 IAP 的位址段。接著停用舊規則（規則仍在，只是不生效），由一位管理員對 Linux 與 Windows 各一台 VM 再連一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules update default-allow-ssh --disabled
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules update default-allow-rdp --disabled
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute ssh &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tunnel-through-iap</span></span></code></pre></div><p>這一次連得上，代表放行連線的是新規則；前提是專案裡沒有其他來源涵蓋 <code>35.235.240.0/20</code> 的允許規則，用概念篇〈預設網路自帶的防火牆規則〉的列出規則指令確認。這次實測停用之後經由 IAP 連得上、不加 <code>--tunnel-through-iap</code> 的直連被拒，Windows 經 IAP 的 3389 回了 X.224 Connection Confirm。新規則建立時加了 <code>--enable-logging</code>，可以查防火牆紀錄確認命中的是哪一條規則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查最近一小時經由 IAP 規則放行的連線</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;logName:&#34;compute.googleapis.com%2Ffirewall&#34; AND jsonPayload.rule_details.reference:&#34;allow-ssh-from-iap&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1h --limit<span class="o">=</span><span class="m">10</span></span></span></code></pre></div><p>規則剛開啟 logging 的最初幾分鐘，紀錄裡的 <code>rule_details</code> 可能是空的（實測前兩筆沒有、約五分鐘後的三筆有），照本節的順序正好落在這段時間，所以上面這條查不到不代表新規則沒有命中。這時改用來源 IP 查，結果與 <code>rule_details</code> 是否已經填入無關；驗 RDP 的規則時把 <code>22</code> 換成 <code>3389</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="s1">&#39;logName:&#34;compute.googleapis.com%2Ffirewall&#34; AND ip_in_net(jsonPayload.connection.src_ip, &#34;35.235.240.0/20&#34;) AND jsonPayload.connection.dest_port=22&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>1h --limit<span class="o">=</span><span class="m">10</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;value(timestamp,jsonPayload.connection.src_ip,jsonPayload.rule_details.reference)&#34;</span></span></span></code></pre></div><p>連不上時，用下面的指令把舊規則恢復，再照概念篇〈IAP 通道連不上時的依層判讀〉找出是哪一層；防火牆規則透過 GCP 的 API 修改，SSH 連不上也改得回來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules update default-allow-ssh --no-disabled
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules update default-allow-rdp --no-disabled</span></span></code></pre></div><p><code>4003</code> 時要用 Connectivity Tests 查是哪一條規則或政策擋下：<code>gcloud network-management connectivity-tests create &lt;test-name&gt; --source-ip-address=35.235.240.1 --destination-instance=projects/&lt;project-id&gt;/zones/&lt;zone&gt;/instances/&lt;instance-name&gt; --destination-port=22 --protocol=TCP</code>，<code>&lt;test-name&gt;</code> 自己取，<code>--source-ip-address</code> 只收單一 IP，取 IAP 位址段裡的任一個。</p>
<ul>
<li><strong>主控台的 SSH 按鈕</strong>（SSH-in-browser）：官方〈Connect to Linux VMs using IAP〉寫明它只在 VM 沒有外部 IP 時才走 IAP。所以 VM 還帶外部 IP 時，停用舊規則之後，用主控台按鈕的人連不上，而 <code>--tunnel-through-iap</code> 驗起來是通的，這不代表新規則壞了；外部 IP 移除之後，主控台按鈕改走 IAP（照官方文件推出，這次沒有實跑）。盤點時把用主控台按鈕登入的人一起找出來，切換期間請他們改用 <code>--tunnel-through-iap</code>。</li>
<li><strong>沒做時</strong>（只在舊規則還在時驗證）：連得上的那一次可能是舊規則放行的，tag 漏加或規則寫錯都測不出來，要到刪除舊規則之後才發現（照規則的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>不停用、只查防火牆紀錄裡命中的規則名稱，換到的是驗證期間外部來源照常連得到，付出的是要等 <code>rule_details</code> 填入，而且兩條規則都放行時紀錄記的是先被評估的那一條（推論，這次沒有在兩條並存時查）。</li>
<li>直接刪除舊規則再驗證，換到的是少一步，付出的是出錯時要重建規則，重建時可能寫錯；停用的規則用 <code>--no-disabled</code> 一行恢復原樣。</li>
</ul>
</li>
</ul>
<h2 id="刪除對外開放的規則">刪除對外開放的規則</h2>
<p>刪除之前，列出還沒帶 tag 的 VM，兩份清單都要是空的（只有 Linux 或只有 Windows 時，查對應的那一份）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute instances list --filter<span class="o">=</span><span class="s2">&#34;NOT tags.items=&lt;ssh-via-iap-tag&gt;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances list --filter<span class="o">=</span><span class="s2">&#34;NOT tags.items=&lt;rdp-via-iap-tag&gt;&#34;</span></span></span></code></pre></div><p>清單裡出現的是另一種作業系統的 VM 時合理（Linux VM 本來就不帶 RDP 的 tag），其餘的要先補上 tag 再刪規則。專案裡還沒有任何 VM 帶 tag 時，<code>gcloud</code> 會先印一行 <code>WARNING: The following filter keys were not present in any resource : tags.items</code>，下面列出的 VM 清單仍然正確（實測）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules delete default-allow-ssh default-allow-rdp</span></span></code></pre></div><p>刪除之後才發現有漏掉的連線來源時，照原樣把規則建回來，再回頭處理（實測建得回來）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules create default-allow-ssh --network<span class="o">=</span>default <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>allow --rules<span class="o">=</span>tcp:22 --source-ranges<span class="o">=</span>0.0.0.0/0 --priority<span class="o">=</span><span class="m">65534</span> --enable-logging
</span></span><span class="line"><span class="ln">3</span><span class="cl">gcloud compute firewall-rules create default-allow-rdp --network<span class="o">=</span>default <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>allow --rules<span class="o">=</span>tcp:3389 --source-ranges<span class="o">=</span>0.0.0.0/0 --priority<span class="o">=</span><span class="m">65534</span> --enable-logging</span></span></code></pre></div><ul>
<li><strong>沒做時</strong>（停用之後不刪）：停用的規則不放行任何連線，效果與刪除相同；留著的代價是有人用 <code>--no-disabled</code> 一行就恢復全開（照規則的作用推論，這次沒有實跑）。Security Command Center 的 <code>OPEN_SSH_PORT</code> finding 是否把停用的規則算進去，這次沒有確認。</li>
<li><strong>替代做法</strong>：用 <code>gcloud compute firewall-rules update default-allow-ssh --source-ranges=&lt;固定的管理網段&gt;</code> 把來源收窄、不刪除，換到的是保留一條直連的路，付出的是這條路的授權仍然是來源 IP，分辨不出人（取捨見 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a> 的〈網路可達性的做法：管理 port 對誰開放〉，這次沒有實跑）。</li>
</ul>
<h2 id="收斂-default-allow-internal-與-default-allow-icmp">收斂 default-allow-internal 與 default-allow-icmp</h2>
<p><code>default-allow-internal</code> 要先確認 VM 之間實際需要哪些 port（應用連資料庫、節點之間的叢集通訊）再改成具體的規則。改法是為每一組需要的連線各建一條規則，例如應用連 PostgreSQL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute firewall-rules create allow-app-to-db --network<span class="o">=</span>default <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>allow --rules<span class="o">=</span>tcp:5432 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --source-tags<span class="o">=</span>&lt;app-tag&gt; --target-tags<span class="o">=</span>&lt;db-tag&gt;</span></span></code></pre></div><p><code>&lt;app-tag&gt;</code>、<code>&lt;db-tag&gt;</code> 是自己取的 tag，加在應用與資料庫 VM 上；<code>tcp:5432</code> 換成實際的服務 port。每一組需要的連線都建好之後，再刪 <code>default-allow-internal</code>。<code>default-allow-icmp</code> 的處理看用途：監控或除錯流程會用 <code>ping</code> 確認 VM 存活時，保留並把來源縮到內部網段與監控主機（<code>gcloud compute firewall-rules update default-allow-icmp --source-ranges=10.128.0.0/9,&lt;monitoring-host-ip&gt;/32</code>，<code>&lt;monitoring-host-ip&gt;</code> 是發 ping 的監控主機的 IP）；沒有這種用途就一起刪除。這一節的指令這次沒有實跑。</p>
<ul>
<li><strong>沒做時</strong>：同一個網路裡的 VM 之間不受 port 限制，一台 VM 被入侵之後能直接連到其他 VM 的資料庫與管理 port；管理通道本身不受影響，所以這一步可以排進另一次變更（照規則的作用推論，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：直接刪除 <code>default-allow-internal</code>，換到的是一步收完，付出的是 VM 之間的服務連線當場中斷；整個網路改成自己規劃的 custom-mode VPC，換到的是預設規則一次清掉，付出的是 VM 要搬網路，見概念篇〈預設網路自帶的防火牆規則〉（兩者都是推論，這次沒有實跑）。</li>
</ul>
<h2 id="啟用-os-login先授角色單台先行再做專案層級">啟用 OS Login：先授角色、單台先行、再做專案層級</h2>
<p>啟用 OS Login 之後，VM 不再接受 metadata 裡的金鑰（實測：metadata 金鑰被拒），順序為什麼是先授角色、單台先行，見 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道</a> 的〈SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件〉。先把登入角色授予每一位管理員與每一個要登入 VM 的服務帳號；VM 掛著服務帳號時，每一個登入者另外需要那個服務帳號的 <code>roles/iam.serviceAccountUser</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 授予管理員 OS Login 的登入角色：一般登入與含 sudo 的兩種擇一</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; --role<span class="o">=</span>roles/compute.osLogin
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; --role<span class="o">=</span>roles/compute.osAdminLogin
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># CI 或自動化工具的服務帳號同樣要授予（需要 sudo 時改用 osAdminLogin）</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">gcloud projects add-iam-policy-binding &lt;project-id&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>serviceAccount:&lt;ci-service-account-email&gt; --role<span class="o">=</span>roles/compute.osLogin
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 查 VM 掛的服務帳號，並授予登入者使用它的權限</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">gcloud compute instances describe &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;get(serviceAccounts[0].email)&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">gcloud iam service-accounts add-iam-policy-binding &lt;service-account-email&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --member<span class="o">=</span>user:&lt;admin-email&gt; --role<span class="o">=</span>roles/iam.serviceAccountUser</span></span></code></pre></div><p><code>&lt;ci-service-account-email&gt;</code> 是 CI 或自動化工具開通道用的服務帳號，<code>&lt;service-account-email&gt;</code> 是上一條 <code>describe</code> 查出來、VM 掛的那個服務帳號，兩者不同。這次用第二個帳號實測：缺 <code>serviceAccountUser</code> 時 <code>Permission denied (publickey)</code>，補上之後 20 秒內登入；<code>osLogin</code> 不能 sudo、<code>osAdminLogin</code> 可以。</p>
<p>接著先對一台 VM 啟用，讓每一位非 Owner 的管理員各自用自己的帳號連通並確認 sudo 可用，CI 也對這台跑一次部署，再做專案層級：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 先對單一 VM 啟用，連通確認</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata enable-oslogin<span class="o">=</span>TRUE
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認之後在專案層級啟用（VM 層級的設定會蓋過專案層級）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud compute project-info add-metadata --metadata enable-oslogin<span class="o">=</span>TRUE
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 連不上時還原（修改 metadata 走 API，不需要登入 VM）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud compute instances add-metadata &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --metadata enable-oslogin<span class="o">=</span>FALSE
</span></span><span class="line"><span class="ln">9</span><span class="cl">gcloud compute project-info add-metadata --metadata enable-oslogin<span class="o">=</span>FALSE</span></span></code></pre></div><p>專案層級啟用之後再盤點兩件事。VM 層級設了 <code>enable-oslogin=FALSE</code> 的 VM 會蓋過專案設定、仍然讀 metadata 金鑰（實測），用下面這條列出它們的名稱，逐台改成 <code>TRUE</code> 或移除這個鍵：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute instances list --format<span class="o">=</span>json <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  <span class="p">|</span> jq -r <span class="s1">&#39;.[] | select(any(.metadata.items[]?; .key==&#34;enable-oslogin&#34; and (.value|ascii_downcase)==&#34;false&#34;)) | .name&#39;</span></span></span></code></pre></div><p>專案 metadata 的 <code>ssh-keys</code> 仍然留著前任與現任成員的公鑰，OS Login 一旦還原成 <code>FALSE</code> 它們就又能用，確認全部 VM 都啟用之後用 <code>gcloud compute project-info remove-metadata --keys=ssh-keys</code> 清掉（實測）。</p>
<ul>
<li><strong>沒做時</strong>（不啟用 OS Login）：登入驗證仍是 metadata 裡的公鑰，寫在專案 metadata 的公鑰對專案裡每台 VM 都生效，成員離職時漏刪一把，那把金鑰就持續能登入整個專案（OS Login 文件，這次沒有實跑）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>直接在專案層級啟用、不經過單台，換到的是少一輪驗證，付出的是角色漏授時鎖住的是整個專案的每一台 VM；只由 Owner 驗證，看不出漏授，因為 Owner 持有全部權限（推論，這次沒有實跑）。</li>
<li>改用 SSH 憑證（OpenSSH CA），換到的是主機不全在 GCP 時也能用同一套，付出的是要維運 CA，見 <a href="/blog/devops/infra/03-network-foundation/openssh-ca-setup/" data-link-title="OpenSSH 內建 CA 的 SSH 憑證導入：CA 金鑰、sshd 設定、簽發、principal、撤銷與主機憑證" data-link-desc="用 OpenSSH 自帶的 ssh-keygen 與 sshd 建立 SSH 憑證的完整流程：使用者 CA 與主機 CA 的金鑰怎麼產生與保管、sshd 的 TrustedUserCAKeys 與 AuthorizedPrincipalsFile、簽發時的識別字串與有效期、憑證外洩時用 KRL 撤銷，以及用主機憑證取代逐台確認指紋，以及導入順序與兩個會讓所有人登不進去的設定：AuthorizedPrincipalsFile 的讀取權限、RevokedKeys 指的檔案不存在">OpenSSH 內建 CA 的 SSH 憑證導入</a>。</li>
<li>再加 <code>enable-oslogin-2fa=TRUE</code> 要求每次登入多一次第二因素，同樣先對單一 VM 設定驗證，連不上時改回 <code>FALSE</code>。付出的是每一位登入者的 Google 帳號都要先啟用兩步驟驗證，沒有啟用的會登不進去；而 OS Login 文件寫明兩步驟驗證不套用在服務帳號上，CI 這類登入者不受它約束（OS Login 文件，這次沒有實跑）。換到的是帳號密碼或本機的 refresh token 外洩時，登入還要第二因素（推論，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<h2 id="建-cloud-nat-並移除外部-ip">建 Cloud NAT 並移除外部 IP</h2>
<p>Cloud NAT 承接沒有外部 IP 的 VM 往外的連線，建在 Cloud Router 上，以 region 為單位生效。哪些出站連線改由 Cloud NAT 承接、哪些改走 Private Google Access，以及出站的驗證為什麼要在移除外部 IP 之後做，見概念篇〈拿掉外部 IP 之後的出站路徑：Cloud NAT 與 Private Google Access〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 在 VM 所在的 region 建 Cloud Router，再在上面建 Cloud NAT（出站 IP 自動配發）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud compute routers create &lt;router-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --network<span class="o">=</span>&lt;network-name&gt; --region<span class="o">=</span>&lt;region&gt;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud compute routers nats create &lt;nat-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --router<span class="o">=</span>&lt;router-name&gt; --region<span class="o">=</span>&lt;region&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --auto-allocate-nat-external-ips <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --nat-all-subnet-ip-ranges
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 外部服務要求來源 IP 白名單時：先預留靜態 IP，NAT 改用它出站</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud compute addresses create &lt;nat-ip-name&gt; --region<span class="o">=</span>&lt;region&gt;
</span></span><span class="line"><span class="ln">11</span><span class="cl">gcloud compute routers nats create &lt;nat-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --router<span class="o">=</span>&lt;router-name&gt; --region<span class="o">=</span>&lt;region&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --nat-external-ip-pool<span class="o">=</span>&lt;nat-ip-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  --nat-all-subnet-ip-ranges</span></span></code></pre></div><ul>
<li><code>&lt;router-name&gt;</code>、<code>&lt;nat-name&gt;</code>、<code>&lt;nat-ip-name&gt;</code>：自己取的資源名稱。</li>
<li><code>&lt;region&gt;</code>：VM 所在 zone 去掉最後的字母那一段，例如 zone <code>asia-east1-b</code> 對應 region <code>asia-east1</code>；VM 分布在幾個 region 就要各建一組。</li>
<li><code>--auto-allocate-nat-external-ips</code> 讓 GCP 自動配發出站用的外部 IP，這些 IP 可能變動；<code>--nat-external-ip-pool</code> 改用預留的靜態 IP。</li>
<li><code>--nat-all-subnet-ip-ranges</code> 讓這個 region 裡所有 subnet 都走這個 NAT，只想讓部分 subnet 出站時改用 <code>--nat-custom-subnet-ip-ranges</code> 逐一列出。</li>
</ul>
<p>VM 要存取 Cloud Storage、Artifact Registry 這類 Google API 時，subnet 開啟 Private Google Access，就能用內部 IP 連到 Google API，不經過 Cloud NAT：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gcloud compute networks subnets update &lt;subnet-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --region<span class="o">=</span>&lt;region&gt; --enable-private-ip-google-access</span></span></code></pre></div><p><code>&lt;subnet-name&gt;</code> 用 <code>gcloud compute networks subnets list --filter=&quot;region:&lt;region&gt;&quot;</code> 查；<code>default</code> 網路在每個 region 各有一個名為 <code>default</code> 的 subnet。</p>
<p>外部 IP 設定在 VM 網卡的 access config 上，<code>delete-access-config</code> 要靠名稱指定移除哪一個，而這個名稱依 VM 建立的方式不同，所以先查出來。對外提供服務的 VM 移除之前，要先把流量入口換到負載平衡器；只做管理用途的 VM 可以直接移除。先在一台 VM 上移除、確認，再做其餘的 VM：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查 VM 第一張網卡的 access config 名稱</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute instances describe &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;get(networkInterfaces[0].accessConfigs[0].name)&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 用查到的名稱移除外部 IP</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">gcloud compute instances delete-access-config &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --access-config-name<span class="o">=</span><span class="s2">&#34;&lt;access-config-name&gt;&#34;</span></span></span></code></pre></div><p>移除之後經由 IAP 連進這台 VM，執行 <code>curl -s https://ifconfig.me</code> 印出出站時對方看到的來源 IP；那個 IP 列在 <code>gcloud compute routers get-nat-ip-info &lt;router-name&gt; --region=&lt;region&gt;</code> 的清單裡，就代表出站走了 Cloud NAT。這次實測 <code>--auto-allocate-nat-external-ips</code> 配到的 IP 正好是這台 VM 剛釋放的外部 IP，移除前後 <code>curl</code> 印出同一個 IP，<code>gcloud compute addresses list</code> 的 <code>PURPOSE</code> 欄是 <code>NAT_AUTO</code>，所以判斷依據是那份清單，而不是移除前後的 IP 有沒有變。連不到網際網路時，用 <code>gcloud compute instances add-access-config &lt;instance-name&gt; --zone=&lt;zone&gt;</code> 把外部 IP 加回去，再回頭查 Cloud NAT 的 region 與 subnet 設定。</p>
<ul>
<li><strong>沒做時</strong>（保留外部 IP）：管理通道已經只放行 IAP，SSH 照樣安全；外部 IP 仍讓 VM 的其他 port 只靠防火牆擋，而防火牆規則一寫寬，那台 VM 就直接暴露（照規則的作用推論，這次沒有實跑）。Security Command Center 有標出帶公開 IP 的資源的 finding 類別，VM 不需要對外時移除之後少一類要處理的 finding（Security Health Analytics 文件）。</li>
<li><strong>替代做法</strong>：
<ul>
<li>反過來先移除 IP、再建 NAT，換到的是驗證不受外部 IP 干擾，付出的是移除到 NAT 建好之間 VM 連不到網際網路，<code>apt update</code> 這類操作會失敗（推論，這次沒有實跑）。</li>
<li>不建 Cloud NAT、只開 Private Google Access，換到的是不經過 NAT、不產生 NAT 的閘道時數與資料處理費，付出的是 VM 只連得到 Google API，連不到外部的套件來源與第三方 API（Cloud NAT 定價與 Private Google Access 文件，這次沒有實跑）。</li>
<li>用 <code>--nat-external-ip-pool</code> 改用預留的靜態 IP，換到的是外部服務可以把這個固定的 IP 列進白名單；Cloud NAT 定價頁寫明 NAT 使用的外部 IP 不論靜態或自動配發都按 IP 計費，差別在靜態 IP 從 NAT 拿下來之後仍然保留在專案裡、要另外釋放（Cloud NAT 定價與 IP 位址文件，這次沒有實跑）。</li>
</ul>
</li>
</ul>
<p>移除之後，這台 VM 只剩 IAP 一條管理路徑，連線一律加 <code>--tunnel-through-iap</code>；救援入口是〈準備救援入口：Serial Console 與本機救援帳號〉建好的那一條。</p>
]]></content:encoded></item><item><title>源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-25，在 Oracle Cloud &lt;code>ap-batam-1&lt;/code> 區域的一台 A1 主機（Arm 架構，Oracle Linux 10.2，核心 &lt;code>6.12.0-207.111.5.1.el10uek.aarch64&lt;/code>，1 OCPU、6 GB）上實測。主機在私有 subnet、沒有公開 IP，管理員經由 Oracle Cloud Bastion 的 port forwarding session 登入（建法見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/launch-instance-walkthrough/" data-link-title="Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入" data-link-desc="要在 Oracle Cloud 建一台沒有公開 IP、經由 Bastion 登入的 Always Free A1 主機，想照一次實測重做、看建立頁每一欄的取值與換一種做法的結果時讀；也用在建立請求一直回容量不足、managed SSH session 建立逾時，或沒放公鑰的主機想用 Run Command 救援、Oracle Linux 10 的主機上找不到 Bastion plugin、想改用 port forwarding session 登入的時候">Oracle Cloud 建立主機的實測紀錄&lt;/a>）。firewalld 2.4.3、nftables 後端、預設 zone &lt;code>public&lt;/code>，&lt;code>public&lt;/code> zone 只開 &lt;code>ssh&lt;/code> 與 &lt;code>dhcpv6-client&lt;/code>；Docker 用官方的靜態二進位檔 29.9.2（iptables 後端），測試容器是 &lt;code>busybox:1.37&lt;/code>。2026-10-26 另在本機 OrbStack 2.2.5 建的一台 Oracle Linux 10.2 機器上（核心是 OrbStack 的 &lt;code>7.2.12-orbstack&lt;/code>，firewalld 2.4.3 nftables 後端、Docker CE 29.9.2 以套件安裝、由 systemd 執行）測了 zone 的 sources、IPv6、daemon 的 &lt;code>ip&lt;/code> 設定、policy 上的 forward-port、本機 forward-port，以及 reload 清掉 &lt;code>DOCKER-USER&lt;/code> 的條件。OrbStack 的核心是它自己編的版本，量到的是 firewalld、iptables-nft、nftables 與 Docker 這幾個使用者層程式的行為，跟 Oracle Linux 映像核心的差異沒有比較。&lt;/p>&lt;/blockquote>
&lt;p>這篇記錄在一台 Linux 主機上驗證「源站只放行 Cloudflare 的位址」這類防火牆規則的過程：用兩個 network namespace 模擬位址在 Cloudflare 範圍內與範圍外的兩個來源、firewalld 以 ipset 與 rich rule 只放行 Cloudflare 位址到主機本身的程式、Docker 發佈的連接埠不受這條規則約束、&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件">&lt;code>DOCKER-USER&lt;/code>&lt;/a> 鏈的規則在 dockerd 重啟之後還在、在 &lt;code>firewall-cmd --reload&lt;/code> 之後是否消失取決於 firewalld 的後端與主機有沒有 direct 設定（iptables 後端時一律清掉；nftables 後端時看有沒有 direct rule 或 passthrough，OCI 的 Oracle Linux 映像自帶 passthrough）、firewalld policy 擋不到發佈的連接埠與它的原因、&lt;code>StrictForwardPorts=yes&lt;/code> 只管得到連接埠、掛在目的位址轉換之前的 nftables 表與它的開機載入、出站 DNS 只准送往 VCN 解析器的 policy 對主機與容器的不同效果、清理，以及各寫法在 reload、dockerd 重啟與重開機之後的對照表。每一種限制寫法的機制與取捨寫在概念文章 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a>，這裡不重寫。這篇沒有用到 Cloudflare 帳號，也沒有讓 Cloudflare 實際回源：防火牆比對的只有來源位址，所以用 Cloudflare 位址段裡的一個位址當來源，就能驗證規則。Cloudflare 回源時實際用的位址、Authenticated Origin Pulls、Worker 與 Tunnel，接上實際的 Cloudflare 帳號量在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-25，在 Oracle Cloud <code>ap-batam-1</code> 區域的一台 A1 主機（Arm 架構，Oracle Linux 10.2，核心 <code>6.12.0-207.111.5.1.el10uek.aarch64</code>，1 OCPU、6 GB）上實測。主機在私有 subnet、沒有公開 IP，管理員經由 Oracle Cloud Bastion 的 port forwarding session 登入（建法見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/launch-instance-walkthrough/" data-link-title="Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入" data-link-desc="要在 Oracle Cloud 建一台沒有公開 IP、經由 Bastion 登入的 Always Free A1 主機，想照一次實測重做、看建立頁每一欄的取值與換一種做法的結果時讀；也用在建立請求一直回容量不足、managed SSH session 建立逾時，或沒放公鑰的主機想用 Run Command 救援、Oracle Linux 10 的主機上找不到 Bastion plugin、想改用 port forwarding session 登入的時候">Oracle Cloud 建立主機的實測紀錄</a>）。firewalld 2.4.3、nftables 後端、預設 zone <code>public</code>，<code>public</code> zone 只開 <code>ssh</code> 與 <code>dhcpv6-client</code>；Docker 用官方的靜態二進位檔 29.9.2（iptables 後端），測試容器是 <code>busybox:1.37</code>。2026-10-26 另在本機 OrbStack 2.2.5 建的一台 Oracle Linux 10.2 機器上（核心是 OrbStack 的 <code>7.2.12-orbstack</code>，firewalld 2.4.3 nftables 後端、Docker CE 29.9.2 以套件安裝、由 systemd 執行）測了 zone 的 sources、IPv6、daemon 的 <code>ip</code> 設定、policy 上的 forward-port、本機 forward-port，以及 reload 清掉 <code>DOCKER-USER</code> 的條件。OrbStack 的核心是它自己編的版本，量到的是 firewalld、iptables-nft、nftables 與 Docker 這幾個使用者層程式的行為，跟 Oracle Linux 映像核心的差異沒有比較。</p></blockquote>
<p>這篇記錄在一台 Linux 主機上驗證「源站只放行 Cloudflare 的位址」這類防火牆規則的過程：用兩個 network namespace 模擬位址在 Cloudflare 範圍內與範圍外的兩個來源、firewalld 以 ipset 與 rich rule 只放行 Cloudflare 位址到主機本身的程式、Docker 發佈的連接埠不受這條規則約束、<a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a> 鏈的規則在 dockerd 重啟之後還在、在 <code>firewall-cmd --reload</code> 之後是否消失取決於 firewalld 的後端與主機有沒有 direct 設定（iptables 後端時一律清掉；nftables 後端時看有沒有 direct rule 或 passthrough，OCI 的 Oracle Linux 映像自帶 passthrough）、firewalld policy 擋不到發佈的連接埠與它的原因、<code>StrictForwardPorts=yes</code> 只管得到連接埠、掛在目的位址轉換之前的 nftables 表與它的開機載入、出站 DNS 只准送往 VCN 解析器的 policy 對主機與容器的不同效果、清理，以及各寫法在 reload、dockerd 重啟與重開機之後的對照表。每一種限制寫法的機制與取捨寫在概念文章 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>，這裡不重寫。這篇沒有用到 Cloudflare 帳號，也沒有讓 Cloudflare 實際回源：防火牆比對的只有來源位址，所以用 Cloudflare 位址段裡的一個位址當來源，就能驗證規則。Cloudflare 回源時實際用的位址、Authenticated Origin Pulls、Worker 與 Tunnel，接上實際的 Cloudflare 帳號量在 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a>。</p>
<h2 id="前置條件主機登入方式與測試檔案">前置條件：主機、登入方式與測試檔案</h2>
<ul>
<li>
<p><strong>一台跑 firewalld 的 Linux 主機，有 sudo</strong>：指令以 Oracle Linux 10 與 firewalld 的 nftables 後端寫成。<code>firewall-cmd --state</code> 回 <code>running</code>、<code>sudo grep FirewallBackend /etc/firewalld/firewalld.conf</code> 回 <code>nftables</code> 才跟這次相同。主機上要有 <code>curl</code>、<code>dig</code>（套件 <code>bind-utils</code>）、<code>python3</code>、<code>iptables</code>（套件 <code>iptables-nft</code>）與 <code>nft</code>，<code>command -v curl dig python3 iptables nft</code> 印得出五個路徑就齊了；這台的平台映像都已經裝好。用 <code>ufw</code> 的主機（Ubuntu）上，Docker 發佈的連接埠同樣繞過 ufw，規則在 <code>ufw reload</code> 與重開機之後的存續、寫進 <code>/etc/ufw/after.rules</code> 的做法，記在 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a>。</p>
</li>
<li>
<p><strong>登入路徑跟要測的連接埠分開</strong>：這次經由 Bastion 連 SSH 的連接埠 22，測試只改動 8080 到 8083，<code>public</code> zone 的 <code>ssh</code> 沒有動，規則寫錯也不會把自己鎖在外面。主機有公開 IP、從網際網路直接 SSH 時，同樣只動測試用的連接埠。</p>
</li>
<li>
<p><strong>Docker 與測試映像</strong>：這台主機在私有 subnet，只有連到 Oracle 服務的 service gateway，連不到 <code>download.docker.com</code> 與 Docker Hub。所以在本機下載靜態二進位檔、匯出映像與 Cloudflare 的位址清單，再傳上主機：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -sSfLO https://download.docker.com/linux/static/stable/aarch64/docker-29.9.2.tgz
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker pull --platform linux/arm64 busybox:1.37 <span class="o">&amp;&amp;</span> docker save busybox:1.37 -o busybox-1.37.tar
</span></span><span class="line"><span class="ln">3</span><span class="cl">curl -s https://www.cloudflare.com/ips-v4 &gt; cf-ips-v4.txt
</span></span><span class="line"><span class="ln">4</span><span class="cl">ssh -F &lt;ssh 設定檔&gt; &lt;主機的 Host 名稱&gt; <span class="s1">&#39;mkdir -p /tmp/verify-fw&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">scp -F &lt;ssh 設定檔&gt; docker-29.9.2.tgz busybox-1.37.tar cf-ips-v4.txt &lt;主機的 Host 名稱&gt;:/tmp/verify-fw/</span></span></code></pre></div><p><code>aarch64</code> 對應 A1 這種 Arm 主機，x86 主機換成 <code>x86_64</code>，<code>--platform</code> 換成 <code>linux/amd64</code>；<code>29.9.2</code> 是 2026-10-25 時 <code>stable/aarch64/</code> 目錄裡最新的版本，照抄之前先打開那個目錄看一次。<code>&lt;ssh 設定檔&gt;</code> 是寫了 <code>ProxyJump</code> 經由 Bastion 的 ssh 設定（寫法在上面那篇 Oracle Cloud 實測紀錄的〈建 port forwarding session 並經由本機的轉送登入〉），<code>&lt;主機的 Host 名稱&gt;</code> 是那份設定裡主機的 <code>Host</code>。約 82 MB 的檔案經由 Bastion 傳了約 5 分鐘（本機計時，依本機的上行頻寬與 Bastion 而變）。<code>cf-ips-v4.txt</code> 在 2026-10-25 是 15 段 IPv4，最後一行沒有換行（<code>wc -l</code> 回 14）；清單會變，每次照著做時重抓。</p>
</li>
<li>
<p><strong>主機連得到網際網路時改用套件安裝</strong>：加 Docker 的 RHEL 套件庫再 <code>dnf install docker-ce</code>。Docker 寫防火牆規則的行為由 dockerd 決定，跟安裝方式無關；差別在套件版附 systemd 的 unit，裝好就由 systemd 執行 dockerd，下文 <code>start_dockerd</code> 與 <code>restart_dockerd</code> 裡的 <code>nohup dockerd</code> 與 <code>pkill -x dockerd</code> 要換成 <code>systemctl start docker</code> 與 <code>systemctl restart docker</code>（unit 帶 socket 啟用與自動重啟，<code>pkill</code> 之後 dockerd 會被拉起來），重開機之後 dockerd 也會自動啟動。這一段依套件的安裝方式推論，沒有實跑。</p>
</li>
<li>
<p><strong>暫存目錄用 <code>verify-</code> 開頭</strong>：測完一條 <code>find /tmp /var/tmp -maxdepth 1 -name 'verify-*'</code> 就查得出有沒有漏清的檔案。這台的 <code>/tmp</code> 在重開機之後還在；<code>/tmp</code> 是 tmpfs 的主機（Fedora、Debian 13 這類）重開機會清空它，那時把下文的 <code>D</code> 改成 <code>/var/tmp/verify-fw</code>，傳檔的目的地也跟著改。</p>
</li>
</ul>
<h2 id="共用的變數與函式">共用的變數與函式</h2>
<p>下文每一個指令區塊都在主機上同一個 root shell 裡依序執行（<code>sudo -i</code> 之後一路跑到清理），變數與函式只存在那個 shell 裡；中途換了 shell 或重開機之後，重新貼一次這個區塊。<code>HOSTIP</code> 填主機自己的 IP：<code>ip -br a</code> 主要網卡那一行的位址，取斜線之前的部分（<code>10.0.1.35/24</code> 填 <code>10.0.1.35</code>）。<code>RESOLVER</code> 填主機用的 DNS 解析器（<code>/etc/resolv.conf</code> 的 <code>nameserver</code>），Oracle Cloud VCN 裡是 <code>169.254.169.254</code>，只在出站 DNS 那一節用到。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nv">HOSTIP</span><span class="o">=</span>&lt;主機的 IP&gt;
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nv">RESOLVER</span><span class="o">=</span>&lt;主機的 DNS 解析器&gt;
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">D</span><span class="o">=</span>/tmp/verify-fw
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="nv">$D</span>/docker:<span class="nv">$PATH</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nb">cd</span> <span class="nv">$D</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">ns_setup<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  ip netns add verify-cf<span class="p">;</span> ip netns add verify-other
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ip link add vcf-h <span class="nb">type</span> veth peer name vcf-n netns verify-cf
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  ip link add voth-h <span class="nb">type</span> veth peer name voth-n netns verify-other
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ip addr add 192.0.2.1/32 dev vcf-h<span class="p">;</span> ip link <span class="nb">set</span> vcf-h up            <span class="c1"># 主機這一端的位址，只當 namespace 的閘道</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ip addr add 192.0.2.5/32 dev voth-h<span class="p">;</span> ip link <span class="nb">set</span> voth-h up
</span></span><span class="line"><span class="ln">12</span><span class="cl">  ip route add 173.245.48.10/32 dev vcf-h                              <span class="c1"># 回給 verify-cf 的封包從 vcf-h 送出</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  ip route add 203.0.113.10/32 dev voth-h
</span></span><span class="line"><span class="ln">14</span><span class="cl">  ip -n verify-cf addr add 173.245.48.10/32 dev vcf-n<span class="p">;</span> ip -n verify-cf link <span class="nb">set</span> vcf-n up<span class="p">;</span> ip -n verify-cf link <span class="nb">set</span> lo up
</span></span><span class="line"><span class="ln">15</span><span class="cl">  ip -n verify-cf route add 192.0.2.1/32 dev vcf-n<span class="p">;</span> ip -n verify-cf route add default via 192.0.2.1 dev vcf-n
</span></span><span class="line"><span class="ln">16</span><span class="cl">  ip -n verify-other addr add 203.0.113.10/32 dev voth-n<span class="p">;</span> ip -n verify-other link <span class="nb">set</span> voth-n up<span class="p">;</span> ip -n verify-other link <span class="nb">set</span> lo up
</span></span><span class="line"><span class="ln">17</span><span class="cl">  ip -n verify-other route add 192.0.2.5/32 dev voth-n<span class="p">;</span> ip -n verify-other route add default via 192.0.2.5 dev voth-n
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">probe<span class="o">()</span> <span class="o">{</span>  <span class="c1"># probe &lt;連接埠&gt;：兩個來源各連一次 http://$HOSTIP:&lt;連接埠&gt;/，-m 5 是 5 秒逾時</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="k">for</span> ns in verify-cf verify-other<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nv">out</span><span class="o">=</span><span class="k">$(</span>ip netns <span class="nb">exec</span> <span class="nv">$ns</span> curl -sS -m <span class="m">5</span> -o /dev/null -w <span class="s1">&#39;%{http_code}&#39;</span> http://<span class="nv">$HOSTIP</span>:<span class="nv">$1</span>/ 2&gt;<span class="p">&amp;</span>1<span class="k">)</span><span class="p">;</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nb">printf</span> <span class="s1">&#39;  %-13s -&gt; :%s  rc=%s  %s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$ns</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">start_dockerd<span class="o">()</span> <span class="o">{</span>  <span class="c1"># 背景啟動 dockerd，等到 docker info 回得了（最多 30 秒）</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  nohup dockerd &gt;&gt;<span class="nv">$D</span>/dockerd.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="k">for</span> i in <span class="k">$(</span>seq 30<span class="k">)</span><span class="p">;</span> <span class="k">do</span> docker info &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">&amp;&amp;</span> break<span class="p">;</span> sleep 1<span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">restart_dockerd<span class="o">()</span> <span class="o">{</span>  <span class="c1"># 停掉 dockerd、等它結束、再啟動，並把測試容器啟動回來</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">  pkill -x dockerd<span class="p">;</span> <span class="k">while</span> pgrep -x dockerd &gt;/dev/null<span class="p">;</span> <span class="k">do</span> sleep 1<span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">  start_dockerd
</span></span><span class="line"><span class="ln">32</span><span class="cl">  docker start verify-web-all verify-web-lo verify-web-v &gt;/dev/null 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> sleep <span class="m">2</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p><code>docker start</code> 的三個容器在〈Docker 發佈的連接埠與 firewalld 的規則〉那一節建立，在那之前呼叫 <code>restart_dockerd</code> 時這一行什麼都不做。</p>
<h2 id="用-network-namespace-模擬-cloudflare-與其他來源">用 network namespace 模擬 Cloudflare 與其他來源</h2>
<p>要測「只放行 Cloudflare 的位址」，需要兩個來源：一個的位址在 Cloudflare 的範圍裡，一個不在。<code>ns_setup</code> 在主機上建兩個 <a href="/blog/devops/infra/knowledge-cards/network-namespace/" data-link-title="Network Namespace（網路命名空間）" data-link-desc="容器的網路為什麼跟主機分開、要在一台主機上模擬另一個來源位址做防火牆實測、或看到 ip netns 與 veth 時，查 Linux 核心的網路隔離單位與它跟 Kubernetes namespace 的差別">network namespace</a>（核心裡各自獨立的一套網路介面與路由表），各用一對 veth（成對的虛擬網路介面，從一端送進去的封包從另一端出來）接到主機：<code>verify-cf</code> 的位址是 <code>173.245.48.10</code>（落在 Cloudflare 的 <code>173.245.48.0/20</code>），<code>verify-other</code> 的位址是 <code>203.0.113.10</code>（RFC 5737 保留給文件用的 TEST-NET-3，不在任何 Cloudflare 範圍）。兩者都對主機的 IP 發連線，封包從主機的 <code>vcf-h</code>、<code>voth-h</code> 介面進來，經過的防火牆路徑跟從網卡進來的封包相同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ns_setup
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --get-zone-of-interface<span class="o">=</span>vcf-h<span class="p">;</span> firewall-cmd --get-default-zone</span></span></code></pre></div><p><code>ns_setup</code> 只跑一次；namespace 已經存在時再跑會印一串 <code>File exists</code>。<code>--get-zone-of-interface=vcf-h</code> 回 <code>no zone</code>、<code>--get-default-zone</code> 回 <code>public</code>：veth 介面沒有被指派到任何 zone，firewalld 把它們的流量交給預設 zone 處理，跟主機的網卡同一個 zone。</p>
<p>下文輸出裡的 <code>10.0.1.35</code> 是這次那台主機的私有 IP，每段輸出前的 <code>==</code> 行是實測時腳本印的說明。<code>probe</code> 的輸出有三種讀法：<code>rc=0  200</code> 是連上並拿到回應；<code>rc=7</code>、<code>after 0 ms: Could not connect to server</code> 是主機立即回拒（firewalld 以 ICMP 回拒，或沒有程式在聽）；<code>rc=28</code>、<code>Connection timed out after 5002 milliseconds</code> 是封包被丟棄、沒有任何回應。連線失敗時 <code>curl</code> 另外印一行 <code>000</code>（<code>-w</code> 印的狀態碼），下文的輸出把那一行省略了。</p>
<p>這一步可以換成實體的兩個來源（另一台主機，或實際讓 Cloudflare 回源），結果相同，因為防火牆只看封包的來源位址（依規則的比對方式推論）；代價是要多一台主機或一個 Cloudflare 帳號，而且一般主機的位址不會落在 Cloudflare 的範圍裡，Cloudflare 位址被放行的情形測不到。</p>
<h2 id="firewalld-以-ipset-只放行-cloudflare-位址到主機本身的程式">firewalld 以 ipset 只放行 Cloudflare 位址到主機本身的程式</h2>
<p>主機上聽 8080 的程式代表直接跑在主機上的網站程式。還沒加任何規則時，兩個來源都被 <code>public</code> zone 回拒：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nohup python3 -m http.server <span class="m">8080</span> --directory <span class="nv">$D</span> &gt;<span class="nv">$D</span>/http8080.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sleep 1<span class="p">;</span> probe <span class="m">8080</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== 沒有任何放行 8080 的規則
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-cf     -&gt; :8080  rc=7  curl: (7) Failed to connect to 10.0.1.35 port 8080 after 0 ms: Could not connect to server
</span></span><span class="line"><span class="ln">3</span><span class="cl">  verify-other  -&gt; :8080  rc=7  curl: (7) Failed to connect to 10.0.1.35 port 8080 after 0 ms: Could not connect to server</span></span></code></pre></div><p>把 Cloudflare 的位址段放進一個 ipset，再加一條 rich rule（firewalld 可以同時寫來源、連接埠與動作的規則）放行這個 ipset 到 8080：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --new-ipset<span class="o">=</span>cloudflare-v4 --type<span class="o">=</span>hash:net     <span class="c1"># hash:net 存的是位址段</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --permanent --ipset<span class="o">=</span>cloudflare-v4 --add-entries-from-file<span class="o">=</span><span class="nv">$D</span>/cf-ips-v4.txt
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --add-rich-rule<span class="o">=</span><span class="s1">&#39;rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; port port=&#34;8080&#34; protocol=&#34;tcp&#34; accept&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --reload
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --ipset<span class="o">=</span>cloudflare-v4 --get-entries <span class="p">|</span> wc -l
</span></span><span class="line"><span class="ln">6</span><span class="cl">probe <span class="m">8080</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">15
</span></span><span class="line"><span class="ln">2</span><span class="cl">== 放行 ipset cloudflare-v4 到 8080
</span></span><span class="line"><span class="ln">3</span><span class="cl">  verify-cf     -&gt; :8080  rc=0  200
</span></span><span class="line"><span class="ln">4</span><span class="cl">  verify-other  -&gt; :8080  rc=7  curl: (7) Failed to connect to 10.0.1.35 port 8080 after 0 ms: Could not connect to server</span></span></code></pre></div><p>ipset 裡是 15 段，跟下載的清單相同。正式設定時把 <code>8080</code> 換成 <code>80</code> 與 <code>443</code>（或 <code>service=&quot;http&quot;</code>、<code>service=&quot;https&quot;</code>）。這一步的選項與替代做法：</p>
<ul>
<li><strong><code>--permanent</code> 加 <code>--reload</code>，或不帶 <code>--permanent</code></strong>：帶 <code>--permanent</code> 的規則寫進設定檔；不帶時只改執行中的規則，下一次 <code>--reload</code> 就消失。這次用 <code>--permanent</code>，dockerd 啟動之後與 reload 之後 8080 的結果都跟上面相同（實測）；重開機之後依 <code>--permanent</code> 的機制會在，重開機後沒有再連 8080。</li>
<li><strong>ipset，或每一段各寫一條 rich rule</strong>：每段一條也行得通，15 段就是 15 條規則，清單更新時要逐條比對；ipset 更新時只換集合的內容，rich rule 不動。依 firewalld 的規則寫法推論，沒有實跑。</li>
<li><strong>把 ipset 加到一個 zone 的 sources</strong>：來源落在 ipset 的連線改由那個 zone 處理，放行的範圍是那個 zone 開的全部服務與連接埠。OrbStack 那一台上建了一個只開 8080 的 zone，sources 放 <code>ipset:cloudflare-v4</code>（實測時這個 zone 叫 <code>verify-cf</code>，跟 namespace 同名，這裡改名避免混淆；<code>firewall-cmd --permanent --new-zone=verify-cfzone</code>、<code>--zone=verify-cfzone --add-source=ipset:cloudflare-v4</code>、<code>--zone=verify-cfzone --add-port=8080/tcp</code>）：<code>verify-cf</code> 連 8080 拿到 200、<code>verify-other</code> 被回拒，跟 rich rule 的結果相同；差別在 <code>verify-cf</code> 連 22 時回 <code>No route to host</code>（被 <code>verify-cfzone</code> 回拒，它沒有開 ssh），<code>verify-other</code> 連 22 仍由 <code>public</code> zone 處理。zone sources 接管的是那些位址的全部入站連線，來源落在清單裡的連線只看這個 zone 開了什麼，<code>public</code> zone 的設定對它們不再生效。用 <code>trusted</code> zone 時則相反，那些位址連任何連接埠都放行。Docker 發佈的 8081 照樣兩個來源都連得到，zone 管不到轉送路徑。測完以 <code>firewall-cmd --permanent --delete-zone=verify-cfzone</code> 加 <code>firewall-cmd --reload</code> 刪掉這個 zone。</li>
<li><strong>IPv6</strong>：OCI 這台主機沒有全域的 IPv6 位址，在 OrbStack 那一台上用一個 ULA（unique local address，<code>fd00::/8</code> 裡只在本地使用的 IPv6 位址）<code>fd00:beef::1</code> 當主機的 IPv6 位址（兩個來源加 IPv6 位址的指令見 ufw 那一篇〈共用的函式與兩個測試來源〉的 <code>ns_setup6</code>）、兩個 namespace 各加一個 IPv6 位址（<code>verify-cf</code> 用 Cloudflare 的 <code>2606:4700::10</code>，<code>verify-other</code> 用文件保留的 <code>2001:db8::10</code>）測。<code>--option=family=inet6</code> 建的 ipset 放進 <code>ips-v6</code> 的 7 段，rich rule 寫 <code>family=&quot;ipv6&quot;</code>：<code>verify-cf</code> 經由 IPv6 連 8080 拿到 200、<code>verify-other</code> 被回拒，跟 IPv4 相同。同一組 IPv6 位址也拿來量 Docker 發佈的 8081，結果寫在〈Docker 發佈的連接埠與 firewalld 的規則〉裡 IPv6 那一份發佈的那一段。測完以 <code>--remove-rich-rule</code> 刪掉這一段與那一段加的兩條 IPv6 rich rule（8080 與 8081）再 reload；namespace 裡的 IPv6 位址隨清理段刪 namespace 一起消失，<code>lo</code> 上的 ULA 與 <code>cloudflare-v6</code> ipset 寫在清理段。</li>
</ul>
<h2 id="docker-發佈的連接埠與-firewalld-的規則">Docker 發佈的連接埠與 firewalld 的規則</h2>
<p>解開靜態檔、在背景啟動 dockerd，看它對 firewalld 與 iptables 做了什麼：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sysctl net.ipv4.ip_forward
</span></span><span class="line"><span class="ln">2</span><span class="cl">tar xzf <span class="nv">$D</span>/docker-29.9.2.tgz -C <span class="nv">$D</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">start_dockerd
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker info --format <span class="s1">&#39;firewall backend: {{.FirewallBackend}}&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sysctl net.ipv4.ip_forward
</span></span><span class="line"><span class="ln">6</span><span class="cl">firewall-cmd --zone<span class="o">=</span>docker --list-all<span class="p">;</span> firewall-cmd --info-policy<span class="o">=</span>docker-forwarding
</span></span><span class="line"><span class="ln">7</span><span class="cl">iptables -S FORWARD</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">net.ipv4.ip_forward = 0
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">firewall backend: {iptables+firewalld [...]}
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">net.ipv4.ip_forward = 1
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">docker (active)
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  target: ACCEPT
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  interfaces: docker0
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">docker-forwarding (active)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  priority: -1
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  target: ACCEPT
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ingress-zones: ANY
</span></span><span class="line"><span class="ln">11</span><span class="cl">  egress-zones: docker
</span></span><span class="line"><span class="ln">12</span><span class="cl">-P FORWARD DROP
</span></span><span class="line"><span class="ln">13</span><span class="cl">-A FORWARD -j DOCKER-USER
</span></span><span class="line"><span class="ln">14</span><span class="cl">-A FORWARD -j DOCKER-FORWARD</span></span></code></pre></div><p>（zone 與 policy 只留相關的欄位。）dockerd 把 <code>ip_forward</code> 從 0 改成 1，在 firewalld 裡建了 <code>docker</code> zone 與 <code>docker-forwarding</code> policy；iptables 的 FORWARD 鏈在 dockerd 啟動之後預設政策是 DROP（同一台主機在沒有 dockerd 時是 ACCEPT），開頭跳到 <code>DOCKER-USER</code> 與 <code>DOCKER-FORWARD</code>。接著跑三個容器：一個發佈在全部介面、一個只發佈在本機位址、一個帶存取記錄的當作後面的對照組：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">docker load -i <span class="nv">$D</span>/busybox-1.37.tar
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir -p <span class="nv">$D</span>/www <span class="o">&amp;&amp;</span> <span class="nb">echo</span> hello &gt; <span class="nv">$D</span>/www/index.html
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># busybox 的 httpd：-f 在前景執行、-p 聽的連接埠、-h 網頁根目錄；-v 印出每一筆存取</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">docker run -d --name verify-web-all -v <span class="nv">$D</span>/www:/www:ro -p 8081:80 busybox:1.37 httpd -f -p <span class="m">80</span> -h /www
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">docker run -d --name verify-web-lo  -v <span class="nv">$D</span>/www:/www:ro -p 127.0.0.1:8082:80 busybox:1.37 httpd -f -p <span class="m">80</span> -h /www
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">docker run -d --name verify-web-v   -v <span class="nv">$D</span>/www:/www:ro -p 8083:80 busybox:1.37 httpd -f -v -p <span class="m">80</span> -h /www
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">sleep <span class="m">2</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">firewall-cmd --zone<span class="o">=</span>public --list-ports<span class="p">;</span> firewall-cmd --zone<span class="o">=</span>public --list-rich-rules
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">probe 8081<span class="p">;</span> probe <span class="m">8082</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">curl -sS -o /dev/null -w <span class="s1">&#39;%{http_code}\n&#39;</span> http://127.0.0.1:8082/</span></span></code></pre></div><p>同一行裡 <code>docker run</code> 的 <code>-v $D/www:/www:ro</code> 是把主機的目錄唯讀掛進容器，<code>httpd -f -v</code> 的 <code>-v</code> 是 busybox httpd 自己的記錄選項，兩者不同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">（--list-ports 沒有輸出）
</span></span><span class="line"><span class="ln">2</span><span class="cl">rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; port port=&#34;8080&#34; protocol=&#34;tcp&#34; accept
</span></span><span class="line"><span class="ln">3</span><span class="cl">== -p 8081:80（沒有指定位址）
</span></span><span class="line"><span class="ln">4</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-other  -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">6</span><span class="cl">== -p 127.0.0.1:8082:80
</span></span><span class="line"><span class="ln">7</span><span class="cl">  verify-cf     -&gt; :8082  rc=7  ... Could not connect to server
</span></span><span class="line"><span class="ln">8</span><span class="cl">  verify-other  -&gt; :8082  rc=7  ... Could not connect to server
</span></span><span class="line"><span class="ln">9</span><span class="cl">200</span></span></code></pre></div><p>firewalld 沒有任何一條規則提到 8081，<code>public</code> zone 對未列出的連線一律回拒，而兩個來源都拿到容器的回應。看改寫目的地的那條規則與容器記到的來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">iptables -t nat -S DOCKER <span class="p">|</span> grep <span class="m">8081</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">probe <span class="m">8083</span> &gt;/dev/null<span class="p">;</span> docker logs verify-web-v 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -2</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8081 -j DNAT --to-destination 172.17.0.2:80
</span></span><span class="line"><span class="ln">2</span><span class="cl">[::ffff:173.245.48.10]:34162: response:200
</span></span><span class="line"><span class="ln">3</span><span class="cl">[::ffff:203.0.113.10]:33384: response:200</span></span></code></pre></div><p><code>DNAT</code>（<a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">目的位址轉換</a>）那一條就是在主機防火牆之前改寫目的地的規則。容器的記錄裡兩個來源的位址原樣出現：容器看得到真正的來源，所以來源限制做得到，只是要寫在別的位置。只發佈在 <code>127.0.0.1</code> 的 8082 從兩個外部來源都連不到、主機本機連得到；這是 Docker 29.9.2 的結果，Docker〈Port publishing〉寫明 28.0.0 以前同一個 L2 網段的其他主機連得到發佈在 127.0.0.1 的連接埠。對外改由主機上的反向代理接時，反向代理受〈firewalld 以 ipset 只放行 Cloudflare 位址到主機本身的程式〉那種 rich rule 約束。</p>
<p>預設的發佈位址可以在 Docker 的設定裡改，OrbStack 那一台實跑了兩種。<code>/etc/docker/daemon.json</code> 寫 <code>{ &quot;ip&quot;: &quot;127.0.0.1&quot; }</code> 並重啟 Docker 之後，預設 bridge 網路上 <code>-p 8085:80</code> 的容器發佈在 <code>127.0.0.1:8085</code>，兩個外部來源都連不到；同時間另一個接在自訂網路（<code>docker network create verify-custom</code>）上、<code>-p 8086:80</code> 的容器仍發佈在 <code>0.0.0.0:8086</code> 與 <code>[::]:8086</code>，兩個外部來源都連得到：<code>ip</code> 選項只作用在預設的 bridge 網路。自訂網路建立時帶 <code>-o com.docker.network.bridge.host_binding_ipv4=127.0.0.1</code>（這次建的網路叫 <code>verify-hb</code>），那個網路上 <code>-p 8087:80</code> 的容器就發佈在 <code>127.0.0.1:8087</code>。<code>docker ps</code> 的 PORTS 欄看得到每個容器實際發佈在哪個位址。compose 的自訂網路在 <code>networks:</code> 底下的 <code>driver_opts</code> 寫同一個選項，沒有實跑。測完要還原，否則之後各節重建的容器都會發佈在 <code>127.0.0.1</code>、結果跟文章不同：刪掉這三個容器、<code>docker network rm verify-custom verify-hb</code>、<code>rm /etc/docker/daemon.json</code>、<code>systemctl restart docker</code>。</p>
<p>IPv6 那一份發佈走另一條路。OrbStack 那一台用〈firewalld 以 ipset 只放行 Cloudflare 位址到主機本身的程式〉的 IPv6 那一項建的位址量：Docker 的 <code>-p 8081:80</code> 同時發佈在 IPv6，而容器的網路沒有開 IPv6 時，<code>[::]:8081</code> 由 docker-proxy 這個主機上的程式（<a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy</a> 的一種）接聽。沒有任何規則時兩個來源經由 IPv6 連 8081 都被 <code>public</code> zone 回拒，加一條 <code>rule family=&quot;ipv6&quot; source ipset=&quot;cloudflare-v6&quot; port port=&quot;8081&quot; protocol=&quot;tcp&quot; accept</code> 之後只有 <code>verify-cf</code> 連得到。IPv6 那一份走 input 路徑、受 zone 管，跟 IPv4 那一份相反；同一條 rich rule 加上之後，IPv4 的 8081 仍然兩個來源都連得到。容器的網路帶 <code>--ipv6</code> 時，Docker 改用 ip6tables 的位址轉換處理 IPv6，那一種沒有測。</p>
<h2 id="docker-user-鏈的限制效果與規則的存續">DOCKER-USER 鏈的限制效果與規則的存續</h2>
<p>在 <code>DOCKER-USER</code> 寫一條跳到自建鏈 <code>CF-ONLY</code> 的規則，<code>CF-ONLY</code> 對 Cloudflare 的每一段回到原本的流程（RETURN），其餘丟棄。這一組指令在這一節會跑兩次，所以寫成函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">add_cf_only<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  iptables -N CF-ONLY
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">while</span> <span class="nb">read</span> -r net<span class="p">;</span> <span class="k">do</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$net</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> iptables -A CF-ONLY -s <span class="s2">&#34;</span><span class="nv">$net</span><span class="s2">&#34;</span> -j RETURN<span class="p">;</span> <span class="k">done</span> &lt; &lt;<span class="o">(</span>cat <span class="nv">$D</span>/cf-ips-v4.txt<span class="p">;</span> <span class="nb">echo</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  iptables -A CF-ONLY -j DROP
</span></span><span class="line"><span class="ln">5</span><span class="cl">  iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport <span class="m">8081</span> --ctdir ORIGINAL -j CF-ONLY
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">add_cf_only
</span></span><span class="line"><span class="ln">8</span><span class="cl">probe 8081<span class="p">;</span> probe <span class="m">8083</span></span></span></code></pre></div><p>正式設定把 8081 換成 80 與 443 時，跳轉那一條要加 <code>-m conntrack --ctstate DNAT</code>，只比對送往發佈連接埠、經過位址轉換的連線；少了它，容器自己往外連 443、容器之間連 80 的封包也會進 <code>CF-ONLY</code> 被丟掉（ufw 那一篇在 Ubuntu 上補測過：<a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈DOCKER-USER 規則的效果，以及它在 ufw reload、Docker 重啟與重開機之後的存續〉）。</p>
<p><code>&lt; &lt;(cat …; echo)</code> 補一個換行，因為 Cloudflare 的清單最後一行沒有換行，<code>read</code> 會漏讀最後一段。每一段各寫一條 <code>-s</code> 而不用 iptables 的 <code>-m set --match-set cloudflare-v4</code>：firewalld 的 nftables 後端把 ipset 建成 nftables 的集合，不是 iptables 的 <code>set</code> 模組讀的那種核心 ipset，推論 <code>-m set</code> 引用不到它，沒有實跑。<code>--ctorigdstport 8081</code> 比對<a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">連線追蹤</a>記下的原始目的連接埠：封包到 <code>DOCKER-USER</code> 時目的地已經改成容器的 80，寫 <code>--dport 8081</code> 比對不到。<code>--ctdir ORIGINAL</code> 讓規則只看發起方向的封包，容器回覆的封包不進 <code>CF-ONLY</code>。<code>-I</code> 把規則插在 <code>DOCKER-USER</code> 最前面；最後那一條用 <code>DROP</code>，範圍外的來源等到逾時，換成 <code>REJECT</code> 會變成立即失敗，沒有比較。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== DOCKER-USER 只放行 Cloudflare 到原始目的連接埠 8081
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">3</span><span class="cl">  verify-other  -&gt; :8081  rc=28  curl: (28) Connection timed out after 5002 milliseconds
</span></span><span class="line"><span class="ln">4</span><span class="cl">== 對照：沒有套規則的 8083
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-cf     -&gt; :8083  rc=0  200
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-other  -&gt; :8083  rc=0  200</span></span></code></pre></div><p>規則擋得住。接著分開看 firewalld 重新載入與 dockerd 重啟。先重新載入 firewalld：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">2</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">iptables -S DOCKER-USER<span class="p">;</span> iptables -S CF-ONLY<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== firewall-cmd --reload 之後
</span></span><span class="line"><span class="ln">2</span><span class="cl">-N DOCKER-USER
</span></span><span class="line"><span class="ln">3</span><span class="cl">iptables: No chain/target/match by that name.
</span></span><span class="line"><span class="ln">4</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-other  -&gt; :8081  rc=0  200</span></span></code></pre></div><p><code>firewall-cmd --reload</code> 之後，<code>DOCKER-USER</code> 只剩空的鏈，<code>CF-ONLY</code> 整條不見了，範圍外的來源又連得到。分出是誰清掉的：先停掉 dockerd，建一條測試鏈再 reload，再在 dockerd 執行中做一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">pkill -x dockerd<span class="p">;</span> <span class="k">while</span> pgrep -x dockerd &gt;/dev/null<span class="p">;</span> <span class="k">do</span> sleep 1<span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">iptables -N VERIFY-T<span class="p">;</span> iptables -A VERIFY-T -j RETURN<span class="p">;</span> iptables -I DOCKER-USER -j VERIFY-T
</span></span><span class="line"><span class="ln">3</span><span class="cl">iptables -S <span class="p">|</span> grep -c VERIFY-T
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep 2<span class="p">;</span> iptables -S <span class="p">|</span> grep -c VERIFY-T
</span></span><span class="line"><span class="ln">5</span><span class="cl">restart_dockerd
</span></span><span class="line"><span class="ln">6</span><span class="cl">iptables -N VERIFY-T<span class="p">;</span> iptables -A VERIFY-T -j RETURN<span class="p">;</span> iptables -I DOCKER-USER -j VERIFY-T
</span></span><span class="line"><span class="ln">7</span><span class="cl">iptables -S <span class="p">|</span> grep -c VERIFY-T
</span></span><span class="line"><span class="ln">8</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep 3<span class="p">;</span> iptables -S <span class="p">|</span> grep -c VERIFY-T
</span></span><span class="line"><span class="ln">9</span><span class="cl">grep -i firewalld <span class="nv">$D</span>/dockerd.log <span class="p">|</span> tail -3</span></span></code></pre></div><p>兩次都是 reload 之前 3、reload 之後 0：清掉 iptables 裡自建規則的是 firewalld 的重新載入，跟 dockerd 有沒有在跑無關；dockerd 的記錄裡只有它在 reload 之後重建自己 zone 與 policy 的訊息（<code>Firewalld: docker zone already exists, returning</code> 這類）。這台主機的 <code>/etc/firewalld/firewalld.conf</code> 是 <code>FlushAllOnReload=yes</code>；〈firewalld.conf〉寫明這是預設值，作用是 reload 時清掉全部執行中的規則。</p>
<p>OrbStack 那一台的 firewalld 沒有任何 direct 設定（<code>firewall-cmd --direct --get-all-rules</code> 與 <code>--get-all-passthroughs</code> 都沒有輸出），<code>DOCKER-USER</code> 裡的同一條規則在 <code>FlushAllOnReload=yes</code> 與 <code>no</code> 下 reload 之後都還在、照樣擋得住。在那一台加一條 direct rule 之後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT <span class="m">0</span> -d 169.254.0.0/16 -p tcp -j ACCEPT
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --reload
</span></span><span class="line"><span class="ln">3</span><span class="cl">iptables -N VERIFY-T<span class="p">;</span> iptables -I DOCKER-USER -j VERIFY-T
</span></span><span class="line"><span class="ln">4</span><span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport <span class="m">8081</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">iptables -S DOCKER-USER<span class="p">;</span> iptables -S VERIFY-T</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-N DOCKER-USER
</span></span><span class="line"><span class="ln">2</span><span class="cl">iptables: No chain/target/match by that name.</span></span></code></pre></div><p>reload 之後 <code>DOCKER-USER</code> 只剩空鏈、自建鏈 <code>VERIFY-T</code> 整條不見，範圍外的來源又連得到；<code>FlushAllOnReload</code> 改成 <code>no</code> 再做一次，結果相同。那條 direct rule 只是用來觸發 firewalld 管理 iptables，內容不重要。測完 <code>firewall-cmd --permanent --direct --remove-rule …</code> 移除它，<code>/etc/firewalld/direct.xml</code> 會留下一個只有 <code>&lt;direct&gt;</code> 空元素的檔，刪掉再 reload。</p>
<p>direct 設定的另一種是 passthrough（直接交給 iptables 的一行規則）。同一台改加一條 passthrough、不加 direct rule：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --direct --add-passthrough ipv4 -A OUTPUT -d 169.254.0.2/32 -p tcp --dport <span class="m">3260</span> -j ACCEPT
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport <span class="m">8081</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">iptables -S DOCKER-USER</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-N DOCKER-USER</span></span></code></pre></div><p>只有一條 passthrough 時 reload 也清掉了 <code>DOCKER-USER</code> 的規則；加 passthrough 之前（沒有任何 direct 設定時），同一條規則留過了 reload。量完移除 passthrough、刪掉 <code>direct.xml</code> 再 reload，<code>--get-all-passthroughs</code> 回 0。所以在 nftables 後端（這兩台都是）上，清掉 iptables 自建規則的條件是 firewalld 有沒有 direct 設定（direct rule 或 passthrough 任一種），跟 <code>FlushAllOnReload</code> 的值無關。查的時候兩個指令都要跑：<code>--get-all-rules</code> 只列 direct rule，不列 passthrough。</p>
<p>firewalld 改用 iptables 後端時沒有這個條件。OrbStack 那一台把 <code>/etc/firewalld/firewalld.conf</code> 的 <code>FirewallBackend</code> 改成 <code>iptables</code>、重啟 firewalld 與 Docker，<code>--get-all-rules</code> 與 <code>--get-all-passthroughs</code> 都是 0，<code>DOCKER-USER</code> 裡的同一條規則在 <code>firewall-cmd --reload</code> 之後一樣不見了：原因依後端的設計推論是 iptables 後端的 firewalld 自己管理 iptables 的表，reload 時整張重建。量完改回 <code>nftables</code>、重啟兩個服務，<code>nft list tables | grep -c 'inet firewalld'</code> 回 1、<code>iptables -t nat -S DOCKER | grep -c 8081</code> 回 1，firewalld 的表與 Docker 的位址轉換規則都回來了。direct 設定也不是主機固定的屬性：之後裝的軟體可能再加進來，例如 fail2ban 的 <code>firewallcmd-*</code> 動作在 jail 啟動時寫 direct rule（依 fail2ban 的動作設定推論，沒有實跑），查的時候沒有、之後 reload 才清掉。只寫在執行中設定、沒有 <code>--permanent</code> 的 direct rule 是否同樣觸發，沒有量。</p>
<p>OCI 的 Oracle Linux 映像本來就有 passthrough。2026-10-26 用同一個映像（<code>Oracle-Linux-10.2-aarch64-2026.10.16-0</code>）建的另一台主機上：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ls -l /etc/firewalld/direct.xml
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -c <span class="s1">&#39;&lt;passthrough&#39;</span> /etc/firewalld/direct.xml
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --direct --get-all-rules <span class="p">|</span> wc -l<span class="p">;</span> firewall-cmd --direct --get-all-passthroughs <span class="p">|</span> wc -l</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-rw-r--r--. 1 root root 8495 Aug  8 00:48 /etc/firewalld/direct.xml
</span></span><span class="line"><span class="ln">2</span><span class="cl">32
</span></span><span class="line"><span class="ln">3</span><span class="cl">0
</span></span><span class="line"><span class="ln">4</span><span class="cl">32</span></span></code></pre></div><p><code>direct.xml</code> 有 32 條 passthrough，內容是 <code>BareMetalInstanceServices</code> 鏈：主機送往 <code>169.254.0.0/16</code>（Oracle Cloud 的執行個體中繼資料、iSCSI、DNS 與 NTP 這類服務）的連線，只放行列出的幾個服務連接埠，其中 iSCSI 的 3260 與部分 80 只限 root 的程式，其餘 TCP 與 UDP 一律回拒；每一條的註解寫著修改或移除之前先讀 Oracle 文件的〈Oracle-Provided Images〉。<code>--get-all-rules</code> 只列 direct rule、不列 passthrough，所以回 0；要用 <code>--get-all-passthroughs</code> 才看得到。依 OrbStack 那一台的 passthrough 結果，這 32 條就是第一輪 reload 清掉 <code>DOCKER-USER</code> 的原因；在這台主機上沒有移除它們再 reload 確認。這幾條規則保護主機與中繼資料服務之間的連線，刪掉它們換取 <code>DOCKER-USER</code> 的存續不划算；在 OCI 的 Oracle Linux 上，要長期使用的限制改寫進下文的自建 nftables 表。</p>
<p>再把同一組規則加回去，只重啟 dockerd、不動 firewalld：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">add_cf_only
</span></span><span class="line"><span class="ln">2</span><span class="cl">restart_dockerd
</span></span><span class="line"><span class="ln">3</span><span class="cl">iptables -S DOCKER-USER<span class="p">;</span> iptables -S CF-ONLY <span class="p">|</span> wc -l<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== 只重啟 dockerd（不 reload firewalld）
</span></span><span class="line"><span class="ln">2</span><span class="cl">-N DOCKER-USER
</span></span><span class="line"><span class="ln">3</span><span class="cl">-A DOCKER-USER -p tcp -m conntrack --ctorigdstport 8081 --ctdir ORIGINAL -j CF-ONLY
</span></span><span class="line"><span class="ln">4</span><span class="cl">17
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-other  -&gt; :8081  rc=28  curl: (28) Connection timed out after 5002 milliseconds</span></span></code></pre></div><p>dockerd 重啟不清 <code>DOCKER-USER</code>，<code>CF-ONLY</code> 的 17 行（建鏈一行、15 段 RETURN、一條 DROP）都在，照樣擋得住。iptables 指令加的規則只存在執行中的核心裡，重開機之後不在；實測那一輪重開機之前 <code>DOCKER-USER</code> 已經被 reload 清空，所以重開機這一項依機制寫成，沒有單獨實測。這一節做完時 <code>CF-ONLY</code> 還在，〈firewalld policy 對發佈連接埠的效果與轉送鏈的順序〉開頭的 reload 會清掉它。</p>
<h2 id="firewalld-policy-對發佈連接埠的效果與轉送鏈的順序">firewalld policy 對發佈連接埠的效果與轉送鏈的順序</h2>
<p>Docker 用 <code>docker-forwarding</code> policy 放行轉送到 <code>docker</code> zone 的流量，所以試著加一條優先序更高的 policy，從 <code>public</code> 轉送到 <code>docker</code> 只放行 Cloudflare。<code>firewall-cmd --reload</code> 會一併清掉〈DOCKER-USER 鏈的限制效果與規則的存續〉留下的 <code>CF-ONLY</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --new-policy<span class="o">=</span>cf-to-docker
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --add-ingress-zone<span class="o">=</span>public
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --add-egress-zone<span class="o">=</span>docker
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --set-priority<span class="o">=</span>-10      <span class="c1"># 比 docker-forwarding 的 -1 先比對</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --set-target<span class="o">=</span>DROP
</span></span><span class="line"><span class="ln">6</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --add-rich-rule<span class="o">=</span><span class="s1">&#39;rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; accept&#39;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">firewall-cmd --reload
</span></span><span class="line"><span class="ln">8</span><span class="cl">probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">restart_dockerd<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div><p>reload 剛完成就連，兩個來源連 8081 都立即被回拒；<code>restart_dockerd</code> 之後再連，兩個來源都拿到 8081 的回應，policy 沒有擋下範圍外的來源。reload 剛完成時的回拒，推論是 Docker 的目的位址轉換規則還沒重建、封包沒有被改寫而落到 <code>public</code> zone 的回拒：〈DOCKER-USER 鏈的限制效果與規則的存續〉裡 reload 之後等 2 秒、下面移除這條 policy 之後等 3 秒再連，8081 都通了；回拒持續多久沒有量。policy 擋不住的原因在 firewalld 自己的轉送鏈：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nft list chain inet firewalld filter_FORWARD</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">chain filter_FORWARD {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	type filter hook forward priority filter + 10; policy accept;
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	ct state { established, related } accept
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	ct status dnat accept
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	iifname &#34;lo&#34; accept
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	ct state invalid drop
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	...
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	jump filter_FORWARD_POLICIES
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	reject with icmpx admin-prohibited
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p><code>ct status dnat accept</code> 排在 <code>jump filter_FORWARD_POLICIES</code> 之前，經過目的位址轉換的連線在任何 policy 之前就被接受，policy 寫什麼都輪不到它。移除這條 policy，讓 <code>StrictForwardPorts</code> 那一節從沒有 policy 的狀態開始：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --delete-policy<span class="o">=</span>cf-to-docker
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">probe <span class="m">8081</span></span></span></code></pre></div><p>移除之後兩個來源都連得到 8081。</p>
<h2 id="strictforwardportsyes轉送鏈的變化與-forward-port-規則的比對條件">StrictForwardPorts=yes：轉送鏈的變化與 forward-port 規則的比對條件</h2>
<p>firewalld 轉送鏈 <code>filter_FORWARD</code> 開頭的 <code>ct status dnat accept</code> 由設定檔的 <code>StrictForwardPorts</code> 控制，預設 <code>no</code>。這一節開始時，ipset <code>cloudflare-v4</code>、8080 的 rich rule 與三個容器都在，<code>cf-to-docker</code> 已經移除。（實測時這一節在另一輪、以重新建好的 namespace、ipset 與 <code>verify-web-all</code> 跑，結果不受影響。）改成 <code>yes</code> 再看轉送鏈與連線結果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cp /etc/firewalld/firewalld.conf /etc/firewalld/firewalld.conf.verify-bak
</span></span><span class="line"><span class="ln">2</span><span class="cl">sed -i <span class="s1">&#39;s/^StrictForwardPorts=no/StrictForwardPorts=yes/&#39;</span> /etc/firewalld/firewalld.conf
</span></span><span class="line"><span class="ln">3</span><span class="cl">grep <span class="s1">&#39;^StrictForwardPorts=&#39;</span> /etc/firewalld/firewalld.conf
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">nft list chain inet firewalld filter_FORWARD<span class="p">;</span> nft list chain inet firewalld filter_FORWARD_dnat
</span></span><span class="line"><span class="ln">6</span><span class="cl">probe <span class="m">8081</span></span></span></code></pre></div><p><code>grep</code> 要回 <code>StrictForwardPorts=yes</code>；設定檔裡沒有這一行（較舊的 firewalld 沒有這個選項）時 <code>sed</code> 什麼都不改、也不報錯，後面的結果會跟下面不同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">StrictForwardPorts=yes
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">	ct state { established, related } accept
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	ct status dnat jump filter_FORWARD_dnat
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	...
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	chain filter_FORWARD_dnat {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		reject with icmpx admin-prohibited
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	}
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">== StrictForwardPorts=yes，還沒有任何放行
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  verify-cf     -&gt; :8081  rc=7  ... Could not connect to server
</span></span><span class="line"><span class="ln">10</span><span class="cl">  verify-other  -&gt; :8081  rc=7  ... Could not connect to server</span></span></code></pre></div><p>經過目的位址轉換的連線改跳到 <code>filter_FORWARD_dnat</code>，那條鏈只有回拒，兩個來源都連不到發佈的 8081。先試 policy：從 <code>public</code> 轉送到 <code>docker</code>、只放行 Cloudflare，target 用預設的 CONTINUE：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --new-policy<span class="o">=</span>cf-to-docker
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --add-ingress-zone<span class="o">=</span>public
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --add-egress-zone<span class="o">=</span>docker
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --set-priority<span class="o">=</span>-10
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>cf-to-docker --add-rich-rule<span class="o">=</span><span class="s1">&#39;rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; accept&#39;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep 3<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">restart_dockerd<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">firewall-cmd --permanent --delete-policy<span class="o">=</span>cf-to-docker<span class="p">;</span> firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span></span></span></code></pre></div><p>加上 policy、reload 與 dockerd 重啟之後，兩個來源都還是立即被回拒：經過目的位址轉換的連線進的是 <code>filter_FORWARD_dnat</code>，policy 輪不到它們。〈firewalld.conf〉與 firewalld 官方部落格〈Strict Forward Ports〉的說法是，發佈的連接埠要用 firewalld 的 forward-port 明確放行。試的是 <code>public</code> zone 的 forward-port rich rule，限定來源是 Cloudflare 的 ipset：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">CIP</span><span class="o">=</span><span class="k">$(</span>docker inspect -f <span class="s1">&#39;{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}&#39;</span> verify-web-all<span class="k">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> <span class="nv">$CIP</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --add-rich-rule<span class="o">=</span><span class="s2">&#34;rule family=\&#34;ipv4\&#34; source ipset=\&#34;cloudflare-v4\&#34; forward-port port=\&#34;8081\&#34; protocol=\&#34;tcp\&#34; to-port=\&#34;80\&#34; to-addr=\&#34;</span><span class="nv">$CIP</span><span class="s2">\&#34;&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">nft list chain inet firewalld filter_FORWARD_dnat
</span></span><span class="line"><span class="ln">6</span><span class="cl">probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">restart_dockerd<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div><p><code>CIP</code> 是容器在 docker0 網路上的位址（這次是 <code>172.17.0.2</code>）。Docker 29 的 <code>docker inspect</code> 已經沒有 <code>.NetworkSettings.IPAddress</code> 這個欄位，用舊的寫法會回 <code>map has no entry for key &quot;IPAddress&quot;</code>，<code>CIP</code> 是空的，接著 <code>firewall-cmd</code> 回 <code>INVALID_RULE: internal error in _lexer(): to-addr=</code>；所以從 <code>.NetworkSettings.Networks</code> 取。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">	chain filter_FORWARD_dnat {
</span></span><span class="line"><span class="ln">2</span><span class="cl">		ct original proto-dst 8081 accept
</span></span><span class="line"><span class="ln">3</span><span class="cl">		reject with icmpx admin-prohibited
</span></span><span class="line"><span class="ln">4</span><span class="cl">	}
</span></span><span class="line"><span class="ln">5</span><span class="cl">== 加上限定來源的 forward-port rich rule
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">7</span><span class="cl">  verify-other  -&gt; :8081  rc=0  200</span></span></code></pre></div><p>firewalld 依這條 rich rule 在 <code>filter_FORWARD_dnat</code> 寫的放行是 <code>ct original proto-dst 8081 accept</code>，只比對原始目的連接埠，rich rule 的來源條件沒有寫進這一條，所以範圍外的來源也連得到；dockerd 重啟之後結果相同。rich rule 的來源條件只出現在 firewalld 依同一條規則另外寫的那條目的位址轉換裡，而 Docker 自己的位址轉換已經先改寫了封包。</p>
<p>OrbStack 那一台另外在 <code>StrictForwardPorts=yes</code> 下試了兩種 forward-port。一是寫在 policy 上：新建 policy <code>verify-fp</code>（<code>--new-policy=verify-fp</code>、<code>--add-ingress-zone=public</code>、<code>--add-egress-zone=docker</code>），從 <code>public</code> 到 <code>docker</code>，rich rule 一樣帶來源 ipset 與 <code>to-addr</code>。<code>firewall-cmd</code> 接受這條規則（回 <code>success</code>），reload 之後 <code>filter_FORWARD_dnat</code> 裡仍然只有 <code>reject with icmpx admin-prohibited</code>，沒有任何放行，兩個來源連 8081 都被回拒：policy 上的 forward-port 沒有在 <code>StrictForwardPorts</code> 的那條鏈裡留下放行。二是不帶 <code>to-addr</code>、轉到本機另一個連接埠：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --add-rich-rule<span class="o">=</span><span class="s1">&#39;rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; forward-port port=&#34;8088&#34; protocol=&#34;tcp&#34; to-port=&#34;8080&#34;&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep 3<span class="p">;</span> probe <span class="m">8088</span></span></span></code></pre></div><p><code>public</code> zone 沒有開 8080，而 <code>verify-cf</code> 連 8088 拿到 8080 那個主機程式的回應、<code>verify-other</code> 被回拒：轉到本機的 forward-port 帶著來源條件，結果是只有 Cloudflare 連得到。它轉到的是主機上的程式（封包改寫成本機位址之後走 input 路徑，見 <a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT</a> 卡），不是容器，所以只適用於網站由主機上的程式（或聽在主機位址上的反向代理）提供的情形。還原這一節改的設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --remove-rich-rule<span class="o">=</span><span class="s2">&#34;rule family=\&#34;ipv4\&#34; source ipset=\&#34;cloudflare-v4\&#34; forward-port port=\&#34;8081\&#34; protocol=\&#34;tcp\&#34; to-port=\&#34;80\&#34; to-addr=\&#34;</span><span class="nv">$CIP</span><span class="s2">\&#34;&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --permanent --delete-policy<span class="o">=</span>verify-fp             <span class="c1"># OrbStack 那一台試的 policy</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --remove-rich-rule<span class="o">=</span><span class="s1">&#39;rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; forward-port port=&#34;8088&#34; protocol=&#34;tcp&#34; to-port=&#34;8080&#34;&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">mv /etc/firewalld/firewalld.conf.verify-bak /etc/firewalld/firewalld.conf
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">grep <span class="s1">&#39;^StrictForwardPorts=&#39;</span> /etc/firewalld/firewalld.conf<span class="p">;</span> firewall-cmd --zone<span class="o">=</span>public --list-rich-rules</span></span></code></pre></div><p><code>grep</code> 回 <code>StrictForwardPorts=no</code>，rich rule 只剩 8080 那一條。OrbStack 那一台改 <code>StrictForwardPorts</code> 時用的是 <code>sed</code> 直接改值，沒有備份檔，還原也用 <code>sed</code> 改回 <code>no</code>；<code>mv</code> 那一行是 OCI 那一台的做法。</p>
<h2 id="自己的-nftables-表掛在目的位址轉換之前">自己的 nftables 表：掛在目的位址轉換之前</h2>
<p>這一節開始時 <code>StrictForwardPorts</code> 是 <code>no</code>，三個容器都在跑。在 nftables 建一張自己的表 <code>verify_cf_only</code>，鏈接在 prerouting 這個 hook 上、優先序 <code>dstnat - 10</code>（排在 Docker 與 firewalld 的目的位址轉換之前），封包的目的連接埠還是 8081 時就比對來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nv">CF</span><span class="o">=</span><span class="k">$(</span>paste -sd, <span class="nv">$D</span>/cf-ips-v4.txt<span class="k">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">cat &gt; <span class="nv">$D</span>/verify-cf-only.nft <span class="s">&lt;&lt;EOF
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">table inet verify_cf_only {
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">  set cloudflare_v4 { type ipv4_addr; flags interval; elements = { $CF } }
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">  chain prerouting {
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">    type filter hook prerouting priority dstnat - 10; policy accept;
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">    iifname != &#34;lo&#34; tcp dport { 8081 } ip saddr != @cloudflare_v4 drop
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">nft -f <span class="nv">$D</span>/verify-cf-only.nft
</span></span><span class="line"><span class="ln">12</span><span class="cl">probe 8081<span class="p">;</span> probe <span class="m">8083</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">curl -sS -o /dev/null -w <span class="s1">&#39;%{http_code}\n&#39;</span> http://<span class="nv">$HOSTIP</span>:8081/</span></span></code></pre></div><p><code>iifname != &quot;lo&quot;</code> 讓主機自己連自己的位址不受限制；<code>flags interval</code> 讓位址集合 <code>cloudflare_v4</code> 存得下位址段。優先序排在 <code>dstnat</code> 或之後時，封包的目的連接埠已經被改寫成 80，<code>tcp dport { 8081 }</code> 比對不到，範圍外的來源會連得到（依 nftables 的 hook 順序推論，沒有實跑）。<code>ip saddr</code> 只比對 IPv4；主機有 IPv6 時，IPv6 的連線不受這條規則限制，要另寫一條 <code>ip6 saddr</code> 與 IPv6 的集合，後果見概念文章的〈只放行 CDN 位址的套用時機、範圍與清單的維護〉。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== 自己的 nft 表，只放行 Cloudflare 到 8081
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">3</span><span class="cl">  verify-other  -&gt; :8081  rc=28  curl: (28) Connection timed out after 5002 milliseconds
</span></span><span class="line"><span class="ln">4</span><span class="cl">== 對照 8083
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-cf     -&gt; :8083  rc=0  200
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-other  -&gt; :8083  rc=0  200
</span></span><span class="line"><span class="ln">7</span><span class="cl">200</span></span></code></pre></div><p>接著重新載入 firewalld 與重啟 dockerd，各看一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep 3<span class="p">;</span> nft list tables <span class="p">|</span> grep verify<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">restart_dockerd<span class="p">;</span> nft list tables <span class="p">|</span> grep verify<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div><p>兩次都列得出 <code>table inet verify_cf_only</code>，結果跟上面相同。firewalld 重新載入只重建它自己的 <code>inet firewalld</code> 這張表，自建的表留著。</p>
<p><code>nft -f</code> 只改執行中的規則，開機時要由 <code>nftables.service</code> 載入。先看這台主機上這個服務與它的設定檔原本的狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">systemctl is-enabled nftables
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep -v <span class="s1">&#39;^#&#39;</span> /etc/sysconfig/nftables.conf <span class="p">|</span> grep -c .
</span></span><span class="line"><span class="ln">3</span><span class="cl">systemctl cat nftables <span class="p">|</span> grep -E <span class="s1">&#39;^Exec&#39;</span><span class="p">;</span> systemctl cat firewalld <span class="p">|</span> grep <span class="s1">&#39;^Conflicts&#39;</span></span></span></code></pre></div><p>這台回 <code>disabled</code>、<code>0</code>（設定檔有 6 行、全部是註解）。<code>ExecStop</code> 是 <code>/sbin/nft flush ruleset</code>，<code>ExecReload</code> 是 <code>nft 'flush ruleset; include &quot;/etc/sysconfig/nftables.conf&quot;;'</code>；firewalld 的 <code>Conflicts=</code> 列的是 <code>iptables.service ip6tables.service ebtables.service ipset.service</code>，沒有 <code>nftables.service</code>，所以兩者可以同時啟用。設定檔原本就有內容、尤其有 <code>flush ruleset</code> 時，開機載入它會清掉 firewalld 已經建好的表，那時不要在這個檔加 <code>include</code>，改用下面替代做法裡的 oneshot unit。設定檔原本是空的時：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cp /etc/sysconfig/nftables.conf /etc/sysconfig/nftables.conf.verify-bak
</span></span><span class="line"><span class="ln">2</span><span class="cl">cp <span class="nv">$D</span>/verify-cf-only.nft /etc/nftables/verify-cf-only.nft
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;include &#34;/etc/nftables/verify-cf-only.nft&#34;&#39;</span> &gt;&gt; /etc/sysconfig/nftables.conf
</span></span><span class="line"><span class="ln">4</span><span class="cl">nft -c -f /etc/sysconfig/nftables.conf <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;syntax ok&#34;</span>     <span class="c1"># -c 只檢查語法，不套用</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">systemctl <span class="nb">enable</span> nftables
</span></span><span class="line"><span class="ln">6</span><span class="cl">systemctl reboot</span></span></code></pre></div><p>重開機之後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nft list tables
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemctl is-active firewalld nftables
</span></span><span class="line"><span class="ln">3</span><span class="cl">journalctl -b -u nftables -u firewalld --no-pager <span class="p">|</span> tail -3</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">table inet verify_cf_only
</span></span><span class="line"><span class="ln">2</span><span class="cl">table inet firewalld
</span></span><span class="line"><span class="ln">3</span><span class="cl">table ip filter
</span></span><span class="line"><span class="ln">4</span><span class="cl">table ip6 filter
</span></span><span class="line"><span class="ln">5</span><span class="cl">active
</span></span><span class="line"><span class="ln">6</span><span class="cl">active
</span></span><span class="line"><span class="ln">7</span><span class="cl">... Started firewalld.service - firewalld - dynamic firewall daemon.
</span></span><span class="line"><span class="ln">8</span><span class="cl">... Starting nftables.service - Netfilter Tables...
</span></span><span class="line"><span class="ln">9</span><span class="cl">... Finished nftables.service - Netfilter Tables.</span></span></code></pre></div><p>這一次 firewalld 先啟動、<code>nftables.service</code> 接著完成，兩張表都在。接著 <code>sudo -i</code>、重新貼〈共用的變數與函式〉的區塊，再跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ns_setup
</span></span><span class="line"><span class="ln">2</span><span class="cl">start_dockerd
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker start verify-web-all verify-web-lo verify-web-v<span class="p">;</span> sleep <span class="m">2</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">probe 8081<span class="p">;</span> probe <span class="m">8083</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== 重開機後 8081（開機載入的 nft 表）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">3</span><span class="cl">  verify-other  -&gt; :8081  rc=28  curl: (28) Connection timed out after 5002 milliseconds
</span></span><span class="line"><span class="ln">4</span><span class="cl">== 重開機後 8083（沒有限制）
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-cf     -&gt; :8083  rc=0  200
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-other  -&gt; :8083  rc=0  200</span></span></code></pre></div><p><code>nftables.service</code> 的 <code>stop</code>、<code>restart</code> 與 <code>reload</code> 都會先 <code>flush ruleset</code>，清掉的是整個 nftables 規則集，firewalld 的 <code>inet firewalld</code> 表也在裡面。所以這個服務只用來開機載入；改了 <code>verify-cf-only.nft</code> 之後，先 <code>nft delete table inet verify_cf_only</code> 再 <code>nft -f</code> 那一個檔。這一段依 unit 檔的內容寫成，沒有實跑 <code>restart</code>。</p>
<p>這一步的替代做法：</p>
<ul>
<li><strong>自己寫一個開機時執行 <code>nft -f /etc/nftables/verify-cf-only.nft</code> 的 systemd oneshot unit</strong>：不經過 <code>nftables.service</code>，避開它的 <code>flush ruleset</code>，誤下 <code>systemctl restart nftables</code> 也不會清掉 firewalld 的表；代價是多一個要維護的 unit，沒有實跑。</li>
<li><strong>把同樣的規則寫成 firewalld 的 direct rule 或 passthrough</strong>：firewalld 會在每次重新載入時重寫，但〈firewalld.direct〉寫明 direct 介面已經棄用、由 policy 取代；而且主機一有 direct 設定，reload 就會清掉 iptables 裡其他自建的規則（〈DOCKER-USER 鏈的限制效果與規則的存續〉量到的），沒有實跑這個寫法本身。</li>
<li><strong>把 <code>{ 8081 }</code> 換成 <code>{ 80, 443 }</code></strong>：這是正式設定的寫法，但要多一個條件 <code>fib daddr type local</code>（目的地是這台主機自己的位址）。少了它時，容器自己往外連 443、容器之間連 80 的封包也從 docker0 進 prerouting、目的連接埠一樣是 443 或 80、來源不在清單，一起被丟掉。OrbStack 那一台補測：規則寫成 <code>iifname != &quot;lo&quot; tcp dport { 80, 443, 8081 } ip saddr != @cloudflare_v4 drop</code> 時，一個 <code>busybox</code> 容器 <code>wget https://www.cloudflare.com/</code> 與連另一個容器的 80 都失敗；改成 <code>iifname != &quot;lo&quot; fib daddr type local tcp dport { 80, 443, 8081 } ip saddr != @cloudflare_v4 drop</code> 之後兩者恢復，範圍外的來源連 8081 仍然逾時。拿掉 <code>tcp dport</code> 這個條件時，規則變成對整台主機所有連接埠的 Cloudflare 限制，那時要另外排除管理通道進來的介面或來源，否則 SSH 一起被擋。</li>
</ul>
<p>這張表的規則不出現在 <code>firewall-cmd --list-all</code> 裡，查的指令是 <code>nft list table inet verify_cf_only</code>。</p>
<h2 id="出站的-dns-只准送往-vcn-解析器主機與容器的差別">出站的 DNS 只准送往 VCN 解析器：主機與容器的差別</h2>
<p>在 <code>verify-other</code>（<code>203.0.113.10</code>）放一個收 UDP 53 與 TCP 443 的小程式，把收到的封包記進 log，用 log 的筆數判斷封包有沒有送到。<code>RESOLVER</code> 是〈共用的變數與函式〉裡填的主機解析器：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; <span class="nv">$D</span>/listen.py <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">import socket, threading, sys
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">log = open(sys.argv[1], &#34;a&#34;, buffering=1)
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">def udp():
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind((&#34;203.0.113.10&#34;, 53))
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">    while True:
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">        data, peer = s.recvfrom(4096); log.write(f&#34;udp53 from {peer[0]}\n&#34;)
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">def tcp():
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">    s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); s.bind((&#34;203.0.113.10&#34;, 443)); s.listen()
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">    while True:
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">        c, peer = s.accept(); log.write(f&#34;tcp443 from {peer[0]}\n&#34;); c.close()
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">threading.Thread(target=udp, daemon=True).start(); tcp()
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">ip netns <span class="nb">exec</span> verify-other python3 <span class="nv">$D</span>/listen.py <span class="nv">$D</span>/listen.log <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">sleep <span class="m">1</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">check<span class="o">()</span> <span class="o">{</span>  <span class="c1"># 清空 log，送四種流量，數對方收到的封包</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  : &gt; <span class="nv">$D</span>/listen.log
</span></span><span class="line"><span class="ln">18</span><span class="cl">  dig +tries<span class="o">=</span><span class="m">1</span> +time<span class="o">=</span><span class="m">2</span> @<span class="nv">$RESOLVER</span> oracle.com A +short &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="nv">r1</span><span class="o">=</span>ok <span class="o">||</span> <span class="nv">r1</span><span class="o">=</span>fail
</span></span><span class="line"><span class="ln">19</span><span class="cl">  dig +tries<span class="o">=</span><span class="m">1</span> +time<span class="o">=</span><span class="m">2</span> @203.0.113.10 oracle.com A &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  curl -s -m <span class="m">3</span> -o /dev/null http://203.0.113.10:443/
</span></span><span class="line"><span class="ln">21</span><span class="cl">  docker run --rm busybox:1.37 nslookup -timeout<span class="o">=</span><span class="m">2</span> oracle.com 203.0.113.10 &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  sleep <span class="m">1</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;  host -&gt; </span><span class="nv">$RESOLVER</span><span class="s2">:53  </span><span class="nv">$r1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;  203.0.113.10 收到的封包：</span><span class="k">$(</span>sort <span class="nv">$D</span>/listen.log <span class="p">|</span> uniq -c <span class="p">|</span> tr <span class="s1">&#39;\n&#39;</span> <span class="s1">&#39;;&#39;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">check</span></span></code></pre></div><p><code>dig +tries=1</code> 只送一次查詢；容器裡 busybox 的 <code>nslookup</code> 用 <code>docker run --rm</code> 起一個只跑這一條指令的容器。<code>curl</code> 連 443 用的是 HTTP，監聽程式收到連線就關掉，對方記到一筆 <code>tcp443</code> 就代表封包送到了，回應內容不重要；這是一般的 TCP 連線，不是 DNS over HTTPS 的查詢。加限制之前：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== 沒有出站限制
</span></span><span class="line"><span class="ln">2</span><span class="cl">  host -&gt; 169.254.169.254:53  ok
</span></span><span class="line"><span class="ln">3</span><span class="cl">  203.0.113.10 收到的封包：1 tcp443 from 192.0.2.5; 5 udp53 from 192.0.2.5;</span></span></code></pre></div><p>容器的封包經過 Docker 的位址偽裝（masquerade），在對方看來來源也是主機的 <code>192.0.2.5</code>，所以收到的 5 個 UDP 53 混著主機與容器的。加一條從 <code>HOST</code>（主機本身送出的流量）到 <code>ANY</code>（任何 zone）的 policy：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firewall-cmd --permanent --new-policy<span class="o">=</span>egress-dns
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>egress-dns --add-ingress-zone<span class="o">=</span>HOST
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>egress-dns --add-egress-zone<span class="o">=</span>ANY
</span></span><span class="line"><span class="ln">4</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>egress-dns --add-rich-rule<span class="o">=</span><span class="s2">&#34;rule priority=\&#34;-2\&#34; family=\&#34;ipv4\&#34; destination address=\&#34;</span><span class="nv">$RESOLVER</span><span class="s2">\&#34; service name=\&#34;dns\&#34; accept&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --permanent --policy<span class="o">=</span>egress-dns --add-rich-rule<span class="o">=</span><span class="s1">&#39;rule priority=&#34;-1&#34; service name=&#34;dns&#34; reject&#39;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">firewall-cmd --reload<span class="p">;</span> sleep <span class="m">3</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">check</span></span></code></pre></div><p><code>priority</code> 小的 rich rule 先比對，放行指定解析器的那一條要排在拒絕之前。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== firewalld policy egress-dns（HOST -&gt; ANY）
</span></span><span class="line"><span class="ln">2</span><span class="cl">  host -&gt; 169.254.169.254:53  ok
</span></span><span class="line"><span class="ln">3</span><span class="cl">  203.0.113.10 收到的封包：1 tcp443 from 192.0.2.5; 4 udp53 from 192.0.2.5;</span></span></code></pre></div><p>分開送，確認那 4 個是誰的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">: &gt; <span class="nv">$D</span>/listen.log<span class="p">;</span> dig +tries<span class="o">=</span><span class="m">1</span> +time<span class="o">=</span><span class="m">2</span> @203.0.113.10 oracle.com A <span class="p">|</span> grep -E <span class="s1">&#39;refused|timed out&#39;</span><span class="p">;</span> sleep 1<span class="p">;</span> wc -l &lt; <span class="nv">$D</span>/listen.log
</span></span><span class="line"><span class="ln">2</span><span class="cl">: &gt; <span class="nv">$D</span>/listen.log<span class="p">;</span> docker run --rm busybox:1.37 nslookup -timeout<span class="o">=</span><span class="m">2</span> oracle.com 203.0.113.10 &gt;/dev/null 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> sleep 1<span class="p">;</span> wc -l &lt; <span class="nv">$D</span>/listen.log</span></span></code></pre></div><p>只有主機 <code>dig @203.0.113.10</code> 時對方收到 0 個，<code>dig</code> 印 <code>communications error to 203.0.113.10#53: connection refused</code>；只有容器 <code>nslookup</code> 時對方收到 4 個。這條 policy 擋下主機本身的查詢，容器的查詢走轉送路徑、不經過 <code>HOST</code>，照樣送出去；送往 443 的連線在加限制之前與之後都送到。這裡的容器跑在預設的 bridge 網路（介面 docker0），查詢直接從容器送出。compose 的自訂網路上，容器先問 Docker 內建的解析器 <code>127.0.0.11</code>，內建解析器往外查的封包由主機送出，結果反過來：OrbStack 那一台上把這條 policy 改成拒絕主機送出的全部 <code>dns</code>，compose 容器經由 <code>127.0.0.11</code> 的查詢失敗、bridge 容器照常；量法與 conntrack 的輸出在 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈compose 自訂網路的容器經由 127.0.0.11 查詢時，DNS 封包從哪裡送出〉。容器自己送出的查詢另外寫在 <code>DOCKER-USER</code>（自訂網路的介面是 <code>br-</code> 開頭，<code>-i docker0</code> 改成 <code>-i br-+</code> 或兩條都寫）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">iptables -I DOCKER-USER -i docker0 -p udp --dport <span class="m">53</span> ! -d <span class="nv">$RESOLVER</span> -j REJECT
</span></span><span class="line"><span class="ln">2</span><span class="cl">iptables -I DOCKER-USER -i docker0 -p tcp --dport <span class="m">53</span> ! -d <span class="nv">$RESOLVER</span> -j REJECT
</span></span><span class="line"><span class="ln">3</span><span class="cl">check</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== 再加 DOCKER-USER：容器的 53 只准到 169.254.169.254
</span></span><span class="line"><span class="ln">2</span><span class="cl">  host -&gt; 169.254.169.254:53  ok
</span></span><span class="line"><span class="ln">3</span><span class="cl">  203.0.113.10 收到的封包：1 tcp443 from 192.0.2.5;</span></span></code></pre></div><p>這兩條寫在 <code>DOCKER-USER</code>，有〈DOCKER-USER 鏈的限制效果與規則的存續〉量到的問題：這台主機上 <code>firewall-cmd --reload</code> 之後規則消失，重開機之後也不在。要長期使用就改寫進自建的 nftables 表，鏈接在 forward hook 上。OrbStack 那一台實跑了這個寫法（<code>RESOLVER</code> 是〈共用的變數與函式〉填的主機解析器）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat &gt; <span class="nv">$D</span>/verify-egress.nft <span class="s">&lt;&lt;EOF
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">table inet verify_egress {
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">  chain forward {
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">    type filter hook forward priority filter - 10; policy accept;
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">    iifname { &#34;docker0&#34;, &#34;br-*&#34; } meta l4proto { tcp, udp } th dport 53 ip daddr != $RESOLVER drop
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">nft -f <span class="nv">$D</span>/verify-egress.nft</span></span></code></pre></div><p><code>&quot;br-*&quot;</code> 比對 compose 自訂網路的介面；<code>th dport 53</code> 同時比對 TCP 與 UDP 的目的連接埠；優先序 <code>filter - 10</code> 排在 firewalld 的轉送鏈（<code>filter + 10</code>）之前。bridge 容器與 compose 容器直接問 <code>1.1.1.1</code> 的查詢都失敗，經由各自預設解析器的照常；<code>firewall-cmd --reload</code> 之後 <code>nft list tables</code> 仍列得出 <code>table inet verify_egress</code>，結果相同。compose 容器經由 <code>127.0.0.11</code> 的那一路不經過 forward hook，要靠上面主機的 policy。開機載入的做法同〈自己的 nftables 表：掛在目的位址轉換之前〉。</p>
<h2 id="清理容器dockerd防火牆設定與暫存檔">清理：容器、dockerd、防火牆設定與暫存檔</h2>
<p>照上面的順序做完時，要清的有：三個容器與映像、dockerd、兩個 namespace、8080 的 rich rule、ipset、<code>egress-dns</code> policy、Docker 建的 zone 與 policy、自建的 nftables 表與它的開機載入，以及暫存目錄。中途停下來的讀者，只跑做過的那幾節對應的行；<code>--remove-rich-rule</code> 與 <code>--delete-*</code> 對不存在的項目只回錯誤，不改別的設定。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">docker rm -f verify-web-all verify-web-lo verify-web-v<span class="p">;</span> docker rmi busybox:1.37
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">pkill -x dockerd<span class="p">;</span> <span class="k">while</span> pgrep -x dockerd &gt;/dev/null<span class="p">;</span> <span class="k">do</span> sleep 1<span class="p">;</span> <span class="k">done</span><span class="p">;</span> pkill -x containerd
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">pkill -f <span class="s1">&#39;http.server 8080&#39;</span><span class="p">;</span> pkill -f <span class="s2">&#34;</span><span class="nv">$D</span><span class="s2">/listen.py&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">ip link del vcf-h<span class="p">;</span> ip link del voth-h<span class="p">;</span> ip netns del verify-cf<span class="p">;</span> ip netns del verify-other
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --remove-rich-rule<span class="o">=</span><span class="s1">&#39;rule family=&#34;ipv4&#34; source ipset=&#34;cloudflare-v4&#34; port port=&#34;8080&#34; protocol=&#34;tcp&#34; accept&#39;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">firewall-cmd --permanent --delete-policy<span class="o">=</span>egress-dns
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">firewall-cmd --permanent --delete-ipset<span class="o">=</span>cloudflare-v4
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">firewall-cmd --permanent --delete-policy<span class="o">=</span>docker-forwarding     <span class="c1"># 這台主機原本沒有 Docker 時才刪</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">firewall-cmd --permanent --delete-zone<span class="o">=</span>docker                  <span class="c1"># 同上</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">firewall-cmd --reload
</span></span><span class="line"><span class="ln">11</span><span class="cl">nft delete table inet verify_cf_only
</span></span><span class="line"><span class="ln">12</span><span class="cl">mv /etc/sysconfig/nftables.conf.verify-bak /etc/sysconfig/nftables.conf<span class="p">;</span> rm -f /etc/nftables/verify-cf-only.nft
</span></span><span class="line"><span class="ln">13</span><span class="cl">systemctl disable nftables                                     <span class="c1"># 實測前是 disabled 時才關</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">rm -rf /var/lib/docker /var/lib/containerd /run/docker /var/run/docker.sock   <span class="c1"># 這台主機原本沒有 Docker 時才刪</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nb">cd</span> /<span class="p">;</span> rm -rf /tmp/verify-fw
</span></span><span class="line"><span class="ln">16</span><span class="cl">systemctl reboot</span></span></code></pre></div><p>OrbStack 那一台另外多了幾項，各節測完已經還原的（zone <code>verify-cfzone</code>、IPv6 的 rich rule、<code>daemon.json</code>、policy <code>verify-fp</code>、8088 的 forward-port、<code>table inet verify_egress</code>）不必再清，剩下的是 <code>lo</code> 上的 ULA 與 IPv6 的 ipset；這兩行沒有實跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ip -6 addr del fd00:beef::1/128 dev lo
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --permanent --delete-ipset<span class="o">=</span>cloudflare-v6<span class="p">;</span> firewall-cmd --reload</span></span></code></pre></div><p>ipset 排在引用它的 rich rule 之後刪。Docker 建的 <code>docker</code> zone 與 <code>docker-forwarding</code> policy 寫進了 firewalld 的永久設定，dockerd 停掉之後還在。<code>/var/lib/docker</code> 與 <code>/var/lib/containerd</code> 存著 Docker 的映像、容器與 volume；這次的主機原本沒有 Docker，所以整個刪掉。主機原本就有 Docker、這次只是借用時，只刪這次建的三個容器（<code>busybox:1.37</code> 原本就有的話也留著），zone、policy 與這兩個目錄都留著，dockerd 與 containerd 用 <code>systemctl</code> 管，不用 <code>pkill</code>。重開機是為了清掉 dockerd 留在 iptables 的 nat 與 filter 規則，以及被它改成 1 的 <code>ip_forward</code>。重開之後：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nft list tables<span class="p">;</span> sysctl net.ipv4.ip_forward
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --list-all <span class="p">|</span> grep -E <span class="s1">&#39;services|rich|forward-ports&#39;</span><span class="p">;</span> firewall-cmd --get-ipsets
</span></span><span class="line"><span class="ln">3</span><span class="cl">grep <span class="s1">&#39;^StrictForwardPorts=&#39;</span> /etc/firewalld/firewalld.conf
</span></span><span class="line"><span class="ln">4</span><span class="cl">find /tmp /var/tmp -maxdepth <span class="m">1</span> -name <span class="s1">&#39;verify-*&#39;</span></span></span></code></pre></div><p><code>nft list tables</code> 只剩 <code>inet firewalld</code>、<code>ip filter</code>、<code>ip6 filter</code>（跟實測前登入後第一次查的結果相同），<code>net.ipv4.ip_forward = 0</code>，<code>public</code> zone 只有 <code>dhcpv6-client ssh</code>、沒有 rich rule 與 forward-port，<code>--get-ipsets</code> 沒有輸出，<code>StrictForwardPorts=no</code>，<code>find</code> 沒有輸出。</p>
<h2 id="各種寫法在重新載入dockerd-重啟與重開機之後的結果">各種寫法在重新載入、dockerd 重啟與重開機之後的結果</h2>
<p>這張表的「範圍外的來源」欄量的是規則剛套上時；其餘三欄量的是規則套上之後各做一次那個動作，範圍外的來源還連不連得到、規則還在不在。「沒有單獨測」是那個動作做過、但這條規則不是當時要量的對象，沒有記錄它的狀態；「沒有測」是沒有做那個動作。<code>DOCKER-USER</code> 那一列只比對連接埠，正式改成 80、443 時要加 <code>--ctstate DNAT</code>，自建 nftables 表那一列要加 <code>fib daddr type local</code>（各節的說明）。</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>範圍外的來源連發佈的連接埠</th>
          <th><code>firewall-cmd --reload</code> 之後</th>
          <th>dockerd 重啟之後</th>
          <th>重開機之後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>firewalld rich rule（只放行 ipset，寫在 zone 上）</td>
          <td>連得到（規則管不到轉送路徑）</td>
          <td>規則還在</td>
          <td>規則還在</td>
          <td>沒有單獨測（<code>--permanent</code> 依機制會在）</td>
      </tr>
      <tr>
          <td>只發佈在 <code>127.0.0.1</code></td>
          <td>連不到</td>
          <td>沒有單獨測</td>
          <td>沒有測</td>
          <td>沒有測（發佈位址屬於容器設定）</td>
      </tr>
      <tr>
          <td><code>DOCKER-USER</code> 跳到自建鏈</td>
          <td>被擋（逾時）</td>
          <td>nftables 後端有 direct rule 或 passthrough 時、或 iptables 後端時規則消失（OCI 的映像有 passthrough），其餘還在</td>
          <td>規則還在</td>
          <td>沒有單獨測（依機制不在）</td>
      </tr>
      <tr>
          <td>firewalld policy（<code>public</code> 到 <code>docker</code>）</td>
          <td>連得到（<code>ct status dnat accept</code>）</td>
          <td>以 reload 套用，見正文</td>
          <td>連得到</td>
          <td>沒有測</td>
      </tr>
      <tr>
          <td><code>StrictForwardPorts=yes</code> 加限定來源的 forward-port</td>
          <td>連得到（放行規則只比對連接埠）</td>
          <td>連得到（規則以 reload 套用）</td>
          <td>連得到</td>
          <td>沒有測</td>
      </tr>
      <tr>
          <td><code>StrictForwardPorts=yes</code> 加 policy 上的 forward-port</td>
          <td>連不到（範圍內的也被回拒）</td>
          <td>以 reload 套用</td>
          <td>沒有測</td>
          <td>沒有測</td>
      </tr>
      <tr>
          <td>自建 nftables 表（prerouting，位址轉換之前）</td>
          <td>被擋（逾時）</td>
          <td>規則還在</td>
          <td>規則還在</td>
          <td>規則還在（<code>nftables.service</code>）</td>
      </tr>
  </tbody>
</table>
<p>OrbStack 那一台補測的幾列（<code>DOCKER-USER</code> 在沒有 direct 設定時的存續、policy 上的 forward-port）環境與 OCI 那一台不同，見事件說明；轉到本機程式的 forward-port 與自建表的出站 DNS 限制不是限制發佈的連接埠，不列在這張表，結果在各自的段落。</p>
]]></content:encoded></item><item><title>GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding</title><link>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/</link><pubDate>Fri, 23 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/</guid><description>&lt;p>這篇整理 GCP Compute Engine 上管理員登入 VM 的通道（Linux 的 SSH、Windows 的 RDP），從防火牆對 &lt;code>0.0.0.0/0&lt;/code> 開放 22 與 3389 port 改成走 Identity-Aware Proxy（IAP）TCP forwarding 的機制與判斷依據：預設網路的規則與它們留下的暴露面、IAP 的連線路徑、切換的順序為什麼這樣排、連不上時的依層判讀、IAM 授權的範圍、OS Login、拿掉外部 IP 之後的出站路徑、組織層級的護欄與稽核。範圍從專案裡已經有 VM、而且用的是預設網路（&lt;code>default&lt;/code> network）開始；自建網路上手寫的全開規則，處理方式相同。從盤點、救援入口、建規則與授權、停用舊規則驗證、啟用 OS Login 到移除外部 IP 的逐步操作與實跑結果，在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄&lt;/a>。IAP 這一類代理服務的共通機制見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/identity-aware-proxy/" data-link-title="Identity-Aware Proxy（身分感知代理）" data-link-desc="要讓管理員或使用者不經公開入口連到內部主機與服務、評估把存取控制從來源 IP 改成帳號身分時，查身分感知代理的連線路徑、前提與代價">Identity-Aware Proxy（身分感知代理）&lt;/a>。&lt;/p>
&lt;p>管理通道為什麼要收斂、它拆成哪幾層、各層有哪些與廠商無關的做法，在&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口&lt;/a>。本篇是那套模型在 GCP 上的一種組合：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>管理通道的層&lt;/th>
 &lt;th>本篇在 GCP 上的做法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>網路可達性&lt;/td>
 &lt;td>防火牆只放行 IAP 的來源位址段，VM 移除外部 IP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>通道授權&lt;/td>
 &lt;td>Google 帳號加 IAM role &lt;code>roles/iap.tunnelResourceAccessor&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>主機登入驗證&lt;/td>
 &lt;td>Linux 用 OS Login；Windows 用 VM 上的 Windows 帳號&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>救援入口用 Serial Console，它不經過上面三層的設定，有自己的 IAM 權限與開關。&lt;/p>
&lt;p>這個組合假設組織的身分提供者（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/identity-provider/" data-link-title="Identity Provider" data-link-desc="登入要交給外部系統時，用來定位對方承擔了什麼、沒承擔什麼，以及這邊還剩下哪些責任">identity provider&lt;/a>，IdP）是 Google 帳號：IAP 與 OS Login 都依 Google 的 IAM 判斷。組織用的是 Microsoft Entra ID、Okta 這類其他 IdP 時，有兩條路。一條是讓 Cloud Identity 以 SAML 單一登入接那個 IdP、並由 IdP 把帳號同步進來，使用者在原本的 IdP 登入、拿到的是 Google 身分，本篇全部適用。另一條是通道授權改用能接那個 IdP 的代理服務，網路可達性跟著那個代理走（防火牆改放行它的位址段，或改用不開入站 port 的反向連線）；這時本篇依賴 Google 帳號的幾節——〈IAP 的 IAM 授權範圍：誰拿得到通道〉、〈SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件〉的 OS Login 與組織政策的 &lt;code>compute.requireOsLogin&lt;/code>、稽核紀錄裡 IAP 的 Data Access 紀錄——都不適用，Cloud NAT、移除外部 IP、Serial Console 與防火牆規則的收斂仍然適用。&lt;/p>
&lt;p>另外四種環境要先調整做法再往下讀：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>由 managed instance group（MIG）或 GKE 管理的 VM&lt;/strong>：tag、metadata、救援帳號與移除外部 IP 都要改在 instance template、node pool 設定或映像檔裡。逐台用 &lt;code>gcloud&lt;/code> 修改，VM 在自動修復、升級或縮放時重建，修改就消失，tag 消失之後 IAP 規則也套不到新的 VM。這兩種環境的設定位置、救援改用重建的做法與 GKE 控制層的存取，見&lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取&lt;/a>。&lt;/li>
&lt;li>&lt;strong>Shared VPC 的 service project&lt;/strong>：防火牆規則住在 host project，防火牆相關的指令要在 host project 執行；停用 &lt;code>default-allow-ssh&lt;/code> 這類規則會同時影響其他 service project 的 VM。&lt;/li>
&lt;li>&lt;strong>用 Terraform 這類 IaC 管理的專案&lt;/strong>：每一個 &lt;code>gcloud&lt;/code> 修改都要改寫成程式碼。只在 CLI 加的 tag、IAM 授權與 metadata，若所屬資源在程式碼裡被完整定義（例如 &lt;code>google_compute_instance&lt;/code> 寫了 &lt;code>tags&lt;/code>、用 &lt;code>google_project_iam_policy&lt;/code> 管 IAM），下一次 apply 會把它拿掉；只在 CLI 刪掉的規則與外部 IP，下一次 apply 會建回來。用 CLI 停用舊規則做驗證的期間要暫停所有 apply。兩個方向的落差與過渡期的做法見&lt;a href="https://tarrragon.github.io/blog/devops/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">兩套真相並存的過渡期操作&lt;/a>。&lt;/li>
&lt;li>&lt;strong>新專案從零開始&lt;/strong>：沒有舊規則要移走，切換前的盤點與停用驗證可以跳過，直接建 IAP 規則、OS Login 與 Cloud NAT；建 VM 時加 &lt;code>--no-address&lt;/code> 就不配外部 IP。2024 年 5 月 3 日之後建立的 Organization 會預設強制一組 Organization Policy，其中可能已經包含本篇〈組織層級的護欄：Organization Policy 與階層式防火牆〉提到的幾項；用 &lt;code>gcloud resource-manager org-policies list --organization=&amp;lt;org-id&amp;gt;&lt;/code> 查組織層級設定了哪些（&lt;code>&amp;lt;org-id&amp;gt;&lt;/code> 用 &lt;code>gcloud organizations list&lt;/code> 查）。&lt;/li>
&lt;/ul>
&lt;h2 id="預設網路自帶的防火牆規則">預設網路自帶的防火牆規則&lt;/h2>
&lt;p>專案第一次啟用 Compute Engine API 時，GCP 會自動建一個叫 &lt;code>default&lt;/code> 的 auto-mode VPC，並在上面預先建好四條入站規則。這四條規則的設計目標是讓第一次用 GCP 的人開完 VM 就連得上，所以來源範圍放得很寬。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理 GCP Compute Engine 上管理員登入 VM 的通道（Linux 的 SSH、Windows 的 RDP），從防火牆對 <code>0.0.0.0/0</code> 開放 22 與 3389 port 改成走 Identity-Aware Proxy（IAP）TCP forwarding 的機制與判斷依據：預設網路的規則與它們留下的暴露面、IAP 的連線路徑、切換的順序為什麼這樣排、連不上時的依層判讀、IAM 授權的範圍、OS Login、拿掉外部 IP 之後的出站路徑、組織層級的護欄與稽核。範圍從專案裡已經有 VM、而且用的是預設網路（<code>default</code> network）開始；自建網路上手寫的全開規則，處理方式相同。從盤點、救援入口、建規則與授權、停用舊規則驗證、啟用 OS Login 到移除外部 IP 的逐步操作與實跑結果，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>。IAP 這一類代理服務的共通機制見 <a href="/blog/devops/infra/knowledge-cards/identity-aware-proxy/" data-link-title="Identity-Aware Proxy（身分感知代理）" data-link-desc="要讓管理員或使用者不經公開入口連到內部主機與服務、評估把存取控制從來源 IP 改成帳號身分時，查身分感知代理的連線路徑、前提與代價">Identity-Aware Proxy（身分感知代理）</a>。</p>
<p>管理通道為什麼要收斂、它拆成哪幾層、各層有哪些與廠商無關的做法，在<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>。本篇是那套模型在 GCP 上的一種組合：</p>
<table>
  <thead>
      <tr>
          <th>管理通道的層</th>
          <th>本篇在 GCP 上的做法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>網路可達性</td>
          <td>防火牆只放行 IAP 的來源位址段，VM 移除外部 IP</td>
      </tr>
      <tr>
          <td>通道授權</td>
          <td>Google 帳號加 IAM role <code>roles/iap.tunnelResourceAccessor</code></td>
      </tr>
      <tr>
          <td>主機登入驗證</td>
          <td>Linux 用 OS Login；Windows 用 VM 上的 Windows 帳號</td>
      </tr>
  </tbody>
</table>
<p>救援入口用 Serial Console，它不經過上面三層的設定，有自己的 IAM 權限與開關。</p>
<p>這個組合假設組織的身分提供者（<a href="/blog/backend/knowledge-cards/identity-provider/" data-link-title="Identity Provider" data-link-desc="登入要交給外部系統時，用來定位對方承擔了什麼、沒承擔什麼，以及這邊還剩下哪些責任">identity provider</a>，IdP）是 Google 帳號：IAP 與 OS Login 都依 Google 的 IAM 判斷。組織用的是 Microsoft Entra ID、Okta 這類其他 IdP 時，有兩條路。一條是讓 Cloud Identity 以 SAML 單一登入接那個 IdP、並由 IdP 把帳號同步進來，使用者在原本的 IdP 登入、拿到的是 Google 身分，本篇全部適用。另一條是通道授權改用能接那個 IdP 的代理服務，網路可達性跟著那個代理走（防火牆改放行它的位址段，或改用不開入站 port 的反向連線）；這時本篇依賴 Google 帳號的幾節——〈IAP 的 IAM 授權範圍：誰拿得到通道〉、〈SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件〉的 OS Login 與組織政策的 <code>compute.requireOsLogin</code>、稽核紀錄裡 IAP 的 Data Access 紀錄——都不適用，Cloud NAT、移除外部 IP、Serial Console 與防火牆規則的收斂仍然適用。</p>
<p>另外四種環境要先調整做法再往下讀：</p>
<ul>
<li><strong>由 managed instance group（MIG）或 GKE 管理的 VM</strong>：tag、metadata、救援帳號與移除外部 IP 都要改在 instance template、node pool 設定或映像檔裡。逐台用 <code>gcloud</code> 修改，VM 在自動修復、升級或縮放時重建，修改就消失，tag 消失之後 IAP 規則也套不到新的 VM。這兩種環境的設定位置、救援改用重建的做法與 GKE 控制層的存取，見<a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a>。</li>
<li><strong>Shared VPC 的 service project</strong>：防火牆規則住在 host project，防火牆相關的指令要在 host project 執行；停用 <code>default-allow-ssh</code> 這類規則會同時影響其他 service project 的 VM。</li>
<li><strong>用 Terraform 這類 IaC 管理的專案</strong>：每一個 <code>gcloud</code> 修改都要改寫成程式碼。只在 CLI 加的 tag、IAM 授權與 metadata，若所屬資源在程式碼裡被完整定義（例如 <code>google_compute_instance</code> 寫了 <code>tags</code>、用 <code>google_project_iam_policy</code> 管 IAM），下一次 apply 會把它拿掉；只在 CLI 刪掉的規則與外部 IP，下一次 apply 會建回來。用 CLI 停用舊規則做驗證的期間要暫停所有 apply。兩個方向的落差與過渡期的做法見<a href="/blog/devops/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">兩套真相並存的過渡期操作</a>。</li>
<li><strong>新專案從零開始</strong>：沒有舊規則要移走，切換前的盤點與停用驗證可以跳過，直接建 IAP 規則、OS Login 與 Cloud NAT；建 VM 時加 <code>--no-address</code> 就不配外部 IP。2024 年 5 月 3 日之後建立的 Organization 會預設強制一組 Organization Policy，其中可能已經包含本篇〈組織層級的護欄：Organization Policy 與階層式防火牆〉提到的幾項；用 <code>gcloud resource-manager org-policies list --organization=&lt;org-id&gt;</code> 查組織層級設定了哪些（<code>&lt;org-id&gt;</code> 用 <code>gcloud organizations list</code> 查）。</li>
</ul>
<h2 id="預設網路自帶的防火牆規則">預設網路自帶的防火牆規則</h2>
<p>專案第一次啟用 Compute Engine API 時，GCP 會自動建一個叫 <code>default</code> 的 auto-mode VPC，並在上面預先建好四條入站規則。這四條規則的設計目標是讓第一次用 GCP 的人開完 VM 就連得上，所以來源範圍放得很寬。</p>
<table>
  <thead>
      <tr>
          <th>規則名稱</th>
          <th>放行內容</th>
          <th>來源範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>default-allow-ssh</code></td>
          <td>tcp:22</td>
          <td><code>0.0.0.0/0</code></td>
      </tr>
      <tr>
          <td><code>default-allow-rdp</code></td>
          <td>tcp:3389</td>
          <td><code>0.0.0.0/0</code></td>
      </tr>
      <tr>
          <td><code>default-allow-icmp</code></td>
          <td>ICMP</td>
          <td><code>0.0.0.0/0</code></td>
      </tr>
      <tr>
          <td><code>default-allow-internal</code></td>
          <td>同網路內 TCP、UDP 所有 port 與 ICMP</td>
          <td><code>10.128.0.0/9</code></td>
      </tr>
  </tbody>
</table>
<p>GCP 的防火牆規則依 priority 數字由小到大評估。這四條的 priority 是 65534，只排在 VPC 隱含的「拒絕所有入站」規則（65535）前面，所以自己建立的規則（不指定時是 1000）都會比它們先被評估。它們也沒有指定 target tag（防火牆規則用來挑選作用對象的標記），作用對象是這個網路裡的每一台 VM。Oracle Cloud 與 AWS 預設網路的同類規則見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈各家預設網路規則對 SSH 的開放〉。</p>
<p>只收掉 <code>default-allow-ssh</code> 會留下兩個暴露面。<code>default-allow-rdp</code> 讓每一台 Windows VM 的遠端桌面對整個網際網路開放，而 RDP 預設接受密碼登入。<code>default-allow-internal</code> 讓同一個網路裡的 VM 之間不受 port 限制，一台 VM 被入侵之後，攻擊者從那台機器能直接連到其他 VM 的資料庫 port 與管理 port，這種擴散方式叫<a href="/blog/backend/knowledge-cards/lateral-movement/" data-link-title="Lateral Movement（橫向移動）" data-link-desc="評估一台主機被入侵之後攻擊者還連得到哪些機器、內網或 VPN 裡的可達範圍要收到多窄時，查攻擊者以已經拿下的機器為起點往內擴散的方式">橫向移動（lateral movement）</a>。另外，這四條自動建立的規則預設沒有開啟 firewall rules logging，防火牆這一層不會留下被掃描或被連入的紀錄。管理 port 對外開放的風險與廠商無關，見<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>的〈對外開放管理 port 的實際風險〉。</p>
<p>CIS Google Cloud Platform Foundation Benchmark 的 3.1 條因此建議專案裡不保留 <code>default</code> 網路，改用自己規劃 subnet 的 custom-mode VPC。已經有服務跑在 <code>default</code> 網路上的專案，可以先收斂這四條規則，搬遷網路排進另一次變更；收斂 <code>default-allow-internal</code> 與 <code>default-allow-icmp</code> 的指令與實測結果在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈收斂 default-allow-internal 與 default-allow-icmp〉。</p>
<p>列出專案目前在 <code>default</code> 網路上的規則，<code>--format</code> 讓 <code>gcloud</code> 把來源範圍與 target tag 兩欄加進輸出（預設表格沒有這兩欄）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 列出 default 網路上的防火牆規則，含來源範圍、放行內容與 target tag</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute firewall-rules list --filter<span class="o">=</span><span class="s2">&#34;network:default&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s2">&#34;table(name,priority,sourceRanges.list():label=SRC_RANGES,allowed[].map().firewall_rule().list():label=ALLOW,targetTags.list():label=TARGET_TAGS,disabled)&#34;</span></span></span></code></pre></div><p>輸出裡要看 <code>SRC_RANGES</code> 欄是 <code>0.0.0.0/0</code> 的列，以及 <code>TARGET_TAGS</code> 欄是空白的列——空白代表規則套用到整個網路的每一台 VM。這兩條規則要換掉、不能直接刪：刪除的當下，管理員自己的 SSH 與 RDP 連線會一起中斷，理由見〈切換到 IAP 的順序〉。從 Security Command Center 的 <code>OPEN_SSH_PORT</code> 或 <code>OPEN_RDP_PORT</code> finding 進來的，finding 裡寫著被標記的那條規則的資源名稱，以那個名稱替換指令裡的 <code>default-allow-ssh</code>；網路不是 <code>default</code> 時，把 <code>--filter</code> 裡的網路名稱一起換掉。</p>
<h2 id="iap-tcp-forwarding-的連線路徑與來源範圍">IAP TCP forwarding 的連線路徑與來源範圍</h2>
<p>IAP TCP forwarding 是 GCP 的身分感知代理：管理員的 <code>gcloud</code> 先用 HTTPS 連到 Google 的 IAP 端點，IAP 檢查這個 Google 帳號有沒有開通道的 IAM 權限，通過之後由 IAP 從 <code>35.235.240.0/20</code> 這段位址連到 VM 的內部 IP 與指定 port，再把兩端的位元組來回轉送。</p>
<p>一條連線因此由兩個元件各檢查一次，分別對應通道授權與主機登入驗證：</p>
<table>
  <thead>
      <tr>
          <th>驗證位置</th>
          <th>由誰檢查</th>
          <th>檢查什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IAP 端點</td>
          <td>Google IAM</td>
          <td>這個 Google 帳號在這個 VM 上有沒有 <code>iap.tunnelInstances.accessViaIAP</code> 權限</td>
      </tr>
      <tr>
          <td>VM 內部</td>
          <td>Linux 上的 sshd／Windows 的遠端桌面服務</td>
          <td>SSH 金鑰在不在授權清單裡（metadata 金鑰或 OS Login）／Windows 帳號密碼</td>
      </tr>
  </tbody>
</table>
<p>IAP 只決定是否允許開通道，開了通道之後登入 VM 仍然要過 VM 自己的認證，所以兩者要分開設定、分開稽核：IAM 給得太寬，sshd 就暴露給不該碰這台 VM 的成員；VM 的金鑰管理鬆散，拿到通道的人就能用別人留下的金鑰登入。</p>
<p><code>35.235.240.0/20</code> 是 Google 文件公開的 IAP TCP forwarding 來源範圍，裡面包含 IAP 轉送時使用的全部位址。IPv6 的 VM 要另外放行 <code>2600:2d00:1:7::/64</code>；auto-mode 的 <code>default</code> 網路只有 IPv4 subnet，IPv6 的規則只在自建、開了 dual-stack subnet 的 custom-mode VPC 上用得到。這兩段位址是 Google 定義的規格值，每個專案都相同，可以直接寫進防火牆規則；Google 調整範圍時以 <a href="https://cloud.google.com/iap/docs/using-tcp-forwarding">IAP TCP forwarding 文件</a>為準。防火牆規則的關鍵是 <code>--source-ranges=35.235.240.0/20</code> 加上 <code>--target-tags</code> 限定作用的 VM、<code>--rules</code> 只開需要的 port，完整的建立指令與各引數在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈啟用 IAP API、開啟稽核紀錄並建立只放行 IAP 來源的防火牆規則〉。</p>
<p>設定完成之後，日常的連線指令是這幾條，<code>&lt;instance-name&gt;</code> 與 <code>&lt;zone&gt;</code> 用 <code>gcloud compute instances list</code> 查，第一欄是 VM 名稱、第二欄是 zone：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Linux：透過 IAP 通道 SSH 進 VM</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud compute ssh &lt;instance-name&gt; --zone<span class="o">=</span>&lt;zone&gt; --tunnel-through-iap
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Linux：透過 IAP 通道傳檔</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gcloud compute scp &lt;local-file&gt; &lt;instance-name&gt;:&lt;remote-path&gt; --zone<span class="o">=</span>&lt;zone&gt; --tunnel-through-iap
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># Windows：把 VM 的 3389 port 經 IAP 轉到本機的 13389，再用 RDP 用戶端連 localhost:13389</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">gcloud compute start-iap-tunnel &lt;windows-instance-name&gt; <span class="m">3389</span> --local-host-port<span class="o">=</span>localhost:13389 --zone<span class="o">=</span>&lt;zone&gt;</span></span></code></pre></div><p>主控台上 VM 旁的 SSH 按鈕（SSH-in-browser）不是這幾條指令：官方〈Connect to Linux VMs using IAP〉寫明它只在 VM 沒有外部 IP 時才走 IAP，VM 還帶外部 IP 時它連的是外部 IP，舊規則停用之後就連不上。本機的 port 用 3389 以外的號碼（這裡是 <code>13389</code>），管理員自己的 Windows 電腦開著遠端桌面時，本機的 3389 已經被占用。Ansible 這類用一般 <code>ssh</code> 連線的工具，在 ssh 設定裡用 <code>ProxyCommand</code> 讓 <code>gcloud</code> 開通道：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># ~/.ssh/config：&lt;vm-name&gt; 寫 VM 名稱，ssh 連線時代入 %h
</span></span><span class="line"><span class="ln">2</span><span class="cl">Host &lt;vm-name&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ProxyCommand gcloud compute start-iap-tunnel %h 22 --listen-on-stdin --zone=&lt;zone&gt; --project=&lt;project-id&gt;
</span></span><span class="line"><span class="ln">4</span><span class="cl">  User &lt;os-login-username&gt;
</span></span><span class="line"><span class="ln">5</span><span class="cl">  IdentityFile &lt;private-key-file&gt;</span></span></code></pre></div><p><code>--listen-on-stdin</code> 讓 <code>gcloud</code> 把通道接到標準輸入輸出，ssh 就經由 IAP 連到 <code>%h</code>；這個旗標不出現在 <code>gcloud compute start-iap-tunnel --help</code> 的輸出裡，而功能存在。VM 啟用了 OS Login 時，一般 <code>ssh</code> 不會像 <code>gcloud compute ssh</code> 那樣自動把公鑰登錄到帳號的 OS Login profile，所以先以 Ansible 開通道所用的<a href="/blog/devops/infra/knowledge-cards/service-account/" data-link-title="Service Account（服務帳號）" data-link-desc="程式、排程或雲端服務要以自己的身分呼叫雲端 API、要決定給它哪些角色、遇到「狀態正常卻呼叫失敗」的權限問題、或看到 IAM 綁定裡的 serviceAccount: 成員時，查服務帳號是什麼、它的權限要從「它能做什麼」與「誰能以它的身分行事」兩邊設，以及它和人的帳號在管理上的差別">服務帳號</a>（<code>&lt;ci-service-account-email&gt;</code>）的身分登錄一次：<code>gcloud compute os-login ssh-keys add --key-file=&lt;public-key-file&gt; --impersonate-service-account=&lt;ci-service-account-email&gt;</code>，<code>&lt;public-key-file&gt;</code> 是 <code>&lt;private-key-file&gt;</code> 對應的公鑰檔；<code>&lt;os-login-username&gt;</code> 用同樣加上 <code>--impersonate-service-account</code> 的 <code>gcloud compute os-login describe-profile</code> 輸出裡的 <code>username</code>。</p>
<p>IAP 通道的代價在傳輸速度：所有位元組都經過 Google 的轉送，傳大檔案比直連慢，大量傳檔適合改用 Cloud Storage 當中轉。</p>
<h2 id="切換到-iap-的順序">切換到 IAP 的順序</h2>
<p>切換的順序由兩個事實決定。<code>default-allow-ssh</code> 的來源 <code>0.0.0.0/0</code> 本身就涵蓋 IAP 的位址段，所以舊規則還在的時候，經由 IAP 的連線成功，可能是舊規則放行的，證明不了新規則寫對了；驗證新規則因此要先停用舊規則。而停用舊規則的那一刻，從公開網際網路來的 SSH 與 RDP 都會被拒絕，包括管理員自己的連線與依賴這條規則的 CI、監控、廠商連線。所以：依賴舊規則的連線要先盤點出來並改走 IAP；救援入口（Serial Console 與本機救援帳號）要在停用之前準備好並實際登入過；新規則、tag 與 IAM 權限要在停用之前建好；停用之後確認新路徑可用，才刪除舊規則。與廠商無關的推導見<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>的〈切換管理通道時避免把自己鎖在外面〉。</p>
<p>這個順序裡有三件事的判斷落在 GCP 的機制上：</p>
<ul>
<li><strong>盤點靠 VM 上的登入紀錄，不只靠防火牆紀錄</strong>：全開規則的紀錄大部分是網際網路掃描程式的來源，CI 託管執行環境的 IP 每次不同，混在裡面認不出來；VM 上 sshd 紀錄的帳號與金鑰指紋才認得出是哪個人或哪個工具。</li>
<li><strong>驗證用停用而不是刪除</strong>：停用的規則用 <code>--no-disabled</code> 一行恢復原樣，刪除之後要重建、重建時可能寫錯；防火牆規則透過 GCP 的 API 修改，SSH 連不上也改得回來。</li>
<li><strong>Serial Console 的權限收不窄</strong>：連 Serial Console 需要能修改這台 VM 的權限（例如 <code>roles/compute.instanceAdmin.v1</code>），而這個角色也是一般 VM 管理會用到的權限，所以光靠 IAM 收不窄救援入口；真正把它收窄的是組織政策 <code>compute.disableSerialPortAccess</code>（見〈組織層級的護欄：Organization Policy 與階層式防火牆〉）。組織強制了這條政策時，救援名單裡要有持有組織政策權限、能對專案解除它的人。</li>
</ul>
<p>每一步的指令、實跑看到的輸出，以及每一步沒做或換一種做法時的結果，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>：盤點在〈切換前的盤點：依賴舊規則的連線〉，救援入口在〈準備救援入口：Serial Console 與本機救援帳號〉，停用驗證在〈停用舊規則，用 IAP 驗證新規則〉，<code>default-allow-internal</code> 與 <code>default-allow-icmp</code> 的收斂在〈收斂 default-allow-internal 與 default-allow-icmp〉。</p>
<h2 id="iap-通道連不上時的依層判讀">IAP 通道連不上時的依層判讀</h2>
<p><code>--tunnel-through-iap</code> 的連線失敗時，錯誤訊息與它停在哪一層對得起來：</p>
<ul>
<li><strong>錯誤訊息說 API 未啟用</strong>：連線停在 IAP 端點之前，專案上還沒有啟用 <code>iap.googleapis.com</code>。</li>
<li><strong><code>4033</code>（not authorized）</strong>：停在通道授權。成因是這個帳號沒有 <code>roles/iap.tunnelResourceAccessor</code>、IAM Conditions 不允許這個 port 或 VM、條件的期限已過，或 VM 不存在、沒有在執行；帳號實際拿不拿得到權限，以 Policy Troubleshooter 的結果為準（見〈IAP 的 IAM 授權範圍：誰拿得到通道〉）。IAM 綁定送出之後約 60 到 120 秒才生效，這段時間內的 <code>4033</code> 不代表條件寫錯。</li>
<li><strong><code>4003</code>（failed to connect to backend）</strong>：IAP 已經放行，連不到 VM 的那個 port。成因在網路可達性那一層：VM 沒帶 tag、新規則的網路或 port 寫錯、組織的階層式防火牆或 network firewall policy 先擋下（這類政策優先於專案規則，專案規則寫對了也連不上），或 VM 上的作業系統防火牆（ufw、firewalld、Windows 防火牆）與 sshd 沒有在監聽。防火牆這一段由 Connectivity Tests（<code>gcloud network-management connectivity-tests create</code>，來源給 IAP 位址段裡的一個 IP、目的地給這台 VM 與 port）模擬一次 IAP 到 VM 的連線，結果指出是哪一條規則或政策擋下；完整的指令在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈停用舊規則，用 IAP 驗證新規則〉。VM 內部的成因要從 Serial Console 進去看。</li>
<li><strong>通道開了、登入時 <code>Permission denied (publickey)</code></strong>：停在主機登入驗證。啟用了 OS Login 時，成因是 OS Login 的登入角色或 <code>roles/iam.serviceAccountUser</code> 缺了；沒啟用時，成因是 metadata 裡沒有這把公鑰或缺修改 metadata 的權限（見〈SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件〉）。</li>
</ul>
<h2 id="iap-的-iam-授權範圍誰拿得到通道">IAP 的 IAM 授權範圍：誰拿得到通道</h2>
<p>改走 IAP 之後，VM 的連線權限改由 IAM 決定，所以 IAM policy 的寬度就是 SSH 與 RDP 的暴露面。開通道的角色 <code>roles/iap.tunnelResourceAccessor</code> 可以用 IAM Conditions 限定 port（<code>destination.port == 22</code>）、單台 VM（<code>destination.ip == &quot;&lt;vm-internal-ip&gt;&quot;</code>）或期限（<code>request.time &lt; timestamp(&quot;&lt;expiry-time&gt;&quot;)</code>），各種條件的完整指令在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈授予開通道的 IAM 權限〉。幾個地方會讓這個範圍在不知不覺中變大或對不到人：</p>
<ul>
<li><strong>專案層級的基本角色</strong>：Owner 這一類基本角色涵蓋開通道的權限，而且不受 IAM Conditions 限制。日常登入用的帳號拿掉基本角色、改授本篇列出的具體角色，Owner 只留給緊急時才用的帳號；團隊全員都是 Owner 時，IAM Conditions 對任何人都不生效，OS Login 的單台先行驗證也找不到非 Owner 的人來做。綁定之前先用 <code>gcloud projects get-iam-policy &lt;project-id&gt;</code> 列出目前持有基本角色的成員，再用 IAM 的 Policy Troubleshooter 確認某個帳號實際拿不拿得到 <code>iap.tunnelInstances.accessViaIAP</code>；<code>get-iam-policy</code> 只列出直接綁定，群組與繼承來的權限以 Policy Troubleshooter 的結果為準。稽核要一份「所有拿得到通道的人」的清單時，用 Policy Analyzer（<code>gcloud asset analyze-iam-policy --organization=&lt;org-id&gt; --permissions=iap.tunnelInstances.accessViaIAP --expand-groups</code>），<code>--expand-groups</code> 讓結果把 Google Group 展開成成員（預設不展開，只列到群組），要逐台列出 VM 時另加 <code>--expand-resources</code>；Policy Troubleshooter 一次只回答一個帳號。</li>
<li><strong>整批授權</strong>：把 <code>roles/iap.tunnelResourceAccessor</code> 綁給 <code>domain:</code> 或大型群組，等於讓整個組織的帳號都能開通道。綁給負責維運的群組，並用 IAM Conditions 限定 port 與 VM。</li>
<li><strong>服務帳號</strong>：CI 或自動化工具用的服務帳號若持有通道權限，它的金鑰外洩就等同一把能連進所有 VM 的鑰匙。GCP 上這類帳號用 Workload Identity Federation（<a href="/blog/backend/knowledge-cards/workload-identity/" data-link-title="Workload Identity" data-link-desc="用於機器工作負載的身份語意與授權邊界">Workload Identity</a>）取得短期存取憑證，不發長期金鑰：CI 平台簽發的 OIDC token 換成服務帳號的身分，設定方式以 GCP 的 Workload Identity Federation 文件為準，與 AWS 上用 OIDC 換取 IAM role 的對照見<a href="/blog/devops/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與存取憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與存取憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計</a>的〈OIDC：給 CI/CD 的短期憑證〉。服務帳號要的角色與人相同：開通道的 <code>roles/iap.tunnelResourceAccessor</code>，啟用 OS Login 之後另加 OS Login 的登入角色，VM 掛著服務帳號時再加那個服務帳號的 <code>roles/iam.serviceAccountUser</code>。</li>
<li><strong>組織外的帳號</strong>：外包人員用他自己公司或個人的 Google 帳號時，OS Login 預設不讓組織外的帳號登入，要在組織層級另外授予 <code>roles/compute.osLoginExternalUser</code>（未實跑：測試帳號底下沒有 Organization）；組織若強制了 Organization Policy 的 <code>iam.allowedPolicyMemberDomains</code>（只允許組織網域的成員出現在 IAM 綁定裡），連綁定本身都會被拒絕；2024 年 5 月 3 日之後建立的 Organization 預設就強制這一條，用〈組織層級的護欄：Organization Policy 與階層式防火牆〉的 <code>org-policies list</code> 確認。這類帳號也不受組織的兩步驟驗證強制管轄，所以替外包人員在組織的 Cloud Identity 建帳號，比授權給外部帳號更容易控制，合約到期時停用帳號、或用帶 <code>request.time</code> 的條件讓綁定自動失效。</li>
</ul>
<p>通道授權改用 Google 帳號之後，帳號被釣魚，攻擊者就能從 IAP 開通道。GCP 上的對應設定是在 Google Workspace 或 Cloud Identity 對能開通道的帳號強制兩步驟驗證，管理員帳號用硬體安全金鑰或 <a href="/blog/backend/knowledge-cards/passkey/" data-link-title="Passkey" data-link-desc="要用金鑰對取代密碼、或判斷已導入的 passkey 實際擋掉了什麼時，用來定位它的保護落在哪一段、上限由哪條路徑決定">passkey</a> 這類防釣魚的方式——一次性驗證碼會在釣魚頁面上被一起騙走。<code>gcloud</code> 登入之後會在本機保存 refresh token，管理員帳號停用前這份 token 一直可用，而 token 檔可以被複製；Workspace 與 Cloud Identity 的管理控制台有 Google Cloud 工作階段控制，設定多久要重新驗證一次（1 到 24 小時）；這項設定預設關閉，啟用之後這個間隔才是本機 token 被複製之後能用多久的上限，被設成受信任而豁免的應用程式不受它限制。需要再收緊時，Context-Aware Access 可以在 IAM 綁定上加 access level 條件（Access Context Manager 定義的一組連線條件，例如只允許公司管理的裝置或特定網段）。</p>
<p>人員異動時，移出群組或移除 IAM 綁定只擋下下一次開通道；已經開著的工作階段要另外在 VM 上結束，做法見〈SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件〉最後結束工作階段的那兩段。</p>
<h2 id="ssh-金鑰在-gcp-上的管理位置metadata-金鑰與-os-login-的啟用條件">SSH 金鑰在 GCP 上的管理位置：metadata 金鑰與 OS Login 的啟用條件</h2>
<p>GCP 上把 SSH 公鑰交給 Linux VM 有兩種方式，分別對應<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>〈主機登入驗證的做法：SSH 金鑰怎麼發、怎麼收回〉裡的靜態公鑰與由中央服務決定的公鑰：metadata 金鑰寫在專案或 VM 的 metadata（設在專案或 VM 上的鍵值設定）裡，由 VM 上的 guest agent（GCP 映像檔內建、把 metadata 同步進 VM 的常駐程式）寫進 <code>authorized_keys</code>；<a href="/blog/devops/infra/knowledge-cards/os-login/" data-link-title="OS Login" data-link-desc="GCP 專案要讓 SSH 登入權限跟著 Google 帳號與 IAM 走、啟用 OS Login 之後管理員或 CI 連不進 VM、或盤點專案 metadata 裡殘留的 SSH 公鑰時，查 OS Login 的運作方式、IAM role 與它和 metadata 金鑰的差別">OS Login</a> 依 Google 帳號與 IAM role 決定誰能登入、能不能用 sudo。寫在專案 metadata 的公鑰對專案內每一台沒有啟用 OS Login 的 VM 都生效，成員離職時漏刪一把，那把金鑰就持續能登入整個專案，而沒啟用 OS Login 時 <code>gcloud compute ssh</code> 預設就是把公鑰寫進專案 metadata。兩者的機制與差別見 OS Login 卡。Oracle Cloud 與 AWS 上不放金鑰時由哪些服務登入、各自包辦管理通道的哪幾層，見 <a href="/blog/devops/infra/cloud-providers/vm-options/login/" data-link-title="登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務" data-link-desc="雲端主機建立頁 SSH 金鑰欄位的幾個選項（讓業者產生、上傳、貼上、不放金鑰）各自把什麼放進主機、私鑰由誰保管，映像檔決定的預設使用者名稱，建立之後還能不能加金鑰、金鑰遺失時剩下哪些入口，以及完全不放金鑰時靠業者的哪些服務登入">登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務</a> 的〈不放金鑰時的登入服務〉。</p>
<p>啟用 OS Login（metadata 的 <code>enable-oslogin=TRUE</code>）之後，VM 不再接受 metadata 裡的金鑰，原本靠 metadata 金鑰登入的管理員與自動化工具會立刻連不進去。啟用的順序因此由三個條件決定：</p>
<ul>
<li><strong>角色先授</strong>：每一位管理員與每一個要登入 VM 的服務帳號先拿到登入角色（<code>roles/compute.osLogin</code> 一般登入，<code>roles/compute.osAdminLogin</code> 含 sudo）；VM 掛著服務帳號時（<code>gcloud compute instances describe</code> 輸出的 <code>serviceAccounts</code> 欄不是空的，用 Google Cloud 控制台以預設值建立的 VM 會掛上 Compute Engine 預設服務帳號），每一個登入者另外需要那個服務帳號的 <code>roles/iam.serviceAccountUser</code>。</li>
<li><strong>單台先行</strong>：先在單一 VM 上啟用，讓角色漏授這類錯誤只鎖住那一台；驗證要由非 Owner 的管理員做，Owner 本身持有全部權限，只由 Owner 驗證會看不出漏授。</li>
<li><strong>VM 層級蓋過專案層級</strong>：專案層級啟用之後，VM 層級設了 <code>enable-oslogin=FALSE</code> 的 VM 仍然讀 metadata 金鑰；專案 metadata 的 <code>ssh-keys</code> 留著前任與現任成員的公鑰，OS Login 一旦還原成 <code>FALSE</code> 它們就又能用，所以全部 VM 都啟用之後要清掉。</li>
</ul>
<p>啟用、還原、列出 <code>FALSE</code> 的 VM 與清除 <code>ssh-keys</code> 的指令，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈啟用 OS Login：先授角色、單台先行、再做專案層級〉。OS Login 只作用在 Linux VM，Windows VM 的登入仍由 Windows 帳號決定。OS Login 也可以要求登入時的兩步驟驗證（metadata 設 <code>enable-oslogin-2fa=TRUE</code>），讓每一次 SSH 登入都多一次 Google 帳號的第二因素；沒有啟用兩步驟驗證的帳號會登不進去，所以同樣先對單一 VM 設定驗證。OS Login 文件寫明這項要求不套用在服務帳號上，CI 這類登入者不受它約束。</p>
<p>帳號停用或角色移除之後，下一次登入就會失敗；已經建立的工作階段不受影響，所以處理離職或帳號外洩時，管理員要另外登入 VM 結束那個使用者的工作階段。在 systemd 的發行版上：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 結束 systemd-logind 登記過的工作階段</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo loginctl terminate-user &lt;username&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 確認那個使用者的行程已經全部結束；有輸出就代表還有行程在跑</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pgrep -u &lt;username&gt; -a
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 還有剩下的行程時，直接結束</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">sudo pkill -KILL -u &lt;username&gt;</span></span></code></pre></div><p><code>&lt;username&gt;</code> 是 OS Login 由 Google 帳號轉成的使用者名稱，例如 <code>alice_example_com</code>。確認的依據是 <code>pgrep</code> 的輸出，而不是 <code>terminate-user</code> 的回傳值：<code>loginctl</code> 只結束 systemd-logind 登記過的工作階段，登入時 <code>pam_systemd</code> 登記失敗的工作階段（VM 的日誌裡有 <code>pam_systemd(sshd:session): Failed to create session</code>）不在它的清單裡，這時 <code>loginctl terminate-user</code> 會回報使用者沒有登入，而行程仍在執行。Windows VM 上用 <code>query session</code> 找出那個使用者的 RDP 工作階段，再用 <code>logoff &lt;session-id&gt;</code> 結束。</p>
<h2 id="拿掉外部-ip-之後的出站路徑cloud-nat-與-private-google-access">拿掉外部 IP 之後的出站路徑：Cloud NAT 與 Private Google Access</h2>
<p>Cloud NAT 承接沒有外部 IP 的 VM 往外的連線，<code>apt update</code>、<code>docker pull</code>、呼叫第三方 API 都靠它；它和 AWS 的 NAT Gateway 承擔同一件事，機制見 <a href="/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT</a>。Cloud NAT 建在 Cloud Router（GCP 管理 VPC 路由的元件）上，以 region 為單位生效，VM 分布在幾個 region 就要各建一組；出站 IP 可以讓 GCP 自動配發（可能變動），或改用預留的靜態 IP（外部服務要求來源 IP 白名單時）。VM 要存取 Cloud Storage、Artifact Registry 這類 Google API 時，subnet 開啟 Private Google Access，就能用內部 IP 連到 Google API，不經過 Cloud NAT，也就不產生 NAT 的資料處理費。</p>
<p>拿掉外部 IP 的判斷有兩點：</p>
<ul>
<li><strong>哪些 VM 可以拿</strong>：對外提供服務的 VM（網站、API）移除外部 IP 之前，要先把流量入口換到負載平衡器，否則移除的當下服務就中斷，入口層的設計見<a href="/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用</a>。只做管理用途的 VM 可以直接移除。</li>
<li><strong>驗證要在移除之後做</strong>：網卡還帶著外部 IP 時，Cloud NAT 不處理這張網卡的出站流量，流量走的是 VM 自己的外部 IP，所以移除之前測出站一定是通的，證明不了 Cloud NAT 有效。移除之後在 VM 上看出站的來源 IP，判斷依據是那個 IP 在不在 Cloud NAT 的 IP 清單（<code>gcloud compute routers get-nat-ip-info</code>）裡，而不是移除前後的 IP 有沒有變：自動配發的 Cloud NAT 可能正好配到這台 VM 剛釋放的那個外部 IP。</li>
</ul>
<p>建 Cloud NAT、開 Private Google Access、查 access config 名稱與移除外部 IP、驗證出站的指令與實跑結果，在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈建 Cloud NAT 並移除外部 IP〉。移除之後，這台 VM 只剩 IAP 一條管理路徑，連線一律加 <code>--tunnel-through-iap</code>。</p>
<h2 id="組織層級的護欄organization-policy-與階層式防火牆">組織層級的護欄：Organization Policy 與階層式防火牆</h2>
<p><code>default</code> 網路與 <code>default-allow-ssh</code>、<code>default-allow-rdp</code> 這些預設規則，每個新建的專案都會重新長出來，所以單一專案的收斂只保護那一個專案。要讓組織裡的新專案不再帶著這些規則，在 Organization Policy 設定下面幾個 constraint。這一節的指令需要專案屬於一個 Organization 資源，執行者要有組織層級的權限（<code>roles/orgpolicy.policyAdmin</code> 與 <code>roles/compute.orgFirewallPolicyAdmin</code>）；這一節的指令沒有實跑（測試帳號底下沒有 Organization）。2024 年 5 月 3 日之後建立的 Organization 會預設強制一組 constraint，先用 <code>gcloud resource-manager org-policies list --organization=&lt;org-id&gt;</code> 看哪些已經生效。</p>
<ul>
<li><code>compute.skipDefaultNetworkCreation</code>：新專案不再自動建立 <code>default</code> 網路。</li>
<li><code>compute.vmExternalIpAccess</code>：只允許列在清單上的 VM 持有外部 IP，其餘 VM 建立時帶外部 IP 會被拒絕。</li>
<li><code>compute.requireOsLogin</code>：新建的專案與 VM 啟用 OS Login，任何層級都無法用 metadata 把它關掉；既有 VM 是否已啟用要另外盤點，而既有的管理員要先拿到 OS Login 的登入角色。</li>
<li><code>compute.disableSerialPortAccess</code>：關閉 Serial Console 的存取，讓救援入口平時保持關閉；救援時由持有組織政策權限的人對那個專案解除，救援結束後恢復。</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 在組織層級強制布林型的 constraint（skipDefaultNetworkCreation、requireOsLogin、disableSerialPortAccess 同一種寫法）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud resource-manager org-policies enable-enforce compute.skipDefaultNetworkCreation <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --organization<span class="o">=</span>&lt;org-id&gt;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 救援時，對單一專案解除 Serial Console 的限制</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">gcloud resource-manager org-policies disable-enforce compute.disableSerialPortAccess <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --project<span class="o">=</span>&lt;project-id&gt;
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 救援結束後，刪掉專案層級的例外，讓專案重新繼承組織層級的強制</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">gcloud resource-manager org-policies delete compute.disableSerialPortAccess <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --project<span class="o">=</span>&lt;project-id&gt;</span></span></code></pre></div><p><code>&lt;org-id&gt;</code> 用 <code>gcloud organizations list</code> 查。<code>compute.vmExternalIpAccess</code> 是清單型的 constraint，要用政策檔列出允許的 VM，寫法以 Organization Policy 文件為準。</p>
<p>階層式防火牆政策（hierarchical firewall policy）套用在組織或資料夾上，優先於專案的 VPC 防火牆規則評估。政策裡的規則依 priority 數字由小到大評估，命中 <code>allow</code> 或 <code>deny</code> 就停止，專案的規則不會再被看到；命中 <code>goto_next</code> 則把這條連線交給下一層（最終是專案的 VPC 規則）判斷。所以規則的相對 priority 決定結果：</p>
<ul>
<li>交給專案判斷的 IAP 規則（來源 <code>35.235.240.0/20</code>，有 IPv6 VM 時加上 <code>2600:2d00:1:7::/64</code>；tcp:22 與 tcp:3389；動作 <code>goto_next</code>）priority 要比拒絕規則小，否則 IAP 的流量先被拒絕規則擋下。它用 <code>goto_next</code> 而不用 <code>allow</code>：<code>allow</code> 會讓 IAP 的位址段直接通到組織內每一台 VM，繞過專案用 target tag 做的收斂。</li>
<li>VM 之間或從 VPN 進來的 SSH 需要保留時，它們的來源（內部網段、VPN 的位址段）同樣要有一條 priority 比拒絕規則小的 <code>goto_next</code> 規則，因為拒絕規則的 <code>0.0.0.0/0</code> 也涵蓋內部位址。</li>
<li>拒絕 <code>0.0.0.0/0</code> 連到 tcp:22 與 tcp:3389 的規則只涵蓋 IPv4，IPv6 要另寫一條拒絕 <code>::/0</code> 的規則，這兩條拒絕規則之間沒有先後依賴。</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 建立組織層級的防火牆政策，加入規則，再關聯到一個資料夾（之後再關聯到組織）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">gcloud compute firewall-policies create --short-name<span class="o">=</span>&lt;policy-name&gt; --organization<span class="o">=</span>&lt;org-id&gt;
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">gcloud compute firewall-policies rules create <span class="m">100</span> --firewall-policy<span class="o">=</span>&lt;policy-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --organization<span class="o">=</span>&lt;org-id&gt; --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>goto_next <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --src-ip-ranges<span class="o">=</span>35.235.240.0/20 --layer4-configs<span class="o">=</span>tcp:22,tcp:3389
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">gcloud compute firewall-policies rules create <span class="m">1000</span> --firewall-policy<span class="o">=</span>&lt;policy-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --organization<span class="o">=</span>&lt;org-id&gt; --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>deny <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --src-ip-ranges<span class="o">=</span>0.0.0.0/0 --layer4-configs<span class="o">=</span>tcp:22,tcp:3389
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">gcloud compute firewall-policies associations create --firewall-policy<span class="o">=</span>&lt;policy-name&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --organization<span class="o">=</span>&lt;org-id&gt; --folder<span class="o">=</span>&lt;folder-id&gt;</span></span></code></pre></div><p><code>100</code>、<code>1000</code> 是規則的 priority，只要 <code>goto_next</code> 那幾條比拒絕規則小就行，中間留空號給之後要插入的規則。<code>&lt;policy-name&gt;</code> 是自己取的政策名稱。<code>--folder=&lt;folder-id&gt;</code> 把政策關聯到一個資料夾；組織與資料夾層級的政策影響其下每一個專案，先關聯到一個測試用的資料夾，確認既有服務沒有依賴被擋掉的路徑，再關聯到組織（拿掉 <code>--folder</code>）。</p>
<h2 id="稽核與偵測firewall-logcloud-audit-logs-與-security-command-center">稽核與偵測：firewall log、Cloud Audit Logs 與 Security Command Center</h2>
<p><a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>〈組織層級的護欄與稽核紀錄〉列出事後要查的三項事件，在 GCP 上分別從這幾個來源查：</p>
<ul>
<li>
<p><strong>誰在什麼時候經由哪條通道登入了哪台 VM</strong>：開通道的帳號與目標 VM 記在 Cloud Audit Logs 裡 IAP 的 Data Access 紀錄，要為 Cloud Identity-Aware Proxy 開啟「管理員讀取」（<code>ADMIN_READ</code>）類型才有：開通道時 IAP 檢查的權限 <code>iap.tunnelInstances.accessViaIAP</code> 歸在這個類型，只開「資料讀取」（<code>DATA_READ</code>）時，實測連了十次左右，開通道的紀錄一筆都沒有。開啟之後經由 IAP 連一次、查到方法名稱為 <code>AuthorizeUser</code> 的紀錄才算開對，查詢指令在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a> 的〈啟用 IAP API、開啟稽核紀錄並建立只放行 IAP 來源的防火牆規則〉。登入 VM 的帳號記在 VM 上的 sshd 紀錄，啟用 OS Login 時是由 Google 帳號轉成的使用者名稱。啟用 OS Login 時，每一次登入另外在 Cloud Audit Logs 留下 OS Login 檢查登入權限的 Data Access 紀錄，記著 Google 帳號與目標 VM，而且存在 VM 之外，取得 VM root 的人清不掉：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 列出最近七天每一次 OS Login 的時間、Google 帳號與目標 VM；--freshness 依調查範圍調整</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gcloud logging <span class="nb">read</span> <span class="s1">&#39;protoPayload.serviceName=&#34;oslogin.googleapis.com&#34; AND protoPayload.methodName:&#34;CheckPolicy&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --freshness<span class="o">=</span>7d --format<span class="o">=</span><span class="s2">&#34;value(timestamp,protoPayload.authenticationInfo.principalEmail,protoPayload.resourceName)&#34;</span></span></span></code></pre></div><p>實測的專案沒有為 Cloud OS Login API 另外開啟 Data Access 紀錄，<code>CheckPolicy</code> 的紀錄仍然寫進了 <code>data_access</code> 這個紀錄檔；查不到時，到「IAM 與管理 &gt; 稽核記錄」確認 Cloud OS Login API 的 Data Access 設定，並用 <code>gcloud projects get-iam-policy &lt;project-id&gt; --format=&quot;json(auditConfigs)&quot;</code> 看專案層有沒有排除它。firewall rules logging 記錄每一次放行的來源 IP、目的 VM、port 與命中的規則名稱，經由 IAP 的連線來源都是 IAP 的位址段，從這份紀錄看不出是哪個人。</p>
</li>
<li>
<p><strong>誰在什麼時候開了救援入口</strong>：開啟 Serial Console 的 metadata 修改與組織政策的解除屬於 Admin Activity 紀錄，預設就有。</p>
</li>
<li>
<p><strong>環境裡是否出現新的對外開放管理 port</strong>：Security Command Center 的 Security Health Analytics 持續掃描組織內的防火牆規則，<code>OPEN_SSH_PORT</code> 與 <code>OPEN_RDP_PORT</code> 這兩類 finding 對應的就是對外開放 22 與 3389 的規則。處理方式是照〈切換到 IAP 的順序〉把規則換掉（逐步指令在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>），直接刪除會中斷管理員自己的連線；規則刪除或縮小來源之後，下一次掃描會把對應的 finding 標為已解決。</p>
</li>
</ul>
<p>事件發生時才發現 IAP 的 Data Access 紀錄沒開，開啟之前的通道紀錄查不回來，剩下的來源是 Admin Activity 紀錄裡 metadata 與 IAM 的變更，以及 VM 上的 sshd 紀錄；sshd 紀錄只存在 VM 本機，取得 root 的攻擊者可以清掉，所以要保全時先把 VM 的磁碟做快照，證據的保全與分流見 <a href="/blog/backend/08-incident-response/incident-intake-evidence-triage/" data-link-title="8.18 Incident Intake &amp; Evidence Triage" data-link-desc="把告警、客訴、支援回報與第三方狀態轉成同一個 intake / evidence 判讀流程">8.18 Incident Intake &amp; Evidence Triage</a>。</p>
<p>AWS 上同類的盤點流程見 <a href="/blog/devops/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">Security Group 稽核與清理</a>：兩邊都是先列出來源是 <code>0.0.0.0/0</code> 的規則，逐條確認有沒有服務依賴，再刪除或縮小來源。</p>
<h2 id="延伸閱讀逐步的實作與廠商無關的設計模型與-vpc-的分層規則">延伸閱讀：逐步的實作、與廠商無關的設計模型與 VPC 的分層規則</h2>
<ul>
<li>→ <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP 的逐步指令、實跑看到的輸出，以及每一步沒做或換一種做法時的結果</li>
<li>→ <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>：跳板機、VPN、代理程式反向連線、SSH 憑證這些 GCP 以外的做法，以及依存取者身分、代理把連線送進主機的方式、金鑰收回方式選擇組合的依據</li>
<li>→ <a href="/blog/devops/infra/03-network-foundation/openssh-ca-setup/" data-link-title="OpenSSH 內建 CA 的 SSH 憑證導入：CA 金鑰、sshd 設定、簽發、principal、撤銷與主機憑證" data-link-desc="用 OpenSSH 自帶的 ssh-keygen 與 sshd 建立 SSH 憑證的完整流程：使用者 CA 與主機 CA 的金鑰怎麼產生與保管、sshd 的 TrustedUserCAKeys 與 AuthorizedPrincipalsFile、簽發時的識別字串與有效期、憑證外洩時用 KRL 撤銷，以及用主機憑證取代逐台確認指紋，以及導入順序與兩個會讓所有人登不進去的設定：AuthorizedPrincipalsFile 的讀取權限、RevokedKeys 指的檔案不存在">OpenSSH 內建 CA 的 SSH 憑證導入</a>：主機不全在 GCP、OS Login 用不上時，改用 SSH 憑證讓登入權限自動到期</li>
<li>→ <a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a>：本篇的 tag、OS Login 與移除外部 IP 在 instance template 與 node pool 上的寫法，以及 GKE 控制平面的存取</li>
<li>→ <a href="/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — VPC、subnet 分層與 security group 設計</a>：VPC 與 subnet 的分層與最小開放原則，本篇的防火牆規則是同一套原則在 GCP 上的寫法</li>
</ul>
]]></content:encoded></item><item><title>ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-26，在本機 macOS 的 OrbStack 2.2.5 建的一台 Ubuntu 24.04.5 機器上實測（Arm 架構，核心是 OrbStack 自己的 &lt;code>7.2.12-orbstack&lt;/code>，有 systemd、可以重開機）。ufw 0.36.2，iptables 1.8.10（nf_tables 後端），Docker CE 29.9.2 從 Docker 官方的 apt 套件庫安裝，測試容器是 &lt;code>busybox:1.37&lt;/code>。機器的 DNS 解析器是 OrbStack 提供的 &lt;code>0.250.250.200&lt;/code>。&lt;/p>&lt;/blockquote>
&lt;p>這篇記錄在一台用 ufw 的 Ubuntu 主機上，Docker 發佈的連接埠跟 ufw 規則之間的關係：用兩個 network namespace 模擬位址在 Cloudflare 範圍內與範圍外的來源、ufw 的入站規則與 route 規則對發佈連接埠的效果、FORWARD 鏈裡 Docker 與 ufw 的跳轉順序、&lt;code>DOCKER-USER&lt;/code> 規則在 &lt;code>ufw reload&lt;/code>、ufw 關閉再開啟、Docker 重啟與重開機之後還在不在、寫進 &lt;code>/etc/ufw/after.rules&lt;/code> 讓規則開機時載入的做法與這個檔案對非 ASCII 字元的限制、&lt;code>-p&lt;/code> 發佈在 IPv6 的那一份由 docker-proxy 接收而受 ufw 入站規則管、compose 自訂網路的容器經由 Docker 內建解析器 &lt;code>127.0.0.11&lt;/code> 查詢時封包由主機送出，以及清理。為什麼要只放行 CDN 的位址、各種限制寫法的取捨，寫在概念文章 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a>；firewalld 主機上的同一組實測在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>，Podman 與 rootless Docker 在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址&lt;/a>。&lt;/p>
&lt;h2 id="前置條件ubuntu-主機ufw-與-docker">前置條件：Ubuntu 主機、ufw 與 Docker&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>一台有 systemd、可以重開機的 Ubuntu 主機，有 sudo&lt;/strong>：雲端 VM、本機 VM 或 OrbStack、Multipass 這類工具建的機器都行。Oracle Cloud 的 Ubuntu 平台映像例外：Oracle 的〈Images〉寫明不要在上面用 ufw 改規則，可能讓主機開不了機，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則&lt;/a> 的〈防火牆規則的位置與合併方式〉。這次用 OrbStack 是因為免費、建與刪都在一分鐘內，代價是核心是 OrbStack 自己編的版本、不是 Ubuntu 的映像核心；下文量到的是 iptables、ufw 與 Docker 的行為，它們在 Ubuntu 的映像核心上的差異沒有比較。建法是 &lt;code>orb create ubuntu:noble verify-ufw&lt;/code>，之後用 &lt;code>orb -m verify-ufw sudo bash&lt;/code> 進 root shell。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-26，在本機 macOS 的 OrbStack 2.2.5 建的一台 Ubuntu 24.04.5 機器上實測（Arm 架構，核心是 OrbStack 自己的 <code>7.2.12-orbstack</code>，有 systemd、可以重開機）。ufw 0.36.2，iptables 1.8.10（nf_tables 後端），Docker CE 29.9.2 從 Docker 官方的 apt 套件庫安裝，測試容器是 <code>busybox:1.37</code>。機器的 DNS 解析器是 OrbStack 提供的 <code>0.250.250.200</code>。</p></blockquote>
<p>這篇記錄在一台用 ufw 的 Ubuntu 主機上，Docker 發佈的連接埠跟 ufw 規則之間的關係：用兩個 network namespace 模擬位址在 Cloudflare 範圍內與範圍外的來源、ufw 的入站規則與 route 規則對發佈連接埠的效果、FORWARD 鏈裡 Docker 與 ufw 的跳轉順序、<code>DOCKER-USER</code> 規則在 <code>ufw reload</code>、ufw 關閉再開啟、Docker 重啟與重開機之後還在不在、寫進 <code>/etc/ufw/after.rules</code> 讓規則開機時載入的做法與這個檔案對非 ASCII 字元的限制、<code>-p</code> 發佈在 IPv6 的那一份由 docker-proxy 接收而受 ufw 入站規則管、compose 自訂網路的容器經由 Docker 內建解析器 <code>127.0.0.11</code> 查詢時封包由主機送出，以及清理。為什麼要只放行 CDN 的位址、各種限制寫法的取捨，寫在概念文章 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>；firewalld 主機上的同一組實測在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>，Podman 與 rootless Docker 在 <a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a>。</p>
<h2 id="前置條件ubuntu-主機ufw-與-docker">前置條件：Ubuntu 主機、ufw 與 Docker</h2>
<ul>
<li>
<p><strong>一台有 systemd、可以重開機的 Ubuntu 主機，有 sudo</strong>：雲端 VM、本機 VM 或 OrbStack、Multipass 這類工具建的機器都行。Oracle Cloud 的 Ubuntu 平台映像例外：Oracle 的〈Images〉寫明不要在上面用 ufw 改規則，可能讓主機開不了機，見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈防火牆規則的位置與合併方式〉。這次用 OrbStack 是因為免費、建與刪都在一分鐘內，代價是核心是 OrbStack 自己編的版本、不是 Ubuntu 的映像核心；下文量到的是 iptables、ufw 與 Docker 的行為，它們在 Ubuntu 的映像核心上的差異沒有比較。建法是 <code>orb create ubuntu:noble verify-ufw</code>，之後用 <code>orb -m verify-ufw sudo bash</code> 進 root shell。</p>
</li>
<li>
<p><strong>登入路徑跟要測的連接埠分開</strong>：測試只動 8080、8081 與 IPv6 的 8081。用 SSH 登入雲端主機時，啟用 ufw 之前先 <code>ufw allow 22/tcp</code>（或只放行自己的位址），否則 <code>ufw enable</code> 之後連線會斷；OrbStack 經由自己的通道進機器，不受 ufw 影響。</p>
</li>
<li>
<p><strong>ufw 與 Docker</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt-get update
</span></span><span class="line"><span class="ln">2</span><span class="cl">apt-get install -y ufw iproute2 iptables conntrack dnsutils curl
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># Docker 官方的 apt 套件庫，步驟見 docs.docker.com 的〈Install Docker Engine on Ubuntu〉</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker pull busybox:1.37</span></span></code></pre></div><p><code>docker-ce</code> 帶 systemd 的 unit，裝好就由 systemd 執行並設成開機啟動。Ubuntu 自己的 <code>docker.io</code> 套件版本較舊，Docker 寫防火牆規則的方式依版本而變（28.0.0 改過發佈在 127.0.0.1 的連接埠對同網段主機的可達性），沒有比較。</p>
</li>
<li>
<p><strong>暫存目錄用 <code>verify-</code> 開頭，放在 <code>/var/tmp</code></strong>：這台機器的 <code>/tmp</code> 是 tmpfs（<code>findmnt /tmp</code> 的 <code>FSTYPE</code> 欄是 <code>tmpfs</code>），重開機就清空；<code>/var/tmp</code> 在磁碟上，重開機後還在。這次第一輪把網頁檔放在 <code>/tmp/verify-fw/www</code>，重開機之後容器自動重新啟動時，Docker 發現 bind mount 的來源目錄不存在，替它建了一個空目錄，httpd 找不到 <code>index.html</code> 而回 404。</p>
</li>
</ul>
<h2 id="共用的函式與兩個測試來源">共用的函式與兩個測試來源</h2>
<p>下文的指令都在同一個 root shell 裡跑。<code>ns_setup</code> 建兩個 <a href="/blog/devops/infra/knowledge-cards/network-namespace/" data-link-title="Network Namespace（網路命名空間）" data-link-desc="容器的網路為什麼跟主機分開、要在一台主機上模擬另一個來源位址做防火牆實測、或看到 ip netns 與 veth 時，查 Linux 核心的網路隔離單位與它跟 Kubernetes namespace 的差別">network namespace</a>：<code>verify-cf</code> 的位址 <code>173.245.48.10</code> 落在 Cloudflare 的 <code>173.245.48.0/20</code>，<code>verify-other</code> 的 <code>203.0.113.10</code> 是文件保留位址、不在任何 Cloudflare 範圍。兩者各用一對 veth 接到主機，對主機的 IP 發連線，封包從主機的 veth 介面進來，經過的防火牆路徑跟從網卡進來的相同。<code>probe &lt;連接埠&gt;</code> 讓兩個來源各連一次。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nv">HOSTIP</span><span class="o">=</span><span class="k">$(</span>ip -4 -br a show eth0 <span class="p">|</span> awk <span class="s1">&#39;{print $3}&#39;</span> <span class="p">|</span> cut -d/ -f1<span class="k">)</span>   <span class="c1"># 主機主要網卡的位址；網卡名不是 eth0 時換掉</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">ns_setup<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ip netns add verify-cf<span class="p">;</span> ip netns add verify-other
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ip link add vcf-h <span class="nb">type</span> veth peer name vcf-n netns verify-cf
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  ip link add voth-h <span class="nb">type</span> veth peer name voth-n netns verify-other
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  ip addr add 192.0.2.1/32 dev vcf-h<span class="p">;</span> ip link <span class="nb">set</span> vcf-h up
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  ip addr add 192.0.2.5/32 dev voth-h<span class="p">;</span> ip link <span class="nb">set</span> voth-h up
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ip route add 173.245.48.10/32 dev vcf-h<span class="p">;</span> ip route add 203.0.113.10/32 dev voth-h
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  ip -n verify-cf addr add 173.245.48.10/32 dev vcf-n<span class="p">;</span> ip -n verify-cf link <span class="nb">set</span> vcf-n up<span class="p">;</span> ip -n verify-cf link <span class="nb">set</span> lo up
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ip -n verify-cf route add 192.0.2.1/32 dev vcf-n<span class="p">;</span> ip -n verify-cf route add default via 192.0.2.1 dev vcf-n
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ip -n verify-other addr add 203.0.113.10/32 dev voth-n<span class="p">;</span> ip -n verify-other link <span class="nb">set</span> voth-n up<span class="p">;</span> ip -n verify-other link <span class="nb">set</span> lo up
</span></span><span class="line"><span class="ln">12</span><span class="cl">  ip -n verify-other route add 192.0.2.5/32 dev voth-n<span class="p">;</span> ip -n verify-other route add default via 192.0.2.5 dev voth-n
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">probe<span class="o">()</span> <span class="o">{</span>  <span class="c1"># probe &lt;連接埠&gt;：兩個來源各連一次 http://$HOSTIP:&lt;連接埠&gt;/，5 秒逾時</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="k">for</span> ns in verify-cf verify-other<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nv">out</span><span class="o">=</span><span class="k">$(</span>ip netns <span class="nb">exec</span> <span class="nv">$ns</span> curl -sS -m <span class="m">5</span> -o /dev/null -w <span class="s1">&#39;%{http_code}&#39;</span> http://<span class="nv">$HOSTIP</span>:<span class="nv">$1</span>/ 2&gt;<span class="p">&amp;</span>1<span class="k">)</span><span class="p">;</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">printf</span> <span class="s1">&#39;  %-13s -&gt; :%s  rc=%s  %s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$ns</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span> <span class="p">|</span> tail -1<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">ns_setup
</span></span><span class="line"><span class="ln">21</span><span class="cl">sysctl -w net.ipv4.ip_forward<span class="o">=</span><span class="m">1</span></span></span></code></pre></div><p><code>192.0.2.1</code> 與 <code>192.0.2.5</code> 是主機這一端的位址，只當兩個 namespace 的閘道。namespace 連主機本身的程式（8080）屬於主機自己接收，不需要轉送；連 Docker 發佈的連接埠時，封包被改寫成容器位址之後要由主機轉送到容器網路，<code>ip_forward=1</code> 管的是這一段，Docker 啟動時也會把它設成 1。<code>probe</code> 的輸出有三種讀法：<code>rc=0  200</code> 是連上並拿到回應；<code>rc=7</code> 是主機立即回拒；<code>rc=28  000</code> 是封包被丟棄、5 秒內沒有回應。namespace 與 veth 在重開機之後消失，<code>HOSTIP</code> 與兩個函式只存在這個 shell 裡：重開機或換一個 shell 之後，重新貼一次上面整個區塊（它會重新定義函式並重建 namespace）。</p>
<p>量 IPv6 的那幾節另外替兩個來源各加一個 IPv6 位址，並在主機的 <code>lo</code> 上加一個測試用的 IPv6 位址（ULA，<code>fd00::/8</code> 裡只在本地使用的位址）當連線目標。<code>verify-cf</code> 用 Cloudflare <code>2606:4700::/32</code> 裡的 <code>2606:4700::10</code>，<code>verify-other</code> 用文件保留的 <code>2001:db8::10</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nv">HOST6</span><span class="o">=</span>fd00:beef::1
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">ns_setup6<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ip -6 addr add <span class="nv">$HOST6</span>/128 dev lo
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ip -n verify-cf addr add 2606:4700::10/128 dev vcf-n<span class="p">;</span> ip -n verify-other addr add 2001:db8::10/128 dev voth-n
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  sleep <span class="m">3</span>                                                    <span class="c1"># 等 veth 的 link-local 位址生效</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  ip -6 route add 2606:4700::10/128 dev vcf-h<span class="p">;</span> ip -6 route add 2001:db8::10/128 dev voth-h
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  ip -n verify-cf -6 route add default via <span class="k">$(</span>ip -6 -br a show vcf-h <span class="p">|</span> grep -o <span class="s1">&#39;fe80::[^/]*&#39;</span><span class="k">)</span> dev vcf-n
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ip -n verify-other -6 route add default via <span class="k">$(</span>ip -6 -br a show voth-h <span class="p">|</span> grep -o <span class="s1">&#39;fe80::[^/]*&#39;</span><span class="k">)</span> dev voth-n
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">probe6<span class="o">()</span> <span class="o">{</span>  <span class="c1"># probe6 &lt;連接埠&gt;：兩個來源各經由 IPv6 連一次 http://[$HOST6]:&lt;連接埠&gt;/</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">for</span> ns in verify-cf verify-other<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nv">out</span><span class="o">=</span><span class="k">$(</span>ip netns <span class="nb">exec</span> <span class="nv">$ns</span> curl -sS -m <span class="m">5</span> -o /dev/null -w <span class="s1">&#39;%{http_code}&#39;</span> <span class="s2">&#34;http://[</span><span class="nv">$HOST6</span><span class="s2">]:</span><span class="nv">$1</span><span class="s2">/&#34;</span> 2&gt;<span class="p">&amp;</span>1<span class="k">)</span><span class="p">;</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nb">printf</span> <span class="s1">&#39;  %-13s -&gt; [%s]:%s  rc=%s  %s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$ns</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$HOST6</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span><span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span> <span class="p">|</span> tail -1<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">ns_setup6</span></span></code></pre></div><p>兩個 namespace 的預設路由指向主機那一端 veth 的 link-local 位址（<code>fe80::</code> 開頭，介面啟用後幾秒才生效）。重開機之後 <code>ns_setup</code> 與 <code>ns_setup6</code> 都要重跑。</p>
<p>主機上聽 8080 的程式代表直接跑在主機上的網站，聽 8081 的容器代表 Docker 發佈的網站：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p /var/tmp/verify-fw/www<span class="p">;</span> <span class="nb">echo</span> ok &gt; /var/tmp/verify-fw/www/index.html
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 -m http.server <span class="m">8080</span> --directory /var/tmp/verify-fw/www &gt;/var/tmp/verify-fw/http8080.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker run -d --name verify-web --restart unless-stopped -p 8081:80 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  -v /var/tmp/verify-fw/www:/www:ro busybox:1.37 httpd -f -p <span class="m">80</span> -h /www</span></span></code></pre></div><p><code>--restart unless-stopped</code> 讓容器在 Docker 重啟與重開機之後自己起來，重開機那幾步要靠它。</p>
<h2 id="ufw-的入站規則與-route-規則對發佈連接埠的效果">ufw 的入站規則與 route 規則對發佈連接埠的效果</h2>
<p>ufw 預設拒絕入站，只放行 Cloudflare 的一段到 8080 與 8081：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw default deny incoming<span class="p">;</span> ufw default allow outgoing
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw allow from 173.245.48.0/20 to any port <span class="m">8080</span> proto tcp
</span></span><span class="line"><span class="ln">3</span><span class="cl">ufw allow from 173.245.48.0/20 to any port <span class="m">8081</span> proto tcp
</span></span><span class="line"><span class="ln">4</span><span class="cl">ufw --force <span class="nb">enable</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ufw status verbose
</span></span><span class="line"><span class="ln">6</span><span class="cl">probe 8080<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">iptables -S FORWARD <span class="p">|</span> grep -- <span class="s1">&#39;-j&#39;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">Default: deny (incoming), allow (outgoing), deny (routed)
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">8080/tcp                   ALLOW IN    173.245.48.0/20
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">8081/tcp                   ALLOW IN    173.245.48.0/20
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  verify-cf     -&gt; :8080  rc=0  200
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  verify-other  -&gt; :8080  rc=28  000
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  verify-other  -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">-A FORWARD -j DOCKER-USER
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">-A FORWARD -j DOCKER-FORWARD
</span></span><span class="line"><span class="ln">10</span><span class="cl">-A FORWARD -j ufw-before-logging-forward
</span></span><span class="line"><span class="ln">11</span><span class="cl">-A FORWARD -j ufw-before-forward
</span></span><span class="line"><span class="ln">12</span><span class="cl">-A FORWARD -j ufw-after-forward
</span></span><span class="line"><span class="ln">13</span><span class="cl">-A FORWARD -j ufw-after-logging-forward
</span></span><span class="line"><span class="ln">14</span><span class="cl">-A FORWARD -j ufw-reject-forward
</span></span><span class="line"><span class="ln">15</span><span class="cl">-A FORWARD -j ufw-track-forward</span></span></code></pre></div><p>主機上的 8080 只有 Cloudflare 那一段連得到，範圍外的來源被丟棄（逾時）：ufw 的入站規則管的是 input 路徑，也就是目的地是主機本身程式的封包。容器的 8081 兩個來源都連得到：Docker 在封包剛進主機時把目的地改寫成容器（<a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT</a>），封包變成要由主機轉出到另一個介面的轉送路徑，不經過 ufw 的入站規則。兩條路徑的分岔與各自的限制寫法，在概念文章 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a> 的〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。<code>ufw status</code> 列的 8081 規則對它沒有作用。</p>
<p>轉送路徑上 ufw 也有規則：<code>ufw route</code> 寫的規則放在 <code>ufw-user-forward</code>，封包經過 <code>ufw-before-forward</code> 時跳轉（jump）進這條鏈比對，而預設的 <code>deny (routed)</code> 是 FORWARD 鏈的預設政策 DROP。試著用 route 規則擋 8081 與容器的 80：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw route deny proto tcp from any to any port <span class="m">80</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw route deny proto tcp from any to any port <span class="m">8081</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ufw reload
</span></span><span class="line"><span class="ln">4</span><span class="cl">probe <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8081  rc=0  200</span></span></code></pre></div><p>兩條 route 規則都沒有效果。原因在上面那份跳轉順序：FORWARD 鏈開頭是 Docker 的 <code>DOCKER-USER</code> 與 <code>DOCKER-FORWARD</code>，<code>DOCKER-FORWARD</code> 接受送往發佈連接埠的連線，封包在跳到任何 <code>ufw-*</code> 鏈之前就被接受，ufw 的 route 規則與 FORWARD 的預設 DROP 都輪不到它。測完刪掉這兩條：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw route delete deny proto tcp from any to any port <span class="m">80</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw route delete deny proto tcp from any to any port <span class="m">8081</span></span></span></code></pre></div><p>刪除的語法是 <code>ufw route delete &lt;規則&gt;</code>；寫成 <code>ufw delete route deny …</code> 時 ufw 回 <code>ERROR: Invalid syntax</code>、規則沒有刪掉。</p>
<h2 id="docker-user-規則的效果以及它在-ufw-reloaddocker-重啟與重開機之後的存續">DOCKER-USER 規則的效果，以及它在 ufw reload、Docker 重啟與重開機之後的存續</h2>
<p><a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a> 排在 FORWARD 鏈最前面，裡面的規則由使用者寫。封包到這裡時目的地已經改成容器的 80，所以比對 <a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack</a>（連線追蹤）記下的原始目的連接埠 8081：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport <span class="m">8081</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP
</span></span><span class="line"><span class="ln">2</span><span class="cl">probe <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8081  rc=28  000</span></span></code></pre></div><p>範圍外的來源被擋下，<code>DROP</code> 讓它等到逾時；換成 <code>-j REJECT</code> 時它會立即收到回拒，這次沒有比較。</p>
<p>這條規則只比對連接埠與來源，發佈的連接埠是 8081 時沒有副作用；換成正式的 80 與 443 時，容器自己往外連 443（呼叫外部 API、ACME 取憑證）的封包，與同一台主機上容器之間連 80 的封包，也會經過 <code>DOCKER-USER</code>，原始目的連接埠同樣是 443 或 80，來源是容器位址、不在清單裡，一起被丟掉。補測時把同樣寫法的規則套在 443、80、8081 上，一個 <code>busybox</code> 容器 <code>wget https://www.cloudflare.com/</code> 與連另一個容器的 80 都以非零結束（出站那一次的訊息是 <code>download timed out</code>）；三條規則都加上 <code>-m conntrack --ctstate DNAT</code>（只比對經過位址轉換的連線，也就是送往發佈連接埠的那些）之後，兩者恢復，範圍外的來源連 8081 仍然逾時：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctstate DNAT --ctorigdstport <span class="m">443</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP</span></span></code></pre></div><p>另一種限定法是加 <code>-i &lt;對外的網卡&gt;</code>，只比對從外面進來的封包，補測時同樣讓容器的出站恢復；這次的測試來源從 veth 進來，<code>-i eth0</code> 時範圍外的來源是否仍被擋，沒有量到。下文的規則以 8081 實測，正式設定換成 80、443 時都加 <code>--ctstate DNAT</code>。這裡只放行 Cloudflare 的一段，正式設定時對整份清單各寫一條 <code>-s … -j RETURN</code> 的自建鏈，寫法在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a> 的〈DOCKER-USER 鏈的限制效果與規則的存續〉。接著依序做四個會重建防火牆規則的動作，每做完一個就看一次規則與結果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw reload<span class="p">;</span>                    iptables -S DOCKER-USER<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw disable<span class="p">;</span> ufw --force enable<span class="p">;</span> iptables -S DOCKER-USER<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">systemctl restart docker<span class="p">;</span>      iptables -S DOCKER-USER<span class="p">;</span> probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">reboot                         <span class="c1"># OrbStack 在本機跑 orb restart verify-ufw；開機後重新貼〈共用的函式與兩個測試來源〉的區塊</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">iptables -S DOCKER-USER<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>動作</th>
          <th><code>DOCKER-USER</code> 裡的規則</th>
          <th>範圍外的來源連 8081</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ufw reload</code></td>
          <td>還在</td>
          <td>逾時</td>
      </tr>
      <tr>
          <td><code>ufw disable</code> 再 enable</td>
          <td>還在</td>
          <td>逾時</td>
      </tr>
      <tr>
          <td><code>systemctl restart docker</code></td>
          <td>還在</td>
          <td>逾時</td>
      </tr>
      <tr>
          <td>重開機</td>
          <td>不在（只剩空的鏈）</td>
          <td>200</td>
      </tr>
  </tbody>
</table>
<p>ufw 重新載入時只重建自己的 <code>ufw-*</code> 鏈，不碰 Docker 的鏈；Docker 重啟時保留 <code>DOCKER-USER</code> 的內容。iptables 指令加的規則只存在執行中的核心裡，重開機之後就不在，範圍外的來源又連得到。重開機那一列實測用的是 <code>orb restart verify-ufw</code>。</p>
<h2 id="讓規則開機時載入寫進-etcufwafterrules">讓規則開機時載入：寫進 /etc/ufw/after.rules</h2>
<p>ufw 啟動時會依序套用 <code>/etc/ufw/before.rules</code>、使用者規則與 <code>/etc/ufw/after.rules</code>，三個檔案是 <code>iptables-restore</code> 的格式。在 <code>after.rules</code> 結尾加一段 <code>*filter</code> 區塊，宣告 <code>DOCKER-USER</code> 鏈並寫規則，ufw 每次啟動與重新載入時都會寫入這條規則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cp /etc/ufw/after.rules /var/tmp/verify-fw/after.rules.orig
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">cat &gt;&gt; /etc/ufw/after.rules <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s"># verify-ufw: published 8081 only from one Cloudflare range
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">*filter
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">:DOCKER-USER - [0:0]
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">-A DOCKER-USER -p tcp -m conntrack --ctorigdstport 8081 --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">COMMIT
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">iptables -F DOCKER-USER     <span class="c1"># 沒有做〈DOCKER-USER 規則的效果…〉那一節的重開機時，清掉手動加的那一條；宣告這條鏈本身也會清空它，這一行可以省略</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">ufw reload
</span></span><span class="line"><span class="ln">12</span><span class="cl">iptables -S DOCKER-USER<span class="p">;</span> probe <span class="m">8081</span></span></span></code></pre></div><p><code>ufw reload</code> 之後規則在、範圍外的來源逾時；<code>systemctl restart docker</code> 之後也一樣。</p>
<p><code>:DOCKER-USER - [0:0]</code> 這一行宣告這條鏈：鏈不存在時建出來（ufw 開機時比 Docker 先啟動，這時鏈還不存在），鏈已經存在時（Docker 建好的）清空它的內容，再寫入檔案裡的規則。所以上面的 <code>iptables -F</code> 可以省略。宣告會清空這條鏈，帶來一個副作用，另外量過一次：先用 <code>iptables -I</code> 在 <code>DOCKER-USER</code> 加一條別的規則，再寫進這段、<code>ufw reload</code>，那一條就不在了，鏈裡只剩 after.rules 寫的。所以同一台主機上用 after.rules 時，<code>DOCKER-USER</code> 的其他規則（包括下文 DNS 那一節用 <code>iptables -I</code> 加的）也要寫進同一段，否則下一次 <code>ufw reload</code> 就被清掉。</p>
<p>反方向也量過：從 after.rules 刪掉這一段再 <code>ufw reload</code>，鏈裡的規則還在（ufw 不再宣告這條鏈，就不動它），要再 <code>iptables -D</code> 或重開機才消失（依 iptables 規則只存在執行中的核心推論，<code>iptables -D</code> 與重開機這兩項沒有量）。</p>
<p>重開機之後（OrbStack 用 <code>orb restart verify-ufw</code>，雲端主機用 <code>reboot</code>），重新貼〈共用的函式與兩個測試來源〉的區塊，再看：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">iptables -S DOCKER-USER<span class="p">;</span> iptables -S FORWARD <span class="p">|</span> grep -- <span class="s1">&#39;-j&#39;</span> <span class="p">|</span> head -2
</span></span><span class="line"><span class="ln">2</span><span class="cl">probe <span class="m">8081</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">systemctl show -p ActiveEnterTimestampMonotonic ufw docker</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-N DOCKER-USER
</span></span><span class="line"><span class="ln">2</span><span class="cl">-A DOCKER-USER ! -s 173.245.48.0/20 -p tcp -m conntrack --ctorigdstport 8081 --ctdir ORIGINAL -j DROP
</span></span><span class="line"><span class="ln">3</span><span class="cl">-A FORWARD -j DOCKER-USER
</span></span><span class="line"><span class="ln">4</span><span class="cl">-A FORWARD -j DOCKER-FORWARD
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  404
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-other  -&gt; :8081  rc=28  000
</span></span><span class="line"><span class="ln">7</span><span class="cl">ActiveEnterTimestampMonotonic=86988488358
</span></span><span class="line"><span class="ln">8</span><span class="cl">ActiveEnterTimestampMonotonic=86990212381</span></span></code></pre></div><p>規則在，範圍外的來源被擋。兩個時間戳是 ufw 與 docker 兩個 unit 進入 active 的時間（開機後的微秒數），ufw 先、Docker 後：開機時 ufw 先建出 <code>DOCKER-USER</code> 並寫入規則，Docker 啟動時沿用這條已經存在的鏈、不清它的內容，再把兩條跳轉插到 FORWARD 最前面。範圍內的來源回 404 是實測時網頁目錄放在 <code>/tmp</code>、重開機後變成空的（〈前置條件〉的暫存目錄那一項），連線本身送到了容器；下面整份清單那一次放在 <code>/var/tmp</code>，重開機之後是 200。</p>
<h3 id="整份-cloudflare-清單ipv4-寫進-afterrulesipv6-用-ufw-的入站規則">整份 Cloudflare 清單：IPv4 寫進 after.rules、IPv6 用 ufw 的入站規則</h3>
<p>上面那段只放行一個位址段。正式設定時，IPv4 那一份（走轉送路徑）把整份清單寫成一條自建鏈放進 after.rules；IPv6 那一份由 docker-proxy 接收、走 input 路徑（見〈IPv6 那一份發佈：docker-proxy 的接收路徑與 ufw 入站規則的效果〉），用 ufw 的入站規則對每一段放行。先把上面那段從 after.rules 拿掉（<code>cp /var/tmp/verify-fw/after.rules.orig /etc/ufw/after.rules</code>），再：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nv">D</span><span class="o">=</span>/var/tmp/verify-fw
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">curl -sSf https://www.cloudflare.com/ips-v4 -o <span class="nv">$D</span>/ips-v4
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">curl -sSf https://www.cloudflare.com/ips-v6 -o <span class="nv">$D</span>/ips-v6
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nb">echo</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;# verify-ufw: Docker published port 8081, Cloudflare IPv4 ranges only&#39;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;*filter&#39;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;:DOCKER-USER - [0:0]&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;:CF-ONLY - [0:0]&#39;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">while</span> <span class="nb">read</span> -r n <span class="o">||</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$n</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">do</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$n</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;-A CF-ONLY -s </span><span class="nv">$n</span><span class="s2"> -j RETURN&#34;</span><span class="p">;</span> <span class="k">done</span> &lt; <span class="nv">$D</span>/ips-v4
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;-A CF-ONLY -j DROP&#39;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;-A DOCKER-USER -p tcp -m conntrack --ctstate DNAT --ctorigdstport 8081 --ctdir ORIGINAL -j CF-ONLY&#39;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nb">echo</span> <span class="s1">&#39;COMMIT&#39;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="o">}</span> &gt;&gt; /etc/ufw/after.rules
</span></span><span class="line"><span class="ln">15</span><span class="cl">ufw reload
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="k">while</span> <span class="nb">read</span> -r n <span class="o">||</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$n</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">do</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$n</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> ufw allow proto tcp from <span class="s2">&#34;</span><span class="nv">$n</span><span class="s2">&#34;</span> to any port <span class="m">8081</span> &gt;/dev/null<span class="p">;</span> <span class="k">done</span> &lt; <span class="nv">$D</span>/ips-v6
</span></span><span class="line"><span class="ln">17</span><span class="cl">grep -c . <span class="nv">$D</span>/ips-v4<span class="p">;</span> iptables -S CF-ONLY <span class="p">|</span> grep -c RETURN
</span></span><span class="line"><span class="ln">18</span><span class="cl">grep -c . <span class="nv">$D</span>/ips-v6<span class="p">;</span> ufw status <span class="p">|</span> grep <span class="s1">&#39;8081.*ALLOW&#39;</span> <span class="p">|</span> grep -c <span class="s1">&#39;::&#39;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">probe <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">15
</span></span><span class="line"><span class="ln">2</span><span class="cl">15
</span></span><span class="line"><span class="ln">3</span><span class="cl">7
</span></span><span class="line"><span class="ln">4</span><span class="cl">7
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-cf     -&gt; :8081  rc=0  200
</span></span><span class="line"><span class="ln">6</span><span class="cl">  verify-other  -&gt; :8081  rc=28  000</span></span></code></pre></div><p>封包進到 <code>DOCKER-USER</code>、連線經過位址轉換而原始目的連接埠是 8081 時跳到 <code>CF-ONLY</code>（實測那一次的跳轉沒有 <code>--ctstate DNAT</code>，加上它的效果是〈DOCKER-USER 規則的效果…〉量的那一組）：來源落在清單的任一段就 <code>RETURN</code> 回 <code>DOCKER-USER</code>，接著由 Docker 的 <code>DOCKER-FORWARD</code> 放行；全部不符時落到最後的 <code>DROP</code>。<code>8081</code> 換成自己發佈的連接埠，每個連接埠在 <code>DOCKER-USER</code> 寫一行跳轉、在 ufw 寫一組放行。</p>
<p>兩組計數要對得上：<code>grep -c .</code> 數清單的段數，另一個數實際寫進去的規則數。實測那一次是在一台新開的 ufw 上量，ufw 裡只有 IPv6 的那 7 條放行，計數用的是 <code>ufw status | grep -c '8081.*ALLOW'</code>；照本文從前面幾節做下來時，ufw 裡還有一條 IPv4 的 <code>173.245.48.0/20</code> 放行，上面的區塊因此只數來源帶 <code>::</code> 的那幾條，這個寫法沒有照這個形式跑過。<code>CF-ONLY</code> 那一行只數 <code>RETURN</code>，鏈裡另外還有最後一條 <code>DROP</code>。Cloudflare 這兩份清單的最後一行沒有換行字元，<code>while read -r n</code> 單獨寫時讀不到最後一行、也不報錯，第一次實測時 IPv4 少了 <code>131.0.72.0/22</code>、IPv6 少了 <code>2c0f:f248::/32</code>，被漏掉的位址段回源時會被擋下；<code>|| [ -n &quot;$n&quot; ]</code> 讓迴圈也處理沒有換行結尾的最後一行。</p>
<p>用〈共用的函式與兩個測試來源〉的 <code>ns_setup6</code> 加好 IPv6 位址之後，<code>probe6 8081</code> 也只有 <code>verify-cf</code> 拿到 200。重開機之後重新貼函式的區塊再量一次，IPv4 的 15 條 <code>RETURN</code> 還在，<code>probe 8081</code> 與 <code>probe6 8081</code> 的結果都跟上面相同（IPv6 那一份量的是連線結果，沒有再數規則）。Cloudflare 的清單更新時，重抓兩份清單、把 after.rules 裡這一段換成新產生的內容再 <code>ufw reload</code>，IPv6 那一份另外用 <code>ufw delete</code> 刪掉不在新清單裡的段；這個更新流程沒有實跑。</p>
<p>after.rules 裡的非 ASCII 字元，會讓 ufw 在重寫規則檔時中止。第一次寫進去的註解是「<code># verify-ufw：Docker 發佈的 8081 …</code>」，<code>ufw reload</code> 照常，而改預設政策時失敗。下面的指令是那一次的紀錄；照本文用 ASCII 註解時，這條指令會成功、把 routed 政策改成 ACCEPT，所以不必照跑，跑了的話以 <code>ufw default deny routed</code> 改回：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw default allow routed<span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;exit=</span><span class="nv">$?</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">grep DEFAULT_FORWARD_POLICY /etc/default/ufw<span class="p">;</span> iptables -S FORWARD <span class="p">|</span> head -1</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">UnicodeEncodeError: &#39;ascii&#39; codec can&#39;t encode character &#39;\uff1a&#39; in position 12: ordinal not in range(128)
</span></span><span class="line"><span class="ln">2</span><span class="cl">exit=1
</span></span><span class="line"><span class="ln">3</span><span class="cl">DEFAULT_FORWARD_POLICY=&#34;ACCEPT&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">-P FORWARD DROP</span></span></code></pre></div><p><code>\uff1a</code> 是全形冒號 <code>：</code>，position 12 是從 0 起算，也就是註解的第 13 個字元。ufw 改預設政策時會重寫規則檔，遇到這個字元時 Python 以 ASCII 編碼失敗、以 exit 1 結束。失敗之前 <code>/etc/default/ufw</code> 已經改成 <code>ACCEPT</code>，執行中的 FORWARD 鏈還是 <code>DROP</code>：設定檔與執行中的規則不一致。下一次 <code>ufw reload</code> 或重開機時 ufw 會讀設定檔，依這個讀法推論 FORWARD 會變成 <code>ACCEPT</code>，沒有實跑。反方向的 <code>ufw default deny routed</code> 也以同一個錯誤失敗過（Podman 那一篇實測時遇到）。<code>after.rules</code> 本身沒有被截斷（行數與修改時間都沒變）。註解改成 ASCII 之後同一條指令正常完成。檔案裡有沒有非 ASCII 字元，用 <code>grep -nP '[^\x00-\x7F]' /etc/ufw/*.rules</code> 查，沒有輸出就是全部 ASCII。</p>
<p>這一步的替代做法：</p>
<ul>
<li><strong>iptables-persistent（netfilter-persistent）</strong>：開機時載入 <code>iptables-save</code> 存下的整份規則。Docker 與 ufw 的規則也會一起被存進去，開機時跟 Docker、ufw 自己寫的規則重複載入，順序也可能排在它們之前或之後；這一種沒有實跑。</li>
<li><strong>自己的 nftables 表</strong>：寫法與開機載入在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a> 的〈自己的 nftables 表：掛在目的位址轉換之前〉，那裡量過它在 firewalld 主機上的存續；在 ufw 主機上沒有實跑。ufw 用的是 iptables-nft，自建的 nftables 表跟它並存，ufw reload 時不會被清（依 ufw 只重建自己的鏈推論）。</li>
<li><strong>不發佈或只發佈在 <code>127.0.0.1</code></strong>（這一項替代的是整個 <code>DOCKER-USER</code> 做法，不只是開機載入）：發佈在 <code>127.0.0.1</code> 的連接埠外部來源連不到（firewalld 那一篇在 Docker 29.9.2 上量過），前面的反向代理以主機上的程式執行時，ufw 的入站規則就管得到它；在 ufw 主機上沒有實跑。</li>
</ul>
<h2 id="ipv6-那一份發佈docker-proxy-的接收路徑與-ufw-入站規則的效果">IPv6 那一份發佈：docker-proxy 的接收路徑與 ufw 入站規則的效果</h2>
<p><code>-p 8081:80</code> 同時發佈在 IPv4 與 IPv6。容器所在的網路沒有開 IPv6（Docker 預設的 bridge 網路就是）時，IPv6 那一份改由主機上的程式接收：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ss -ltnp <span class="p">|</span> grep <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">LISTEN 0  4096     0.0.0.0:8081   0.0.0.0:*   users:((&#34;docker-proxy&#34;,pid=616,fd=8))
</span></span><span class="line"><span class="ln">2</span><span class="cl">LISTEN 0  4096        [::]:8081      [::]:*   users:((&#34;docker-proxy&#34;,pid=622,fd=8))</span></span></code></pre></div><p><code>[::]:8081</code> 由 docker-proxy 這個主機上的程式（<a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy</a> 的一種）接聽，它收下連線、再自己連到容器的 IPv4 位址。IPv6 的連線因此送到主機上的程式，走的是 input 路徑。IPv4 的 <code>0.0.0.0:8081</code> 也由 docker-proxy 聽著，而外部來的 IPv4 封包在 prerouting 就被 DNAT 改寫成容器位址，到不了它；它接的是不經過 prerouting 的連線，例如主機本機連 <code>127.0.0.1:8081</code>（依 Docker 的規則推論，沒有實跑）。用〈共用的函式與兩個測試來源〉的 <code>ns_setup6</code> 與 <code>probe6</code> 驗證，ufw 預設拒絕入站、沒有任何 IPv6 的放行規則時：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">probe6 <span class="m">8081</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; [fd00:beef::1]:8081  rc=28  000
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; [fd00:beef::1]:8081  rc=28  000</span></span></code></pre></div><p>兩個來源經由 IPv6 都逾時，同一時間經由 IPv4 兩個都拿到 200：IPv6 那一份受 ufw 的入站規則管，跟 IPv4 那一份相反。加上 Cloudflare <code>ips-v6</code> 每一段的放行之後只有 <code>verify-cf</code> 拿到 200（指令與計數的對帳在〈整份 Cloudflare 清單：IPv4 寫進 after.rules、IPv6 用 ufw 的入站規則〉）。第一輪另用一個只有一個來源的 namespace 量過加上與刪掉一條 IPv6 放行規則的前後，三次是逾時、連上（回 404，網頁目錄放在 <code>/tmp</code> 被清掉，連線送到了容器）、逾時。<code>ip6tables -S DOCKER-USER</code> 這時是空的，IPv6 這一路沒有經過它。</p>
<p>這個 IPv4 與 IPv6 分兩套機制的寫法，依賴容器網路沒有開 IPv6：之後有一個網路帶 <code>--ipv6</code>（或 daemon 設定的 <code>default-network-opts</code> 替新網路開了 IPv6），那個網路上發佈的 IPv6 連接埠就改走 ip6tables 的位址轉換與轉送路徑，ufw 的 IPv6 入站放行對它失效，而 ip6tables 的 <code>DOCKER-USER</code> 是空的，沒有任何訊號（依 Docker〈Port publishing〉推論，沒有實跑）。<code>docker network inspect -f '{{.EnableIPv6}}' &lt;網路名&gt;</code> 查得到每個網路有沒有開。</p>
<p>這一路的代價在容器看到的來源。補測時 <code>docker logs verify-web</code> 記到的兩筆是 <code>[::ffff:173.245.48.10]</code>（經由 IPv4、DNAT 轉進來的那一筆）與 <code>[::ffff:172.17.0.1]</code>（經由 IPv6、docker-proxy 轉進來的那一筆）：docker-proxy 自己連到容器，容器看到的來源是預設網路的閘道位址，不是 IPv6 的真實來源。容器裡的反向代理依來源位址判斷（例如只信任 Cloudflare 位址段送來的 <code>CF-Connecting-IP</code>）時，經由 IPv6 回源的請求不符合條件。</p>
<p>這個結果有兩個條件。一是 docker-proxy 有在執行：<code>/etc/docker/daemon.json</code> 寫 <code>{ &quot;userland-proxy&quot;: false }</code>、重啟 Docker 並重建容器之後，<code>ss</code> 只剩 <code>0.0.0.0:8081</code>（由 dockerd 聽著），<code>docker ps</code> 的 PORTS 欄只有 <code>0.0.0.0:8081-&gt;80/tcp</code>，<code>ip6tables -t nat -S DOCKER</code> 沒有任何轉換規則。在 ufw 停用時跑 <code>probe6 8081</code>，兩個來源的 <code>curl</code> 都以 exit 7 結束（連線立即被拒）：IPv6 那一份沒有發佈，不是改走別的路徑。二是容器的網路沒有開 IPv6：網路建立時帶 <code>--ipv6</code>、容器有 IPv6 位址時，Docker 用 ip6tables 把 IPv6 的連線位址轉換送進容器，那時它跟 IPv4 一樣走轉送路徑、不受 ufw 入站規則管，規則要寫在 ip6tables 的 <code>DOCKER-USER</code>；這一種也沒有實跑。</p>
<h2 id="compose-自訂網路的容器經由-1270011-查詢時dns-封包從哪裡送出">compose 自訂網路的容器經由 127.0.0.11 查詢時，DNS 封包從哪裡送出</h2>
<p>容器送出的 DNS 查詢有兩條路，依容器接在哪一種網路而定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">mkdir -p /var/tmp/verify-fw/compose
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">cat &gt; /var/tmp/verify-fw/compose/compose.yaml <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">name: verify
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">services:
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">  dns:
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">    image: busybox:1.37
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">    container_name: verify-dns-compose
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    command: sleep infinity
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">docker compose -f /var/tmp/verify-fw/compose/compose.yaml up -d
</span></span><span class="line"><span class="ln">11</span><span class="cl">docker run -d --name verify-dns-bridge busybox:1.37 sleep infinity
</span></span><span class="line"><span class="ln">12</span><span class="cl">docker <span class="nb">exec</span> verify-dns-bridge grep nameserver /etc/resolv.conf
</span></span><span class="line"><span class="ln">13</span><span class="cl">docker <span class="nb">exec</span> verify-dns-compose grep nameserver /etc/resolv.conf</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">nameserver 0.250.250.200
</span></span><span class="line"><span class="ln">2</span><span class="cl">nameserver 127.0.0.11</span></span></code></pre></div><p>預設 bridge 網路的容器拿到主機 <code>/etc/resolv.conf</code> 的副本，直接問主機的解析器；compose 建的自訂網路（<code>verify_default</code>）上的容器問的是 <code>127.0.0.11</code>，那是 Docker 內建的解析器，它先解析同一個網路裡的服務名稱，其他名稱再往外查。</p>
<p>用 <a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack</a> 看往外查的那一筆封包的來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">conntrack -F
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker <span class="nb">exec</span> verify-dns-compose nslookup -timeout<span class="o">=</span><span class="m">3</span> example.org &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">conntrack -L -p udp --dport <span class="m">53</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">conntrack -F
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker <span class="nb">exec</span> verify-dns-bridge nslookup -timeout<span class="o">=</span><span class="m">3</span> example.net &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">conntrack -L -p udp --dport <span class="m">53</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">udp 17 29 src=192.168.139.162 dst=0.250.250.200 sport=50462 dport=53 src=0.250.250.200 dst=192.168.139.162 ...
</span></span><span class="line"><span class="ln">2</span><span class="cl">udp 17 29 src=172.17.0.3 dst=0.250.250.200 sport=42585 dport=53 src=0.250.250.200 dst=192.168.139.162 ...</span></span></code></pre></div><p>第一筆是 compose 容器（位址 <code>172.18.0.2</code>）的查詢：原始方向的來源是主機自己的 IP（<code>192.168.139.162</code> 是這台測試機的位址），代表往外查的封包由主機送出，不是從容器轉送出去的。第二筆是 bridge 容器的：原始來源是容器的 <code>172.17.0.3</code>，回程方向的目的地是主機 IP，那是 Docker 的位址偽裝。<code>conntrack -F</code> 清空連線追蹤表，讓 <code>-L</code> 只列出這一次查詢的那幾筆。</p>
<p>兩條路經過的防火牆位置因此不同。在 <code>DOCKER-USER</code> 擋掉容器網路送往解析器以外的 53，再在主機的 OUTPUT 擋掉全部的 53，量各種查詢。<code>RES</code> 是容器預設會問的上游解析器：這台機器的 <code>/etc/resolv.conf</code> 直接寫著 <code>0.250.250.200</code>。Ubuntu 預設的 systemd-resolved 讓 <code>/etc/resolv.conf</code> 指向 <code>127.0.0.53</code>，容器連不到主機的迴環位址，Docker 那時改把 <code>/run/systemd/resolve/resolv.conf</code> 裡的上游清單給容器；照抄下面那一行會取到 <code>127.0.0.53</code>，<code>! -d $RES</code> 就連 bridge 容器的預設查詢也擋掉。那種主機上改從 <code>/run/systemd/resolve/resolv.conf</code> 取，上游有好幾個時每一個各寫一組規則（依 Docker 的行為推論，沒有實跑）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nv">RES</span><span class="o">=</span><span class="k">$(</span>awk <span class="s1">&#39;/^nameserver/{print $2; exit}&#39;</span> /etc/resolv.conf<span class="k">)</span>   <span class="c1"># systemd-resolved 主機改讀 /run/systemd/resolve/resolv.conf</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">q<span class="o">()</span> <span class="o">{</span> docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> nslookup -timeout<span class="o">=</span><span class="m">3</span> example.com <span class="nv">$2</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;  </span><span class="nv">$1</span><span class="s2"> </span><span class="si">${</span><span class="nv">2</span><span class="k">:-</span><span class="p">（預設）</span><span class="si">}</span><span class="s2"> rc=</span><span class="nv">$?</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">qa<span class="o">()</span> <span class="o">{</span> q verify-dns-bridge<span class="p">;</span> q verify-dns-bridge 1.1.1.1<span class="p">;</span> q verify-dns-compose<span class="p">;</span> q verify-dns-compose 1.1.1.1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">for</span> i in docker0 br-+<span class="p">;</span> <span class="k">do</span> <span class="k">for</span> p in udp tcp<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  iptables -I DOCKER-USER -i <span class="nv">$i</span> -p <span class="nv">$p</span> --dport <span class="m">53</span> ! -d <span class="nv">$RES</span> -j DROP
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">done</span><span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">qa
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">iptables -I OUTPUT -p udp --dport <span class="m">53</span> -j DROP<span class="p">;</span> iptables -I OUTPUT -p tcp --dport <span class="m">53</span> -j DROP
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">q verify-dns-bridge<span class="p">;</span> q verify-dns-compose
</span></span><span class="line"><span class="ln">10</span><span class="cl">iptables -D OUTPUT -p udp --dport <span class="m">53</span> -j DROP<span class="p">;</span> iptables -D OUTPUT -p tcp --dport <span class="m">53</span> -j DROP</span></span></code></pre></div><p><code>br-+</code> 比對所有 <code>br-</code> 開頭的介面，compose 建的自訂網路都是這種介面名；<code>docker0</code> 是預設網路。<code>nslookup</code> 的第二個引數是要問的 DNS 伺服器，沒給時用容器的 <code>/etc/resolv.conf</code>。</p>
<table>
  <thead>
      <tr>
          <th>查詢</th>
          <th>只有 <code>DOCKER-USER</code> 的規則</th>
          <th>再加主機 OUTPUT 擋 53</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>bridge 容器，用預設的解析器</td>
          <td>成功</td>
          <td>成功</td>
      </tr>
      <tr>
          <td>bridge 容器，直接問 <code>1.1.1.1</code></td>
          <td>失敗（逾時）</td>
          <td>沒有量</td>
      </tr>
      <tr>
          <td>compose 容器，用 <code>127.0.0.11</code></td>
          <td>成功</td>
          <td>失敗</td>
      </tr>
      <tr>
          <td>compose 容器，直接問 <code>1.1.1.1</code></td>
          <td>失敗（逾時）</td>
          <td>沒有量</td>
      </tr>
  </tbody>
</table>
<p>右欄的兩格「沒有量」：加了 OUTPUT 規則之後只送了兩種用預設解析器的查詢（上面區塊的 <code>q</code> 那一行），直接問 <code>1.1.1.1</code> 的兩種在左欄已經被 <code>DOCKER-USER</code> 擋下。「失敗」是 <code>nslookup</code> 在 3 秒內拿不到回應、以非零結束。</p>
<p>bridge 容器的查詢與 compose 容器直接指定伺服器的查詢，都從容器的介面轉送出去，<code>DOCKER-USER</code> 管得到、主機的 OUTPUT 管不到。compose 容器經由 <code>127.0.0.11</code> 的查詢由主機送出，只有主機的 OUTPUT 管得到；<code>DOCKER-USER</code> 的規則對它沒有作用。所以要讓容器的 DNS 只送往指定的解析器，兩個位置都要寫：<code>DOCKER-USER</code>（或自建 nftables 表的 forward 鏈）擋容器直接送出的，主機的出站規則擋內建解析器替它送出的。用 ufw 寫主機的出站規則時，寫法是先 <code>ufw allow out to &lt;解析器的位址&gt; port 53</code>、再 <code>ufw deny out 53</code>（ufw 依加入順序比對），這兩條沒有實跑，實測用的是上面的 iptables OUTPUT 規則。</p>
<p>這個結果是在主機解析器不是迴環位址（<code>0.250.250.200</code>）時量的；systemd-resolved 的主機上內建解析器往外查的封包從哪裡送出，沒有量。</p>
<h2 id="清理容器ufw-規則afterrulesnamespace-與暫存目錄">清理：容器、ufw 規則、after.rules、namespace 與暫存目錄</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">docker rm -f verify-web verify-dns-bridge<span class="p">;</span> docker compose -f /var/tmp/verify-fw/compose/compose.yaml down
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">pkill -f <span class="s1">&#39;http.server 8080&#39;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">cp /var/tmp/verify-fw/after.rules.orig /etc/ufw/after.rules
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">ufw --force reset            <span class="c1"># 回到 ufw 未設定、停用的狀態；主機原本就在用 ufw 時改成逐條 ufw delete</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">iptables -F DOCKER-USER<span class="p">;</span> iptables -X CF-ONLY   <span class="c1"># after.rules 不再宣告之後，鏈裡的規則要手動清（或重開機）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">ip link del vcf-h<span class="p">;</span> ip link del voth-h
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">ip netns del verify-cf<span class="p">;</span> ip netns del verify-other
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">ip -6 addr del fd00:beef::1/128 dev lo
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">rm -f /etc/docker/daemon.json<span class="p">;</span> systemctl restart docker   <span class="c1"># 做過 userland-proxy 那一項時才需要</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">rm -rf /var/tmp/verify-fw
</span></span><span class="line"><span class="ln">11</span><span class="cl">find /tmp /var/tmp -maxdepth <span class="m">1</span> -name <span class="s1">&#39;verify-*&#39;</span>      <span class="c1"># 沒有輸出就清乾淨了</span></span></span></code></pre></div><p>實測時 <code>after.rules</code> 的備份第一輪放在 <code>/tmp</code>、重開機時被清掉，所以還原那一行沒有實跑，改用 <code>sed -i '/^# verify-ufw/,$d' /etc/ufw/after.rules</code> 刪掉加上去的那一段（從註解那一行刪到檔尾，前提是那一段加在檔案最後），<code>grep -c verify /etc/ufw/after.rules</code> 回 0。<code>ufw --force reset</code> 會停用 ufw 並把規則檔備份成帶時間戳的副本，用在專門為實測建的機器上；主機原本有自己的 ufw 規則時，只刪這次加的那幾條（8080、8081 的兩條入站、IPv6 測試段與 <code>ips-v6</code> 各段的放行），改過 routed 政策的話以 <code>ufw default deny routed</code> 改回。整份清單那一次的清理照這個區塊實跑過，<code>ip netns list</code> 沒有輸出、<code>ufw status</code> 回 <code>inactive</code>、<code>DOCKER-USER</code> 只剩空鏈。veth 要在 namespace 之前刪：先刪 namespace 時，接在裡面的那一端消失，整對 veth 跟著不在，之後的 <code>ip link del</code> 回 <code>Cannot find device</code>（結果一樣乾淨，只是多一行錯誤訊息）。實測的清理在一個 <code>bash -c '…'</code> 裡跑時，<code>pkill -f 'http.server 8080'</code> 比對到了那個 bash 自己的命令列、把它殺掉，後面的指令都沒有執行；在互動式 shell 裡一行一行貼不會遇到，要寫成腳本時改用 <code>pkill -f '[h]ttp.server 8080'</code>，讓比對字串不出現在自己的命令列裡。機器是專為實測建的時，整台刪掉最乾淨：<code>orb delete verify-ufw</code>。</p>
<h2 id="各寫法在-ufw-主機上的結果">各寫法在 ufw 主機上的結果</h2>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>範圍外的來源連發佈的連接埠（IPv4）</th>
          <th><code>ufw reload</code> 之後</th>
          <th>Docker 重啟之後</th>
          <th>重開機之後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ufw 入站規則（<code>ufw allow from … to any port 8081</code>）</td>
          <td>連得到（管不到轉送路徑）</td>
          <td>沒有單獨測</td>
          <td>沒有單獨測</td>
          <td>沒有測</td>
      </tr>
      <tr>
          <td>ufw route 規則（<code>ufw route deny</code>）</td>
          <td>連得到（排在 Docker 的鏈之後）</td>
          <td>連得到</td>
          <td>沒有測</td>
          <td>沒有測</td>
      </tr>
      <tr>
          <td>iptables 指令加進 <code>DOCKER-USER</code></td>
          <td>被擋（逾時）</td>
          <td>規則還在（after.rules 沒有宣告這條鏈時）</td>
          <td>規則還在</td>
          <td>規則不在</td>
      </tr>
      <tr>
          <td>寫進 <code>/etc/ufw/after.rules</code></td>
          <td>被擋（逾時）</td>
          <td>規則還在</td>
          <td>規則還在</td>
          <td>規則還在</td>
      </tr>
      <tr>
          <td>IPv6 那一份：ufw 入站規則只放行 <code>ips-v6</code></td>
          <td>被擋（逾時，經由 IPv6 連）</td>
          <td>沒有單獨測</td>
          <td>沒有測</td>
          <td>被擋（沒有再數規則）</td>
      </tr>
  </tbody>
</table>
<p>第二欄是規則剛套上時，範圍外的來源經由 IPv4（最後一列經由 IPv6）連發佈的連接埠的結果；後三欄是做完那個動作之後規則還在不在。「沒有單獨測」是那個動作做過、但這條規則不是當時要量的對象（例如量 <code>DOCKER-USER</code> 時順帶 reload 過），沒有記錄它的狀態；「沒有測」是沒有做那個動作。</p>
]]></content:encoded></item><item><title>Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-26，在本機 macOS 的 OrbStack 2.2.5 建的兩台機器上實測（Arm 架構，核心是 OrbStack 自己的 &lt;code>7.2.12-orbstack&lt;/code>）。一台是 Ubuntu 24.04.5：ufw 0.36.2、iptables 1.8.10（nf_tables 後端）、Podman 4.9.3 與 netavark 1.4.0、slirp4netns 1.2.1、Docker CE 29.9.2 加 &lt;code>docker-ce-rootless-extras&lt;/code>。另一台是 Oracle Linux 10.2：firewalld 2.4.3（nftables 後端）、Podman 5.8.2（netavark）、Docker CE 29.9.2。測試容器是 &lt;code>busybox:1.37&lt;/code>。&lt;/p>&lt;/blockquote>
&lt;p>這篇記錄 Docker 以外的兩種容器執行方式發佈連接埠時，主機防火牆管不管得到：rootful Podman（以 root 執行、用 netavark 寫防火牆規則）在 Ubuntu 的 ufw 與 Oracle Linux 的 firewalld 上得到相反結果的原因、ufw 的 route 規則放行它的寫法；rootless Docker 與 rootless Podman（以一般使用者執行，核心的網路設定由使用者命名空間裡的程式代辦）發佈的連接埠由主機上的程式接收、受主機防火牆的入站規則管，以及代價：容器看到的來源位址不是真正的來源；最後是清理。以 root 執行的 Docker 在 ufw 主機上的行為見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a>，在 firewalld 主機上見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>，只放行 CDN 位址的取捨見概念文章 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a>。Kubernetes 的 NodePort 沒有測。&lt;/p>
&lt;h2 id="前置條件與測試來源">前置條件與測試來源&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>兩台主機&lt;/strong>：一台用 ufw 的 Ubuntu、一台用 firewalld 的 RHEL 系（這次是 Oracle Linux）。OrbStack 的建法是 &lt;code>orb create ubuntu:noble verify-ufw&lt;/code> 與 &lt;code>orb create oracle:10 verify-fwd&lt;/code>。兩台的 Docker 都照 Docker 官方套件庫的步驟裝，Podman 用發行版自己的套件（&lt;code>apt-get install podman&lt;/code>、&lt;code>dnf install podman&lt;/code>），所以兩台的 Podman 版本不同（4.9.3 與 5.8.2）。下文 rootful Podman 在兩台的結果不同，成因可能是防火牆、也可能是 Podman 的版本，這次沒有拿同一個版本在兩種防火牆上比較來分開兩者。&lt;/li>
&lt;li>&lt;strong>兩個測試來源&lt;/strong>：每台都照 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a> 的〈共用的函式與兩個測試來源〉建 &lt;code>verify-cf&lt;/code>（&lt;code>173.245.48.10&lt;/code>，在 Cloudflare 範圍內）與 &lt;code>verify-other&lt;/code>（&lt;code>203.0.113.10&lt;/code>，範圍外）兩個 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/network-namespace/" data-link-title="Network Namespace（網路命名空間）" data-link-desc="容器的網路為什麼跟主機分開、要在一台主機上模擬另一個來源位址做防火牆實測、或看到 ip netns 與 veth 時，查 Linux 核心的網路隔離單位與它跟 Kubernetes namespace 的差別">network namespace&lt;/a>，用同一個 &lt;code>probe &amp;lt;連接埠&amp;gt;&lt;/code> 函式從兩個來源各連一次。&lt;code>rc=0 200&lt;/code> 是連上，&lt;code>rc=7&lt;/code> 是立即回拒，&lt;code>rc=28 000&lt;/code> 是逾時（兩種失敗的分辨見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/connection-refused-vs-timeout/" data-link-title="Connection Refused vs. Timeout（連線被拒與逾時）" data-link-desc="遠端連不上、要判斷是封包到不了還是服務拒絕、決定往網路層還是服務層除錯時回來讀">Connection Refused vs. Timeout（連線被拒與逾時）&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>網頁檔&lt;/strong>：&lt;code>/var/tmp/verify-fw/www/index.html&lt;/code>，內容隨意（&lt;code>mkdir -p /var/tmp/verify-fw/www; echo ok &amp;gt; /var/tmp/verify-fw/www/index.html&lt;/code>）。&lt;/li>
&lt;li>&lt;strong>起始狀態&lt;/strong>：Ubuntu 那一台從 ufw 那一篇的〈DOCKER-USER 規則的效果…〉之前的狀態接著做：ufw 已經啟用（&lt;code>ufw default deny incoming&lt;/code>、&lt;code>ufw --force enable&lt;/code>；雲端主機先 &lt;code>ufw allow 22/tcp&lt;/code>），Docker 的 &lt;code>verify-web&lt;/code> 發佈著 8081（下文比較 Docker 與 Podman 時用到）。從一台新機器開始時，照那一篇的〈共用的函式與兩個測試來源〉與〈ufw 的入站規則與 route 規則對發佈連接埠的效果〉的前兩個區塊做到這個狀態。Oracle Linux 那一台只要 firewalld 執行中、&lt;code>public&lt;/code> zone 是預設值。&lt;/li>
&lt;/ul>
&lt;h2 id="rootful-podman-在-ufw-上netavark-的轉送鏈ufw-的預設-routed-政策route-規則與-docker-user">rootful Podman 在 ufw 上：netavark 的轉送鏈、ufw 的預設 routed 政策、route 規則與 DOCKER-USER&lt;/h2>
&lt;p>ufw 已經啟用、預設拒絕入站，&lt;code>default routed&lt;/code> 是 Ubuntu 預設的 &lt;code>deny&lt;/code>（&lt;code>ufw default deny routed&lt;/code>），沒有任何一條規則提到 8082：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-26，在本機 macOS 的 OrbStack 2.2.5 建的兩台機器上實測（Arm 架構，核心是 OrbStack 自己的 <code>7.2.12-orbstack</code>）。一台是 Ubuntu 24.04.5：ufw 0.36.2、iptables 1.8.10（nf_tables 後端）、Podman 4.9.3 與 netavark 1.4.0、slirp4netns 1.2.1、Docker CE 29.9.2 加 <code>docker-ce-rootless-extras</code>。另一台是 Oracle Linux 10.2：firewalld 2.4.3（nftables 後端）、Podman 5.8.2（netavark）、Docker CE 29.9.2。測試容器是 <code>busybox:1.37</code>。</p></blockquote>
<p>這篇記錄 Docker 以外的兩種容器執行方式發佈連接埠時，主機防火牆管不管得到：rootful Podman（以 root 執行、用 netavark 寫防火牆規則）在 Ubuntu 的 ufw 與 Oracle Linux 的 firewalld 上得到相反結果的原因、ufw 的 route 規則放行它的寫法；rootless Docker 與 rootless Podman（以一般使用者執行，核心的網路設定由使用者命名空間裡的程式代辦）發佈的連接埠由主機上的程式接收、受主機防火牆的入站規則管，以及代價：容器看到的來源位址不是真正的來源；最後是清理。以 root 執行的 Docker 在 ufw 主機上的行為見 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a>，在 firewalld 主機上見 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>，只放行 CDN 位址的取捨見概念文章 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>。Kubernetes 的 NodePort 沒有測。</p>
<h2 id="前置條件與測試來源">前置條件與測試來源</h2>
<ul>
<li><strong>兩台主機</strong>：一台用 ufw 的 Ubuntu、一台用 firewalld 的 RHEL 系（這次是 Oracle Linux）。OrbStack 的建法是 <code>orb create ubuntu:noble verify-ufw</code> 與 <code>orb create oracle:10 verify-fwd</code>。兩台的 Docker 都照 Docker 官方套件庫的步驟裝，Podman 用發行版自己的套件（<code>apt-get install podman</code>、<code>dnf install podman</code>），所以兩台的 Podman 版本不同（4.9.3 與 5.8.2）。下文 rootful Podman 在兩台的結果不同，成因可能是防火牆、也可能是 Podman 的版本，這次沒有拿同一個版本在兩種防火牆上比較來分開兩者。</li>
<li><strong>兩個測試來源</strong>：每台都照 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈共用的函式與兩個測試來源〉建 <code>verify-cf</code>（<code>173.245.48.10</code>，在 Cloudflare 範圍內）與 <code>verify-other</code>（<code>203.0.113.10</code>，範圍外）兩個 <a href="/blog/devops/infra/knowledge-cards/network-namespace/" data-link-title="Network Namespace（網路命名空間）" data-link-desc="容器的網路為什麼跟主機分開、要在一台主機上模擬另一個來源位址做防火牆實測、或看到 ip netns 與 veth 時，查 Linux 核心的網路隔離單位與它跟 Kubernetes namespace 的差別">network namespace</a>，用同一個 <code>probe &lt;連接埠&gt;</code> 函式從兩個來源各連一次。<code>rc=0  200</code> 是連上，<code>rc=7</code> 是立即回拒，<code>rc=28  000</code> 是逾時（兩種失敗的分辨見 <a href="/blog/linux/dotfile/knowledge-cards/connection-refused-vs-timeout/" data-link-title="Connection Refused vs. Timeout（連線被拒與逾時）" data-link-desc="遠端連不上、要判斷是封包到不了還是服務拒絕、決定往網路層還是服務層除錯時回來讀">Connection Refused vs. Timeout（連線被拒與逾時）</a>）。</li>
<li><strong>網頁檔</strong>：<code>/var/tmp/verify-fw/www/index.html</code>，內容隨意（<code>mkdir -p /var/tmp/verify-fw/www; echo ok &gt; /var/tmp/verify-fw/www/index.html</code>）。</li>
<li><strong>起始狀態</strong>：Ubuntu 那一台從 ufw 那一篇的〈DOCKER-USER 規則的效果…〉之前的狀態接著做：ufw 已經啟用（<code>ufw default deny incoming</code>、<code>ufw --force enable</code>；雲端主機先 <code>ufw allow 22/tcp</code>），Docker 的 <code>verify-web</code> 發佈著 8081（下文比較 Docker 與 Podman 時用到）。從一台新機器開始時，照那一篇的〈共用的函式與兩個測試來源〉與〈ufw 的入站規則與 route 規則對發佈連接埠的效果〉的前兩個區塊做到這個狀態。Oracle Linux 那一台只要 firewalld 執行中、<code>public</code> zone 是預設值。</li>
</ul>
<h2 id="rootful-podman-在-ufw-上netavark-的轉送鏈ufw-的預設-routed-政策route-規則與-docker-user">rootful Podman 在 ufw 上：netavark 的轉送鏈、ufw 的預設 routed 政策、route 規則與 DOCKER-USER</h2>
<p>ufw 已經啟用、預設拒絕入站，<code>default routed</code> 是 Ubuntu 預設的 <code>deny</code>（<code>ufw default deny routed</code>），沒有任何一條規則提到 8082：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">podman run -d --name verify-pod-web -p 8082:80 -v /var/tmp/verify-fw/www:/www:ro <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  docker.io/library/busybox:1.37 httpd -f -p <span class="m">80</span> -h /www
</span></span><span class="line"><span class="ln">3</span><span class="cl">probe <span class="m">8082</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">ss -ltnp <span class="p">|</span> grep <span class="m">8082</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">iptables -t nat -S <span class="p">|</span> grep <span class="s1">&#39;dport 8082 -j DNAT&#39;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">iptables -S NETAVARK_FORWARD</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8082  rc=28  000
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8082  rc=28  000
</span></span><span class="line"><span class="ln">3</span><span class="cl">LISTEN 0  4096   0.0.0.0:8082   0.0.0.0:*   users:((&#34;conmon&#34;,pid=2929,fd=5))
</span></span><span class="line"><span class="ln">4</span><span class="cl">-A NETAVARK-DN-1D8721804F16F -p tcp -m tcp --dport 8082 -j DNAT --to-destination 10.88.0.2:80
</span></span><span class="line"><span class="ln">5</span><span class="cl">-A NETAVARK_FORWARD -d 10.88.0.0/16 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
</span></span><span class="line"><span class="ln">6</span><span class="cl">-A NETAVARK_FORWARD -s 10.88.0.0/16 -j ACCEPT</span></span></code></pre></div><p>Podman 用全名 <code>docker.io/library/busybox:1.37</code>，因為它不像 Docker 預設到 Docker Hub 找短名稱的映像。netavark 跟 Docker 一樣用 <a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT</a> 把 8082 改寫成容器的 80，封包走轉送路徑。差別在轉送鏈：netavark 的 <code>NETAVARK_FORWARD</code> 只放行「送往容器網路的回程」與「容器送出的」，新的入站連線不在其中，封包往下落到 ufw 的鏈，最後被 FORWARD 的預設政策 DROP 丟掉，兩個來源都逾時。<code>conmon</code>（Podman 監看容器的程式）聽著 <code>0.0.0.0:8082</code>，它只是佔住這個連接埠，連線不經過它。</p>
<p>實測的順序跟上面相反：容器建好時這台的 routed 是 <code>allow</code>（FORWARD 的預設政策是 ACCEPT），第一次 <code>probe 8082</code> 兩個來源都拿到 200；改成 <code>deny routed</code> 之後才量到上面的兩個逾時。所以 routed 政策是 <code>allow</code> 時兩個來源都連得到，是 <code>deny</code> 時都連不到。</p>
<p>要只放行 Cloudflare，用 ufw 的 route 規則寫在轉送路徑上，目的地寫容器的位址與容器內的連接埠（位址轉換之後的值）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw default deny routed
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw route allow proto tcp from 173.245.48.0/20 to 10.88.0.2 port <span class="m">80</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">probe <span class="m">8082</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8082  rc=0  200
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8082  rc=28  000</span></span></code></pre></div><p>只有範圍內的來源連得到。<code>10.88.0.2</code> 是容器的位址，取自上面那條 DNAT 規則的 <code>--to-destination</code>；容器重建之後位址可能變，規則要跟著改，這是 route 規則寫在位址轉換之後的代價。同一台主機上 Docker 的 8081 在 <code>deny routed</code> 下照樣對兩個來源開放（Docker 的 <code>DOCKER-FORWARD</code> 排在 ufw 的鏈之前、先接受了），所以同一台主機上 Docker 與 Podman 對同一條 ufw 設定的結果相反。</p>
<p>主機上也裝了 Docker 時，Podman 的轉送封包也經過 <a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a>。為了只看 <code>DOCKER-USER</code> 的效果，先把 route 規則與 8082 的容器拿掉、routed 改回 <code>allow</code>（否則 FORWARD 的 DROP 會先把兩個來源都擋下），再發佈一個 8089 並看跳轉順序：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">podman rm -f verify-pod-web
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw route delete allow proto tcp from 173.245.48.0/20 to 10.88.0.2 port <span class="m">80</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ufw default allow routed
</span></span><span class="line"><span class="ln">4</span><span class="cl">podman run -d --name verify-pod-web -p 8089:80 -v /var/tmp/verify-fw/www:/www:ro <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  docker.io/library/busybox:1.37 httpd -f -v -p <span class="m">80</span> -h /www
</span></span><span class="line"><span class="ln">6</span><span class="cl">iptables -S FORWARD <span class="p">|</span> grep -- <span class="s1">&#39;-j&#39;</span> <span class="p">|</span> head -3
</span></span><span class="line"><span class="ln">7</span><span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport <span class="m">8089</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP
</span></span><span class="line"><span class="ln">8</span><span class="cl">probe <span class="m">8089</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">iptables -D DOCKER-USER -p tcp -m conntrack --ctorigdstport <span class="m">8089</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-A FORWARD -m comment --comment &#34;netavark firewall plugin rules&#34; -j NETAVARK_FORWARD
</span></span><span class="line"><span class="ln">2</span><span class="cl">-A FORWARD -j DOCKER-USER
</span></span><span class="line"><span class="ln">3</span><span class="cl">-A FORWARD -j DOCKER-FORWARD
</span></span><span class="line"><span class="ln">4</span><span class="cl">  verify-cf     -&gt; :8089  rc=0  200
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-other  -&gt; :8089  rc=28  000</span></span></code></pre></div><p>FORWARD 先跳 <code>NETAVARK_FORWARD</code>，那裡不接受新的入站連線，封包回到 FORWARD 往下走，接著跳 Docker 的 <code>DOCKER-USER</code>；這一條跳轉沒有比對條件，所有轉送的封包都會進去。所以同一條比對原始目的連接埠的規則，對 Podman 發佈的連接埠一樣有效，範圍外的來源被擋。主機上沒有裝 Docker 時沒有 <code>DOCKER-USER</code> 這條鏈，這個寫法用不了（依鏈的建立者推論，沒有在沒裝 Docker 的主機上實跑）。量完以 <code>ufw default deny routed</code> 改回 routed 政策。</p>
<h2 id="rootful-podman-在-firewalld-上netavark-的-nftables-表trusted-zone-與-firewalld-的轉送鏈">rootful Podman 在 firewalld 上：netavark 的 nftables 表、trusted zone 與 firewalld 的轉送鏈</h2>
<p>Oracle Linux 那一台的 <code>public</code> zone 沒有任何規則提到 8089：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">podman run -d --name verify-pod-web -p 8089:80 -v /var/tmp/verify-fw/www:/www:ro,Z <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  docker.io/library/busybox:1.37 httpd -f -p <span class="m">80</span> -h /www
</span></span><span class="line"><span class="ln">3</span><span class="cl">probe <span class="m">8089</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">nft list tables
</span></span><span class="line"><span class="ln">5</span><span class="cl">firewall-cmd --get-active-zones
</span></span><span class="line"><span class="ln">6</span><span class="cl">nft list chain inet netavark FORWARD
</span></span><span class="line"><span class="ln">7</span><span class="cl">nft list chain inet firewalld filter_FORWARD <span class="p">|</span> sed -n 2,5p
</span></span><span class="line"><span class="ln">8</span><span class="cl">iptables -S FORWARD <span class="p">|</span> head -1</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">  verify-cf     -&gt; :8089  rc=0  200
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  verify-other  -&gt; :8089  rc=0  200
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">table ip nat
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">table ip filter
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">table ip6 nat
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">table ip6 filter
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">table ip raw
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">table inet firewalld
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">table inet netavark
</span></span><span class="line"><span class="ln">10</span><span class="cl">docker
</span></span><span class="line"><span class="ln">11</span><span class="cl">  interfaces: docker0
</span></span><span class="line"><span class="ln">12</span><span class="cl">public (default)
</span></span><span class="line"><span class="ln">13</span><span class="cl">  interfaces: eth0
</span></span><span class="line"><span class="ln">14</span><span class="cl">trusted
</span></span><span class="line"><span class="ln">15</span><span class="cl">  sources: 10.88.0.0/16
</span></span><span class="line"><span class="ln">16</span><span class="cl">	chain FORWARD {
</span></span><span class="line"><span class="ln">17</span><span class="cl">		type filter hook forward priority filter; policy accept;
</span></span><span class="line"><span class="ln">18</span><span class="cl">		jump NETAVARK-ISOLATION-1
</span></span><span class="line"><span class="ln">19</span><span class="cl">		ip daddr 10.88.0.0/16 ct state established,related accept
</span></span><span class="line"><span class="ln">20</span><span class="cl">		ip saddr 10.88.0.0/16 accept
</span></span><span class="line"><span class="ln">21</span><span class="cl">	}
</span></span><span class="line"><span class="ln">22</span><span class="cl">		type filter hook forward priority filter + 10; policy accept;
</span></span><span class="line"><span class="ln">23</span><span class="cl">		ct state { established, related } accept
</span></span><span class="line"><span class="ln">24</span><span class="cl">		ct status dnat accept
</span></span><span class="line"><span class="ln">25</span><span class="cl">		iifname &#34;lo&#34; accept
</span></span><span class="line"><span class="ln">26</span><span class="cl">-P FORWARD ACCEPT</span></span></code></pre></div><p><code>-v</code> 的 <code>:Z</code> 讓 Podman 替掛進去的目錄改 SELinux 標籤（Oracle Linux 預設啟用 SELinux）；Ubuntu 沒有 SELinux，不需要。兩個來源都連得到。Podman 5.8.2 的 netavark 在這台用 nftables 寫自己的表 <code>inet netavark</code>，並把容器網路 <code>10.88.0.0/16</code> 加進 firewalld 的 <code>trusted</code> zone 的來源。一個轉送的封包要通過每一張表接在 forward hook 上的鏈，三張都放它過去：netavark 的 FORWARD 鏈預設政策是 accept、iptables 的 FORWARD 鏈（這台裝了 Docker，表是 Docker 經由 iptables-nft 建的）預設政策是 ACCEPT、firewalld 的轉送鏈有 <code>ct status dnat accept</code>。</p>
<p>Ubuntu 那一台被擋，是因為 ufw 把 iptables 的 FORWARD 預設政策設成 DROP；這一台的 firewalld 對經過位址轉換的連線一律接受。兩台的 Podman 版本也不同（見〈前置條件與測試來源〉），版本與防火牆的影響沒有分開。限制的寫法依同樣的路徑推論跟 Docker 在 firewalld 上的相同（自己的 nftables 表掛在位址轉換之前、雲端防火牆），沒有對 Podman 實跑。這台也裝了 Docker，iptables 的 FORWARD 鏈有跳到 <code>DOCKER-USER</code> 的那一條，依 Ubuntu 那一台的結果推論 <code>DOCKER-USER</code> 的規則同樣管得到 Podman 的轉送封包，在這一台沒有實跑。firewalld 的 <code>StrictForwardPorts=yes</code> 對 Podman 的效果沒有測。</p>
<p>從兩個帶 IPv6 位址的來源對主機的 IPv6 位址連 8089（namespace 加 IPv6 位址的做法同 ufw 那一篇的〈IPv6 那一份發佈：docker-proxy 的接收路徑與 ufw 入站規則的效果〉），兩個都立即被回拒：<code>ss -ltnp</code> 裡 <code>conmon</code> 只聽 <code>0.0.0.0:8089</code>，Podman 沒有替 IPv6 發佈這個連接埠，連線落到 <code>public</code> zone 的回拒。</p>
<h2 id="rootless-dockerrootlesskit-的接收路徑ufw-入站規則與容器看到的來源位址">rootless Docker：rootlesskit 的接收路徑、ufw 入站規則與容器看到的來源位址</h2>
<p>rootless 模式的 dockerd 以一般使用者執行，它不能改主機的 iptables，發佈連接埠改由 RootlessKit（rootless 模式替 dockerd 代辦網路的程式，屬於 <a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy</a> 這一類）在主機上聽那個連接埠、收下連線再轉進容器。安裝與啟動（以 root 建使用者、以那個使用者執行設定工具）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">apt-get install -y docker-ce-rootless-extras uidmap
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">useradd -m -s /bin/bash verifyrl
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">loginctl enable-linger verifyrl             <span class="c1"># 讓這個使用者的 systemd user 服務在沒有登入時也執行</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">U</span><span class="o">=</span>verifyrl<span class="p">;</span> <span class="nv">X</span><span class="o">=</span>/run/user/<span class="k">$(</span>id -u <span class="nv">$U</span><span class="k">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nb">cd</span> /tmp                                     <span class="c1"># 一般使用者讀不了 /root，原因寫在指令之後</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">sudo -u <span class="nv">$U</span> <span class="nv">XDG_RUNTIME_DIR</span><span class="o">=</span><span class="nv">$X</span> <span class="nv">DBUS_SESSION_BUS_ADDRESS</span><span class="o">=</span>unix:path<span class="o">=</span><span class="nv">$X</span>/bus dockerd-rootless-setuptool.sh install
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">sudo -u <span class="nv">$U</span> <span class="nv">XDG_RUNTIME_DIR</span><span class="o">=</span><span class="nv">$X</span> <span class="nv">DOCKER_HOST</span><span class="o">=</span>unix://<span class="nv">$X</span>/docker.sock bash -lc <span class="s1">&#39;cd ~; mkdir -p www; echo ok &gt; www/index.html;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">  docker run -d --name verify-rl-web -p 8083:80 -v ~/www:/www:ro busybox:1.37 httpd -f -v -p 80 -h /www&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">probe <span class="m">8083</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">ss -ltnp <span class="p">|</span> grep 8083<span class="p">;</span> iptables -t nat -S <span class="p">|</span> grep -c <span class="m">8083</span></span></span></code></pre></div><p><code>verifyrl</code> 是這次建的測試使用者，換成實際要跑 rootless Docker 的帳號。<code>XDG_RUNTIME_DIR</code> 與 <code>DBUS_SESSION_BUS_ADDRESS</code> 指向那個使用者的 systemd user 執行環境，設定工具要用它們建立 user 服務；<code>DOCKER_HOST</code> 指到 rootless dockerd 的 socket，否則 <code>docker</code> 指令會去連 root 的那一個。httpd 的 <code>-v</code> 讓它逐筆印存取紀錄，下面用它看容器收到的來源位址。這台 OrbStack 的核心沒有 AppArmor（<code>aa-status</code> 回 <code>apparmor not present</code>），所以碰不到 Ubuntu 24.04 對非特權 user namespace 的限制；Docker 的〈Rootless mode troubleshooting〉寫明，在 Ubuntu 24.04 以後的發行版核心上，以 deb 套件裝 <code>docker-ce-rootless-extras</code> 時 rootlesskit 的 AppArmor profile 已經隨 <code>apparmor</code> 套件附上（這台的 <code>/etc/apparmor.d/</code> 也有 <code>rootlesskit</code> 這個檔），改用安裝腳本裝時要照那一頁手動加 profile，在發行版核心上沒有實跑。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8083  rc=28  000
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8083  rc=28  000
</span></span><span class="line"><span class="ln">3</span><span class="cl">LISTEN 0  4096   0.0.0.0:8083   0.0.0.0:*   users:((&#34;rootlesskit&#34;,pid=4085,fd=13))
</span></span><span class="line"><span class="ln">4</span><span class="cl">LISTEN 0  4096      [::]:8083      [::]:*   users:((&#34;rootlesskit&#34;,pid=4085,fd=15))
</span></span><span class="line"><span class="ln">5</span><span class="cl">0</span></span></code></pre></div><p>nat 表裡沒有 8083 的位址轉換（<code>0</code>），聽 8083 的是主機上的 <code>rootlesskit</code>。連線送到主機上的程式，走的是 input 路徑，ufw 的預設拒絕入站擋下兩個來源。加一條入站規則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw allow from 173.245.48.0/20 to any port <span class="m">8083</span> proto tcp
</span></span><span class="line"><span class="ln">2</span><span class="cl">probe <span class="m">8083</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">sudo -u verifyrl <span class="nv">XDG_RUNTIME_DIR</span><span class="o">=</span>/run/user/<span class="k">$(</span>id -u verifyrl<span class="k">)</span> <span class="nv">DOCKER_HOST</span><span class="o">=</span>unix:///run/user/<span class="k">$(</span>id -u verifyrl<span class="k">)</span>/docker.sock <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  bash -lc <span class="s1">&#39;docker logs verify-rl-web 2&gt;&amp;1 | tail -1&#39;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8083  rc=0  200
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8083  rc=28  000
</span></span><span class="line"><span class="ln">3</span><span class="cl">[::ffff:172.17.0.1]:54596: response:200</span></span></code></pre></div><p>ufw 的入站規則對 rootless Docker 發佈的連接埠有效。IPv6 那一份也由 rootlesskit 聽著（<code>[::]:8083</code>），依同一條路徑推論受 ufw 的 IPv6 入站規則管，沒有實跑。代價在最後一行：busybox httpd 記下的連線來源是 <code>172.17.0.1</code>（rootless dockerd 自己網路裡的閘道），不是 <code>173.245.48.10</code>。預設設定下來源位址沒有保留，容器裡的網站程式依來源位址做的事（存取紀錄、速率限制、只信任來自 CDN 的 <code>CF-Connecting-IP</code>）都拿不到真正的來源。Docker 的〈Rootless mode troubleshooting〉列了保留來源位址的做法，這次都沒有實跑：RootlessKit v3.0 以後，預設的 <code>builtin</code> 連接埠轉送在 <code>~/.config/docker/daemon.json</code> 寫 <code>{&quot;userland-proxy&quot;: false}</code> 並重啟之後保留來源位址（文件寫明可能還要載入 <code>br_netfilter</code> 模組）；更早的版本改用 <code>slirp4netns</code> 轉送方式（環境變數 <code>DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=slirp4netns</code>），或 <code>pasta</code> 網路加 <code>implicit</code> 轉送方式（<code>DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta</code>、<code>DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit</code>，文件標為實驗性、吞吐量比 slirp4netns 好）。環境變數寫在 <code>~/.config/systemd/user/docker.service.d/override.conf</code> 的 <code>[Service]</code> 底下。</p>
<h2 id="rootless-podmanrootlessport-的接收路徑ufw-入站規則與容器看到的來源位址">rootless Podman：rootlessport 的接收路徑、ufw 入站規則與容器看到的來源位址</h2>
<p>網頁檔沿用〈rootless Docker：rootlesskit 的接收路徑、ufw 入站規則與容器看到的來源位址〉在 <code>verifyrl</code> 家目錄建的 <code>~/www</code>；跳過那一節時，在 <code>cd ~;</code> 之後先 <code>mkdir -p www; echo ok &gt; www/index.html</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /tmp
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo -u verifyrl <span class="nv">XDG_RUNTIME_DIR</span><span class="o">=</span>/run/user/<span class="k">$(</span>id -u verifyrl<span class="k">)</span> bash -lc <span class="s1">&#39;cd ~;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">  podman run -d --name verify-rlp-web -p 8084:80 -v ~/www:/www:ro docker.io/library/busybox:1.37 httpd -f -v -p 80 -h /www&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">probe 8084<span class="p">;</span> ss -ltnp <span class="p">|</span> grep <span class="m">8084</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ufw allow from 173.245.48.0/20 to any port <span class="m">8084</span> proto tcp<span class="p">;</span> probe <span class="m">8084</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">sudo -u verifyrl <span class="nv">XDG_RUNTIME_DIR</span><span class="o">=</span>/run/user/<span class="k">$(</span>id -u verifyrl<span class="k">)</span> bash -lc <span class="s1">&#39;cd ~; podman logs verify-rlp-web 2&gt;&amp;1 | tail -1&#39;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  verify-cf     -&gt; :8084  rc=28  000
</span></span><span class="line"><span class="ln">2</span><span class="cl">  verify-other  -&gt; :8084  rc=28  000
</span></span><span class="line"><span class="ln">3</span><span class="cl">LISTEN 0  4096   *:8084   *:*   users:((&#34;rootlessport&#34;,pid=4921,fd=11))
</span></span><span class="line"><span class="ln">4</span><span class="cl">  verify-cf     -&gt; :8084  rc=0  200
</span></span><span class="line"><span class="ln">5</span><span class="cl">  verify-other  -&gt; :8084  rc=28  000
</span></span><span class="line"><span class="ln">6</span><span class="cl">[::ffff:10.0.2.100]:59274: response:200</span></span></code></pre></div><p><code>podman logs</code> 印出的存取紀錄裡，來源是 <code>10.0.2.100</code>（<code>::ffff:</code> 是 IPv4 位址寫成 IPv6 格式時的前綴，httpd 聽的是 IPv6 的 socket）。那是 slirp4netns（Podman 4.9 文件寫的 rootless 預設網路）在 rootless 網路命名空間裡那張 tap 介面的位址，rootlessport 從這個位址把連線轉進容器。這台的 <code>podman info</code> 取不出 rootless 網路的種類，預設是 slirp4netns 是依文件，不是從輸出確認的。跟 rootless Docker 一樣，ufw 的入站規則管得到，容器看不到真正的來源。Podman 5 的 rootless 預設網路改成 pasta，pasta 預設保留來源位址（Podman 5.0 的發行說明），沒有實跑。</p>
<p>兩個 rootless 的指令都先 <code>cd /tmp</code>、在使用者的 shell 裡再 <code>cd ~</code>：在 <code>/root</code> 底下以 <code>sudo -u</code> 執行時，第一次跑 rootless Podman 得到 <code>cannot chdir to /root: Permission denied</code> 與 <code>Error: setting up the process</code>，容器沒有啟動。原因是一般使用者讀不了 <code>/root</code>，而 Podman 在這個使用者的身分下要用目前的工作目錄。</p>
<h2 id="清理兩台主機上的容器測試使用者ufw-規則namespace-與暫存目錄">清理：兩台主機上的容器、測試使用者、ufw 規則、namespace 與暫存目錄</h2>
<p>Ubuntu 那一台：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">podman rm -f verify-pod-web
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nb">cd</span> /tmp
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">sudo -u verifyrl <span class="nv">XDG_RUNTIME_DIR</span><span class="o">=</span>/run/user/<span class="k">$(</span>id -u verifyrl<span class="k">)</span> bash -lc <span class="s1">&#39;cd ~; podman rm -f verify-rlp-web;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">  DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock docker rm -f verify-rl-web;
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">  dockerd-rootless-setuptool.sh uninstall&#39;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">loginctl disable-linger verifyrl
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">while</span> pgrep -u verifyrl &gt;/dev/null<span class="p">;</span> <span class="k">do</span> sleep 1<span class="p">;</span> <span class="k">done</span>      <span class="c1"># 等 systemd 停掉這個使用者的程序</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">userdel -r verifyrl
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">ufw delete allow from 173.245.48.0/20 to any port <span class="m">8083</span> proto tcp
</span></span><span class="line"><span class="ln">10</span><span class="cl">ufw delete allow from 173.245.48.0/20 to any port <span class="m">8084</span> proto tcp</span></span></code></pre></div><p>8082 的 route 規則已經在 8089 那一步刪掉。namespace、<code>verify-web</code>、<code>/var/tmp/verify-fw</code> 與其他 ufw 設定，照 ufw 那一篇的〈清理：容器、ufw 規則、after.rules、namespace 與暫存目錄〉清。Oracle Linux 那一台這一篇只建了 <code>verify-pod-web</code> 與兩個 namespace：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">podman rm -f verify-pod-web
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --zone<span class="o">=</span>trusted --list-sources                 <span class="c1"># 還列得出 10.88.0.0/16 時接下一行</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --zone<span class="o">=</span>trusted --remove-source<span class="o">=</span>10.88.0.0/16   <span class="c1"># 只移除執行中的設定；沒有實跑</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">ip link del vcf-h<span class="p">;</span> ip link del voth-h<span class="p">;</span> ip netns del verify-cf<span class="p">;</span> ip netns del verify-other
</span></span><span class="line"><span class="ln">5</span><span class="cl">rm -rf /var/tmp/verify-fw</span></span></code></pre></div><p>firewalld 那一篇的清理段刪的是那一篇自己建的資源（靜態的 dockerd、ipset、nftables 表、<code>/var/lib/docker</code>），不要照它整段跑。這兩段清理指令跟實測時的順序不同：實測時兩台的清理分在幾次跑，沒有照這兩段一次跑完。</p>
<p><code>disable-linger</code> 之後，systemd 要過一會兒才停掉那個使用者的 user 服務；實測緊接著跑 <code>userdel</code> 時回 <code>userdel: user verifyrl is currently used by process 2749</code>、使用者沒有刪掉，所以中間等到 <code>pgrep -u verifyrl</code> 沒有輸出。<code>dockerd-rootless-setuptool.sh uninstall</code> 只移除 user 服務，印出的提示寫明不刪 Docker 的資料；<code>userdel -r</code> 刪掉家目錄時，那些資料（<code>~/.local/share/docker</code>）一起刪掉。</p>
<p>firewalld 那一台的 <code>trusted</code> zone 裡 <code>10.88.0.0/16</code> 的來源由 netavark 在容器網路存在時加入，最後一個用到預設網路的容器刪掉之後是否移除沒有確認。留著的影響是之後任何從 <code>10.88.0.0/16</code> 來的連線都由 <code>trusted</code> zone 處理、一律放行；這段位址只在主機內部的 Podman 網路上，外部來源用不到它。OrbStack 的機器整台刪掉時兩台一起刪：<code>orb delete verify-ufw verify-fwd</code>。</p>
<h2 id="rootful-與-rootless-的-dockerpodman接收路徑入站規則與容器看到的來源位址">rootful 與 rootless 的 Docker、Podman：接收路徑、入站規則與容器看到的來源位址</h2>
<table>
  <thead>
      <tr>
          <th>執行方式</th>
          <th>接收連線的路徑</th>
          <th>主機防火牆的入站規則是否管得到</th>
          <th>容器看到的來源位址</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>rootful Docker（參考，見 ufw 與 firewalld 那兩篇）</td>
          <td>IPv4 位址轉換後轉送；IPv6 由 docker-proxy 接收（容器網路沒開 IPv6、userland-proxy 預設開著時）</td>
          <td>IPv4 管不到；IPv6 管得到（ufw 與 firewalld 都量過）</td>
          <td>IPv4 是真正的來源；IPv6 是預設網路的閘道 <code>172.17.0.1</code>（ufw 那一篇量的）</td>
      </tr>
      <tr>
          <td>rootful Podman 4.9.3，ufw</td>
          <td>位址轉換後轉送；routed 是 deny 時被 FORWARD 的 DROP 擋下，是 allow 時放行</td>
          <td>管不到；ufw route 規則管得到，主機也裝了 Docker 時 <code>DOCKER-USER</code> 也管得到</td>
          <td>沒有記錄</td>
      </tr>
      <tr>
          <td>rootful Podman 5.8.2，firewalld</td>
          <td>位址轉換後轉送，firewalld 接受</td>
          <td>管不到</td>
          <td>沒有記錄</td>
      </tr>
      <tr>
          <td>rootless Docker 29.9.2</td>
          <td>主機上的 <code>rootlesskit</code></td>
          <td>管得到</td>
          <td><code>172.17.0.1</code></td>
      </tr>
      <tr>
          <td>rootless Podman 4.9.3</td>
          <td>主機上的 <code>rootlessport</code></td>
          <td>管得到</td>
          <td><code>10.0.2.100</code></td>
      </tr>
  </tbody>
</table>
<p>判斷一種執行方式落在哪一列，先查有沒有規則把那個連接埠的目的地改寫掉，再看誰在聽。改寫規則用 <code>nft list ruleset | grep 'dport &lt;連接埠&gt;'</code> 查：它同時列出經由 iptables-nft 寫的表（Docker、Podman 4.9 的 netavark）與原生 nftables 的表（Podman 5.8.2 的 <code>inet netavark</code>）。只看 <code>iptables -t nat -S</code> 會漏掉後一種：在 Oracle Linux 那一台另發佈一個 8090，<code>iptables -t nat -S | grep -c 8090</code> 回 0，<code>nft list ruleset</code> 列得出 <code>tcp dport 8090 dnat ip to 10.88.0.3:80</code>，同時 <code>ss -ltnp</code> 顯示 <code>conmon</code> 聽著 8090。查到改寫規則時，就是轉送路徑，結果取決於轉送鏈上每一張表的預設政策，聽著那個連接埠的程式（<code>conmon</code>、docker-proxy 的 IPv4 那一份）只是佔住連接埠，不算數；IPv6 的改寫規則（<code>ip6 nat</code> 或 <code>inet</code> 表裡帶 <code>ip6</code> 的那幾條）另外判一次。查不到改寫規則、由一個轉送程式聽著時（rootlesskit、rootlessport、rootful Docker 的 IPv6 那一份聽著的 docker-proxy），才是 input 路徑，入站規則管得到。查表的結果是預測，最終以範圍外的來源實際連一次為準（<code>probe &lt;連接埠&gt;</code>）；Kubernetes 的 NodePort 這類多跳改寫沒有測。</p>
]]></content:encoded></item><item><title>Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-25 到 26 日，用 Cloudflare 免費方案的帳號與一個已經加進 Cloudflare 的網域（原本只有郵件路由的紀錄），源站是 Oracle Cloud &lt;code>ap-batam-1&lt;/code> 區域新建的一台 A1 主機（Arm、Oracle Linux 10.2、1 OCPU、6 GB），放在新建的公開子網路、有公開 IPv4，VCN 沒有開 IPv6。nginx 1.26.3（Oracle Linux 的套件），cloudflared 2026.10.3（Cloudflare 在 GitHub 發佈的 RPM），wrangler 4.152.0（以 &lt;code>npx&lt;/code> 執行）。&lt;/p>&lt;/blockquote>
&lt;p>這篇記錄把一台源站（實際跑網站程式的主機）接到 Cloudflare 後面、實際讓 Cloudflare 回源（&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN&lt;/a> 從自己的機房連回源站取回應）時量到的結果：Oracle Cloud 的 NSG（network security group，套在網卡上的一組放行規則，跟 AWS 的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> 是同一類）只放行 Cloudflare 的 IPv4 清單，以及 NSG 的規則數上限與每次 API 呼叫能加的規則數、GCP 與 AWS 文件寫的上限；源站的 nginx 設定與從 Cloudflare 範圍外直連的結果；網域的 DNS 紀錄經由 Cloudflare 代理之後，回源連線實際的來源位址與它們落在公布清單的哪一段；Authenticated Origin Pulls（AOP，Cloudflare 回源時出示用戶端憑證，也就是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向數位憑證驗證如何保護跨邊界資料流">TLS / mTLS&lt;/a> 的雙向驗證）在 nginx 上的驗證與拒絕沒有憑證的連線；Cloudflare Worker 從 Cloudflare 的位址連到源站、並在請求裡偽造 &lt;code>CF-Connecting-IP&lt;/code> 時源站收到什麼（用的是自己的帳號，連一個不屬於任何 Cloudflare 網域的主機名稱）；Cloudflare Tunnel 的 quick tunnel 在源站沒有任何入站放行時的連線方式、預設的協定，以及出站擋掉 UDP 7844、TCP 7844 之後的結果；清理與 Cloudflare 設定的還原。這些限制各自擋得住什麼、要放在雲端防火牆、主機防火牆還是 Cloudflare 端，寫在概念文章 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a>；主機防火牆上只放行 Cloudflare 位址的寫法在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>。&lt;/p>
&lt;h2 id="前置條件cloudflare-的網域有公開-ip-的源站與本機的工具">前置條件：Cloudflare 的網域、有公開 IP 的源站與本機的工具&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>一個加進 Cloudflare 的網域&lt;/strong>：免費方案就夠。實測只新增一筆子網域的 A 紀錄（下文的 &lt;code>verify-origin.&amp;lt;網域&amp;gt;&lt;/code>），網域原有的紀錄不動；SSL/TLS 模式與 AOP 是整個網域的設定，改之前先記下原值，測完照原值還原（〈清理與 Cloudflare 設定的還原〉）。網域上有正在服務的網站時，改 SSL/TLS 模式會影響它，要另外評估或用一個沒有在用的網域。&lt;/li>
&lt;li>&lt;strong>一台有公開 IP、能連外的主機&lt;/strong>：Cloudflare 回源要連得到源站，Tunnel 要源站連得出去，所以主機要在有 internet gateway 的公開子網路。只有 service gateway 的私有子網路（例如 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/launch-instance-walkthrough/" data-link-title="Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入" data-link-desc="要在 Oracle Cloud 建一台沒有公開 IP、經由 Bastion 登入的 Always Free A1 主機，想照一次實測重做、看建立頁每一欄的取值與換一種做法的結果時讀；也用在建立請求一直回容量不足、managed SSH session 建立逾時，或沒放公鑰的主機想用 Run Command 救援、Oracle Linux 10 的主機上找不到 Bastion plugin、想改用 port forwarding session 登入的時候">Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入&lt;/a> 建的那一種）兩件事都做不到，下文在一個既有的 VCN 裡另建公開子網路與一台新主機。原本想用永久免費的 &lt;code>VM.Standard.E2.1.Micro&lt;/code>：&lt;code>oci limits resource-availability get&lt;/code> 回 &lt;code>available: 2&lt;/code>，但 &lt;code>oci compute shape list --availability-domain &amp;lt;AD&amp;gt;&lt;/code> 列出的 shape 裡沒有它（&lt;code>&amp;lt;AD&amp;gt;&lt;/code> 是 availability domain 的名稱，&lt;code>oci iam availability-domain list&lt;/code> 查得到），ap-batam-1 不提供這個 shape，改用同樣在免費額度內的 A1（&lt;code>VM.Standard.A1.Flex&lt;/code>，1 OCPU、6 GB）。&lt;/li>
&lt;li>&lt;strong>本機的工具&lt;/strong>：OCI CLI（以 session token 登入，做法見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/oracle-cloud/cli-session-login/" data-link-title="從本機用 OCI CLI 操作 Oracle Cloud：session token 登入、時效與 API 簽章金鑰" data-link-desc="OCI CLI 的兩種身分驗證方式——瀏覽器登入換來的 session token 與上傳公鑰的 API 簽章金鑰——各自把什麼存在本機、有效多久、外洩時的影響範圍，以及登入時的互動提示與錯誤訊息怎麼讀">從本機用 OCI CLI 操作 Oracle Cloud：session token 登入、時效與 API 簽章金鑰&lt;/a>）、&lt;code>npx&lt;/code>（Node.js 附的，執行 wrangler 用），以及 SSH 金鑰。&lt;/li>
&lt;li>&lt;strong>wrangler 登入與 workers.dev 子網域&lt;/strong>：&lt;code>npx wrangler login&lt;/code> 會開瀏覽器做 OAuth 授權，由帳號的擁有者自己按。Worker 要部署到 &lt;code>*.workers.dev&lt;/code> 之前，帳號要先有一個 workers.dev 子網域；這次的帳號還沒有，&lt;code>wrangler deploy&lt;/code> 回 &lt;code>Could not automatically register &amp;quot;worker&amp;quot; as your workers.dev subdomain because the name is unavailable&lt;/code>，它給的註冊網址（&lt;code>/workers/onboarding&lt;/code>）在主控台回 404。打開主控台的 Workers &amp;amp; Pages 頁面之後，頁面的 Account details 列出了一個以帳號名稱建的子網域，再 &lt;code>wrangler deploy&lt;/code> 就成功了；子網域是打開頁面時自動建的，還是更早就有，沒有分辨。&lt;/li>
&lt;/ul>
&lt;h2 id="公開子網路只放行-cloudflare-的-nsg-與它的規則數上限">公開子網路、只放行 Cloudflare 的 NSG 與它的規則數上限&lt;/h2>
&lt;p>這一步在 VCN 裡建出一條對外的路：internet gateway、預設路由指向它的 route table、只放行管理者自己 SSH 的 security list、公開子網路，以及一個只放行 Cloudflare IPv4 到 80 與 443 的 NSG。&lt;code>&amp;lt;C&amp;gt;&lt;/code>、&lt;code>&amp;lt;V&amp;gt;&lt;/code> 是 compartment 與 VCN 的 OCID，&lt;code>&amp;lt;管理者的公開 IP&amp;gt;&lt;/code> 是自己這台電腦連外時的公開位址（&lt;code>curl -s https://ifconfig.me&lt;/code> 查得到）：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-25 到 26 日，用 Cloudflare 免費方案的帳號與一個已經加進 Cloudflare 的網域（原本只有郵件路由的紀錄），源站是 Oracle Cloud <code>ap-batam-1</code> 區域新建的一台 A1 主機（Arm、Oracle Linux 10.2、1 OCPU、6 GB），放在新建的公開子網路、有公開 IPv4，VCN 沒有開 IPv6。nginx 1.26.3（Oracle Linux 的套件），cloudflared 2026.10.3（Cloudflare 在 GitHub 發佈的 RPM），wrangler 4.152.0（以 <code>npx</code> 執行）。</p></blockquote>
<p>這篇記錄把一台源站（實際跑網站程式的主機）接到 Cloudflare 後面、實際讓 Cloudflare 回源（<a href="/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN</a> 從自己的機房連回源站取回應）時量到的結果：Oracle Cloud 的 NSG（network security group，套在網卡上的一組放行規則，跟 AWS 的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> 是同一類）只放行 Cloudflare 的 IPv4 清單，以及 NSG 的規則數上限與每次 API 呼叫能加的規則數、GCP 與 AWS 文件寫的上限；源站的 nginx 設定與從 Cloudflare 範圍外直連的結果；網域的 DNS 紀錄經由 Cloudflare 代理之後，回源連線實際的來源位址與它們落在公布清單的哪一段；Authenticated Origin Pulls（AOP，Cloudflare 回源時出示用戶端憑證，也就是 <a href="/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向數位憑證驗證如何保護跨邊界資料流">TLS / mTLS</a> 的雙向驗證）在 nginx 上的驗證與拒絕沒有憑證的連線；Cloudflare Worker 從 Cloudflare 的位址連到源站、並在請求裡偽造 <code>CF-Connecting-IP</code> 時源站收到什麼（用的是自己的帳號，連一個不屬於任何 Cloudflare 網域的主機名稱）；Cloudflare Tunnel 的 quick tunnel 在源站沒有任何入站放行時的連線方式、預設的協定，以及出站擋掉 UDP 7844、TCP 7844 之後的結果；清理與 Cloudflare 設定的還原。這些限制各自擋得住什麼、要放在雲端防火牆、主機防火牆還是 Cloudflare 端，寫在概念文章 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>；主機防火牆上只放行 Cloudflare 位址的寫法在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>。</p>
<h2 id="前置條件cloudflare-的網域有公開-ip-的源站與本機的工具">前置條件：Cloudflare 的網域、有公開 IP 的源站與本機的工具</h2>
<ul>
<li><strong>一個加進 Cloudflare 的網域</strong>：免費方案就夠。實測只新增一筆子網域的 A 紀錄（下文的 <code>verify-origin.&lt;網域&gt;</code>），網域原有的紀錄不動；SSL/TLS 模式與 AOP 是整個網域的設定，改之前先記下原值，測完照原值還原（〈清理與 Cloudflare 設定的還原〉）。網域上有正在服務的網站時，改 SSL/TLS 模式會影響它，要另外評估或用一個沒有在用的網域。</li>
<li><strong>一台有公開 IP、能連外的主機</strong>：Cloudflare 回源要連得到源站，Tunnel 要源站連得出去，所以主機要在有 internet gateway 的公開子網路。只有 service gateway 的私有子網路（例如 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/launch-instance-walkthrough/" data-link-title="Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入" data-link-desc="要在 Oracle Cloud 建一台沒有公開 IP、經由 Bastion 登入的 Always Free A1 主機，想照一次實測重做、看建立頁每一欄的取值與換一種做法的結果時讀；也用在建立請求一直回容量不足、managed SSH session 建立逾時，或沒放公鑰的主機想用 Run Command 救援、Oracle Linux 10 的主機上找不到 Bastion plugin、想改用 port forwarding session 登入的時候">Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入</a> 建的那一種）兩件事都做不到，下文在一個既有的 VCN 裡另建公開子網路與一台新主機。原本想用永久免費的 <code>VM.Standard.E2.1.Micro</code>：<code>oci limits resource-availability get</code> 回 <code>available: 2</code>，但 <code>oci compute shape list --availability-domain &lt;AD&gt;</code> 列出的 shape 裡沒有它（<code>&lt;AD&gt;</code> 是 availability domain 的名稱，<code>oci iam availability-domain list</code> 查得到），ap-batam-1 不提供這個 shape，改用同樣在免費額度內的 A1（<code>VM.Standard.A1.Flex</code>，1 OCPU、6 GB）。</li>
<li><strong>本機的工具</strong>：OCI CLI（以 session token 登入，做法見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/cli-session-login/" data-link-title="從本機用 OCI CLI 操作 Oracle Cloud：session token 登入、時效與 API 簽章金鑰" data-link-desc="OCI CLI 的兩種身分驗證方式——瀏覽器登入換來的 session token 與上傳公鑰的 API 簽章金鑰——各自把什麼存在本機、有效多久、外洩時的影響範圍，以及登入時的互動提示與錯誤訊息怎麼讀">從本機用 OCI CLI 操作 Oracle Cloud：session token 登入、時效與 API 簽章金鑰</a>）、<code>npx</code>（Node.js 附的，執行 wrangler 用），以及 SSH 金鑰。</li>
<li><strong>wrangler 登入與 workers.dev 子網域</strong>：<code>npx wrangler login</code> 會開瀏覽器做 OAuth 授權，由帳號的擁有者自己按。Worker 要部署到 <code>*.workers.dev</code> 之前，帳號要先有一個 workers.dev 子網域；這次的帳號還沒有，<code>wrangler deploy</code> 回 <code>Could not automatically register &quot;worker&quot; as your workers.dev subdomain because the name is unavailable</code>，它給的註冊網址（<code>/workers/onboarding</code>）在主控台回 404。打開主控台的 Workers &amp; Pages 頁面之後，頁面的 Account details 列出了一個以帳號名稱建的子網域，再 <code>wrangler deploy</code> 就成功了；子網域是打開頁面時自動建的，還是更早就有，沒有分辨。</li>
</ul>
<h2 id="公開子網路只放行-cloudflare-的-nsg-與它的規則數上限">公開子網路、只放行 Cloudflare 的 NSG 與它的規則數上限</h2>
<p>這一步在 VCN 裡建出一條對外的路：internet gateway、預設路由指向它的 route table、只放行管理者自己 SSH 的 security list、公開子網路，以及一個只放行 Cloudflare IPv4 到 80 與 443 的 NSG。<code>&lt;C&gt;</code>、<code>&lt;V&gt;</code> 是 compartment 與 VCN 的 OCID，<code>&lt;管理者的公開 IP&gt;</code> 是自己這台電腦連外時的公開位址（<code>curl -s https://ifconfig.me</code> 查得到）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nb">export</span> <span class="nv">OCI_CLI_AUTH</span><span class="o">=</span>security_token
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nv">IGW</span><span class="o">=</span><span class="k">$(</span>oci network internet-gateway create -c &lt;C&gt; --vcn-id &lt;V&gt; --is-enabled <span class="nb">true</span> --display-name verify-igw <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --wait-for-state AVAILABLE --query data.id --raw-output<span class="k">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">RTP</span><span class="o">=</span><span class="k">$(</span>oci network route-table create -c &lt;C&gt; --vcn-id &lt;V&gt; --display-name verify-public-rt <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  --route-rules <span class="s2">&#34;[{\&#34;destination\&#34;:\&#34;0.0.0.0/0\&#34;,\&#34;destinationType\&#34;:\&#34;CIDR_BLOCK\&#34;,\&#34;networkEntityId\&#34;:\&#34;</span><span class="nv">$IGW</span><span class="s2">\&#34;}]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  --wait-for-state AVAILABLE --query data.id --raw-output<span class="k">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nv">SLP</span><span class="o">=</span><span class="k">$(</span>oci network security-list create -c &lt;C&gt; --vcn-id &lt;V&gt; --display-name verify-public-sl <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  --ingress-security-rules <span class="s1">&#39;[{&#34;protocol&#34;:&#34;6&#34;,&#34;source&#34;:&#34;&lt;管理者的公開 IP&gt;/32&#34;,&#34;tcpOptions&#34;:{&#34;destinationPortRange&#34;:{&#34;min&#34;:22,&#34;max&#34;:22}}}]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  --egress-security-rules <span class="s1">&#39;[{&#34;protocol&#34;:&#34;all&#34;,&#34;destination&#34;:&#34;0.0.0.0/0&#34;}]&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --wait-for-state AVAILABLE --query data.id --raw-output<span class="k">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nv">SNP</span><span class="o">=</span><span class="k">$(</span>oci network subnet create -c &lt;C&gt; --vcn-id &lt;V&gt; --cidr-block 10.0.2.0/24 --display-name verify-public-subnet <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --dns-label pub --prohibit-public-ip-on-vnic <span class="nb">false</span> --route-table-id <span class="s2">&#34;</span><span class="nv">$RTP</span><span class="s2">&#34;</span> --security-list-ids <span class="s2">&#34;[\&#34;</span><span class="nv">$SLP</span><span class="s2">\&#34;]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  --wait-for-state AVAILABLE --query data.id --raw-output<span class="k">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nv">NSG</span><span class="o">=</span><span class="k">$(</span>oci network nsg create -c &lt;C&gt; --vcn-id &lt;V&gt; --display-name verify-origin-cf <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  --wait-for-state AVAILABLE --query data.id --raw-output<span class="k">)</span></span></span></code></pre></div><p>五個變數存著新建資源的 OCID，後面的 NSG、主機與清理都用到它們；只存在這個 shell 裡，換了 shell（實測橫跨兩天）時用顯示名稱查回來，例如 <code>NSG=$(oci network nsg list -c &lt;C&gt; --display-name verify-origin-cf --query 'data[0].id' --raw-output)</code>，其餘四種資源的 <code>list</code> 子命令同樣接受 <code>--display-name</code>（這幾行查詢沒有實跑）。session token 預設一小時過期，過期之後照 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/cli-session-login/" data-link-title="從本機用 OCI CLI 操作 Oracle Cloud：session token 登入、時效與 API 簽章金鑰" data-link-desc="OCI CLI 的兩種身分驗證方式——瀏覽器登入換來的 session token 與上傳公鑰的 API 簽章金鑰——各自把什麼存在本機、有效多久、外洩時的影響範圍，以及登入時的互動提示與錯誤訊息怎麼讀">從本機用 OCI CLI 操作 Oracle Cloud：session token 登入、時效與 API 簽章金鑰</a> 的〈Session token 的時效與 refresh〉延長或重新登入。<code>10.0.2.0/24</code> 是 VCN 的 <code>10.0.0.0/16</code> 裡一段還沒用過的位址，換成自己 VCN 裡沒有被其他子網路佔用的一段。security list 套在整個子網路上，這裡只放 SSH；網站的 80 與 443 放在 NSG，NSG 只套在指定的網卡上，同一個子網路裡別的主機不會因此對 Cloudflare 開放。出站全放行，cloudflared 與套件安裝要用。</p>
<p>NSG 的規則每一條只放一段來源與一個連接埠範圍，Cloudflare 的 15 段 IPv4 對 80 與 443 各一條，是 30 條，每 25 條分一批送：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">curl -s https://www.cloudflare.com/ips-v4 <span class="p">|</span> python3 -c <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s1">import sys, json
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s1">nets = sys.stdin.read().split()
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">rules = [{&#34;direction&#34;:&#34;INGRESS&#34;,&#34;protocol&#34;:&#34;6&#34;,&#34;source&#34;:n,&#34;sourceType&#34;:&#34;CIDR_BLOCK&#34;,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">  &#34;tcpOptions&#34;:{&#34;destinationPortRange&#34;:{&#34;min&#34;:p,&#34;max&#34;:p}},&#34;description&#34;:&#34;cloudflare&#34;} for n in nets for p in (80,443)]
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">for i in range(0, len(rules), 25):
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">    open(f&#34;/tmp/verify-nsg-batch{i//25}.json&#34;,&#34;w&#34;).write(json.dumps(rules[i:i+25]))&#39;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">for</span> f in /tmp/verify-nsg-batch*.json<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  oci network nsg rules add --nsg-id <span class="s2">&#34;</span><span class="nv">$NSG</span><span class="s2">&#34;</span> --security-rules <span class="s2">&#34;file://</span><span class="nv">$f</span><span class="s2">&#34;</span> --query <span class="s1">&#39;length(data.&#34;security-rules&#34;)&#39;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">oci network nsg rules list --nsg-id <span class="s2">&#34;</span><span class="nv">$NSG</span><span class="s2">&#34;</span> --all --query <span class="s1">&#39;length(data)&#39;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">25
</span></span><span class="line"><span class="ln">2</span><span class="cl">5
</span></span><span class="line"><span class="ln">3</span><span class="cl">30</span></span></code></pre></div><p>一次送出 30 條時 API 回 <code>InvalidParameter</code>：<code>Count of security rules to add: 30 cannot be greater than 25</code>。每一次 <code>nsg rules add</code> 最多 25 條，所以 Python 把規則每 25 條寫成一個檔，迴圈逐檔加。<code>/tmp/verify-nsg-batch*.json</code> 是本機的暫存檔，加完以 <code>rm /tmp/verify-nsg-batch*.json</code> 刪掉。Cloudflare 的清單會變（2026-10-25 是 15 段 IPv4、7 段 IPv6），每次重抓。</p>
<p>NSG 的總規則數另有上限。另建一個空的 NSG（建法同上面的 <code>nsg create</code>，顯示名稱用 <code>verify-limit</code>），每次加 25 條、目的連接埠 443、來源是 <code>198.18.x.y/32</code> 的假位址（<code>198.18.0.0/15</code> 是保留給網路設備效能測試的位址段，不會出現在網際網路上），直到被拒：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>回應</th>
          <th>加完的總數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第 1 到 4 批，每批 25 條入站</td>
          <td>成功</td>
          <td>100</td>
      </tr>
      <tr>
          <td>第 5 批 25 條</td>
          <td><code>Total security rules count 125 cannot be greater than 120</code></td>
          <td>100</td>
      </tr>
      <tr>
          <td>1 條出站</td>
          <td>成功</td>
          <td>101</td>
      </tr>
      <tr>
          <td>19 條入站</td>
          <td>成功</td>
          <td>120</td>
      </tr>
      <tr>
          <td>再 1 條入站</td>
          <td><code>Total security rules count 121 cannot be greater than 120</code></td>
          <td>120</td>
      </tr>
  </tbody>
</table>
<p>一個 NSG 最多 120 條，入站與出站合計，跟 Oracle〈Security Rules〉的限制表相同；超過時整批被拒，不是加到滿為止。只放行 Cloudflare 的 IPv4 到 80 與 443 要 30 條，再加 IPv6 的 7 段是 44 條，一個 NSG 放得下；要放行更多連接埠時，條數是「段數 × 連接埠數」，一張網卡最多套 5 個 NSG。security list 的上限是入站 200 條、出站 200 條（同一份文件，不能申請調高），沒有實測。測試用的 NSG 測完以 <code>oci network nsg delete --nsg-id &lt;verify-limit 的 OCID&gt; --force</code> 刪掉。</p>
<p>GCP 與 AWS 只查了文件，沒有實測：</p>
<table>
  <thead>
      <tr>
          <th>業者</th>
          <th>規則的形狀</th>
          <th>上限（文件）</th>
          <th>放 Cloudflare 清單的條數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Oracle Cloud NSG</td>
          <td>一條一段來源、一個連接埠範圍</td>
          <td>120 條（入站加出站），實測相同</td>
          <td>IPv4 30 條，加 IPv6 共 44 條</td>
      </tr>
      <tr>
          <td>AWS security group</td>
          <td>一條一段來源、一個連接埠範圍</td>
          <td>入站 60 條（IPv4 與 IPv6 分開計算，可申請調高；乘上每張網卡的 security group 數不超過 1000）〔Amazon VPC quotas〕</td>
          <td>IPv4 30 條、IPv6 14 條，各自在 60 之內</td>
      </tr>
      <tr>
          <td>GCP VPC 防火牆規則</td>
          <td>一條可放多段來源與多個連接埠</td>
          <td>每條最多 5000 段來源，不能調高；一條規則只能是 IPv4 或只能是 IPv6〔Cloud NGFW quotas〕</td>
          <td>IPv4、IPv6 各 1 條</td>
      </tr>
  </tbody>
</table>
<p>AWS 的 managed prefix list 被 security group 引用時，以 prefix list 的「最大項數」計入規則數，不是實際的項數（同一份 quotas 文件）。清單的更新三家都要有人或程式去改：Oracle 一條規則只放一段，每次更新是逐條增刪；AWS 規則直接寫位址段時也是逐條增刪，改用自建的 customer-managed prefix list 時更新的是 list 的項目，security group 的規則不動（依文件，沒有實跑）；GCP 改的是那一條規則的來源清單。</p>
<h2 id="源站主機建立nginx-與從範圍外直連的結果">源站主機：建立、nginx 與從範圍外直連的結果</h2>
<p>主機建在公開子網路，帶公開 IP，網卡套上那個 NSG：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">INST</span><span class="o">=</span><span class="k">$(</span>oci compute instance launch -c &lt;C&gt; --availability-domain &lt;AD&gt; --display-name verify-origin <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --shape VM.Standard.A1.Flex --shape-config <span class="s1">&#39;{&#34;ocpus&#34;:1,&#34;memoryInGBs&#34;:6}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --image-id &lt;Oracle Linux <span class="m">10</span> aarch64 映像的 OCID&gt; --subnet-id <span class="s2">&#34;</span><span class="nv">$SNP</span><span class="s2">&#34;</span> --assign-public-ip <span class="nb">true</span> --nsg-ids <span class="s2">&#34;[\&#34;</span><span class="nv">$NSG</span><span class="s2">\&#34;]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --ssh-authorized-keys-file &lt;公鑰檔&gt; --wait-for-state RUNNING --max-wait-seconds <span class="m">900</span> --query data.id --raw-output<span class="k">)</span></span></span></code></pre></div><p>A1 回容量不足（<code>Out of host capacity</code>）時要重送，做法見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/launch-instance-walkthrough/" data-link-title="Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入" data-link-desc="要在 Oracle Cloud 建一台沒有公開 IP、經由 Bastion 登入的 Always Free A1 主機，想照一次實測重做、看建立頁每一欄的取值與換一種做法的結果時讀；也用在建立請求一直回容量不足、managed SSH session 建立逾時，或沒放公鑰的主機想用 Run Command 救援、Oracle Linux 10 的主機上找不到 Bastion plugin、想改用 port forwarding session 登入的時候">Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入</a> 的〈重送建立請求：CLI 自己的重送、限流門檻與重送腳本〉；這次第一次送出就建成了。映像的 OCID 用 <code>oci compute image list -c &lt;C&gt; --operating-system &quot;Oracle Linux&quot; --shape VM.Standard.A1.Flex --sort-by TIMECREATED</code> 查，第一筆是最新的；這次是 <code>Oracle-Linux-10.2-aarch64-2026.10.16-0</code>。主機進入 RUNNING 之後，<code>oci compute instance list-vnics --instance-id &quot;$INST&quot;</code> 列出公開 IP，SSH 以 <code>opc</code> 登入，再 <code>sudo -i</code> 進 root shell：下面在源站上跑的區塊都在這個 root shell 裡（<code>cat &gt; /etc/…</code> 的重導向要 root 權限）。</p>
<p>nginx 開兩個服務：80 給一般回源；443 用自簽憑證，之後測 AOP。存取紀錄記下 TCP 連線的來源位址（<code>$remote_addr</code>）、Cloudflare 帶來的 <code>CF-Connecting-IP</code> 標頭與用戶端憑證的驗證結果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">dnf -y install nginx openssl
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir -p /etc/nginx/verify
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">openssl req -x509 -newkey rsa:2048 -nodes -days <span class="m">7</span> -subj <span class="s2">&#34;/CN=verify-origin&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  -keyout /etc/nginx/verify/self.key -out /etc/nginx/verify/self.crt
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">curl -sSf https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem -o /etc/nginx/verify/cf-origin-pull-ca.pem
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">cat &gt; /etc/nginx/conf.d/verify.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">log_format verify &#39;$remote_addr cfip=$http_cf_connecting_ip host=$host ssl_client_verify=$ssl_client_verify &#34;$request&#34; $status&#39;;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">server {
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">    listen 80 default_server;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">    server_name _;
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">    access_log /var/log/nginx/verify.log verify;
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    location / { default_type text/plain; return 200 &#34;verify-origin http from $remote_addr\n&#34;; }
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">server {
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">    listen 443 ssl default_server;
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">    server_name _;
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">    ssl_certificate     /etc/nginx/verify/self.crt;
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">    ssl_certificate_key /etc/nginx/verify/self.key;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">    ssl_client_certificate /etc/nginx/verify/cf-origin-pull-ca.pem;
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">    ssl_verify_client optional;
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">    access_log /var/log/nginx/verify.log verify;
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">    location / { default_type text/plain; return 200 &#34;verify-origin https from $remote_addr client_verify=$ssl_client_verify\n&#34;; }
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">sed -i <span class="s1">&#39;s/listen       80 default_server;/listen       80;/; s/listen       \[::\]:80 default_server;/listen       [::]:80;/&#39;</span> /etc/nginx/nginx.conf
</span></span><span class="line"><span class="ln">26</span><span class="cl">nginx -t <span class="o">&amp;&amp;</span> systemctl <span class="nb">enable</span> --now nginx
</span></span><span class="line"><span class="ln">27</span><span class="cl">firewall-cmd --permanent --zone<span class="o">=</span>public --add-service<span class="o">=</span>http --add-service<span class="o">=</span>https<span class="p">;</span> firewall-cmd --reload</span></span></code></pre></div><p><code>authenticated_origin_pull_ca.pem</code> 是 Cloudflare 用來簽 AOP 共用用戶端憑證的 CA，<code>ssl_client_certificate</code> 指向它，nginx 才驗得出那張憑證。<code>ssl_verify_client optional</code> 先只記錄驗證結果、不拒絕，後面改成 <code>on</code>。Oracle Linux 套件附的 <code>nginx.conf</code> 自帶一個 <code>listen 80 default_server</code> 的 server 區塊，跟 <code>verify.conf</code> 的 <code>default_server</code> 重複時 <code>nginx -t</code> 會失敗，所以 <code>sed</code> 拿掉原檔的 <code>default_server</code>；拿掉之後 <code>nginx -t</code> 仍印 <code>conflicting server name &quot;_&quot; on 0.0.0.0:80, ignored</code> 的警告，兩個區塊都用 <code>_</code> 當 server_name，不影響結果。主機防火牆開 http 與 https 是因為這次的來源限制放在 NSG；主機防火牆再疊一層只放行 Cloudflare 的寫法見 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>。</p>
<p>從本機（位址不在 Cloudflare 範圍）直接連源站的公開 IP：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -sS -m <span class="m">8</span> -o /dev/null -w <span class="s1">&#39;%{http_code}\n&#39;</span> http://&lt;源站的公開 IP&gt;/
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -sSk -m <span class="m">8</span> -o /dev/null -w <span class="s1">&#39;%{http_code}\n&#39;</span> https://&lt;源站的公開 IP&gt;/</span></span></code></pre></div><p>兩條都在 8 秒後逾時，印 <code>000</code>：NSG 沒有放行本機的位址，封包在 Oracle 的網路上就被丟掉，主機收不到。管理者的 SSH 走 security list 放行的 22，不受影響。</p>
<h2 id="dns-紀錄經由-cloudflare-代理之後回源的來源位址">DNS 紀錄經由 Cloudflare 代理之後，回源的來源位址</h2>
<p>在 Cloudflare 主控台的 DNS → Records 新增一筆紀錄：類型 <code>A</code>、名稱 <code>verify-origin</code>、IPv4 位址填源站的公開 IP、Proxy status 是 Proxied（橘色雲朵）、TTL 自動（用預設值，沒有比較其他值；依 Cloudflare 的 DNS 文件，Proxied 的紀錄 TTL 由 Cloudflare 決定）。Proxy status 改成 DNS only（灰色雲朵）時，網域直接解析到源站的公開 IP，訪客直連源站，被 NSG 擋下，這次沒有實跑。存好之後從本機連：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">dig +short verify-origin.&lt;網域&gt; A<span class="p">;</span> dig +short verify-origin.&lt;網域&gt; AAAA
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">for</span> i in <span class="k">$(</span>seq 10<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  curl -s -m <span class="m">10</span> -o /dev/null -w <span class="s2">&#34;http %{http_code}\n&#34;</span> http://verify-origin.&lt;網域&gt;/
</span></span><span class="line"><span class="ln">4</span><span class="cl">  curl -s -m <span class="m">10</span> -o /dev/null -w <span class="s2">&#34;https %{http_code}\n&#34;</span> https://verify-origin.&lt;網域&gt;/
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">done</span> <span class="p">|</span> sort <span class="p">|</span> uniq -c
</span></span><span class="line"><span class="ln">6</span><span class="cl">curl -s https://verify-origin.&lt;網域&gt;/</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">&lt;Cloudflare 的 IPv4 位址，兩個&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">&lt;Cloudflare 的 IPv6 位址，兩個&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">  10 http 200
</span></span><span class="line"><span class="ln">4</span><span class="cl">  10 https 200
</span></span><span class="line"><span class="ln">5</span><span class="cl">verify-origin http from 172.69.134.32</span></span></code></pre></div><p>網域解析到 Cloudflare 的位址（<code>104.21.0.0/16</code>、<code>172.67.0.0/16</code> 與 <code>2606:4700::/32</code> 裡各兩個，每個網域分到的位址不同），Proxied 的紀錄另外有 AAAA，即使源站只填了 IPv4。https 的回應內容寫著 <code>http from</code>：這個網域的 SSL/TLS 是自動模式，當時實際套用的是 Flexible（彈性），訪客到 Cloudflare 用 https，Cloudflare 回源用 http 的 80。</p>
<p>連了幾輪之後，把源站的存取紀錄拿回本機，逐筆比對來源位址在不在 Cloudflare 公布的清單裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python3 - verify.log ips-v4.txt <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">import sys, ipaddress, collections
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">nets = [ipaddress.ip_network(n) for n in open(sys.argv[2]).read().split()]
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s">c = collections.Counter(l.split()[0] for l in open(sys.argv[1]) if not l.startswith(&#39;127.&#39;))
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">for ip, n in sorted(c.items()):
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s">    hit = [str(x) for x in nets if ipaddress.ip_address(ip) in x]
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s">    print(f&#34;{ip:16} {n:3}  {hit[0] if hit else &#39;範圍外&#39;}&#34;)
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s">EOF</span></span></span></code></pre></div><p><code>verify.log</code> 是源站的 <code>/var/log/nginx/verify.log</code>，<code>ips-v4.txt</code> 是 <code>https://www.cloudflare.com/ips-v4</code> 的內容。這次全部測試結束時的結果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">104.22.17.208      3  104.16.0.0/13
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">104.22.17.80       2  104.16.0.0/13
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">104.22.20.232      6  104.16.0.0/13
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">104.22.20.233      3  104.16.0.0/13
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">108.162.246.209    2  108.162.192.0/18
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">162.158.167.47     1  162.158.0.0/15
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">162.158.167.48     4  162.158.0.0/15
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">172.69.134.32      5  172.64.0.0/13
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">172.69.23.187      3  172.64.0.0/13
</span></span><span class="line"><span class="ln">10</span><span class="cl">172.71.154.190     1  172.64.0.0/13
</span></span><span class="line"><span class="ln">11</span><span class="cl">172.71.158.118     5  172.64.0.0/13
</span></span><span class="line"><span class="ln">12</span><span class="cl">172.71.158.119     2  172.64.0.0/13</span></span></code></pre></div><p>37 筆來自 12 個位址，全部落在公布的四段裡，沒有一筆在範圍外；回源用哪些位址依訪客連到的 Cloudflare 機房與時間而變，自己量時清單會不同。<code>cfip=</code> 的分布：33 筆是本機的公開 IP（下文的輸出以 <code>&lt;管理者的公開 IP&gt;</code> 代替），2 筆是下文 Worker 的請求（<code>-</code> 與偽造的 <code>198.51.100.77</code> 各 1 筆），另外 2 筆來自 <code>108.162.246.209</code>、連的是經由代理的 <code>verify-origin.&lt;網域&gt;</code>、AOP 驗證 <code>SUCCESS</code>，<code>cfip</code> 是 <code>2a06:98c0:3600::103</code>，跟下文 Worker 出站時的位址相同。這 2 筆出現在 Tunnel 那一節之後，這次部署的 Worker 程式沒有連這個主機名稱，送出它們的是什麼沒有查明。VCN 沒有開 IPv6、源站只有 A 紀錄，所以回源全部經由 IPv4；源站有 IPv6 時 Cloudflare 是否改用 IPv6 回源，沒有測。</p>
<h2 id="ssltls-模式完整與-authenticated-origin-pulls">SSL/TLS 模式「完整」與 Authenticated Origin Pulls</h2>
<p>AOP 是 TLS 的用戶端憑證驗證，要求 Cloudflare 用 https 回源，所以先在主控台的 SSL/TLS → Configure 把加密模式從 Automatic 改成 Full（完整：回源用 https，不驗證源站憑證，接受自簽的）。Full (strict) 會驗證源站憑證，自簽的會失敗，要換成 Cloudflare 的 Origin CA 憑證或公開 CA 簽的憑證（依 Cloudflare 文件，沒有實跑）。改完之後回源改到 443，nginx 記到的驗證結果是 <code>NONE</code>（沒有出示用戶端憑證）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">172.71.158.118 cfip=&lt;管理者的公開 IP&gt; host=verify-origin.&lt;網域&gt; ssl_client_verify=NONE &#34;GET /?aop=before HTTP/1.1&#34; 200</span></span></code></pre></div><p>查詢字串 <code>?aop=before</code> 只是用來在紀錄裡分辨這一批請求；在 zsh 裡網址要加引號，否則 <code>?</code> 被當成萬用字元，zsh 回 <code>no matches found</code>、curl 沒有執行。接著在 SSL/TLS → Origin Server → Authenticated Origin Pulls 打開 Global（全域：使用 Cloudflare 共用的用戶端憑證）的開關，再連：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">verify-origin https from 172.69.23.187 client_verify=SUCCESS
</span></span><span class="line"><span class="ln">2</span><span class="cl">verify-origin https from 104.22.20.233 client_verify=SUCCESS</span></span></code></pre></div><p>打開開關之後再連的那一批請求都出示了憑證，驗證成功（開關生效要多久沒有量）。再把 nginx 的 <code>ssl_verify_client optional;</code> 改成 <code>on;</code>（沒有有效用戶端憑證的連線一律拒絕），<code>systemctl reload nginx</code>，各連一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -sk https://127.0.0.1/ <span class="p">|</span> grep -o <span class="s1">&#39;&lt;title&gt;[^&lt;]*&#39;</span>                            <span class="c1"># 在源站上直連，沒有用戶端憑證</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -s -w <span class="s1">&#39; [%{http_code}]\n&#39;</span> <span class="s1">&#39;https://verify-origin.&lt;網域&gt;/?aop=global-on&#39;</span>       <span class="c1"># 在本機，經由 Cloudflare</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">&lt;title&gt;400 No required SSL certificate was sent
</span></span><span class="line"><span class="ln">2</span><span class="cl">verify-origin https from 172.69.23.187 client_verify=SUCCESS
</span></span><span class="line"><span class="ln">3</span><span class="cl"> [200]</span></span></code></pre></div><p>沒有用戶端憑證的直連被 nginx 回 400，經由 Cloudflare 的照常。從源站自己直連是因為 NSG 擋住了所有非 Cloudflare 的外部來源，外面的直連在 nginx 之前就到不了；這一步量的是 NSG 被拿掉或設錯時，nginx 這一層還擋不擋得住。</p>
<p>Global 用的是所有 Cloudflare 客戶共用的同一張憑證。Cloudflare 的〈Protect your origin server〉寫明，共用憑證只證明請求來自 Cloudflare 的網路：別人用自己的 Cloudflare 帳號把一個網域指向這台源站、也打開 Global 時，那些請求出示的是同一張憑證，nginx 同樣驗證成功。要分辨請求是否由自己的網域送來，就在 Zone-level（區域層級）或 Per-hostname（每個主機名稱）上傳自己的用戶端憑證，nginx 的 <code>ssl_client_certificate</code> 改成簽那張憑證的 CA。這兩種沒有實跑，「別人的帳號開 Global 也會通過」是依文件的說法，沒有用第二個帳號實測。</p>
<h2 id="cloudflare-worker-送出的請求出站位址只放行-cloudflare-的-nsg-與自己寫的-cf-connecting-ip">Cloudflare Worker 送出的請求：出站位址、只放行 Cloudflare 的 NSG 與自己寫的 CF-Connecting-IP</h2>
<p>Cloudflare Workers 是跑在 Cloudflare 邊緣的程式，它對外送出的請求從 Cloudflare 的位址出去。任何人都能用免費帳號部署一個 Worker，對任意主機名稱送請求，所以它可以代表「經由別的 Cloudflare 帳號送來的請求」；這次部署在自己的帳號，換成別的帳號時的結果寫在本節最後。Worker 要連一個主機名稱，這次沒有試直接 fetch IP 位址，用的是 <code>nip.io</code> 這個公開的 DNS 服務：<code>&lt;源站 IP 以連字號分隔&gt;.nip.io</code> 解析到那個 IP。Worker 做三件事：問 <code>api.ipify.org</code> 自己出站的位址、連源站、帶著偽造的 <code>CF-Connecting-IP</code> 與 <code>X-Forwarded-For</code> 再連一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// index.js
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="p">{};</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kr">const</span> <span class="nx">t</span> <span class="o">=</span> <span class="kr">async</span> <span class="p">(</span><span class="nx">label</span><span class="p">,</span> <span class="nx">url</span><span class="p">,</span> <span class="nx">init</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="k">try</span> <span class="p">{</span> <span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">init</span><span class="p">);</span> <span class="nx">out</span><span class="p">[</span><span class="nx">label</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="nx">r</span><span class="p">.</span><span class="nx">status</span><span class="p">,</span> <span class="nx">body</span><span class="o">:</span> <span class="p">(</span><span class="kr">await</span> <span class="nx">r</span><span class="p">.</span><span class="nx">text</span><span class="p">()).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">200</span><span class="p">)</span> <span class="p">};</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span> <span class="nx">out</span><span class="p">[</span><span class="nx">label</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">error</span><span class="o">:</span> <span class="nb">String</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">};</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">};</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kr">await</span> <span class="nx">t</span><span class="p">(</span><span class="s2">&#34;egress_ipify&#34;</span><span class="p">,</span> <span class="s2">&#34;https://api.ipify.org?format=json&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kr">await</span> <span class="nx">t</span><span class="p">(</span><span class="s2">&#34;origin_http&#34;</span><span class="p">,</span> <span class="s2">&#34;http://&lt;源站 IP 以連字號分隔&gt;.nip.io/&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kr">await</span> <span class="nx">t</span><span class="p">(</span><span class="s2">&#34;origin_http_spoof&#34;</span><span class="p">,</span> <span class="s2">&#34;http://&lt;源站 IP 以連字號分隔&gt;.nip.io/spoof&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="p">{</span> <span class="nx">headers</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;CF-Connecting-IP&#34;</span><span class="o">:</span> <span class="s2">&#34;198.51.100.77&#34;</span><span class="p">,</span> <span class="s2">&#34;X-Forwarded-For&#34;</span><span class="o">:</span> <span class="s2">&#34;198.51.100.77&#34;</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="kc">null</span><span class="p">,</span> <span class="mi">2</span><span class="p">),</span> <span class="p">{</span> <span class="nx">headers</span><span class="o">:</span> <span class="p">{</span> <span class="s2">&#34;content-type&#34;</span><span class="o">:</span> <span class="s2">&#34;application/json&#34;</span> <span class="p">}</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">};</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># wrangler.toml</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">name</span> <span class="p">=</span> <span class="s2">&#34;verify-origin-probe&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">main</span> <span class="p">=</span> <span class="s2">&#34;index.js&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">compatibility_date</span> <span class="p">=</span> <span class="s2">&#34;2026-10-01&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">workers_dev</span> <span class="p">=</span> <span class="kc">true</span></span></span></code></pre></div><p><code>compatibility_date</code> 決定 Worker 執行環境的行為版本，填部署當天或之前的日期；<code>workers_dev = true</code> 讓 Worker 部署到 <code>*.workers.dev</code> 網址，不用綁自己的網域。<code>198.51.100.77</code> 是文件保留位址，代表攻擊者想讓源站相信的訪客位址。部署並呼叫：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">npx wrangler deploy
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -s https://verify-origin-probe.&lt;帳號的 workers.dev 子網域&gt;.workers.dev/</span></span></code></pre></div><p>剛註冊 workers.dev 子網域之後的第一次呼叫，curl 回 <code>sslv3 alert handshake failure</code>：新子網域的憑證還沒發好；幾分鐘後再呼叫就正常了。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">&#34;egress_ipify&#34;: { &#34;status&#34;: 200, &#34;body&#34;: &#34;{\&#34;ip\&#34;:\&#34;2a06:98c0:3600::103\&#34;}&#34; },
</span></span><span class="line"><span class="ln">2</span><span class="cl">&#34;origin_http&#34;:  { &#34;status&#34;: 200, &#34;body&#34;: &#34;verify-origin http from 104.22.17.80\n&#34; },
</span></span><span class="line"><span class="ln">3</span><span class="cl">&#34;origin_http_spoof&#34;: { &#34;status&#34;: 200, &#34;body&#34;: &#34;verify-origin http from 104.22.17.80\n&#34; }</span></span></code></pre></div><p>源站的存取紀錄：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">104.22.17.80 cfip=- host=&lt;源站 IP 以連字號分隔&gt;.nip.io ssl_client_verify=- &#34;GET / HTTP/1.1&#34; 200
</span></span><span class="line"><span class="ln">2</span><span class="cl">104.22.17.80 cfip=198.51.100.77 host=&lt;源站 IP 以連字號分隔&gt;.nip.io ssl_client_verify=- &#34;GET /spoof HTTP/1.1&#34; 200</span></span></code></pre></div><p>Worker 連 <code>api.ipify.org</code> 走的是 IPv6，出站位址 <code>2a06:98c0:3600::103</code> 落在 Cloudflare 公布的 <code>2a06:98c0::/29</code>；連只有 IPv4 的源站時用的是 <code>104.22.17.80</code>，落在 <code>104.16.0.0/13</code>。連源站的兩次請求都通過了只放行 Cloudflare IPv4 的 NSG。第二筆的 <code>cfip=198.51.100.77</code>：Worker 自己寫的 <code>CF-Connecting-IP</code> 原封不動送到源站，而連線的來源是 Cloudflare 的位址。</p>
<p>所以「只放行 Cloudflare 的位址之後，就能信任 <code>CF-Connecting-IP</code>」這個前提不成立：nginx 以 <code>set_real_ip_from &lt;Cloudflare 的位址段&gt;</code> 加 <code>real_ip_header CF-Connecting-IP</code> 取訪客位址時，這一筆會被記成 <code>198.51.100.77</code>，依訪客位址做的存取紀錄、速率限制與封鎖都被騙過。擋下這一類要靠回源驗證：Zone-level 或 Per-hostname 的 AOP（Worker 對 http 的源站連線不帶用戶端憑證，對 https 的是否帶共用憑證沒有測），或改用 Tunnel、源站不收任何外部的入站連線。這次的 Worker 連的是 http 的 80；源站的 443 用自簽憑證，依推論 Worker 的 fetch 會驗證憑證而失敗，所以沒有測 Worker 對 443 的結果。</p>
<p>這次的 Worker 在自己的帳號裡，但它連的 <code>nip.io</code> 不屬於任何 Cloudflare 的網域，請求不經過自己網域的任何設定，依這條路徑推論，換成任何一個免費帳號結果都一樣；沒有用第二個帳號實測。測完刪掉 Worker：<code>npx wrangler delete --name verify-origin-probe --force</code>。</p>
<h2 id="cloudflare-tunnel源站不開入站以及出站要放行的連接埠">Cloudflare Tunnel：源站不開入站，以及出站要放行的連接埠</h2>
<p>Tunnel 由源站上的 cloudflared 主動往外連到 Cloudflare，請求經由這條連線送進來。quick tunnel 不需要帳號：cloudflared 向 Cloudflare 要一個隨機的 <code>*.trycloudflare.com</code> 網址，把它接到本機的一個服務。後端只聽本機位址：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">dnf -y install https://github.com/cloudflare/cloudflared/releases/download/2026.10.3/cloudflared-linux-aarch64.rpm
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">cat &gt; /etc/nginx/conf.d/verify2-tunnel.conf <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">server {
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">    listen 127.0.0.1:8080;
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">    access_log /var/log/nginx/verify.log verify;
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">    location / { default_type text/plain; return 200 &#34;verify-origin tunnel backend, remote_addr=$remote_addr cfip=$http_cf_connecting_ip\n&#34;; }
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">nginx -t <span class="o">&amp;&amp;</span> systemctl reload nginx
</span></span><span class="line"><span class="ln">10</span><span class="cl">nohup cloudflared tunnel --no-autoupdate --url http://127.0.0.1:8080 &gt;/var/tmp/verify-tunnel.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">sleep <span class="m">10</span>                                    <span class="c1"># 等 cloudflared 拿到網址、寫進 log</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">grep -oE <span class="s1">&#39;https://[a-z0-9-]+\.trycloudflare\.com&#39;</span> /var/tmp/verify-tunnel.log
</span></span><span class="line"><span class="ln">13</span><span class="cl">grep -E <span class="s1">&#39;Initial protocol|Registered tunnel connection&#39;</span> /var/tmp/verify-tunnel.log</span></span></code></pre></div><p><code>2026.10.3</code> 是 2026-10-21 發佈的版本，x86 主機換成 <code>cloudflared-linux-x86_64.rpm</code>，照抄之前到 GitHub 的 releases 頁看最新版。設定檔叫 <code>verify2-tunnel.conf</code> 而不是 <code>verify-tunnel.conf</code>：<code>conf.d</code> 依檔名排序載入，<code>-</code>（0x2D）排在 <code>.</code>（0x2E）前面，<code>verify-tunnel.conf</code> 會先於 <code>verify.conf</code> 載入，它用到的 <code>log_format verify</code> 還沒定義，<code>nginx -t</code> 失敗。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">INF Initial protocol quic
</span></span><span class="line"><span class="ln">2</span><span class="cl">INF Registered tunnel connection connIndex=0 ... ip=198.41.200.13 location=sin22 protocol=quic</span></span></code></pre></div><p>從本機連印出來的網址，三次都回 200，後端記到的 <code>remote_addr</code> 是 <code>127.0.0.1</code>（cloudflared 從本機連進來），<code>cfip</code> 是本機的公開 IP。後端只聽 <code>127.0.0.1:8080</code>，NSG 與主機防火牆都沒有放行 8080：Tunnel 的請求不經過任何入站規則。cloudflared 預設用 QUIC（UDP）連 Cloudflare 的邊緣，連到 <code>198.41.200.13</code>，位置是新加坡（<code>sin22</code>）。</p>
<p>接著在源站上建一張自己的 nftables 表擋出站，分三步收緊，每一步都要重新啟動 cloudflared、等它試完再量。<code>--no-autoupdate</code> 讓 cloudflared 不在測試中途自己更新並重啟。先擋 UDP 7844、協定照預設：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nft add table inet verify_tunnel
</span></span><span class="line"><span class="ln">2</span><span class="cl">nft add chain inet verify_tunnel output <span class="s1">&#39;{ type filter hook output priority filter; policy accept; }&#39;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">nft add rule inet verify_tunnel output udp dport <span class="m">7844</span> drop
</span></span><span class="line"><span class="ln">4</span><span class="cl">pkill -x cloudflared<span class="p">;</span> sleep 1<span class="p">;</span> nohup cloudflared tunnel --no-autoupdate --url http://127.0.0.1:8080 &gt;/var/tmp/verify-tunnel.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">sleep 10<span class="p">;</span> grep -oE <span class="s1">&#39;https://[a-z0-9-]+\.trycloudflare\.com&#39;</span> /var/tmp/verify-tunnel.log
</span></span><span class="line"><span class="ln">6</span><span class="cl">grep -c <span class="s1">&#39;Failed to dial a quic connection&#39;</span> /var/tmp/verify-tunnel.log   <span class="c1"># 隔幾分鐘再看一次，數字一直增加</span></span></span></code></pre></div><p>從本機連新的網址。實測等了 7 分鐘，失敗計數到 28 次，網址一直回 530。實測用的是一支包著這些指令的腳本；這三個區塊裡的 <code>grep</code> 依那次 log 的訊息原文寫成，沒有照這個形式逐條跑過。接著改成 http2、UDP 仍擋著：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">pkill -x cloudflared<span class="p">;</span> sleep 1<span class="p">;</span> nohup cloudflared tunnel --no-autoupdate --protocol http2 --url http://127.0.0.1:8080 &gt;/var/tmp/verify-tunnel.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sleep 10<span class="p">;</span> grep -oE <span class="s1">&#39;https://[a-z0-9-]+\.trycloudflare\.com&#39;</span> /var/tmp/verify-tunnel.log
</span></span><span class="line"><span class="ln">3</span><span class="cl">grep -E <span class="s1">&#39;Registered tunnel connection&#39;</span> /var/tmp/verify-tunnel.log</span></span></code></pre></div><p>最後把 TCP 7844 也擋掉，協定仍是 http2：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">nft add rule inet verify_tunnel output tcp dport <span class="m">7844</span> drop
</span></span><span class="line"><span class="ln">2</span><span class="cl">pkill -x cloudflared<span class="p">;</span> sleep 1<span class="p">;</span> nohup cloudflared tunnel --no-autoupdate --protocol http2 --url http://127.0.0.1:8080 &gt;/var/tmp/verify-tunnel.log 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">&amp;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">sleep 30<span class="p">;</span> grep -oE <span class="s1">&#39;https://[a-z0-9-]+\.trycloudflare\.com&#39;</span> /var/tmp/verify-tunnel.log<span class="p">;</span> grep -E <span class="s1">&#39;i/o timeout&#39;</span> /var/tmp/verify-tunnel.log <span class="p">|</span> tail -1</span></span></code></pre></div><p>quick tunnel 每次啟動都拿到不同的網址，每一步都從本機連那一步印出的網址。</p>
<table>
  <thead>
      <tr>
          <th>出站限制</th>
          <th>cloudflared 的啟動參數</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>沒有限制</td>
          <td>預設</td>
          <td>QUIC 連上，網址回 200</td>
      </tr>
      <tr>
          <td>擋 UDP 7844</td>
          <td>預設</td>
          <td>QUIC 一直失敗（<code>sendmsg: operation not permitted</code>），等了 7 分鐘、失敗 28 次，沒有改用 http2；網址回 530（Cloudflare 錯誤 1033）</td>
      </tr>
      <tr>
          <td>擋 UDP 7844</td>
          <td><code>--protocol http2</code></td>
          <td>經由 TCP 連到 <code>198.41.192.37:7844</code>，網址回 200</td>
      </tr>
      <tr>
          <td>擋 UDP 與 TCP 7844</td>
          <td><code>--protocol http2</code></td>
          <td><code>dial tcp …:7844: i/o timeout</code>，沒有連線；網址回 530</td>
      </tr>
  </tbody>
</table>
<p>擋掉 UDP 時，cloudflared 的預先檢查印出 <code>suggested_protocol=http2</code>，但 quick tunnel 的設定是 <code>protocol:quic</code>，它照樣只重試 QUIC。所以出站只放行清單的主機上，cloudflared 要的是 7844：UDP 給 QUIC，TCP 給 http2；只開 TCP 時要明確指定 <code>--protocol http2</code>（或在設定檔寫 <code>protocol: http2</code>）。具名的 tunnel（綁在帳號上、由主控台或設定檔管理）的預設協定是否同樣不自動改用 http2，沒有測。兩個 7844 都擋時，quick tunnel 照樣拿到網址；只擋 UDP 的那兩次，<code>ss -tanp | grep cloudflared</code> 列得出取網址那一步的連線是 <code>104.16.230.132:443</code>，所以取網址走 443，後續的連線註冊才走 7844。</p>
<p>這一節的限制寫在自建的 nftables 表上，不經過 firewalld，測完 <code>nft delete table inet verify_tunnel</code> 拿掉。</p>
<h2 id="清理與-cloudflare-設定的還原">清理與 Cloudflare 設定的還原</h2>
<p>Cloudflare 那一側照建立的反方向還原，每一步看主控台的頁面或查詢結果確認：</p>
<ol>
<li><strong>Worker</strong>：<code>npx wrangler delete --name verify-origin-probe --force</code>，回 <code>Successfully deleted verify-origin-probe</code>。帳號的 workers.dev 子網域留著，它沒有費用，是否刪除由帳號擁有者決定。</li>
<li><strong>AOP</strong>：Authenticated Origin Pulls 的 Global 開關關掉。</li>
<li><strong>SSL/TLS 模式</strong>：改回 Automatic SSL/TLS。改回之後頁面顯示「Current encryption mode: Full」：自動模式沿用最後一次的模式，要等下一次自動掃描（頁面寫著下次掃描的日期）才重新判斷，原本實際套用的 Flexible 沒有立刻回來。改回之前網域上沒有其他經過代理的網站紀錄時，這個差別不影響任何流量。</li>
<li><strong>DNS 紀錄</strong>：刪掉 <code>verify-origin</code> 那一筆。<code>dig @&lt;網域的權威 nameserver&gt; verify-origin.&lt;網域&gt; A</code> 回 <code>NXDOMAIN</code>，網域原有的紀錄還在。</li>
</ol>
<p>Oracle Cloud 那一側，主機要先終止，NSG、子網路、security list、route table、internet gateway 才刪得掉，順序跟建立相反：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">oci compute instance terminate --instance-id <span class="s2">&#34;</span><span class="nv">$INST</span><span class="s2">&#34;</span> --preserve-boot-volume <span class="nb">false</span> --force
</span></span><span class="line"><span class="ln">2</span><span class="cl">oci compute instance get --instance-id <span class="s2">&#34;</span><span class="nv">$INST</span><span class="s2">&#34;</span> --query <span class="s1">&#39;data.&#34;lifecycle-state&#34;&#39;</span> --raw-output   <span class="c1"># 重複查，直到回 TERMINATED</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">oci network nsg delete --nsg-id <span class="s2">&#34;</span><span class="nv">$NSG</span><span class="s2">&#34;</span> --force --wait-for-state TERMINATED
</span></span><span class="line"><span class="ln">4</span><span class="cl">oci network subnet delete --subnet-id <span class="s2">&#34;</span><span class="nv">$SNP</span><span class="s2">&#34;</span> --force --wait-for-state TERMINATED
</span></span><span class="line"><span class="ln">5</span><span class="cl">oci network security-list delete --security-list-id <span class="s2">&#34;</span><span class="nv">$SLP</span><span class="s2">&#34;</span> --force --wait-for-state TERMINATED
</span></span><span class="line"><span class="ln">6</span><span class="cl">oci network route-table delete --rt-id <span class="s2">&#34;</span><span class="nv">$RTP</span><span class="s2">&#34;</span> --force --wait-for-state TERMINATED
</span></span><span class="line"><span class="ln">7</span><span class="cl">oci network internet-gateway delete --ig-id <span class="s2">&#34;</span><span class="nv">$IGW</span><span class="s2">&#34;</span> --force --wait-for-state TERMINATED</span></span></code></pre></div><p>主機還在時刪其他資源，各自回的錯誤指出是誰還在引用它：NSG 回 <code>PreconditionFailed</code>（<code>cannot be deleted since it still has vnics attached to it</code>），子網路回 <code>Conflict</code>（<code>references the VNIC</code>），security list 與 route table 回 <code>IncorrectState</code>（<code>associated with … that is in use</code>），internet gateway 回 <code>Conflict</code>（<code>The Route Table … references the Internet Gateway</code>）。這次第一次的終止指令寫在腳本裡、帶 <code>--wait-for-state TERMINATED</code> 且輸出被導到 <code>/dev/null</code>，執行之後主機還是 RUNNING，後面每一步都回上面的錯誤。原因推定是 <code>terminate</code> 的 <code>--wait-for-state</code> 只收 work request 的狀態（<code>ACCEPTED</code>、<code>IN_PROGRESS</code>、<code>FAILED</code>、<code>SUCCEEDED</code>），不收主機的 <code>TERMINATED</code>：2026-10-27 用同一個 CLI 版本（3.95.1）同樣寫成 <code>TERMINATED</code> 時，CLI 印 <code>Invalid value for '--wait-for-state': invalid choice: TERMINATED.</code>、以 2 結束，請求沒有送出（實跑）；第一次清理時的 stderr 沒有留下，所以那一次的原因是從這次重現推回去的。那支腳本沒有檢查這一條的結束碼，照樣往下跑。要等它刪完，寫成 <code>--wait-for-state SUCCEEDED --wait-for-state FAILED</code>（同一天實跑，66 秒 <code>SUCCEEDED</code>，見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/suspend-resume-drill/" data-link-title="Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序" data-link-desc="要讓 Oracle Cloud 上一台放在 Cloudflare 後面的主機停機一段時間再恢復上線、想知道停止之後公開 IP 留不留、停止期間 A1 與開機磁碟怎麼計費、Cloudflare 回什麼錯誤、恢復時每一步要多久，或服務要收掉時 DNS 紀錄與主機的刪除順序時，查實際跑過的指令、輸出與量到的時間">Oracle Cloud 主機的暫停與恢復上線實測紀錄：暫停前的基準、收窄入口、停止主機、停止期間的計費與 Cloudflare 的回應、恢復上線的每一步與耗時，以及收尾時 DNS 紀錄與主機的刪除順序</a>〈收尾：DNS 紀錄、主機與網路資源的刪除順序〉）。單獨再下一次不帶等待的終止指令，回應裡帶 <code>opc-work-request-id</code>，主機進入 TERMINATING。<code>--preserve-boot-volume false</code> 讓開機磁碟一起刪掉，免費額度的區塊儲存才會釋放。刪完之後每一個 <code>get</code> 都回 <code>NotAuthorizedOrNotFound</code>，主機、子網路與 Cloudflare 上的測試設定都已刪除或還原。</p>
<h2 id="nsg回源位址aopworker-與-quick-tunnel-的量測結果">NSG、回源位址、AOP、Worker 與 quick tunnel 的量測結果</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>NSG 的規則數</td>
          <td>每次 API 呼叫最多 25 條；一個 NSG 最多 120 條（入站加出站），超過時整批被拒</td>
      </tr>
      <tr>
          <td>從 Cloudflare 範圍外直連源站</td>
          <td>NSG 只放行 Cloudflare 的 IPv4 時逾時</td>
      </tr>
      <tr>
          <td>回源的來源位址</td>
          <td>37 筆、12 個位址，全部在公布的 IPv4 清單裡；Flexible 模式回源走 http 的 80</td>
      </tr>
      <tr>
          <td>AOP 的 Global</td>
          <td>開啟後回源出示憑證、驗證 <code>SUCCESS</code>；nginx <code>ssl_verify_client on</code> 時沒有憑證的直連回 400；共用憑證只證明請求來自 Cloudflare 的網路（依文件）</td>
      </tr>
      <tr>
          <td>Worker（自己的帳號，連不屬於 Cloudflare 網域的主機名稱）</td>
          <td>出站位址在公布的清單裡，穿過只放行 Cloudflare 的 NSG；它寫的 <code>CF-Connecting-IP</code> 原樣送到源站；換成別的帳號依路徑推論相同</td>
      </tr>
      <tr>
          <td>quick tunnel</td>
          <td>後端只聽 127.0.0.1、沒有入站放行也連得到；預設 QUIC（UDP 7844），擋掉 UDP 時不自動改用 http2；<code>--protocol http2</code> 用 TCP 7844</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/</guid><description>&lt;p>這篇整理網站放在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN&lt;/a>（內容遞送網路，content delivery network：代替源站接收使用者連線、在邊緣做快取與過濾的服務，例如 Cloudflare）後面時，源站主機的防火牆要限制什麼、限制放在哪裡：請求避開 CDN 直接連到源站的成因（源站位址從 DNS 歷史紀錄、憑證透明度紀錄與其他記錄外洩）、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證時各自擋得住什麼、只放行 CDN 位址的規則什麼時候套上去、管理通道與同一台主機上不經 CDN 的服務怎麼處理、Docker 發佈的連接埠為什麼不受 &lt;code>ufw&lt;/code> 與 &lt;code>firewalld&lt;/code> 的一般規則約束、Podman 與 rootless 模式的容器在這一點上的差異，以及不發佈、只發佈在本機位址、&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件">&lt;code>DOCKER-USER&lt;/code>&lt;/a> 鏈、firewalld 的 &lt;code>StrictForwardPorts&lt;/code>、掛在位址轉換之前的 nftables 表、雲端防火牆與 Cloudflare Tunnel 這幾種做法、出站的 DNS 只准送往指定解析器擋得住與擋不住的部分，以及地理封鎖放在 CDN 邊緣的理由。&lt;/p>
&lt;p>機制以 Linux 主機、Cloudflare 與以 root 執行的 Docker（預設的 iptables 後端；compose 發佈連接埠的方式與 &lt;code>docker run -p&lt;/code> 相同）說明。其他 CDN 對應的是它公佈的、連回源站時使用的位址清單與它的回源驗證功能。Podman 與 rootless 模式的 Docker 轉送連接埠的方式不同，判斷的依據相同（容器執行環境有沒有在主機防火牆之前改寫封包的目的地），各自量到的結果見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉裡 Docker 以外的容器執行方式那一段；Kubernetes 的 NodePort 沒有驗證。&lt;/p>
&lt;p>指令與輸出逐步記在下列實測紀錄，下文寫「實測」的地方都出自它們：&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a> 在 Oracle Linux 10 的 firewalld 2.4.3 與 Docker 29.9.2 上，用一個位址落在 Cloudflare 範圍內、一個落在範圍外的兩個測試來源，量了每一種限制寫法擋不擋得住，以及規則在 firewalld 重新載入、dockerd 重啟與重開機之後還在不在；&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a> 在 Ubuntu 的 ufw 上量同一組問題；&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址&lt;/a> 換成其他容器執行方式；&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠&lt;/a> 接上實際的 Cloudflare 帳號，讓 Cloudflare 回源到一台 Oracle Cloud 主機。管理員登入主機那條通道的設計另見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口&lt;/a>，請求從 DNS 到應用經過哪幾層見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用&lt;/a>，CDN 本身的分發責任見 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源（CDN / Origin Protection）&lt;/a>（那一篇的 origin protection 指的是快取未命中時保護源站的負載，跟這篇的安全限制是兩件事）。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理網站放在 <a href="/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN</a>（內容遞送網路，content delivery network：代替源站接收使用者連線、在邊緣做快取與過濾的服務，例如 Cloudflare）後面時，源站主機的防火牆要限制什麼、限制放在哪裡：請求避開 CDN 直接連到源站的成因（源站位址從 DNS 歷史紀錄、憑證透明度紀錄與其他記錄外洩）、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證時各自擋得住什麼、只放行 CDN 位址的規則什麼時候套上去、管理通道與同一台主機上不經 CDN 的服務怎麼處理、Docker 發佈的連接埠為什麼不受 <code>ufw</code> 與 <code>firewalld</code> 的一般規則約束、Podman 與 rootless 模式的容器在這一點上的差異，以及不發佈、只發佈在本機位址、<a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a> 鏈、firewalld 的 <code>StrictForwardPorts</code>、掛在位址轉換之前的 nftables 表、雲端防火牆與 Cloudflare Tunnel 這幾種做法、出站的 DNS 只准送往指定解析器擋得住與擋不住的部分，以及地理封鎖放在 CDN 邊緣的理由。</p>
<p>機制以 Linux 主機、Cloudflare 與以 root 執行的 Docker（預設的 iptables 後端；compose 發佈連接埠的方式與 <code>docker run -p</code> 相同）說明。其他 CDN 對應的是它公佈的、連回源站時使用的位址清單與它的回源驗證功能。Podman 與 rootless 模式的 Docker 轉送連接埠的方式不同，判斷的依據相同（容器執行環境有沒有在主機防火牆之前改寫封包的目的地），各自量到的結果見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉裡 Docker 以外的容器執行方式那一段；Kubernetes 的 NodePort 沒有驗證。</p>
<p>指令與輸出逐步記在下列實測紀錄，下文寫「實測」的地方都出自它們：<a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a> 在 Oracle Linux 10 的 firewalld 2.4.3 與 Docker 29.9.2 上，用一個位址落在 Cloudflare 範圍內、一個落在範圍外的兩個測試來源，量了每一種限制寫法擋不擋得住，以及規則在 firewalld 重新載入、dockerd 重啟與重開機之後還在不在；<a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 在 Ubuntu 的 ufw 上量同一組問題；<a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a> 換成其他容器執行方式；<a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 接上實際的 Cloudflare 帳號，讓 Cloudflare 回源到一台 Oracle Cloud 主機。管理員登入主機那條通道的設計另見 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>，請求從 DNS 到應用經過哪幾層見 <a href="/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用</a>，CDN 本身的分發責任見 <a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源（CDN / Origin Protection）</a>（那一篇的 origin protection 指的是快取未命中時保護源站的負載，跟這篇的安全限制是兩件事）。</p>
<h2 id="主機前面有-cdn-時連到源站的路徑經由-cdn直連公開-ip-與管理通道">主機前面有 CDN 時連到源站的路徑：經由 CDN、直連公開 IP 與管理通道</h2>
<p>源站（origin server）是實際跑網站程式的主機，CDN 收到使用者的請求之後，從自己的機房連回源站取回應，這個動作叫回源。網域的 DNS 記錄設成經由 CDN 代理時，使用者解析網域拿到的是 CDN 的位址，請求先到 CDN，CDN 再用它自己的位址連到源站。這時連得到源站的路徑有三條：</p>
<ul>
<li><strong>經由 CDN 回源</strong>：使用者連 CDN，CDN 連源站的 80 或 443。源站看到的來源位址是 CDN 機房的位址，使用者真正的位址放在 CDN 加上的 HTTP 標頭裡（Cloudflare 是 <code>CF-Connecting-IP</code>）。</li>
<li><strong>直連源站的公開 IP</strong>：知道源站位址的人跳過 CDN，直接對源站的 80、443 或其他對外的連接埠送請求。CDN 上設定的 <a href="/blog/backend/knowledge-cards/waf/" data-link-title="WAF" data-link-desc="說明 Web Application Firewall 如何在入口層過濾常見攻擊與濫用">WAF</a>（Web 應用程式防火牆：依規則檢查 HTTP 請求內容並攔下可疑請求的元件）規則、速率限制與地理封鎖都在 CDN 那一端執行，這條路徑上沒有任何一條會作用。</li>
<li><strong>管理通道</strong>：管理員登入主機的 SSH，或經由雲端業者的代理（Oracle Cloud 的 Bastion、GCP 的 IAP）。這條路徑跟網站流量無關，CDN 也不經手。</li>
</ul>
<p>CDN 帶來的保護只對經由 CDN 回源那條路徑成立。一個漏洞公開之後，掃描程式會對網際網路上的主機送出探測請求；源站主機的防火牆對全網開放 80 與 443 時，這些請求可以走直連公開 IP 那條路徑送到源站，CDN 上為這個漏洞加的每一條規則都碰不到它們。直連那條路徑開著還有一個影響：直連的人可以自己在請求裡寫一個假的 <code>CF-Connecting-IP</code>，源站的存取記錄、速率限制與依國家的判斷照這個標頭做時都會被騙。反向代理的設定裡只信任來自 CDN 位址段的這個標頭（nginx 是 <code>set_real_ip_from</code> 加 <code>real_ip_header</code>），擋下的是不經過 CDN 的直連；從 CDN 位址送來的請求不一定是 CDN 替這個網域轉送的：實測用一個 Cloudflare Worker（任何帳號都能部署、從 Cloudflare 的位址對外連線的程式）連到只放行 Cloudflare 位址的源站，Worker 自己寫的 <code>CF-Connecting-IP</code> 原封不動送到了源站。這個標頭要能信任，源站還要能確認請求是 CDN 替自己的網域送來的，做法在〈限制放在哪一層：雲端防火牆、主機防火牆與 CDN 端的驗證〉。</p>
<h2 id="讓請求避開-cdn-的成因源站位址從哪裡外洩">讓請求避開 CDN 的成因：源站位址從哪裡外洩</h2>
<p>直連源站要先知道源站的位址，而這個位址的來源比網域的 A 記錄多。Cloudflare 的〈Protect your origin server〉列出的外洩來源有：接上 CDN 之前的 DNS 歷史紀錄（DNS 歷史查詢服務保留了網域過去解析到的位址，接上 CDN 之後舊記錄仍查得到）、沒有經由 CDN 代理的記錄（<code>mail</code>、<code>ftp</code> 這類子網域的 A 記錄，以及寫著 IP 的 SPF、TXT 記錄；SPF 是寫在 TXT 記錄裡、列出允許替這個網域寄信的主機的設定），以及跟網站同一台主機收發郵件時，退信會帶出主機的位址。Cloudflare 那一頁沒有列、但同樣公開的另一個來源是憑證透明度紀錄（certificate transparency log）：每一張公開簽發的 TLS 憑證都記在公開的日誌裡，源站曾經用自己的位址對外提供 HTTPS 時，掃描服務把憑證上的網域與當時回應的位址對起來。另外，網際網路上的掃描程式不必先知道網域：它們對整段 IP 位址逐一連 80 與 443，碰到回應的主機就送出探測請求，所以位址從未外洩的源站一樣會被掃到。</p>
<p>源站的位址因此要當成已經公開，防護要直接寫在源站能被連到的位置上：只有經由 CDN 的請求連得進來，直連公開 IP 那條路徑才會關上。Cloudflare 那一頁另外建議接上 CDN 之後更換源站的 IP，讓歷史紀錄裡的位址失效。</p>
<h2 id="限制放在哪一層雲端防火牆主機防火牆與-cdn-端的驗證">限制放在哪一層：雲端防火牆、主機防火牆與 CDN 端的驗證</h2>
<p>「只讓 CDN 連進來」可以寫在三個位置，它們擋的範圍與失效的方式不同：</p>
<ul>
<li><strong>雲端防火牆</strong>（Oracle Cloud 的 security list 與 NSG（network security group，套在網卡上的一組放行規則）、AWS 的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group</a>、GCP 的 VPC 防火牆規則，位置與合併方式見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈防火牆規則的位置與合併方式〉）：規則在業者的網路上執行，被擋的封包到不了主機，主機上跑什麼程式、程式怎麼改寫主機的防火牆都不影響它。要評估的是規則數：Cloudflare 在 2026-10-25 公佈的是 15 段 IPv4 與 7 段 IPv6，一條規則能放幾段來源、一組規則能放幾條，每一家業者的配額不同。Oracle Cloud 的 NSG 與 AWS 的 security group 一條規則只放一段來源，條數是「段數 × 要放行的連接埠數」：只放行 80 與 443 時 IPv4 是 30 條、加上 IPv6 是 44 條，實測 Oracle 的 NSG 上限是入站加出站合計 120 條，AWS 的 security group 文件寫的預設上限是入站 60 條、IPv4 與 IPv6 分開算，兩家都放得下；GCP 的 VPC 防火牆規則一條可以放 5000 段來源，IPv4 與 IPv6 各一條就放下了（GCP 與 AWS 依文件，沒有實測）。清單變動時也要有人改規則：一條規則只放一段時，每次更新是逐條增刪；AWS 可以改用自建的 prefix list，更新的是 list 的項目，security group 的規則不動，而 prefix list 被引用時以它設定的最大項數計入規則數（依文件，沒有實測）。</li>
<li><strong>主機防火牆</strong>（<code>firewalld</code>、<code>ufw</code>、<code>nftables</code>）：在主機的核心裡執行，可以用 ipset（核心裡的一個位址集合，一條規則比對整個集合）一次放進整份清單，也能寫出只對某個連接埠生效的規則。它管不到的是在它之前就改寫封包目的地的程式，Docker 是最常見的一個，機制見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。</li>
<li><strong>CDN 端的驗證</strong>：源站確認請求是 CDN 替這個網域送來的，而不只是來自 CDN 的位址。Cloudflare 的做法有兩種：Authenticated Origin Pulls 讓 Cloudflare 回源時出示用戶端憑證，源站的 TLS 設定只接受帶這張憑證的連線；Cloudflare Tunnel 由源站上的 <code>cloudflared</code> 主動往外連到 Cloudflare，請求經由這條往外建好的連線送進來（<a href="/blog/backend/knowledge-cards/outbound-tunnel/" data-link-title="Outbound Tunnel" data-link-desc="反向隧道把出站連線轉成可達入口、與傳統 port-forward 的責任倒轉">outbound tunnel</a> 的一種）。Authenticated Origin Pulls 用 Cloudflare 預設的共用憑證（主控台的 Global 開關）時，〈Protect your origin server〉寫明它只保證請求來自 Cloudflare 的網路；要分辨請求是否由自己的網域送來，要改用自己上傳的用戶端憑證（設在 Cloudflare 的 zone 層級，也就是一個網域的設定單位，或每個主機名稱各一張）。實測打開 Global 之後，Cloudflare 回源時出示憑證、nginx 驗證成功，nginx 設成必須有憑證時，沒有憑證的直連被回 400（nginx 的設定與輸出在 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 的〈SSL/TLS 模式「完整」與 Authenticated Origin Pulls〉）；自己上傳憑證的那兩種沒有實跑。</li>
</ul>
<p>雲端防火牆與主機防火牆比對的是來源位址，而 CDN 的位址由所有客戶共用。別人也可以用自己的 Cloudflare 帳號，把一個網域指向這台源站的位址，或部署一個 Worker 直接對源站送請求；這些請求同樣從 Cloudflare 的位址送來，通過只放行 CDN 位址的規則，而源站擁有者在自己帳號上設的 WAF 規則管不到別人帳號送來的請求。Worker 這一條實測過，用的是自己帳號的 Worker，連一個不屬於任何 Cloudflare 網域的主機名稱：Worker 出站的位址落在 Cloudflare 公布的清單裡，穿過了只放行 Cloudflare 位址的 Oracle Cloud NSG，它在請求裡寫的 <code>CF-Connecting-IP</code> 也原樣送到源站。下面兩條是推論，都沒有用第二個帳號實測：Worker 這條路徑不經過帳號裡任何網域的設定，所以換成別人的帳號結果相同；別人把自己帳號裡的網域指向這台源站時，回源請求同樣從共用的 Cloudflare 位址送來，所以同樣通過只放行位址的規則。把這一類稱為<strong>經由別的 CDN 帳號送來的請求</strong>：只放行位址的規則擋不住它；共用憑證的 Authenticated Origin Pulls 擋不住別人帳號裡同樣打開 Global 的網域（依文件），Worker 對 https 源站的請求會不會帶這張共用憑證沒有測；擋住整類要用自己上傳憑證的 Authenticated Origin Pulls 或 Tunnel。只放行位址的規則擋下的是不經過 CDN 的直連，漏洞公開後對整段位址的掃描就屬於這一類。自己上傳憑證的 Authenticated Origin Pulls 以外，還有幾種讓源站分辨請求屬於自己網域的做法，這次都沒有實跑：在自己的 zone 用 Cloudflare 的規則替回源請求加一個只有自己知道的標頭、源站驗證它；或用 Cloudflare Access 的 service token。三個位置可以疊加，例如雲端防火牆只放行 CDN 位址到 443，源站的 TLS 設定再驗證自己上傳的用戶端憑證，主機防火牆則管住雲端防火牆碰不到的出站流量與主機上的其他連接埠。用戶端憑證只在 TLS 上驗證，80 上的 http 請求不帶憑證：80 也放行時，依 <code>CF-Connecting-IP</code> 做判斷的設定只能放在驗證憑證的那一個 server 區塊，80 只做轉址到 https、不採信這個標頭；實測的 Worker 偽造標頭走的正是 80。</p>
<h2 id="只放行-cdn-位址的套用時機範圍與清單的維護">只放行 CDN 位址的套用時機、範圍與清單的維護</h2>
<p>只放行 CDN 位址的規則一套上去，不經過 CDN 的連線全部連不進來。規則的效果與失效的方式，依它在什麼時候套上、套在哪些連接埠而不同：</p>
<ul>
<li><strong>時機：網域的 DNS 記錄已經經由 CDN 代理，舊記錄的快取也過期了</strong>。還沒接上 CDN、或記錄設成只做 DNS 解析（Cloudflare 的 DNS only）時，使用者直接連源站，規則一套上，所有訪客都連不進網站：規則寫在雲端防火牆或丟棄封包的規則上時，瀏覽器等到連線逾時；寫在 firewalld 的 zone 上時，firewalld 以 ICMP 回拒，瀏覽器立即顯示連線失敗（兩種結果的分辨見 <a href="/blog/linux/dotfile/knowledge-cards/connection-refused-vs-timeout/" data-link-title="Connection Refused vs. Timeout（連線被拒與逾時）" data-link-desc="遠端連不上、要判斷是封包到不了還是服務拒絕、決定往網路層還是服務層除錯時回來讀">Connection Refused vs. Timeout（連線被拒與逾時）</a>）。網站回應的標頭帶著 CDN 的標記（Cloudflare 會加 <code>cf-ray</code>），只代表管理員自己這一次的請求經過了 CDN；其他訪客的解析器可能還快取著舊的 A 記錄（指向源站），換成 Cloudflare 的 nameserver 時，舊的 NS 記錄也有快取（2026-10-25 查 <code>.com</code> 上層伺服器回的 NS 記錄，TTL 是 172800 秒，也就是兩天；<code>dig +noall +authority &lt;網域&gt; @a.gtld-servers.net</code> 查得到自己網域的值）。所以套規則的時間點在舊記錄的快取全部過期之後：只改了 A 記錄時，等舊 A 記錄的 TTL；換了 nameserver、而舊的 DNS 業者還保留著這個網域的資料時，快取著舊 NS 的解析器在那份快取到期前一刻向舊業者查 A，又會拿到一份新的舊 A 記錄，所以要等舊 NS 的 TTL 加上舊 A 的 TTL；舊 NS 的 TTL 取上層伺服器回的值與舊業者自己回的值之中較大的那一個（解析器常採用後者）。舊業者已經刪掉這個網域時，等兩者之中較長的那一個就夠（依 DNS 快取的機制推論，沒有實測）。在那之前套上，快取著舊記錄的訪客連不進網站。</li>
<li><strong>接上 CDN 之前就能做的限制</strong>：管理通道改走白名單或代理、資料庫與內部服務的容器不發佈或只發佈在本機位址、關掉 80 與 443 以外對外開著的連接埠、出站 DNS 的限制，這幾項不依賴 DNS 記錄的狀態，可以先做。只放行 CDN 位址到 80 與 443 這一項，要等上一項的時機。</li>
<li><strong>範圍：管理通道不在這條規則裡</strong>。規則只套在網站的 80 與 443 上，SSH 走 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a> 裡的那一條（來源 IP 白名單、Bastion、IAP 或 VPN），兩者分開設定。把整台主機的入站都改成只放行 CDN 位址時，管理員自己的 SSH 會一起被擋在外面；Cloudflare 的 DNS 代理轉送的是網站的 HTTP 與 HTTPS，SSH 要經由 Cloudflare 時另外架 Tunnel。</li>
<li><strong>範圍：同一台主機上還有不經 CDN 的服務</strong>。例如網站經由 Cloudflare，而同一台主機的 443 也直接對外提供一個 API 子網域。防火牆只看位址與連接埠，分不出同一個連接埠上的兩個網域（那要看 TLS 的 SNI 或 HTTP 的 Host），所以對 443 套「只放行 CDN」，API 就斷線；不套，網站的直連路徑就開著，而 API 子網域的 A 記錄本身就公開了源站的位址。可行的分法有：API 也改經由 CDN 代理；API 改用另一個公開 IP 或另一個連接埠，各自套各自的規則；或在反向代理依網域分流，網站的網域只接受來自 CDN 位址的請求（這一種仍留著經由別的 CDN 帳號送來的請求）；或網站的網域用自己上傳的每個主機名稱一張的用戶端憑證，只在網站的 server 區塊驗證，API 的區塊不驗證。這幾種做法沒有實跑。</li>
</ul>
<p>規則寫在主機本身程式收連線的路徑上（firewalld 的 zone、ufw 的入站規則）時，寫成「放行 CDN 位址到 80 與 443」就夠：zone 的預設動作已經回拒未列出的連線（firewalld 的 <code>public</code> zone 是回拒，ufw 預設拒絕入站是丟棄），放行清單之外的來源自然被擋下。這個前提要先查：主機的預設 zone 不一定是 <code>public</code>（<code>firewall-cmd --get-default-zone</code>），有些發行版的預設 zone 本身就放行一段高連接埠，<code>firewall-cmd --list-all</code> 列得出它的 target 與開著的連接埠。zone 的預設回拒只作用在主機本身收連線的那條路徑上。Docker 發佈的連接埠走的是另一條路徑：封包的目的地先被改寫成容器，再由主機轉送過去，這條轉送路徑上沒有對應的預設拒絕，那裡的規則要寫成「來源不在 CDN 清單裡就丟棄」，寫法見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉的 <code>DOCKER-USER</code> 與 nftables 表。</p>
<p>CDN 經由 IPv6 回源的條件，是管理員在 CDN 的 DNS 設定裡替源站填了 IPv6 位址（Cloudflare 的 AAAA 紀錄、內容是源站的 IPv6）。訪客查得到的 AAAA 不代表這件事：實測只填了源站 IPv4 的 Proxied 紀錄，網域照樣解析出 Cloudflare 的 IPv6 位址，而回源全部經由 IPv4；填了源站 IPv6 時 Cloudflare 會不會改用 IPv6 回源沒有測（依 Cloudflare 的 DNS 文件推論會）。沒有替源站填 IPv6 時，主機的 IPv6 只會收到直連的請求，主機防火牆照預設回拒就好。填了的話，漏寫 IPv6 的後果依規則的寫法而不同：</p>
<ul>
<li><strong>放行式</strong>（只放行清單，其餘交給預設回拒）只寫了 IPv4 的清單：CDN 經由 IPv6 回源的連線被預設回拒擋下，網站對 IPv6 回源失效。</li>
<li><strong>丟棄式</strong>（來源不在清單裡就丟棄）的比對只寫了 IPv4 位址（nftables 的 <code>ip saddr</code>、iptables）：IPv6 的連線不受這條規則限制，經由 IPv6 直連源站的路徑照舊開著。</li>
</ul>
<p>兩種寫法都要另寫一份 IPv6 清單（Cloudflare 的 <code>ips-v6</code>）。Docker 發佈的連接埠的 IPv6 那一份走的路徑跟 IPv4 那一份不同，寫在〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。</p>
<p>CDN 的位址清單會變動。Cloudflare 的〈Cloudflare IP addresses〉寫明清單不常變，新的位址段會先加進公佈的清單再投入使用，沒有寫會另外通知。規則裡的清單沒有跟著更新時，Cloudflare 從新位址段回源的請求會被擋下，所以清單要定期從 <code>https://www.cloudflare.com/ips-v4</code> 與 <code>ips-v6</code>（或 Cloudflare 的 API）取回、跟規則比對。規則用位址集合（firewalld 的 ipset、nftables 的 set）寫時，更新只換集合的內容，規則本身不動，排程一支取回清單、換掉集合內容的程式就能維護；這個做法沒有實跑。<code>DOCKER-USER</code> 的自建鏈、ufw 的 after.rules 與 ufw 的 IPv6 入站放行都是一段一條的規則，更新時整段重新產生（after.rules 的產生與對帳見 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈讓規則開機時載入：寫進 /etc/ufw/after.rules〉）。不論哪一種載體，Cloudflare 這兩份清單的最後一行沒有換行字元，逐行讀取的迴圈會漏掉最後一段而不報錯，所以寫進去的條數要跟清單的段數對帳。</p>
<h2 id="docker-發佈的連接埠封包路徑與限制的寫法">Docker 發佈的連接埠：封包路徑與限制的寫法</h2>
<p>容器用 <code>docker run -p 8081:80</code> 發佈連接埠時，Docker 在 iptables 的 nat 表寫一條目的位址轉換規則（<a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT</a>，destination NAT：把封包的目的地改寫成另一個位址與連接埠），在封包剛進主機的 prerouting 階段，就把目的地從「主機的 8081」改成「容器的 80」。改寫之後，核心判定這個封包要轉送給另一個網路介面（容器網路在主機上的 bridge 介面：預設網路是 docker0，compose 建的自訂網路是 <code>br-</code> 開頭的介面，容器接在它後面），它走的是轉送（forward）路徑，不經過管主機本身入站連線的 input 路徑。</p>
<p>主機防火牆的一般規則寫在 input 路徑上，所以對發佈的連接埠沒有作用，<code>ufw status</code> 與 <code>firewall-cmd --list-all</code> 也都列不出這些連接埠（要用 <code>docker ps</code> 或 <code>ss -ltnp</code> 查）：</p>
<ul>
<li><strong>ufw</strong>：Docker 的〈Packet filtering and firewalls〉寫明，送往發佈連接埠的流量在經過 ufw 的規則之前就被轉走，ufw 的設定對它等於不存在。網路上常見的解法是在 Docker 的設定裡關掉它的 iptables 管理（<code>&quot;iptables&quot;: false</code>），同一頁寫明這樣做多半會讓容器的網路失效，發佈的連接埠也會變成區網內連得到。</li>
<li><strong>firewalld</strong>：firewalld 把網路介面分到 zone（一組介面共用一套入站規則，例如 <code>public</code>），zone 與 zone 之間轉送的流量由 policy 管（一條 policy 指定從哪些 zone 轉送到哪些 zone、帶一個優先序與預設動作）。Docker 啟動時建一個 target 為 ACCEPT 的 <code>docker</code> zone，把它的 bridge 介面放進去，再建一條 <code>docker-forwarding</code> policy，放行任何 zone 轉送到 <code>docker</code> zone 的流量。實測在 <code>public</code> zone 只開了 <code>ssh</code> 與 <code>dhcpv6-client</code>、沒有任何一條規則提到 8081 的主機上，位址落在 Cloudflare 範圍外的測試來源照樣拿到容器的回應。firewalld 的轉送鏈開頭還有一條 <code>ct status dnat accept</code>：在預設的 <code>StrictForwardPorts=no</code> 下，所有經過目的位址轉換的連線在任何 policy 之前就被接受，所以自己加一條「<code>public</code> 轉送到 <code>docker</code> 只放行 CDN 位址」的 policy 也擋不住，實測加上之後範圍外的來源仍然連得到。</li>
</ul>
<p>經過位址轉換的那一份，Docker 保留封包原本的來源位址，容器裡的程式看到的是真正的連線來源（實測容器的存取記錄裡，兩個經由 IPv4 連的測試來源位址都原樣出現），所以來源限制在技術上做得到。</p>
<p><code>-p 8080:80</code> 同時發佈在 IPv4 與 IPv6，而兩份走的路徑不同。容器所在的網路沒有開 IPv6（Docker 預設的 bridge 網路就是），而 Docker 的 <code>userland-proxy</code> 是預設的開著時，IPv6 那一份由 docker-proxy 這個主機上的程式（<a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy</a> 的一種）接聽、再自己連到容器的 IPv4 位址，連線走 input 路徑：實測在 ufw 與 firewalld 上，沒有放行規則時經由 IPv6 連發佈的連接埠都被擋下，加了 IPv6 的放行規則之後才連得到，跟 IPv4 那一份正好相反，所以 IPv6 這一份照放行式寫在主機防火牆的入站規則上。代價是來源位址：docker-proxy 自己連進容器，實測容器記到的 IPv6 那一筆來源是預設網路的閘道 <code>172.17.0.1</code>（compose 建的自訂網路閘道是別的位址，<code>docker network inspect &lt;網路名&gt;</code> 的 <code>Gateway</code> 欄查得到），容器裡的反向代理依來源位址做的判斷（只信任 CDN 位址段送來的 <code>CF-Connecting-IP</code>、存取紀錄）對經由 IPv6 回源的請求都不成立。Docker 的 <code>userland-proxy</code> 設成 <code>false</code> 時，實測在 ufw 主機上 IPv6 那一份根本沒有發佈：<code>docker ps</code> 只列 <code>0.0.0.0</code> 的那一份、ip6tables 沒有轉換規則，經由 IPv6 連都被拒。網路建立時帶 <code>--ipv6</code>、容器有 IPv6 位址時，依 Docker〈Port publishing〉的說明，IPv6 那一份改由 ip6tables 的位址轉換送進容器，走轉送路徑，規則要寫在 ip6tables 的 <code>DOCKER-USER</code> 或 nftables 的 <code>ip6 saddr</code>；這一種沒有實跑。</p>
<p>限制發佈連接埠的做法，從「讓連接埠不對外」到「在主機之外擋」依序是：</p>
<ul>
<li>
<p><strong>不發佈</strong>：同一個 compose 專案或同一個 Docker 網路裡的容器，以服務名稱互相連線，不需要發佈連接埠。資料庫、快取這類只給其他容器用的服務不寫 <code>ports</code>，主機外面就連不到它們；主機上的工具要連時，才發佈在本機位址。</p>
</li>
<li>
<p><strong>只發佈在本機位址</strong>（<code>-p 127.0.0.1:8082:80</code>）：外部來源連不到這個連接埠，實測兩個外部來源都被拒、主機本機連得到。這個結果有版本條件：Docker 的〈Port publishing〉寫明 28.0.0 以前，跟主機在同一個 L2 網段的其他主機連得到發佈在 127.0.0.1 的連接埠（實測用的是 29.9.2，<code>docker version</code> 查得到版本），共用網段的 VPS 或區網裡要先確認版本。對外改由反向代理（nginx、Caddy）收 80 與 443 再轉給容器；反向代理以主機上的程式或 host network 模式的容器執行時，它收連線走 input 路徑，主機防火牆的放行式規則對它有效，這是用 ufw、又沒有雲端防火牆可設的主機上規則最少的組合。反向代理自己也用 <code>-p 80:80 -p 443:443</code> 發佈時，它跟其他發佈的連接埠一樣走轉送路徑，要照下面幾種寫法限制。預設的發佈位址可以在 Docker 的設定裡改：實測 daemon 設定的 <code>ip</code> 設成 <code>127.0.0.1</code> 之後，預設 bridge 網路上的容器發佈在 <code>127.0.0.1</code>，而接在自訂網路上的容器照樣發佈在 <code>0.0.0.0</code> 與 <code>[::]</code>，外部來源連得到；自訂網路建立時帶網路選項 <code>com.docker.network.bridge.host_binding_ipv4=127.0.0.1</code>，那個網路上的容器才發佈在 <code>127.0.0.1</code>。compose 的自訂網路在 <code>driver_opts</code> 寫同一個選項，或用 daemon 設定的 <code>default-network-opts</code> 套到之後新建的每一個 bridge 網路（已經建好的網路不受影響），這兩種沒有實跑。</p>
</li>
<li>
<p><strong><code>DOCKER-USER</code> 鏈</strong>：Docker 在 iptables 的 FORWARD 鏈最前面跳到這條鏈，裡面的規則由使用者自己寫，Docker 啟動與重啟時不清掉它（實測只重啟 dockerd 之後規則還在、照樣擋得住）。封包到這裡時目的地已經改寫成容器的位址與連接埠，所以要用連線追蹤（<a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack</a>：核心記錄每一條連線的狀態與它原本的位址、連接埠的表）記下的原始目的連接埠比對（<code>-m conntrack --ctstate DNAT --ctorigdstport 443 --ctdir ORIGINAL</code>）。<code>--ctstate DNAT</code> 限定只比對經過位址轉換的連線：容器自己往外連 443、容器之間連 80 的封包也經過這條鏈、原始目的連接埠一樣是 443 或 80，實測少了這個條件時兩者都被丟掉，加上之後恢復，而範圍外的來源仍被擋（實測時發佈的連接埠是 8081，正式設定換成 80 與 443 時才會撞到這兩種流量）。<code>--ctdir ORIGINAL</code> 讓規則只比對發起方向的封包；容器回覆的封包也帶著同一個原始目的連接埠，少了這個條件時這些回覆封包會被拿去比對來源而丟掉（依連線追蹤的機制推論，沒有拿掉這個條件實測）。實測範圍外的來源被擋下（連線逾時），範圍內的照常。它的弱點在規則的存續：防火牆重新載入、dockerd 重啟與重開機之後規則還在不在。用 iptables 指令加的規則只存在執行中的核心裡，各種主機上的結果如下：</p>
<ul>
<li><strong>任何主機重開機</strong>：規則不在（ufw 主機上實測）。</li>
<li><strong>ufw 主機，after.rules 沒有宣告這條鏈</strong>：<code>ufw reload</code> 與關閉再開啟只重建 ufw 自己的鏈，規則還在（實測）。</li>
<li><strong>ufw 主機，規則寫進 <code>/etc/ufw/after.rules</code></strong>：這是 ufw 主機上讓規則留過重開機的做法（整份 Cloudflare 清單的寫法在 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈讓規則開機時載入：寫進 /etc/ufw/after.rules〉）。實測 reload、Docker 重啟與重開機之後都還在：開機時 ufw 先於 Docker 啟動，先建出這條鏈，Docker 沿用它。after.rules 宣告這條鏈時，每次 <code>ufw reload</code> 先清空它再寫入檔案裡的規則，實測另外用 <code>iptables -I</code> 加的規則在 reload 之後不在，所以同一台主機上的 <code>DOCKER-USER</code> 規則要全部寫在 after.rules 裡。</li>
<li><strong>firewalld 主機，nftables 後端</strong>：<code>firewall-cmd --reload</code> 在有 direct 設定時清掉規則與自建的鏈，沒有時規則還在（<code>FlushAllOnReload</code> 設成 <code>yes</code> 或 <code>no</code> 都一樣）；direct 設定有 direct rule 與 passthrough 兩種，實測各加一條都會觸發，查的時候 <code>firewall-cmd --direct --get-all-rules</code> 與 <code>--get-all-passthroughs</code> 兩個都要跑，前一個列不出 passthrough。實測的 Oracle Cloud 主機（2026.10.16 的 Oracle Linux 10.2 平台映像，nftables 後端）自帶一組保護執行個體中繼資料服務的 passthrough，reload 清掉了規則，那一組的內容記在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a> 的〈DOCKER-USER 鏈的限制效果與規則的存續〉。direct 設定也會在之後被別的軟體加進來（例如 fail2ban 的 firewalld 動作，依它的設定推論），所以這是主機當下的狀態，不是固定的屬性。</li>
<li><strong>firewalld 主機，iptables 後端</strong>（<code>/etc/firewalld/firewalld.conf</code> 的 <code>FirewallBackend</code>）：沒有任何 direct 設定，reload 也清掉了規則，因為那時 iptables 的表由 firewalld 管、reload 整張重建（實測）。</li>
<li>用<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具定期 reload 的主機上，規則會被清掉的那幾種情形，每一次 reload 之後直連就重新開著，沒有任何告警。<code>systemctl restart firewalld</code>，以及 iptables-services 或 netfilter-persistent 的開機載入，都沒有實跑。</li>
</ul>
<p>另外，Docker 改用實驗性的 nftables 後端時，〈firewall-nftables〉寫明沒有 <code>DOCKER-USER</code> 這條鏈，自訂規則要寫在自己的 nftables 表裡，也就是下面「自己的 nftables 表」那一種。</p>
</li>
<li>
<p><strong>firewalld 的 <code>StrictForwardPorts=yes</code></strong>：設定檔 <code>/etc/firewalld/firewalld.conf</code> 的這個選項（這次的 firewalld 2.4.3 有；它是 2024 年 11 月 firewalld 官方部落格〈Strict Forward Ports〉介紹的新功能，較舊的 firewalld 沒有）把轉送鏈開頭的 <code>ct status dnat accept</code> 換成只接受 firewalld 明確放行的連接埠。〈firewalld.conf〉與那篇部落格寫明，設成 <code>yes</code> 之後發佈的連接埠要用 firewalld 的 forward-port（firewalld 自己做的目的位址轉換規則）明確放行。實測它管得到「哪些發佈的連接埠可以從外面連」：設成 <code>yes</code> 之後兩個測試來源都被回拒，加了 forward-port 之後又都連得到；它管不到「誰可以連」：即使 forward-port 寫在限定來源的 rich rule（firewalld 可以同時寫來源、連接埠與動作的規則）裡，firewalld 在轉送鏈產生的放行規則也只比對原始目的連接埠（連線追蹤記下的、位址轉換之前的連接埠），沒有帶上來源條件，範圍外的來源也連得到。forward-port 改寫在 policy 上時，轉送鏈裡沒有產生任何放行，範圍內的來源也連不到。規則存在 firewalld 的永久設定裡，reload 與 dockerd 重啟之後都在。</p>
</li>
<li>
<p><strong>自己的 nftables 表，掛在目的位址轉換之前</strong>：在 nftables 建一張自己的表，鏈接在 prerouting 這個 hook（核心處理封包的流程上，讓規則掛上去執行的固定位置；prerouting 是封包剛進主機、還沒決定路由的那一個）上、優先序排在目的位址轉換之前（<code>priority dstnat - 10</code>），封包還沒被改寫時就用原本的目的連接埠比對來源，來源不在 CDN 清單裡就丟棄。規則要限定目的地是主機自己的位址（<code>fib daddr type local</code>）：容器往外連 443 與容器之間連 80 的封包也從 docker0 進 prerouting，實測少了這個條件時兩者都被丟掉。實測範圍外的來源被擋下，<code>firewall-cmd --reload</code>、dockerd 重啟之後規則都還在；把表寫進 <code>nftables.service</code> 開機時載入的設定檔（RHEL 系是 <code>/etc/sysconfig/nftables.conf</code>，其他發行版看 <code>systemctl cat nftables</code> 的 <code>ExecStart</code>）並啟用這個服務之後，重開機也還在。代價有兩個。一是規則不在 firewalld 的管理介面裡，<code>firewall-cmd --list-all</code> 看不到它，接手的人要知道還有這一張表。二是 <code>nftables.service</code> 會清掉整個 nftables 規則集：RHEL 系的 unit 在停止、重新啟動與重新載入時執行 <code>nft flush ruleset</code>，而 Debian 系的預設設定檔第一行就是 <code>flush ruleset</code>（這一點沒有核對各版本），被清掉的包括 firewalld 的表，以及 Docker 經由 iptables-nft 寫的位址轉換與轉送規則；之後自建的表又被載回來，主機只剩這一張表，其他連接埠失去預設回拒、發佈的連接埠與容器的出站都斷線，要依序重新啟動 firewalld 與 dockerd 才會重建（依 unit 檔的內容推論，沒有實跑）。避開這個風險的做法是不經過 <code>nftables.service</code>，改用一個開機時只執行 <code>nft -f</code> 載入這一張表的 systemd unit。</p>
</li>
<li>
<p><strong>雲端防火牆</strong>：規則在主機之外執行，Docker 改寫的是主機自己的防火牆，碰不到它。只放行 CDN 位址到 80 與 443、其他連接埠一律不開時，主機上誤發佈的任何連接埠都連不進來。</p>
</li>
<li>
<p><strong>Cloudflare Tunnel</strong>：源站不需要任何入站放行。這個效果要管理員自己做到：移除雲端防火牆與主機防火牆上 80、443 的入站放行，網站容器不發佈連接埠（或只發佈在本機位址），讓 <code>cloudflared</code> 經由同一個 Docker 網路或本機位址連到網站；網站容器還用 <code>-p 80:80</code> 發佈時，直連路徑照樣開著。代價是 <code>cloudflared</code> 要常駐、主機要能出站連到 Cloudflare，而且網站的入口綁在 Cloudflare 一家。實測用不需要帳號的 quick tunnel：後端只聽 <code>127.0.0.1</code>、沒有任何入站放行也連得到；quick tunnel 的 <code>cloudflared</code> 用 QUIC 連 Cloudflare 的 UDP 7844，出站擋掉 UDP 7844、TCP 7844 開著時，它一直重試 QUIC、沒有自動改用 http2，網站回 Cloudflare 的 530；指定 <code>--protocol http2</code> 之後改用 TCP 7844 連上。取得 quick tunnel 網址的那一步另外連出站的 443。所以出站只放行一份清單的主機上，quick tunnel 要的是 UDP 與 TCP 的 7844（各給 QUIC 與 http2）與取網址用的 443；清單只有 TCP 7844 而協定留在預設時，quick tunnel 停在重試 QUIC、網站回 530，協定設成 http2 時才改走 TCP 7844。每一種出站限制的結果記在 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 的〈Cloudflare Tunnel：源站不開入站，以及出站要放行的連接埠〉。具名的 tunnel（綁在帳號上的那種）預設的協定選擇沒有測。沒有出站路徑的主機（例如只有 service gateway 的私有 subnet 主機）用不了。</p>
</li>
</ul>
<p>Docker 以外的容器執行方式，發佈連接埠的路徑分成兩類：在主機防火牆之前改寫封包目的地的，連線走轉送路徑，結果取決於轉送路徑上每一張表的預設動作；由主機上一個轉送程式聽著那個連接埠、收下連線再轉進容器的，連線走 input 路徑，主機防火牆的入站規則管得到。分辨是哪一類，先查有沒有規則把那個連接埠的目的地改寫掉，再看誰在聽：改寫規則可能寫在經由 iptables-nft 管的表，也可能寫在原生 nftables 的表（實測 Podman 5.8.2 的 netavark 寫在 <code>inet netavark</code>，<code>iptables -t nat</code> 看不到），所以要用 <code>nft list ruleset</code> 查。查到改寫規則時就是前一類，<code>ss -ltnp</code> 裡聽著那個連接埠的程式（Podman 的 <code>conmon</code>、docker-proxy 的 IPv4 那一份）只是佔住連接埠；查不到時，聽著的轉送程式才是接收者。查表是預測，最終以範圍外的來源實際連一次為準。</p>
<p>以 root 執行的 Podman（用 netavark 寫規則）屬於前一類，跟 Docker 一樣用位址轉換。實測在 firewalld 上它跟 Docker 一樣繞過 zone，在 ufw 上卻被 ufw 預設的 <code>deny (routed)</code> 擋下，連 Cloudflare 位址都連不到，要用 ufw 的 route 規則放行到容器的位址與連接埠；ufw 那一台的差別在 netavark 的轉送鏈只放行回程，沒有 Docker 那樣接受新入站連線的 <code>DOCKER-FORWARD</code>。兩台的 Podman 版本也不同（ufw 那一台是 4.9.3、firewalld 那一台是 5.8.2），防火牆與版本的影響沒有分開。主機上同時裝了 Docker 時，Podman 的轉送封包也經過 <code>DOCKER-USER</code>：實測在 ufw 那一台，一條比對原始目的連接埠 8089、沒有帶 <code>--ctstate DNAT</code> 的規則，對 Podman 發佈的 8089 一樣擋得住範圍外的來源（換成 80、443 時照上面 <code>DOCKER-USER</code> 那一項加 <code>--ctstate DNAT</code>，這個組合沒有在 Podman 上實跑）。</p>
<p>rootless 模式的 Docker 與 Podman 屬於後一類：它們以一般使用者執行，不能改主機的防火牆，發佈的連接埠由主機上的轉送程式（<code>rootlesskit</code>、<code>rootlessport</code>）接聽，ufw 的入站規則實測管得到。代價是在預設設定下（RootlessKit 的預設轉送方式、Podman 4 的 slirp4netns），容器看到的來源是轉送程式那一端的位址（<code>172.17.0.1</code>、<code>10.0.2.100</code>），存取紀錄與依來源位址的判斷拿不到真正的來源；Podman 5 預設的 pasta 依它的發行說明保留來源位址，沒有實跑。另外，<code>--network host</code> 的容器沒有位址轉換也沒有轉送程式，聽著連接埠的是程式本身、走 input 路徑；macvlan、ipvlan 網路的容器有自己的位址，封包不經過主機的轉送與 input 路徑，主機防火牆的規則都碰不到它（依網路驅動的機制推論，沒有實跑）。Docker 文件列的保留來源位址做法這次沒有實跑，列在 <a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a> 的 rootless Docker 那一節。</p>
<p>這些寫法可以疊加。業者的防火牆放得下整份 CDN 清單時，雲端防火牆加上不發佈或只發佈在本機位址的組合，不靠主機上任何一條會被重新載入或重開機清掉的規則：雲端防火牆擋住直連，不發佈與本機位址讓「某個容器不小心發佈在 <code>0.0.0.0</code>」這種錯誤不會變成對外開放。網站跑在主機上的程式（不是容器）時，雲端防火牆加上 firewalld zone 或 ufw 的放行式規則就夠，兩層各自擋一次直連。同一組規則要套在多台主機上時，雲端防火牆的規則可以套在一組網卡上共用（Oracle Cloud 的 NSG、AWS 的 security group），主機防火牆的寫法則要在每一台各自部署與維護。單台主機、網站流量、主機連得到外面時，Cloudflare Tunnel 可以取代入站的 CDN 位址清單：移除 80 與 443 的入站放行，而網站容器不發佈或只發佈在本機位址時，清單的維護與 Docker 繞過主機防火牆這兩件事都不必處理。</p>
<h2 id="出站的-dns-只准送往指定的解析器擋得到與擋不到的部分">出站的 DNS 只准送往指定的解析器：擋得到與擋不到的部分</h2>
<p>主機加固清單常見的一條是限制 DNS 只用 53，它有入站與出站兩種讀法。入站的讀法是主機有跑 DNS 伺服器時才開 53，沒有跑就不開；網站主機多半沒有跑 DNS 伺服器，所以入站的做法就是不把 53 放進放行清單。出站的讀法是主機與容器送出的 DNS 查詢只准送往指定的解析器（雲端業者的解析器，例如 Oracle Cloud 虛擬網路 VCN 裡的 <code>169.254.169.254</code>），送往其他位址的 53 一律拒絕。後一種的目的，是讓被植入的程式不能用自己指定的 DNS 伺服器查詢，查詢全部經過一個留有紀錄的解析器。它擋不住把資料編進查詢的網域名稱送出去：允許的解析器會遞迴解析任何公開的名稱，<code>&lt;編碼後的資料&gt;.&lt;攻擊者的網域&gt;</code> 照樣被它送到攻擊者的權威伺服器，這一種要靠解析器端的查詢紀錄或 DNS 防火牆偵測。「指定的解析器」指的是主機實際往外送查詢的上游：Ubuntu 預設的 systemd-resolved 讓 <code>/etc/resolv.conf</code> 寫 <code>127.0.0.53</code>，實際的上游要用 <code>resolvectl status</code> 查，規則照 <code>/etc/resolv.conf</code> 寫會擋掉 systemd-resolved 自己的查詢；容器拿到的伺服器用 <code>docker exec &lt;容器&gt; cat /etc/resolv.conf</code> 查。</p>
<p>出站限制在 firewalld 上寫成一條從 <code>HOST</code>（主機本身送出的流量）到 <code>ANY</code>（任何 zone）的 policy，裡面兩條 rich rule：放行送往指定解析器的 <code>dns</code> 服務那一條優先序較小、先比對，拒絕其餘 <code>dns</code> 的那一條排在它之後。實測它的效果與漏洞：</p>
<ul>
<li><strong>主機本身送出的查詢</strong>：送往指定解析器的照常解析，送往其他位址的 53 被拒絕（<code>dig</code> 回 <code>connection refused</code>），對方收不到任何封包。</li>
<li><strong>容器送出的查詢</strong>：實測的容器在預設的 bridge 網路上，它的 DNS 設定是主機 <code>/etc/resolv.conf</code> 的副本，查詢直接從容器送出、走轉送路徑，不經過 <code>HOST</code>，所以這條 policy 對它無效，容器照樣把查詢送到外部的 53。這些查詢要在 <code>DOCKER-USER</code> 另寫一條（從容器網路的 bridge 介面進來、目的連接埠 53、目的地不是指定解析器的拒絕；預設網路的介面是 docker0，compose 的自訂網路是 <code>br-</code> 開頭的介面，iptables 可以寫成 <code>-i br-+</code>），而它有 <code>DOCKER-USER</code> 規則存續的問題（用 iptables 指令加的規則，在用 iptables 後端、或用 nftables 後端而有 direct 設定的 firewalld 主機上 reload 就消失、在 ufw 主機上被 after.rules 宣告的鏈 reload 時清空、重開機之後都不在；寫進 after.rules 的那一份重開機之後還在），要一起處理。compose 的自訂網路上，容器先問 Docker 內建的解析器（<code>127.0.0.11</code>），內建解析器再往外查，而那一筆往外查的封包由主機送出：實測連線追蹤記到的原始來源是主機的 IP，不是容器的位址；主機的出站規則擋掉 53（ufw 主機上的 OUTPUT、firewalld 的 <code>HOST</code> policy）時，compose 容器經由 <code>127.0.0.11</code> 的查詢失敗，<code>DOCKER-USER</code> 的規則對它沒有作用。compose 容器自己指定別的 DNS 伺服器時，查詢又從容器的介面轉送出去，<code>DOCKER-USER</code> 擋得到。所以容器的 DNS 要兩個位置都限制：轉送路徑上的規則（<code>DOCKER-USER</code> 或自建 nftables 表的 forward 鏈，實測自建表在 <code>firewall-cmd --reload</code> 之後還在）擋容器直接送出的，主機的出站規則擋內建解析器替它送出的。</li>
<li><strong>不走 53 的 DNS</strong>：DNS over TLS 用專用的 853，再加一條拒絕出站 853 的規則就擋得下（沒有實跑）。DNS over HTTPS 把查詢包在送往 443 的 HTTPS 裡，連接埠的限制看不到它。實測時主機對外部 443 送出的一般 TCP 連線不受這條 policy 影響；沒有實際送 DNS over HTTPS 的查詢，它走同一個連接埠，結果依此推論。要擋這一種，出站就要改成只放行一份允許的目的地清單（套件庫、業者的 API），這在 443 上會連帶影響每一個需要連外的功能，成本遠高於限制 53。</li>
</ul>
<p>所以出站限制 53 擋下的是寫死了 DNS 伺服器位址、走 53 的程式，它讓主機與容器的名稱查詢集中到一個留有查詢紀錄的解析器；有意繞過的程式改走 443 就繞得過去。</p>
<h2 id="地理封鎖依國家比對來源的位置與-geoip-清單的成本">地理封鎖：依國家比對來源的位置與 GeoIP 清單的成本</h2>
<p>依國家封鎖連線，要先知道連線來自哪個國家，而源站在 CDN 後面時，源站看到的來源位址全是 CDN 機房的位址：主機防火牆照來源位址查國家，查到的是 CDN 機房所在的國家，不是使用者的。所以地理封鎖寫在 CDN 那一端（Cloudflare 的 WAF custom rule 依 <code>ip.src.country</code> 比對），那裡看得到使用者真正的位址；源站只需要擋掉不經過 CDN 的連線，只放行 CDN 位址的規則已經做到。源站要自己做時，由反向代理依 <code>CF-Connecting-IP</code> 查國家，前提是這個標頭可信。只放行 CDN 位址不足以讓它可信（任何帳號的 Worker 都從 CDN 的位址送出、標頭可以自己寫，見〈主機前面有 CDN 時連到源站的路徑：經由 CDN、直連公開 IP 與管理通道〉），可信要靠自己上傳憑證的 Authenticated Origin Pulls 或 Tunnel；防火牆這一層做不到，因為它看不到 HTTP 標頭。</p>
<p>主機防火牆做地理封鎖的機制跟只放行 CDN 位址相同（把一個國家的位址段放進 ipset），適用的情形是主機直接面對使用者、前面沒有 CDN，或同一台主機上不經 CDN 的服務。它的成本在清單：國家的位址段清單要從 GeoIP 資料庫（把 IP 位址段對應到國家的資料庫）取得並定期更新，資料庫對 VPN、行動網路與雲端業者的位址判斷常常不準，被誤擋的使用者只看到連線失敗（逾時或立即被拒，看規則是丟棄還是回拒），沒有任何頁面告訴他原因。CDN 端的封鎖可以回一個說明頁或改成驗證挑戰，誤判時使用者看得到發生了什麼。</p>
<h2 id="防火牆限制與修補的關係">防火牆限制與修補的關係</h2>
<p>只放行 CDN 位址、在 CDN 上加 WAF 規則，縮小的是能連到有漏洞程式的來源範圍，漏洞本身還在。源站已經修補時，直連的掃描請求送到也攻不破；源站還沒修補時，經由 CDN 送來的同一種請求只要沒有被 WAF 規則認出來，照樣會送到程式。防火牆限制與修補各自關掉一條路徑：限制擋住不經過 CDN 的請求，修補處理經過 CDN 也會送到的那些。漏洞公開到修補完成之間的暫時緩解（WAF 規則、關閉功能）見 <a href="/blog/backend/07-security-data-protection/entrypoint-and-server-protection/" data-link-title="7.3 入口治理與伺服器防護" data-link-desc="以問題驅動方式整理對外入口、管理平面與伺服器邊界">7.3 入口治理與伺服器防護</a> 的〈修補窗口期內的暫時緩解〉。單台主機上的修補依軟體的安裝管道分開處理：發行版與第三方套件庫的套件、容器映像檔、應用程式自己的更新、自己建置的程式與從上游下載的二進位檔，各自由不同的機制得知有修補、能不能交給排程也不同；裝上去之後，已經在執行的程序要重新啟動、核心要重開機，修補才生效。各條管道的做法見 <a href="/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/" data-link-title="長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算" data-link-desc="單台 Linux 主機（VPS、雲端主機）長期上線時，沒有人操作也會改變的狀態與維護對它們做的事：log 與資料的寫入者和清理機制（logrotate、journald、容器 log）、磁碟寫滿時各元件的反應、每份 log 要讀的欄位、入口在主機與雲端各層的盤點（ss）、各條更新管道的安全更新與重開機（dnf-automatic、unattended-upgrades、needs-restarting）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算</a>〈會老化的軟體：各條更新管道與更新後的重開機〉。</p>
<h2 id="延伸閱讀實測紀錄入口層管理通道cdn-的分發與兩個網路之間的連線">延伸閱讀：實測紀錄、入口層、管理通道、CDN 的分發與兩個網路之間的連線</h2>
<ul>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>：firewalld 的 ipset、rich rule 與 zone sources、Docker 發佈的連接埠繞過 firewalld、<code>DOCKER-USER</code> 在 dockerd 重啟後還在而 reload 後是否消失取決於 firewalld 的後端與 direct 設定、<code>StrictForwardPorts</code> 只管連接埠不管來源、nftables 表在 reload 與重開機之後的存續、出站 DNS 的 policy 對主機與容器的不同效果，每一步的指令與輸出</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a>：ufw 的入站與 route 規則對發佈連接埠沒有作用的原因、<code>DOCKER-USER</code> 寫進 <code>/etc/ufw/after.rules</code> 之後在重開機後的存續、IPv6 那一份由 docker-proxy 接收、compose 容器的 DNS 查詢由主機送出</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a>：同一條 ufw 設定對 Docker 與 Podman 結果相反的原因、rootless 模式由轉送程式接收連線的代價</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a>：NSG 的 120 條上限、回源位址與公布清單的比對、nginx 上的用戶端憑證驗證、Worker 穿過位址限制並偽造標頭、cloudflared 在 UDP 7844 被擋時的行為</li>
<li><a href="/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用</a>：DNS、負載平衡、反向代理與應用在責任鏈上的分工，TLS 終結放在哪一層，單機 nginx 與雲端負載平衡器的選擇條件</li>
<li><a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>：只放行 CDN 位址之後，管理員登入主機的那條通道的做法與選擇</li>
<li><a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源（CDN / Origin Protection）</a>：CDN 在分發與快取上的責任，以及它跟入口資安的交接</li>
<li><a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄</a>：VPN 隧道介面上的 <code>DOCKER-USER</code> 規則與只放行回應的連線追蹤規則</li>
</ul>
]]></content:encoded></item><item><title>網路地基的實作紀錄</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/</guid><description>&lt;p>這個資料夾收網路地基模組裡不綁在某一家雲端業者的操作紀錄：前置條件、每一步的指令與輸出、遇到的錯誤與除錯過程、量到的值。觀念與判斷依據寫在上一層的概念文章，這裡的文章連回去，不重寫。&lt;/p>
&lt;h2 id="章節文章">章節文章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>文章&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>&lt;/td>
 &lt;td>用 network namespace 模擬 Cloudflare 位址範圍內與範圍外的來源、firewalld 的 ipset、rich rule 與 zone sources、Docker 發佈連接埠繞過 firewalld、DOCKER-USER 在 dockerd 重啟後還在而 reload 後是否消失取決於 firewalld 的後端與 direct 設定、firewalld policy 擋不到的原因、StrictForwardPorts 只管連接埠不管來源、掛在位址轉換之前的 nftables 表與它的開機載入、出站 DNS 的 policy 與 forward 鏈的 nftables 表對主機與容器的不同效果、清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a>&lt;/td>
 &lt;td>Ubuntu 的 ufw 入站與 route 規則對發佈連接埠沒有作用的原因、DOCKER-USER 在 ufw reload、Docker 重啟與重開機之後的存續、寫進 /etc/ufw/after.rules 與它的 ASCII 限制、IPv6 那一份由 docker-proxy 接收、compose 容器的 DNS 查詢由主機送出、清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址&lt;/a>&lt;/td>
 &lt;td>rootful Podman（netavark）在 ufw 上被擋、在 firewalld 上繞過 zone 的原因與 ufw route 規則的放行寫法、rootless Docker 的 rootlesskit 與 rootless Podman 的 rootlessport 接收連線、容器看到的來源位址、測試使用者的清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠&lt;/a>&lt;/td>
 &lt;td>Oracle Cloud 的公開子網路與 NSG、每次 API 呼叫 25 條與每個 NSG 120 條的上限、GCP 與 AWS 文件的上限、經由 Cloudflare 代理時回源的來源位址、SSL/TLS 模式與 nginx 上的 AOP、Worker 穿過位址限制並偽造 CF-Connecting-IP、quick tunnel 與出站 7844、清理與 Cloudflare 設定的還原&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<p>這個資料夾收網路地基模組裡不綁在某一家雲端業者的操作紀錄：前置條件、每一步的指令與輸出、遇到的錯誤與除錯過程、量到的值。觀念與判斷依據寫在上一層的概念文章，這裡的文章連回去，不重寫。</p>
<h2 id="章節文章">章節文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a></td>
          <td>用 network namespace 模擬 Cloudflare 位址範圍內與範圍外的來源、firewalld 的 ipset、rich rule 與 zone sources、Docker 發佈連接埠繞過 firewalld、DOCKER-USER 在 dockerd 重啟後還在而 reload 後是否消失取決於 firewalld 的後端與 direct 設定、firewalld policy 擋不到的原因、StrictForwardPorts 只管連接埠不管來源、掛在位址轉換之前的 nftables 表與它的開機載入、出站 DNS 的 policy 與 forward 鏈的 nftables 表對主機與容器的不同效果、清理</td>
      </tr>
      <tr>
          <td><a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a></td>
          <td>Ubuntu 的 ufw 入站與 route 規則對發佈連接埠沒有作用的原因、DOCKER-USER 在 ufw reload、Docker 重啟與重開機之後的存續、寫進 /etc/ufw/after.rules 與它的 ASCII 限制、IPv6 那一份由 docker-proxy 接收、compose 容器的 DNS 查詢由主機送出、清理</td>
      </tr>
      <tr>
          <td><a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a></td>
          <td>rootful Podman（netavark）在 ufw 上被擋、在 firewalld 上繞過 zone 的原因與 ufw route 規則的放行寫法、rootless Docker 的 rootlesskit 與 rootless Podman 的 rootlessport 接收連線、容器看到的來源位址、測試使用者的清理</td>
      </tr>
      <tr>
          <td><a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a></td>
          <td>Oracle Cloud 的公開子網路與 NSG、每次 API 呼叫 25 條與每個 NSG 120 條的上限、GCP 與 AWS 文件的上限、經由 Cloudflare 代理時回源的來源位址、SSL/TLS 模式與 nginx 上的 AOP、Worker 穿過位址限制並偽造 CF-Connecting-IP、quick tunnel 與出站 7844、清理與 Cloudflare 設定的還原</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Network Tag（GCP 網路標籤）</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/network-tag/</link><pubDate>Fri, 23 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/network-tag/</guid><description>&lt;p>Network tag 是貼在 GCP VM 上的字串標籤，防火牆規則用它選出要套用的 VM：規則的 &lt;code>--target-tags&lt;/code> 列出 tag，只有帶著其中一個 tag 的 VM 受這條規則影響（沒有 target 的規則套用到整個網路的 VM）。它是 VM 的屬性，&lt;code>gcloud compute instances add-tags&lt;/code> 逐台加，或寫進 instance template 讓建出來的 VM 都帶著。AWS 沒有對應的標籤機制，對應的做法是把 instance 掛進 security group。可先對照 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>tag 把「哪些 VM 要開這個 port」從防火牆規則裡拆出來，放到 VM 上：規則只寫一次，VM 加上 tag 就套用。代價是 tag 由誰都能改的 VM 屬性決定——有權限改 VM 的帳號加上一個 tag，那台 VM 就套用了那條規則；而一台 VM 被重建時，只有寫在 template 裡的 tag 會留下。這與 AWS 的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> 方向相反：Security Group 是把 instance 掛進規則集合。要讓授權更嚴格時，防火牆規則改用 &lt;code>--target-service-accounts&lt;/code> 依 VM 的服務帳號選，換服務帳號需要的權限比改 tag 高。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;ul>
&lt;li>&lt;code>gcloud compute instances describe &amp;lt;vm&amp;gt; --format=&amp;quot;value(tags.items)&amp;quot;&lt;/code> 列出這台 VM 的 tag&lt;/li>
&lt;li>只放行 IAP 來源的規則存在、而某台 VM 經 IAP 連不上：先查這台 VM 有沒有那條規則的 target tag&lt;/li>
&lt;li>由 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/managed-instance-group/" data-link-title="Managed Instance Group（MIG，代管執行個體群組）" data-link-desc="VM 被自動重建之後設定不見、要決定一組 VM 的設定該寫在哪裡，或要判斷一台 VM 能不能直接刪掉重建時，查 GCP 的 MIG 與 instance template 怎麼建立、修復與更新 VM">Managed Instance Group（MIG）&lt;/a> 自動重建或新建的 VM 少了手動加的 tag&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>管理通道用的 tag（例如放行 IAP 來源連 22 port 的那一個）寫進 instance template 或 GKE node pool 的設定，不靠逐台 &lt;code>add-tags&lt;/code>。改 template 或 node pool 的 tag 時，&lt;code>--tags&lt;/code> 是整組指定，既有的 tag 要一起列進去。能改 VM tag 的權限等於能改那台 VM 套用哪些防火牆規則，授權時與防火牆規則的管理權一起考慮。&lt;/p>
&lt;h2 id="鄰卡">鄰卡&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> — AWS 上用掛載而不是標籤決定規則套用範圍&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/managed-instance-group/" data-link-title="Managed Instance Group（MIG，代管執行個體群組）" data-link-desc="VM 被自動重建之後設定不見、要決定一組 VM 的設定該寫在哪裡，或要判斷一台 VM 能不能直接刪掉重建時，查 GCP 的 MIG 與 instance template 怎麼建立、修復與更新 VM">Managed Instance Group（MIG）&lt;/a> — tag 寫在 template 裡才會留到重建後&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/identity-aware-proxy/" data-link-title="Identity-Aware Proxy（身分感知代理）" data-link-desc="要讓管理員或使用者不經公開入口連到內部主機與服務、評估把存取控制從來源 IP 改成帳號身分時，查身分感知代理的連線路徑、前提與代價">Identity-Aware Proxy（身分感知代理）&lt;/a> — GCP 上放行 IAP 來源的防火牆規則用 tag 選 VM&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Network tag 是貼在 GCP VM 上的字串標籤，防火牆規則用它選出要套用的 VM：規則的 <code>--target-tags</code> 列出 tag，只有帶著其中一個 tag 的 VM 受這條規則影響（沒有 target 的規則套用到整個網路的 VM）。它是 VM 的屬性，<code>gcloud compute instances add-tags</code> 逐台加，或寫進 instance template 讓建出來的 VM 都帶著。AWS 沒有對應的標籤機制，對應的做法是把 instance 掛進 security group。可先對照 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>tag 把「哪些 VM 要開這個 port」從防火牆規則裡拆出來，放到 VM 上：規則只寫一次，VM 加上 tag 就套用。代價是 tag 由誰都能改的 VM 屬性決定——有權限改 VM 的帳號加上一個 tag，那台 VM 就套用了那條規則；而一台 VM 被重建時，只有寫在 template 裡的 tag 會留下。這與 AWS 的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> 方向相反：Security Group 是把 instance 掛進規則集合。要讓授權更嚴格時，防火牆規則改用 <code>--target-service-accounts</code> 依 VM 的服務帳號選，換服務帳號需要的權限比改 tag 高。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<ul>
<li><code>gcloud compute instances describe &lt;vm&gt; --format=&quot;value(tags.items)&quot;</code> 列出這台 VM 的 tag</li>
<li>只放行 IAP 來源的規則存在、而某台 VM 經 IAP 連不上：先查這台 VM 有沒有那條規則的 target tag</li>
<li>由 <a href="/blog/devops/infra/knowledge-cards/managed-instance-group/" data-link-title="Managed Instance Group（MIG，代管執行個體群組）" data-link-desc="VM 被自動重建之後設定不見、要決定一組 VM 的設定該寫在哪裡，或要判斷一台 VM 能不能直接刪掉重建時，查 GCP 的 MIG 與 instance template 怎麼建立、修復與更新 VM">Managed Instance Group（MIG）</a> 自動重建或新建的 VM 少了手動加的 tag</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>管理通道用的 tag（例如放行 IAP 來源連 22 port 的那一個）寫進 instance template 或 GKE node pool 的設定，不靠逐台 <code>add-tags</code>。改 template 或 node pool 的 tag 時，<code>--tags</code> 是整組指定，既有的 tag 要一起列進去。能改 VM tag 的權限等於能改那台 VM 套用哪些防火牆規則，授權時與防火牆規則的管理權一起考慮。</p>
<h2 id="鄰卡">鄰卡</h2>
<ul>
<li><a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> — AWS 上用掛載而不是標籤決定規則套用範圍</li>
<li><a href="/blog/devops/infra/knowledge-cards/managed-instance-group/" data-link-title="Managed Instance Group（MIG，代管執行個體群組）" data-link-desc="VM 被自動重建之後設定不見、要決定一組 VM 的設定該寫在哪裡，或要判斷一台 VM 能不能直接刪掉重建時，查 GCP 的 MIG 與 instance template 怎麼建立、修復與更新 VM">Managed Instance Group（MIG）</a> — tag 寫在 template 裡才會留到重建後</li>
<li><a href="/blog/devops/infra/knowledge-cards/identity-aware-proxy/" data-link-title="Identity-Aware Proxy（身分感知代理）" data-link-desc="要讓管理員或使用者不經公開入口連到內部主機與服務、評估把存取控制從來源 IP 改成帳號身分時，查身分感知代理的連線路徑、前提與代價">Identity-Aware Proxy（身分感知代理）</a> — GCP 上放行 IAP 來源的防火牆規則用 tag 選 VM</li>
</ul>
]]></content:encoded></item><item><title>DNAT（目的位址轉換）</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dnat/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dnat/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT Gateway&lt;/a> 改的是出站封包的來源位址（SNAT），讓內部主機借一個公開位址出去；DNAT（destination NAT，目的位址轉換）改的是入站封包的目的地，讓外面的連線送到指定的那一台：封包原本送往「主機的 8081」，改寫之後送往「容器的 80」。回程的封包由 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack（連線追蹤）&lt;/a> 記下的對應自動改回來，外面的連線方看到的始終是主機的位址與 8081。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Linux 處理封包的框架叫 netfilter，它在封包流程上留了幾個固定位置（hook）讓規則掛上去。DNAT 掛在 prerouting 這個 hook：封包剛進主機、核心還沒決定路由之前就改寫（iptables 寫在 nat 表的 PREROUTING 鏈，nftables 寫在 &lt;code>type nat&lt;/code> 的鏈；主機本機發出的連線則在 OUTPUT 那一段改寫）。改寫之後，核心依新的目的地決定路由：目的地是容器或另一台主機時，封包走轉送（forward）路徑送給另一個介面，不經過管主機本身入站連線的 input 路徑；改寫成主機本機的位址或連接埠時（iptables 的 REDIRECT、firewalld 不帶目的位址的 forward-port），封包仍走 input 路徑。&lt;/p>
&lt;p>Docker 用 &lt;code>-p 8081:80&lt;/code> 發佈連接埠時，IPv4 那一份屬於目的地改成容器的那一種：寫在 input 路徑上的主機防火牆規則（ufw、firewalld 的 zone）管不到它（容器網路沒開 IPv6 時，IPv6 那一份不經 DNAT，由主機上的 docker-proxy 程式（&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy&lt;/a>）接收、走 input 路徑，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a>），而轉送路徑上 Docker 與 firewalld 預設都放行經過位址轉換的連線（Docker 的 &lt;code>DOCKER-FORWARD&lt;/code> 鏈、firewalld 的 &lt;code>ct status dnat accept&lt;/code>），所以這些連接埠繞過了主機防火牆。雲端的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> 在封包到達主機、還沒被改寫之前就比對完，不受這個改寫影響。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>iptables -t nat -S DOCKER&lt;/code> 看得到規則&lt;/strong>：&lt;code>-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8081 -j DNAT --to-destination 172.17.0.2:80&lt;/code> 就是 Docker 發佈 8081 時寫的那一條。&lt;/li>
&lt;li>&lt;strong>防火牆清單看不到、外面卻連得到的連接埠&lt;/strong>：&lt;code>firewall-cmd --list-all&lt;/code> 與 &lt;code>ufw status&lt;/code> 都沒有列 8081，從外面連照樣拿到容器的回應；查發佈的連接埠要用 &lt;code>docker ps&lt;/code> 或 &lt;code>ss -ltnp&lt;/code>。&lt;/li>
&lt;li>&lt;strong>規則比對不到改寫前的連接埠&lt;/strong>：寫在 FORWARD 鏈的 &lt;code>--dport 8081&lt;/code> 比對不到任何封包，因為封包到那裡時目的連接埠已經是 80。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>限制規則寫在 DNAT 之前還是之後，決定它看得到哪一個連接埠：之前（prerouting、優先序比 dstnat 小）看得到原本的目的連接埠，直接用它比對；之後（FORWARD 鏈、Docker 的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件">&lt;code>DOCKER-USER&lt;/code>&lt;/a>）看到的是改寫後的容器位址，要改用連線追蹤記下的原始值比對（iptables 的 &lt;code>-m conntrack --ctorigdstport 8081&lt;/code>、nftables 的 &lt;code>ct original proto-dst 8081&lt;/code>）。主機防火牆沒有放行、外面卻連得到的連接埠，多半是經過 DNAT 送給了容器或另一台主機，這時原因在規則的位置，不在規則的內容。Kubernetes 的 NodePort 在 kube-proxy 的 iptables 模式下也用 DNAT 送往 Pod，IPVS 模式的掛法不同，兩者都沒有在這個站上實測。Docker 的實測與各種限制寫法的比較見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT Gateway</a> 改的是出站封包的來源位址（SNAT），讓內部主機借一個公開位址出去；DNAT（destination NAT，目的位址轉換）改的是入站封包的目的地，讓外面的連線送到指定的那一台：封包原本送往「主機的 8081」，改寫之後送往「容器的 80」。回程的封包由 <a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack（連線追蹤）</a> 記下的對應自動改回來，外面的連線方看到的始終是主機的位址與 8081。</p>
<h2 id="概念位置">概念位置</h2>
<p>Linux 處理封包的框架叫 netfilter，它在封包流程上留了幾個固定位置（hook）讓規則掛上去。DNAT 掛在 prerouting 這個 hook：封包剛進主機、核心還沒決定路由之前就改寫（iptables 寫在 nat 表的 PREROUTING 鏈，nftables 寫在 <code>type nat</code> 的鏈；主機本機發出的連線則在 OUTPUT 那一段改寫）。改寫之後，核心依新的目的地決定路由：目的地是容器或另一台主機時，封包走轉送（forward）路徑送給另一個介面，不經過管主機本身入站連線的 input 路徑；改寫成主機本機的位址或連接埠時（iptables 的 REDIRECT、firewalld 不帶目的位址的 forward-port），封包仍走 input 路徑。</p>
<p>Docker 用 <code>-p 8081:80</code> 發佈連接埠時，IPv4 那一份屬於目的地改成容器的那一種：寫在 input 路徑上的主機防火牆規則（ufw、firewalld 的 zone）管不到它（容器網路沒開 IPv6 時，IPv6 那一份不經 DNAT，由主機上的 docker-proxy 程式（<a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy</a>）接收、走 input 路徑，見 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a>），而轉送路徑上 Docker 與 firewalld 預設都放行經過位址轉換的連線（Docker 的 <code>DOCKER-FORWARD</code> 鏈、firewalld 的 <code>ct status dnat accept</code>），所以這些連接埠繞過了主機防火牆。雲端的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> 在封包到達主機、還沒被改寫之前就比對完，不受這個改寫影響。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong><code>iptables -t nat -S DOCKER</code> 看得到規則</strong>：<code>-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8081 -j DNAT --to-destination 172.17.0.2:80</code> 就是 Docker 發佈 8081 時寫的那一條。</li>
<li><strong>防火牆清單看不到、外面卻連得到的連接埠</strong>：<code>firewall-cmd --list-all</code> 與 <code>ufw status</code> 都沒有列 8081，從外面連照樣拿到容器的回應；查發佈的連接埠要用 <code>docker ps</code> 或 <code>ss -ltnp</code>。</li>
<li><strong>規則比對不到改寫前的連接埠</strong>：寫在 FORWARD 鏈的 <code>--dport 8081</code> 比對不到任何封包，因為封包到那裡時目的連接埠已經是 80。</li>
</ul>
<h2 id="判讀方式">判讀方式</h2>
<p>限制規則寫在 DNAT 之前還是之後，決定它看得到哪一個連接埠：之前（prerouting、優先序比 dstnat 小）看得到原本的目的連接埠，直接用它比對；之後（FORWARD 鏈、Docker 的 <a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a>）看到的是改寫後的容器位址，要改用連線追蹤記下的原始值比對（iptables 的 <code>-m conntrack --ctorigdstport 8081</code>、nftables 的 <code>ct original proto-dst 8081</code>）。主機防火牆沒有放行、外面卻連得到的連接埠，多半是經過 DNAT 送給了容器或另一台主機，這時原因在規則的位置，不在規則的內容。Kubernetes 的 NodePort 在 kube-proxy 的 iptables 模式下也用 DNAT 送往 Pod，IPVS 模式的掛法不同，兩者都沒有在這個站上實測。Docker 的實測與各種限制寫法的比較見 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>。</p>
]]></content:encoded></item><item><title>conntrack（連線追蹤）</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/conntrack/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/conntrack/</guid><description>&lt;p>雲端的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group&lt;/a> 放行一條入站連線之後，回程的封包不必另寫規則，因為它是「有狀態」的防火牆；Linux 主機上讓防火牆有狀態的元件是 conntrack（connection tracking，連線追蹤）。它是核心裡記錄每一條連線的表：兩端的位址與連接埠、協定、TCP 的連線階段（SYN_SENT、ESTABLISHED、TIME_WAIT 這類），以及經過位址轉換時原本與改寫後的兩組位址。防火牆規則比對的連線狀態（NEW、ESTABLISHED、RELATED、INVALID）是核心依這張表替每個封包推出來的。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>iptables 的 &lt;code>-m conntrack&lt;/code>、&lt;code>-m state&lt;/code> 與 nftables 的 &lt;code>ct&lt;/code> 都在讀這張表。它也是 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT&lt;/a> 與 SNAT（&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT Gateway&lt;/a> 做的那一種）能運作的前提：核心在連線的第一個封包改寫位址時，把原本與改寫後的對應記進表裡，同一條連線之後的封包與回程封包都照這個對應改寫。規則要比對位址轉換之前的值時，讀的就是表裡記下的「原始方向」（original）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>防火牆規則開頭的 &lt;code>ct state { established, related } accept&lt;/code>&lt;/strong>：實測時 firewalld 的轉送鏈就以這一條起手，已經放行的連線之後的封包直接通過，規則只需要描述新連線。&lt;/li>
&lt;li>&lt;strong>firewalld 的 &lt;code>ct status dnat accept&lt;/code>&lt;/strong>：在 &lt;code>StrictForwardPorts=no&lt;/code>（預設值）下，所有經過目的位址轉換的連線在任何 policy 之前就被接受，Docker 發佈的連接埠因此不受 firewalld policy 約束。&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件">&lt;code>DOCKER-USER&lt;/code>&lt;/a> 裡的 &lt;code>-m conntrack --ctorigdstport 8081 --ctdir ORIGINAL&lt;/code>&lt;/strong>：封包的目的地已經改成容器的 80，用表裡記下的原始目的連接埠 8081 比對；&lt;code>--ctdir ORIGINAL&lt;/code> 只比對發起方向的封包，回程封包不進這條規則。&lt;/li>
&lt;li>&lt;strong>&lt;code>conntrack -L&lt;/code>&lt;/strong>（套件 conntrack-tools）或 &lt;code>/proc/net/nf_conntrack&lt;/code>：列出表裡每一條連線，一行裡有協定、TCP 階段、&lt;code>src=&lt;/code>/&lt;code>dst=&lt;/code>/&lt;code>sport=&lt;/code>/&lt;code>dport=&lt;/code> 兩組（第一組是原始方向，第二組是回程方向；兩組對不上就是經過了位址轉換），以及 &lt;code>ASSURED&lt;/code> 這類旗標。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>一條防火牆規則比對的是封包現在的欄位，或是表裡記下的原始值，兩者在位址轉換之後不同：寫在位址轉換之後的規則，要比對原本的位址或連接埠時要用 &lt;code>ctorig*&lt;/code> 這類條件，用 &lt;code>--dport&lt;/code> 只比對得到改寫後的值。規則改了卻像沒生效時，原因常在已經存在的連線：ESTABLISHED 的連線多半在規則前面就被放行，新規則只擋得到之後的新連線；要讓既有的連線也受新規則約束，用 &lt;code>conntrack -D&lt;/code>（可加 &lt;code>-p tcp --dport &amp;lt;連接埠&amp;gt;&lt;/code> 縮小範圍）刪掉表裡那幾條，連線方重新連線時才會被新規則比對。表的容量有上限（&lt;code>nf_conntrack_max&lt;/code>），連線數很大的主機表滿時會丟掉新連線，核心記錄裡會出現 &lt;code>nf_conntrack: table full, dropping packet&lt;/code>。Docker 發佈的連接埠怎麼用它限制來源，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>雲端的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security Group</a> 放行一條入站連線之後，回程的封包不必另寫規則，因為它是「有狀態」的防火牆；Linux 主機上讓防火牆有狀態的元件是 conntrack（connection tracking，連線追蹤）。它是核心裡記錄每一條連線的表：兩端的位址與連接埠、協定、TCP 的連線階段（SYN_SENT、ESTABLISHED、TIME_WAIT 這類），以及經過位址轉換時原本與改寫後的兩組位址。防火牆規則比對的連線狀態（NEW、ESTABLISHED、RELATED、INVALID）是核心依這張表替每個封包推出來的。</p>
<h2 id="概念位置">概念位置</h2>
<p>iptables 的 <code>-m conntrack</code>、<code>-m state</code> 與 nftables 的 <code>ct</code> 都在讀這張表。它也是 <a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT</a> 與 SNAT（<a href="/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT Gateway</a> 做的那一種）能運作的前提：核心在連線的第一個封包改寫位址時，把原本與改寫後的對應記進表裡，同一條連線之後的封包與回程封包都照這個對應改寫。規則要比對位址轉換之前的值時，讀的就是表裡記下的「原始方向」（original）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong>防火牆規則開頭的 <code>ct state { established, related } accept</code></strong>：實測時 firewalld 的轉送鏈就以這一條起手，已經放行的連線之後的封包直接通過，規則只需要描述新連線。</li>
<li><strong>firewalld 的 <code>ct status dnat accept</code></strong>：在 <code>StrictForwardPorts=no</code>（預設值）下，所有經過目的位址轉換的連線在任何 policy 之前就被接受，Docker 發佈的連接埠因此不受 firewalld policy 約束。</li>
<li><strong><a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a> 裡的 <code>-m conntrack --ctorigdstport 8081 --ctdir ORIGINAL</code></strong>：封包的目的地已經改成容器的 80，用表裡記下的原始目的連接埠 8081 比對；<code>--ctdir ORIGINAL</code> 只比對發起方向的封包，回程封包不進這條規則。</li>
<li><strong><code>conntrack -L</code></strong>（套件 conntrack-tools）或 <code>/proc/net/nf_conntrack</code>：列出表裡每一條連線，一行裡有協定、TCP 階段、<code>src=</code>/<code>dst=</code>/<code>sport=</code>/<code>dport=</code> 兩組（第一組是原始方向，第二組是回程方向；兩組對不上就是經過了位址轉換），以及 <code>ASSURED</code> 這類旗標。</li>
</ul>
<h2 id="判讀方式">判讀方式</h2>
<p>一條防火牆規則比對的是封包現在的欄位，或是表裡記下的原始值，兩者在位址轉換之後不同：寫在位址轉換之後的規則，要比對原本的位址或連接埠時要用 <code>ctorig*</code> 這類條件，用 <code>--dport</code> 只比對得到改寫後的值。規則改了卻像沒生效時，原因常在已經存在的連線：ESTABLISHED 的連線多半在規則前面就被放行，新規則只擋得到之後的新連線；要讓既有的連線也受新規則約束，用 <code>conntrack -D</code>（可加 <code>-p tcp --dport &lt;連接埠&gt;</code> 縮小範圍）刪掉表裡那幾條，連線方重新連線時才會被新規則比對。表的容量有上限（<code>nf_conntrack_max</code>），連線數很大的主機表滿時會丟掉新連線，核心記錄裡會出現 <code>nf_conntrack: table full, dropping packet</code>。Docker 發佈的連接埠怎麼用它限制來源，見 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>。</p>
]]></content:encoded></item><item><title>DOCKER-USER（Docker 留給使用者的轉送規則鏈）</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/</guid><description>&lt;p>DOCKER-USER 是 Docker 在 iptables 的 filter 表裡建的一條自訂鏈：FORWARD 鏈的第一條規則無條件跳到它，Docker 自己不在裡面寫任何規則，啟動與重啟時也不清它的內容，讓使用者在 Docker 放行容器流量之前插入自己的限制。它管的是轉送路徑：Docker 發佈的連接埠經過 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT（目的位址轉換）&lt;/a> 改寫成容器位址之後，封包由主機轉送給容器，寫在主機入站規則（ufw 的入站規則、firewalld 的 zone）上的限制碰不到它，DOCKER-USER 是這條路上第一個可以寫規則的位置。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>FORWARD 鏈的跳轉順序是 DOCKER-USER、Docker 自己的 &lt;code>DOCKER-FORWARD&lt;/code>、然後才是 ufw 的 &lt;code>ufw-*&lt;/code> 鏈；&lt;code>DOCKER-FORWARD&lt;/code> 接受送往發佈連接埠的連線，所以寫在 ufw route 規則裡的限制輪不到這些封包，寫在 DOCKER-USER 的輪得到。封包到這條鏈時目的地已經是容器的位址與連接埠，限制發佈的連接埠要用 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack（連線追蹤）&lt;/a> 記下的原始目的連接埠比對：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctstate DNAT --ctorigdstport &lt;span class="m">443&lt;/span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個 conntrack 條件各管一件事：&lt;code>--ctorigdstport 443&lt;/code> 比對位址轉換之前的連接埠；&lt;code>--ctdir ORIGINAL&lt;/code> 只比對發起方向的封包，容器回覆的封包不拿來比對來源；&lt;code>--ctstate DNAT&lt;/code> 只比對經過位址轉換的連線，也就是送往發佈連接埠的那些。少了最後一個時，容器自己往外連 443、同一台主機上容器之間連 80 的封包也進這條鏈、原始目的連接埠一樣是 443 或 80，一起被丟掉（2026-10-26 在 Ubuntu 24.04 與 Docker 29.9.2 上實測）。&lt;/p>
&lt;p>這條鏈只存在 iptables 後端：Docker 改用 nftables 後端時沒有它（Docker 的〈firewall-nftables〉文件）。它也只管 IPv4 那一份發佈：容器網路沒開 IPv6 時，IPv6 那一份由主機上的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy（使用者空間的連接埠轉送程式）&lt;/a> 接收，不經過轉送路徑。主機上同時裝了 Docker 與以 root 執行的 Podman 時，Podman 的轉送封包也經過 DOCKER-USER（實測）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>iptables -S FORWARD&lt;/code> 的前兩行&lt;/strong>：&lt;code>-A FORWARD -j DOCKER-USER&lt;/code>、&lt;code>-A FORWARD -j DOCKER-FORWARD&lt;/code>。主機也裝了 Podman 時，最前面還有一條 &lt;code>-j NETAVARK_FORWARD&lt;/code>。&lt;/li>
&lt;li>&lt;strong>規則加了、從外面量過擋得住，過一陣子又失效&lt;/strong>：防火牆重新載入或重開機清掉了它，&lt;code>iptables -S DOCKER-USER&lt;/code> 只剩 &lt;code>-N DOCKER-USER&lt;/code> 這一行。&lt;/li>
&lt;li>&lt;strong>加了限制之後容器呼叫外部 API 逾時&lt;/strong>：規則只比對原始目的連接埠，沒有 &lt;code>--ctstate DNAT&lt;/code>，容器的出站連線也被丟掉。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>DOCKER-USER 的弱點在規則的存續，用 iptables 指令加的規則只存在執行中的核心裡，各種情形下的結果不同（2026-10-25 到 26 日在 Ubuntu 24.04 的 ufw 0.36.2、Oracle Linux 10.2 的 firewalld 2.4.3 上實測）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情形&lt;/th>
 &lt;th>規則&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>任何主機重開機&lt;/td>
 &lt;td>不在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>systemctl restart docker&lt;/code>&lt;/td>
 &lt;td>還在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ufw 的 &lt;code>ufw reload&lt;/code>，after.rules 沒有宣告這條鏈&lt;/td>
 &lt;td>還在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ufw 的 after.rules 宣告這條鏈&lt;/td>
 &lt;td>每次 reload 先清空、再寫入檔案裡的規則；用 iptables 指令另外加的不在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>firewalld 的 reload，nftables 後端、沒有 direct 設定&lt;/td>
 &lt;td>還在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>firewalld 的 reload，nftables 後端、有 direct rule 或 passthrough&lt;/td>
 &lt;td>不在（Oracle Cloud 的 Oracle Linux 平台映像自帶 passthrough）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>firewalld 的 reload，iptables 後端&lt;/td>
 &lt;td>不在&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>要讓規則留過重開機，ufw 主機把它寫進 &lt;code>/etc/ufw/after.rules&lt;/code>，同一台主機上的 DOCKER-USER 規則都要寫在那裡；firewalld 主機上改用掛在位址轉換之前的自建 nftables 表，或把限制放在雲端防火牆。direct 設定是主機當下的狀態，之後裝的軟體可能再加進來，用&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理&lt;/a>工具定期 reload 的主機上，規則被清掉之後直連就重新開著而沒有告警。實測的指令、輸出與 after.rules 的整份清單寫法見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>；各種限制寫法的取捨見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a> 的〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。&lt;/p></description><content:encoded><![CDATA[<p>DOCKER-USER 是 Docker 在 iptables 的 filter 表裡建的一條自訂鏈：FORWARD 鏈的第一條規則無條件跳到它，Docker 自己不在裡面寫任何規則，啟動與重啟時也不清它的內容，讓使用者在 Docker 放行容器流量之前插入自己的限制。它管的是轉送路徑：Docker 發佈的連接埠經過 <a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT（目的位址轉換）</a> 改寫成容器位址之後，封包由主機轉送給容器，寫在主機入站規則（ufw 的入站規則、firewalld 的 zone）上的限制碰不到它，DOCKER-USER 是這條路上第一個可以寫規則的位置。</p>
<h2 id="概念位置">概念位置</h2>
<p>FORWARD 鏈的跳轉順序是 DOCKER-USER、Docker 自己的 <code>DOCKER-FORWARD</code>、然後才是 ufw 的 <code>ufw-*</code> 鏈；<code>DOCKER-FORWARD</code> 接受送往發佈連接埠的連線，所以寫在 ufw route 規則裡的限制輪不到這些封包，寫在 DOCKER-USER 的輪得到。封包到這條鏈時目的地已經是容器的位址與連接埠，限制發佈的連接埠要用 <a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack（連線追蹤）</a> 記下的原始目的連接埠比對：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">iptables -I DOCKER-USER -p tcp -m conntrack --ctstate DNAT --ctorigdstport <span class="m">443</span> --ctdir ORIGINAL ! -s 173.245.48.0/20 -j DROP</span></span></code></pre></div><p>三個 conntrack 條件各管一件事：<code>--ctorigdstport 443</code> 比對位址轉換之前的連接埠；<code>--ctdir ORIGINAL</code> 只比對發起方向的封包，容器回覆的封包不拿來比對來源；<code>--ctstate DNAT</code> 只比對經過位址轉換的連線，也就是送往發佈連接埠的那些。少了最後一個時，容器自己往外連 443、同一台主機上容器之間連 80 的封包也進這條鏈、原始目的連接埠一樣是 443 或 80，一起被丟掉（2026-10-26 在 Ubuntu 24.04 與 Docker 29.9.2 上實測）。</p>
<p>這條鏈只存在 iptables 後端：Docker 改用 nftables 後端時沒有它（Docker 的〈firewall-nftables〉文件）。它也只管 IPv4 那一份發佈：容器網路沒開 IPv6 時，IPv6 那一份由主機上的 <a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy（使用者空間的連接埠轉送程式）</a> 接收，不經過轉送路徑。主機上同時裝了 Docker 與以 root 執行的 Podman 時，Podman 的轉送封包也經過 DOCKER-USER（實測）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong><code>iptables -S FORWARD</code> 的前兩行</strong>：<code>-A FORWARD -j DOCKER-USER</code>、<code>-A FORWARD -j DOCKER-FORWARD</code>。主機也裝了 Podman 時，最前面還有一條 <code>-j NETAVARK_FORWARD</code>。</li>
<li><strong>規則加了、從外面量過擋得住，過一陣子又失效</strong>：防火牆重新載入或重開機清掉了它，<code>iptables -S DOCKER-USER</code> 只剩 <code>-N DOCKER-USER</code> 這一行。</li>
<li><strong>加了限制之後容器呼叫外部 API 逾時</strong>：規則只比對原始目的連接埠，沒有 <code>--ctstate DNAT</code>，容器的出站連線也被丟掉。</li>
</ul>
<h2 id="判讀方式">判讀方式</h2>
<p>DOCKER-USER 的弱點在規則的存續，用 iptables 指令加的規則只存在執行中的核心裡，各種情形下的結果不同（2026-10-25 到 26 日在 Ubuntu 24.04 的 ufw 0.36.2、Oracle Linux 10.2 的 firewalld 2.4.3 上實測）：</p>
<table>
  <thead>
      <tr>
          <th>情形</th>
          <th>規則</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>任何主機重開機</td>
          <td>不在</td>
      </tr>
      <tr>
          <td><code>systemctl restart docker</code></td>
          <td>還在</td>
      </tr>
      <tr>
          <td>ufw 的 <code>ufw reload</code>，after.rules 沒有宣告這條鏈</td>
          <td>還在</td>
      </tr>
      <tr>
          <td>ufw 的 after.rules 宣告這條鏈</td>
          <td>每次 reload 先清空、再寫入檔案裡的規則；用 iptables 指令另外加的不在</td>
      </tr>
      <tr>
          <td>firewalld 的 reload，nftables 後端、沒有 direct 設定</td>
          <td>還在</td>
      </tr>
      <tr>
          <td>firewalld 的 reload，nftables 後端、有 direct rule 或 passthrough</td>
          <td>不在（Oracle Cloud 的 Oracle Linux 平台映像自帶 passthrough）</td>
      </tr>
      <tr>
          <td>firewalld 的 reload，iptables 後端</td>
          <td>不在</td>
      </tr>
  </tbody>
</table>
<p>要讓規則留過重開機，ufw 主機把它寫進 <code>/etc/ufw/after.rules</code>，同一台主機上的 DOCKER-USER 規則都要寫在那裡；firewalld 主機上改用掛在位址轉換之前的自建 nftables 表，或把限制放在雲端防火牆。direct 設定是主機當下的狀態，之後裝的軟體可能再加進來，用<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具定期 reload 的主機上，規則被清掉之後直連就重新開著而沒有告警。實測的指令、輸出與 after.rules 的整份清單寫法見 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 與 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>；各種限制寫法的取捨見 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a> 的〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。</p>
]]></content:encoded></item><item><title>Userland Proxy（使用者空間的連接埠轉送程式）</title><link>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/userland-proxy/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/knowledge-cards/userland-proxy/</guid><description>&lt;p>把連線送進容器有兩種做法。&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT（目的位址轉換）&lt;/a> 是核心在封包剛進主機時改寫目的地，封包本身被轉送給容器；userland proxy 是一個在主機上執行的使用者空間程式聽著那個連接埠，收下連線之後自己再開一條連線到容器，把兩條連線之間的資料來回搬。常見的三個是 Docker 的 docker-proxy、rootless Docker 的 rootlesskit，以及 rootless Podman 的 rootlessport。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>連線的終點是主機上的一個程式，所以封包走的是 input 路徑（目的地是主機本身程式的那一條），主機防火牆的入站規則（ufw 的入站規則、firewalld 的 zone）管得到它，跟 DNAT 那一類正好相反。代價在容器看到的來源：容器收到的連線是轉送程式開的，來源位址是轉送程式那一端的位址，不是外面的真實來源。這跟 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/reverse-proxy/" data-link-title="Reverse Proxy" data-link-desc="代替後端服務接收外部請求、再轉發到內部服務的中介層，承擔 TLS 終結、負載平衡與路由分流">Reverse Proxy（反向代理）&lt;/a> 換掉來源位址的道理相同，差別在反向代理常用 HTTP 標頭（&lt;code>X-Forwarded-For&lt;/code>）把原本的來源帶過去，這幾個轉送程式在 TCP 這一層搬資料，什麼都不帶。&lt;/p>
&lt;p>三個程式各在什麼時候出現：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>docker-proxy&lt;/strong>：以 root 執行的 Docker 用 &lt;code>-p&lt;/code> 發佈時，主機上 IPv4 與 IPv6 各有一份；IPv4 那一份從外面來的封包在 prerouting 就被 DNAT 改寫、到不了它，IPv6 那一份在容器網路沒開 IPv6 時由它接收。Docker 的 &lt;code>userland-proxy&lt;/code> 設成 &lt;code>false&lt;/code> 時它不啟動，IPv6 那一份也就沒有發佈（2026-10-26 在 Docker 29.9.2 上實測）。&lt;/li>
&lt;li>&lt;strong>rootlesskit&lt;/strong>：rootless Docker 的 dockerd 以一般使用者執行、不能改主機的 iptables，發佈的連接埠全部由它接聽。&lt;/li>
&lt;li>&lt;strong>rootlessport&lt;/strong>：rootless Podman 的對應程式，網路由 slirp4netns（Podman 4）或 pasta（Podman 5 的預設）提供。&lt;/li>
&lt;/ul>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>ss -ltnp&lt;/code> 列出這幾個程式聽著發佈的連接埠，nat 表裡沒有對應的 DNAT&lt;/strong>：&lt;code>users:((&amp;quot;rootlesskit&amp;quot;,pid=4085,fd=13))&lt;/code> 聽著 &lt;code>0.0.0.0:8083&lt;/code>，&lt;code>iptables -t nat -S | grep -c 8083&lt;/code> 回 0。&lt;/li>
&lt;li>&lt;strong>容器的存取紀錄只有閘道位址&lt;/strong>：2026-10-26 的實測裡，經由 docker-proxy 的 IPv6 連線與 rootless Docker 的連線，容器記到的來源都是 &lt;code>172.17.0.1&lt;/code>；rootless Podman 4.9 記到的是 slirp4netns 那張 tap 介面的 &lt;code>10.0.2.100&lt;/code>。&lt;/li>
&lt;li>&lt;strong>主機防火牆的入站規則對容器的連接埠有效&lt;/strong>：ufw 預設拒絕入站時兩個測試來源都連不到 rootless 容器的連接埠，加一條入站放行之後只有被放行的那個連得到。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>判斷一個發佈的連接埠屬於哪一類，先查有沒有規則把它的目的地改寫掉，再看誰在聽：&lt;code>nft list ruleset | grep 'dport &amp;lt;連接埠&amp;gt;'&lt;/code> 同時列得出經由 iptables-nft 寫的與原生 nftables 寫的改寫規則。查到改寫規則時是 DNAT 那一類，聽著連接埠的程式只是佔住它（Podman 的 &lt;code>conmon&lt;/code>、docker-proxy 的 IPv4 那一份），不算數；查不到時，聽著的轉送程式才是接收者。查表是預測，最終以從範圍外的來源實際連一次為準。屬於這一類時，限制寫在主機防火牆的入站規則上就有效，而容器裡依來源位址做的判斷（存取紀錄、速率限制、只信任來自 CDN 位址段的 &lt;code>CF-Connecting-IP&lt;/code>）都拿不到真正的來源；Docker 文件列的保留來源位址做法（RootlessKit v3 的 &lt;code>builtin&lt;/code> 加 &lt;code>userland-proxy: false&lt;/code>、slirp4netns 轉送方式、pasta 加 &lt;code>implicit&lt;/code>）這個站上沒有實跑。實測見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a> 的〈IPv6 那一份發佈：docker-proxy 的接收路徑與 ufw 入站規則的效果〉。&lt;/p></description><content:encoded><![CDATA[<p>把連線送進容器有兩種做法。<a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT（目的位址轉換）</a> 是核心在封包剛進主機時改寫目的地，封包本身被轉送給容器；userland proxy 是一個在主機上執行的使用者空間程式聽著那個連接埠，收下連線之後自己再開一條連線到容器，把兩條連線之間的資料來回搬。常見的三個是 Docker 的 docker-proxy、rootless Docker 的 rootlesskit，以及 rootless Podman 的 rootlessport。</p>
<h2 id="概念位置">概念位置</h2>
<p>連線的終點是主機上的一個程式，所以封包走的是 input 路徑（目的地是主機本身程式的那一條），主機防火牆的入站規則（ufw 的入站規則、firewalld 的 zone）管得到它，跟 DNAT 那一類正好相反。代價在容器看到的來源：容器收到的連線是轉送程式開的，來源位址是轉送程式那一端的位址，不是外面的真實來源。這跟 <a href="/blog/devops/infra/knowledge-cards/reverse-proxy/" data-link-title="Reverse Proxy" data-link-desc="代替後端服務接收外部請求、再轉發到內部服務的中介層，承擔 TLS 終結、負載平衡與路由分流">Reverse Proxy（反向代理）</a> 換掉來源位址的道理相同，差別在反向代理常用 HTTP 標頭（<code>X-Forwarded-For</code>）把原本的來源帶過去，這幾個轉送程式在 TCP 這一層搬資料，什麼都不帶。</p>
<p>三個程式各在什麼時候出現：</p>
<ul>
<li><strong>docker-proxy</strong>：以 root 執行的 Docker 用 <code>-p</code> 發佈時，主機上 IPv4 與 IPv6 各有一份；IPv4 那一份從外面來的封包在 prerouting 就被 DNAT 改寫、到不了它，IPv6 那一份在容器網路沒開 IPv6 時由它接收。Docker 的 <code>userland-proxy</code> 設成 <code>false</code> 時它不啟動，IPv6 那一份也就沒有發佈（2026-10-26 在 Docker 29.9.2 上實測）。</li>
<li><strong>rootlesskit</strong>：rootless Docker 的 dockerd 以一般使用者執行、不能改主機的 iptables，發佈的連接埠全部由它接聽。</li>
<li><strong>rootlessport</strong>：rootless Podman 的對應程式，網路由 slirp4netns（Podman 4）或 pasta（Podman 5 的預設）提供。</li>
</ul>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong><code>ss -ltnp</code> 列出這幾個程式聽著發佈的連接埠，nat 表裡沒有對應的 DNAT</strong>：<code>users:((&quot;rootlesskit&quot;,pid=4085,fd=13))</code> 聽著 <code>0.0.0.0:8083</code>，<code>iptables -t nat -S | grep -c 8083</code> 回 0。</li>
<li><strong>容器的存取紀錄只有閘道位址</strong>：2026-10-26 的實測裡，經由 docker-proxy 的 IPv6 連線與 rootless Docker 的連線，容器記到的來源都是 <code>172.17.0.1</code>；rootless Podman 4.9 記到的是 slirp4netns 那張 tap 介面的 <code>10.0.2.100</code>。</li>
<li><strong>主機防火牆的入站規則對容器的連接埠有效</strong>：ufw 預設拒絕入站時兩個測試來源都連不到 rootless 容器的連接埠，加一條入站放行之後只有被放行的那個連得到。</li>
</ul>
<h2 id="判讀方式">判讀方式</h2>
<p>判斷一個發佈的連接埠屬於哪一類，先查有沒有規則把它的目的地改寫掉，再看誰在聽：<code>nft list ruleset | grep 'dport &lt;連接埠&gt;'</code> 同時列得出經由 iptables-nft 寫的與原生 nftables 寫的改寫規則。查到改寫規則時是 DNAT 那一類，聽著連接埠的程式只是佔住它（Podman 的 <code>conmon</code>、docker-proxy 的 IPv4 那一份），不算數；查不到時，聽著的轉送程式才是接收者。查表是預測，最終以從範圍外的來源實際連一次為準。屬於這一類時，限制寫在主機防火牆的入站規則上就有效，而容器裡依來源位址做的判斷（存取紀錄、速率限制、只信任來自 CDN 位址段的 <code>CF-Connecting-IP</code>）都拿不到真正的來源；Docker 文件列的保留來源位址做法（RootlessKit v3 的 <code>builtin</code> 加 <code>userland-proxy: false</code>、slirp4netns 轉送方式、pasta 加 <code>implicit</code>）這個站上沒有實跑。實測見 <a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a> 與 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈IPv6 那一份發佈：docker-proxy 的接收路徑與 ufw 入站規則的效果〉。</p>
]]></content:encoded></item></channel></rss>