<?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>Firewalld on Tarragon</title><link>https://tarrragon.github.io/blog/tags/firewalld/</link><description>Recent content in Firewalld 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/firewalld/index.xml" rel="self" type="application/rss+xml"/><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>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></channel></rss>