-
-
Save nczz/d0252f26a86a4d75887e15e0ecf9385a to your computer and use it in GitHub Desktop.
wgm — WireGuard multi-tunnel manager for macOS (wraps wg-quick)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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