Skip to content

Instantly share code, notes, and snippets.

@nczz
Created September 6, 2026 02:07
Show Gist options
  • Select an option

  • Save nczz/d0252f26a86a4d75887e15e0ecf9385a to your computer and use it in GitHub Desktop.

Select an option

Save nczz/d0252f26a86a4d75887e15e0ecf9385a to your computer and use it in GitHub Desktop.
wgm — WireGuard multi-tunnel manager for macOS (wraps wg-quick)
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment