<?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>模組三：網路地基 — VPC 與分層 on Tarragon</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/</link><description>Recent content in 模組三：網路地基 — VPC 與分層 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/index.xml" rel="self" type="application/rss+xml"/><item><title>網路地基 — VPC、subnet 分層與 security group 設計</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/</guid><description>&lt;p>網路地基要先於核心服務存在。&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/route-table/" data-link-title="Route Table" data-link-desc="掛在 subnet 上的流量轉送規則，決定封包離開 subnet 後往哪走">route table&lt;/a> 與 security group 構成一張「服務能落在哪、誰能跟誰講話」的地圖，資料庫、運算節點與對外入口都得落在這張地圖規劃好的格子裡。先把邊界畫清楚，後面每個核心服務上線時只需要選一塊已經定義好安全等級的位置，而不是邊開服務邊補洞。&lt;/p>
&lt;p>這篇文章建立四層邊界：最外層的 VPC 隔離、中層的 public / private subnet 切分、流量進出的 route table 與 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT&lt;/a>、以及最貼近服務的 security group。每一層解決的問題不同，疊起來才是一個可審計、可收斂的網路。&lt;/p>
&lt;h2 id="vpc網路隔離的最外層邊界">VPC：網路隔離的最外層邊界&lt;/h2>
&lt;p>VPC（Virtual Private Cloud）先圈定整個系統的網路地址空間 — 一塊邏輯隔離的私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。它是後面所有切分動作的容器 — 沒有 VPC，subnet 與 security group 無處依附。&lt;/p>
&lt;h3 id="cidr-規劃一次決定事後難改">CIDR 規劃：一次決定、事後難改&lt;/h3>
&lt;p>建立 VPC 時最關鍵的決策是 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR&lt;/a> 區塊的大小。這個範圍要一次規劃足夠大，因為事後擴張地址空間在多數雲上是麻煩且容易出錯的操作。AWS 雖然允許在 VPC 上追加 secondary CIDR，但追加的網段不能與原有的重疊，也不是所有服務都能自然使用跨 CIDR 的 subnet，routing 的複雜度會因此上升。&lt;/p>
&lt;p>CIDR 規劃要同時考慮三件事。第一是容量：&lt;code>/16&lt;/code> 提供約六萬五千個位址，對多數單一環境的 VPC 足夠寬裕，切成 &lt;code>/24&lt;/code> 的 subnet 也有 256 個可用子網。第二是不重疊：未來若要透過 VPC peering、Transit Gateway 或 VPN 把這個 VPC 接回地端機房或其他環境，重疊的 CIDR 會讓路由無法解析。三個環境各自是 &lt;code>10.0.0.0/16&lt;/code>，在彼此不需要互連時不是問題，但一旦要開 peering 就會撞車 — 這時候改 CIDR 的代價是重建整個 VPC。第三是預留：如果公司同時有多個 VPC（不同環境或不同產品線），用連續但不重疊的大段分配（如 dev &lt;code>10.0.0.0/16&lt;/code>、staging &lt;code>10.1.0.0/16&lt;/code>、prod &lt;code>10.2.0.0/16&lt;/code>）讓路由表更乾淨。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_vpc&amp;#34; &amp;#34;main&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="n"> cidr_block&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;10.0.0.0/16&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="n"> enable_dns_support&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> enable_dns_hostnames&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> Name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;platform-prod&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="n"> Environment&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>enable_dns_support&lt;/code> 和 &lt;code>enable_dns_hostnames&lt;/code> 在多數場景都該開啟。沒開 DNS hostname 時，EC2 instance 不會拿到可解析的 hostname，某些服務依賴 DNS 尋址而非 IP（如 VPC endpoint 的 private DNS），關著會讓它們靜靜失敗而不報錯。&lt;/p>
&lt;p>判讀訊號：規劃 CIDR 時先問「這個環境三年後會有幾個 subnet、跨幾個可用區、要不要跟其他 VPC 或地端互連」。風險集中在地址耗盡與網段衝突 — 兩者都得在開第一個 subnet 之前定案。VPC 只負責隔離與定址，它不決定哪個服務能對外，那是 subnet 與 security group 的工作。環境之間的 VPC 該怎麼分，是&lt;a href="https://tarrragon.github.io/blog/devops/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化&lt;/a>的主題。&lt;/p></description><content:encoded><![CDATA[<p>網路地基要先於核心服務存在。<a href="/blog/devops/infra/knowledge-cards/vpc/" data-link-title="VPC（Virtual Private Cloud）" data-link-desc="雲端帳號內的一塊邏輯隔離私有網段，是所有網路切分的起點與容器">VPC</a>、<a href="/blog/devops/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">subnet</a>、<a href="/blog/devops/infra/knowledge-cards/route-table/" data-link-title="Route Table" data-link-desc="掛在 subnet 上的流量轉送規則，決定封包離開 subnet 後往哪走">route table</a> 與 security group 構成一張「服務能落在哪、誰能跟誰講話」的地圖，資料庫、運算節點與對外入口都得落在這張地圖規劃好的格子裡。先把邊界畫清楚，後面每個核心服務上線時只需要選一塊已經定義好安全等級的位置，而不是邊開服務邊補洞。</p>
<p>這篇文章建立四層邊界：最外層的 VPC 隔離、中層的 public / private subnet 切分、流量進出的 route table 與 <a href="/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT</a>、以及最貼近服務的 security group。每一層解決的問題不同，疊起來才是一個可審計、可收斂的網路。</p>
<h2 id="vpc網路隔離的最外層邊界">VPC：網路隔離的最外層邊界</h2>
<p>VPC（Virtual Private Cloud）先圈定整個系統的網路地址空間 — 一塊邏輯隔離的私有網段，是其餘所有網路切分的起點。在 VPC 裡開出來的所有資源預設只看得到同一個 VPC 內的成員，與其他 VPC、與其他帳號的網路天然隔離。它是後面所有切分動作的容器 — 沒有 VPC，subnet 與 security group 無處依附。</p>
<h3 id="cidr-規劃一次決定事後難改">CIDR 規劃：一次決定、事後難改</h3>
<p>建立 VPC 時最關鍵的決策是 <a href="/blog/devops/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR</a> 區塊的大小。這個範圍要一次規劃足夠大，因為事後擴張地址空間在多數雲上是麻煩且容易出錯的操作。AWS 雖然允許在 VPC 上追加 secondary CIDR，但追加的網段不能與原有的重疊，也不是所有服務都能自然使用跨 CIDR 的 subnet，routing 的複雜度會因此上升。</p>
<p>CIDR 規劃要同時考慮三件事。第一是容量：<code>/16</code> 提供約六萬五千個位址，對多數單一環境的 VPC 足夠寬裕，切成 <code>/24</code> 的 subnet 也有 256 個可用子網。第二是不重疊：未來若要透過 VPC peering、Transit Gateway 或 VPN 把這個 VPC 接回地端機房或其他環境，重疊的 CIDR 會讓路由無法解析。三個環境各自是 <code>10.0.0.0/16</code>，在彼此不需要互連時不是問題，但一旦要開 peering 就會撞車 — 這時候改 CIDR 的代價是重建整個 VPC。第三是預留：如果公司同時有多個 VPC（不同環境或不同產品線），用連續但不重疊的大段分配（如 dev <code>10.0.0.0/16</code>、staging <code>10.1.0.0/16</code>、prod <code>10.2.0.0/16</code>）讓路由表更乾淨。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_vpc&#34; &#34;main&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  cidr_block</span>           <span class="o">=</span> <span class="s2">&#34;10.0.0.0/16&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  enable_dns_support</span>   <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  enable_dns_hostnames</span> <span class="o">=</span> <span class="kt">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">    Name</span>        <span class="o">=</span> <span class="s2">&#34;platform-prod&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">    Environment</span> <span class="o">=</span> <span class="s2">&#34;production&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}</span></span></code></pre></div><p><code>enable_dns_support</code> 和 <code>enable_dns_hostnames</code> 在多數場景都該開啟。沒開 DNS hostname 時，EC2 instance 不會拿到可解析的 hostname，某些服務依賴 DNS 尋址而非 IP（如 VPC endpoint 的 private DNS），關著會讓它們靜靜失敗而不報錯。</p>
<p>判讀訊號：規劃 CIDR 時先問「這個環境三年後會有幾個 subnet、跨幾個可用區、要不要跟其他 VPC 或地端互連」。風險集中在地址耗盡與網段衝突 — 兩者都得在開第一個 subnet 之前定案。VPC 只負責隔離與定址，它不決定哪個服務能對外，那是 subnet 與 security group 的工作。環境之間的 VPC 該怎麼分，是<a href="/blog/devops/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>的主題。</p>
<h2 id="public-與-private-subnet-的切分原則">public 與 private subnet 的切分原則</h2>
<p>一塊資源對外暴露到什麼程度，取決於它被放進哪個 subnet。VPC 內部按可用區與暴露程度切出來的子網段，決定資源有沒有一條通往網際網路的路徑。判斷一個資源該放 public 還是 private，問題只有一個：它需不需要被網際網路直接定址。</p>
<h3 id="兩類-subnet-的定位">兩類 subnet 的定位</h3>
<p>public subnet 放的是必須接收外部入站流量的元件 — 對外的負載平衡器、NAT Gateway、<a href="/blog/devops/infra/knowledge-cards/bastion-host/" data-link-title="Bastion Host（跳板機）" data-link-desc="內部主機不想各自對外開管理 port、要設定 ProxyJump 經由跳板機連線、判斷 agent forwarding 的風險，或要評估自建跳板機與改用身分感知代理的取捨時，查跳板機的連線方式、暴露面與維運責任">跳板機（bastion host）</a>。這些資源透過 route table 連到 Internet Gateway，因此能被外部 IP 直接觸及。private subnet 放的是只該在內網被存取的元件 — 應用伺服器、資料庫、快取、內部佇列。它們沒有通往 Internet Gateway 的路由，外部無法主動連入，需要對外時才透過 NAT 出去。</p>
<table>
  <thead>
      <tr>
          <th>Subnet 類型</th>
          <th>典型住戶</th>
          <th>對外路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>public</td>
          <td>對外 LB、NAT Gateway、跳板機</td>
          <td>經 Internet Gateway 雙向</td>
      </tr>
      <tr>
          <td>private</td>
          <td>應用節點、資料庫、快取、佇列</td>
          <td>僅經 NAT 單向出站、不可入站</td>
      </tr>
  </tbody>
</table>
<p>public subnet 的真實樣貌是「薄薄一層」：它通常只住負載平衡器與 NAT 這類入口設施，而不是業務邏輯。常見陷阱是為了 SSH 方便把應用伺服器直接開在 public subnet 並配公網 IP，等於把每一台業務主機的管理埠暴露在掃描流量下 — 全球的 bot 會在秒級頻率對公網 IP 的 22 埠嘗試登入。private subnet 的住戶反而是系統的主體 — 資料庫放這裡是因為它一旦能被外網定址，攻擊面就從「打穿入口層」變成「直接連資料庫埠試密碼」。</p>
<h3 id="跨可用區冗餘">跨可用區冗餘</h3>
<p>每個 subnet 綁定單一可用區（Availability Zone）。高可用設計通常是每種角色跨至少兩個可用區各開一個 subnet：兩個 public、兩個 private，讓單一可用區故障時另一區的同類 subnet 還能承接。subnet 的 CIDR 切法要留足空間 — 如果 VPC 是 <code>/16</code>，每個 subnet 用 <code>/20</code>（約四千個位址）可以在三個可用區各開 public + private 共六個 subnet，還有大量空間留給未來擴展。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">locals</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  azs</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ap-northeast-1a&#34;, &#34;ap-northeast-1c&#34;, &#34;ap-northeast-1d&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_subnet&#34; &#34;public&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  for_each</span>          <span class="o">=</span> <span class="k">toset</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  vpc_id</span>            <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  cidr_block</span>        <span class="o">=</span> <span class="k">cidrsubnet</span><span class="p">(</span><span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">cidr_block</span><span class="p">,</span> <span class="m">4</span><span class="p">,</span> <span class="k">index</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">,</span> <span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  availability_zone</span> <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">key</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;public-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_subnet&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  for_each</span>          <span class="o">=</span> <span class="k">toset</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  vpc_id</span>            <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">  cidr_block</span>        <span class="o">=</span> <span class="k">cidrsubnet</span><span class="p">(</span><span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">cidr_block</span><span class="p">,</span> <span class="m">4</span><span class="p">,</span> <span class="k">index</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">,</span> <span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">)</span> <span class="err">+</span> <span class="k">length</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">  availability_zone</span> <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">key</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;private-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln">21</span><span class="cl">}</span></span></code></pre></div><p><code>cidrsubnet</code> 函式自動切分子網段，避免手動計算 CIDR。第二個參數 <code>4</code> 表示在 <code>/16</code> 基礎上加 4 bit 得到 <code>/20</code>，第三個參數是序號。public 與 private 各佔不同序號區間，保證不重疊。</p>
<p>對外入口怎麼把流量分到跨可用區的 private 後端，是 運維層負載平衡的範圍。這裡只要確保 subnet 的地圖在多 AZ 下對稱。</p>
<h2 id="route-table-與-nat流量的進出路徑">route table 與 NAT：流量的進出路徑</h2>
<p>離開一個 subnet 的封包往哪走，逐條寫在 route table 這組轉送規則裡 — 它掛在 subnet 上，是封包出口方向的依據。一個 subnet 是 public 還是 private，技術上的差別就在它關聯的 route table 裡有沒有一條指向 Internet Gateway 的預設路由。subnet 的對外性質由它關聯的 route table 賦予，而非寫在 subnet 自身的屬性。</p>
<h3 id="public-與-private-的路由差異">public 與 private 的路由差異</h3>
<p>public subnet 的 route table 有一條 <code>0.0.0.0/0 → Internet Gateway</code>，讓未知目的地的流量直接出網、也讓外部可達。private subnet 的 route table 則把 <code>0.0.0.0/0</code> 指向 NAT Gateway。</p>
<p>NAT（Network Address Translation）解決的問題是：private subnet 的資源需要主動對外（拉套件、呼叫第三方 API、抓 OS 更新），但不能因此變得可被外部入站連入。NAT 讓出站流量借用一個公網位址出去、把回應導回原請求者，同時不開放任何外部主動發起的連線。</p>
<h3 id="每-az-一個-nat-vs-共享-nat-的取捨">每 AZ 一個 NAT vs 共享 NAT 的取捨</h3>
<p>AWS 的 NAT Gateway 有兩種模式。傳統的 zonal 模式綁定單一可用區：一個 NAT Gateway 放在某一個 public subnet 裡，也就在那個可用區裡。AWS 在 2025 年 11 月另外推出 regional 模式：一個 NAT Gateway 依工作負載所在的可用區自動擴展，不放進 public subnet，下面的單點取捨不適用（新的可用區出現工作負載後，擴展到那一區要等上一段時間），兩種模式的差別見 <a href="/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT Gateway 卡</a>。用 zonal 模式時，這帶來一個架構取捨：</p>
<p><strong>共享 NAT（成本優先）</strong>：全部 private subnet 的 route table 都指向同一個 NAT。用一份 NAT 成本服務整個 VPC，代價是把 NAT 所在的可用區變成出站方向的單點 — 該可用區故障時，所有 private subnet 的對外連線同時中斷，即使其他可用區的節點本身健康。</p>
<p><strong>每 AZ 一個 NAT（可用性優先）</strong>：每個可用區各放一個 NAT Gateway，並讓每一區的 private subnet route table 指向同區的 NAT。出站路徑與 subnet 的跨可用區冗餘對齊，單一 AZ 故障只影響該區。每個 NAT Gateway 的固定月費約 $32 加流量費 $0.045/GB 處理量。三個可用區各一個就是三倍固定費。這筆成本與業務對出站中斷的容忍度對齊——如果單一可用區故障導致全部出站中斷可接受（例如有重試機制），共享 NAT 的成本效益較高。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_eip&#34; &#34;nat&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> <span class="k">toset</span><span class="p">(</span><span class="k">local</span><span class="p">.</span><span class="k">azs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  domain</span>   <span class="o">=</span> <span class="s2">&#34;vpc&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  tags</span>     <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;nat-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_nat_gateway&#34; &#34;per_az&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  for_each</span>      <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">public</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  allocation_id</span> <span class="o">=</span> <span class="k">aws_eip</span><span class="p">.</span><span class="k">nat</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  subnet_id</span>     <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">  tags</span>          <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;nat-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_route_table&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  for_each</span> <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  vpc_id</span>   <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="k">route</span> {
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">    cidr_block</span>     <span class="o">=</span> <span class="s2">&#34;0.0.0.0/0&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">    nat_gateway_id</span> <span class="o">=</span> <span class="k">aws_nat_gateway</span><span class="p">.</span><span class="k">per_az</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</span>
</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">
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;private-rt-${each.key}&#34;</span> }
</span></span><span class="line"><span class="ln">24</span><span class="cl">}
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_route_table_association&#34; &#34;private&#34;</span> {
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="n">  for_each</span>       <span class="o">=</span> <span class="k">aws_subnet</span><span class="p">.</span><span class="k">private</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="n">  subnet_id</span>      <span class="o">=</span> <span class="k">each</span><span class="p">.</span><span class="k">value</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="n">  route_table_id</span> <span class="o">=</span> <span class="k">aws_route_table</span><span class="p">.</span><span class="k">private</span><span class="p">[</span><span class="k">each</span><span class="p">.</span><span class="k">key</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">}</span></span></code></pre></div><p>判讀訊號：private subnet 的服務拉不到外部套件、或第三方 API 全部逾時，先查它關聯的 route table 有沒有指向健康的 NAT；若只有某一個可用區的節點受影響，多半是那一區的 NAT 或其所在 subnet 出狀況。</p>
<h3 id="nat-的成本邊界">NAT 的成本邊界</h3>
<p>NAT Gateway 按處理流量計費（每 GB 一個費率），把大量出站流量長期走 NAT 會讓帳單可觀。常見的高流量場景包括：備份上傳到 S3、跨區資料同步、大量 API 呼叫。對於走向 AWS 自家服務的流量，成本效益較好的做法是用 VPC Endpoint（Gateway 型或 Interface 型）讓流量直連服務、繞過 NAT。S3 與 DynamoDB 的 Gateway Endpoint 是免費的，光是把 S3 備份流量從 NAT 改走 Gateway Endpoint 就能在流量大的環境省下可觀的費用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_vpc_endpoint&#34; &#34;s3&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  vpc_id</span>       <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  service_name</span> <span class="o">=</span> <span class="s2">&#34;com.amazonaws.ap-northeast-1.s3&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">  route_table_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="k">rt</span> <span class="k">in</span> <span class="k">aws_route_table</span><span class="p">.</span><span class="k">private</span> <span class="err">:</span> <span class="k">rt</span><span class="p">.</span><span class="k">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;s3-gateway-endpoint&#34;</span> }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>NAT 的數量取捨與出站成本的更完整討論在 <a href="/blog/devops/operations/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing 與成本監控告警；預算門檻、配額與停損層級的推算在本模組，各家的建置篇目前只有 GCP，放在雲端業者模組">運維 模組八：成本管理</a>。route table 與 NAT 只管「能不能出去、走哪條路」，至於某個埠允不允許連，是 security group 的職責。</p>
<h2 id="security-group-設計最小開放">security group 設計：最小開放</h2>
<p>一條連線究竟能不能打到某個埠，由 security group 逐埠拍板 — 它是掛在資源網卡（ENI）層級的有狀態防火牆，規則描述的是哪些來源連得進這個資源。它是貼著服務的最後一道網路邊界 — 即使封包順著 route table 抵達了 private subnet，security group 仍能逐埠決定放不放行。「有狀態」的意思是：放行一條入站連線後，對應的回應出站自動允許，規則只需描述入站方向想開放什麼。</p>
<h3 id="用-group-引用取代-ip-範圍">用 group 引用取代 IP 範圍</h3>
<p>設計原則是最小開放：每條規則只開「這個服務確實需要被誰連的那個埠」。資料庫的 security group 入站只允許來自應用層 security group 的資料庫埠，而不是某個 IP 範圍。用 security group 互相引用、而非寫死網段，是因為應用節點會隨擴縮而換 IP — 引用來源 group 讓規則跟著成員身分走、不跟著位址走。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_security_group&#34; &#34;app&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name_prefix</span> <span class="o">=</span> <span class="s2">&#34;app-&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  vpc_id</span>      <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  tags</span>        <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;app-sg&#34;</span> }
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_security_group&#34; &#34;database&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  name_prefix</span> <span class="o">=</span> <span class="s2">&#34;db-&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  vpc_id</span>      <span class="o">=</span> <span class="k">aws_vpc</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  tags</span>        <span class="o">=</span><span class="n"> { Name</span> <span class="o">=</span> <span class="s2">&#34;db-sg&#34;</span> }
</span></span><span class="line"><span class="ln">11</span><span class="cl">}
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_security_group_rule&#34; &#34;db_from_app&#34;</span> {
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  type</span>                     <span class="o">=</span> <span class="s2">&#34;ingress&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  from_port</span>                <span class="o">=</span> <span class="m">5432</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">  to_port</span>                  <span class="o">=</span> <span class="m">5432</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">  protocol</span>                 <span class="o">=</span> <span class="s2">&#34;tcp&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">  security_group_id</span>        <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">database</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">  source_security_group_id</span> <span class="o">=</span> <span class="k">aws_security_group</span><span class="p">.</span><span class="k">app</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">}</span></span></code></pre></div><p>這條規則表達的語意是「資料庫只接受來自 app group 成員的 5432 連線」。app 的 instance 數量從 2 台增長到 20 台時，規則本身不需要改 — 新 instance 只要也掛了 <code>app</code> 的 security group 就自動被允許。</p>
<h3 id="00000-的盤點紀律">0.0.0.0/0 的盤點紀律</h3>
<p>把入站來源設成 <code>0.0.0.0/0</code> 等於允許整個網際網路連這個埠。對資料庫埠（5432、3306、6379）或管理埠（22、3389）這麼做，會讓服務暴露在持續性的自動掃描與暴力嘗試下。</p>
<p>合理出現 <code>0.0.0.0/0</code> 的位置只有對外負載平衡器的 80 / 443 入站 — 因為它的工作本來就是接收公開流量。其餘所有 <code>0.0.0.0/0</code> 都該被質疑。</p>
<p>盤點 <code>0.0.0.0/0</code> 規則的人，要把沒有人刻意寫過的規則也算進去：業者預先建好的網路與啟動精靈建的規則本身就放行 22 port。Google Cloud 專案的 <code>default</code> 網路帶一條 <code>default-allow-ssh</code> 規則，放行來源 <code>0.0.0.0/0</code> 的 TCP 22；Oracle Cloud 主控台建新 VCN 時一起建的 Default Security List 也放行 TCP 22，官方說明只寫來源是「authorized source IP addresses」，實際建出來的來源值要打開那份 security list 看；AWS 的 EC2 啟動精靈建的 security group 預設放行進來的 SSH 流量，VPC 的 default security group 則不放行。Oracle Cloud、Google Cloud 與 AWS 預設規則的內容與收緊的位置，見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈各家預設網路規則對 SSH 的開放〉。下面的 CLI 查詢只掃得到 AWS 的 security group，Oracle Cloud 的 security list 與 Google Cloud 的 VPC 防火牆規則要在各自的主控台或 CLI 另外列。</p>
<p>盤點的做法：列出所有 security group，過濾 source 是 <code>0.0.0.0/0</code> 的 ingress rule，逐條問「這個埠確實需要全世界都連得到嗎」。在 CLI 上可以用一條查詢掃：</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">aws ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;SecurityGroups[].{
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">    ID:GroupId,
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    Name:GroupName,
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    OpenPorts:IpPermissions[?IpRanges[?CidrIp==`0.0.0.0/0`]].[FromPort,ToPort]
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">  }&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --output table</span></span></code></pre></div><p>資料庫埠、SSH、內部 API 出現在這份清單上就是該收斂的目標。管理埠的存取更安全的替代方案是 SSM Session Manager — 它讓管理員透過 IAM 權限建立 shell session，完全不需要開 22 埠，連線經由 Systems Manager 的控制通道走、不走公網，同時自動留下 session log。另一種把管理埠移出公網的做法是私網 overlay（mesh VPN，如 Tailscale）：SSH 只綁 tailnet 私網介面、公網不開任何埠，連得到私網位址本身即代表通過 tailnet 認證。tailnet 裡每一台裝置都符合這個條件，預設政策下它們彼此互通，要收窄到只有管理用的那一台連得到 SSH 得靠 ACL，見 <a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全</a>；tailnet 位址模型、NAT 穿透與第三方 control plane 取捨見 <a href="/blog/linux/tools/remote/tailscale-tailnet-and-relay/" data-link-title="Tailscale 深入：tailnet、MagicDNS、直連與 DERP 中繼" data-link-desc="要用 Tailscale 讓散在不同網路的機器互連、遇到連得到但延遲高、或想讀懂 tailscale status 的 relay / direct 標示時回來讀">Tailscale 深入</a>。這幾種做法各自改變了管理通道的哪一層、依身分來源與主機分布怎麼選，見<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>；GCP 上把預設的全開規則換成只放行 IAP 來源的機制與切換順序見 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道</a>，建立與停用規則的指令見 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>。誰能透過 IAM 改動這些規則，銜接<a href="/blog/devops/infra/02-identity-credentials/" data-link-title="模組二：身分與存取憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與存取憑證地基</a>。</p>
<p>在 CI 層面，<a href="/blog/devops/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>用 tfsec / checkov 做靜態掃描，自動攔截「敏感埠 + 全開 CIDR」的組合，把 security group 的盤點從人工定期做變成每次 PR 自動做。</p>
<p>邊界設備漏洞帶來的教訓同樣適用於 security group 設計。<a href="/blog/backend/07-security-data-protection/red-team/cases/edge-exposure/check-point-cve-2024-24919-vpn-info-disclosure/" data-link-title="7.R7.3.12 Check Point 2024：VPN 資訊外洩與會話風險" data-link-desc="邊界設備資訊外洩漏洞可快速轉為存取憑證與會話濫用風險">Check Point CVE-2024-24919</a> 事件顯示 VPN 邊界設備的資訊外洩漏洞可以直接轉為存取憑證與會話濫用，攻擊路徑是「邊界入口 → 會話竊取 → 內部橫向擴散」。<a href="/blog/backend/07-security-data-protection/red-team/cases/edge-exposure/citrix-bleed-2023-session-hijack/" data-link-title="7.R7.3.3 Citrix Bleed 2023：會話被劫持與重放風險" data-link-desc="邊界設備會話資料外洩後，如何演變成帳號與服務風險">Citrix Bleed 2023</a> 則是邊界設備的會話資料外洩導致重放攻擊。這兩個案例的 infra 層教訓是：邊界設備（VPN concentrator、ADC、bastion）的 security group 只開必要的管理埠，且事件後需要全域 session/token 失效流程。</p>
<p>網路控制面的自動化也有風險。<a href="/blog/backend/07-security-data-protection/cases/cloudflare-route-leak-2026/" data-link-title="7.C1 Cloudflare：2026 Route Leak 事件" data-link-desc="BGP 路由政策自動化失誤如何回寫控制面治理。">Cloudflare 2026 Route Leak</a> 事件中，自動化路由政策配置的錯誤導致流量擁塞。infra 層的教訓是：路由與 security group 規則的自動化變更需要 pre-check 與影響範圍評估，且要有快速撤回機制——這正是 <a href="/blog/devops/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>的 plan → review → apply 流程要擋的。</p>
<h2 id="nacl-與-security-group-的分工">NACL 與 security group 的分工</h2>
<p>subnet 這一層還有另一道防火牆 — network ACL（NACL），它與 security group 分工在兩個層級。</p>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>Security Group</th>
          <th>NACL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>掛在哪裡</td>
          <td>資源網卡（ENI）</td>
          <td>Subnet</td>
      </tr>
      <tr>
          <td>狀態</td>
          <td>有狀態（回程自動放行）</td>
          <td>無狀態（回程要另寫規則）</td>
      </tr>
      <tr>
          <td>規則方向</td>
          <td>只寫入站</td>
          <td>入站與出站各寫</td>
      </tr>
      <tr>
          <td>能否 deny</td>
          <td>只能列允許清單</td>
          <td>支援顯式 deny</td>
      </tr>
      <tr>
          <td>評估順序</td>
          <td>所有規則一起評估</td>
          <td>按規則編號順序，命中即停</td>
      </tr>
  </tbody>
