用一台舊筆電打造校園 PVE 實驗平台:Tailscale 無固定 IP 存取、NAT 容器網路與防火牆隔離#

情境:實驗室想用一台筆電架 Proxmox VE,放在學校有線網路,讓多位同學各自擁有 LXC 容器做專題(前端、後端、資料庫)。學校沒有提供固定對外 IP,也不希望校內其他人連到管理介面。本文整理整個建置流程與踩過的坑。

整體架構#

 同學電腦 (Tailscale)
        │  加密 VPN(NAT 穿透)
        ▼
 ┌───────────────────────────────────────────┐
 │ PVE 主機(筆電,校園網路 vmbr0)           │
 │  - tailscale0:subnet router               │
 │  - PVE 防火牆:只放行 Tailscale            │
 │  - vmbr1 10.10.10.1/24 ── NAT ──► 校園網路 │
 └───────────────┬───────────────────────────┘
                 │ vmbr1(內部網段 10.10.10.0/24)
      ┌──────────┼──────────┐
    CT 110     CT 111     CT 112 ...
     DB          BE         FE

重點設計:

  • PVE 不對外開放,只能透過 Tailscale 連入,校內其他 IP 一律擋掉。
  • 容器放在內部 NAT 網段 vmbr1,由 PVE 負責上網,不佔用學校的 IP。
  • 每位同學一個 Tailscale tag,只能連到自己的容器。
  • 容器之間再用 PVE 防火牆依角色隔離(FE → BE → DB)。

1. 安裝 PVE 與網路基礎#

PVE 9 基於 Debian 13。安裝後第一步是處理套件來源:沒有訂閱時,企業版 repo 會回 401,要移除並改用 pve-no-subscription。

# 移除 enterprise 來源後,加入 no-subscription
echo "deb http://download.proxmox.com/debian/pve trixie pve-no-subscription" \
  > /etc/apt/sources.list.d/pve-no-subscription.list
apt update

幾個常見的小坑:

  • 網頁介面要用 https://IP:8006,用 http:// 只會看到 Moved Permanently。
  • USB 網卡若一直斷線(dmesg 出現 -71 錯誤),換 USB 孔或關閉 autosuspend。
  • PVE 不適合直接用 WiFi 當橋接網路,建議用有線或 USB 有線轉接。

2. 無固定 IP 的遠端存取:Tailscale#

PVE 在校園網路裡本來就有 IP,但:

  • 這個 IP 可能被 DHCP 更換;
  • 校內其他人也看得到;
  • 沒有對外固定 IP,校外無法直連。

解法是讓 PVE 主動向外連線。Tailscale 從 PVE 往外連到協調伺服器,建立 VPN,不需要學校開任何 port。

curl -fsSL https://tailscale.com/install.sh | sh

# 開啟 IP 轉發(subnet router 需要)
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-tailscale.conf
sysctl -p /etc/sysctl.d/99-tailscale.conf

# 宣告內部容器網段
tailscale up --advertise-routes=10.10.10.0/24

同學登入同一個 Tailnet 後,用 PVE 的 Tailscale 位址(100.x.y.z)連線:

https://100.x.y.z:8006

不管 PVE 的校園 IP 怎麼變,同學連的都是 Tailscale 位址,不受影響。

注意事項#

  • 在 Tailscale 管理後台關閉 PVE 節點的 key expiry,否則金鑰過期後大家會突然連不上。
  • 重開機後確認 tailscaled 有自動啟動(systemctl enable --now tailscaled)。

3. 內部 NAT 網段 vmbr1#

在 PVE 上新增一個沒有實體網卡的橋接 vmbr1,容器接在這上面,再由 PVE 做 NAT 出去。

/etc/network/interfaces:

auto vmbr0
iface vmbr0 inet static
        address 140.115.87.208/24      # 依你的環境修改
        gateway 140.115.87.254
        bridge-ports enx9cebe8185114   # 你的實體網卡
        bridge-stp off
        bridge-fd 0

auto vmbr1
iface vmbr1 inet static
        address 10.10.10.1/24
        bridge-ports none
        bridge-stp off
        bridge-fd 0
        post-up   echo 1 > /proc/sys/net/ipv4/ip_forward
        post-up   nft -f /etc/pve-nat.nft
        post-up   iptables -t raw -C PREROUTING -i fwbr+ -j CT --zone 1 2>/dev/null || iptables -t raw -I PREROUTING -i fwbr+ -j CT --zone 1
        post-up   iptables -t mangle -C PREROUTING -i fwbr+ -j MARK --set-xmark 0x0/0xff0000 2>/dev/null || iptables -t mangle -I PREROUTING -i fwbr+ -j MARK --set-xmark 0x0/0xff0000
        post-down iptables -t mangle -D PREROUTING -i fwbr+ -j MARK --set-xmark 0x0/0xff0000
        post-down iptables -t raw -D PREROUTING -i fwbr+ -j CT --zone 1
        post-down nft delete table ip pvenat
#NAT internal

/etc/pve-nat.nft(這裡用 nftables,不是 iptables):

