<?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>Ufw on Tarragon</title><link>https://tarrragon.github.io/blog/tags/ufw/</link><description>Recent content in Ufw 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/ufw/index.xml" rel="self" type="application/rss+xml"/><item><title>ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事件說明&lt;/strong>：2026-10-26，在本機 macOS 的 OrbStack 2.2.5 建的一台 Ubuntu 24.04.5 機器上實測（Arm 架構，核心是 OrbStack 自己的 &lt;code>7.2.12-orbstack&lt;/code>，有 systemd、可以重開機）。ufw 0.36.2，iptables 1.8.10（nf_tables 後端），Docker CE 29.9.2 從 Docker 官方的 apt 套件庫安裝，測試容器是 &lt;code>busybox:1.37&lt;/code>。機器的 DNS 解析器是 OrbStack 提供的 &lt;code>0.250.250.200&lt;/code>。&lt;/p>&lt;/blockquote>
&lt;p>這篇記錄在一台用 ufw 的 Ubuntu 主機上，Docker 發佈的連接埠跟 ufw 規則之間的關係：用兩個 network namespace 模擬位址在 Cloudflare 範圍內與範圍外的來源、ufw 的入站規則與 route 規則對發佈連接埠的效果、FORWARD 鏈裡 Docker 與 ufw 的跳轉順序、&lt;code>DOCKER-USER&lt;/code> 規則在 &lt;code>ufw reload&lt;/code>、ufw 關閉再開啟、Docker 重啟與重開機之後還在不在、寫進 &lt;code>/etc/ufw/after.rules&lt;/code> 讓規則開機時載入的做法與這個檔案對非 ASCII 字元的限制、&lt;code>-p&lt;/code> 發佈在 IPv6 的那一份由 docker-proxy 接收而受 ufw 入站規則管、compose 自訂網路的容器經由 Docker 內建解析器 &lt;code>127.0.0.11&lt;/code> 查詢時封包由主機送出，以及清理。為什麼要只放行 CDN 的位址、各種限制寫法的取捨，寫在概念文章 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖&lt;/a>；firewalld 主機上的同一組實測在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a>，Podman 與 rootless Docker 在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址&lt;/a>。&lt;/p>
&lt;h2 id="前置條件ubuntu-主機ufw-與-docker">前置條件：Ubuntu 主機、ufw 與 Docker&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>一台有 systemd、可以重開機的 Ubuntu 主機，有 sudo&lt;/strong>：雲端 VM、本機 VM 或 OrbStack、Multipass 這類工具建的機器都行。Oracle Cloud 的 Ubuntu 平台映像例外：Oracle 的〈Images〉寫明不要在上面用 ufw 改規則，可能讓主機開不了機，見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則&lt;/a> 的〈防火牆規則的位置與合併方式〉。這次用 OrbStack 是因為免費、建與刪都在一分鐘內，代價是核心是 OrbStack 自己編的版本、不是 Ubuntu 的映像核心；下文量到的是 iptables、ufw 與 Docker 的行為，它們在 Ubuntu 的映像核心上的差異沒有比較。建法是 &lt;code>orb create ubuntu:noble verify-ufw&lt;/code>，之後用 &lt;code>orb -m verify-ufw sudo bash&lt;/code> 進 root shell。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事件說明</strong>：2026-10-26，在本機 macOS 的 OrbStack 2.2.5 建的一台 Ubuntu 24.04.5 機器上實測（Arm 架構，核心是 OrbStack 自己的 <code>7.2.12-orbstack</code>，有 systemd、可以重開機）。ufw 0.36.2，iptables 1.8.10（nf_tables 後端），Docker CE 29.9.2 從 Docker 官方的 apt 套件庫安裝，測試容器是 <code>busybox:1.37</code>。機器的 DNS 解析器是 OrbStack 提供的 <code>0.250.250.200</code>。</p></blockquote>
<p>這篇記錄在一台用 ufw 的 Ubuntu 主機上，Docker 發佈的連接埠跟 ufw 規則之間的關係：用兩個 network namespace 模擬位址在 Cloudflare 範圍內與範圍外的來源、ufw 的入站規則與 route 規則對發佈連接埠的效果、FORWARD 鏈裡 Docker 與 ufw 的跳轉順序、<code>DOCKER-USER</code> 規則在 <code>ufw reload</code>、ufw 關閉再開啟、Docker 重啟與重開機之後還在不在、寫進 <code>/etc/ufw/after.rules</code> 讓規則開機時載入的做法與這個檔案對非 ASCII 字元的限制、<code>-p</code> 發佈在 IPv6 的那一份由 docker-proxy 接收而受 ufw 入站規則管、compose 自訂網路的容器經由 Docker 內建解析器 <code>127.0.0.11</code> 查詢時封包由主機送出，以及清理。為什麼要只放行 CDN 的位址、各種限制寫法的取捨，寫在概念文章 <a href="/blog/devops/infra/03-network-foundation/origin-host-firewall/" data-link-title="CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖" data-link-desc="網站放在 CDN（例如 Cloudflare）後面時，源站主機的入站與出站限制：請求避開 CDN 直連源站的成因、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證的差別、只放行 CDN 位址的套用時機、Docker 發佈的連接埠繞過 ufw 與 firewalld 的路徑與各種限制寫法、CDN 位址送來的請求為什麼不等於 CDN 替自己的網域轉送、出站 DNS 限制的效果與漏洞、地理封鎖放在 CDN 邊緣的理由">CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</a>；firewalld 主機上的同一組實測在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>，Podman 與 rootless Docker 在 <a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a>。</p>
<h2 id="前置條件ubuntu-主機ufw-與-docker">前置條件：Ubuntu 主機、ufw 與 Docker</h2>
<ul>
<li>
<p><strong>一台有 systemd、可以重開機的 Ubuntu 主機，有 sudo</strong>：雲端 VM、本機 VM 或 OrbStack、Multipass 這類工具建的機器都行。Oracle Cloud 的 Ubuntu 平台映像例外：Oracle 的〈Images〉寫明不要在上面用 ufw 改規則，可能讓主機開不了機，見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈防火牆規則的位置與合併方式〉。這次用 OrbStack 是因為免費、建與刪都在一分鐘內，代價是核心是 OrbStack 自己編的版本、不是 Ubuntu 的映像核心；下文量到的是 iptables、ufw 與 Docker 的行為，它們在 Ubuntu 的映像核心上的差異沒有比較。建法是 <code>orb create ubuntu:noble verify-ufw</code>，之後用 <code>orb -m verify-ufw sudo bash</code> 進 root shell。</p>
</li>
<li>
<p><strong>登入路徑跟要測的連接埠分開</strong>：測試只動 8080、8081 與 IPv6 的 8081。用 SSH 登入雲端主機時，啟用 ufw 之前先 <code>ufw allow 22/tcp</code>（或只放行自己的位址），否則 <code>ufw enable</code> 之後連線會斷；OrbStack 經由自己的通道進機器，不受 ufw 影響。</p>
</li>
<li>
<p><strong>ufw 與 Docker</strong>：</p>





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





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





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





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





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




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





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




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





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





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




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





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





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





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





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




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





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




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





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




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





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




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





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




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





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




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





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




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





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





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





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




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





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




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





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




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





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




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





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





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





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




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





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




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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">podman rm -f verify-pod-web
</span></span><span class="line"><span class="ln">2</span><span class="cl">firewall-cmd --zone<span class="o">=</span>trusted --list-sources                 <span class="c1"># 還列得出 10.88.0.0/16 時接下一行</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">firewall-cmd --zone<span class="o">=</span>trusted --remove-source<span class="o">=</span>10.88.0.0/16   <span class="c1"># 只移除執行中的設定；沒有實跑</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">ip link del vcf-h<span class="p">;</span> ip link del voth-h<span class="p">;</span> ip netns del verify-cf<span class="p">;</span> ip netns del verify-other
</span></span><span class="line"><span class="ln">5</span><span class="cl">rm -rf /var/tmp/verify-fw</span></span></code></pre></div><p>firewalld 那一篇的清理段刪的是那一篇自己建的資源（靜態的 dockerd、ipset、nftables 表、<code>/var/lib/docker</code>），不要照它整段跑。這兩段清理指令跟實測時的順序不同：實測時兩台的清理分在幾次跑，沒有照這兩段一次跑完。</p>
<p><code>disable-linger</code> 之後，systemd 要過一會兒才停掉那個使用者的 user 服務；實測緊接著跑 <code>userdel</code> 時回 <code>userdel: user verifyrl is currently used by process 2749</code>、使用者沒有刪掉，所以中間等到 <code>pgrep -u verifyrl</code> 沒有輸出。<code>dockerd-rootless-setuptool.sh uninstall</code> 只移除 user 服務，印出的提示寫明不刪 Docker 的資料；<code>userdel -r</code> 刪掉家目錄時，那些資料（<code>~/.local/share/docker</code>）一起刪掉。</p>
<p>firewalld 那一台的 <code>trusted</code> zone 裡 <code>10.88.0.0/16</code> 的來源由 netavark 在容器網路存在時加入，最後一個用到預設網路的容器刪掉之後是否移除沒有確認。留著的影響是之後任何從 <code>10.88.0.0/16</code> 來的連線都由 <code>trusted</code> zone 處理、一律放行；這段位址只在主機內部的 Podman 網路上，外部來源用不到它。OrbStack 的機器整台刪掉時兩台一起刪：<code>orb delete verify-ufw verify-fwd</code>。</p>
<h2 id="rootful-與-rootless-的-dockerpodman接收路徑入站規則與容器看到的來源位址">rootful 與 rootless 的 Docker、Podman：接收路徑、入站規則與容器看到的來源位址</h2>
<table>
  <thead>
      <tr>
          <th>執行方式</th>
          <th>接收連線的路徑</th>
          <th>主機防火牆的入站規則是否管得到</th>
          <th>容器看到的來源位址</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>rootful Docker（參考，見 ufw 與 firewalld 那兩篇）</td>
          <td>IPv4 位址轉換後轉送；IPv6 由 docker-proxy 接收（容器網路沒開 IPv6、userland-proxy 預設開著時）</td>
          <td>IPv4 管不到；IPv6 管得到（ufw 與 firewalld 都量過）</td>
          <td>IPv4 是真正的來源；IPv6 是預設網路的閘道 <code>172.17.0.1</code>（ufw 那一篇量的）</td>
      </tr>
      <tr>
          <td>rootful Podman 4.9.3，ufw</td>
          <td>位址轉換後轉送；routed 是 deny 時被 FORWARD 的 DROP 擋下，是 allow 時放行</td>
          <td>管不到；ufw route 規則管得到，主機也裝了 Docker 時 <code>DOCKER-USER</code> 也管得到</td>
          <td>沒有記錄</td>
      </tr>
      <tr>
          <td>rootful Podman 5.8.2，firewalld</td>
          <td>位址轉換後轉送，firewalld 接受</td>
          <td>管不到</td>
          <td>沒有記錄</td>
      </tr>
      <tr>
          <td>rootless Docker 29.9.2</td>
          <td>主機上的 <code>rootlesskit</code></td>
          <td>管得到</td>
          <td><code>172.17.0.1</code></td>
      </tr>
      <tr>
          <td>rootless Podman 4.9.3</td>
          <td>主機上的 <code>rootlessport</code></td>
          <td>管得到</td>
          <td><code>10.0.2.100</code></td>
      </tr>
  </tbody>
