#!/usr/bin/env bash # uninstall-mac.sh — fully remove a package-installed myvpn client from macOS. # # Installed at /usr/local/share/myvpn/uninstall-mac.sh. The optional # MYVPN_UNINSTALL_ROOT / MYVPN_UNINSTALL_TEST_UID seams are only for the # repository's isolated fixture tests; production always operates on /. set -u YES=0 for arg in "$@"; do case "$arg" in --yes) YES=1 ;; --help|-h) echo "Usage: sudo $0 [--yes]" exit 0 ;; *) echo "ERROR: unknown argument: $arg" >&2 echo "Usage: sudo $0 [--yes]" >&2 exit 2 ;; esac done ROOT_PREFIX="${MYVPN_UNINSTALL_ROOT:-}" case "$ROOT_PREFIX" in ""|/) ROOT_PREFIX="" ;; /*) ROOT_PREFIX="${ROOT_PREFIX%/}" ;; *) echo "ERROR: MYVPN_UNINSTALL_ROOT must be an absolute path" >&2; exit 2 ;; esac root_path() { printf '%s%s\n' "$ROOT_PREFIX" "$1"; } effective_uid="$(/usr/bin/id -u)" platform="$(uname -s)" if [ -n "$ROOT_PREFIX" ]; then effective_uid="${MYVPN_UNINSTALL_TEST_UID:-$effective_uid}" platform="${MYVPN_UNINSTALL_TEST_PLATFORM:-$platform}" fi [ "$effective_uid" -eq 0 ] || { echo "ERROR: 卸载需要 root。请用: sudo $0" >&2 exit 1 } [ "$platform" = "Darwin" ] || { echo "ERROR: uninstall-mac.sh 只能在 macOS 上运行。" >&2 exit 1 } echo "即将完整卸载 myvpn:程序、服务、登录状态、凭据和产品日志都会被删除。" echo "本地卸载不会删除控制台中的设备记录;如需删除,请让管理员在控制台另行操作。" if [ "$YES" -ne 1 ]; then printf "继续卸载?输入 y 确认 [y/N]: " answer="" IFS= read -r answer || true case "$answer" in y|Y|yes|YES|Yes) ;; *) echo "已取消,未做任何更改。"; exit 0 ;; esac fi cleanup_failed=0 preserve_state=0 cleanup_warning() { echo "WARNING: $*" >&2 cleanup_failed=1 } remove_files() { if ! rm -f "$@"; then cleanup_warning "删除 myvpn 文件失败,请检查上方错误。" return fi for target in "$@"; do if [ -e "$target" ] || [ -L "$target" ]; then cleanup_warning "删除后路径仍然存在:$target" fi done } remove_tree() { target="$1" [ -e "$target" ] || [ -L "$target" ] || return 0 if ! rm -rf "$target"; then cleanup_warning "删除目录失败:$target" return fi if [ -e "$target" ] || [ -L "$target" ]; then cleanup_warning "删除后目录仍然存在:$target" fi } # Check this boundary before asking the live agent to run `down`: the agent's # own split-DNS cleanup also follows the logical /etc/resolver path. If that # directory itself was replaced by a symlink, fail before either process can # touch a same-named file outside the real resolver directory. MANIFEST="$(root_path /var/lib/myvpn/splitdns_macos.json)" RESOLVER_DIR="$(root_path /etc/resolver)" if [ -f "$MANIFEST" ] && [ -L "$RESOLVER_DIR" ]; then echo "ERROR: $RESOLVER_DIR 是符号链接;为避免越界删除,未执行任何卸载操作。" >&2 exit 1 fi # Ask the live agent to restore routes/DNS while its socket is still available. # Then detach the LaunchDaemon and use the wrapper as a bounded fallback for a # manually-started agent that launchd did not own. CLI="$(root_path /usr/local/bin/myvpn)" DOWN="$(root_path /usr/local/bin/myvpn-down)" if [ -x "$CLI" ]; then MYVPN_SOCKET="$(root_path /var/run/myvpn-agent.sock)" "$CLI" down >/dev/null 2>&1 || true fi AUTOSTART_PLIST="$(root_path /Library/LaunchDaemons/com.myvpn.autostart.plist)" EXIT_PLIST="$(root_path /Library/LaunchDaemons/com.myvpn.exit-node.plist)" if command -v launchctl >/dev/null 2>&1; then # Label targets also catch a loaded job whose plist was manually moved. launchctl bootout system/com.myvpn.autostart >/dev/null 2>&1 || true launchctl bootout system/com.myvpn.exit-node >/dev/null 2>&1 || true for plist in "$AUTOSTART_PLIST" "$EXIT_PLIST"; do [ -e "$plist" ] && launchctl bootout system "$plist" >/dev/null 2>&1 || true done fi if [ -x "$DOWN" ]; then "$DOWN" >/dev/null 2>&1 || true fi # An exit-node install is optional and older macOS packages may not contain the # helper. When present, reverse its tagged pf/sysctl changes before removing it. EXIT_DISABLE="$(root_path /usr/local/share/myvpn/exit-node-disable-darwin.sh)" if [ -x "$EXIT_DISABLE" ]; then if ! "$EXIT_DISABLE" >/dev/null 2>&1; then cleanup_warning "出口节点网络清理失败,请手工检查 pf/sysctl 配置。" fi fi # Only remove /etc/resolver entries recorded by myvpn's manifest. Filenames are # restricted to the domain-suffix character set and regular files. The agent # writes a canonical top-level-only {"files":[...]} object (or a cleared # {"files":null}); anything else is rejected as corrupt rather than letting a # regex discover a nested "files" key. removed_resolver=0 if [ -f "$MANIFEST" ]; then if [ -L "$RESOLVER_DIR" ]; then cleanup_warning "$RESOLVER_DIR 是符号链接;为避免越界删除,已保留 resolver manifest。" preserve_state=1 else manifest_flat="$(tr -d '\n\r' <"$MANIFEST" 2>/dev/null || true)" manifest_record="$(printf '%s\n' "$manifest_flat" | sed -n \ -e 's/^[[:space:]]*{"files"[[:space:]]*:[[:space:]]*\[\([^]]*\)\][[:space:]]*}[[:space:]]*$/OK:\1/p' \ -e 's/^[[:space:]]*{"files"[[:space:]]*:[[:space:]]*null[[:space:]]*}[[:space:]]*$/OK:/p')" case "$manifest_record" in OK:*) manifest_body="${manifest_record#OK:}" ;; *) cleanup_warning "split-DNS manifest 格式异常;为避免误删,已保留状态目录。" preserve_state=1 manifest_body="" ;; esac manifest_items="$(printf '%s\n' "$manifest_body" \ | tr ',' '\n' \ | sed -n 's/^[[:space:]]*"\([A-Za-z0-9._-][A-Za-z0-9._-]*\)"[[:space:]]*$/\1/p')" while IFS= read -r name; do [ -n "$name" ] || continue case "$name" in .|..) continue ;; esac resolver="$RESOLVER_DIR/$name" if [ -f "$resolver" ] && [ ! -L "$resolver" ]; then if rm -f "$resolver" && [ ! -e "$resolver" ] && [ ! -L "$resolver" ]; then removed_resolver=1 else cleanup_warning "无法删除 manifest 记录的 resolver:$resolver" preserve_state=1 fi fi done </dev/null 2>&1 || true /usr/bin/killall -HUP mDNSResponder >/dev/null 2>&1 || true fi [ -d "$RESOLVER_DIR" ] && rmdir "$RESOLVER_DIR" >/dev/null 2>&1 || true remove_files "$AUTOSTART_PLIST" "$EXIT_PLIST" remove_files \ "$(root_path /usr/local/bin/myvpn-agent)" \ "$(root_path /usr/local/bin/myvpn-agent.bak)" \ "$(root_path /usr/local/bin/myvpn)" \ "$(root_path /usr/local/bin/myvpn.bak)" \ "$(root_path /usr/local/bin/myvpn-up)" \ "$(root_path /usr/local/bin/myvpn-down)" \ "$(root_path /usr/local/bin/myvpn-status)" \ "$(root_path /var/run/myvpn-agent.sock)" \ "$(root_path /var/run/myvpn-upgrade.lock)" \ "$(root_path /var/log/myvpn-agent.log)" \ "$(root_path /var/log/myvpn-agent-autostart.log)" \ "$(root_path /var/log/myvpn-exit-node.log)" \ "$(root_path /var/log/myvpn-upgrade.log)" STATE_DIR="$(root_path /var/lib/myvpn)" SHARE_DIR="$(root_path /usr/local/share/myvpn)" if [ "$preserve_state" -ne 1 ]; then [ -n "$STATE_DIR" ] && [ "$STATE_DIR" != "$ROOT_PREFIX" ] && remove_tree "$STATE_DIR" fi if [ "$cleanup_failed" -eq 0 ]; then [ -n "$SHARE_DIR" ] && [ "$SHARE_DIR" != "$ROOT_PREFIX" ] && remove_tree "$SHARE_DIR" fi if [ "$cleanup_failed" -ne 0 ]; then echo "ERROR: myvpn 卸载不完整;已保留卸载脚本,请处理警告后重试。" >&2 exit 1 fi echo "myvpn 已从本机完整卸载。" echo "提醒:控制台中的设备记录仍然保留,需要管理员另行删除。"