table ip pvenat
delete table ip pvenat
table ip pvenat {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 10.10.10.0/24 oifname "vmbr0" masquerade
    }
}

post-up 用 -C ... || -I 的寫法是為了讓規則可重複執行,避免 ifreload 之後規則重複堆疊。

建立容器時,網路設定為 Bridge: vmbr1、IP 例如 10.10.10.110/24、Gateway 10.10.10.1,DNS 填學校 DNS 或 1.1.1.1。

4. 擋掉校園其他 IP:PVE 防火牆#

重點在「PVE 會自動把本機所在網段加入 management ipset」,所以只寫「允許 management」不夠,校內同網段的人也會被放行。必須明確 DROP 校園網段,且放在規則最上方。

/etc/pve/firewall/cluster.fw:

[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT
policy_forward: ACCEPT

[IPSET management]
100.x.y.z # 管理者的 Tailscale 位址

[RULES]
IN DROP -source 140.115.87.0/24 -p tcp -dport 22,3128,8006,5900:5999,60000:60050
IN DROP -source 140.115.87.0/24 -p icmp
IN Ping(ACCEPT) -source +management
IN Ping(ACCEPT) -i vmbr1
IN ACCEPT -i tailscale0 -p tcp -dport 8006

驗證方式:用校園 IP 連 https://PVE校園IP:8006 應逾時;用 Tailscale 位址則可正常登入。

此外,vmbr1 那條規則建議使用 Ping(ACCEPT) 而不是全放行,否則容器可以連到 PVE 主機的 22、8006。

5. 每位同學只能連自己的容器:Tailscale ACL#

為什麼不用 user 規則#

被標上 tag 的裝置沒有使用者身分,所以依 email 寫的規則對它們無效。因此改用「每位同學一個 tag」的方式。

Tailscale policy 的重點部分:

{
  "tagOwners": {
    "tag:bus-pve": ["autogroup:member"],
    "tag:u110":    ["autogroup:member"],
    "tag:u111":    ["autogroup:member"]
    // ... 依同學數量增加
  },

  "autoApprovers": {
    "routes": {
      "10.10.10.0/24": ["tag:bus-pve"]
    }
  },

  "grants": [
    // 所有同學裝置:只能開 PVE 管理介面
    { "src": ["tag:bus-pve"], "dst": ["100.x.y.z"], "ip": ["tcp:8006"] },

    // 每位同學:只能連自己的容器
    { "src": ["tag:u110"], "dst": ["10.10.10.110"], "ip": ["icmp:*", "tcp:*"] },
    { "src": ["tag:u111"], "dst": ["10.10.10.111"], "ip": ["icmp:*", "tcp:*"] }
  ]
}

要點:

  • 同學的裝置同時帶 tag:bus-pve(能開 PVE 網頁)與自己的 tag:u11x(能連自己的容器)。
  • tcp:* 之後可以收斂成實際需要的 port。
  • Mac 或 Linux 客戶端需要接受路由,用 route -n get 10.10.10.110(macOS)確認走的是 utun 介面。

帳號權限#

網頁登入也要分權限,每位同學只能管理自己的容器:

pveum user add u110@pve
pveum passwd u110@pve
pveum acl modify /vms/110 --users u110@pve --roles PVEVMUser

再替管理者與重要帳號開啟 2FA,並使用強 root 密碼。

6. 踩坑紀錄:容器防火牆開啟後的三個問題#

容器防火牆預設是關閉的。要啟用「依角色隔離」,需要同時:

  1. 容器 → Firewall → Options:Firewall = Yes
  2. 容器網卡 net0:勾選 Firewall
  3. 設定 policy_in = DROP

一旦勾選網卡的 Firewall,PVE 會在路徑上多插入 fwbr / fwpr / fwln 這組橋接,結果出現下面三個問題。

問題一:Tailscale 進來的 ping 沒有回應#

現象:網卡 firewall 關閉時能 ping 通,開啟後不通。tcpdump 在 vmbr1 看到來源是 10.10.10.1,但沒有回應封包。

原因:Tailscale 用 mark 決定哪些封包要做 MASQUERADE。封包進入 fwbr 橋接時,這個 mark 還在,MASQUERADE 又被觸發一次,來源被改成 Tailscale 位址,回程路徑就斷了。

修正:進入 fwbr+ 時清掉 mark:

iptables -t mangle -I PREROUTING -i fwbr+ -j MARK --set-xmark 0x0/0xff0000

問題二:容器開啟防火牆後無法上網#

現象:ping 8.8.8.8 失敗,但關掉網卡 firewall 就好了。

原因:NAT 與橋接防火牆各自追蹤連線,造成 conntrack 狀態不一致,回程封包被判定為 INVALID。

修正:這是 Proxmox wiki 記載的做法,替 fwbr+ 指定獨立的 conntrack zone:

iptables -t raw -I PREROUTING -i fwbr+ -j CT --zone 1

問題三:容器之間 ping 不通#

兩個原因疊在一起:

  • ICMP 規則的來源只允許 10.10.10.1,要改成 10.10.10.0/24:

    IN Ping(ACCEPT) -source 10.10.10.0/24
    
  • 規則是由上往下比對。如果最上面已經有一條明確的 in DROP,後面新增的規則永遠不會被比對到。policy_in: DROP 已經設定的話,這條明確 DROP 可以直接刪掉。

設定檔參考(每個容器)#

/etc/pve/firewall/110.fw:

[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT

[RULES]
IN ACCEPT -source 10.10.10.1 -p tcp -dport 22
IN Ping(ACCEPT) -source 10.10.10.0/24
IN ACCEPT -source 10.10.10.1 -p tcp -dport 3000

7. 依角色隔離(FE → BE → DB)#

目標:

容器角色允許來源允許 port
.110DB.1115432
.111BE.1128000
.112FETailscale 進來的使用者前端服務 port

以資料庫容器為例:

IN ACCEPT -source 10.10.10.111 -p tcp -dport 5432

再開啟 IP filter,防止容器偽造來源 IP。

連線到 PostgreSQL 的檢查清單#

如果從本機連容器裡的 PostgreSQL 失敗,逐層檢查:

  1. 服務監聽位址:ss -tlnp | grep 5432,如果只監聽 127.0.0.1,要把 listen_addresses 改成 '*'。

  2. pg_hba.conf:經過 Tailscale subnet router 進來的來源會被 SNAT 成 10.10.10.1(--snat-subnet-routes 預設開啟),要允許這個位址。

    host  all  all  10.10.10.1/32  scram-sha-256
    
  3. 容器防火牆:確認 5432 的 ACCEPT 規則在任何 DROP 之前。

  4. Tailscale ACL:確認自己的 tag 有權限連該容器。

  5. 客戶端工具:我們遇過 Windows 版 pgAdmin 回報 access violation writing 0x0000000000000000。這是客戶端程式崩潰,不是網路問題。先用 PowerShell 驗證網路:

    Test-NetConnection 10.10.10.113 -Port 5432
    

    通了就是客戶端的問題,換工具或更新版本即可。

8. 服務很慢?先看 IO 壓力#

容器中的網站上線後,雖然可以連,但很慢。監控數字:CPU 2.5%、記憶體 35%、SWAP 53%、IO 壓力持續 90% 以上。

CPU 低、IO 壓力高,通常表示瓶頸在磁碟。可能的原因:

  • 容器的記憶體上限太小,資料被換到 swap,而 swap 在慢速磁碟上;
  • 主機本身 RAM 不足;
  • 磁碟是 HDD 或 USB 儲存,隨機讀寫很慢;
  • 應用本身 IO 很多,例如 dev server 的 file watcher、持續寫 log、資料庫與其他服務共用同一顆慢碟。

在 PVE 主機上診斷:

free -h
swapon --show
cat /proc/pressure/io /proc/pressure/memory
lsblk -d -o NAME,ROTA,SIZE,MODEL    # ROTA=1 代表 HDD
pvesm status                        # 看容器 rootfs 放在哪個 storage
vmstat 1 10                         # 看 si/so 與 wa

處理方向:

  • 調高容器 Memory,並降低 Swap;

  • 降低主機 vm.swappiness:

    sysctl vm.swappiness=10
    echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
    
  • 把容器 rootfs 搬到較快的 storage(Move Storage),或換 SSD;

  • 前端用 production build,不要在容器裡長時間跑 dev server。

9. 對外公開網站:Cloudflare Tunnel#

上面的架構只有 Tailnet 內的人能連。如果要讓一般使用者透過網址瀏覽網站,可以用 Cloudflare Tunnel。原理和 Tailscale 類似,也是「由內往外」連線:

  1. 在前端容器(或專用容器)執行 cloudflared,它會主動連到 Cloudflare,建立加密長連線。
  2. 在 Cloudflare 設定「網址 → 內網服務」,例如 app.example.com → http://10.10.10.112:3000。
  3. 使用者連 app.example.com,DNS 指向 Cloudflare,請求先到 Cloudflare。
  4. Cloudflare 沿著那條已建立的連線把請求送進內網,回應原路回去。

好處是學校不用開 port、不需要固定 IP,還有 HTTPS 與基本防護。前提是要有自己的網域並託管在 Cloudflare。

總結#

需求做法
沒有固定 IP 也能遠端管理Tailscale(PVE 主動向外連線)
擋掉校內其他 IPPVE 防火牆明確 DROP 校園網段,只放行 Tailscale
容器不佔學校 IPvmbr1 內部網段 + nftables NAT
每人只能連自己的容器每人一個 Tailscale tag + grants
容器之間依角色隔離容器防火牆 + IP filter
對外公開網站Cloudflare Tunnel

這次最大的收穫是:網路問題常常不是單一設定造成的,NAT、conntrack、橋接防火牆、VPN 的 mark 都可能互相影響。排錯時用 tcpdump 一層一層看封包到底停在哪裡,比憑猜測改規則有效得多。

GitHub repo#

iforgotthings