</table>
<p>判斷一種執行方式落在哪一列，先查有沒有規則把那個連接埠的目的地改寫掉，再看誰在聽。改寫規則用 <code>nft list ruleset | grep 'dport &lt;連接埠&gt;'</code> 查：它同時列出經由 iptables-nft 寫的表（Docker、Podman 4.9 的 netavark）與原生 nftables 的表（Podman 5.8.2 的 <code>inet netavark</code>）。只看 <code>iptables -t nat -S</code> 會漏掉後一種：在 Oracle Linux 那一台另發佈一個 8090，<code>iptables -t nat -S | grep -c 8090</code> 回 0，<code>nft list ruleset</code> 列得出 <code>tcp dport 8090 dnat ip to 10.88.0.3:80</code>，同時 <code>ss -ltnp</code> 顯示 <code>conmon</code> 聽著 8090。查到改寫規則時，就是轉送路徑，結果取決於轉送鏈上每一張表的預設政策，聽著那個連接埠的程式（<code>conmon</code>、docker-proxy 的 IPv4 那一份）只是佔住連接埠，不算數；IPv6 的改寫規則（<code>ip6 nat</code> 或 <code>inet</code> 表裡帶 <code>ip6</code> 的那幾條）另外判一次。查不到改寫規則、由一個轉送程式聽著時（rootlesskit、rootlessport、rootful Docker 的 IPv6 那一份聽著的 docker-proxy），才是 input 路徑，入站規則管得到。查表的結果是預測，最終以範圍外的來源實際連一次為準（<code>probe &lt;連接埠&gt;</code>）；Kubernetes 的 NodePort 這類多跳改寫沒有測。</p>
]]></content:encoded></item></channel></rss>