本篇文章更新時間:2026/09/06
如有資訊過時或語誤之處,歡迎使用 Contact 功能通知或向一介資男的 LINE 社群反應。
如果本站內容對你有幫助,歡迎贊助支持 。
macOS 官方的 WireGuard App 有個惱人的硬限制:一次只能啟用一條 tunnel。當你需要同時連上多個彼此獨立的內網時,只能在它們之間切換。這篇記錄我怎麼用命令列工具突破這個限制,並附上我自己寫的一支管理腳本 wgm,把多 tunnel 的開關、狀態查看、開機自動啟動都收斂成好記的指令。
先建立正確的心智模型
- 一條 tunnel = 一個網路介面:每啟用一條 WireGuard tunnel,macOS 就多一個
utun介面(utun0、utun1…),彼此在系統層面獨立。 - 能否同時連,取決於路由有沒有打架:WireGuard 用 conf 裡的
AllowedIPs決定「哪些流量走這條 tunnel」,所有 tunnel 最後都寫進同一張系統路由表。網段不重疊才能並存(split tunnel);若多條都寫0.0.0.0/0,會搶同一個預設出口,只有一條真正生效。 - App 限制是「App 層」,不是「協議層」:WireGuard 協議本身允許同時起多個介面(Linux 上用
wg-quick就能做到),是 macOS 官方 App 的 UI 把它限制成一次一條。所以繞過方法就是改用命令列。
解法:用 CLI 取代 App
市面上有一些第三方 GUI/menubar 工具,但它們底層幾乎都是包 wg-quick,而且不少已停止開發,在新版 macOS 上相容性有風險。與其賭它們能不能跑,不如直接用官方 CLI,再自己包一層方便的腳本。
brew install wireguard-tools
這會裝好 wg、wg-quick,以及依賴 wireguard-go(macOS 沒有核心模組,靠這個 userspace 實作運行)和新版 bash。conf 檔放在 /opt/homebrew/etc/wireguard/(Apple Silicon),檔名即 tunnel 名稱。conf 含私鑰,記得收緊權限:
chmod 600 /opt/homebrew/etc/wireguard/*.conf
CLI 基本操作
# 啟用(需 sudo,因為要改路由表)
sudo wg-quick up office-vpn
# 關閉
sudo wg-quick down office-vpn
# 同時多條,分別 up 即可(前提:AllowedIPs 不重疊)
sudo wg-quick up datacenter-vpn
# 查看細節
sudo wg show
# 確認各網段路由並存、沒互搶
netstat -rn | grep utun
關鍵陷阱:DNS 副作用
當 conf 裡有 DNS = 這行,wg-quick up 會把 DNS 設定套用到你系統上所有的網路服務(Wi-Fi、各個 VPN…)。這帶來兩個問題:多條 tunnel 的 DNS 會互相覆蓋(最終生效的是最後啟用那條);而且可能干擾其他網路服務的 DNS,關閉時的還原也不保證乾淨。
如果你的 tunnel 是純內網用途、且都用 IP 存取內網機器,那 conf 裡的 DNS = 根本不需要,拿掉它就能消除這串副作用。若有用內網域名解析才需要保留。
wgm:多 tunnel 管理腳本
記 sudo wg-quick up/down 加一堆驗證指令太瑣碎,所以我包了一支 wgm。完整原始碼也放在這個 Gist:https://gist.github.com/nczz/d0252f26a86a4d75887e15e0ecf9385a
| 指令 | 行為 | 需要 sudo |
|---|---|---|
wgm list | 列出所有 tunnel(● 已啟用 / ○ 未啟用) | 否 |
wgm status | 完整狀態:handshake、流量、本機/server WG IP | 是 |
wgm status -q | 快速狀態:up/down + 路由 + 本機 WG IP | 否 |
wgm up <name|all> | 啟用一條或全部 | 是 |
wgm down <name|all> | 關閉一條或全部 | 是 |
wgm restart <name> | 重連(down 再 up) | 是 |
wgm enable-boot <name> | 設定開機自動啟動(launchd) | 是 |
wgm disable-boot <name> | 取消開機自動啟動 | 是 |
wgm boot-status | 顯示各 tunnel 開機啟動設定 | 否 |
設計重點
- 自動列舉 conf:掃描 conf 目錄,
up all自動全開,新增 conf 不用改腳本。 - sudo 自動化:偵測非 root 時自動
exec sudo重呼叫自己,搭配 Touch ID for sudo 就能刷指紋而非打密碼。 - 啟用狀態判斷:用
/var/run/wireguard/<name>.name檔案存在與否判斷,這是 wg-quick 在 macOS 上記錄 tunnel↔utun 對應的官方機制。 - 衝突防呆:
up前檢查AllowedIPs有沒有跟已啟用的 tunnel 重疊、或含全流量,重疊就警告。 - 狀態解析用
wg dump:解析機器可讀的 tab 分隔格式,比解析人類可讀輸出穩定。 - server WG IP 是推測值:WireGuard 不會回報對端的 WG 內網 IP,腳本取 AllowedIPs 網段的
.1(常見慣例)作推測,明確標注。
開機自動啟動的原理
enable-boot 會產生一個 launchd LaunchDaemon plist 到 /Library/LaunchDaemons/。關鍵是 LaunchDaemon 執行時環境變數極簡,plist 裡必須明確設定 PATH 指向 /opt/homebrew/bin,否則開機會因為找不到 wg/bash 而失敗。
安裝 wgm
# 從 Gist 下載到 ~/.local/bin
mkdir -p ~/.local/bin
curl -fsSL https://gist.github.com/nczz/d0252f26a86a4d75887e15e0ecf9385a/raw/wgm -o ~/.local/bin/wgm
chmod +x ~/.local/bin/wgm
# 確認 ~/.local/bin 在 PATH,然後:
wgm list
(若 Gist 的 raw 連結因檔名而不同,直接開上面的 Gist 頁面複製內容存成 ~/.local/bin/wgm 即可。)
wgm 完整原始碼
#!/opt/homebrew/bin/bash
#
# wgm — WireGuard tunnel manager for macOS (wraps wg-quick)
#
# 依賴:Homebrew wireguard-tools(wg, wg-quick)
# conf 目錄:/opt/homebrew/etc/wireguard/*.conf(檔名 = tunnel 名稱)
#
# 用法:
# wgm list 列出所有可用 tunnel(不需 sudo)
# wgm status 顯示每條的 up/down 與 handshake
# wgm up <name|all> 啟用一條或全部
# wgm down <name|all> 關閉一條或全部
# wgm restart <name> 重連(down 再 up)
# wgm enable-boot <name> 設定開機自動啟動(launchd)
# wgm disable-boot <name> 取消開機自動啟動
# wgm boot-status 顯示各 tunnel 的開機啟動設定狀態
#
set -euo pipefail
# ---- 設定 ----
WG_DIR="/opt/homebrew/etc/wireguard"
BREW_BIN="/opt/homebrew/bin"
WG_QUICK="$BREW_BIN/wg-quick"
WG="$BREW_BIN/wg"
LAUNCHD_DIR="/Library/LaunchDaemons"
LABEL_PREFIX="com.wireguard"
# ---- 顏色 ----
if [[ -t 1 ]]; then
C_GREEN=$'\033[32m'; C_GREY=$'\033[90m'; C_RED=$'\033[31m'
C_YELLOW=$'\033[33m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
else
C_GREEN=""; C_GREY=""; C_RED=""; C_YELLOW=""; C_BOLD=""; C_RESET=""
fi
err() { echo "${C_RED}error:${C_RESET} $*" >&2; }
warn() { echo "${C_YELLOW}warn:${C_RESET} $*" >&2; }
info() { echo "$*"; }
# ---- 前置檢查 ----
[[ -x "$WG_QUICK" ]] || { err "找不到 wg-quick($WG_QUICK)。請先 brew install wireguard-tools"; exit 1; }
[[ -d "$WG_DIR" ]] || { err "conf 目錄不存在:$WG_DIR"; exit 1; }
# 列出所有 tunnel 名稱(conf 檔名去掉 .conf)
list_tunnels() {
local f
shopt -s nullglob
for f in "$WG_DIR"/*.conf; do
basename "$f" .conf
done
shopt -u nullglob
}
# 某 tunnel 是否存在對應 conf
conf_exists() {
[[ -f "$WG_DIR/$1.conf" ]]
}
# 某 tunnel 目前是否啟用(wg show interfaces 用的是 utunN,這裡靠 wg-quick 的慣例:
# 啟用中的 tunnel 名稱可從 wg show 的 interface 反查,但 macOS 上 interface 是 utunN,
# 名稱對應存在 /var/run/wireguard/<name>.name。用該檔存在與否判斷最可靠。)
is_up() {
local name="$1"
[[ -f "/var/run/wireguard/$name.name" ]]
}
# 取出 conf 的 AllowedIPs(用於衝突檢查),輸出每行一個 CIDR
allowed_ips() {
local name="$1"
grep -iE '^[[:space:]]*AllowedIPs[[:space:]]*=' "$WG_DIR/$name.conf" 2>/dev/null \
| sed -E 's/^[[:space:]]*[Aa][Ll][Ll][Oo][Ww][Ee][Dd][Ii][Pp][Ss][[:space:]]*=[[:space:]]*//' \
| tr ',' '\n' \
| sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \
| grep -v '^$' || true
}
# 本機在這條 tunnel 的 WG IP(conf 的 Address,取 IPv4,去掉 /mask)
local_wg_ip() {
local name="$1"
grep -iE '^[[:space:]]*Address[[:space:]]*=' "$WG_DIR/$name.conf" 2>/dev/null \
| sed -E 's/^[[:space:]]*[Aa][Dd][Dd][Rr][Ee][Ss][Ss][[:space:]]*=[[:space:]]*//' \
| tr ',' '\n' \
| sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
| head -1 \
| sed -E 's#/.*##' || true
}
# 推測 server 在 WG 網路內的 IP:取第一個 IPv4 AllowedIPs 網段的 .1(WireGuard 慣例,僅供參考)
guess_server_wg_ip() {
local name="$1" cidr net
cidr="$(allowed_ips "$name" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/' | head -1)"
[[ -z "$cidr" ]] && return 0
net="${cidr%/*}" # 10.10.10.0
echo "${net%.*}.1" # 10.10.10.1
}
# 檢查即將啟用的 tunnel 是否與「已啟用的其他 tunnel」AllowedIPs 重疊(僅字串完全相同比對,
# 做基本防呆;不做完整 CIDR 子網包含運算,避免誤判與過度複雜)
check_conflict() {
local target="$1" other cidr t_cidrs o_cidrs
t_cidrs="$(allowed_ips "$target")"
# 全流量特別警告
if grep -qE '(^|[[:space:]])0\.0\.0\.0/0([[:space:]]|$)|(^|[[:space:]])::/0([[:space:]]|$)' <<<"$t_cidrs"; then
while read -r other; do
[[ -z "$other" || "$other" == "$target" ]] && continue
if is_up "$other"; then
warn "$target 含全流量路由 (0.0.0.0/0 或 ::/0),會接管所有流量,可能影響已啟用的 $other"
fi
done < <(list_tunnels)
fi
while read -r other; do
[[ -z "$other" || "$other" == "$target" ]] && continue
is_up "$other" || continue
o_cidrs="$(allowed_ips "$other")"
while read -r cidr; do
[[ -z "$cidr" ]] && continue
if grep -qxF "$cidr" <<<"$o_cidrs"; then
warn "AllowedIPs 重疊:$target 與已啟用的 $other 都有 $cidr —— 路由可能衝突"
fi
done <<<"$t_cidrs"
done < <(list_tunnels)
}
# 需要 root 的操作:若非 root 則用 sudo 重新呼叫該子命令
need_root() {
if [[ "$EUID" -ne 0 ]]; then
exec sudo -p "wgm 需要管理員權限,請輸入密碼: " "$0" "${ORIG_ARGS[@]}"
fi
}
cmd_list() {
info "${C_BOLD}可用的 WireGuard tunnel:${C_RESET}"
local n found=0
while read -r n; do
[[ -z "$n" ]] && continue
found=1
if is_up "$n"; then
echo " ${C_GREEN}●${C_RESET} $n"
else
echo " ${C_GREY}○ $n${C_RESET}"
fi
done < <(list_tunnels)
[[ "$found" -eq 0 ]] && info " ${C_GREY}(無,放 .conf 到 $WG_DIR)${C_RESET}"
}
# 人類可讀的相對時間(傳入 Unix timestamp)
_ago() {
local ts="$1" now diff
[[ -z "$ts" || "$ts" == "0" ]] && { echo "從未"; return; }
now="$(date +%s)"
diff=$(( now - ts ))
if (( diff < 0 )); then echo "剛剛"
elif (( diff < 60 )); then echo "${diff} 秒前"
elif (( diff < 3600 )); then echo "$(( diff / 60 )) 分鐘前"
elif (( diff < 86400 ));then echo "$(( diff / 3600 )) 小時前"
else echo "$(( diff / 86400 )) 天前"
fi
}
# 人類可讀的位元組
_hbytes() {
local b="${1:-0}"
if (( b < 1024 )); then echo "${b} B"
elif (( b < 1048576 )); then echo "$(( b / 1024 )) KiB"
elif (( b < 1073741824 )); then echo "$(( b / 1048576 )) MiB"
else echo "$(( b / 1073741824 )) GiB"
fi
}
# 快速版狀態(免 sudo):只看 up/down + conf 的 AllowedIPs + 路由
_status_quick() {
local n any=0 cidr
while read -r n; do
[[ -z "$n" ]] && continue
if is_up "$n"; then
any=1
echo "${C_GREEN}● $n${C_RESET} ${C_GREY}(up)${C_RESET}"
local lip; lip="$(local_wg_ip "$n")"
[[ -n "$lip" ]] && echo " ${C_GREY}本機 WG IP:${C_RESET} $lip"
while read -r cidr; do
[[ -z "$cidr" ]] && continue
echo " ${C_GREY}route:${C_RESET} $cidr"
done < <(allowed_ips "$n")
else
echo "${C_GREY}○ $n (down)${C_RESET}"
fi
done < <(list_tunnels)
[[ "$any" -eq 0 ]] && info "${C_GREY}(目前沒有啟用中的 tunnel)${C_RESET}"
echo "${C_GREY}(快速模式:未讀 handshake。跑 'wgm status' 看完整細節)${C_RESET}"
}
# 完整版狀態:需要 root 讀 wg dump
_status_full() {
local n any=0 iface line
while read -r n; do
[[ -z "$n" ]] && continue
if ! is_up "$n"; then
echo "${C_GREY}○ $n (down)${C_RESET}"
continue
fi
any=1
iface="$(cat "/var/run/wireguard/$n.name" 2>/dev/null || true)"
echo "${C_GREEN}● $n${C_RESET} ${C_GREY}($iface)${C_RESET}"
# 本機與 server 的 WG 內網 IP(從 conf 推導)
local lip sip
lip="$(local_wg_ip "$n")"
sip="$(guess_server_wg_ip "$n")"
[[ -n "$lip" ]] && echo " ${C_GREY}本機 WG IP:${C_RESET} $lip"
[[ -n "$sip" ]] && echo " ${C_GREY}server WG IP:${C_RESET} ${sip} ${C_GREY}(推測,網段 .1)${C_RESET}"
# wg show <iface> dump:
# 行1(interface): private-key pub-key listen-port fwmark
# 行2+(peer): pub-key psk endpoint allowed-ips latest-handshake rx tx keepalive
local first=1
while IFS=$'\t' read -r f1 f2 f3 f4 f5 f6 f7 f8; do
if [[ "$first" -eq 1 ]]; then
first=0
[[ -n "$f3" ]] && echo " ${C_GREY}listen-port:${C_RESET} $f3"
continue
fi
# peer 行:f3=endpoint f4=allowed-ips f5=handshake(ts) f6=rx f7=tx f8=keepalive
local ep="$f3" aips="$f4" hs="$f5" rx="$f6" tx="$f7" ka="$f8"
local hs_txt; hs_txt="$(_ago "$hs")"
# 健康判讀:3 分鐘內握手 = healthy
local health
if [[ "$hs" == "0" || -z "$hs" ]]; then
health="${C_YELLOW}⚠ 尚未握手${C_RESET}"
elif (( $(date +%s) - hs < 180 )); then
health="${C_GREEN}✓ 連線正常${C_RESET}"
else
health="${C_YELLOW}⚠ handshake 已過期(可能閒置或斷線)${C_RESET}"
fi
[[ "$ep" == "(none)" || -z "$ep" ]] || echo " ${C_GREY}endpoint:${C_RESET} $ep"
echo " ${C_GREY}allowed-ips:${C_RESET} $aips"
echo " ${C_GREY}handshake:${C_RESET} $hs_txt $health"
echo " ${C_GREY}transfer:${C_RESET} ↓ $(_hbytes "$rx") ↑ $(_hbytes "$tx")"
[[ "$ka" == "off" || -z "$ka" ]] || echo " ${C_GREY}keepalive:${C_RESET} ${ka}s"
done < <("$WG" show "$iface" dump 2>/dev/null)
done < <(list_tunnels)
[[ "$any" -eq 0 ]] && info "${C_GREY}(目前沒有啟用中的 tunnel)${C_RESET}"
}
cmd_status() {
# -q / --quick:免 sudo 快速模式
if [[ "${1:-}" == "-q" || "${1:-}" == "--quick" ]]; then
_status_quick
return
fi
# 完整模式需要 root 讀 wg dump;非 root 時自動用 sudo(觸發 Touch ID)
if [[ "$EUID" -ne 0 ]]; then
# 若沒有任何 tunnel 啟用,不必為了空結果動用 sudo
local has_up=0 n
while read -r n; do [[ -n "$n" ]] && is_up "$n" && has_up=1; done < <(list_tunnels)
if [[ "$has_up" -eq 0 ]]; then
_status_quick
return
fi
exec sudo -p "wgm status 需要權限讀取 handshake(可刷 Touch ID): " "$0" "${ORIG_ARGS[@]}"
fi
_status_full
}
cmd_up() {
local target="${1:-}"
[[ -z "$target" ]] && { err "用法:wgm up <name|all>"; exit 1; }
need_root
if [[ "$target" == "all" ]]; then
local n
while read -r n; do
[[ -z "$n" ]] && continue
_up_one "$n"
done < <(list_tunnels)
else
conf_exists "$target" || { err "找不到 conf:$target(用 wgm list 查看)"; exit 1; }
_up_one "$target"
fi
}
_up_one() {
local name="$1"
if is_up "$name"; then
info "${C_GREY}$name 已啟用,略過${C_RESET}"
return 0
fi
check_conflict "$name"
info "${C_BOLD}啟用 $name ...${C_RESET}"
"$WG_QUICK" up "$name"
}
cmd_down() {
local target="${1:-}"
[[ -z "$target" ]] && { err "用法:wgm down <name|all>"; exit 1; }
need_root
if [[ "$target" == "all" ]]; then
local n
while read -r n; do
[[ -z "$n" ]] && continue
is_up "$n" && { info "${C_BOLD}關閉 $n ...${C_RESET}"; "$WG_QUICK" down "$n"; } || info "${C_GREY}$n 未啟用,略過${C_RESET}"
done < <(list_tunnels)
else
conf_exists "$target" || { err "找不到 conf:$target"; exit 1; }
if is_up "$target"; then
info "${C_BOLD}關閉 $target ...${C_RESET}"
"$WG_QUICK" down "$target"
else
info "${C_GREY}$target 未啟用${C_RESET}"
fi
fi
}
cmd_restart() {
local target="${1:-}"
[[ -z "$target" ]] && { err "用法:wgm restart <name>"; exit 1; }
conf_exists "$target" || { err "找不到 conf:$target"; exit 1; }
need_root
is_up "$target" && { info "${C_BOLD}關閉 $target ...${C_RESET}"; "$WG_QUICK" down "$target"; }
info "${C_BOLD}啟用 $target ...${C_RESET}"
"$WG_QUICK" up "$target"
}
plist_path() { echo "$LAUNCHD_DIR/$LABEL_PREFIX.$1.plist"; }
cmd_enable_boot() {
local name="${1:-}"
[[ -z "$name" ]] && { err "用法:wgm enable-boot <name>"; exit 1; }
conf_exists "$name" || { err "找不到 conf:$name"; exit 1; }
need_root
local plist; plist="$(plist_path "$name")"
cat > "$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL_PREFIX.$name</string>
<key>ProgramArguments</key>
<array>
<string>$WG_QUICK</string>
<string>up</string>
<string>$name</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>$BREW_BIN:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>WG_QUICK_USERSPACE_IMPLEMENTATION</key>
<string>wireguard-go</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/wgm-$name.log</string>
<key>StandardErrorPath</key>
<string>/var/log/wgm-$name.log</string>
</dict>
</plist>
PLIST
chown root:wheel "$plist"
chmod 644 "$plist"
launchctl load -w "$plist" 2>/dev/null || launchctl bootstrap system "$plist" 2>/dev/null || true
info "${C_GREEN}已設定開機自動啟動:$name${C_RESET}"
info " plist: $plist"
info " log: /var/log/wgm-$name.log"
}
cmd_disable_boot() {
local name="${1:-}"
[[ -z "$name" ]] && { err "用法:wgm disable-boot <name>"; exit 1; }
need_root
local plist; plist="$(plist_path "$name")"
[[ -f "$plist" ]] || { warn "$name 未設定開機啟動"; return 0; }
launchctl unload -w "$plist" 2>/dev/null || launchctl bootout system "$plist" 2>/dev/null || true
rm -f "$plist"
info "${C_GREEN}已取消開機自動啟動:$name${C_RESET}"
}
cmd_boot_status() {
local n plist
info "${C_BOLD}開機啟動設定狀態:${C_RESET}"
while read -r n; do
[[ -z "$n" ]] && continue
plist="$(plist_path "$n")"
if [[ -f "$plist" ]]; then
echo " ${C_GREEN}✓${C_RESET} $n ${C_GREY}(開機自動啟動)${C_RESET}"
else
echo " ${C_GREY}✗ $n (不自動啟動)${C_RESET}"
fi
done < <(list_tunnels)
}
usage() {
cat <<'USAGE'
wgm — WireGuard tunnel manager (macOS)
用法:
wgm list 列出所有 tunnel(● 已啟用 / ○ 未啟用)
wgm status 顯示啟用狀態、handshake、流量、本機/server WG IP(需權限,可刷 Touch ID)
wgm status -q 快速狀態(免 sudo,只看 up/down + 路由 + 本機 WG IP)
wgm up <name|all> 啟用一條或全部
wgm down <name|all> 關閉一條或全部
wgm restart <name> 重連(down 再 up)
wgm enable-boot <name> 設定開機自動啟動(launchd LaunchDaemon)
wgm disable-boot <name> 取消開機自動啟動
wgm boot-status 顯示各 tunnel 開機啟動設定
conf 目錄:/opt/homebrew/etc/wireguard/(檔名即 tunnel 名稱)
需要 root 的操作會自動以 sudo 重新執行。
USAGE
}
# ---- 主流程 ----
ORIG_ARGS=("$@")
cmd="${1:-}"; shift || true
case "$cmd" in
list) cmd_list ;;
status) cmd_status "${1:-}" ;;
up) cmd_up "${1:-}" ;;
down) cmd_down "${1:-}" ;;
restart) cmd_restart "${1:-}" ;;
enable-boot) cmd_enable_boot "${1:-}" ;;
disable-boot) cmd_disable_boot "${1:-}" ;;
boot-status) cmd_boot_status ;;
""|-h|--help|help) usage ;;
*) err "未知指令:$cmd"; echo; usage; exit 1 ;;
esac
一頁速查
安裝: brew install wireguard-tools
conf 目錄: /opt/homebrew/etc/wireguard/(檔名=tunnel 名)
權限: chmod 600 conf 檔
日常:
wgm list / wgm status / wgm status -q
wgm up <name|all> / wgm down <name|all> / wgm restart <name>
開機自動:
wgm enable-boot <name> / wgm disable-boot <name> / wgm boot-status
多條並存鐵則:各 conf 的 AllowedIPs 不能重疊;最多一條可以是全流量。
純內網 split tunnel:考慮拿掉 conf 的 DNS = 那行。
驗證連上:sudo wg show → 看 latest handshake + transfer received