</table>
<p>NACL 的特點是無狀態與支援顯式 deny。無狀態意味著放行了入站不代表回應的出站自動放行，回程封包得自己對得上另一條出站規則 — 這讓 NACL 的維護比 security group 複雜。支援顯式 deny 則是它獨有的能力：security group 只能說「誰可以進」，NACL 能說「誰一定不能進」，這在需要 subnet 邊界封鎖特定已知惡意網段時有用。</p>
<p>多數設計的主力是 security group：它貼著服務、用 group 互相引用就能表達「誰能連誰」，已經涵蓋大部分最小開放需求。NACL 留給少數情境 — 需要在 subnet 邊界擋掉一整段已知惡意網段、或要對某類流量做顯式 deny 時才展開。多數環境讓 NACL 維持預設全通、把存取控制集中在 security group，是可以接受的選擇。Oracle Cloud 與 Google Cloud 的這兩層規則合併方式不同（Oracle Cloud 的 security list 與 NSG 取聯集、Google Cloud 只有 VPC 一層），見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈防火牆規則的位置與合併方式〉。重點是知道這一層存在、在需要 subnet 層粗篩時記得它。</p>
<h2 id="為什麼網路要先於核心服務鋪好">為什麼網路要先於核心服務鋪好</h2>
<p>網路地基先行，是因為核心服務的安全位置由網路拓樸決定，而不是反過來。資料庫該落在哪個 private subnet、它的 security group 只接受哪個來源、它的出站走不走 NAT — 這些都是服務「出生時」就該確定的屬性。</p>
<p>先有規劃好的 subnet 與 security group，新服務上線只是挑一塊已定義安全等級的位置放進去。網路還沒鋪就先開服務，則往往落在預設 VPC 與寬鬆規則上。預設 VPC 是所有人共享的、CIDR 不可控的、security group 預設全通的 — 把正式服務放在這裡，等於跳過了所有隔離設計。事後再回頭收斂，要在服務已經有流量、有依賴的情況下改網段與防火牆，風險和協調成本都高得多。</p>
<p>這也呼應<a href="/blog/devops/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零：infra 是什麼</a>的 day-1 鐵律：邊界與隔離屬於一開始就該存在的地基，不是長出問題後才補的修補。網路規劃好之後，照「從零建置」路線下一步先進<a href="/blog/devops/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>確定環境怎麼切，再讓核心服務落進這些 subnet（見<a href="/blog/devops/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>）。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/devops/infra/02-identity-credentials/" data-link-title="模組二：身分與存取憑證地基 — IAM 與 OIDC" data-link-desc="IAM role / policy 設計、最小權限，以及用 OIDC 短期憑證取代長期 access key">模組二：身分與存取憑證地基</a>：誰有權改動 security group 與路由表</li>
<li>→ <a href="/blog/devops/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：環境之間的 VPC 怎麼分</li>
<li>→ <a href="/blog/devops/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>：核心服務怎麼落進規劃好的 subnet</li>
<li>→ <a href="/blog/devops/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：tfsec / checkov 自動攔截 security group 全開</li>
<li>→ <a href="/blog/devops/operations/08-cost-management/" data-link-title="模組八：成本管理" data-link-desc="雲端帳單怎麼不失控 — reserved instance、spot instance、right-sizing 與成本監控告警；預算門檻、配額與停損層級的推算在本模組，各家的建置篇目前只有 GCP，放在雲端業者模組">運維 模組八：成本管理</a>：NAT 與出站流量的成本取捨</li>
<li>→ <a href="/blog/devops/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">Security Group 稽核與清理</a>：SG 規則盤點、未使用 SG 識別、清理工作流</li>
</ul>
]]></content:encoded></item><item><title>Security Group 稽核與清理</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/security-group-audit-cleanup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/security-group-audit-cleanup/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security group&lt;/a> 的規則會隨時間累積：某次救火加了一條 0.0.0.0/0、某個已下線的服務留下沒人認領的 SG、某條規則的用途只存在建立者的記憶裡。稽核的目標是把這些累積的規則攤開來，逐條回答「這條規則還有在用嗎、來源該這麼寬嗎」，然後安全地清理不需要的部分。&lt;/p>
&lt;h2 id="匯出所有-security-group-與規則">匯出所有 security group 與規則&lt;/h2>
&lt;p>稽核的第一步是把當前所有 SG 和它們的規則拉出來存成可查詢的 JSON。這份 JSON 是後續所有分析的輸入，也是「稽核那天環境長什麼樣」的快照。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">aws ec2 describe-security-groups &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --query &lt;span class="s1">&amp;#39;SecurityGroups[].{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s1"> GroupId:GroupId,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s1"> GroupName:GroupName,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s1"> VpcId:VpcId,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s1"> Description:Description,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> IngressRules:IpPermissions,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s1"> EgressRules:IpPermissionsEgress,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> Tags:Tags
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output json &amp;gt; sg-inventory-&lt;span class="k">$(&lt;/span>date +%Y%m%d&lt;span class="k">)&lt;/span>.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這份檔案通常幾百 KB 到幾 MB，存進 repo 的 &lt;code>inventory/&lt;/code> 目錄，方便日後比對變化。如果帳號有多個 region，每個 region 各跑一次並標明 region。&lt;/p>
&lt;p>用 jq 快速看有多少 SG 和總規則數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">jq &lt;span class="s1">&amp;#39;length&amp;#39;&lt;/span> sg-inventory-*.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">jq &lt;span class="s1">&amp;#39;[.[].IngressRules | length] | add&amp;#39;&lt;/span> sg-inventory-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="找出-00000-全開的入站規則">找出 0.0.0.0/0 全開的入站規則&lt;/h2>
&lt;p>0.0.0.0/0 入站代表允許整個網際網路連到這個埠。對外 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> 的 80/443 開 0.0.0.0/0 是設計意圖，但資料庫埠（5432、3306、6379）、SSH（22）或管理埠開 0.0.0.0/0 是需要收斂的目標。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">jq -r &lt;span class="s1">&amp;#39;.[] | select(.IngressRules[]?.IpRanges[]?.CidrIp == &amp;#34;0.0.0.0/0&amp;#34;) |
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="s1"> {GroupId, GroupName, OpenPorts: [.IngressRules[] |
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="s1"> select(.IpRanges[]?.CidrIp == &amp;#34;0.0.0.0/0&amp;#34;) |
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="s1"> &amp;#34;\(.FromPort // &amp;#34;all&amp;#34;)-\(.ToPort // &amp;#34;all&amp;#34;)/\(.IpProtocol)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="s1"> ]}&amp;#39;&lt;/span> sg-inventory-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸出會列出每個有全開規則的 SG 和對應的 port 範圍。對每一條命中，判斷：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>全開是否合規&lt;/th>
 &lt;th>處理方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>ALB 的 80/443&lt;/td>
 &lt;td>合規 — 負載平衡器的職責就是接收公開流量&lt;/td>
 &lt;td>保留，標記為已審查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SSH (22) 或 RDP (3389)&lt;/td>
 &lt;td>需收斂 — 管理埠暴露在持續的暴力掃描下&lt;/td>
 &lt;td>改用 SSM Session Manager 或限縮到辦公室 IP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料庫埠 (5432/3306/6379)&lt;/td>
 &lt;td>需收斂 — 資料庫不應從公網可達&lt;/td>
 &lt;td>改為只允許應用層 SG 來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>全埠 (0-65535 / -1)&lt;/td>
 &lt;td>需收斂 — 等於沒有防火牆&lt;/td>
 &lt;td>拆成具體需要的埠和來源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>管理埠的替代做法（跳板機、VPN、身分感知代理、代理程式反向連線）各自改變哪一層、切換時怎麼避免把自己鎖在外面，見&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計&lt;/a>。&lt;/p>
&lt;p>IPv6 的 &lt;code>::/0&lt;/code> 也要一併查：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">jq -r &lt;span class="s1">&amp;#39;.[] | select(.IngressRules[]?.Ipv6Ranges[]?.CidrIpv6 == &amp;#34;::/0&amp;#34;) |
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="s1"> .GroupId&amp;#39;&lt;/span> sg-inventory-*.json&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="找出未使用的-security-group">找出未使用的 security group&lt;/h2>
&lt;p>未使用的 SG 是沒有任何網路介面（ENI）掛載的 SG。它不影響任何正在運行的資源，但佔用 SG 配額（每個 VPC 預設上限 2500 個），而且它的規則會讓稽核清單更長、更難判讀。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">Security group</a> 的規則會隨時間累積：某次救火加了一條 0.0.0.0/0、某個已下線的服務留下沒人認領的 SG、某條規則的用途只存在建立者的記憶裡。稽核的目標是把這些累積的規則攤開來，逐條回答「這條規則還有在用嗎、來源該這麼寬嗎」，然後安全地清理不需要的部分。</p>
<h2 id="匯出所有-security-group-與規則">匯出所有 security group 與規則</h2>
<p>稽核的第一步是把當前所有 SG 和它們的規則拉出來存成可查詢的 JSON。這份 JSON 是後續所有分析的輸入，也是「稽核那天環境長什麼樣」的快照。</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">aws ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;SecurityGroups[].{
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s1">    GroupId:GroupId,
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">    GroupName:GroupName,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">    VpcId:VpcId,
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">    Description:Description,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">    IngressRules:IpPermissions,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">    EgressRules:IpPermissionsEgress,
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    Tags:Tags
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  }&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --output json &gt; sg-inventory-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span>.json</span></span></code></pre></div><p>這份檔案通常幾百 KB 到幾 MB，存進 repo 的 <code>inventory/</code> 目錄，方便日後比對變化。如果帳號有多個 region，每個 region 各跑一次並標明 region。</p>
<p>用 jq 快速看有多少 SG 和總規則數：</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">jq <span class="s1">&#39;length&#39;</span> sg-inventory-*.json
</span></span><span class="line"><span class="ln">2</span><span class="cl">jq <span class="s1">&#39;[.[].IngressRules | length] | add&#39;</span> sg-inventory-*.json</span></span></code></pre></div><h2 id="找出-00000-全開的入站規則">找出 0.0.0.0/0 全開的入站規則</h2>
<p>0.0.0.0/0 入站代表允許整個網際網路連到這個埠。對外 <a href="/blog/devops/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> 的 80/443 開 0.0.0.0/0 是設計意圖，但資料庫埠（5432、3306、6379）、SSH（22）或管理埠開 0.0.0.0/0 是需要收斂的目標。</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">jq -r <span class="s1">&#39;.[] | select(.IngressRules[]?.IpRanges[]?.CidrIp == &#34;0.0.0.0/0&#34;) |
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">  {GroupId, GroupName, OpenPorts: [.IngressRules[] |
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">    select(.IpRanges[]?.CidrIp == &#34;0.0.0.0/0&#34;) |
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    &#34;\(.FromPort // &#34;all&#34;)-\(.ToPort // &#34;all&#34;)/\(.IpProtocol)&#34;
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">  ]}&#39;</span> sg-inventory-*.json</span></span></code></pre></div><p>輸出會列出每個有全開規則的 SG 和對應的 port 範圍。對每一條命中，判斷：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>全開是否合規</th>
          <th>處理方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ALB 的 80/443</td>
          <td>合規 — 負載平衡器的職責就是接收公開流量</td>
          <td>保留，標記為已審查</td>
      </tr>
      <tr>
          <td>SSH (22) 或 RDP (3389)</td>
          <td>需收斂 — 管理埠暴露在持續的暴力掃描下</td>
          <td>改用 SSM Session Manager 或限縮到辦公室 IP</td>
      </tr>
      <tr>
          <td>資料庫埠 (5432/3306/6379)</td>
          <td>需收斂 — 資料庫不應從公網可達</td>
          <td>改為只允許應用層 SG 來源</td>
      </tr>
      <tr>
          <td>全埠 (0-65535 / -1)</td>
          <td>需收斂 — 等於沒有防火牆</td>
          <td>拆成具體需要的埠和來源</td>
      </tr>
  </tbody>
</table>
<p>管理埠的替代做法（跳板機、VPN、身分感知代理、代理程式反向連線）各自改變哪一層、切換時怎麼避免把自己鎖在外面，見<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>。</p>
<p>IPv6 的 <code>::/0</code> 也要一併查：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">jq -r <span class="s1">&#39;.[] | select(.IngressRules[]?.Ipv6Ranges[]?.CidrIpv6 == &#34;::/0&#34;) |
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">  .GroupId&#39;</span> sg-inventory-*.json</span></span></code></pre></div><h2 id="找出未使用的-security-group">找出未使用的 security group</h2>
<p>未使用的 SG 是沒有任何網路介面（ENI）掛載的 SG。它不影響任何正在運行的資源，但佔用 SG 配額（每個 VPC 預設上限 2500 個），而且它的規則會讓稽核清單更長、更難判讀。</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">aws ec2 describe-network-interfaces <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;NetworkInterfaces[].Groups[].GroupId&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --output text <span class="p">|</span> tr <span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;\n&#39;</span> <span class="p">|</span> sort -u &gt; sg-in-use.txt
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">jq -r <span class="s1">&#39;.[].GroupId&#39;</span> sg-inventory-*.json <span class="p">|</span> sort -u &gt; sg-all.txt
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl">comm -23 sg-all.txt sg-in-use.txt &gt; sg-unused.txt
</span></span><span class="line"><span class="ln">8</span><span class="cl">cat sg-unused.txt</span></span></code></pre></div><p><code>sg-unused.txt</code> 裡列出的就是當前沒有任何 ENI 引用的 SG。注意幾個例外：</p>
<ul>
<li><strong>default SG</strong>：每個 VPC 都有一個 default SG，即使未使用也無法刪除，可以跳過</li>
<li><strong>被其他 SG 引用</strong>：某個 SG 雖然沒有掛在任何 ENI 上，但被另一個 SG 的入站規則引用為 source — 刪除它會讓引用方的規則失效</li>
<li><strong>被 launch template 或 auto-scaling group 引用</strong>：新啟動的實例會套用這個 SG，刪了之後新實例啟動會失敗</li>
</ul>
<h2 id="依賴檢查刪除前確認沒有間接引用">依賴檢查：刪除前確認沒有間接引用</h2>
<p>直接刪一個 SG 之前，確認沒有其他資源引用它。AWS 在 SG 被引用時會擋住刪除（報 DependencyViolation），但提前知道引用方可以避免白跑一趟。</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">SG_ID</span><span class="o">=</span><span class="s2">&#34;sg-0abc123&#34;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># 哪些 SG 的入站規則引用了這個 SG 作為來源</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">jq -r --arg sg <span class="s2">&#34;</span><span class="nv">$SG_ID</span><span class="s2">&#34;</span> <span class="s1">&#39;.[] |
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">  select(.IngressRules[]?.UserIdGroupPairs[]?.GroupId == $sg) |
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">  &#34;\(.GroupId) (\(.GroupName)) 的入站規則引用了 \($sg)&#34;&#39;</span> sg-inventory-*.json
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 哪些 ENI 掛了這個 SG</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">aws ec2 describe-network-interfaces <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --filters <span class="nv">Name</span><span class="o">=</span>group-id,Values<span class="o">=</span><span class="nv">$SG_ID</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;NetworkInterfaces[].{Id:NetworkInterfaceId,Desc:Description,Status:Status}&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  --output table
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"># 哪些 RDS instance 使用這個 SG</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">aws rds describe-db-instances <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --query <span class="s2">&#34;DBInstances[?VpcSecurityGroups[?VpcSecurityGroupId==&#39;</span><span class="nv">$SG_ID</span><span class="s2">&#39;]].[DBInstanceIdentifier]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="se"></span>  --output text
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"># 哪些 ELB 使用這個 SG</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">aws elbv2 describe-load-balancers <span class="se">\
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="se"></span>  --query <span class="s2">&#34;LoadBalancers[?SecurityGroups[?contains(@,&#39;</span><span class="nv">$SG_ID</span><span class="s2">&#39;)]].[LoadBalancerName]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="se"></span>  --output text</span></span></code></pre></div><p>如果所有查詢都回傳空，這個 SG 可以安全刪除。</p>
<h2 id="清理流程標記--通知--等待--刪除">清理流程：標記 → 通知 → 等待 → 刪除</h2>
<p>批量清理不是一次 <code>delete-security-group</code> 的事。安全的流程有四步：</p>
<h3 id="標記候選">標記候選</h3>
<p>對每個要清理的 SG 加一個 tag 標明狀態和預定刪除日期：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws ec2 create-tags <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --resources sg-0abc123 sg-0def456 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --tags <span class="nv">Key</span><span class="o">=</span>cleanup-status,Value<span class="o">=</span>pending-deletion <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>         <span class="nv">Key</span><span class="o">=</span>cleanup-date,Value<span class="o">=</span>2026-07-10 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>         <span class="nv">Key</span><span class="o">=</span>cleanup-reason,Value<span class="o">=</span><span class="s2">&#34;unused-no-eni-no-reference&#34;</span></span></span></code></pre></div><h3 id="通知">通知</h3>
<p>如果 SG 有 <code>owner</code> tag，通知該 owner：「這個 SG 預計在 cleanup-date 刪除，如果仍在使用請回報」。如果沒有 owner tag（多數需要清理的 SG 都沒有），在團隊頻道公告清理清單。</p>
<h3 id="等待">等待</h3>
<p>給 7-14 天的寬限期。期間如果有人回報某個 SG 仍在使用，把 cleanup-status 改成 <code>retained</code> 並補上正確的 owner tag。</p>
<h3 id="刪除">刪除</h3>
<p>寬限期過後，對仍是 <code>pending-deletion</code> 的 SG 執行刪除：</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="k">for</span> sg in <span class="k">$(</span>aws ec2 describe-security-groups <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --filters <span class="nv">Name</span><span class="o">=</span>tag:cleanup-status,Values<span class="o">=</span>pending-deletion <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;SecurityGroups[].GroupId&#39;</span> --output text<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;Deleting </span><span class="nv">$sg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  aws ec2 delete-security-group --group-id <span class="nv">$sg</span> 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"><span class="k">done</span></span></span></code></pre></div><p>DependencyViolation 代表有遺漏的引用，跳過該 SG 並重新調查。</p>
<h2 id="自動化持續治理">自動化持續治理</h2>
<p>手動稽核適合第一次清理，持續治理靠自動化：</p>
<h3 id="aws-config-規則">AWS Config 規則</h3>
<p><code>restricted-ssh</code> 和 <code>restricted-common-ports</code> 是 AWS Config 的 managed rule，啟用後會持續監控 SG 規則，新增的 0.0.0.0/0 規則會在幾分鐘內被標記為 non-compliant。</p>
<h3 id="prowler-定期掃描">Prowler 定期掃描</h3>
<p>在 CI 排程中定期跑 Prowler，掃描結果存進 repo 作為趨勢追蹤：</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">prowler aws --services ec2 --checks ec2_securitygroup_allow_ingress_from_internet_to_any_port <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -M json-ocsf -o inventory/prowler/</span></span></code></pre></div><h3 id="pr-流程攔截">PR 流程攔截</h3>
<p><a href="/blog/devops/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七的 checkov/tfsec 護欄</a>在 PR 階段攔截新增的 0.0.0.0/0 規則。這是把治理從「事後稽核」推到「事前攔截」的關鍵一步：稽核能發現已存在的問題，PR 護欄能阻止新問題被引入。</p>
<p>AWS Security Hub 啟用 Foundational Security Best Practices 標準後，會自動聚合 SG 相關的合規 finding 並提供統一 dashboard，適合作為管理層報告的來源。Security Hub 整合了 Config rules 和 Prowler 各自能發現的問題，提供單一窗口追蹤合規趨勢。</p>
<h2 id="稽核節奏">稽核節奏</h2>
<p>第一次稽核最花時間（半天到一天，取決於 SG 數量）。之後的節奏取決於環境變動速度：</p>
<table>
  <thead>
      <tr>
          <th>環境類型</th>
          <th>建議節奏</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>有 PR 流程 + checkov 的環境</td>
          <td>每季</td>
          <td>新規則已被 PR 攔截，稽核主要看 drift</td>
      </tr>
      <tr>
          <td>有 IaC 但沒有 PR 護欄</td>
          <td>每月</td>
          <td>手動 apply 可能繞過審查</td>
      </tr>
      <tr>
          <td>全手動環境</td>
          <td>每月或每次事故後</td>
          <td>沒有任何自動攔截機制</td>
      </tr>
  </tbody>
</table>
<p>稽核產出一份報告：SG 總數、0.0.0.0/0 規則數、未使用 SG 數、上次稽核以來的變化。這份報告可以作為治理進度的量化指標，納入月報。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — security group 設計</a>：SG 的設計原則（最小開放、group 互相引用）</li>
<li>→ <a href="/blog/devops/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：checkov/tfsec 在 PR 階段攔截 0.0.0.0/0</li>
<li>→ <a href="/blog/devops/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">治理好習慣 — tagging</a>：tag 是識別 SG owner 和清理候選的依據</li>
</ul>
]]></content:encoded></item><item><title>流量入口層 — 請求怎麼從使用者到達應用</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/traffic-entry-layer/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/traffic-entry-layer/</guid><description>&lt;p>流量入口層是一條請求從使用者鍵入網址到抵達應用程式所經過的責任鏈。每一段各自承擔一件事：把域名解析成位址、把流量分到還活著的後端、在後端前面終結加密與分流、最後才由應用處理業務邏輯。這條鏈的存在是為了把「對外的穩定介面」跟「對內的可變後端」分開——後端可以增減、替換、搬家，使用者連的入口位址不變。&lt;/p>
&lt;p>共享主機時代這條鏈是隱形的。cPanel 或 Plesk 把域名、TLS 憑證、web server、應用執行環境全部包在同一台機器、同一套面板後面，使用者只需要在面板點幾下綁定網域、開啟 AutoSSL，整條入口鏈就「自己接好了」。接手一個自管環境（VPS 或雲端）之後，這些原本被面板吸收掉的步驟，每一個都變回一個要自己下的決定：DNS 指向哪裡、要不要一層負載平衡、TLS 在哪一層解開、靜態檔案誰來回。&lt;/p>
&lt;p>這篇文章要建立的判斷力是「我的服務需要幾層入口、每層放什麼」，而不是某一種 web server 的配置語法。責任鏈的每一段先講清楚它承擔什麼、什麼訊號代表它出問題，再把兩個核心的架構選擇（TLS 終結放哪層、單機 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/reverse-proxy/" data-link-title="Reverse Proxy" data-link-desc="代替後端服務接收外部請求、再轉發到內部服務的中介層，承擔 TLS 終結、負載平衡與路由分流">reverse proxy&lt;/a> 還是雲端負載平衡器）展開成可操作的判斷標準。runtime 的調校（負載分散演算法、健康檢查參數、timeout 串接）是&lt;a href="https://tarrragon.github.io/blog/devops/operations/01-load-balancing/" data-link-title="模組一：負載平衡與反向代理" data-link-desc="流量進來怎麼分給多個服務實例 — 反向代理職責、負載分散演算法、nginx 實務配置與健康檢查路由設計">運維 模組一：負載平衡與反向代理&lt;/a>的範圍，這裡先把入口層的地圖畫出來。&lt;/p>
&lt;h2 id="從一個網址到一個回應責任鏈的四次交棒">從一個網址到一個回應：責任鏈的四次交棒&lt;/h2>
&lt;p>一條 HTTP 請求從使用者到應用，中間經過最多四段獨立的責任，每一段把處理好的結果交棒給下一段。理解入口層就是理解這四次交棒各自完成了什麼。&lt;/p>
&lt;p>第一段是 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS&lt;/a> 解析：使用者的瀏覽器拿著域名去問 DNS，換回一個可連線的位址。第二段是負載平衡：流量抵達入口位址後，由一層負載平衡器從多台後端裡挑一台還健康的送過去。第三段是 reverse proxy：在後端應用前面，由它解開 HTTPS 加密、依 URL 決定送到哪個服務、把靜態檔案直接回掉。第四段才是應用伺服器：跑業務邏輯、查資料庫、產生真正的回應。回應再沿原路逐段交棒回使用者。&lt;/p>
&lt;p>這四段不是每個服務都要各配一台獨立設備。它們是四種責任，可以分散在四層設備上，也可以壓縮到同一台機器。共享主機就是把後三段壓縮到一台 Apache 上——同一個 process 收 HTTPS、跑 PHP、回靜態圖片，DNS 則由面板或域名商代管。壓縮成一層在單機、低流量時完全合理；責任鏈之所以要展開成多層，是因為可用性與流量規模長大之後，把它們分開才能各自獨立擴縮、獨立故障、獨立收斂。判斷「需要幾層」的核心，就是判斷這條鏈該在哪幾個交棒點切開。&lt;/p>
&lt;p>這條鏈的最前面還可以再疊一層本文不展開的入口——CDN（內容遞送網路）。需要就近服務地理分散的使用者、或要在邊緣吸收 DDoS 並回應靜態內容時，DNS 會指向 CDN 而不是直接指向源站入口，由 CDN 在邊緣終結 TLS、回應快取命中的靜態資源，只把 cache-miss 與動態請求回源到下面這條鏈。CDN 承擔的是邊緣遞送與快取，跟本文聚焦的源站入口鏈是不同層的責任；這裡先把源站這條鏈畫清楚，邊緣層另屬 CDN 的主題。源站放在 CDN 後面之後，只讓 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>。四次交棒是源站側的完整責任集合，不是「使用者到應用」的唯一路徑。&lt;/p>
&lt;h2 id="dns責任鏈的門牌不承載流量">DNS：責任鏈的門牌，不承載流量&lt;/h2>
&lt;p>DNS 承擔的是把域名解析成入口位址這一件事，它是責任鏈的第一段，但它不承載後續的請求流量。瀏覽器向 DNS resolver 問一次 &lt;code>example.com&lt;/code> 的位址，拿到結果後就直接對那個位址建立連線，後面的 HTTP 請求與回應都不再經過 DNS。這個「只在連線前查一次」的性質，把 DNS 的判讀重點落在指向與切換這兩件事上，效能不是它的判讀軸。&lt;/p>
&lt;p>DNS 在自管環境的關鍵決策是指向哪裡。共享主機時代，域名商或面板把一筆 A record 指向那台主機的固定 IP 就結束了。自管之後，入口設施的位址可能會變——雲端負載平衡器重建後換一個 hostname、VPS 抽換後換一個 IP。穩健的做法是讓 DNS 指向一個穩定的別名，而不是易變的原始位址：雲端環境用 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB&lt;/a> 前面掛一筆 Route 53 alias record，對外暴露的是自己的網域、內部指向會變的 ALB hostname 由 alias 自動追蹤。&lt;/p>
&lt;p>判讀訊號：DNS 切換的生效速度由 TTL 決定，而 TTL 是遷移與回退時唯一能提前準備的槓桿。平常 TTL 設 3600 秒足夠；要切換入口（遷移平台、換 IP）之前先把 TTL 降到 300 秒，讓各地 resolver 的舊快取快速過期，切換才能在分鐘級生效、出問題也能快速回退。這條在&lt;a href="https://tarrragon.github.io/blog/devops/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移&lt;/a>的 DNS 切換段有完整的操作步驟與 Route 53 指令。DNS 只負責「域名對到哪個入口」，至於流量抵達入口後怎麼分，是負載平衡層的職責。&lt;/p>
&lt;h2 id="負載平衡l4-與-l7-的分界">負載平衡：L4 與 L7 的分界&lt;/h2>
&lt;p>負載平衡層承擔的是把抵達入口的流量分配到多台後端、並把不健康的後端從輪替裡摘除。它存在的前提是後端有多台——單一後端沒有「分配」的問題。這一層最基本的判斷是它讀到請求的哪一層資訊來做決定——L4 與 L7 的分界看的是讀取層級，而不是 vendor 招牌。&lt;/p></description><content:encoded><![CDATA[<p>流量入口層是一條請求從使用者鍵入網址到抵達應用程式所經過的責任鏈。每一段各自承擔一件事：把域名解析成位址、把流量分到還活著的後端、在後端前面終結加密與分流、最後才由應用處理業務邏輯。這條鏈的存在是為了把「對外的穩定介面」跟「對內的可變後端」分開——後端可以增減、替換、搬家，使用者連的入口位址不變。</p>
<p>共享主機時代這條鏈是隱形的。cPanel 或 Plesk 把域名、TLS 憑證、web server、應用執行環境全部包在同一台機器、同一套面板後面，使用者只需要在面板點幾下綁定網域、開啟 AutoSSL，整條入口鏈就「自己接好了」。接手一個自管環境（VPS 或雲端）之後，這些原本被面板吸收掉的步驟，每一個都變回一個要自己下的決定：DNS 指向哪裡、要不要一層負載平衡、TLS 在哪一層解開、靜態檔案誰來回。</p>
<p>這篇文章要建立的判斷力是「我的服務需要幾層入口、每層放什麼」，而不是某一種 web server 的配置語法。責任鏈的每一段先講清楚它承擔什麼、什麼訊號代表它出問題，再把兩個核心的架構選擇（TLS 終結放哪層、單機 <a href="/blog/devops/infra/knowledge-cards/reverse-proxy/" data-link-title="Reverse Proxy" data-link-desc="代替後端服務接收外部請求、再轉發到內部服務的中介層，承擔 TLS 終結、負載平衡與路由分流">reverse proxy</a> 還是雲端負載平衡器）展開成可操作的判斷標準。runtime 的調校（負載分散演算法、健康檢查參數、timeout 串接）是<a href="/blog/devops/operations/01-load-balancing/" data-link-title="模組一：負載平衡與反向代理" data-link-desc="流量進來怎麼分給多個服務實例 — 反向代理職責、負載分散演算法、nginx 實務配置與健康檢查路由設計">運維 模組一：負載平衡與反向代理</a>的範圍，這裡先把入口層的地圖畫出來。</p>
<h2 id="從一個網址到一個回應責任鏈的四次交棒">從一個網址到一個回應：責任鏈的四次交棒</h2>
<p>一條 HTTP 請求從使用者到應用，中間經過最多四段獨立的責任，每一段把處理好的結果交棒給下一段。理解入口層就是理解這四次交棒各自完成了什麼。</p>
<p>第一段是 <a href="/blog/devops/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS</a> 解析：使用者的瀏覽器拿著域名去問 DNS，換回一個可連線的位址。第二段是負載平衡：流量抵達入口位址後，由一層負載平衡器從多台後端裡挑一台還健康的送過去。第三段是 reverse proxy：在後端應用前面，由它解開 HTTPS 加密、依 URL 決定送到哪個服務、把靜態檔案直接回掉。第四段才是應用伺服器：跑業務邏輯、查資料庫、產生真正的回應。回應再沿原路逐段交棒回使用者。</p>
<p>這四段不是每個服務都要各配一台獨立設備。它們是四種責任，可以分散在四層設備上，也可以壓縮到同一台機器。共享主機就是把後三段壓縮到一台 Apache 上——同一個 process 收 HTTPS、跑 PHP、回靜態圖片，DNS 則由面板或域名商代管。壓縮成一層在單機、低流量時完全合理；責任鏈之所以要展開成多層，是因為可用性與流量規模長大之後，把它們分開才能各自獨立擴縮、獨立故障、獨立收斂。判斷「需要幾層」的核心，就是判斷這條鏈該在哪幾個交棒點切開。</p>
<p>這條鏈的最前面還可以再疊一層本文不展開的入口——CDN（內容遞送網路）。需要就近服務地理分散的使用者、或要在邊緣吸收 DDoS 並回應靜態內容時，DNS 會指向 CDN 而不是直接指向源站入口，由 CDN 在邊緣終結 TLS、回應快取命中的靜態資源，只把 cache-miss 與動態請求回源到下面這條鏈。CDN 承擔的是邊緣遞送與快取，跟本文聚焦的源站入口鏈是不同層的責任；這裡先把源站這條鏈畫清楚，邊緣層另屬 CDN 的主題。源站放在 CDN 後面之後，只讓 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>。四次交棒是源站側的完整責任集合，不是「使用者到應用」的唯一路徑。</p>
<h2 id="dns責任鏈的門牌不承載流量">DNS：責任鏈的門牌，不承載流量</h2>
<p>DNS 承擔的是把域名解析成入口位址這一件事，它是責任鏈的第一段，但它不承載後續的請求流量。瀏覽器向 DNS resolver 問一次 <code>example.com</code> 的位址，拿到結果後就直接對那個位址建立連線，後面的 HTTP 請求與回應都不再經過 DNS。這個「只在連線前查一次」的性質，把 DNS 的判讀重點落在指向與切換這兩件事上，效能不是它的判讀軸。</p>
<p>DNS 在自管環境的關鍵決策是指向哪裡。共享主機時代，域名商或面板把一筆 A record 指向那台主機的固定 IP 就結束了。自管之後，入口設施的位址可能會變——雲端負載平衡器重建後換一個 hostname、VPS 抽換後換一個 IP。穩健的做法是讓 DNS 指向一個穩定的別名，而不是易變的原始位址：雲端環境用 <a href="/blog/devops/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> 前面掛一筆 Route 53 alias record，對外暴露的是自己的網域、內部指向會變的 ALB hostname 由 alias 自動追蹤。</p>
<p>判讀訊號：DNS 切換的生效速度由 TTL 決定，而 TTL 是遷移與回退時唯一能提前準備的槓桿。平常 TTL 設 3600 秒足夠；要切換入口（遷移平台、換 IP）之前先把 TTL 降到 300 秒，讓各地 resolver 的舊快取快速過期，切換才能在分鐘級生效、出問題也能快速回退。這條在<a href="/blog/devops/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a>的 DNS 切換段有完整的操作步驟與 Route 53 指令。DNS 只負責「域名對到哪個入口」，至於流量抵達入口後怎麼分，是負載平衡層的職責。</p>
<h2 id="負載平衡l4-與-l7-的分界">負載平衡：L4 與 L7 的分界</h2>
<p>負載平衡層承擔的是把抵達入口的流量分配到多台後端、並把不健康的後端從輪替裡摘除。它存在的前提是後端有多台——單一後端沒有「分配」的問題。這一層最基本的判斷是它讀到請求的哪一層資訊來做決定——L4 與 L7 的分界看的是讀取層級，而不是 vendor 招牌。</p>
<p>L4 負載平衡器工作在傳輸層，只看 IP 與埠。它不拆開封包內容、不理解 HTTP，收到一條 TCP 連線就依規則轉給某台後端，全程不碰加密內容。因為不解析應用層，它的轉發成本極低、吞吐極高，也能承載任何 TCP/UDP 協定（不限 HTTP）。AWS 的 Network Load Balancer（NLB）是這一類。</p>
<p>L7 負載平衡器工作在應用層，讀得懂 HTTP。它看得到 host header、URL path、cookie，因此能依「<code>/api</code> 導到 API 後端、<code>/</code> 導到前端」這種規則分流，也能在這一層終結 TLS、做應用層的健康檢查。AWS 的 <a href="/blog/devops/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB</a> 是這一類。代價是它要解析每個請求，單位成本高於 L4。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>L4（如 NLB）</th>
          <th>L7（如 ALB）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>讀取層級</td>
          <td>傳輸層（IP、埠）</td>
          <td>應用層（HTTP host、path、header）</td>
      </tr>
      <tr>
          <td>路由依據</td>
          <td>目標埠與轉發規則</td>
          <td>URL path、host header、cookie</td>
      </tr>
      <tr>
          <td>TLS</td>
          <td>通常透傳到後端、或在 L4 卸載</td>
          <td>在入口終結（讀 HTTP 需要先解密）</td>
      </tr>
      <tr>
          <td>適用協定</td>
          <td>任何 TCP / UDP</td>
          <td>HTTP / HTTPS / gRPC</td>
      </tr>
      <tr>
          <td>選它的訊號</td>
          <td>非 HTTP 協定、極高吞吐、要保留來源 IP、要端到端加密</td>
          <td>依路徑或網域分流、在入口終結 TLS、HTTP 感知的健康檢查</td>
      </tr>
  </tbody>
</table>
<p>選 L7 的典型情境是多個 HTTP 服務共用一個網域、靠 path 或 host 分流，而且希望在入口就把 HTTPS 解掉讓後端只收 HTTP——這涵蓋了大多數 web 應用。選 L4 的情境更專門：後端跑的是非 HTTP 協定（資料庫協定、遊戲的自訂 TCP、MQTT）、需要極致吞吐而不能付應用層解析成本、或合規要求加密必須端到端不能在入口解開（讓 TLS 透傳到後端自己終結）。這一層挑好之後，怎麼在多台健康後端之間分配下一個請求（round-robin、least-connection、雜湊）是<a href="/blog/devops/operations/01-load-balancing/load-balancing-algorithms/" data-link-title="負載分散演算法" data-link-desc="在 round-robin、least-connections、IP hash、consistent hash 之間選負載分散演算法時，用請求成本是否均勻、實例是否同質、需不需要親和性當判斷標準">負載分散演算法</a>的主題。</p>
<h2 id="reverse-proxy橫切關注點的集中點">reverse proxy：橫切關注點的集中點</h2>
<p>reverse proxy 承擔的是代替後端應用接收外部請求、再依規則轉發給內部服務。它坐在應用前面，把幾件「每個後端都需要、但不該每個後端各做一份」的橫切關注點收攏到一層：終結 TLS、依 URL 分流、把靜態檔案直接回掉、加安全標頭、限流。使用者連的永遠是 reverse proxy，它背後有幾台應用、跑什麼語言、在哪個 subnet，使用者不需要知道。</p>
<p><a href="/blog/devops/infra/knowledge-cards/nginx/" data-link-title="nginx" data-link-desc="高效能 Web Server 與 Reverse Proxy，以集中設定檔取代 Apache 的 .htaccess 分散設定">nginx</a> 是單機環境最常見的 reverse proxy 實作。它以集中的設定檔取代 Apache 分散的 <code>.htaccess</code>，把請求轉給後端的應用伺服器：講 HTTP 的後端（Node.js、Python 的 gunicorn）用 <code>proxy_pass</code>，只講 FastCGI 的 PHP-FPM 用 <code>fastcgi_pass</code>（見 <a href="/blog/programming/php/01-server-runtime/fastcgi/" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI</a>）。在雲端，L7 負載平衡器（ALB）本身就內建了大部分 reverse proxy 的職責——TLS 終結、path 路由、健康檢查——所以雲端環境不一定需要另一層獨立的 nginx。這兩者的關係是本文後段「單機 nginx vs 雲端 ALB」要展開的核心選擇。</p>
<p><a href="/blog/devops/infra/knowledge-cards/static-dynamic-separation/" data-link-title="動靜分離（Static / Dynamic Content Separation）" data-link-desc="決定入口層或 reverse proxy 怎麼分配請求時，用動靜分離把不需要應用邏輯的靜態資源直接回掉、只把動態請求轉給應用伺服器">動靜分離</a>是 reverse proxy 這一層最具體的判斷。靜態資源（圖片、CSS、JS、字型）不需要應用邏輯就能回應，讓 reverse proxy 直接從檔案系統回掉，動態請求才轉發給應用伺服器。這樣做把應用伺服器的工作量集中在真正需要它的請求上——一個載入 50 個靜態資源、只有 1 個動態 API 呼叫的頁面，應用伺服器只需要處理那 1 個。共享主機時代 Apache 加 <a href="/blog/programming/php/01-server-runtime/mod-php/" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php</a> 把動靜都吃在同一個 process 裡，動靜分離是隱形的預設；自管的 nginx 要在設定裡明確劃出「哪些路徑走檔案、哪些路徑走 <code>proxy_pass</code>」。reverse proxy 四類職責（TLS 終結、路由、負載分散、健康檢查）的設計邊界與 timeout 由外到內遞減的紀律，在<a href="/blog/devops/operations/01-load-balancing/reverse-proxy-responsibilities/" data-link-title="反向代理的職責" data-link-desc="釐清反向代理在單一入口後面承擔哪些職責、TLS 終止與路由怎麼設計、以及一條請求路徑上的 timeout 為什麼要由外到內遞減時回來讀">反向代理的職責</a>展開。</p>
<h2 id="tls-終結放在哪一層">TLS 終結放在哪一層</h2>
<p>TLS 終結指的是責任鏈上由哪一層解開 HTTPS 加密，這個位置決定了鏈上哪幾段是密文、哪幾段是明文。共享主機時代這個決定由面板的 AutoSSL 代掉，使用者不會意識到「憑證裝在哪、加密在哪解」。自管之後，終結點變成一個明確的架構選擇，落在下面幾種安排。</p>
<p>終結在負載平衡器是雲端環境的預設做法。ALB 的 HTTPS listener 掛一張 ACM（AWS Certificate Manager）簽發的憑證，ALB 解密後把明文 HTTP 交給後端，後端不必各自持有憑證、不必各自做加解密。憑證由 ACM 搭配 <a href="/blog/devops/infra/knowledge-cards/dns/" data-link-title="DNS" data-link-desc="Domain Name System — 把域名轉成 IP 位址的系統，以及 A record、CNAME、NS、TTL 的角色">DNS</a> 驗證自動續期，整條「簽發、續期、掛載」進 IaC 版本控制。這套 ALB 加 ACM 的完整 IaC 描述在<a href="/blog/devops/infra/05-core-services/loadbalancer-alb/" data-link-title="入口上 IaC — ALB、TLS 與健康檢查" data-link-desc="Application Load Balancer 的 listener、target group、健康檢查閾值設計，以及用 ACM 把 TLS 憑證的簽發、驗證與掛載整條鏈寫進版本控制">模組五：入口上 IaC</a>。</p>
<p>終結在 reverse proxy 是單機環境的對應做法。nginx 掛一張 <a href="/blog/devops/infra/knowledge-cards/ssl-tls/" data-link-title="SSL / TLS" data-link-desc="加密 client 與 server 之間通訊的協定，讓 HTTPS 成為可能。TLS 是 SSL 的後繼者，但 SSL 憑證的稱呼仍廣泛使用">SSL/TLS</a> 憑證（VPS 上常用 Let&rsquo;s Encrypt 搭配 certbot 自動續期），解密後把請求轉給同機或內網的應用。共享主機遷到 VPS 後的憑證續期驗證在<a href="/blog/devops/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a>的 SSL 段有具體指令。</p>
<p>後端這段也要加密時有兩種不同的安排，差別在入口層有沒有看到明文。透傳到後端（TLS passthrough）是入口層完全不解密，把加密流量原封轉給後端由後端自己終結——它保證中間沒有任何一跳是明文，代價是入口層看不到 HTTP 內容，無法做 path 路由與應用層健康檢查，等於退回 L4。重新加密（TLS re-encryption，又稱 TLS bridging）則是入口層先解密（因此讀得到 HTTP、能做 path 路由與健康檢查），處理完再對後端開一條新的 TLS 連線把流量重新加密送過去。兩者都讓後端這段是密文，但透傳全程不解密、放棄 L7 能力，重新加密在入口解一次、對後端再加一次、L7 路由與端到端加密都保住，代價是入口多付一次加解密成本。合規要求內網也不得明文、但入口又需要依 path 或 host 分流時，選的是重新加密而不是透傳。</p>
<p>判讀的核心是入口到後端這段網路可不可信。多數設計讓入口終結、後端收明文，因為後端住在 <a href="/blog/devops/infra/knowledge-cards/subnet/" data-link-title="Subnet（子網路）" data-link-desc="VPC 內按可用區與暴露程度切出的子網段，決定資源有沒有一條通往網際網路的路徑">private subnet</a>、只接受來自入口 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group</a> 的流量，這段內網被視為可信；只有當合規把「這段也必須加密」寫成硬要求時，才付出重新加密或透傳的成本，並在兩者之間按「還需不需要 L7 分流」選擇。這個位置決策本身、以及它和後端信任模型的關係，在<a href="/blog/devops/operations/01-load-balancing/reverse-proxy-responsibilities/" data-link-title="反向代理的職責" data-link-desc="釐清反向代理在單一入口後面承擔哪些職責、TLS 終止與路由怎麼設計、以及一條請求路徑上的 timeout 為什麼要由外到內遞減時回來讀">反向代理的職責</a>的 TLS 終止段有更完整的討論。</p>
<h2 id="健康檢查與摘除入口層怎麼知道後端還活著">健康檢查與摘除：入口層怎麼知道後端還活著</h2>
<p>健康檢查是入口層持續探測後端能不能服務、並把不能服務的後端從輪替裡摘除的機制。這個能力在單機環境不存在——只有一台應用，它活著就有服務、死了就整站掛，沒有「從輪替摘除」的餘地。責任鏈展開成多後端之後，入口層才需要一個判斷「這台還能不能接流量」的依據，讓請求不會落到一台已經壞掉的後端上。</p>
<p>摘除的語意是：健康檢查連續失敗到閾值，入口層就把那台後端標記為 unhealthy、停止分配新請求給它，流量自動重導到其餘健康的後端。這條路徑在部署時最容易出狀況——滾動部署把舊的服務實例停掉、新實例還沒通過健康檢查的那個空窗，target group（ALB 登記後端的分組清單、健康檢查與流量分配都以它為單位）裡沒有任何健康後端，使用者會看到 503。這也是為什麼健康檢查的路徑要用應用層的專屬 health endpoint（真的能反映應用狀態）而不是根路徑，閾值要在「太寬鬆把壞後端留在輪替」與「太嚴格在部署瞬間誤判」之間取平衡。ALB 的健康檢查參數、502 與 503 的分辨在 <a href="/blog/devops/infra/knowledge-cards/alb/" data-link-title="ALB" data-link-desc="Application Load Balancer — 流量進入系統的第一站，負責 listener 路由、健康檢查與 TLS 終結">ALB 卡</a>有可觀察訊號的對照；被動觀察與主動探測的差別、interval 與 threshold 的設計、flapping 的成因在<a href="/blog/devops/operations/01-load-balancing/health-check-routing/" data-link-title="健康檢查路由設計" data-link-desc="設計負載平衡的健康檢查時，釐清被動與主動探測的差別、interval 與 threshold 怎麼定出兩個時間窗口、以及為什麼 LB 顯示 healthy 不等於服務可服務">健康檢查路由設計</a>展開。</p>
<h2 id="單機-nginx-vs-雲端-alb選擇條件">單機 nginx vs 雲端 ALB：選擇條件</h2>
<p>單機 reverse proxy 與雲端受管負載平衡器之間的選擇，取決於服務對可用性的要求與運維的規模，而不是哪一個「比較先進」。兩者都能扮演入口，差別在於誰承擔跨可用區冗餘、誰承擔設備本身的運維、以及固定成本的形狀。這是自管環境接手後最實際的一個入口層決策。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>單機 nginx</th>
          <th>雲端 ALB</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>誰運維設備</td>
          <td>自己（裝、配、reload、修）</td>
          <td>雲端受管，設備本身不用維護</td>
      </tr>
      <tr>
          <td>跨可用區</td>
          <td>單機、單一可用區</td>
          <td>跨可用區冗餘，單區故障自動轉移</td>
      </tr>
      <tr>
          <td>健康檢查摘除</td>
          <td>開源版只有被動、主動探測是商業功能</td>
          <td>內建主動健康檢查與自動摘除</td>
      </tr>
      <tr>
          <td>TLS 續期</td>
          <td>自己配 certbot / Let&rsquo;s Encrypt</td>
          <td>ACM 自動續期，進 IaC</td>
      </tr>
      <tr>
          <td>成本形狀</td>
          <td>一台機器的固定月費</td>
          <td>固定每小時費 + 按用量的 LCU（Load Balancer Capacity Unit）費</td>
      </tr>
      <tr>
          <td>前置依賴</td>
          <td>一台 VPS 就能跑</td>
          <td>需要規劃好的 VPC、public / private subnet</td>
      </tr>
  </tbody>
</table>
<p>單機 nginx 適合單一節點或低流量、且運維能力有限的服務。它的優點是輕、便宜、一台 VPS 就能跑起來，配置與問題排查都在自己手上。它的邊界是單機即單點——這台機器或它所在的可用區掛了，整個入口就斷了；而且開源 nginx 的主動健康檢查是商業版功能，只配開源版時拿到的是被動健康檢查（靠實際請求失敗才發現後端壞掉），不是主動探測，這一點不知道會誤以為配了主動探測、實際上沒生效（見<a href="/blog/devops/operations/01-load-balancing/nginx-configuration/" data-link-title="nginx 實務配置" data-link-desc="用 nginx 當反向代理配 upstream、負載分散方法、健康檢查與 timeout 時，特別是要知道開源版的主動健康檢查限制與 proxy header 陷阱時回來讀">nginx 實務配置</a>）。</p>
<p>雲端 ALB 適合多後端、要求跨可用區高可用、且已經有網路地基的服務。它的優點是受管——跨可用區冗餘、主動健康檢查與自動摘除、ACM 憑證續期都由平台承擔，不必自己維護設備。它的邊界是前置依賴與成本形狀：ALB 要掛在 public subnet、把流量導向 private subnet 的後端，這條前提要求先有規劃好的網路（見<a href="/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基</a>）；成本是固定每小時費加按用量的 LCU 費，低流量時單位成本不見得比一台小 VPS 划算。</p>
<p>兩者也常常疊用而非二選一。大型部署常見的形狀是 ALB 在最外層承擔跨可用區分流與 TLS 終結，每台後端節點上再跑一個 nginx 承擔應用專屬的路由與靜態檔案回應。這時 ALB 與 nginx 是責任鏈上相鄰的兩段、各守一段職責——ALB 做 L7 入口與冗餘，nginx 做貼著應用的動靜分離與 rewrite。</p>
<p>什麼情境不需要多層入口，是這個選擇的反面。一個單節點的內部工具、一個還在驗證需求的 MVP、一個流量穩定在單機容量內的小站，一台 nginx（甚至應用框架內建的 HTTP server）就是完整的入口，硬加一層 ALB 加多可用區只是把成熟階段才需要的可用性成本提前付掉，換不到對應的價值。這呼應<a href="/blog/devops/infra/00-infra-mindset/" data-link-title="模組零：infra 是什麼，為什麼 day 1 就要鋪地基" data-link-desc="基礎設施的責任邊界、成熟度階梯，以及地基為什麼總在環境爆炸時才被看見">模組零</a>的成熟度階梯：入口層的層數該跟著可用性要求與流量規模長，而不是為了架構看起來完整而堆疊。</p>
<h2 id="我的服務需要幾層入口">我的服務需要幾層入口</h2>
<p>入口層的層數由可用性要求與流量形狀決定，把這條責任鏈在剛好夠用的交棒點切開，就是這篇要帶走的判斷。層數不是愈多愈好——每多切一層，就多一層要運維、要監控、要付固定成本的設備。</p>
<p>判讀從三個問題收斂。第一，後端是單台還是多台：單台就沒有負載平衡與健康檢查摘除的需求，一個 reverse proxy（單機 nginx）或直接讓應用對外就夠；多台且要求單點故障不中斷，才需要一層負載平衡器承擔分流與摘除。第二，流量是 HTTP 還是其他協定：HTTP 且需要依 path / host 分流、在入口終結 TLS，選 L7（ALB）；非 HTTP、要極致吞吐、或要端到端加密透傳，選 L4（NLB）。第三，TLS 要在哪裡解開：內網可信就終結在入口讓後端收明文，合規要求全程加密才做重新加密或透傳。</p>
<p>實際的入口配置指令——nginx 的 <code>try_files</code> 動靜分離與 <code>proxy_pass</code> 轉發、<code>.htaccess</code> 規則怎麼轉成 nginx、Route 53 的 DNS 切換——在<a href="/blog/devops/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a>有可對照的範例；ALB 的 listener、target group、TLS 與健康檢查怎麼寫成 IaC 在<a href="/blog/devops/infra/05-core-services/loadbalancer-alb/" data-link-title="入口上 IaC — ALB、TLS 與健康檢查" data-link-desc="Application Load Balancer 的 listener、target group、健康檢查閾值設計，以及用 ACM 把 TLS 憑證的簽發、驗證與掛載整條鏈寫進版本控制">模組五：入口上 IaC</a>。入口設施該落在哪個 subnet、它的 security group 只開哪些埠，是<a href="/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基</a>已經鋪好的地圖——ALB 掛 public subnet、後端住 private subnet、對外只有 80 / 443 合理開向 <code>0.0.0.0/0</code>。入口層的責任鏈規劃好之後，核心服務怎麼落進這些 subnet、負載平衡器怎麼上 IaC，是<a href="/blog/devops/infra/05-core-services/" data-link-title="模組五：核心服務上 IaC" data-link-desc="資料庫、運算、儲存、load balancer 怎麼寫進基礎設施程式碼，以及上線順序">模組五：核心服務上 IaC</a>的主題。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — VPC、subnet 分層與 security group 設計</a>：入口設施落在哪層 subnet、security group 只開哪些埠</li>
<li>→ <a href="/blog/devops/infra/upgrade/platform-migration/" data-link-title="平台遷移" data-link-desc="FTP 面板主機到 VPS、VPS 到雲端、地端到雲端的遷移路徑 — 資料同步策略、DNS 切換、驗證與回退">平台遷移</a>：<code>.htaccess</code> 轉 nginx、DNS 切換與 SSL 續期的具體指令</li>
<li>→ <a href="/blog/devops/infra/05-core-services/loadbalancer-alb/" data-link-title="入口上 IaC — ALB、TLS 與健康檢查" data-link-desc="Application Load Balancer 的 listener、target group、健康檢查閾值設計，以及用 ACM 把 TLS 憑證的簽發、驗證與掛載整條鏈寫進版本控制">模組五：入口上 IaC</a>：ALB 的 listener、target group、TLS 與健康檢查的完整 IaC</li>
<li>→ <a href="/blog/devops/operations/01-load-balancing/" data-link-title="模組一：負載平衡與反向代理" data-link-desc="流量進來怎麼分給多個服務實例 — 反向代理職責、負載分散演算法、nginx 實務配置與健康檢查路由設計">運維 模組一：負載平衡與反向代理</a>：負載分散演算法、健康檢查參數、timeout 串接的 runtime 調校</li>
<li>→ <a href="/blog/devops/infra/takeover/legacy-ftp-no-ssh/" data-link-title="無 SSH 的 FTP / 面板管理環境接管" data-link-desc="接手一個只有 FTP 和 phpMyAdmin（或 cPanel / Plesk）存取的 PHP 專案：沒有 SSH、沒有 CLI 時，怎麼盤點現況、建立本地開發環境、制定部署與資料庫變更紀律，以及找到升級路徑的切入點">接手維運：無 SSH 的 FTP 環境</a>：共享主機環境的盤點，入口鏈自管化的起點</li>
</ul>
]]></content:encoded></item><item><title>伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/</link><pubDate>Fri, 23 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/</guid><description>&lt;p>這篇整理管理員登入伺服器作業系統的通道怎麼設計，內容與雲端廠商無關，適用於雲端 VM、地端主機與 VPS。適用的前提是服務跑在自己管理的主機上、而且有人要登入那台主機的作業系統；跑在不提供主機登入的平台（PaaS、serverless）上，就沒有這條通道要設計。機制以 SSH 說明，RDP 的差異在〈對外開放管理 port 的實際風險〉與〈主機登入驗證的做法〉另外交代。GCP 上的機制見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding&lt;/a>，逐步的指令見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄&lt;/a>。&lt;/p>
&lt;h2 id="管理通道的三層網路可達性通道授權主機登入驗證">管理通道的三層：網路可達性、通道授權、主機登入驗證&lt;/h2>
&lt;p>管理通道指管理員從自己的電腦登入伺服器作業系統的那條路徑。一次 SSH 登入依序經過三層檢查，每一層的做法可以分開選，但有些做法同時落在兩層：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>網路可達性&lt;/strong>：管理員電腦送出的封包是否到達得了伺服器上 sshd（SSH 的伺服器端程式）監聽的 port。防火牆規則、伺服器是否持有公開 IP、中間是否經過代理，決定的都是這一層。&lt;/li>
&lt;li>&lt;strong>通道授權&lt;/strong>：在封包抵達 sshd 之前，是否有一個元件先確認「這個人可以使用這條通道」，以及它憑什麼確認——來源 IP、VPN 憑證、跳板機上的 SSH 金鑰，或一個身分提供者（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/identity-provider/" data-link-title="Identity Provider" data-link-desc="登入要交給外部系統時，用來定位對方承擔了什麼、沒承擔什麼，以及這邊還剩下哪些責任">identity provider&lt;/a>，IdP）上的帳號。&lt;/li>
&lt;li>&lt;strong>主機登入驗證&lt;/strong>：封包抵達 sshd 之後，sshd 用什麼判斷這個連線可以登入哪個帳號——&lt;code>authorized_keys&lt;/code> 裡的靜態公鑰、由中央服務動態提供的公鑰，或 SSH CA 簽發的短期 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/ssh-certificate/" data-link-title="SSH Certificate（SSH 憑證）" data-link-desc="主機多、人員常異動而逐台收回 authorized_keys 跟不上，或要讓 SSH 登入權限自動到期時，查 SSH CA 簽發短期憑證的運作方式與導入時要決定的事">SSH 憑證（SSH certificate）&lt;/a>。&lt;/li>
&lt;/ul>
&lt;p>防火牆對 &lt;code>0.0.0.0/0&lt;/code>（&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR&lt;/a> 表示法裡的「所有 IPv4 位址」）開 22 port 的狀態，在這個模型裡是網路可達性全開、通道授權不存在、只剩主機登入驗證一層在擋。收斂管理通道的做法，多數是替網路可達性與通道授權換上更嚴格的做法；主機登入驗證另有它自己的換法，從要逐台刪除的靜態金鑰，換成跟著帳號收回、或會自動到期的金鑰。&lt;/p>
&lt;p>每一層擋下的攻擊者，是手上的存取憑證與所在位置過得了前面幾層、過不了這一層的人。網路可達性擋的是只能從網際網路送封包、還沒有任何存取憑證的攻擊者，例如掃描程式與 sshd 漏洞的利用程式；通道授權擋的是已經能把封包送到通道入口、卻沒有通道身分的人，例如私鑰從筆電外洩、但沒有組織 IdP 帳號的人；主機登入驗證擋的是持有通道權限、卻不該登入某台主機或某個帳號的成員，例如開發者登入他不負責的資料庫主機、或取得他不該有的 sudo。同一個做法可能同時改變兩層（來源 IP 白名單、跳板機、VPN、身分感知代理都兼任通道授權），還有少數做法把三層一起換掉（SSM Session Manager 的預設模式、Tailscale SSH，見〈網路可達性的做法〉），所以評估一個做法時要逐層問它改了哪一層。只加強其中一層時，有能力通過那一層的攻擊者（例如持有 IdP 帳號、卻不該碰這台主機的成員）遇到的另外兩層仍是原本的設定；各層要彼此獨立，這個原則叫&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/defense-in-depth/" data-link-title="Defense in Depth" data-link-desc="有人主張某個弱環節「還有其他防線擋著」時，用來判斷這個說法成不成立">縱深防禦（defense in depth）&lt;/a>。&lt;/p>
&lt;h2 id="對外開放管理-port-的實際風險">對外開放管理 port 的實際風險&lt;/h2>
&lt;p>管理 port 對整個網際網路開放之後，主機的認證紀錄很快就會出現來自陌生 IP 的失敗登入，開一台測試主機查一次紀錄就看得到。紀錄的位置依發行版不同：Debian 與 Ubuntu 是 &lt;code>journalctl -u ssh&lt;/code> 或 &lt;code>/var/log/auth.log&lt;/code>，RHEL 系是 &lt;code>journalctl -u sshd&lt;/code> 或 &lt;code>/var/log/secure&lt;/code>。這些嘗試要成功，取決於主機登入驗證那一層的設定；而只剩那一層在擋時，風險集中在四個地方：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>sshd 本身的漏洞&lt;/strong>：2024 年的 regreSSHion（CVE-2024-6387）讓未經認證的連線有機會在 OpenSSH 伺服器上執行程式碼。這類漏洞發生在認證之前，金鑰設定得再嚴格也擋不住；能降低這類風險的是修補速度（縮短暴露期）與收窄網路可達性（減少能把封包送到 sshd 的來源）。&lt;/li>
&lt;li>&lt;strong>密碼登入&lt;/strong>：多數雲端廠商的官方 Linux 映像檔預設關閉 SSH 密碼登入、只接受金鑰，單純猜密碼在沒改過設定的主機上不會成功。不少 VPS 商在開機時寄出 root 密碼、或提供「用密碼建立主機」的選項，這類主機的密碼登入一開始就是開著的，要自己關掉。有人為了方便打開 &lt;code>PasswordAuthentication yes&lt;/code>、或建了弱密碼的本機帳號，這項預設也會失效，而這類改動發生在主機內部，防火牆看不到。&lt;/li>
&lt;li>&lt;strong>金鑰的生命週期&lt;/strong>：私鑰從筆電、CI 或共用資料夾外洩之後，持有者從任何地方都能登入；成員離職時沒從 &lt;code>authorized_keys&lt;/code> 移除的公鑰持續有效。組織處理離職時停用的是公司帳號（郵件、IdP），這個動作不會碰到任何一台主機上的 &lt;code>authorized_keys&lt;/code>；主機的登入紀錄記的是金鑰指紋而不是人名，那把公鑰之後被拿來登入，紀錄上也對不到是誰。靜態公鑰沒有到期時間，所以「誰還拿得到這台主機」只能靠人工盤點查出來，離職流程要把主機登入層列進去，撤銷清單的寫法見&lt;a href="https://tarrragon.github.io/blog/devops/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接與存取撤銷設計&lt;/a>的〈離職或轉調的存取撤銷清單〉。&lt;/li>
&lt;li>&lt;strong>RDP&lt;/strong>：Windows 的遠端桌面預設接受帳號密碼，暴力猜測對它的成功率比對只收金鑰的 SSH 高，所以 3389 對外開放的被入侵機率高於 22。RDP 在主機登入驗證那一層的對應是 Windows 帳號，其餘兩層的做法與 SSH 相同。&lt;/li>
&lt;/ul>
&lt;p>同一個內部網段裡的主機之間如果所有 port 互通，一台主機被攻破後，攻擊者從那台機器可以直接連到其他主機的資料庫 port 與管理 port，這種擴散方式叫&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/lateral-movement/" data-link-title="Lateral Movement（橫向移動）" data-link-desc="評估一台主機被入侵之後攻擊者還連得到哪些機器、內網或 VPN 裡的可達範圍要收到多窄時，查攻擊者以已經拿下的機器為起點往內擴散的方式">橫向移動（lateral movement）&lt;/a>。收斂對外的管理 port 時，內部網段之間的規則要一起盤點，最小開放的設計見&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — VPC、subnet 分層與 security group 設計&lt;/a>的〈security group 設計：最小開放〉一節。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理管理員登入伺服器作業系統的通道怎麼設計，內容與雲端廠商無關，適用於雲端 VM、地端主機與 VPS。適用的前提是服務跑在自己管理的主機上、而且有人要登入那台主機的作業系統；跑在不提供主機登入的平台（PaaS、serverless）上，就沒有這條通道要設計。機制以 SSH 說明，RDP 的差異在〈對外開放管理 port 的實際風險〉與〈主機登入驗證的做法〉另外交代。GCP 上的機制見 <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding</a>，逐步的指令見 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a>。</p>
<h2 id="管理通道的三層網路可達性通道授權主機登入驗證">管理通道的三層：網路可達性、通道授權、主機登入驗證</h2>
<p>管理通道指管理員從自己的電腦登入伺服器作業系統的那條路徑。一次 SSH 登入依序經過三層檢查，每一層的做法可以分開選，但有些做法同時落在兩層：</p>
<ul>
<li><strong>網路可達性</strong>：管理員電腦送出的封包是否到達得了伺服器上 sshd（SSH 的伺服器端程式）監聽的 port。防火牆規則、伺服器是否持有公開 IP、中間是否經過代理，決定的都是這一層。</li>
<li><strong>通道授權</strong>：在封包抵達 sshd 之前，是否有一個元件先確認「這個人可以使用這條通道」，以及它憑什麼確認——來源 IP、VPN 憑證、跳板機上的 SSH 金鑰，或一個身分提供者（<a href="/blog/backend/knowledge-cards/identity-provider/" data-link-title="Identity Provider" data-link-desc="登入要交給外部系統時，用來定位對方承擔了什麼、沒承擔什麼，以及這邊還剩下哪些責任">identity provider</a>，IdP）上的帳號。</li>
<li><strong>主機登入驗證</strong>：封包抵達 sshd 之後，sshd 用什麼判斷這個連線可以登入哪個帳號——<code>authorized_keys</code> 裡的靜態公鑰、由中央服務動態提供的公鑰，或 SSH CA 簽發的短期 <a href="/blog/devops/infra/knowledge-cards/ssh-certificate/" data-link-title="SSH Certificate（SSH 憑證）" data-link-desc="主機多、人員常異動而逐台收回 authorized_keys 跟不上，或要讓 SSH 登入權限自動到期時，查 SSH CA 簽發短期憑證的運作方式與導入時要決定的事">SSH 憑證（SSH certificate）</a>。</li>
</ul>
<p>防火牆對 <code>0.0.0.0/0</code>（<a href="/blog/devops/infra/knowledge-cards/cidr/" data-link-title="CIDR（Classless Inter-Domain Routing）" data-link-desc="用前綴長度表示 IP 地址範圍的表示法，決定 VPC 與 subnet 的地址空間大小">CIDR</a> 表示法裡的「所有 IPv4 位址」）開 22 port 的狀態，在這個模型裡是網路可達性全開、通道授權不存在、只剩主機登入驗證一層在擋。收斂管理通道的做法，多數是替網路可達性與通道授權換上更嚴格的做法；主機登入驗證另有它自己的換法，從要逐台刪除的靜態金鑰，換成跟著帳號收回、或會自動到期的金鑰。</p>
<p>每一層擋下的攻擊者，是手上的存取憑證與所在位置過得了前面幾層、過不了這一層的人。網路可達性擋的是只能從網際網路送封包、還沒有任何存取憑證的攻擊者，例如掃描程式與 sshd 漏洞的利用程式；通道授權擋的是已經能把封包送到通道入口、卻沒有通道身分的人，例如私鑰從筆電外洩、但沒有組織 IdP 帳號的人；主機登入驗證擋的是持有通道權限、卻不該登入某台主機或某個帳號的成員，例如開發者登入他不負責的資料庫主機、或取得他不該有的 sudo。同一個做法可能同時改變兩層（來源 IP 白名單、跳板機、VPN、身分感知代理都兼任通道授權），還有少數做法把三層一起換掉（SSM Session Manager 的預設模式、Tailscale SSH，見〈網路可達性的做法〉），所以評估一個做法時要逐層問它改了哪一層。只加強其中一層時，有能力通過那一層的攻擊者（例如持有 IdP 帳號、卻不該碰這台主機的成員）遇到的另外兩層仍是原本的設定；各層要彼此獨立，這個原則叫<a href="/blog/backend/knowledge-cards/defense-in-depth/" data-link-title="Defense in Depth" data-link-desc="有人主張某個弱環節「還有其他防線擋著」時，用來判斷這個說法成不成立">縱深防禦（defense in depth）</a>。</p>
<h2 id="對外開放管理-port-的實際風險">對外開放管理 port 的實際風險</h2>
<p>管理 port 對整個網際網路開放之後，主機的認證紀錄很快就會出現來自陌生 IP 的失敗登入，開一台測試主機查一次紀錄就看得到。紀錄的位置依發行版不同：Debian 與 Ubuntu 是 <code>journalctl -u ssh</code> 或 <code>/var/log/auth.log</code>，RHEL 系是 <code>journalctl -u sshd</code> 或 <code>/var/log/secure</code>。這些嘗試要成功，取決於主機登入驗證那一層的設定；而只剩那一層在擋時，風險集中在四個地方：</p>
<ul>
<li><strong>sshd 本身的漏洞</strong>：2024 年的 regreSSHion（CVE-2024-6387）讓未經認證的連線有機會在 OpenSSH 伺服器上執行程式碼。這類漏洞發生在認證之前，金鑰設定得再嚴格也擋不住；能降低這類風險的是修補速度（縮短暴露期）與收窄網路可達性（減少能把封包送到 sshd 的來源）。</li>
<li><strong>密碼登入</strong>：多數雲端廠商的官方 Linux 映像檔預設關閉 SSH 密碼登入、只接受金鑰，單純猜密碼在沒改過設定的主機上不會成功。不少 VPS 商在開機時寄出 root 密碼、或提供「用密碼建立主機」的選項，這類主機的密碼登入一開始就是開著的，要自己關掉。有人為了方便打開 <code>PasswordAuthentication yes</code>、或建了弱密碼的本機帳號，這項預設也會失效，而這類改動發生在主機內部，防火牆看不到。</li>
<li><strong>金鑰的生命週期</strong>：私鑰從筆電、CI 或共用資料夾外洩之後，持有者從任何地方都能登入；成員離職時沒從 <code>authorized_keys</code> 移除的公鑰持續有效。組織處理離職時停用的是公司帳號（郵件、IdP），這個動作不會碰到任何一台主機上的 <code>authorized_keys</code>；主機的登入紀錄記的是金鑰指紋而不是人名，那把公鑰之後被拿來登入，紀錄上也對不到是誰。靜態公鑰沒有到期時間，所以「誰還拿得到這台主機」只能靠人工盤點查出來，離職流程要把主機登入層列進去，撤銷清單的寫法見<a href="/blog/devops/infra/08-governance-habits/handover-design/" data-link-title="職務交接與存取撤銷設計" data-link-desc="人員異動時的存取撤銷順序、credential rotation、最小交接清單，以及讓交接成本結構性降低的 infra 設計原則">職務交接與存取撤銷設計</a>的〈離職或轉調的存取撤銷清單〉。</li>
<li><strong>RDP</strong>：Windows 的遠端桌面預設接受帳號密碼，暴力猜測對它的成功率比對只收金鑰的 SSH 高，所以 3389 對外開放的被入侵機率高於 22。RDP 在主機登入驗證那一層的對應是 Windows 帳號，其餘兩層的做法與 SSH 相同。</li>
</ul>
<p>同一個內部網段裡的主機之間如果所有 port 互通，一台主機被攻破後，攻擊者從那台機器可以直接連到其他主機的資料庫 port 與管理 port，這種擴散方式叫<a href="/blog/backend/knowledge-cards/lateral-movement/" data-link-title="Lateral Movement（橫向移動）" data-link-desc="評估一台主機被入侵之後攻擊者還連得到哪些機器、內網或 VPN 裡的可達範圍要收到多窄時，查攻擊者以已經拿下的機器為起點往內擴散的方式">橫向移動（lateral movement）</a>。收斂對外的管理 port 時，內部網段之間的規則要一起盤點，最小開放的設計見<a href="/blog/devops/infra/03-network-foundation/vpc-subnet-security-group/" data-link-title="網路地基 — VPC、subnet 分層與 security group 設計" data-link-desc="VPC CIDR 規劃、public / private subnet 切分、route table 與 NAT 的可用性成本取捨、security group 最小開放設計，以及 NACL 的定位">網路地基 — VPC、subnet 分層與 security group 設計</a>的〈security group 設計：最小開放〉一節。</p>
<h3 id="更換-ssh-port-與-fail2ban-改變的範圍">更換 SSH port 與 fail2ban 改變的範圍</h3>
<p>更換 SSH port 與安裝 fail2ban 常被當成上面這些風險的對策，而它們都不改變「sshd 對全網可達、只靠主機登入驗證在擋」的狀態。更換 port 之後，sshd 仍然對整個網際網路可達，掃描程式找的是開著的 port，不是 22 這個數字。fail2ban 在同一個來源失敗多次之後才封鎖它，所以每個來源的第一次嘗試仍然送得到 sshd；它擋得住重複猜測，擋不住第一次就成功的攻擊（sshd 漏洞的利用、外洩的私鑰）。兩者減少的是認證紀錄裡的雜訊。</p>
<h2 id="網路可達性的做法管理-port-對誰開放">網路可達性的做法：管理 port 對誰開放</h2>
<p>伺服器是否仍需接受入站連線，把網路可達性的做法分成兩類；這一層的目標是讓 sshd 只對經過通道授權的來源可見。</p>
<p><strong>仍然接受入站、但縮小來源</strong>的做法有四種：</p>
<ul>
<li><strong>來源 IP 白名單</strong>：防火牆只放行公司對外的固定 IP。設定成本最低；代價是遠端工作與 IP 變動時要一直改規則，而且公司網路裡任何一台被入侵的電腦都在白名單內。它同時兼任通道授權，判斷身分的依據是 IP。</li>
<li><strong>跳板機（<a href="/blog/devops/infra/knowledge-cards/bastion-host/" data-link-title="Bastion Host（跳板機）" data-link-desc="內部主機不想各自對外開管理 port、要設定 ProxyJump 經由跳板機連線、判斷 agent forwarding 的風險，或要評估自建跳板機與改用身分感知代理的取捨時，查跳板機的連線方式、暴露面與維運責任">bastion host</a>，其他中文資料常寫作「堡壘主機」）</strong>：一台放在公開網段、只用來轉接 SSH 的主機，其他主機只接受來自它的連線。暴露面從每台主機收斂成一台，但那一台仍然對外開著管理 port，要修補、要監控，它的 sshd 漏洞就是整個環境的入口。它同時兼任通道授權，判斷依據是跳板機上的 SSH 金鑰，或跳板機信任的 SSH CA 所簽的憑證。好處是與廠商無關、可以集中錄製操作紀錄。主機只有一台時，跳板機本身就多出一台對外開著 22 port 的主機，暴露面沒有縮小。</li>
<li><strong>從代理位址段連入的身分感知代理（<a href="/blog/devops/infra/knowledge-cards/identity-aware-proxy/" data-link-title="Identity-Aware Proxy（身分感知代理）" data-link-desc="要讓管理員或使用者不經公開入口連到內部主機與服務、評估把存取控制從來源 IP 改成帳號身分時，查身分感知代理的連線路徑、前提與代價">identity-aware proxy</a>）</strong>：由廠商提供或自己架設的代理服務在網際網路上接收連線，向 IdP 驗過身分之後，才從代理服務自己的固定位址段連到主機的內部 IP。防火牆只放行那段代理位址，主機本身不需要公開 IP。它同時承擔通道授權。GCP 的 IAP TCP forwarding、AWS 的 EC2 Instance Connect Endpoint 屬於這一類；它是<a href="/blog/backend/knowledge-cards/zero-trust/" data-link-title="Zero Trust（零信任）" data-link-desc="判斷內網或 VPN 裡的服務能不能因為連線來自內部就省掉認證、或要評估一條連線該放行到什麼範圍時，查「每一次連線各自通過身分與授權」這個原則的內容與導入代價">零信任（zero trust）</a>原則在存取入口上的一種實作。身分感知代理也可以改由主機上的代理程式反向連線送進主機，那種送達方式歸在下面的「主機不接受任何入站連線」。</li>
<li><strong>VPN</strong>：讓管理員的電腦加入內部網路，再用內部 IP 連主機，主機接受的入站只來自 VPN 範圍內的位址。它解決的範圍比管理通道大（資料庫用戶端、內部管理介面都能用），而連上之後通常整個網段都可達，要另外用防火牆規則限縮。VPN 閘道的形態決定它本身的暴露面：SSL VPN 與 IPsec 設備在網際網路上監聽、在認證之前就處理封包，2023 到 2024 年被大規模利用的認證前漏洞集中在這類設備上，它的修補與 sshd 同等重要；WireGuard 不回應沒有通過金鑰驗證的封包，掃描程式看不到它在監聽。Tailscale 這類 mesh VPN 讓每台裝置彼此直連、組成一個私有網路（Tailscale 稱為 tailnet），sshd 只監聽 tailnet 介面的位址，裝置透過 NAT traversal 建立連線、不必對外開入站 port；tailnet 裡的裝置預設彼此互通，收窄要靠 Tailscale 的 ACL。mesh VPN 的登入多半接組織的 IdP，所以它的通道授權依據是 IdP 帳號。Tailscale 另有 Tailscale SSH 模式，由 Tailscale 依 tailnet 的 ACL 決定能登入哪個帳號，取代 sshd 的金鑰驗證，主機登入驗證那一層跟著換掉。Tailscale 依賴它營運的協調伺服器，不想依賴第三方時改用自建的 WireGuard，或接得上 OIDC IdP 的自建協調伺服器（Headscale、NetBird）。連上之後的可達範圍怎麼收窄、哪些限制要放在被入侵的一端改不到的位置，見<a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">兩個網路之間的連線安全</a>的〈VPN 提供的保證與邊界模型〉；Tailscale 與自建 WireGuard 的取捨見 <a href="/blog/linux/tools/remote/tailscale-tailnet-and-relay/" data-link-title="Tailscale 深入：tailnet、MagicDNS、直連與 DERP 中繼" data-link-desc="要用 Tailscale 讓散在不同網路的機器互連、遇到連得到但延遲高、或想讀懂 tailscale status 的 relay / direct 標示時回來讀">Tailscale 深入：tailnet、MagicDNS、直連與 DERP 中繼</a>的〈取捨與邊界〉。</li>
</ul>
<p><strong>主機不接受任何入站連線</strong>的做法是代理程式（agent）反向連線，一種 <a href="/blog/backend/knowledge-cards/outbound-tunnel/" data-link-title="Outbound Tunnel" data-link-desc="反向隧道把出站連線轉成可達入口、與傳統 port-forward 的責任倒轉">Outbound Tunnel</a>：主機上常駐一個代理程式，由它主動往外連到管理服務並維持一條連線，管理員的工作階段經由那條連線送進來，防火牆不必為管理用途開任何入站 port。AWS 的 SSM Session Manager 是這一類的代表；Cloudflare Access 搭配主機上的 <code>cloudflared</code>、Teleport 的節點以反向通道連到 proxy，在網路可達性上也屬於這一類，同時它們也承擔通道授權。Tailscale 的連線方向與 outbound tunnel 相同，但 sshd 仍然接受 tailnet 介面的入站，本篇把它歸在 VPN。這一類的前提是代理程式持續在主機上執行，而且主機有連到管理服務端點的出站路徑。</p>
<p>代理程式反向連線的工作階段不一定經過 sshd。SSM Session Manager 預設由代理程式直接在主機上開 shell，決定能登入成哪個系統帳號、是否能用 sudo 的是 AWS 端的權限設定與代理程式的設定，主機登入驗證那一層因此跟著換掉；只有改用經由 Session Manager 轉送 SSH 的模式時，sshd 與金鑰才仍然在路徑上。Teleport 的節點用它自己內建的 SSH 伺服器與 Teleport 簽發的憑證，同樣不經過系統的 sshd。採用前要查清楚該服務的工作階段是直接開 shell、走自己的 SSH 伺服器，還是轉送到 sshd，再決定主機登入驗證那一層要設計什麼。</p>
<h2 id="通道授權的做法判斷連線使用者身分的依據">通道授權的做法：判斷連線使用者身分的依據</h2>
<p>比較通道授權的判斷依據時看四件事：分不分辨得出個人、判斷依據能不能被複製給別人、收回之後多久生效、留不留得下「是誰」的紀錄。判斷依據與網路可達性的做法不是一對一：VPN 可以用 PKI 簽發的憑證，也可以接 IdP 帳號（mesh VPN、多數商用 VPN 的 SAML 登入），接 IdP 時屬性看 IdP 帳號那一列。</p>
<table>
  <thead>
      <tr>
          <th>判斷依據</th>
          <th>分辨個人</th>
          <th>能否被複製</th>
          <th>收回生效</th>
          <th>「是誰」的紀錄</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>來源 IP</td>
          <td>分辨不出</td>
          <td>同一網路的任何人都通過</td>
          <td>改防火牆規則之後立即生效，但收回的是整個網路</td>
          <td>只有 IP，對不到人</td>
      </tr>
      <tr>
          <td>VPN 憑證</td>
          <td>每人一張時分辨得出</td>
          <td>憑證檔能複製，綁在硬體 token 上時不能</td>
          <td>PKI 型 VPN 靠伺服器維護的憑證撤銷清單；WireGuard 從伺服器設定刪掉那個人的公鑰</td>
          <td>VPN 伺服器的連線紀錄</td>
      </tr>
      <tr>
          <td>跳板機上的 SSH 金鑰</td>
          <td>每人一把時分辨得出</td>
          <td>私鑰檔能複製；存在硬體安全金鑰裡的 <code>-sk</code> 金鑰不能</td>
          <td>要到跳板機的 <code>authorized_keys</code> 刪除</td>
          <td>跳板機紀錄裡的金鑰指紋</td>
      </tr>
      <tr>
          <td>IdP 帳號</td>
          <td>分辨得出</td>
          <td>登入那一刻取決於多因素驗證；登入後留在本機的 token 能被複製，取決於代理的 session 長度與裝置綁定</td>
          <td>IdP 停用之後，代理自己簽發的 session 或憑證到期才失效；收回延遲的上限是代理端設定的 session 長度</td>
          <td>每一次開通道都記錄是哪個帳號</td>
      </tr>
  </tbody>
</table>
<p>跳板機與目的主機接受同一把金鑰時，一把私鑰外洩就同時通過通道授權與主機登入驗證，兩層退化成一層。SSH 金鑰那一列的硬體金鑰（OpenSSH 8.2 起支援 FIDO2 的 <code>ed25519-sk</code>）讓私鑰離不開那支實體金鑰，沒有 IdP 的組織用它就能補上「能否被複製」那一格。</p>
<p>IdP 帳號是 Google Workspace、Microsoft Entra ID、Okta 這類身分提供者上的帳號，通道服務把驗證交給它；GCP IAP 用的 Google 帳號就是一個實例。Cloudflare Access 可以接任何支援 OIDC 或 SAML（兩種把登入交給 IdP 的標準協定）的 IdP，Teleport 的 Enterprise 版也可以，Community 版的單一登入只接 GitHub。IdP 帳號在分辨個人與留下紀錄上比另外三種好，收回速度則由代理的 session 長度決定：IdP 停用一個帳號之後，代理已經簽給他的 session 或憑證（Cloudflare Access 的 session 最長可以設到一個月、Teleport 的使用者憑證有自己的有效期、<code>gcloud</code> 在本機保存 refresh token）要到期或被撤銷才失效，所以導入時要在代理端設定 session 長度，處理離職或帳號外洩時另外在代理端撤銷 session。選用 IdP 帳號之後，管理通道的風險轉移到那個帳號上，帳號被釣魚，攻擊者就能開通道。所以前提是 IdP 對可以開通道的帳號強制多因素驗證，至少管理員帳號用防釣魚的方式（硬體安全金鑰或 <a href="/blog/backend/knowledge-cards/passkey/" data-link-title="Passkey" data-link-desc="要用金鑰對取代密碼、或判斷已導入的 passkey 實際擋掉了什麼時，用來定位它的保護落在哪一段、上限由哪條路徑決定">passkey</a>）——一次性驗證碼會在釣魚頁面上被一起騙走。要再收緊時，多數 IdP 與代理服務支援裝置狀態（device posture）條件，例如只允許公司管理的裝置。授權範圍也要一起設計：限定在負責維運的群組、限定可以連的 port 與主機，原則見<a href="/blog/devops/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與存取憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與存取憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計</a>的〈最小權限：持續收斂而非一次設定〉。</p>
<p>CI 或自動化工具要使用管理通道時，它的身分是<a href="/blog/devops/infra/knowledge-cards/service-account/" data-link-title="Service Account（服務帳號）" data-link-desc="程式、排程或雲端服務要以自己的身分呼叫雲端 API、要決定給它哪些角色、遇到「狀態正常卻呼叫失敗」的權限問題、或看到 IAM 綁定裡的 serviceAccount: 成員時，查服務帳號是什麼、它的權限要從「它能做什麼」與「誰能以它的身分行事」兩邊設，以及它和人的帳號在管理上的差別">服務帳號</a>或機器身分。長期有效的機器金鑰外洩，等同一把能進所有主機的鑰匙，所以這類身分用 OIDC 換取短期存取憑證，做法見<a href="/blog/devops/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與存取憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與存取憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計</a>的〈OIDC：給 CI/CD 的短期憑證〉。</p>
<h2 id="主機登入驗證的做法ssh-金鑰怎麼發怎麼收回">主機登入驗證的做法：SSH 金鑰怎麼發、怎麼收回</h2>
<p>sshd 接受哪些金鑰由主機登入驗證決定，而設計的重點是金鑰的生命週期：誰發、多久失效、離職時怎麼收回。</p>
<ul>
<li><strong>靜態 <code>authorized_keys</code></strong>：公鑰手動或由<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具寫進主機。沒有到期時間，收回要逐台刪除，主機數量一多就難以盤點；金鑰認證的基本設定見 <a href="/blog/devops/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH</a>。</li>
<li><strong>由中央服務決定可用的公鑰</strong>：主機在登入當下（或登入前由中央服務推送）取得這個帳號可用的公鑰，帳號或角色被移除，下一次登入就失敗。GCP 的 <a href="/blog/devops/infra/knowledge-cards/os-login/" data-link-title="OS Login" data-link-desc="GCP 專案要讓 SSH 登入權限跟著 Google 帳號與 IAM 走、啟用 OS Login 之後管理員或 CI 連不進 VM、或盤點專案 metadata 裡殘留的 SSH 公鑰時，查 OS Login 的運作方式、IAM role 與它和 metadata 金鑰的差別">OS Login</a> 在登入當下向 Google 查詢，AWS 的 EC2 Instance Connect 在連線前推送只在短時間內有效的公鑰，兩者都綁定各自的雲端廠商。自建的版本是 sshd 的 <code>AuthorizedKeysCommand</code> 接到 LDAP 或 FreeIPA 這類目錄服務，與廠商無關。這一類都依賴中央服務在登入當下可用：目錄服務或雲端的 metadata server 中斷時，沒有快取的主機一起登不進去；FreeIPA 的用戶端 SSSD 會快取公鑰，目錄服務離線時仍能登入。</li>
<li><strong>SSH CA 簽發的短期憑證</strong>：主機只信任一把 CA 公鑰（sshd 的 <code>TrustedUserCAKeys</code>），管理員登入前向簽發服務取得一張有效期有限的使用者憑證。憑證過期自動失效，不需要逐台收回；有效期就是能接受的最晚收回延遲。管理員手動簽發時常設成一個工作日左右，讓每天開工時簽一次；簽發由用戶端在連線時自動完成時（Teleport 的 <code>tsh</code>、smallstep 的 <code>step ssh</code>），有效期可以縮到幾小時以內。私鑰外洩而等不到到期時，sshd 用 <code>RevokedKeys</code> 指向撤銷清單提前作廢，這份清單要派送到每一台主機。<a href="/blog/devops/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">HashiCorp Vault</a> 的 SSH secrets engine 與 Teleport 都以這種方式運作；OpenSSH 本身就支援，用 <code>ssh-keygen -s</code> 簽發，所以這一種做法同時與廠商無關、又不需要逐台收回；只用 OpenSSH 導入的完整流程見 <a href="/blog/devops/infra/03-network-foundation/openssh-ca-setup/" data-link-title="OpenSSH 內建 CA 的 SSH 憑證導入：CA 金鑰、sshd 設定、簽發、principal、撤銷與主機憑證" data-link-desc="用 OpenSSH 自帶的 ssh-keygen 與 sshd 建立 SSH 憑證的完整流程：使用者 CA 與主機 CA 的金鑰怎麼產生與保管、sshd 的 TrustedUserCAKeys 與 AuthorizedPrincipalsFile、簽發時的識別字串與有效期、憑證外洩時用 KRL 撤銷，以及用主機憑證取代逐台確認指紋，以及導入順序與兩個會讓所有人登不進去的設定：AuthorizedPrincipalsFile 的讀取權限、RevokedKeys 指的檔案不存在">OpenSSH 內建 CA 的 SSH 憑證導入</a>。</li>
</ul>
<p>三種做法收回的都是下一次登入；SSH 憑證的「收回」是到期之後失效。已經建立的工作階段不受影響，處理離職或帳號外洩時，管理員要另外登入主機結束那個使用者的工作階段（systemd 的發行版用 <code>loginctl terminate-user &lt;username&gt;</code>），結束已建立工作階段的動作在應用程式的層次叫 <a href="/blog/backend/knowledge-cards/session-invalidation/" data-link-title="Session Invalidation" data-link-desc="說明事件後如何讓既有會話失效，避免被重放或延續利用">session invalidation</a>。</p>
<p>Windows 主機用 RDP 登入時，這一層是 Windows 帳號而不是 SSH 金鑰。本機帳號的收回與靜態 <code>authorized_keys</code> 同類，要逐台停用；主機加入 Active Directory 或 Entra ID 時，登入用網域帳號，停用網域帳號之後下一次登入就失敗，與由中央服務決定公鑰同類。網域主機仍有本機管理員帳號，它的密碼由 Windows LAPS 逐台產生並定期輪替，避免一組密碼通用於所有主機。已經建立的 RDP 工作階段同樣要另外結束（<code>query session</code> 找出工作階段、<code>logoff &lt;session-id&gt;</code> 結束）。</p>
<p>主機登入驗證與通道授權常由同一個系統提供（例如 Teleport 同時做代理與簽發憑證、GCP 的 IAP 與 OS Login 都依 IAM 判斷），但兩者仍然是兩次檢查：通道授權允許開通道，主機登入驗證決定進了通道之後能登入哪個帳號、是否取得 sudo。</p>
<h2 id="移除公開-ip-之後的出站路徑">移除公開 IP 之後的出站路徑</h2>
<p>管理通道改走身分感知代理、VPN 或代理程式反向連線之後，伺服器的公開 IP 對管理用途就不再必要，移除它可以讓主機從網際網路上無法定址。移除之前要先處理主機往外的連線：沒有公開 IP 的主機預設連不到網際網路，套件更新、拉容器映像檔、呼叫第三方 API 都會逾時。</p>
<p>出站需求的對應做法是 <a href="/blog/devops/infra/knowledge-cards/nat/" data-link-title="NAT Gateway" data-link-desc="private subnet 或沒有外部 IP 的 VM 要主動對外連線、評估 NAT 的數量與出站成本、或在 AWS 與 GCP 之間對照出站做法時，查 NAT Gateway 的職責、部署模式與取捨">NAT</a>：讓內部主機主動往外連、外部無法主動連進來。存取同一家雲端廠商的服務（物件儲存、容器 registry）時，多數廠商提供不經過 NAT 的私有端點，流量不出公網。計費依端點類型不同：GCP 的 Private Google Access 與 AWS 的 Gateway Endpoint（S3、DynamoDB）不另收費，可以省下 NAT 的處理費用；AWS 的 Interface Endpoint 另有按小時與傳輸量的計費。主機還帶著公開 IP 時，雲端廠商的 NAT 不處理這台主機的出站流量，流量走的是它自己的公開 IP，所以在移除公開 IP 之前測出站，結果一定是通的、證明不了 NAT 有效；確認的方式是比對出站時對方看到的來源 IP（例如 <code>curl -s https://ifconfig.me</code>），印出的是 NAT 的 IP 才代表流量走了 NAT。</p>
<p>採用代理程式反向連線的環境，管理通道本身也走出站路徑：代理程式要連到管理服務端點，出站路徑一斷，管理通道也跟著斷。這種中斷不會出現在服務的監控上。收緊出站規則的理由通常與管理通道無關（拿掉 NAT 省費用、限縮出站的目的地），而對外服務的請求不經過代理程式那條連線，服務照常回應；管理通道平時只在除錯時才有人用，除非另外監看管理服務端顯示的代理程式連線狀態，第一個發現它斷掉的人就是下一次要登入主機的人，往往是在事故當下。出站規則若在雲端防火牆上，透過雲端廠商的 API 改得回來，代價是事故處理要等找出並恢復出站路徑之後才能繼續；規則若在主機上的防火牆（ufw、nftables），就只能從救援入口改回來。所以收緊出站規則時，要把管理服務端點列進必須保留的目的地。網站經由 Cloudflare Tunnel 對外時，<code>cloudflared</code> 也是這一類的出站依賴：它用 QUIC 連 Cloudflare 的 UDP 7844、以 http2 連 TCP 7844，實測擋掉 UDP 7844 時 quick tunnel 一直重試 QUIC 而沒有改用 http2，網站回 Cloudflare 的 530，見 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 的〈Cloudflare Tunnel：源站不開入站，以及出站要放行的連接埠〉。</p>
<p>對外提供服務的主機（網站、API）移除公開 IP 之前，要先把流量入口換到負載平衡器，見<a href="/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用</a>。</p>
<h2 id="切換管理通道時避免把自己鎖在外面">切換管理通道時避免把自己鎖在外面</h2>
<p>切換管理通道的順序由一個事實決定：舊路徑被移除的那一刻起，所有走舊路徑的連線都會被拒絕，包括正在操作的管理員自己的連線。所以新路徑要在舊路徑還在的時候建好並驗證，才移除舊路徑，這個順序在網路變更上叫 make-before-break。順序是：救援入口先準備好並實際登入過一次（做法見〈救援入口：管理通道斷掉時的備用路徑〉），同時開啟舊路徑的紀錄開始觀察，接著建新通道、每一位管理員都用自己的身分實際連通一次，等觀察期涵蓋過一次完整的部署週期之後遷移非人員的來源，然後停用舊路徑驗證新路徑，最後才刪除舊路徑。</p>
<p>走舊路徑的不只管理員。CI 或部署腳本用公開 IP 直連主機、組態管理工具、監控探測、第三方廠商的維護連線，都會在舊路徑移除時一起中斷。全開規則的防火牆紀錄大部分是網際網路掃描程式的來源，CI 託管執行環境的 IP 每次不同，混在裡面認不出來，所以防火牆紀錄只用來列出候選來源；實際依賴舊路徑的連線要從主機的認證紀錄裡找成功登入的那幾筆：sshd 的 <code>Accepted publickey for &lt;user&gt; from &lt;ip&gt;</code> 帶著登入的帳號與金鑰指紋，Windows 的事件 ID 4624（登入類型 10 是 RDP）帶著登入的帳號。觀察期要涵蓋一次完整的部署週期與排程工作，找出來的非人員來源先改走新通道（機器身分用短期存取憑證開通道）。</p>
<p>舊路徑還在時，連通測試在兩種情形下會誤報成功。主機同時有公開 IP 與新通道時，連線工具可能優先走舊的直連路徑，所以測試要用工具的強制參數走新路徑（GCP 上是 <code>gcloud compute ssh --tunnel-through-iap</code>）。即使連線確實走了新通道，舊的全開規則（來源 <code>0.0.0.0/0</code>）也同時放行代理的位址段，放行這次測試連線的可能是舊規則，新規則寫錯了也測不出來。驗證新規則要先停用舊規則再測一次——停用的規則一行指令就能恢復原樣，刪除之後要重新建立、重建時可能寫錯——或查防火牆紀錄裡這次連線命中的是哪一條規則；防火牆沒有停用功能時，改用查紀錄這一種。規則在雲端防火牆上時，停用之後 SSH 連不上，管理員仍然能透過雲端廠商的 API 還原。規則在主機上的防火牆時，停用或改錯就只剩救援入口能進去，所以改之前先排一個自動還原：<code>iptables-apply</code> 套用新規則之後要操作者在時限內確認、逾時就自動回到舊規則，用 ufw 或 nftables 時用 <code>at</code> 排一個幾分鐘後還原的工作，確認新路徑可用再取消它。</p>
<p>用 IaC 管防火牆的環境，切換要改在程式碼裡，落差有兩個方向：只在網頁管理介面或 CLI 刪掉的規則，IaC 工具下一次執行 apply 時會依程式碼建回來；只在 CLI 新增的設定（新規則、主機上的 tag、IAM 授權、metadata），所屬資源若在程式碼裡被完整定義，下一次 apply 會把它拿掉，舊規則這時已經刪了，新通道跟著消失。這種落差叫 <a href="/blog/devops/infra/knowledge-cards/drift/" data-link-title="Drift（設定漂移）" data-link-desc="IaC 的 state 與雲端實際狀態之間的不一致，通常因為有人繞過 IaC 直接在 Console 改設定">drift</a>，處置的方式見<a href="/blog/devops/infra/takeover/partial-iac-dual-truth-operation/" data-link-title="兩套真相並存的過渡期操作" data-link-desc="部分資源在 IaC、部分在手動時，怎麼安全操作避免比全手動更危險，以及怎麼縮短這個過渡期">兩套真相並存的過渡期操作</a>。在 CLI 上停用舊規則做驗證的期間，要暫停所有 apply，否則驗證中的規則會被改回去。</p>
<h2 id="救援入口管理通道斷掉時的備用路徑">救援入口：管理通道斷掉時的備用路徑</h2>
<p>管理通道的任何一層出錯——防火牆規則寫錯、代理服務的權限設錯、sshd 設定改壞、磁碟滿導致 sshd 起不來——管理員就進不了主機。救援入口是一條不經過主機作業系統的網路設定與 sshd 的路徑：雲端廠商的<a href="/blog/devops/infra/knowledge-cards/serial-console/" data-link-title="Serial Console（序列主控台）" data-link-desc="SSH 因為防火牆、sshd 設定或磁碟滿而連不進主機，或要規劃管理通道斷掉時的備用路徑時，查序列主控台與頻外管理介面的用法與它本身的暴露面">序列主控台（serial console）</a>（GCP 的 Serial Console、AWS 的 EC2 Serial Console、Oracle Cloud 的 serial console connection）、VPS 商在管理面板提供、在瀏覽器裡顯示主機畫面的 VNC 主控台，以及地端主機的 IPMI、iDRAC 這類嵌在主機板上的頻外管理控制器（out-of-band management；其他中文資料常寫作「帶外管理」）。頻外管理控制器有自己的網路埠，它的網路要接在獨立的管理網段。救援入口不經過管理通道那三層的設定，但它有自己的授權：雲端上是雲端帳號的 IAM 權限與開啟它的開關，登入時用主機上的本機帳號密碼。</p>
<p>救援入口本身也是一個能登入主機的入口，所以它的設計與管理通道相反：平時保持關閉或只授權給少數持有救援責任的帳號，只在救援時對單一主機開啟，用完關回去。組織層級預設關閉救援入口時，救援名單裡要有能對那台主機所在的專案或帳號解除這項限制的人，否則事故當下只有主機權限的人開不了它。這種「平時不存在、事故時才開、事後回顧」的權限叫 <a href="/blog/backend/knowledge-cards/break-glass-access/" data-link-title="Break-Glass Access" data-link-desc="說明緊急情況下臨時授予的高權限存取，如何用工單、時限與事後審查治理">break-glass access</a>，工單、時限與事後回顧的做法見<a href="/blog/devops/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級與存取管理</a>的〈臨時提權（break-glass）〉。</p>
<p>序列主控台與 VNC 主控台多半用主機上的本機帳號密碼登入，所以救援帳號要在主機還正常時就建好：密碼由密碼管理工具逐台隨機產生並存在受控的密碼庫，sshd 仍然維持只收金鑰（<code>PasswordAuthentication no</code>），持有救援密碼的人離開時輪替，並在切換管理通道之前用它實際登入一次，確認帳號、密碼與 sudo 都可用。地端與部分 VPS 沒有事先建好的帳號時，主機內部沒有任何不經登入的寫入路徑，只能重開機進單人模式或掛救援映像檔。雲端 VM 另外有不需要事先建帳號的修復路徑：GCP 在 metadata 設的 startup-script 每次開機都以 root 執行（Linux VM 用 <code>startup-script</code> 這個鍵），可以用來建帳號或改回 sshd 設定；AWS 的 user-data 預設只在第一次開機執行，要拿來救援得改成每次開機都執行的 cloud-init 格式；GCP、AWS 與 Oracle Cloud 都可以把開機磁碟卸下、掛到另一台 VM 修改再掛回去。這幾條路徑都要重開機或停機，比事先建好的帳號慢。不必重開機的路徑是業者在主機裡執行指令的服務：AWS 的 SSM Run Command，以及 Oracle Cloud 的 Run Command。Oracle Cloud 的 Run Command 以 <code>ocarun</code> 這個使用者執行腳本，要改 sshd 設定或別的帳號的 <code>authorized_keys</code>，得事先在主機上替 <code>ocarun</code> 設好 sudo（官方列的做法是建立時用 cloud-init 設，或建好之後登入主機設）。兩者都靠主機上的代理程式執行，代理程式沒在運作、或連不到業者的服務端點時用不上。Oracle Cloud 上序列主控台、Run Command、掛開機磁碟與重建這幾個入口各自要事先準備什麼，見 <a href="/blog/devops/infra/cloud-providers/vm-options/login/" data-link-title="登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務" data-link-desc="雲端主機建立頁 SSH 金鑰欄位的幾個選項（讓業者產生、上傳、貼上、不放金鑰）各自把什麼放進主機、私鑰由誰保管，映像檔決定的預設使用者名稱，建立之後還能不能加金鑰、金鑰遺失時剩下哪些入口，以及完全不放金鑰時靠業者的哪些服務登入">登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務</a> 的〈建立之後加金鑰與金鑰遺失時的入口〉。救援工作階段不經過平時錄製操作的位置，有逐指令稽核要求的環境，要在事後把救援時做了什麼另外記錄下來。</p>
<h2 id="組織層級的護欄與稽核紀錄">組織層級的護欄與稽核紀錄</h2>
<p>單一專案或帳號的收斂只保護那一處，下一個新建的專案仍可能帶著對外開放的管理 port。組織層級的護欄有兩種：限制資源能被建成什麼樣子（禁止建立帶公開 IP 的主機、不讓預設網路與它的全開規則被自動建立、強制主機登入驗證改用中央服務提供公鑰、預設關閉序列主控台），以及在組織層級評估、優先於專案規則的防火牆政策，讓專案裡即使建出來源是 <code>0.0.0.0/0</code> 的管理 port 規則，它也不生效。IaC 環境另外可以在 PR 階段用 checkov 這類工具擋下全開規則，見 <a href="/blog/devops/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>的〈tflint / checkov / tfsec：抓壞寫法與安全漏洞〉。</p>
<p><a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">稽核紀錄（audit log）</a>是在事後用到的：主機上出現沒人認領的設定變更、某把私鑰被回報外洩而要知道它在外洩之後有沒有被用過、成員離職後要確認他的存取確實停了。這些時刻要查的是三項紀錄：誰在什麼時候經由哪條通道登入了哪台主機、誰在什麼時候開了救援入口、環境裡是否出現新的對外開放管理 port（新建的專案或主機照預設建出全開規則，是這一項最常見的來源）。防火牆紀錄只看得到來源位址；走代理的環境，來源位址都是代理的位址段，要知道是哪個人，得靠通道授權那一層的紀錄（代理服務或 IdP 的 audit log），或主機登入驗證那一層的紀錄（每人一把時的金鑰指紋、SSH 憑證的識別字串、OS Login 的使用者名稱）。通道授權用來源 IP 時，「是誰」只剩 sshd 紀錄裡的金鑰指紋可查，而且只在每人一把金鑰時對得到人。這些紀錄多半要事先開啟，只記錄開啟之後的事，所以在切換管理通道的同一天開啟。代理服務記的多半是誰在什麼時候開了通道，不記錄通道裡執行的指令；要逐指令的紀錄，得用有工作階段錄製功能的代理或跳板機（見下面〈錄製操作與不開管理 port 的要求〉）。AWS 上對外開放規則的盤點方法見 <a href="/blog/devops/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">Security Group 稽核與清理</a>。</p>
<h2 id="三層做法的選擇依存取者與主機擁有者的關係主機分布收回方式與開放時間挑選跳板機vpn身分感知代理與-ssh-憑證">三層做法的選擇：依存取者與主機擁有者的關係、主機分布、收回方式與開放時間挑選跳板機、VPN、身分感知代理與 SSH 憑證</h2>
<p>選擇三層做法的順序，與封包通過三層的順序不同。封包先過網路可達性，而設計時先定下的是通道授權的身分來源，也就是通道授權判斷「這個人是誰」時查的那個帳號系統，因為網路可達性與主機登入驗證能用哪些服務，由身分來源篩出來：一個只認 Google 帳號的代理，組織的身分在 Entra ID 時，要先把身分同步或聯合（federation）進 Google 的帳號系統才用得了；OS Login 這類由中央提供公鑰的做法，前提是登入者的身分已經在提供這項服務的雲端廠商的 IAM 裡。所以下面依身分來源、網路可達性、主機登入驗證的順序，說明環境的哪一項條件會排除哪些做法；同一批主機有多種存取者時怎麼合併、錄製操作與不開管理 port 這兩種需求怎麼改變結果，各有一節；通道平時要不要開著，放在最後一節。</p>
<h3 id="通道授權的身分來源存取者與主機擁有者的關係身分所在的帳號系統">通道授權的身分來源：存取者與主機擁有者的關係、身分所在的帳號系統</h3>
<p>分開各種存取者的軸有三件事：存取者是人還是機器（機器不經過登入畫面）、存取者與主機擁有者是不是同一方，以及存取者的身分放在哪個帳號系統裡。依這三件事，存取者分成組織成員、外部合作方、個人與機器四類，各類的身分來源不同。四類都走完之後，若結果是「沒有任何可用的帳號系統」，就落在本節最後一段的處境。</p>
<p><strong>組織成員</strong>（主機擁有者自己的人）的身分在組織的 IdP 上時，通道授權用 IdP 帳號，前提是 IdP 對可以開通道的帳號強制多因素驗證，至少管理員帳號用防釣魚的方式（見〈通道授權的做法〉）。通道服務認的帳號系統與 IdP 不是同一家時有兩條路：把 IdP 的帳號聯合進那個帳號系統（例如讓 Google 的 Cloud Identity 以 SAML 單一登入接 Entra ID 或 Okta，並由 IdP 把帳號同步進去，使用者在原本的 IdP 登入、拿到的是那家雲端認得的身分），或改選一個直接接這個 IdP 的通道服務（Cloudflare Access、Teleport Enterprise）。兩條路怎麼選要看主機分布在哪裡：主機全在同一家雲端時，聯合進那家雲端是常見的選擇，代價是要維運同步與單一登入的設定；主機跨多個環境時，直接接 IdP 的通道服務一套就涵蓋全部。</p>
<p><strong>外部合作方</strong>（外包團隊、維護廠商，主機屬於委託他們的組織）的身分來源有三種狀態，由主機擁有者決定：</p>
<ul>
<li><strong>主機擁有者替他們建帳號</strong>（Entra 的 B2B 訪客、在 Cloud Identity 替外包人員建的帳號）：外部人員走組織成員那一條，多因素驗證也由主機擁有者強制；合約到期時停用帳號，或把授權設成有期限的綁定。</li>
<li><strong>主機擁有者授權給他們自己的帳號</strong>（外部人員公司或個人的 Google 帳號）：部分通道服務接受，GCP 上要另外授予外部帳號登入 VM 的角色，而組織若限制 IAM 綁定只能用自己網域的成員，這種授權會被拒絕（指令見 GCP 篇〈IAP 的 IAM 授權範圍：誰拿得到通道〉）。組織 IdP 的多因素驗證強制管不到這些帳號，採用前要確認對方帳號的驗證方式。</li>
<li><strong>兩者都不行</strong>：外部人員落在沒有可用帳號系統的處境（見本節最後一段）。合約結束、整個團隊一起離場是收回權限的時間點，所以要有一份清單，列出所有主機上屬於這個團隊的金鑰，或改用由主機擁有者的 SSH CA 簽發、會到期的 SSH 憑證（見〈主機登入驗證：登入權限的收回靠人工刪除、帳號連動或到期〉）。</li>
</ul>
<p><strong>個人</strong>（一個人管理屬於自己的一台或幾台主機）用的身分是自己在 GitHub 或 Google 的個人帳號，這個帳號就是通道授權的依據，所以它同樣要開多因素驗證，能用硬體安全金鑰或 passkey 就用。主機屬於客戶時不是這一類，那是外部合作方：用個人帳號把客戶的主機接進自己的 tailnet 或 Cloudflare 帳號，主機擁有者就失去控制，合約結束時也沒有收回的位置。主機在自己申請的雲端帳號裡時，身分已經在那一家的 IAM 裡，可以直接用那一家的代理（GCP 的 IAP、Oracle Cloud 的 Bastion、AWS 的 Session Manager），建立主機時的選法見 <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> 的〈虛擬網路與 subnet：主機放在哪一段網路〉；自己有固定的對外 IP 時，來源 IP 白名單也可以用。主機分散在不同業者或家裡的機器上時，跳板機只是多開一台對外的主機，這時的選擇是 mesh VPN 或以個人帳號登入的代理程式反向連線（Cloudflare Tunnel 搭配 Access）：除了 SSH 還要連主機上其他 port、而且每台要連的裝置都能裝用戶端時選 mesh VPN；想從瀏覽器或沒裝用戶端的裝置登入時選反向連線，代價是依賴 Cloudflare 營運的服務。兩者更完整的取捨見 <a href="/blog/backend/07-security-data-protection/cases/remote-shell-access-tailscale-vs-cloudflare-tunnel/" data-link-title="7.C11 選型：單人遠端 Shell — Tailscale vs Cloudflare Tunnel" data-link-desc="以「手機遠端操作本機 shell」為情境，比較 Tailscale mesh VPN 與 Cloudflare Tunnel &#43; Access 兩種存取模型的選型判讀。">7.C11 選型：單人遠端 Shell — Tailscale vs Cloudflare Tunnel</a>。</p>
<p><strong>機器</strong>（CI、Ansible 這類組態管理工具、監控探測）的身分不經過 IdP 的登入畫面，放在雲端的服務帳號上。跑在 CI 平台上的機器，用 CI 平台簽發的 OIDC token 換成服務帳號的身分（GCP 與 AWS 都稱為 workload identity federation，概念見 <a href="/blog/backend/knowledge-cards/workload-identity/" data-link-title="Workload Identity" data-link-desc="用於機器工作負載的身份語意與授權邊界">Workload Identity</a>，做法見〈通道授權的做法〉最後一段）；跑在雲端 VM 上的機器（一台定期執行 Ansible 的控制主機），用那台 VM 掛的服務帳號。收回是撤掉那個服務帳號的授權。主機登入驗證用 OS Login 這類帳號連動的做法、或 SSH 憑證時，機器不必持有長期金鑰；用靜態 <code>authorized_keys</code> 時，機器手上的 SSH 私鑰仍然長期有效，要定期輪替。</p>
<p><strong>沒有任何可用的帳號系統</strong>時，分兩種情形。主機擁有者有 IdP、只是不給外部人員帳號時，直接從下面的白名單、VPN 與跳板機裡選。組織自己還沒有 IdP 時，先比較建一個的成本：要比較的是每人一把金鑰的清單要逐台維護的工作量（人數乘以主機數、人員異動的頻率），以及稽核需不需要「是誰」的紀錄，對上建立 IdP 的成本（Google 的 Cloud Identity 免費版、把 GitHub 組織當 IdP 用）與替每個人設定多因素驗證的工作。決定不建時，管理員有固定的對外 IP 就用來源 IP 白名單，代價是分辨不出個人，「是誰」只能靠每人一把、登錄在主機 <code>authorized_keys</code> 的使用者金鑰的指紋追。沒有固定的對外 IP（遠端工作的小團隊、外包團隊維護客戶的幾台主機）時白名單縮不下來，剩下 VPN 與跳板機兩種：除了 SSH 還要連其他內部服務時選 VPN，每個人用自己的 VPN 憑證；主機有多台、只需要 SSH、或要集中錄製操作時選跳板機，每個人在跳板機上用一把金鑰、在目的主機上用另一把。每人一張憑證或一把金鑰時，〈通道授權的做法〉那張表裡的分辨個人與「是誰」的紀錄都做得到，代價是靠人工維護那份清單。</p>
<h3 id="網路可達性主機分布與代理把連線送進主機的方式">網路可達性：主機分布與代理把連線送進主機的方式</h3>
<p>這一步的輸入是〈通道授權的身分來源〉的結果（身分落在哪個帳號系統、哪些通道服務認得它）與主機分布在哪裡。</p>
<p>身分落在主機擁有者的通道服務認得的帳號系統時（組織成員、主機擁有者替外部人員建的帳號、主機擁有者授權給外部人員自己的帳號、機器的服務帳號），選擇取決於代理怎麼把連線送進主機。這些通道服務都開在主機擁有者的帳號底下，外部人員只是被授權使用。代理從自己的固定位址段連到主機內部 IP 時（GCP 的 IAP），它只到得了從那家雲端網路路由得到的主機：同一家雲端裡的主機，以及經專線或 VPN 接進那家雲端網路、而且雲端的路由器有對地端通告代理位址段的地端主機；兩種主機都要那家的代理認得這個身分（直接認得，或經由〈通道授權的身分來源〉說的聯合）。代理經由主機上的代理程式反向連線時（Cloudflare Access、Teleport），任何有出站路徑的主機都接得上，跨雲、地端與不提供代理服務的 VPS 商都落在這一類，一套通道授權涵蓋所有環境；反向連線的前提換成那條出站路徑，見〈移除公開 IP 之後的出站路徑〉。AWS 的 SSM Session Manager 也能管理地端主機（在主機上安裝代理程式並註冊為受管節點），但通道授權只認 AWS 的 IAM。接 IdP 的 mesh VPN（Tailscale、NetBird）同樣涵蓋所有環境，適合除了 SSH 還要連其他內部服務的團隊。主機全在同一家雲端、而且只需要 SSH 與 RDP 時，用那家雲端的代理（GCP 的 IAP）：身分已經在那家的 IAM 裡，主機登入驗證也能接同一套帳號（OS Login），不必另外維運代理或 VPN。</p>
<p>不想依賴第三方服務時，代理可以自己架設，代價是代理本身成為組織自己維運、對網際網路開放的入口（見〈錄製操作與不開管理 port 的要求〉）：HashiCorp Boundary 的社群版與 Pomerium 都能接任何 OIDC IdP。Teleport Community 版也能自架，但單一登入只接 GitHub（另有 Teleport 自己的本機帳號），接 OIDC 或 SAML 的 IdP 要 Enterprise 版；而且從 Teleport 16 起，它的授權只允許員工少於 100 人、年營收低於 1,000 萬美元的公司使用（兩個條件都要符合，個人使用不受限），超過的組織要用 Enterprise 版。<code>cloudflared</code> 依賴 Cloudflare 營運的服務。</p>
<p>存取者屬於「個人」這一類（主機屬於自己）、或沒有任何可用的帳號系統時，網路可達性在〈通道授權的身分來源〉已經定了：個人在 mesh VPN 與反向連線之間選，沒有帳號系統的處境在白名單、VPN 與跳板機之間選。</p>
<h3 id="同一批主機有多種存取者時">同一批主機有多種存取者時</h3>
<p>同一批主機常同時有員工、外包與 CI 要連。通道授權可以依存取者各自不同，網路可達性卻不行：一台主機對網際網路的暴露面，由所有存取者用的路徑裡最寬的那一條決定。員工改走 IAP、外包仍走公開的跳板機時，主機仍然能從網際網路經由那台跳板機觸及，員工那條路的收斂就被抵銷了。所以合併的做法是讓每一類存取者都走同一條路：替外包人員建帳號、或把他們自己的帳號授權進主機擁有者的通道服務，讓他們和員工一起走 IAP；機器以服務帳號走同一個代理。兩種帳號都拿不到的那一類，用主機擁有者自己維運、以金鑰認證、不回應未通過驗證的封包的 VPN（WireGuard），金鑰由主機擁有者發給每一個人；mesh VPN 與反向連線也行，但要開在主機擁有者的帳號底下、由主機擁有者授權，不是開在外部人員自己的帳號裡。主機登入驗證也照同一個原則合併：同一台主機上混用兩種做法（例如員工用 OS Login、外包用 SSH 憑證）會讓收回分散在兩處，能不能並存也依實作而定（OS Login 啟用之後，sshd 的設定由 guest agent 管理），所以先讓所有存取者落在同一種做法。</p>
<h3 id="錄製操作與不開管理-port-的要求">錄製操作與不開管理 port 的要求</h3>
<p>不論主機在哪裡，有兩種需求會改變上面的結果。</p>
<p>要集中錄製管理員在主機上執行了什麼時（需要這種錄製的情境例如外部廠商或外包人員要登入組織的主機、內部稽核要求留下逐指令的操作內容而不只是登入紀錄），錄製要有一個所有工作階段都經過的位置：有工作階段錄製功能的代理（Teleport 錄製每一個 SSH 工作階段），或跳板機。選了跳板機時，身分來源仍然沿用〈通道授權的身分來源〉選定的那一個：有 IdP 的組織架一個接 IdP 登入的 SSH CA，管理員登入 IdP 換到短期 SSH 憑證，跳板機只信任這個 CA。沒有 IdP、或外部人員拿不到帳號時，由主機擁有者的 SSH CA 依人簽發短期憑證，憑證的識別字串寫進錄製紀錄，做法與〈主機登入驗證：登入權限的收回靠人工刪除、帳號連動或到期〉的外包團隊相同。跳板機退回每人一把靜態金鑰的話，錄製是有了，隨帳號收回的能力卻沒了。GCP 的 IAP 這類代理只記錄誰開了通道，不錄製通道裡的指令。</p>
<p>需求是「沒有任何主機對網際網路開管理 port」（客戶或稽核常這樣要求）時，這個需求要防的是一個由組織自己維運、在網際網路上監聽、而且會回應沒有通過認證的來源的入口：sshd、SSL VPN 設備與自架代理的對外端點都是這種入口，掃描程式找得到它，認證之前的漏洞也打得到它。代理服務的邊緣節點同樣回應未認證的來源，差別在它由廠商維運，修補的責任跟著移到廠商身上。依這個判斷，跳板機與來源 IP 白名單都不符合：跳板機本身仍對外開著 22 port，白名單則是每台主機都對外開著 22 port、只是限制了來源；組織自己維運的 SSL VPN 與 IPsec 閘道，以及自架、對網際網路開放的 Teleport、Boundary、Pomerium 的 proxy 也不符合。符合的是廠商維運的代理程式反向連線、mesh VPN（裝置之間以 NAT traversal 連線，協調伺服器由廠商維運）、從代理位址段連入的身分感知代理（主機只接受代理位址的入站，對外監聽的是廠商的代理服務），以及 WireGuard：它在主機上開一個 UDP port，但不回應沒有通過金鑰驗證的封包，掃描程式看不到它在監聽。WireGuard 與 mesh VPN 算不算符合，取決於提出需求的一方怎麼定義「開管理 port」，採用前要跟客戶或稽核確認。</p>
<p>兩個需求同時成立時（受稽核的金融業常見），符合的組合有兩種：一種是有工作階段錄製功能、而且以反向連線或代理位址段把連線送進主機的代理，例如 Teleport Enterprise 的廠商代管版（Teleport Cloud：proxy 由廠商維運，節點以反向通道連到 proxy、錄製每個工作階段、接 Entra ID 或 Okta）；自架的 Teleport proxy 本身就是對網際網路開放的入口，不符合不開管理 port 的需求；另一種是沒有公開 IP、放在內部網段的跳板機負責錄製，管理員經由 IAP 或反向連線的代理連到這台跳板機。救援工作階段不經過這兩種錄製位置，處理方式見〈救援入口：管理通道斷掉時的備用路徑〉。</p>
<h3 id="主機登入驗證登入權限的收回靠人工刪除帳號連動或到期">主機登入驗證：登入權限的收回靠人工刪除、帳號連動或到期</h3>
<p>網路可達性選了直接在主機上開 shell 或自帶 SSH 伺服器的做法（SSM Session Manager 的預設模式、Tailscale SSH、Teleport 的節點）時，sshd 不在路徑上，這一層改由那個服務的權限設定承擔。其餘情形，主機登入驗證的選擇取決於登入權限的收回靠什麼發生：</p>
<ul>
<li><strong>收回靠人工刪除</strong>（靜態 <code>authorized_keys</code>）：成立的條件是主機少、人員穩定，而且收回已經是離職與合約結束交接流程裡的一項。</li>
<li><strong>收回靠帳號連動</strong>（由中央服務決定公鑰）：已經用某家廠商的 IdP、而那家廠商提供依同一個身分判斷的公鑰服務（GCP 的 OS Login）時，不必另建簽發服務或金鑰派送流程，收回跟著帳號走；它綁定那家廠商，主機在別的環境就用不了。自建的 <code>AuthorizedKeysCommand</code> 接 LDAP 或 FreeIPA 跨環境也做得到，代價是每台主機登入時都要連得到目錄服務，主機端沒有快取時（FreeIPA 的用戶端 SSSD 會在主機上快取公鑰），目錄服務中斷所有主機一起登不進去。</li>
<li><strong>收回靠到期</strong>（SSH 憑證）：主機跨多個環境（不同雲端、地端、不同 VPS 商，各自管理金鑰的主機群）時，它不依賴任何一家雲端廠商的帳號系統，也不必逐台收回，代價是要維運一個簽發服務並保管 CA 私鑰；私鑰外洩而要提前作廢時，撤銷清單仍要派送到每一台主機。SSH 憑證也適用於人員流動高、而且進不了客戶帳號系統的外包團隊：由主機擁有者的 CA 每天簽發、有效期設成一個工作日左右，合約結束時停止簽發，最後一張憑證在一個有效期之內失效，不必逐台盤點金鑰。</li>
<li><strong>Windows 主機</strong>（RDP）：主機加入 Active Directory 或 Entra ID 時，登入用網域帳號，收回跟著網域帳號走，屬於帳號連動；沒有網域時（例如組織只有 Google Workspace），每個管理員在每台主機上各建一個本機帳號、不共用，收回靠人工逐台停用。本機管理員帳號的密碼在有網域時用 Windows LAPS 逐台產生並輪替，沒有網域時用密碼管理工具逐台產生；Windows 主機多的組織，把主機加入 Entra ID 通常比逐台維護本機帳號省事。</li>
</ul>
<h3 id="不可變基礎設施下的臨時授權通道平時關閉">不可變基礎設施下的臨時授權：通道平時關閉</h3>
<p>服務已經用不可變基礎設施（immutable infrastructure）的方式部署，也就是以重新建立主機取代登入修改時，管理通道只在除錯與救援時才用得到。這時可以讓通道平時對任何人都開不起來：管理員平時移除通道授權的權限綁定，除錯時才臨時授予，用完收回；這一整個循環叫 just-in-time access，授予與收回的流程與救援入口相同，見〈救援入口：管理通道斷掉時的備用路徑〉。主機由 managed instance group 或 GKE 這類機制依 template 重建時，救援也多半改成重建一台而不是登入修，重建、退回舊版 template 與登入修各自適用的情形見<a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a>，重建一台 VM 的指令與實測結果見 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/mig-gke-access-walkthrough/" data-link-title="GCP 上 MIG 與 GKE 管理存取的實測紀錄：instance template 與 canary 滾動更新、重建與主機金鑰、kubectl debug 的 namespace、node pool 設定與控制平面端點的切換" data-link-desc="要把 IAP 的 tag、OS Login 與移除外部 IP 套到 MIG 或 GKE 上，想照一次實跑過的順序做、看每一步換一種做法的結果時讀；也用在 MIG 重建之後 SSH 出現 REMOTE HOST IDENTIFICATION HAS CHANGED、kubectl debug 停在等待，或關掉 GKE 控制平面的 IP 端點之前要確認每個人都改連的時候">GCP 上 MIG 與 GKE 管理存取的實測紀錄</a> 的〈重建一台壞掉的 VM：recreate-instances 與重建之後的主機金鑰〉。</p>
<h2 id="aws-與-oracle-cloud-上對應的元件">AWS 與 Oracle Cloud 上對應的元件</h2>
<p>這篇提到的 AWS 元件按層列在這裡，方便對照：網路可達性有 SSM Session Manager（代理程式反向連線）、EC2 Instance Connect Endpoint（從代理位址連入，支援原生 SSH 與 RDP）與 security group（防火牆規則）；通道授權由 AWS IAM 決定誰能開 Session Manager 的工作階段或使用 Instance Connect Endpoint；主機登入驗證有 EC2 Instance Connect（推送短效公鑰）；出站路徑有 NAT Gateway 與 VPC Endpoint；救援入口是 EC2 Serial Console；組織層級的護欄是 AWS Organizations 的 <a href="/blog/devops/infra/knowledge-cards/scp/" data-link-title="SCP (Service Control Policy)" data-link-desc="AWS Organizations 層級的權限天花板，套用到 OU 後連管理員都越不過">SCP</a>。規則的盤點方式見 <a href="/blog/devops/infra/03-network-foundation/security-group-audit-cleanup/" data-link-title="Security Group 稽核與清理" data-link-desc="盤點所有 security group 規則、找出 0.0.0.0/0 全開與未使用的 SG、依賴檢查後安全刪除、自動化治理">Security Group 稽核與清理</a>。</p>
<p>Oracle Cloud 的 Bastion 名字跟跳板機相同，位置在這張地圖上卻不一樣。它是業者託管的服務，不是放在公開網段、對外開 22 port 的那台主機：官方說明 Bastion 位在公開 subnet，替使用者建好連到私有 subnet 裡目標主機所需的網路設施，主機不需要公開 IP，所以它承擔網路可達性；誰能建立工作階段由 Oracle Cloud 的 IAM 政策決定，這是通道授權。Bastion 的工作階段共有三種：managed SSH session 由主機上的代理程式臨時放進一把公鑰，承擔主機登入驗證，而官方列出 Arm 架構的 A1 機型加非 Oracle Linux 的主機不支援這一種，Oracle Linux 也要選代理程式提供 Bastion plugin 的版本（支援範圍與查法見 <a href="/blog/devops/infra/cloud-providers/vm-options/login/" data-link-title="登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務" data-link-desc="雲端主機建立頁 SSH 金鑰欄位的幾個選項（讓業者產生、上傳、貼上、不放金鑰）各自把什麼放進主機、私鑰由誰保管，映像檔決定的預設使用者名稱，建立之後還能不能加金鑰、金鑰遺失時剩下哪些入口，以及完全不放金鑰時靠業者的哪些服務登入">登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務</a> 的〈不放金鑰時的登入服務〉）；SSH port forwarding session 只承擔網路可達性與通道授權，主機登入驗證仍然是主機自己的 sshd 與 <code>authorized_keys</code>；dynamic port forwarding（SOCKS5）session 同樣不經過代理程式，轉接的目標不固定，可以動態連到私有 subnet 裡的任何資源。私有 subnet 裡的主機要用 managed SSH session，VCN 要有服務閘道之類的閘道與路由，實際的網路設定見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/launch-instance-walkthrough/" data-link-title="Oracle Cloud 建立主機的實測紀錄：建立前的檢查、前置網路、主控台各分頁的 Always Free 設定值、等效的 CLI 指令、容量不足的重送、Bastion 工作階段逾時、Run Command 補公鑰的嘗試與重建後經由 port forwarding 登入" data-link-desc="要在 Oracle Cloud 建一台沒有公開 IP、經由 Bastion 登入的 Always Free A1 主機，想照一次實測重做、看建立頁每一欄的取值與換一種做法的結果時讀；也用在建立請求一直回容量不足、managed SSH session 建立逾時，或沒放公鑰的主機想用 Run Command 救援、Oracle Linux 10 的主機上找不到 Bastion plugin、想改用 port forwarding session 登入的時候">Oracle Cloud 建立主機的實測紀錄</a> 的〈進建立頁之前：compartment、VCN、閘道與 private subnet〉。各欄位怎麼設定見 <a href="/blog/devops/infra/cloud-providers/vm-options/login/" data-link-title="登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務" data-link-desc="雲端主機建立頁 SSH 金鑰欄位的幾個選項（讓業者產生、上傳、貼上、不放金鑰）各自把什麼放進主機、私鑰由誰保管，映像檔決定的預設使用者名稱，建立之後還能不能加金鑰、金鑰遺失時剩下哪些入口，以及完全不放金鑰時靠業者的哪些服務登入">登入：SSH 金鑰、預設使用者、建立後加金鑰與不放金鑰時的登入服務</a> 的〈不放金鑰時的登入服務〉，金鑰遺失時 Oracle Cloud 的救援入口（序列主控台、Run Command、把開機磁碟掛到另一台主機修改）見同一篇的〈建立之後加金鑰與金鑰遺失時的入口〉。〈移除公開 IP 之後的出站路徑〉在 Oracle Cloud 上的實作（私有 subnet 的主機加 NAT gateway、路由規則與只掛在那台主機上的出站 NSG），見 <a href="/blog/devops/infra/cloud-providers/oracle-cloud/hands-on/private-ubuntu-egress-console-rebuild/" data-link-title="Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建" data-link-desc="要在 Oracle Cloud 沒有公開 IP 的私有子網路裡跑一台 Ubuntu 主機、讓它裝得到套件更新，或要經由序列主控台看主機的開機過程，或主機被閒置回收、停止或刪除之後要照原樣重建，或想知道 NAT gateway 要不要另外付費、開機磁碟扣掉多少區塊磁碟額度、合計超過 200 GB 時放不進額度的那一顆怎麼算、怎麼用 quota 擋下超額的建立、線上加大開機磁碟超過額度時怎麼算、空出額度之後付費磁碟多久轉成免費時，查實際跑過的指令、錯誤原文、重建腳本與每一步的讀法">Oracle Cloud 私有子網路 Ubuntu 主機的實測紀錄：套件來源的出站（NAT gateway、路由與 NSG）、序列主控台的連線，以及被回收之後的重建</a>〈加出站：NAT gateway、路由規則與只掛在這台主機上的 NSG〉。</p>
<h2 id="延伸閱讀gcp-上的實作與反向連線的運作細節">延伸閱讀：GCP 上的實作與反向連線的運作細節</h2>
<ul>
<li>→ <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding</a>：IAP、OS Login、Cloud NAT、Serial Console 與組織政策在 GCP 上的機制與切換順序；停用舊規則驗證的逐步指令在 <a href="/blog/devops/infra/cloud-providers/gcp/hands-on/iap-ssh-migration-walkthrough/" data-link-title="GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄：盤點、救援入口、IAP 規則與 IAM、停用舊規則驗證、OS Login 啟用與移除外部 IP" data-link-desc="要把 GCP 專案裡對 0.0.0.0/0 開放 22 與 3389 port 的預設規則換成只放行 IAP，想照一次實跑過的順序做、看每一步沒做或換一種做法的結果時讀；也用在停用舊規則之後連不上、OS Login 啟用後有人登不進去，或拿掉外部 IP 之後要確認出站走了 Cloud NAT 的時候">GCP VM 的 SSH 與 RDP 改走 IAP 的實測紀錄</a></li>
<li>→ <a href="/blog/backend/05-deployment-platform/outbound-tunnel-entry/" data-link-title="5.10 Outbound Tunnel 入口與生命週期" data-link-desc="整理 cloudflared / Tailscale 等反向隧道的入口形態、生命週期合約與故障模式">5.10 Outbound Tunnel 入口與生命週期</a>：代理程式反向連線那條往外的連線怎麼建立、斷線時的故障模式</li>
<li>→ <a href="/blog/devops/infra/03-network-foundation/openssh-ca-setup/" data-link-title="OpenSSH 內建 CA 的 SSH 憑證導入：CA 金鑰、sshd 設定、簽發、principal、撤銷與主機憑證" data-link-desc="用 OpenSSH 自帶的 ssh-keygen 與 sshd 建立 SSH 憑證的完整流程：使用者 CA 與主機 CA 的金鑰怎麼產生與保管、sshd 的 TrustedUserCAKeys 與 AuthorizedPrincipalsFile、簽發時的識別字串與有效期、憑證外洩時用 KRL 撤銷，以及用主機憑證取代逐台確認指紋，以及導入順序與兩個會讓所有人登不進去的設定：AuthorizedPrincipalsFile 的讀取權限、RevokedKeys 指的檔案不存在">OpenSSH 內建 CA 的 SSH 憑證導入</a>：只用 ssh-keygen 與 sshd 簽發、撤銷 SSH 憑證的完整流程，主機登入驗證選「收回靠到期」時的實作</li>
<li>→ <a href="/blog/devops/infra/cloud-providers/gcp/gke-mig-management-access/" data-link-title="GCP 上由 MIG 與 GKE 管理的 VM 的管理存取：instance template、node pool、kubectl debug 與控制平面存取" data-link-desc="VM 由 managed instance group 或 GKE node pool 管理時，管理通道的設定為什麼要寫在 instance template 與 node pool 而不是逐台修改、MIG 救援用重建、退回 template 與 Serial Console 各自適用的情形、GKE 的控制平面與節點兩個存取面、kubectl debug 取代節點 SSH 的權限邊界（RBAC 與 Pod Security Admission），以及 GKE 控制平面三種端點（authorized networks、DNS-based endpoint 與 private endpoint）的差別">GCP 上由 MIG 與 GKE 管理的 VM 的管理存取</a>：主機由 template 重建時，三層的設定要寫在哪裡</li>
<li>→ <a href="/blog/backend/07-security-data-protection/cases/remote-shell-access-tailscale-vs-cloudflare-tunnel/" data-link-title="7.C11 選型：單人遠端 Shell — Tailscale vs Cloudflare Tunnel" data-link-desc="以「手機遠端操作本機 shell」為情境，比較 Tailscale mesh VPN 與 Cloudflare Tunnel &#43; Access 兩種存取模型的選型判讀。">7.C11 選型：單人遠端 Shell — Tailscale vs Cloudflare Tunnel</a>：mesh VPN 與代理程式反向連線用在同一個需求上的取捨</li>
</ul>
]]></content:encoded></item><item><title>CDN 後面的源站主機防火牆：只放行 CDN 來源的位置與套用時機、Docker 發佈連接埠的封包路徑、出站 DNS 的限制與地理封鎖</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/</link><pubDate>Sun, 25 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/origin-host-firewall/</guid><description>&lt;p>這篇整理網站放在 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN&lt;/a>（內容遞送網路，content delivery network：代替源站接收使用者連線、在邊緣做快取與過濾的服務，例如 Cloudflare）後面時，源站主機的防火牆要限制什麼、限制放在哪裡：請求避開 CDN 直接連到源站的成因（源站位址從 DNS 歷史紀錄、憑證透明度紀錄與其他記錄外洩）、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證時各自擋得住什麼、只放行 CDN 位址的規則什麼時候套上去、管理通道與同一台主機上不經 CDN 的服務怎麼處理、Docker 發佈的連接埠為什麼不受 &lt;code>ufw&lt;/code> 與 &lt;code>firewalld&lt;/code> 的一般規則約束、Podman 與 rootless 模式的容器在這一點上的差異，以及不發佈、只發佈在本機位址、&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件">&lt;code>DOCKER-USER&lt;/code>&lt;/a> 鏈、firewalld 的 &lt;code>StrictForwardPorts&lt;/code>、掛在位址轉換之前的 nftables 表、雲端防火牆與 Cloudflare Tunnel 這幾種做法、出站的 DNS 只准送往指定解析器擋得住與擋不住的部分，以及地理封鎖放在 CDN 邊緣的理由。&lt;/p>
&lt;p>機制以 Linux 主機、Cloudflare 與以 root 執行的 Docker（預設的 iptables 後端；compose 發佈連接埠的方式與 &lt;code>docker run -p&lt;/code> 相同）說明。其他 CDN 對應的是它公佈的、連回源站時使用的位址清單與它的回源驗證功能。Podman 與 rootless 模式的 Docker 轉送連接埠的方式不同，判斷的依據相同（容器執行環境有沒有在主機防火牆之前改寫封包的目的地），各自量到的結果見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉裡 Docker 以外的容器執行方式那一段；Kubernetes 的 NodePort 沒有驗證。&lt;/p>
&lt;p>指令與輸出逐步記在下列實測紀錄，下文寫「實測」的地方都出自它們：&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS&lt;/a> 在 Oracle Linux 10 的 firewalld 2.4.3 與 Docker 29.9.2 上，用一個位址落在 Cloudflare 範圍內、一個落在範圍外的兩個測試來源，量了每一種限制寫法擋不擋得住，以及規則在 firewalld 重新載入、dockerd 重啟與重開機之後還在不在；&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑&lt;/a> 在 Ubuntu 的 ufw 上量同一組問題；&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址&lt;/a> 換成其他容器執行方式；&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠&lt;/a> 接上實際的 Cloudflare 帳號，讓 Cloudflare 回源到一台 Oracle Cloud 主機。管理員登入主機那條通道的設計另見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口&lt;/a>，請求從 DNS 到應用經過哪幾層見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用&lt;/a>，CDN 本身的分發責任見 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源（CDN / Origin Protection）&lt;/a>（那一篇的 origin protection 指的是快取未命中時保護源站的負載，跟這篇的安全限制是兩件事）。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理網站放在 <a href="/blog/devops/infra/knowledge-cards/cdn/" data-link-title="CDN（內容遞送網路）" data-link-desc="網站要放到 Cloudflare 這類服務後面、看到回應標頭帶著 CDN 的標記、或要判斷源站看到的來源位址是誰時，查 CDN 代替源站接收連線的方式、回源與它對源站防火牆的影響">CDN</a>（內容遞送網路，content delivery network：代替源站接收使用者連線、在邊緣做快取與過濾的服務，例如 Cloudflare）後面時，源站主機的防火牆要限制什麼、限制放在哪裡：請求避開 CDN 直接連到源站的成因（源站位址從 DNS 歷史紀錄、憑證透明度紀錄與其他記錄外洩）、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證時各自擋得住什麼、只放行 CDN 位址的規則什麼時候套上去、管理通道與同一台主機上不經 CDN 的服務怎麼處理、Docker 發佈的連接埠為什麼不受 <code>ufw</code> 與 <code>firewalld</code> 的一般規則約束、Podman 與 rootless 模式的容器在這一點上的差異，以及不發佈、只發佈在本機位址、<a href="/blog/devops/infra/knowledge-cards/docker-user/" data-link-title="DOCKER-USER（Docker 留給使用者的轉送規則鏈）" data-link-desc="要在 Docker 發佈的連接埠上加來源限制、寫進 DOCKER-USER 的規則在防火牆重新載入或重開機之後不見、或規則擋到了容器自己往外的連線時，查這條鏈在轉送路徑上的位置、比對原始連接埠的寫法與規則的存續條件"><code>DOCKER-USER</code></a> 鏈、firewalld 的 <code>StrictForwardPorts</code>、掛在位址轉換之前的 nftables 表、雲端防火牆與 Cloudflare Tunnel 這幾種做法、出站的 DNS 只准送往指定解析器擋得住與擋不住的部分，以及地理封鎖放在 CDN 邊緣的理由。</p>
<p>機制以 Linux 主機、Cloudflare 與以 root 執行的 Docker（預設的 iptables 後端；compose 發佈連接埠的方式與 <code>docker run -p</code> 相同）說明。其他 CDN 對應的是它公佈的、連回源站時使用的位址清單與它的回源驗證功能。Podman 與 rootless 模式的 Docker 轉送連接埠的方式不同，判斷的依據相同（容器執行環境有沒有在主機防火牆之前改寫封包的目的地），各自量到的結果見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉裡 Docker 以外的容器執行方式那一段；Kubernetes 的 NodePort 沒有驗證。</p>
<p>指令與輸出逐步記在下列實測紀錄，下文寫「實測」的地方都出自它們：<a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a> 在 Oracle Linux 10 的 firewalld 2.4.3 與 Docker 29.9.2 上，用一個位址落在 Cloudflare 範圍內、一個落在範圍外的兩個測試來源，量了每一種限制寫法擋不擋得住，以及規則在 firewalld 重新載入、dockerd 重啟與重開機之後還在不在；<a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 在 Ubuntu 的 ufw 上量同一組問題；<a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a> 換成其他容器執行方式；<a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 接上實際的 Cloudflare 帳號，讓 Cloudflare 回源到一台 Oracle Cloud 主機。管理員登入主機那條通道的設計另見 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>，請求從 DNS 到應用經過哪幾層見 <a href="/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用</a>，CDN 本身的分發責任見 <a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源（CDN / Origin Protection）</a>（那一篇的 origin protection 指的是快取未命中時保護源站的負載，跟這篇的安全限制是兩件事）。</p>
<h2 id="主機前面有-cdn-時連到源站的路徑經由-cdn直連公開-ip-與管理通道">主機前面有 CDN 時連到源站的路徑：經由 CDN、直連公開 IP 與管理通道</h2>
<p>源站（origin server）是實際跑網站程式的主機，CDN 收到使用者的請求之後，從自己的機房連回源站取回應，這個動作叫回源。網域的 DNS 記錄設成經由 CDN 代理時，使用者解析網域拿到的是 CDN 的位址，請求先到 CDN，CDN 再用它自己的位址連到源站。這時連得到源站的路徑有三條：</p>
<ul>
<li><strong>經由 CDN 回源</strong>：使用者連 CDN，CDN 連源站的 80 或 443。源站看到的來源位址是 CDN 機房的位址，使用者真正的位址放在 CDN 加上的 HTTP 標頭裡（Cloudflare 是 <code>CF-Connecting-IP</code>）。</li>
<li><strong>直連源站的公開 IP</strong>：知道源站位址的人跳過 CDN，直接對源站的 80、443 或其他對外的連接埠送請求。CDN 上設定的 <a href="/blog/backend/knowledge-cards/waf/" data-link-title="WAF" data-link-desc="說明 Web Application Firewall 如何在入口層過濾常見攻擊與濫用">WAF</a>（Web 應用程式防火牆：依規則檢查 HTTP 請求內容並攔下可疑請求的元件）規則、速率限制與地理封鎖都在 CDN 那一端執行，這條路徑上沒有任何一條會作用。</li>
<li><strong>管理通道</strong>：管理員登入主機的 SSH，或經由雲端業者的代理（Oracle Cloud 的 Bastion、GCP 的 IAP）。這條路徑跟網站流量無關，CDN 也不經手。</li>
</ul>
<p>CDN 帶來的保護只對經由 CDN 回源那條路徑成立。一個漏洞公開之後，掃描程式會對網際網路上的主機送出探測請求；源站主機的防火牆對全網開放 80 與 443 時，這些請求可以走直連公開 IP 那條路徑送到源站，CDN 上為這個漏洞加的每一條規則都碰不到它們。直連那條路徑開著還有一個影響：直連的人可以自己在請求裡寫一個假的 <code>CF-Connecting-IP</code>，源站的存取記錄、速率限制與依國家的判斷照這個標頭做時都會被騙。反向代理的設定裡只信任來自 CDN 位址段的這個標頭（nginx 是 <code>set_real_ip_from</code> 加 <code>real_ip_header</code>），擋下的是不經過 CDN 的直連；從 CDN 位址送來的請求不一定是 CDN 替這個網域轉送的：實測用一個 Cloudflare Worker（任何帳號都能部署、從 Cloudflare 的位址對外連線的程式）連到只放行 Cloudflare 位址的源站，Worker 自己寫的 <code>CF-Connecting-IP</code> 原封不動送到了源站。這個標頭要能信任，源站還要能確認請求是 CDN 替自己的網域送來的，做法在〈限制放在哪一層：雲端防火牆、主機防火牆與 CDN 端的驗證〉。</p>
<h2 id="讓請求避開-cdn-的成因源站位址從哪裡外洩">讓請求避開 CDN 的成因：源站位址從哪裡外洩</h2>
<p>直連源站要先知道源站的位址，而這個位址的來源比網域的 A 記錄多。Cloudflare 的〈Protect your origin server〉列出的外洩來源有：接上 CDN 之前的 DNS 歷史紀錄（DNS 歷史查詢服務保留了網域過去解析到的位址，接上 CDN 之後舊記錄仍查得到）、沒有經由 CDN 代理的記錄（<code>mail</code>、<code>ftp</code> 這類子網域的 A 記錄，以及寫著 IP 的 SPF、TXT 記錄；SPF 是寫在 TXT 記錄裡、列出允許替這個網域寄信的主機的設定），以及跟網站同一台主機收發郵件時，退信會帶出主機的位址。Cloudflare 那一頁沒有列、但同樣公開的另一個來源是憑證透明度紀錄（certificate transparency log）：每一張公開簽發的 TLS 憑證都記在公開的日誌裡，源站曾經用自己的位址對外提供 HTTPS 時，掃描服務把憑證上的網域與當時回應的位址對起來。另外，網際網路上的掃描程式不必先知道網域：它們對整段 IP 位址逐一連 80 與 443，碰到回應的主機就送出探測請求，所以位址從未外洩的源站一樣會被掃到。</p>
<p>源站的位址因此要當成已經公開，防護要直接寫在源站能被連到的位置上：只有經由 CDN 的請求連得進來，直連公開 IP 那條路徑才會關上。Cloudflare 那一頁另外建議接上 CDN 之後更換源站的 IP，讓歷史紀錄裡的位址失效。</p>
<h2 id="限制放在哪一層雲端防火牆主機防火牆與-cdn-端的驗證">限制放在哪一層：雲端防火牆、主機防火牆與 CDN 端的驗證</h2>
<p>「只讓 CDN 連進來」可以寫在三個位置，它們擋的範圍與失效的方式不同：</p>
<ul>
<li><strong>雲端防火牆</strong>（Oracle Cloud 的 security list 與 NSG（network security group，套在網卡上的一組放行規則）、AWS 的 <a href="/blog/devops/infra/knowledge-cards/security-group/" data-link-title="Security Group" data-link-desc="掛在資源網卡層級的有狀態防火牆，逐埠決定哪些來源能連進這個資源">security group</a>、GCP 的 VPC 防火牆規則，位置與合併方式見 <a href="/blog/devops/infra/cloud-providers/vm-options/networking/" data-link-title="網路：subnet、私有 IP、公開 IP 與防火牆規則" data-link-desc="雲端主機建立頁網路區塊的選項：主機放進哪個虛擬網路與 subnet、私有 IP 與公開 IP 各自的用途與生命週期、防火牆規則掛在 subnet、網卡與作業系統哪一個位置以及各家怎麼合併，以及主控台新建的網路預設對外開放了什麼">網路：subnet、私有 IP、公開 IP 與防火牆規則</a> 的〈防火牆規則的位置與合併方式〉）：規則在業者的網路上執行，被擋的封包到不了主機，主機上跑什麼程式、程式怎麼改寫主機的防火牆都不影響它。要評估的是規則數：Cloudflare 在 2026-10-25 公佈的是 15 段 IPv4 與 7 段 IPv6，一條規則能放幾段來源、一組規則能放幾條，每一家業者的配額不同。Oracle Cloud 的 NSG 與 AWS 的 security group 一條規則只放一段來源，條數是「段數 × 要放行的連接埠數」：只放行 80 與 443 時 IPv4 是 30 條、加上 IPv6 是 44 條，實測 Oracle 的 NSG 上限是入站加出站合計 120 條，AWS 的 security group 文件寫的預設上限是入站 60 條、IPv4 與 IPv6 分開算，兩家都放得下；GCP 的 VPC 防火牆規則一條可以放 5000 段來源，IPv4 與 IPv6 各一條就放下了（GCP 與 AWS 依文件，沒有實測）。清單變動時也要有人改規則：一條規則只放一段時，每次更新是逐條增刪；AWS 可以改用自建的 prefix list，更新的是 list 的項目，security group 的規則不動，而 prefix list 被引用時以它設定的最大項數計入規則數（依文件，沒有實測）。</li>
<li><strong>主機防火牆</strong>（<code>firewalld</code>、<code>ufw</code>、<code>nftables</code>）：在主機的核心裡執行，可以用 ipset（核心裡的一個位址集合，一條規則比對整個集合）一次放進整份清單，也能寫出只對某個連接埠生效的規則。它管不到的是在它之前就改寫封包目的地的程式，Docker 是最常見的一個，機制見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。</li>
<li><strong>CDN 端的驗證</strong>：源站確認請求是 CDN 替這個網域送來的，而不只是來自 CDN 的位址。Cloudflare 的做法有兩種：Authenticated Origin Pulls 讓 Cloudflare 回源時出示用戶端憑證，源站的 TLS 設定只接受帶這張憑證的連線；Cloudflare Tunnel 由源站上的 <code>cloudflared</code> 主動往外連到 Cloudflare，請求經由這條往外建好的連線送進來（<a href="/blog/backend/knowledge-cards/outbound-tunnel/" data-link-title="Outbound Tunnel" data-link-desc="反向隧道把出站連線轉成可達入口、與傳統 port-forward 的責任倒轉">outbound tunnel</a> 的一種）。Authenticated Origin Pulls 用 Cloudflare 預設的共用憑證（主控台的 Global 開關）時，〈Protect your origin server〉寫明它只保證請求來自 Cloudflare 的網路；要分辨請求是否由自己的網域送來，要改用自己上傳的用戶端憑證（設在 Cloudflare 的 zone 層級，也就是一個網域的設定單位，或每個主機名稱各一張）。實測打開 Global 之後，Cloudflare 回源時出示憑證、nginx 驗證成功，nginx 設成必須有憑證時，沒有憑證的直連被回 400（nginx 的設定與輸出在 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 的〈SSL/TLS 模式「完整」與 Authenticated Origin Pulls〉）；自己上傳憑證的那兩種沒有實跑。</li>
</ul>
<p>雲端防火牆與主機防火牆比對的是來源位址，而 CDN 的位址由所有客戶共用。別人也可以用自己的 Cloudflare 帳號，把一個網域指向這台源站的位址，或部署一個 Worker 直接對源站送請求；這些請求同樣從 Cloudflare 的位址送來，通過只放行 CDN 位址的規則，而源站擁有者在自己帳號上設的 WAF 規則管不到別人帳號送來的請求。Worker 這一條實測過，用的是自己帳號的 Worker，連一個不屬於任何 Cloudflare 網域的主機名稱：Worker 出站的位址落在 Cloudflare 公布的清單裡，穿過了只放行 Cloudflare 位址的 Oracle Cloud NSG，它在請求裡寫的 <code>CF-Connecting-IP</code> 也原樣送到源站。下面兩條是推論，都沒有用第二個帳號實測：Worker 這條路徑不經過帳號裡任何網域的設定，所以換成別人的帳號結果相同；別人把自己帳號裡的網域指向這台源站時，回源請求同樣從共用的 Cloudflare 位址送來，所以同樣通過只放行位址的規則。把這一類稱為<strong>經由別的 CDN 帳號送來的請求</strong>：只放行位址的規則擋不住它；共用憑證的 Authenticated Origin Pulls 擋不住別人帳號裡同樣打開 Global 的網域（依文件），Worker 對 https 源站的請求會不會帶這張共用憑證沒有測；擋住整類要用自己上傳憑證的 Authenticated Origin Pulls 或 Tunnel。只放行位址的規則擋下的是不經過 CDN 的直連，漏洞公開後對整段位址的掃描就屬於這一類。自己上傳憑證的 Authenticated Origin Pulls 以外，還有幾種讓源站分辨請求屬於自己網域的做法，這次都沒有實跑：在自己的 zone 用 Cloudflare 的規則替回源請求加一個只有自己知道的標頭、源站驗證它；或用 Cloudflare Access 的 service token。三個位置可以疊加，例如雲端防火牆只放行 CDN 位址到 443，源站的 TLS 設定再驗證自己上傳的用戶端憑證，主機防火牆則管住雲端防火牆碰不到的出站流量與主機上的其他連接埠。用戶端憑證只在 TLS 上驗證，80 上的 http 請求不帶憑證：80 也放行時，依 <code>CF-Connecting-IP</code> 做判斷的設定只能放在驗證憑證的那一個 server 區塊，80 只做轉址到 https、不採信這個標頭；實測的 Worker 偽造標頭走的正是 80。</p>
<h2 id="只放行-cdn-位址的套用時機範圍與清單的維護">只放行 CDN 位址的套用時機、範圍與清單的維護</h2>
<p>只放行 CDN 位址的規則一套上去，不經過 CDN 的連線全部連不進來。規則的效果與失效的方式，依它在什麼時候套上、套在哪些連接埠而不同：</p>
<ul>
<li><strong>時機：網域的 DNS 記錄已經經由 CDN 代理，舊記錄的快取也過期了</strong>。還沒接上 CDN、或記錄設成只做 DNS 解析（Cloudflare 的 DNS only）時，使用者直接連源站，規則一套上，所有訪客都連不進網站：規則寫在雲端防火牆或丟棄封包的規則上時，瀏覽器等到連線逾時；寫在 firewalld 的 zone 上時，firewalld 以 ICMP 回拒，瀏覽器立即顯示連線失敗（兩種結果的分辨見 <a href="/blog/linux/dotfile/knowledge-cards/connection-refused-vs-timeout/" data-link-title="Connection Refused vs. Timeout（連線被拒與逾時）" data-link-desc="遠端連不上、要判斷是封包到不了還是服務拒絕、決定往網路層還是服務層除錯時回來讀">Connection Refused vs. Timeout（連線被拒與逾時）</a>）。網站回應的標頭帶著 CDN 的標記（Cloudflare 會加 <code>cf-ray</code>），只代表管理員自己這一次的請求經過了 CDN；其他訪客的解析器可能還快取著舊的 A 記錄（指向源站），換成 Cloudflare 的 nameserver 時，舊的 NS 記錄也有快取（2026-10-25 查 <code>.com</code> 上層伺服器回的 NS 記錄，TTL 是 172800 秒，也就是兩天；<code>dig +noall +authority &lt;網域&gt; @a.gtld-servers.net</code> 查得到自己網域的值）。所以套規則的時間點在舊記錄的快取全部過期之後：只改了 A 記錄時，等舊 A 記錄的 TTL；換了 nameserver、而舊的 DNS 業者還保留著這個網域的資料時，快取著舊 NS 的解析器在那份快取到期前一刻向舊業者查 A，又會拿到一份新的舊 A 記錄，所以要等舊 NS 的 TTL 加上舊 A 的 TTL；舊 NS 的 TTL 取上層伺服器回的值與舊業者自己回的值之中較大的那一個（解析器常採用後者）。舊業者已經刪掉這個網域時，等兩者之中較長的那一個就夠（依 DNS 快取的機制推論，沒有實測）。在那之前套上，快取著舊記錄的訪客連不進網站。</li>
<li><strong>接上 CDN 之前就能做的限制</strong>：管理通道改走白名單或代理、資料庫與內部服務的容器不發佈或只發佈在本機位址、關掉 80 與 443 以外對外開著的連接埠、出站 DNS 的限制，這幾項不依賴 DNS 記錄的狀態，可以先做。只放行 CDN 位址到 80 與 443 這一項，要等上一項的時機。</li>
<li><strong>範圍：管理通道不在這條規則裡</strong>。規則只套在網站的 80 與 443 上，SSH 走 <a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a> 裡的那一條（來源 IP 白名單、Bastion、IAP 或 VPN），兩者分開設定。把整台主機的入站都改成只放行 CDN 位址時，管理員自己的 SSH 會一起被擋在外面；Cloudflare 的 DNS 代理轉送的是網站的 HTTP 與 HTTPS，SSH 要經由 Cloudflare 時另外架 Tunnel。</li>
<li><strong>範圍：同一台主機上還有不經 CDN 的服務</strong>。例如網站經由 Cloudflare，而同一台主機的 443 也直接對外提供一個 API 子網域。防火牆只看位址與連接埠，分不出同一個連接埠上的兩個網域（那要看 TLS 的 SNI 或 HTTP 的 Host），所以對 443 套「只放行 CDN」，API 就斷線；不套，網站的直連路徑就開著，而 API 子網域的 A 記錄本身就公開了源站的位址。可行的分法有：API 也改經由 CDN 代理；API 改用另一個公開 IP 或另一個連接埠，各自套各自的規則；或在反向代理依網域分流，網站的網域只接受來自 CDN 位址的請求（這一種仍留著經由別的 CDN 帳號送來的請求）；或網站的網域用自己上傳的每個主機名稱一張的用戶端憑證，只在網站的 server 區塊驗證，API 的區塊不驗證。這幾種做法沒有實跑。</li>
</ul>
<p>規則寫在主機本身程式收連線的路徑上（firewalld 的 zone、ufw 的入站規則）時，寫成「放行 CDN 位址到 80 與 443」就夠：zone 的預設動作已經回拒未列出的連線（firewalld 的 <code>public</code> zone 是回拒，ufw 預設拒絕入站是丟棄），放行清單之外的來源自然被擋下。這個前提要先查：主機的預設 zone 不一定是 <code>public</code>（<code>firewall-cmd --get-default-zone</code>），有些發行版的預設 zone 本身就放行一段高連接埠，<code>firewall-cmd --list-all</code> 列得出它的 target 與開著的連接埠。zone 的預設回拒只作用在主機本身收連線的那條路徑上。Docker 發佈的連接埠走的是另一條路徑：封包的目的地先被改寫成容器，再由主機轉送過去，這條轉送路徑上沒有對應的預設拒絕，那裡的規則要寫成「來源不在 CDN 清單裡就丟棄」，寫法見〈Docker 發佈的連接埠：封包路徑與限制的寫法〉的 <code>DOCKER-USER</code> 與 nftables 表。</p>
<p>CDN 經由 IPv6 回源的條件，是管理員在 CDN 的 DNS 設定裡替源站填了 IPv6 位址（Cloudflare 的 AAAA 紀錄、內容是源站的 IPv6）。訪客查得到的 AAAA 不代表這件事：實測只填了源站 IPv4 的 Proxied 紀錄，網域照樣解析出 Cloudflare 的 IPv6 位址，而回源全部經由 IPv4；填了源站 IPv6 時 Cloudflare 會不會改用 IPv6 回源沒有測（依 Cloudflare 的 DNS 文件推論會）。沒有替源站填 IPv6 時，主機的 IPv6 只會收到直連的請求，主機防火牆照預設回拒就好。填了的話，漏寫 IPv6 的後果依規則的寫法而不同：</p>
<ul>
<li><strong>放行式</strong>（只放行清單，其餘交給預設回拒）只寫了 IPv4 的清單：CDN 經由 IPv6 回源的連線被預設回拒擋下，網站對 IPv6 回源失效。</li>
<li><strong>丟棄式</strong>（來源不在清單裡就丟棄）的比對只寫了 IPv4 位址（nftables 的 <code>ip saddr</code>、iptables）：IPv6 的連線不受這條規則限制，經由 IPv6 直連源站的路徑照舊開著。</li>
</ul>
<p>兩種寫法都要另寫一份 IPv6 清單（Cloudflare 的 <code>ips-v6</code>）。Docker 發佈的連接埠的 IPv6 那一份走的路徑跟 IPv4 那一份不同，寫在〈Docker 發佈的連接埠：封包路徑與限制的寫法〉。</p>
<p>CDN 的位址清單會變動。Cloudflare 的〈Cloudflare IP addresses〉寫明清單不常變，新的位址段會先加進公佈的清單再投入使用，沒有寫會另外通知。規則裡的清單沒有跟著更新時，Cloudflare 從新位址段回源的請求會被擋下，所以清單要定期從 <code>https://www.cloudflare.com/ips-v4</code> 與 <code>ips-v6</code>（或 Cloudflare 的 API）取回、跟規則比對。規則用位址集合（firewalld 的 ipset、nftables 的 set）寫時，更新只換集合的內容，規則本身不動，排程一支取回清單、換掉集合內容的程式就能維護；這個做法沒有實跑。<code>DOCKER-USER</code> 的自建鏈、ufw 的 after.rules 與 ufw 的 IPv6 入站放行都是一段一條的規則，更新時整段重新產生（after.rules 的產生與對帳見 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈讓規則開機時載入：寫進 /etc/ufw/after.rules〉）。不論哪一種載體，Cloudflare 這兩份清單的最後一行沒有換行字元，逐行讀取的迴圈會漏掉最後一段而不報錯，所以寫進去的條數要跟清單的段數對帳。</p>
<h2 id="docker-發佈的連接埠封包路徑與限制的寫法">Docker 發佈的連接埠：封包路徑與限制的寫法</h2>
<p>容器用 <code>docker run -p 8081:80</code> 發佈連接埠時，Docker 在 iptables 的 nat 表寫一條目的位址轉換規則（<a href="/blog/devops/infra/knowledge-cards/dnat/" data-link-title="DNAT（目的位址轉換）" data-link-desc="容器發佈的連接埠沒被主機防火牆擋下、port forwarding 的限制規則要寫在位址轉換之前還是之後、或要比對位址轉換之前的連接埠時，查 DNAT 改寫封包目的地的時間點與它對防火牆規則的影響">DNAT</a>，destination NAT：把封包的目的地改寫成另一個位址與連接埠），在封包剛進主機的 prerouting 階段，就把目的地從「主機的 8081」改成「容器的 80」。改寫之後，核心判定這個封包要轉送給另一個網路介面（容器網路在主機上的 bridge 介面：預設網路是 docker0，compose 建的自訂網路是 <code>br-</code> 開頭的介面，容器接在它後面），它走的是轉送（forward）路徑，不經過管主機本身入站連線的 input 路徑。</p>
<p>主機防火牆的一般規則寫在 input 路徑上，所以對發佈的連接埠沒有作用，<code>ufw status</code> 與 <code>firewall-cmd --list-all</code> 也都列不出這些連接埠（要用 <code>docker ps</code> 或 <code>ss -ltnp</code> 查）：</p>
<ul>
<li><strong>ufw</strong>：Docker 的〈Packet filtering and firewalls〉寫明，送往發佈連接埠的流量在經過 ufw 的規則之前就被轉走，ufw 的設定對它等於不存在。網路上常見的解法是在 Docker 的設定裡關掉它的 iptables 管理（<code>&quot;iptables&quot;: false</code>），同一頁寫明這樣做多半會讓容器的網路失效，發佈的連接埠也會變成區網內連得到。</li>
<li><strong>firewalld</strong>：firewalld 把網路介面分到 zone（一組介面共用一套入站規則，例如 <code>public</code>），zone 與 zone 之間轉送的流量由 policy 管（一條 policy 指定從哪些 zone 轉送到哪些 zone、帶一個優先序與預設動作）。Docker 啟動時建一個 target 為 ACCEPT 的 <code>docker</code> zone，把它的 bridge 介面放進去，再建一條 <code>docker-forwarding</code> policy，放行任何 zone 轉送到 <code>docker</code> zone 的流量。實測在 <code>public</code> zone 只開了 <code>ssh</code> 與 <code>dhcpv6-client</code>、沒有任何一條規則提到 8081 的主機上，位址落在 Cloudflare 範圍外的測試來源照樣拿到容器的回應。firewalld 的轉送鏈開頭還有一條 <code>ct status dnat accept</code>：在預設的 <code>StrictForwardPorts=no</code> 下，所有經過目的位址轉換的連線在任何 policy 之前就被接受，所以自己加一條「<code>public</code> 轉送到 <code>docker</code> 只放行 CDN 位址」的 policy 也擋不住，實測加上之後範圍外的來源仍然連得到。</li>
</ul>
<p>經過位址轉換的那一份，Docker 保留封包原本的來源位址，容器裡的程式看到的是真正的連線來源（實測容器的存取記錄裡，兩個經由 IPv4 連的測試來源位址都原樣出現），所以來源限制在技術上做得到。</p>
<p><code>-p 8080:80</code> 同時發佈在 IPv4 與 IPv6，而兩份走的路徑不同。容器所在的網路沒有開 IPv6（Docker 預設的 bridge 網路就是），而 Docker 的 <code>userland-proxy</code> 是預設的開著時，IPv6 那一份由 docker-proxy 這個主機上的程式（<a href="/blog/devops/infra/knowledge-cards/userland-proxy/" data-link-title="Userland Proxy（使用者空間的連接埠轉送程式）" data-link-desc="ss 列出 docker-proxy、rootlesskit 或 rootlessport 聽著容器發佈的連接埠、主機防火牆的入站規則對容器的連接埠有效、或容器的存取紀錄只看得到閘道位址而沒有真實來源時，查這類轉送程式接收連線的路徑與它換掉來源位址的代價">Userland Proxy</a> 的一種）接聽、再自己連到容器的 IPv4 位址，連線走 input 路徑：實測在 ufw 與 firewalld 上，沒有放行規則時經由 IPv6 連發佈的連接埠都被擋下，加了 IPv6 的放行規則之後才連得到，跟 IPv4 那一份正好相反，所以 IPv6 這一份照放行式寫在主機防火牆的入站規則上。代價是來源位址：docker-proxy 自己連進容器，實測容器記到的 IPv6 那一筆來源是預設網路的閘道 <code>172.17.0.1</code>（compose 建的自訂網路閘道是別的位址，<code>docker network inspect &lt;網路名&gt;</code> 的 <code>Gateway</code> 欄查得到），容器裡的反向代理依來源位址做的判斷（只信任 CDN 位址段送來的 <code>CF-Connecting-IP</code>、存取紀錄）對經由 IPv6 回源的請求都不成立。Docker 的 <code>userland-proxy</code> 設成 <code>false</code> 時，實測在 ufw 主機上 IPv6 那一份根本沒有發佈：<code>docker ps</code> 只列 <code>0.0.0.0</code> 的那一份、ip6tables 沒有轉換規則，經由 IPv6 連都被拒。網路建立時帶 <code>--ipv6</code>、容器有 IPv6 位址時，依 Docker〈Port publishing〉的說明，IPv6 那一份改由 ip6tables 的位址轉換送進容器，走轉送路徑，規則要寫在 ip6tables 的 <code>DOCKER-USER</code> 或 nftables 的 <code>ip6 saddr</code>；這一種沒有實跑。</p>
<p>限制發佈連接埠的做法，從「讓連接埠不對外」到「在主機之外擋」依序是：</p>
<ul>
<li>
<p><strong>不發佈</strong>：同一個 compose 專案或同一個 Docker 網路裡的容器，以服務名稱互相連線，不需要發佈連接埠。資料庫、快取這類只給其他容器用的服務不寫 <code>ports</code>，主機外面就連不到它們；主機上的工具要連時，才發佈在本機位址。</p>
</li>
<li>
<p><strong>只發佈在本機位址</strong>（<code>-p 127.0.0.1:8082:80</code>）：外部來源連不到這個連接埠，實測兩個外部來源都被拒、主機本機連得到。這個結果有版本條件：Docker 的〈Port publishing〉寫明 28.0.0 以前，跟主機在同一個 L2 網段的其他主機連得到發佈在 127.0.0.1 的連接埠（實測用的是 29.9.2，<code>docker version</code> 查得到版本），共用網段的 VPS 或區網裡要先確認版本。對外改由反向代理（nginx、Caddy）收 80 與 443 再轉給容器；反向代理以主機上的程式或 host network 模式的容器執行時，它收連線走 input 路徑，主機防火牆的放行式規則對它有效，這是用 ufw、又沒有雲端防火牆可設的主機上規則最少的組合。反向代理自己也用 <code>-p 80:80 -p 443:443</code> 發佈時，它跟其他發佈的連接埠一樣走轉送路徑，要照下面幾種寫法限制。預設的發佈位址可以在 Docker 的設定裡改：實測 daemon 設定的 <code>ip</code> 設成 <code>127.0.0.1</code> 之後，預設 bridge 網路上的容器發佈在 <code>127.0.0.1</code>，而接在自訂網路上的容器照樣發佈在 <code>0.0.0.0</code> 與 <code>[::]</code>，外部來源連得到；自訂網路建立時帶網路選項 <code>com.docker.network.bridge.host_binding_ipv4=127.0.0.1</code>，那個網路上的容器才發佈在 <code>127.0.0.1</code>。compose 的自訂網路在 <code>driver_opts</code> 寫同一個選項，或用 daemon 設定的 <code>default-network-opts</code> 套到之後新建的每一個 bridge 網路（已經建好的網路不受影響），這兩種沒有實跑。</p>
</li>
<li>
<p><strong><code>DOCKER-USER</code> 鏈</strong>：Docker 在 iptables 的 FORWARD 鏈最前面跳到這條鏈，裡面的規則由使用者自己寫，Docker 啟動與重啟時不清掉它（實測只重啟 dockerd 之後規則還在、照樣擋得住）。封包到這裡時目的地已經改寫成容器的位址與連接埠，所以要用連線追蹤（<a href="/blog/devops/infra/knowledge-cards/conntrack/" data-link-title="conntrack（連線追蹤）" data-link-desc="防火牆規則寫了 established、related 或 ctorigdstport、有狀態防火牆為什麼不必寫回程規則、改了規則卻像沒生效、或位址轉換之後要比對原本的位址時，查 Linux 核心記錄連線狀態的表與它在規則裡的用法">conntrack</a>：核心記錄每一條連線的狀態與它原本的位址、連接埠的表）記下的原始目的連接埠比對（<code>-m conntrack --ctstate DNAT --ctorigdstport 443 --ctdir ORIGINAL</code>）。<code>--ctstate DNAT</code> 限定只比對經過位址轉換的連線：容器自己往外連 443、容器之間連 80 的封包也經過這條鏈、原始目的連接埠一樣是 443 或 80，實測少了這個條件時兩者都被丟掉，加上之後恢復，而範圍外的來源仍被擋（實測時發佈的連接埠是 8081，正式設定換成 80 與 443 時才會撞到這兩種流量）。<code>--ctdir ORIGINAL</code> 讓規則只比對發起方向的封包；容器回覆的封包也帶著同一個原始目的連接埠，少了這個條件時這些回覆封包會被拿去比對來源而丟掉（依連線追蹤的機制推論，沒有拿掉這個條件實測）。實測範圍外的來源被擋下（連線逾時），範圍內的照常。它的弱點在規則的存續：防火牆重新載入、dockerd 重啟與重開機之後規則還在不在。用 iptables 指令加的規則只存在執行中的核心裡，各種主機上的結果如下：</p>
<ul>
<li><strong>任何主機重開機</strong>：規則不在（ufw 主機上實測）。</li>
<li><strong>ufw 主機，after.rules 沒有宣告這條鏈</strong>：<code>ufw reload</code> 與關閉再開啟只重建 ufw 自己的鏈，規則還在（實測）。</li>
<li><strong>ufw 主機，規則寫進 <code>/etc/ufw/after.rules</code></strong>：這是 ufw 主機上讓規則留過重開機的做法（整份 Cloudflare 清單的寫法在 <a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a> 的〈讓規則開機時載入：寫進 /etc/ufw/after.rules〉）。實測 reload、Docker 重啟與重開機之後都還在：開機時 ufw 先於 Docker 啟動，先建出這條鏈，Docker 沿用它。after.rules 宣告這條鏈時，每次 <code>ufw reload</code> 先清空它再寫入檔案裡的規則，實測另外用 <code>iptables -I</code> 加的規則在 reload 之後不在，所以同一台主機上的 <code>DOCKER-USER</code> 規則要全部寫在 after.rules 裡。</li>
<li><strong>firewalld 主機，nftables 後端</strong>：<code>firewall-cmd --reload</code> 在有 direct 設定時清掉規則與自建的鏈，沒有時規則還在（<code>FlushAllOnReload</code> 設成 <code>yes</code> 或 <code>no</code> 都一樣）；direct 設定有 direct rule 與 passthrough 兩種，實測各加一條都會觸發，查的時候 <code>firewall-cmd --direct --get-all-rules</code> 與 <code>--get-all-passthroughs</code> 兩個都要跑，前一個列不出 passthrough。實測的 Oracle Cloud 主機（2026.10.16 的 Oracle Linux 10.2 平台映像，nftables 後端）自帶一組保護執行個體中繼資料服務的 passthrough，reload 清掉了規則，那一組的內容記在 <a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a> 的〈DOCKER-USER 鏈的限制效果與規則的存續〉。direct 設定也會在之後被別的軟體加進來（例如 fail2ban 的 firewalld 動作，依它的設定推論），所以這是主機當下的狀態，不是固定的屬性。</li>
<li><strong>firewalld 主機，iptables 後端</strong>（<code>/etc/firewalld/firewalld.conf</code> 的 <code>FirewallBackend</code>）：沒有任何 direct 設定，reload 也清掉了規則，因為那時 iptables 的表由 firewalld 管、reload 整張重建（實測）。</li>
<li>用<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具定期 reload 的主機上，規則會被清掉的那幾種情形，每一次 reload 之後直連就重新開著，沒有任何告警。<code>systemctl restart firewalld</code>，以及 iptables-services 或 netfilter-persistent 的開機載入，都沒有實跑。</li>
</ul>
<p>另外，Docker 改用實驗性的 nftables 後端時，〈firewall-nftables〉寫明沒有 <code>DOCKER-USER</code> 這條鏈，自訂規則要寫在自己的 nftables 表裡，也就是下面「自己的 nftables 表」那一種。</p>
</li>
<li>
<p><strong>firewalld 的 <code>StrictForwardPorts=yes</code></strong>：設定檔 <code>/etc/firewalld/firewalld.conf</code> 的這個選項（這次的 firewalld 2.4.3 有；它是 2024 年 11 月 firewalld 官方部落格〈Strict Forward Ports〉介紹的新功能，較舊的 firewalld 沒有）把轉送鏈開頭的 <code>ct status dnat accept</code> 換成只接受 firewalld 明確放行的連接埠。〈firewalld.conf〉與那篇部落格寫明，設成 <code>yes</code> 之後發佈的連接埠要用 firewalld 的 forward-port（firewalld 自己做的目的位址轉換規則）明確放行。實測它管得到「哪些發佈的連接埠可以從外面連」：設成 <code>yes</code> 之後兩個測試來源都被回拒，加了 forward-port 之後又都連得到；它管不到「誰可以連」：即使 forward-port 寫在限定來源的 rich rule（firewalld 可以同時寫來源、連接埠與動作的規則）裡，firewalld 在轉送鏈產生的放行規則也只比對原始目的連接埠（連線追蹤記下的、位址轉換之前的連接埠），沒有帶上來源條件，範圍外的來源也連得到。forward-port 改寫在 policy 上時，轉送鏈裡沒有產生任何放行，範圍內的來源也連不到。規則存在 firewalld 的永久設定裡，reload 與 dockerd 重啟之後都在。</p>
</li>
<li>
<p><strong>自己的 nftables 表，掛在目的位址轉換之前</strong>：在 nftables 建一張自己的表，鏈接在 prerouting 這個 hook（核心處理封包的流程上，讓規則掛上去執行的固定位置；prerouting 是封包剛進主機、還沒決定路由的那一個）上、優先序排在目的位址轉換之前（<code>priority dstnat - 10</code>），封包還沒被改寫時就用原本的目的連接埠比對來源，來源不在 CDN 清單裡就丟棄。規則要限定目的地是主機自己的位址（<code>fib daddr type local</code>）：容器往外連 443 與容器之間連 80 的封包也從 docker0 進 prerouting，實測少了這個條件時兩者都被丟掉。實測範圍外的來源被擋下，<code>firewall-cmd --reload</code>、dockerd 重啟之後規則都還在；把表寫進 <code>nftables.service</code> 開機時載入的設定檔（RHEL 系是 <code>/etc/sysconfig/nftables.conf</code>，其他發行版看 <code>systemctl cat nftables</code> 的 <code>ExecStart</code>）並啟用這個服務之後，重開機也還在。代價有兩個。一是規則不在 firewalld 的管理介面裡，<code>firewall-cmd --list-all</code> 看不到它，接手的人要知道還有這一張表。二是 <code>nftables.service</code> 會清掉整個 nftables 規則集：RHEL 系的 unit 在停止、重新啟動與重新載入時執行 <code>nft flush ruleset</code>，而 Debian 系的預設設定檔第一行就是 <code>flush ruleset</code>（這一點沒有核對各版本），被清掉的包括 firewalld 的表，以及 Docker 經由 iptables-nft 寫的位址轉換與轉送規則；之後自建的表又被載回來，主機只剩這一張表，其他連接埠失去預設回拒、發佈的連接埠與容器的出站都斷線，要依序重新啟動 firewalld 與 dockerd 才會重建（依 unit 檔的內容推論，沒有實跑）。避開這個風險的做法是不經過 <code>nftables.service</code>，改用一個開機時只執行 <code>nft -f</code> 載入這一張表的 systemd unit。</p>
</li>
<li>
<p><strong>雲端防火牆</strong>：規則在主機之外執行，Docker 改寫的是主機自己的防火牆，碰不到它。只放行 CDN 位址到 80 與 443、其他連接埠一律不開時，主機上誤發佈的任何連接埠都連不進來。</p>
</li>
<li>
<p><strong>Cloudflare Tunnel</strong>：源站不需要任何入站放行。這個效果要管理員自己做到：移除雲端防火牆與主機防火牆上 80、443 的入站放行，網站容器不發佈連接埠（或只發佈在本機位址），讓 <code>cloudflared</code> 經由同一個 Docker 網路或本機位址連到網站；網站容器還用 <code>-p 80:80</code> 發佈時，直連路徑照樣開著。代價是 <code>cloudflared</code> 要常駐、主機要能出站連到 Cloudflare，而且網站的入口綁在 Cloudflare 一家。實測用不需要帳號的 quick tunnel：後端只聽 <code>127.0.0.1</code>、沒有任何入站放行也連得到；quick tunnel 的 <code>cloudflared</code> 用 QUIC 連 Cloudflare 的 UDP 7844，出站擋掉 UDP 7844、TCP 7844 開著時，它一直重試 QUIC、沒有自動改用 http2，網站回 Cloudflare 的 530；指定 <code>--protocol http2</code> 之後改用 TCP 7844 連上。取得 quick tunnel 網址的那一步另外連出站的 443。所以出站只放行一份清單的主機上，quick tunnel 要的是 UDP 與 TCP 的 7844（各給 QUIC 與 http2）與取網址用的 443；清單只有 TCP 7844 而協定留在預設時，quick tunnel 停在重試 QUIC、網站回 530，協定設成 http2 時才改走 TCP 7844。每一種出站限制的結果記在 <a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a> 的〈Cloudflare Tunnel：源站不開入站，以及出站要放行的連接埠〉。具名的 tunnel（綁在帳號上的那種）預設的協定選擇沒有測。沒有出站路徑的主機（例如只有 service gateway 的私有 subnet 主機）用不了。</p>
</li>
</ul>
<p>Docker 以外的容器執行方式，發佈連接埠的路徑分成兩類：在主機防火牆之前改寫封包目的地的，連線走轉送路徑，結果取決於轉送路徑上每一張表的預設動作；由主機上一個轉送程式聽著那個連接埠、收下連線再轉進容器的，連線走 input 路徑，主機防火牆的入站規則管得到。分辨是哪一類，先查有沒有規則把那個連接埠的目的地改寫掉，再看誰在聽：改寫規則可能寫在經由 iptables-nft 管的表，也可能寫在原生 nftables 的表（實測 Podman 5.8.2 的 netavark 寫在 <code>inet netavark</code>，<code>iptables -t nat</code> 看不到），所以要用 <code>nft list ruleset</code> 查。查到改寫規則時就是前一類，<code>ss -ltnp</code> 裡聽著那個連接埠的程式（Podman 的 <code>conmon</code>、docker-proxy 的 IPv4 那一份）只是佔住連接埠；查不到時，聽著的轉送程式才是接收者。查表是預測，最終以範圍外的來源實際連一次為準。</p>
<p>以 root 執行的 Podman（用 netavark 寫規則）屬於前一類，跟 Docker 一樣用位址轉換。實測在 firewalld 上它跟 Docker 一樣繞過 zone，在 ufw 上卻被 ufw 預設的 <code>deny (routed)</code> 擋下，連 Cloudflare 位址都連不到，要用 ufw 的 route 規則放行到容器的位址與連接埠；ufw 那一台的差別在 netavark 的轉送鏈只放行回程，沒有 Docker 那樣接受新入站連線的 <code>DOCKER-FORWARD</code>。兩台的 Podman 版本也不同（ufw 那一台是 4.9.3、firewalld 那一台是 5.8.2），防火牆與版本的影響沒有分開。主機上同時裝了 Docker 時，Podman 的轉送封包也經過 <code>DOCKER-USER</code>：實測在 ufw 那一台，一條比對原始目的連接埠 8089、沒有帶 <code>--ctstate DNAT</code> 的規則，對 Podman 發佈的 8089 一樣擋得住範圍外的來源（換成 80、443 時照上面 <code>DOCKER-USER</code> 那一項加 <code>--ctstate DNAT</code>，這個組合沒有在 Podman 上實跑）。</p>
<p>rootless 模式的 Docker 與 Podman 屬於後一類：它們以一般使用者執行，不能改主機的防火牆，發佈的連接埠由主機上的轉送程式（<code>rootlesskit</code>、<code>rootlessport</code>）接聽，ufw 的入站規則實測管得到。代價是在預設設定下（RootlessKit 的預設轉送方式、Podman 4 的 slirp4netns），容器看到的來源是轉送程式那一端的位址（<code>172.17.0.1</code>、<code>10.0.2.100</code>），存取紀錄與依來源位址的判斷拿不到真正的來源；Podman 5 預設的 pasta 依它的發行說明保留來源位址，沒有實跑。另外，<code>--network host</code> 的容器沒有位址轉換也沒有轉送程式，聽著連接埠的是程式本身、走 input 路徑；macvlan、ipvlan 網路的容器有自己的位址，封包不經過主機的轉送與 input 路徑，主機防火牆的規則都碰不到它（依網路驅動的機制推論，沒有實跑）。Docker 文件列的保留來源位址做法這次沒有實跑，列在 <a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a> 的 rootless Docker 那一節。</p>
<p>這些寫法可以疊加。業者的防火牆放得下整份 CDN 清單時，雲端防火牆加上不發佈或只發佈在本機位址的組合，不靠主機上任何一條會被重新載入或重開機清掉的規則：雲端防火牆擋住直連，不發佈與本機位址讓「某個容器不小心發佈在 <code>0.0.0.0</code>」這種錯誤不會變成對外開放。網站跑在主機上的程式（不是容器）時，雲端防火牆加上 firewalld zone 或 ufw 的放行式規則就夠，兩層各自擋一次直連。同一組規則要套在多台主機上時，雲端防火牆的規則可以套在一組網卡上共用（Oracle Cloud 的 NSG、AWS 的 security group），主機防火牆的寫法則要在每一台各自部署與維護。單台主機、網站流量、主機連得到外面時，Cloudflare Tunnel 可以取代入站的 CDN 位址清單：移除 80 與 443 的入站放行，而網站容器不發佈或只發佈在本機位址時，清單的維護與 Docker 繞過主機防火牆這兩件事都不必處理。</p>
<h2 id="出站的-dns-只准送往指定的解析器擋得到與擋不到的部分">出站的 DNS 只准送往指定的解析器：擋得到與擋不到的部分</h2>
<p>主機加固清單常見的一條是限制 DNS 只用 53，它有入站與出站兩種讀法。入站的讀法是主機有跑 DNS 伺服器時才開 53，沒有跑就不開；網站主機多半沒有跑 DNS 伺服器，所以入站的做法就是不把 53 放進放行清單。出站的讀法是主機與容器送出的 DNS 查詢只准送往指定的解析器（雲端業者的解析器，例如 Oracle Cloud 虛擬網路 VCN 裡的 <code>169.254.169.254</code>），送往其他位址的 53 一律拒絕。後一種的目的，是讓被植入的程式不能用自己指定的 DNS 伺服器查詢，查詢全部經過一個留有紀錄的解析器。它擋不住把資料編進查詢的網域名稱送出去：允許的解析器會遞迴解析任何公開的名稱，<code>&lt;編碼後的資料&gt;.&lt;攻擊者的網域&gt;</code> 照樣被它送到攻擊者的權威伺服器，這一種要靠解析器端的查詢紀錄或 DNS 防火牆偵測。「指定的解析器」指的是主機實際往外送查詢的上游：Ubuntu 預設的 systemd-resolved 讓 <code>/etc/resolv.conf</code> 寫 <code>127.0.0.53</code>，實際的上游要用 <code>resolvectl status</code> 查，規則照 <code>/etc/resolv.conf</code> 寫會擋掉 systemd-resolved 自己的查詢；容器拿到的伺服器用 <code>docker exec &lt;容器&gt; cat /etc/resolv.conf</code> 查。</p>
<p>出站限制在 firewalld 上寫成一條從 <code>HOST</code>（主機本身送出的流量）到 <code>ANY</code>（任何 zone）的 policy，裡面兩條 rich rule：放行送往指定解析器的 <code>dns</code> 服務那一條優先序較小、先比對，拒絕其餘 <code>dns</code> 的那一條排在它之後。實測它的效果與漏洞：</p>
<ul>
<li><strong>主機本身送出的查詢</strong>：送往指定解析器的照常解析，送往其他位址的 53 被拒絕（<code>dig</code> 回 <code>connection refused</code>），對方收不到任何封包。</li>
<li><strong>容器送出的查詢</strong>：實測的容器在預設的 bridge 網路上，它的 DNS 設定是主機 <code>/etc/resolv.conf</code> 的副本，查詢直接從容器送出、走轉送路徑，不經過 <code>HOST</code>，所以這條 policy 對它無效，容器照樣把查詢送到外部的 53。這些查詢要在 <code>DOCKER-USER</code> 另寫一條（從容器網路的 bridge 介面進來、目的連接埠 53、目的地不是指定解析器的拒絕；預設網路的介面是 docker0，compose 的自訂網路是 <code>br-</code> 開頭的介面，iptables 可以寫成 <code>-i br-+</code>），而它有 <code>DOCKER-USER</code> 規則存續的問題（用 iptables 指令加的規則，在用 iptables 後端、或用 nftables 後端而有 direct 設定的 firewalld 主機上 reload 就消失、在 ufw 主機上被 after.rules 宣告的鏈 reload 時清空、重開機之後都不在；寫進 after.rules 的那一份重開機之後還在），要一起處理。compose 的自訂網路上，容器先問 Docker 內建的解析器（<code>127.0.0.11</code>），內建解析器再往外查，而那一筆往外查的封包由主機送出：實測連線追蹤記到的原始來源是主機的 IP，不是容器的位址；主機的出站規則擋掉 53（ufw 主機上的 OUTPUT、firewalld 的 <code>HOST</code> policy）時，compose 容器經由 <code>127.0.0.11</code> 的查詢失敗，<code>DOCKER-USER</code> 的規則對它沒有作用。compose 容器自己指定別的 DNS 伺服器時，查詢又從容器的介面轉送出去，<code>DOCKER-USER</code> 擋得到。所以容器的 DNS 要兩個位置都限制：轉送路徑上的規則（<code>DOCKER-USER</code> 或自建 nftables 表的 forward 鏈，實測自建表在 <code>firewall-cmd --reload</code> 之後還在）擋容器直接送出的，主機的出站規則擋內建解析器替它送出的。</li>
<li><strong>不走 53 的 DNS</strong>：DNS over TLS 用專用的 853，再加一條拒絕出站 853 的規則就擋得下（沒有實跑）。DNS over HTTPS 把查詢包在送往 443 的 HTTPS 裡，連接埠的限制看不到它。實測時主機對外部 443 送出的一般 TCP 連線不受這條 policy 影響；沒有實際送 DNS over HTTPS 的查詢，它走同一個連接埠，結果依此推論。要擋這一種，出站就要改成只放行一份允許的目的地清單（套件庫、業者的 API），這在 443 上會連帶影響每一個需要連外的功能，成本遠高於限制 53。</li>
</ul>
<p>所以出站限制 53 擋下的是寫死了 DNS 伺服器位址、走 53 的程式，它讓主機與容器的名稱查詢集中到一個留有查詢紀錄的解析器；有意繞過的程式改走 443 就繞得過去。</p>
<h2 id="地理封鎖依國家比對來源的位置與-geoip-清單的成本">地理封鎖：依國家比對來源的位置與 GeoIP 清單的成本</h2>
<p>依國家封鎖連線，要先知道連線來自哪個國家，而源站在 CDN 後面時，源站看到的來源位址全是 CDN 機房的位址：主機防火牆照來源位址查國家，查到的是 CDN 機房所在的國家，不是使用者的。所以地理封鎖寫在 CDN 那一端（Cloudflare 的 WAF custom rule 依 <code>ip.src.country</code> 比對），那裡看得到使用者真正的位址；源站只需要擋掉不經過 CDN 的連線，只放行 CDN 位址的規則已經做到。源站要自己做時，由反向代理依 <code>CF-Connecting-IP</code> 查國家，前提是這個標頭可信。只放行 CDN 位址不足以讓它可信（任何帳號的 Worker 都從 CDN 的位址送出、標頭可以自己寫，見〈主機前面有 CDN 時連到源站的路徑：經由 CDN、直連公開 IP 與管理通道〉），可信要靠自己上傳憑證的 Authenticated Origin Pulls 或 Tunnel；防火牆這一層做不到，因為它看不到 HTTP 標頭。</p>
<p>主機防火牆做地理封鎖的機制跟只放行 CDN 位址相同（把一個國家的位址段放進 ipset），適用的情形是主機直接面對使用者、前面沒有 CDN，或同一台主機上不經 CDN 的服務。它的成本在清單：國家的位址段清單要從 GeoIP 資料庫（把 IP 位址段對應到國家的資料庫）取得並定期更新，資料庫對 VPN、行動網路與雲端業者的位址判斷常常不準，被誤擋的使用者只看到連線失敗（逾時或立即被拒，看規則是丟棄還是回拒），沒有任何頁面告訴他原因。CDN 端的封鎖可以回一個說明頁或改成驗證挑戰，誤判時使用者看得到發生了什麼。</p>
<h2 id="防火牆限制與修補的關係">防火牆限制與修補的關係</h2>
<p>只放行 CDN 位址、在 CDN 上加 WAF 規則，縮小的是能連到有漏洞程式的來源範圍，漏洞本身還在。源站已經修補時，直連的掃描請求送到也攻不破；源站還沒修補時，經由 CDN 送來的同一種請求只要沒有被 WAF 規則認出來，照樣會送到程式。防火牆限制與修補各自關掉一條路徑：限制擋住不經過 CDN 的請求，修補處理經過 CDN 也會送到的那些。漏洞公開到修補完成之間的暫時緩解（WAF 規則、關閉功能）見 <a href="/blog/backend/07-security-data-protection/entrypoint-and-server-protection/" data-link-title="7.3 入口治理與伺服器防護" data-link-desc="以問題驅動方式整理對外入口、管理平面與伺服器邊界">7.3 入口治理與伺服器防護</a> 的〈修補窗口期內的暫時緩解〉。單台主機上的修補依軟體的安裝管道分開處理：發行版與第三方套件庫的套件、容器映像檔、應用程式自己的更新、自己建置的程式與從上游下載的二進位檔，各自由不同的機制得知有修補、能不能交給排程也不同；裝上去之後，已經在執行的程序要重新啟動、核心要重開機，修補才生效。各條管道的做法見 <a href="/blog/devops/operations/09-host-lifecycle/long-running-host-maintenance/" data-link-title="長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算" data-link-desc="單台 Linux 主機（VPS、雲端主機）長期上線時，沒有人操作也會改變的狀態與維護對它們做的事：log 與資料的寫入者和清理機制（logrotate、journald、容器 log）、磁碟寫滿時各元件的反應、每份 log 要讀的欄位、入口在主機與雲端各層的盤點（ss）、各條更新管道的安全更新與重開機（dnf-automatic、unattended-upgrades、needs-restarting）、會到期與會改變的設定、檢查間隔的推算，以及接手一台已上線的主機時先處理哪幾項">長期上線主機的維護項目：會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算</a>〈會老化的軟體：各條更新管道與更新後的重開機〉。</p>
<h2 id="延伸閱讀實測紀錄入口層管理通道cdn-的分發與兩個網路之間的連線">延伸閱讀：實測紀錄、入口層、管理通道、CDN 的分發與兩個網路之間的連線</h2>
<ul>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/origin-firewall-walkthrough/" data-link-title="源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS" data-link-desc="要在 Linux 主機上設定只放行 Cloudflare 位址的防火牆、確認 Docker 發佈的連接埠有沒有繞過 firewalld、在 DOCKER-USER、StrictForwardPorts 與 nftables 表之間選一種限制容器的來源、或限制出站 DNS 時，查 Oracle Linux 10 上實際跑過的指令、輸出，以及規則在重新載入、dockerd 重啟與重開機之後還在不在">源站防火牆只放行 Cloudflare 位址的實測紀錄：firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS</a>：firewalld 的 ipset、rich rule 與 zone sources、Docker 發佈的連接埠繞過 firewalld、<code>DOCKER-USER</code> 在 dockerd 重啟後還在而 reload 後是否消失取決於 firewalld 的後端與 direct 設定、<code>StrictForwardPorts</code> 只管連接埠不管來源、nftables 表在 reload 與重開機之後的存續、出站 DNS 的 policy 對主機與容器的不同效果，每一步的指令與輸出</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/ufw-docker-walkthrough/" data-link-title="ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑" data-link-desc="在 Ubuntu 的 ufw 主機上用 Docker 發佈連接埠、想只放行 Cloudflare 的位址、或想限制容器送出的 DNS 查詢時，查 ufw 規則對發佈連接埠的效果、DOCKER-USER 規則在 reload 與重開機之後還在不在、IPv6 那一份發佈由誰接收，以及 compose 容器的 DNS 封包從哪裡送出">ufw 主機上 Docker 發佈連接埠的實測紀錄：ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑</a>：ufw 的入站與 route 規則對發佈連接埠沒有作用的原因、<code>DOCKER-USER</code> 寫進 <code>/etc/ufw/after.rules</code> 之後在重開機後的存續、IPv6 那一份由 docker-proxy 接收、compose 容器的 DNS 查詢由主機送出</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/podman-rootless-ports-walkthrough/" data-link-title="Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址" data-link-desc="用 Podman 或 rootless 模式的 Docker 發佈連接埠、想知道 ufw 或 firewalld 的規則管不管得到、或想在容器裡拿到連線的真實來源位址時，查 rootful Podman 在 ufw 與 firewalld 上的不同結果、rootless 模式接收連線的路徑，以及容器看到的來源位址">Podman 與 rootless Docker 發佈連接埠的實測紀錄：rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址</a>：同一條 ufw 設定對 Docker 與 Podman 結果相反的原因、rootless 模式由轉送程式接收連線的代價</li>
<li><a href="/blog/devops/infra/03-network-foundation/hands-on/cloudflare-origin-pull-walkthrough/" data-link-title="Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠" data-link-desc="源站要放在 Cloudflare 後面、想確認只放行 Cloudflare 位址的雲端防火牆放不放得下整份清單、回源實際用哪些位址、Authenticated Origin Pulls 怎麼驗證、Cloudflare Worker 能不能穿過這道防火牆並偽造 CF-Connecting-IP、或 Tunnel 要開哪些出站連接埠時，查 Oracle Cloud 與 Cloudflare 免費方案上實際跑過的指令與輸出">Cloudflare 回源的實測紀錄：Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠</a>：NSG 的 120 條上限、回源位址與公布清單的比對、nginx 上的用戶端憑證驗證、Worker 穿過位址限制並偽造標頭、cloudflared 在 UDP 7844 被擋時的行為</li>
<li><a href="/blog/devops/infra/03-network-foundation/traffic-entry-layer/" data-link-title="流量入口層 — 請求怎麼從使用者到達應用" data-link-desc="自管環境要決定請求從使用者到應用經過哪幾層入口、每層承擔什麼——TLS 終結的位置、動靜分離、單機 reverse proxy 與雲端負載平衡器的選擇">流量入口層 — 請求怎麼從使用者到達應用</a>：DNS、負載平衡、反向代理與應用在責任鏈上的分工，TLS 終結放在哪一層，單機 nginx 與雲端負載平衡器的選擇條件</li>
<li><a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口</a>：只放行 CDN 位址之後，管理員登入主機的那條通道的做法與選擇</li>
<li><a href="/blog/backend/05-deployment-platform/edge-cdn-static-distribution/" data-link-title="5.9 邊緣分發與靜態資源（CDN / Origin Protection）" data-link-desc="整理 CDN 與 edge cache 在部署平台中的責任邊界、origin protection、purge 與 invalidation 策略">5.9 邊緣分發與靜態資源（CDN / Origin Protection）</a>：CDN 在分發與快取上的責任，以及它跟入口資安的交接</li>
<li><a href="/blog/backend/07-security-data-protection/cross-network-connection-security/" data-link-title="7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄" data-link-desc="地端主機與雲端主機、或兩個雲端業者的主機以 VPN 或存取金鑰接起來之後的安全設計與評估：連線清單上各台連得到什麼、每一台各假設一次被入侵、限制要放在被入侵端沒有寫入路徑的地方、連線的發起方向與流入的資料、VPN 之內的應用層認證、監控主機與管理者裝置的位置、WireGuard 與 Tailscale 的金鑰與控制平面、異地備份的寫入憑證，以及連線本身的紀錄">7.43 兩個網路之間的連線安全：可達範圍、連線方向與流入的資料、被入侵端改不到的限制、應用層認證、監控主機的位置、金鑰與控制平面、異地備份的寫入憑證與連線紀錄</a>：VPN 隧道介面上的 <code>DOCKER-USER</code> 規則與只放行回應的連線追蹤規則</li>
</ul>
]]></content:encoded></item><item><title>OpenSSH 內建 CA 的 SSH 憑證導入：CA 金鑰、sshd 設定、簽發、principal、撤銷與主機憑證</title><link>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/openssh-ca-setup/</link><pubDate>Fri, 23 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/infra/03-network-foundation/openssh-ca-setup/</guid><description>&lt;p>這篇整理只用 OpenSSH 本身（&lt;code>ssh-keygen&lt;/code> 與 &lt;code>sshd&lt;/code>，不加 Vault 或 Teleport 這類簽發服務）導入 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH&lt;/a> 憑證（&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/ssh-certificate/" data-link-title="SSH Certificate（SSH 憑證）" data-link-desc="主機多、人員常異動而逐台收回 authorized_keys 跟不上，或要讓 SSH 登入權限自動到期時，查 SSH CA 簽發短期憑證的運作方式與導入時要決定的事">SSH certificate&lt;/a>）的做法。範圍從「主機上的 sshd 已經只收金鑰登入」開始，到每位管理員用短期憑證登入、憑證外洩時能提前作廢、用戶端不必再逐台確認主機指紋為止。文中的指令與紀錄格式在 OpenSSH 9.6 上跑過；本篇用到的 ed25519 金鑰與 KRL 撤銷清單要 OpenSSH 6.5 以上，憑證機制本身從 5.4 起就有。為什麼要把登入權限從靜態 &lt;code>authorized_keys&lt;/code> 換成會到期的憑證、它跟 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/os-login/" data-link-title="OS Login" data-link-desc="GCP 專案要讓 SSH 登入權限跟著 Google 帳號與 IAM 走、啟用 OS Login 之後管理員或 CI 連不進 VM、或盤點專案 metadata 裡殘留的 SSH 公鑰時，查 OS Login 的運作方式、IAM role 與它和 metadata 金鑰的差別">OS Login&lt;/a> 這類由中央服務決定公鑰的做法怎麼選，見&lt;a href="https://tarrragon.github.io/blog/devops/infra/03-network-foundation/management-access-channel/#%e4%b8%bb%e6%a9%9f%e7%99%bb%e5%85%a5%e9%a9%97%e8%ad%89%e7%99%bb%e5%85%a5%e6%ac%8a%e9%99%90%e7%9a%84%e6%94%b6%e5%9b%9e%e9%9d%a0%e4%ba%ba%e5%b7%a5%e5%88%aa%e9%99%a4%e5%b8%b3%e8%99%9f%e9%80%a3%e5%8b%95%e6%88%96%e5%88%b0%e6%9c%9f" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計&lt;/a>的〈主機登入驗證：登入權限的收回靠人工刪除、帳號連動或到期〉。&lt;/p>
&lt;h2 id="ssh-ca-的運作使用者憑證主機憑證與兩把-ca">SSH CA 的運作：使用者憑證、主機憑證與兩把 CA&lt;/h2>
&lt;p>靜態 &lt;code>authorized_keys&lt;/code>（每個帳號家目錄裡的 &lt;code>~/.ssh/authorized_keys&lt;/code>，一行一把可以登入這個帳號的公鑰）的做法裡，每台主機各自記著「哪些公鑰可以登入哪個帳號」，所以新增一個人要改每一台，收回一個人也要改每一台。SSH CA 把這份清單換成一個簽名：一把 CA 私鑰對使用者的公鑰簽名，簽出一張憑證，憑證裡寫著一組名稱（principal，主機依它決定這張憑證能登入哪些帳號）、在什麼期間內有效、識別字串是什麼（key ID）。主機的 sshd 只要信任那把 CA 的公鑰，任何帶著有效 CA 簽名、還在有效期內、principal 對得上的憑證都能登入，主機上不必有任何一個人的公鑰。&lt;/p>
&lt;p>同一個機制反過來用在主機上，叫主機憑證：CA 對主機的 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/ssh-host-key/" data-link-title="SSH Host Key 與 known_hosts（主機金鑰）" data-link-desc="ssh 第一次連線問要不要信任指紋、出現 REMOTE HOST IDENTIFICATION HAS CHANGED 警告，或主機重建之後連不上時，查 host key 怎麼證明主機身分、known_hosts 記了什麼，以及首次信任的風險">host key&lt;/a>（主機用來向用戶端證明自己身分的金鑰，用戶端第一次連線時確認的指紋就是它的）簽名，用戶端只要信任這把 CA，連到任何一台帶著有效主機憑證的主機都不必再確認指紋。使用者憑證與主機憑證最好用兩把不同的 CA 簽：使用者 CA 的私鑰外洩，攻擊者能簽出登入任何主機的憑證；主機 CA 的私鑰外洩，攻擊者能冒充任何主機。兩把分開，一把外洩只影響一個方向。&lt;/p>
&lt;p>這套做法換掉的是收回方式：權限到期自動失效，收回延遲的上限是憑證的有效期。代價是多了要保管的 CA 私鑰（使用者與主機各一把）、一份要派送到每台主機的撤銷清單，以及一個每天替管理員簽發憑證的流程。&lt;/p>
&lt;h2 id="產生使用者-ca-與主機-ca-的金鑰">產生使用者 CA 與主機 CA 的金鑰&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 在簽發主機（保管 CA 私鑰的機器，位置的選擇見下方）上產生兩把 CA 金鑰：使用者 CA 與主機 CA 分開&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">ssh-keygen -t ed25519 -f user_ca -C &lt;span class="s2">&amp;#34;user-ca&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">ssh-keygen -t ed25519 -f host_ca -C &lt;span class="s2">&amp;#34;host-ca&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;code>-t ed25519&lt;/code>：金鑰類型。CA 金鑰與一般金鑰沒有格式上的差別，差別只在用途。&lt;/li>
&lt;li>&lt;code>-f user_ca&lt;/code>、&lt;code>-f host_ca&lt;/code>：輸出的檔名，自己取。執行之後產生 &lt;code>user_ca&lt;/code>（私鑰）與 &lt;code>user_ca.pub&lt;/code>（公鑰），主機 CA 同理。&lt;/li>
&lt;li>&lt;code>-C&lt;/code>：註解，只是讓人辨認這把金鑰，不影響驗證。&lt;/li>
&lt;li>執行時會要求輸入 passphrase，CA 私鑰要設：之後每次簽發都要輸入它，私鑰檔被複製走時攻擊者也還要猜這個 passphrase。&lt;/li>
&lt;/ul>
&lt;p>CA 私鑰能簽出登入所有主機的憑證，它外洩的影響等於所有主機的所有金鑰一起外洩，所以它的保管位置決定了這套做法的安全上限：放在一台專用、不對外開放、只有少數人能登入的簽發主機上，或放在&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hsm/" data-link-title="HSM（Hardware Security Module）" data-link-desc="判斷金鑰材料需不需要脫離軟體邊界、放進不可讀取明文的專用硬體時的核心術語">硬體安全模組（HSM）&lt;/a>裡（&lt;code>ssh-keygen&lt;/code> 支援用 &lt;code>-D&lt;/code> 指定 PKCS#11 函式庫，讓私鑰留在硬體裡簽名；PKCS#11 是程式存取硬體金鑰的標準介面。這時 &lt;code>-s&lt;/code> 給的是 CA 的公鑰檔，&lt;code>ssh-keygen&lt;/code> 用它找出硬體裡對應的那把私鑰，簽發指令寫成 &lt;code>ssh-keygen -D &amp;lt;pkcs11-library-path&amp;gt; -s user_ca.pub -I ... id_ed25519.pub&lt;/code>，函式庫路徑看硬體廠商的文件）。成本介於兩者之間的做法是把 CA 私鑰放進 FIDO 或 PIV 這類硬體金鑰、經由 &lt;code>ssh-agent&lt;/code> 簽名（&lt;code>ssh-keygen -U -s user_ca.pub ...&lt;/code>，&lt;code>-U&lt;/code> 表示私鑰在 agent 裡），私鑰同樣不以檔案形式存在。不放在任何人的筆電硬碟上，也不放進版本控制。公鑰（&lt;code>.pub&lt;/code>）不是機密，可以放進&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理&lt;/a>工具派送。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理只用 OpenSSH 本身（<code>ssh-keygen</code> 與 <code>sshd</code>，不加 Vault 或 Teleport 這類簽發服務）導入 <a href="/blog/devops/infra/knowledge-cards/ssh/" data-link-title="SSH" data-link-desc="Secure Shell — 加密的遠端 shell 連線，有 SSH 等於有 CLI 工具鏈，沒有就只能靠 FTP 和 web 面板">SSH</a> 憑證（<a href="/blog/devops/infra/knowledge-cards/ssh-certificate/" data-link-title="SSH Certificate（SSH 憑證）" data-link-desc="主機多、人員常異動而逐台收回 authorized_keys 跟不上，或要讓 SSH 登入權限自動到期時，查 SSH CA 簽發短期憑證的運作方式與導入時要決定的事">SSH certificate</a>）的做法。範圍從「主機上的 sshd 已經只收金鑰登入」開始，到每位管理員用短期憑證登入、憑證外洩時能提前作廢、用戶端不必再逐台確認主機指紋為止。文中的指令與紀錄格式在 OpenSSH 9.6 上跑過；本篇用到的 ed25519 金鑰與 KRL 撤銷清單要 OpenSSH 6.5 以上，憑證機制本身從 5.4 起就有。為什麼要把登入權限從靜態 <code>authorized_keys</code> 換成會到期的憑證、它跟 <a href="/blog/devops/infra/knowledge-cards/os-login/" data-link-title="OS Login" data-link-desc="GCP 專案要讓 SSH 登入權限跟著 Google 帳號與 IAM 走、啟用 OS Login 之後管理員或 CI 連不進 VM、或盤點專案 metadata 裡殘留的 SSH 公鑰時，查 OS Login 的運作方式、IAM role 與它和 metadata 金鑰的差別">OS Login</a> 這類由中央服務決定公鑰的做法怎麼選，見<a href="/blog/devops/infra/03-network-foundation/management-access-channel/#%e4%b8%bb%e6%a9%9f%e7%99%bb%e5%85%a5%e9%a9%97%e8%ad%89%e7%99%bb%e5%85%a5%e6%ac%8a%e9%99%90%e7%9a%84%e6%94%b6%e5%9b%9e%e9%9d%a0%e4%ba%ba%e5%b7%a5%e5%88%aa%e9%99%a4%e5%b8%b3%e8%99%9f%e9%80%a3%e5%8b%95%e6%88%96%e5%88%b0%e6%9c%9f" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>的〈主機登入驗證：登入權限的收回靠人工刪除、帳號連動或到期〉。</p>
<h2 id="ssh-ca-的運作使用者憑證主機憑證與兩把-ca">SSH CA 的運作：使用者憑證、主機憑證與兩把 CA</h2>
<p>靜態 <code>authorized_keys</code>（每個帳號家目錄裡的 <code>~/.ssh/authorized_keys</code>，一行一把可以登入這個帳號的公鑰）的做法裡，每台主機各自記著「哪些公鑰可以登入哪個帳號」，所以新增一個人要改每一台，收回一個人也要改每一台。SSH CA 把這份清單換成一個簽名：一把 CA 私鑰對使用者的公鑰簽名，簽出一張憑證，憑證裡寫著一組名稱（principal，主機依它決定這張憑證能登入哪些帳號）、在什麼期間內有效、識別字串是什麼（key ID）。主機的 sshd 只要信任那把 CA 的公鑰，任何帶著有效 CA 簽名、還在有效期內、principal 對得上的憑證都能登入，主機上不必有任何一個人的公鑰。</p>
<p>同一個機制反過來用在主機上，叫主機憑證：CA 對主機的 <a href="/blog/devops/infra/knowledge-cards/ssh-host-key/" data-link-title="SSH Host Key 與 known_hosts（主機金鑰）" data-link-desc="ssh 第一次連線問要不要信任指紋、出現 REMOTE HOST IDENTIFICATION HAS CHANGED 警告，或主機重建之後連不上時，查 host key 怎麼證明主機身分、known_hosts 記了什麼，以及首次信任的風險">host key</a>（主機用來向用戶端證明自己身分的金鑰，用戶端第一次連線時確認的指紋就是它的）簽名，用戶端只要信任這把 CA，連到任何一台帶著有效主機憑證的主機都不必再確認指紋。使用者憑證與主機憑證最好用兩把不同的 CA 簽：使用者 CA 的私鑰外洩，攻擊者能簽出登入任何主機的憑證；主機 CA 的私鑰外洩，攻擊者能冒充任何主機。兩把分開，一把外洩只影響一個方向。</p>
<p>這套做法換掉的是收回方式：權限到期自動失效，收回延遲的上限是憑證的有效期。代價是多了要保管的 CA 私鑰（使用者與主機各一把）、一份要派送到每台主機的撤銷清單，以及一個每天替管理員簽發憑證的流程。</p>
<h2 id="產生使用者-ca-與主機-ca-的金鑰">產生使用者 CA 與主機 CA 的金鑰</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"><span class="c1"># 在簽發主機（保管 CA 私鑰的機器，位置的選擇見下方）上產生兩把 CA 金鑰：使用者 CA 與主機 CA 分開</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -t ed25519 -f user_ca -C <span class="s2">&#34;user-ca&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">ssh-keygen -t ed25519 -f host_ca -C <span class="s2">&#34;host-ca&#34;</span></span></span></code></pre></div><ul>
<li><code>-t ed25519</code>：金鑰類型。CA 金鑰與一般金鑰沒有格式上的差別，差別只在用途。</li>
<li><code>-f user_ca</code>、<code>-f host_ca</code>：輸出的檔名，自己取。執行之後產生 <code>user_ca</code>（私鑰）與 <code>user_ca.pub</code>（公鑰），主機 CA 同理。</li>
<li><code>-C</code>：註解，只是讓人辨認這把金鑰，不影響驗證。</li>
<li>執行時會要求輸入 passphrase，CA 私鑰要設：之後每次簽發都要輸入它，私鑰檔被複製走時攻擊者也還要猜這個 passphrase。</li>
</ul>
<p>CA 私鑰能簽出登入所有主機的憑證，它外洩的影響等於所有主機的所有金鑰一起外洩，所以它的保管位置決定了這套做法的安全上限：放在一台專用、不對外開放、只有少數人能登入的簽發主機上，或放在<a href="/blog/backend/knowledge-cards/hsm/" data-link-title="HSM（Hardware Security Module）" data-link-desc="判斷金鑰材料需不需要脫離軟體邊界、放進不可讀取明文的專用硬體時的核心術語">硬體安全模組（HSM）</a>裡（<code>ssh-keygen</code> 支援用 <code>-D</code> 指定 PKCS#11 函式庫，讓私鑰留在硬體裡簽名；PKCS#11 是程式存取硬體金鑰的標準介面。這時 <code>-s</code> 給的是 CA 的公鑰檔，<code>ssh-keygen</code> 用它找出硬體裡對應的那把私鑰，簽發指令寫成 <code>ssh-keygen -D &lt;pkcs11-library-path&gt; -s user_ca.pub -I ... id_ed25519.pub</code>，函式庫路徑看硬體廠商的文件）。成本介於兩者之間的做法是把 CA 私鑰放進 FIDO 或 PIV 這類硬體金鑰、經由 <code>ssh-agent</code> 簽名（<code>ssh-keygen -U -s user_ca.pub ...</code>，<code>-U</code> 表示私鑰在 agent 裡），私鑰同樣不以檔案形式存在。不放在任何人的筆電硬碟上，也不放進版本控制。公鑰（<code>.pub</code>）不是機密，可以放進<a href="/blog/devops/infra/knowledge-cards/configuration-management/" data-link-title="Configuration Management（組態管理）" data-link-desc="要讓好幾台主機的套件、設定檔與服務維持同一個狀態、讀到 Ansible 的 inventory 與 group_vars、或要判斷主機上的設定該交給 Ansible 這類工具還是 Terraform 這類 IaC 管理時">組態管理</a>工具派送。</p>
<h2 id="設定主機的-sshd信任使用者-ca">設定主機的 sshd：信任使用者 CA</h2>
<p>每台要讓管理員登入的主機，在改 <code>sshd_config</code> 之前先把三行設定要指向的檔案放到位：設定一旦生效，sshd 每次驗證都會去讀這三個檔，少了其中一個，下面〈兩個陷阱〉那一段講的情形就會在重新載入的那一刻發生。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在每台主機上：放使用者 CA 的公鑰（從簽發主機複製過來）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo install -m <span class="m">644</span> user_ca.pub /etc/ssh/user_ca.pub
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 建立放 principals 檔的目錄，所有帳號都要讀得到</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">sudo install -d -m <span class="m">755</span> /etc/ssh/auth_principals
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 產生一份不含任何項目的撤銷清單</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">sudo ssh-keygen -k -f /etc/ssh/revoked.krl</span></span></code></pre></div><p><code>ssh-keygen -k -f</code> 沒有 <code>-u</code>，會建立一份新的清單；只在第一次建立時用，對已有內容的清單執行會把它清空。principals 檔的內容見下方。三個檔都到位之後，在 <code>sshd_config</code> 加入：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"># /etc/ssh/sshd_config：信任使用者 CA 簽發的憑證
</span></span><span class="line"><span class="ln">2</span><span class="cl">TrustedUserCAKeys /etc/ssh/user_ca.pub
</span></span><span class="line"><span class="ln">3</span><span class="cl">AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
</span></span><span class="line"><span class="ln">4</span><span class="cl">RevokedKeys /etc/ssh/revoked.krl</span></span></code></pre></div><ul>
<li><code>TrustedUserCAKeys</code>：指向使用者 CA 的公鑰檔。這一行讓 sshd 接受這把 CA 簽的憑證；檔案裡可以放多把 CA 公鑰，一行一把，換 CA 時新舊兩把並列一段時間。</li>
<li><code>AuthorizedPrincipalsFile</code>：指定每個帳號接受哪些 principal，<code>%u</code> 會代入要登入的帳號名稱，所以 <code>/etc/ssh/auth_principals/deploy</code> 這個檔案列出的 principal 能登入 <code>deploy</code> 帳號。設了這一行之後，sshd 對每個帳號都只看它自己的 principals 檔：個人帳號 <code>alice</code> 也要有一份 <code>/etc/ssh/auth_principals/alice</code>、裡面列出 <code>alice</code>，沒有檔案的帳號用憑證登不進去。<strong><code>sshd_config</code> 沒有這一行時，憑證裡的 principal 必須與登入的帳號名稱完全相同</strong>（簽給 <code>alice</code> 的憑證只能登入 <code>alice</code> 帳號）。</li>
<li><code>RevokedKeys</code>：撤銷清單的位置，格式與用法見〈憑證外洩時提前作廢：KRL 撤銷清單〉。</li>
</ul>
<p>principals 檔是一行一個 principal：</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"># /etc/ssh/auth_principals/deploy：哪些 principal 可以登入 deploy 帳號
</span></span><span class="line"><span class="ln">2</span><span class="cl">alice
</span></span><span class="line"><span class="ln">3</span><span class="cl">ops-oncall
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"># /etc/ssh/auth_principals/alice：alice 自己的帳號
</span></span><span class="line"><span class="ln">6</span><span class="cl">alice</span></span></code></pre></div><p>principal 的命名有兩種常見做法：一種是用人名（<code>alice</code>），principals 檔列出誰能登入哪個帳號；另一種是用角色（<code>ops-oncall</code>、<code>db-admin</code>），簽發時依這個人的角色把對應的 principal 簽進憑證，主機上的 principals 檔只列角色，人員異動時不必改任何一台主機。人少、人員很少異動時用人名就夠；人員常異動、或主機數量多到改 principals 檔本身就是負擔時，用角色。主機上只有一個共用帳號（雲端映像預設的 <code>ubuntu</code>、<code>ec2-user</code>）時，用角色當 principal、簽進憑證的 key ID 寫人名，sshd 的紀錄才分得出是誰登入（見〈sshd 紀錄裡的憑證資訊〉）。</p>
<p>改完設定先檢查語法，再重新載入。<code>sshd -t</code> 只檢查設定檔的語法，下面兩個陷阱它都抓不到（撤銷清單不存在、principals 檔讀不到時它照樣沒有輸出），所以重新載入之後要從另一個終端機實際用憑證登入一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 檢查設定檔語法，沒有輸出代表語法正確</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo sshd -t
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 重新載入設定，已經建立的連線不受影響</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">sudo systemctl reload ssh    <span class="c1"># RHEL 系的服務名稱是 sshd</span></span></span></code></pre></div><p>設定這一步有兩個在實測中會讓所有人登不進去的陷阱，套用到一台以上的主機之前都要先在一台上確認：</p>
<ul>
<li><strong><code>AuthorizedPrincipalsFile</code> 是用登入帳號本人的權限讀的</strong>：sshd 在驗證時以那個帳號的身分開這個檔，路徑上任何一層目錄那個帳號讀不到，sshd 就當成沒有任何 principal、拒絕登入，紀錄裡寫的是 <code>Could not open user 'alice' authorized principals ...: Permission denied</code> 與 <code>Certificate does not contain an authorized principal</code>。放在 <code>/etc/ssh/auth_principals/</code>、目錄權限 755、檔案權限 644，所有帳號都讀得到。反方向也一樣：檔案若被改成其他人可寫（例如 666），sshd 會以權限過寬拒絕它，而預設紀錄等級下留下的只有 <code>Certificate does not contain an authorized principal</code>，看不出原因在權限。</li>
<li><strong><code>RevokedKeys</code> 指的檔案不存在時，sshd 拒絕所有金鑰登入</strong>：不只憑證，連 <code>authorized_keys</code> 裡的一般公鑰也一起被拒，紀錄裡是 <code>Error checking authentication key ... in revoked keys file ...: No such file or directory</code>。所以空的撤銷清單要在加這一行設定之前放好（上面準備檔案的那一段）。之後的派送用新檔覆蓋舊檔，派送失敗時主機上留著的是上一版清單：sshd 照常運作，只是這次新撤銷的憑證在那台主機上還沒生效。</li>
</ul>
<p>導入期間 <code>authorized_keys</code> 照常生效：sshd 同時接受憑證與 <code>authorized_keys</code> 裡的公鑰，所以可以先讓每位管理員用憑證登入過一次，確認之後再停用 <code>authorized_keys</code>（做法見〈導入順序〉）。用另一個終端機保留一條已登入的連線再改設定，改錯時還有一條路能改回來；整台都進不去時的備用路徑見<a href="/blog/devops/infra/03-network-foundation/management-access-channel/" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>的〈救援入口：管理通道斷掉時的備用路徑〉。</p>
<h2 id="簽發使用者憑證識別字串principal-與有效期">簽發使用者憑證：識別字串、principal 與有效期</h2>
<p>管理員把自己的公鑰交給持有 CA 私鑰的人或簽發主機，簽出一張憑證：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 對 alice 的公鑰簽發一張可以登入 alice 與 deploy 帳號、8 小時有效的憑證</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -s user_ca -I <span class="s2">&#34;alice@example.com&#34;</span> -n alice,deploy -V -5m:+8h -z <span class="m">1001</span> id_ed25519.pub</span></span></code></pre></div><ul>
<li><code>-s user_ca</code>：用來簽名的 CA 私鑰。</li>
<li><code>-I &quot;alice@example.com&quot;</code>：識別字串（key ID），會出現在 sshd 的認證紀錄與撤銷清單裡，填一個能對回人的值，通常是這個人在 IdP 的帳號。</li>
<li><code>-n alice,deploy</code>：principal，多個用逗號分隔，對應主機 principals 檔裡的名稱（<code>sshd_config</code> 沒有 <code>AuthorizedPrincipalsFile</code> 這一行時對應帳號名稱）。這張憑證能登入 <code>alice</code> 帳號，前提是主機上有列出 <code>alice</code> 的 <code>/etc/ssh/auth_principals/alice</code>。不給 <code>-n</code> 時簽出的憑證不帶任何 principal：<code>TrustedUserCAKeys</code> 會拒絕這種憑證；但 OpenSSH 另有一種逐帳號信任 CA 的寫法，在某個帳號的 <code>authorized_keys</code> 裡寫一行 <code>cert-authority ssh-ed25519 AAAA...</code>（CA 公鑰），這一行會接受不帶 principal 的憑證登入那個帳號。本篇用的是 <code>TrustedUserCAKeys</code>，而主機上若有人在某個帳號的 <code>authorized_keys</code> 寫過 <code>cert-authority</code> 行，不帶 principal 的憑證就能登入那個帳號，所以每次簽發都寫出 principal。<code>authorized_keys</code> 裡的 <code>cert-authority</code> 與主機憑證一節用戶端 <code>known_hosts</code> 裡的 <code>@cert-authority</code> 是兩回事：<code>authorized_keys</code> 那一行在主機上信任使用者 CA，<code>known_hosts</code> 那一行在用戶端信任主機 CA。</li>
<li><code>-V -5m:+8h</code>：有效期，從五分鐘前到八小時後。起點往前推五分鐘是為了容忍主機之間的時鐘誤差：只寫 <code>+8h</code> 時有效期從簽發主機的此刻開始，時鐘稍慢的主機會判定憑證「尚未生效」而拒絕，用戶端只看到 <code>Permission denied</code>。也可以寫成 <code>20261101:20261102</code> 這種起訖日期。有效期就是收回延遲的上限，手動簽發時常設成一個工作日左右，範例的八小時就是這個長度：每天簽一次，下班時自然失效。</li>
<li><code>-z 1001</code>：序號，撤銷清單可以用序號指定要作廢的憑證，所以每張憑證給不重複的序號（從某個數字起逐張加一即可），簽發主機要記下每個序號簽給了誰。</li>
<li><code>id_ed25519.pub</code>：要被簽的使用者公鑰。</li>
</ul>
<p>執行之後在公鑰旁邊產生 <code>id_ed25519-cert.pub</code>。這個檔名是固定的對應關係：<code>ssh</code> 用 <code>id_ed25519</code> 這把私鑰時，會自動找同目錄的 <code>id_ed25519-cert.pub</code> 一起送出，所以管理員把憑證放回 <code>~/.ssh/</code> 就能直接登入，不必加任何參數。兩種情形要另外處理：金鑰載入 <code>ssh-agent</code> 時憑證是跟著一起載入的，每天重簽之後 agent 裡仍是舊的那一張，要重新 <code>ssh-add</code>（<code>ssh-add -t 8h</code> 讓 agent 裡的金鑰與憑證同時失效）；<code>ssh_config</code> 用 <code>IdentityFile</code> 指定金鑰而憑證不在同目錄或不同名時，用 <code>CertificateFile</code> 指出憑證檔。用 <code>ssh-keygen -L</code> 讀得出憑證的內容：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 讀出憑證的識別字串、序號、有效期與 principal</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -L -f id_ed25519-cert.pub</span></span></code></pre></div><p>輸出裡要對的是 <code>Key ID</code>、<code>Serial</code>、<code>Valid</code> 與 <code>Principals</code> 四欄；<code>Extensions</code> 欄列出這張憑證允許的功能（<code>permit-pty</code>、<code>permit-port-forwarding</code>、<code>permit-agent-forwarding</code> 這類），預設全開，簽發時用 <code>-O</code>（指定憑證選項）加上 <code>no-agent-forwarding</code>、<code>no-port-forwarding</code> 這類值逐項關掉。需要讓 CI 這類機器用憑證時，同樣用 <code>-O force-command=&lt;指令&gt;</code> 限定它登入之後只能執行一條指令，並用 <code>-O source-address=&lt;cidr&gt;</code> 限定這張憑證只能從 CI 所在的位址範圍使用。</p>
<p>經由<a href="/blog/devops/infra/knowledge-cards/bastion-host/" data-link-title="Bastion Host（跳板機）" data-link-desc="內部主機不想各自對外開管理 port、要設定 ProxyJump 經由跳板機連線、判斷 agent forwarding 的風險，或要評估自建跳板機與改用身分感知代理的取捨時，查跳板機的連線方式、暴露面與維運責任">跳板機</a>（<code>ProxyJump</code>）連線時，跳板機與目的主機各自驗證一次，同一張憑證兩段都能用，所以跳板機也信任同一把使用者 CA 時，一張憑證外洩就同時通過兩層。要讓兩層分開，跳板機的 principals 檔用另一個 principal（例如 <code>bastion-access</code>），只簽給需要經過跳板機的人；要分得更徹底就讓跳板機信任另一把 CA。</p>
<p>手動簽發時，簽發的人每天要處理每位管理員的申請，每一次都要先確認申請者的身分，否則任何人寄來一把公鑰都能拿到憑證。組織有 <a href="/blog/backend/knowledge-cards/identity-provider/" data-link-title="Identity Provider" data-link-desc="登入要交給外部系統時，用來定位對方承擔了什麼、沒承擔什麼，以及這邊還剩下哪些責任">IdP</a> 時，常見的做法是讓簽發服務接 IdP 登入，管理員登入 IdP 之後由服務自動簽發，這就是 <a href="/blog/devops/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">HashiCorp Vault</a> 的 SSH secrets engine、smallstep 的 <code>step ssh</code> 與 Teleport 做的事；本篇的手動流程適合人數少、或要先理解機制再導入簽發服務的團隊。</p>
<h2 id="憑證外洩時提前作廢krl-撤銷清單">憑證外洩時提前作廢：KRL 撤銷清單</h2>
<p>憑證到期之前就要失效時（筆電遺失、私鑰外洩、人員在有效期內離職），把它加進撤銷清單（KRL，Key Revocation List）並派送到每一台主機。撤銷清單的正本放在簽發主機上，以下指令都在簽發主機上對正本執行，改完再把整份檔案派送到每台主機的 <code>/etc/ssh/revoked.krl</code>（<code>RevokedKeys</code> 指的位置）。主機上那份空清單是各自產生的，簽發主機上的正本要另外先建一次（<code>ssh-keygen -k -f revoked.krl</code>），否則下面加了 <code>-u</code> 的指令會因為檔案不存在而失敗：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 依憑證檔撤銷：把 alice 那張憑證加進既有的撤銷清單</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -k -u -f revoked.krl id_ed25519-cert.pub
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 依序號或識別字串撤銷：手上沒有憑證檔時用規格檔描述要撤銷的憑證</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;serial: 1001&#34;</span> &gt; krl.spec
</span></span><span class="line"><span class="ln">6</span><span class="cl">ssh-keygen -k -u -f revoked.krl -s user_ca.pub krl.spec
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># 確認某張憑證是否已在清單裡（輸出 REVOKED 代表已撤銷）</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">ssh-keygen -Q -f revoked.krl id_ed25519-cert.pub</span></span></code></pre></div><ul>
<li><code>-k</code>：產生或更新 KRL；<code>-u</code> 表示加進既有的清單，不加 <code>-u</code> 會用這次的內容覆蓋整份清單。</li>
<li><code>-f revoked.krl</code>：簽發主機上那份正本的檔名。</li>
<li><code>-s user_ca.pub</code>：用規格檔撤銷時，指定這些序號或識別字串屬於哪一把 CA。給的是公鑰：撤銷清單只記下「這把 CA 簽的哪些序號作廢」，不需要 CA 私鑰簽名，這與簽發時 <code>-s user_ca</code> 給私鑰不同。</li>
<li><code>krl.spec</code>：規格檔，一行一項，<code>serial: 1001</code> 撤銷那個序號，<code>id: alice@example.com</code> 撤銷那個識別字串簽出的所有憑證。</li>
<li><code>-Q</code>：查詢一把金鑰或一張憑證是否在清單裡。</li>
</ul>
<p>sshd 每次驗證都重新讀這份檔案，換掉檔案之後不必重新載入 sshd。撤銷清單的成本在派送：它要送到每一台主機，送不到的那幾台仍然接受被撤銷的憑證，所以派送要交給組態管理工具或 <a href="/blog/devops/infra/knowledge-cards/iac/" data-link-title="Infrastructure as Code (IaC)" data-link-desc="用程式碼描述基礎設施的最終狀態，由工具負責收斂現實與描述的差異">IaC</a> 這類會回報失敗的機制，派送失敗時有人看得到。撤銷擋下的是下一次登入，已經建立的工作階段要另外在主機上結束，用的是 systemd 管理登入工作階段的 <code>loginctl</code>（要 root 權限）：一人一個帳號時用 <code>sudo loginctl terminate-user &lt;username&gt;</code>；多人共用一個帳號（<code>deploy</code>、<code>ubuntu</code>）時 <code>terminate-user</code> 會結束所有人的工作階段，改用 <code>loginctl list-sessions</code> 列出工作階段、對照 sshd 紀錄裡那一行 <code>Accepted publickey</code> 的來源 IP 與時間找出那個人的 <code>&lt;session-id&gt;</code>，再用 <code>sudo loginctl terminate-session &lt;session-id&gt;</code> 只結束它。</p>
<p>有效期短的時候，撤銷清單能爭取到的時間有限：一張八小時的憑證剩下的有效時間最多八小時，派送清單到每台主機若也要花上幾小時，撤銷只比自然到期早一點生效。撤銷清單是給「等不到到期」的情形用的，而 CA 私鑰本身外洩時撤銷清單也救不了，那時要換掉 CA：產生新的 CA、把新公鑰加進每台主機的 <code>TrustedUserCAKeys</code>、同一次就拿掉舊的那一把、重新簽發所有人的憑證。改 <code>TrustedUserCAKeys</code> 要逐台改設定；撤銷清單也收 CA 公鑰本身（<code>ssh-keygen -k -u -f revoked.krl user_ca.pub</code>，這把 CA 簽的所有憑證一起作廢），已經演練過的清單派送路徑能比逐台改設定更早送到，兩者可以一起做。〈設定主機的 sshd〉講的新舊兩把並列一段時間，是有計畫輪替 CA 時的做法；私鑰外洩時舊的那一把每多留一刻，攻擊者就多一刻能簽出有效憑證。</p>
<h2 id="sshd-紀錄裡的憑證資訊">sshd 紀錄裡的憑證資訊</h2>
<p>用憑證登入時，sshd 的認證紀錄會帶著憑證的識別字串、序號與簽發的 CA，這一行才分得出是誰登入。在預設的 <code>LogLevel INFO</code> 下，成功登入的那一行是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Accepted publickey for deploy from 10.0.1.20 port 53080 ssh2: ED25519-CERT SHA256:eLfd... ID alice@example.com (serial 1001) CA ED25519 SHA256:kSP8...</span></span></code></pre></div><p><code>for deploy</code> 是登入的帳號，<code>ID alice@example.com (serial 1001)</code> 是憑證的識別字串與序號，多人共用 <code>deploy</code> 帳號時靠這一段分出是誰。<code>sshd_config</code> 設 <code>LogLevel VERBOSE</code>（比預設 <code>INFO</code> 多記驗證細節的紀錄等級）時，上面這一行照樣出現，而且在它之前還會多記一行 <code>Accepted certificate ID &quot;alice@example.com&quot; (serial 1001) signed by ED25519 CA ...</code>。</p>
<p>被拒時，紀錄寫出原因：過期是 <code>Certificate invalid: expired</code>；principal 對不上時，有設 <code>AuthorizedPrincipalsFile</code> 是 <code>Certificate does not contain an authorized principal</code>，沒設時是 <code>Certificate invalid: name is not a listed principal</code>；在撤銷清單裡是 <code>Authentication key ED25519-CERT SHA256:... revoked by file /etc/ssh/revoked.krl</code>。這幾種情形用戶端看到的都只是 <code>Permission denied (publickey)</code>，原因只在主機的紀錄裡（Debian 與 Ubuntu 用 <code>journalctl -u ssh</code>，RHEL 系用 <code>journalctl -u sshd</code>）。</p>
<h2 id="主機憑證簽發流程hostcertificate-與-cert-authority">主機憑證：簽發流程、HostCertificate 與 @cert-authority</h2>
<p>第一次連到一台主機時，<code>ssh</code> 會問要不要信任它的指紋；主機重建（autoscaling、重新部署）之後 host key 換了，<code>ssh</code> 會印出 <code>WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!</code> 並拒絕連線。主機經常重建的環境裡，管理員會被這個警告訓練成直接刪掉舊指紋、接受新的，而那正是中間人攻擊需要的反應。主機憑證讓用戶端改成信任主機 CA，帶著有效主機憑證的主機直接通過，不再逐台確認指紋。</p>
<p>主機 CA 的私鑰在簽發主機上，所以流程是把目標主機的 host key 公鑰取回簽發主機、在那裡簽、再把簽出的憑證放回目標主機：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 在簽發主機上執行：從 web-01 取回 host key 的公鑰（公鑰不是機密）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">scp web-01:/etc/ssh/ssh_host_ed25519_key.pub ./web-01_host_ed25519_key.pub
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 在簽發主機上執行：對取回的公鑰簽發主機憑證，-h 表示這是主機憑證</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">ssh-keygen -s host_ca -I <span class="s2">&#34;web-01&#34;</span> -h -n web-01.example.com -V +52w web-01_host_ed25519_key.pub
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 在簽發主機上執行：把簽出的憑證送回 web-01</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">scp web-01_host_ed25519_key-cert.pub web-01:/tmp/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 在 web-01 上執行：把憑證放到 host key 旁邊</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">sudo install -m <span class="m">644</span> /tmp/web-01_host_ed25519_key-cert.pub /etc/ssh/ssh_host_ed25519_key-cert.pub</span></span></code></pre></div><p>兩次 <code>scp</code> 都由簽發主機發起：簽發主機要能連到目標主機，而目標主機不需要能登入簽發主機，簽發主機因此可以維持只有少數人能登入。</p>
<ul>
<li><code>-h</code>：簽的是主機憑證而不是使用者憑證。</li>
<li><code>-I &quot;web-01&quot;</code>：主機的識別字串，填主機名稱。</li>
<li><code>-n web-01.example.com</code>：用戶端連線時會用到的名稱，逗號分隔可以列多個（用 IP 連線時也列 IP）。用戶端連線時用的名稱不在這份 <code>-n</code> 清單裡，用戶端就不接受這張憑證，用戶端的 <code>ssh -v</code> 輸出裡是 <code>Certificate invalid: name is not a listed principal</code>。</li>
<li><code>-V +52w</code>：主機憑證的有效期通常比使用者憑證長（主機不會離職），但仍要在到期前重新簽發，否則到期那天所有用戶端都會連不上這台主機。</li>
<li><code>web-01</code>：目標主機的位址。<code>web-01_host_ed25519_key.pub</code>：取回之後在簽發主機上的檔名，自己取，帶主機名稱以免混淆；來源路徑以目標主機上實際存在的 <code>/etc/ssh/ssh_host_*_key.pub</code> 為準。</li>
</ul>
<p>憑證放到 <code>/etc/ssh/ssh_host_ed25519_key-cert.pub</code> 之後，在 web-01 的 <code>sshd_config</code> 加一行 <code>HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub</code>，同樣先 <code>sudo sshd -t</code> 檢查語法再重新載入。用戶端在 <code>~/.ssh/known_hosts</code>（用戶端記錄已信任主機的檔案；全機共用的是 <code>/etc/ssh/ssh_known_hosts</code>）加一行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在用戶端執行：信任 host_ca 簽給 *.example.com 的主機憑證（host_ca.pub 從簽發主機複製過來）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;@cert-authority *.example.com </span><span class="k">$(</span>cat host_ca.pub<span class="k">)</span><span class="s2">&#34;</span> &gt;&gt; ~/.ssh/known_hosts</span></span></code></pre></div><p>寫進去的那一行是 <code>@cert-authority *.example.com ssh-ed25519 AAAAC3Nza... host-ca</code>：樣式後面接的就是 <code>host_ca.pub</code> 整個檔案的內容。</p>
<p><code>@cert-authority</code> 後面的樣式限定這把 CA 只對哪些主機名稱有效，填組織自己的網域；樣式寫成 <code>*</code>（符合任何主機名稱）時，拿到這把 CA 私鑰的人簽出的憑證，不論掛著哪個主機名稱（包括別的組織的網域），用戶端都會接受。這個樣式比對的是用戶端連線時用的名稱，所以一次連線要通過兩道比對：那個名稱符合 <code>@cert-authority</code> 的樣式，而且列在主機憑證的 <code>-n</code> 裡。用 IP 連線時兩處都要涵蓋那個 IP；樣式只寫網域時，用戶端不採信這把 CA、退回逐台確認指紋。主機憑證的撤銷在用戶端：主機退役或 host key 外洩時，用戶端的 <code>ssh_config</code> 以 <code>RevokedHostKeys &lt;krl-file&gt;</code> 指向一份列了那張主機憑證或 host key 的撤銷清單（產生方式與使用者憑證相同），或在 <code>known_hosts</code> 用 <code>@revoked</code> 開頭的一行標記那把 host key；派送的對象是用戶端，而不是主機。主機憑證最適合交給建立主機的流程：映像檔或開機流程產生 host key 之後，由簽發服務自動簽發，主機重建時憑證跟著重簽。</p>
<h2 id="導入順序ca主機設定憑證與-authorized_keys-之間的依賴">導入順序：CA、主機設定、憑證與 authorized_keys 之間的依賴</h2>
<p>上面各節的設定有先後依賴，而導入期間管理員仍要天天登入主機，所以順序的原則是新的登入路徑驗證過之前，舊的那一條一直保留。</p>
<p>CA 與主機設定之間的依賴最直接：主機要信任的是 CA 公鑰，所以 CA 先產生、私鑰先放進簽發主機或硬體安全模組，才有公鑰可以派送。主機那一側加 <code>TrustedUserCAKeys</code>、<code>AuthorizedPrincipalsFile</code>、<code>RevokedKeys</code> 三行之前，先把使用者 CA 公鑰、principals 檔與一份空的撤銷清單放到位：三行設定指向的檔案只要有一個不在，〈設定主機的 sshd：信任使用者 CA〉講的兩個陷阱就會在重新載入的那一刻讓所有人登不進去。這時 <code>authorized_keys</code> 仍然有效，憑證是多出來的一條路。</p>
<p>憑證與 <code>authorized_keys</code> 之間的依賴決定什麼時候能拿掉舊路徑：每位管理員都用憑證登入過每一類主機、並在 sshd 紀錄裡看到自己那一行帶著 <code>ID &lt;自己的識別字串&gt;</code> 的 <code>Accepted publickey</code> 之後，<code>authorized_keys</code> 才可以停用。停用的做法是在 <code>sshd_config</code> 加 <code>AuthorizedKeysFile none</code>（先 <code>sshd -t</code> 再重新載入），而不只是清空檔案：只清空檔案時，任何登入過的帳號都能把自己的公鑰寫回 <code>~/.ssh/authorized_keys</code>，這把金鑰不受憑證有效期與撤銷清單約束，收回延遲有上限的前提就不成立。撤銷清單的派送也要在停用之前接上組態管理工具並實際演練一次——撤銷一張測試憑證、確認每台主機都拒絕它——因為停用 <code>authorized_keys</code> 之後，收回一個人的權限只剩兩個手段：不再替他簽發、等手上的憑證到期，以及把他的憑證加進撤銷清單，而撤銷清單要靠演練過的派送才送得到每台主機。救援用的本機帳號（給 <a href="/blog/devops/infra/knowledge-cards/serial-console/" data-link-title="Serial Console（序列主控台）" data-link-desc="SSH 因為防火牆、sshd 設定或磁碟滿而連不進主機，或要規劃管理通道斷掉時的備用路徑時，查序列主控台與頻外管理介面的用法與它本身的暴露面">Serial Console</a> 這類救援入口用、不經過 sshd 金鑰登入的帳號）不跟著清掉，它的密碼與保管方式另外管理。</p>
<p>主機憑證與使用者憑證之間沒有依賴，可以獨立導入；它的前提是用戶端的 <code>known_hosts</code> 已經加了 <code>@cert-authority</code> 那一行，否則用戶端仍然逐台確認指紋，主機憑證放上去也不會被用到。</p>
<h2 id="延伸閱讀簽發服務知識卡與-gcp-上的對應做法">延伸閱讀：簽發服務、知識卡與 GCP 上的對應做法</h2>
<ul>
<li>→ <a href="/blog/devops/infra/knowledge-cards/ssh-certificate/" data-link-title="SSH Certificate（SSH 憑證）" data-link-desc="主機多、人員常異動而逐台收回 authorized_keys 跟不上，或要讓 SSH 登入權限自動到期時，查 SSH CA 簽發短期憑證的運作方式與導入時要決定的事">SSH Certificate（SSH 憑證）</a>：SSH 憑證與靜態金鑰、OS Login 三種金鑰發放方式的對照</li>
<li>→ <a href="/blog/devops/infra/knowledge-cards/vault/" data-link-title="HashiCorp Vault" data-link-desc="機密管理系統，集中存放密碼、API key、TLS 私鑰，提供存取控制、稽核和自動輪替">HashiCorp Vault</a>：接 IdP 登入、自動簽發 SSH 憑證的簽發服務，也是保管 CA 私鑰的位置之一</li>
<li>→ <a href="/blog/devops/infra/03-network-foundation/management-access-channel/#%e4%b8%bb%e6%a9%9f%e7%99%bb%e5%85%a5%e9%a9%97%e8%ad%89%e7%99%bb%e5%85%a5%e6%ac%8a%e9%99%90%e7%9a%84%e6%94%b6%e5%9b%9e%e9%9d%a0%e4%ba%ba%e5%b7%a5%e5%88%aa%e9%99%a4%e5%b8%b3%e8%99%9f%e9%80%a3%e5%8b%95%e6%88%96%e5%88%b0%e6%9c%9f" data-link-title="伺服器管理通道的設計：網路可達性、通道授權、主機登入驗證三層的做法與選擇，以及救援入口" data-link-desc="與雲端廠商無關、適用於雲端 VM、地端主機與 VPS 的管理員登入通道設計：拆成網路可達性、通道授權與主機登入驗證的模型，各層的做法（來源白名單、跳板機、VPN、身分感知代理、代理程式反向連線、SSH 憑證）、更換 port 與 fail2ban 改不了的部分，依存取者身分、連線送達方式、收回方式選擇組合的依據，以及移除公開 IP 之後的出站路徑、切換順序、救援入口、組織護欄與稽核">伺服器管理通道的設計</a>：SSH 憑證適合的情境（主機跨多個環境、進不了客戶帳號系統的外包團隊），以及它與跳板機、身分感知代理怎麼組合</li>
<li>→ <a href="/blog/devops/infra/cloud-providers/gcp/gcp-iap-ssh-access/" data-link-title="GCP VM 的 SSH 與 RDP 管理通道：從預設的全開規則改走 IAP TCP forwarding" data-link-desc="GCP 預設網路自帶的防火牆規則與它們留下的暴露面、IAP TCP forwarding 的連線路徑與日常連線指令、切換到 IAP 的順序為什麼這樣排、IAP 通道連不上時的依層判讀，以及 IAM 授權範圍、OS Login 的啟用條件與工作階段的結束、拿掉外部 IP 之後的出站路徑、Organization Policy、階層式防火牆、稽核紀錄與 Security Command Center 的 OPEN_SSH_PORT finding 在 GCP 上的處理">GCP VM 的 SSH 與 RDP 管理通道</a>：主機全在 GCP 時改用 OS Login 讓登入權限跟著 Google 帳號走的做法</li>
</ul>
]]></content:encoded></item></channel></rss>