#!/usr/bin/env bash # myvpn-install-mac.sh — install the myvpn agent + CLI on macOS for everyday use. # Installs binaries to /usr/local/bin, a persistent state/key dir at /var/lib/myvpn, # and myvpn-up / myvpn-down wrappers pointing at the production coord + DERP. # sudo bash myvpn-install-mac.sh # run from the repo root (finds dist/), or # sudo bash myvpn-install-mac.sh # with agent-darwin-* + myvpn-darwin-* next to it # # 可选参数: # --auth-key= 装时即用一次性 auth-key 注册设备(免交互登录)。 # 也可用环境变量 MYVPN_AUTHKEY 传入(显式参数优先于 env)。 # 两者都没有且 stdin/stdout 是 TTY 时会交互询问(留空回车=跳过, # 首次 sudo myvpn-up 走交互式邮箱+密码登录)。 # 安全提示:命令行参数会落 shell history;介意者用交互输入,或 # sudo MYVPN_AUTHKEY= bash myvpn-install-mac.sh(注意 # MYVPN_AUTHKEY 要放在 sudo 后面、脚本名前面 —— sudo 命令行上的 # VAR=value 豁免 env_reset;放 sudo 前面反而会被剥掉)。 # 一次性 key 用后即废。 # --dry-run 只打印命令清单后退出(测试用;不需要 root / Darwin)。 set -euo pipefail # 生产 coord / DERP 单源:wrapper heredoc 里是占位符,写完后 sed 注入; # enroll-once 也直接用这两个变量。 # #643 D-1:coord 默认改拨私有 CA 的 :8444 mTLS 端口(不再是明文 :8080)。全新 # 设备没有 CA 也能验证 coord 证书,是因为本安装包随包带了 coord-ca.crt(见下方 # CA_SRC/CA_FLAG),装时落到 /usr/local/share/myvpn/(与 --cert-dir 目录分开, # 见该常量旁的注释——不能同目录,否则 certstore.Save 会在下次注册后覆盖它)。 COORD=https://fleet.mydiy.fun:8444 DERP=47.108.253.164:8443 SOCK=/var/run/myvpn-agent.sock # --dry-run prints the command listing and exits without installing # anything. Used by install-mac-test.sh to verify the listing is in # sync with cmd/cli/main.go usage(). No sudo / no Darwin check needed. print_command_listing() { echo "" echo "================================================================" echo "可用命令清单(全部需 sudo)" echo "================================================================" echo "[高层 wrapper 脚本 — 日常用这两个]" echo "myvpn-up 一站起:起 agent + up + status(首次交互 myvpn login,之后凭 refresh token 自动连)" echo "myvpn-down 一站停:关 tunnel + 停 agent" echo "" echo "[myvpn 子命令 — 注意中间是空格不是横线;agent 已起前提下]" echo "myvpn login 交互登录(邮箱+密码),成员首登强制改密" echo "myvpn logout 注销当前账号,清除本地 token" echo "myvpn passwd 修改当前账号密码" echo "myvpn up 加入 mesh(agent 已起;wrapper 用 myvpn-up)" echo "myvpn down 离开 mesh(agent 保留;wrapper 用 myvpn-down)" echo "myvpn status 显示连接状态、虚拟 IP、relay-only 标记" echo "myvpn peers 列出对端 + 链路模式 + 真实 IP" echo "myvpn watch 实时跟踪事件流" echo "myvpn devices 列出账号下所有设备(含离线 + last_seen)" echo "myvpn relay-only on 强制走中继(关闭直连,不发探测)" echo "myvpn relay-only off 关闭强制中继,恢复自动决策" echo "myvpn relay-only status 查询当前 relay-only 状态" echo "myvpn longlived on 登录长期有效(refresh token 永不过期)" echo "myvpn longlived off 关闭长期有效,恢复 refresh token 过期" echo "myvpn longlived status 查询当前 longlived 状态" echo "myvpn autostart on 开机自启(macOS LaunchDaemon)" echo "myvpn autostart off 关闭开机自启" echo "myvpn autostart status 查询开机自启状态" echo "myvpn upgrade 升级到最新版本(check+下载+安装+自动回滚)" echo "myvpn help 显示所有命令的详细帮助" echo "================================================================" } usage() { echo "Usage: sudo bash $0 [--auth-key=] [--dry-run]" >&2 echo " --auth-key= 装时注册(免交互登录);也可用 env MYVPN_AUTHKEY(参数优先)。" >&2 echo " 注意:命令行参数会落 shell history;一次性 key 用后即废。" >&2 echo " 介意者用交互输入(TTY 下会询问,可留空跳过)或" >&2 echo " sudo MYVPN_AUTHKEY= bash $0" >&2 echo " --dry-run 只打印命令清单后退出(不需要 root / Darwin)。" >&2 exit 2 } # 参数解析:--dry-run 保持可用(且必须先于平台/root 判断生效);--auth-key= 参数 # 优先于 MYVPN_AUTHKEY env。 DRY_RUN=0 AUTH_KEY="${MYVPN_AUTHKEY:-}" for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; --auth-key=*) AUTH_KEY="${arg#--auth-key=}" ;; --help|-h) usage ;; *) echo "ERROR: 未知参数: $arg" >&2; usage ;; esac done if [ "$DRY_RUN" -eq 1 ]; then print_command_listing exit 0 fi [ "$(uname -s)" = "Darwin" ] || { echo "macOS only" >&2; exit 1; } [ "$EUID" -eq 0 ] || { echo "run with sudo" >&2; exit 1; } # 占位符守卫:下载页一键命令里的 <你的auth-key> 原样传入视为未提供(防占位符 # 误执行导致 enroll 必败);空串同样视为未提供。 if [ "$AUTH_KEY" = "<你的auth-key>" ]; then AUTH_KEY=""; fi # TTY 交互提示(可留空回车跳过);非 TTY(管道/脚本调用)安静跳过。 if [ -z "$AUTH_KEY" ] && [ -t 0 ] && [ -t 1 ]; then # -s 不回显(key 属凭据,防肩窥/scrollback 残留);补一个 echo 换行。 read -r -s -p "auth-key(一次性,留空回车=跳过注册): " AUTH_KEY || true echo if [ "$AUTH_KEY" = "<你的auth-key>" ]; then AUTH_KEY=""; fi fi ARCH="$(uname -m)"; case "$ARCH" in arm64) GA=arm64;; x86_64) GA=amd64;; *) echo "bad arch $ARCH" >&2; exit 1;; esac HERE="$(cd "$(dirname "$0")" && pwd)" find_bin() { # -f (not -x): self-upgrade downloads land as 0644 (no exec bit). The source # need not be executable — install -m 0755 below sets the dest mode anyway. for c in "dist/$1-darwin-$GA" "bin/$1" "$HERE/$1-darwin-$GA" "$HERE/$1"; do [ -f "$c" ] && { echo "$c"; return 0; } done; return 1 } AGENT="$(find_bin agent || true)"; CLI="$(find_bin myvpn || true)" [ -n "$AGENT" ] && [ -n "$CLI" ] || { echo "ERROR: agent/myvpn darwin binaries not found (run from repo root, or put them next to this script)" >&2; exit 1; } UNINSTALL="$HERE/uninstall-mac.sh" install_uninstaller() { uninstall_src="$1" uninstall_dst_dir="$2" if [ -f "$uninstall_src" ]; then install -d -m 0755 "$uninstall_dst_dir" install -m 0755 "$uninstall_src" "$uninstall_dst_dir/uninstall-mac.sh" else # Clients older than the uninstall feature download this installer but do # not select the new uninstall-mac.sh manifest entry. Their binary upgrade # must still succeed; rerunning the complete package from /download supplies # the sidecar and installs it on the next pass. echo "WARNING: uninstall-mac.sh is absent; continuing a legacy self-upgrade without the local uninstaller." >&2 echo " Rerun the complete macOS install command from /download to add it." >&2 fi } mkdir -p /usr/local/bin install -m 0755 "$AGENT" /usr/local/bin/myvpn-agent install -m 0755 "$CLI" /usr/local/bin/myvpn install_uninstaller "$UNINSTALL" /usr/local/share/myvpn # #643 D-1:装 coord 的私有 CA 公钥证书,好让全新设备(无任何既存证书)第一次 # 握手就能验证 coord 的 :8444 叶证书,首次注册全程走 TLS。落盘路径故意跟 # --cert-dir(/var/lib/myvpn/certs)分开:certstore.Save 会在首次注册成功后 # 覆写 cert-dir 里的 coord-ca.crt——把安装器带的信任锚放进那目录,平时无感, # 只在 CA 轮换那天炸。 CA_SRC="$HERE/coord-ca.crt" CA_DST=/usr/local/share/myvpn/coord-ca.crt CA_FLAG="" # install_coord_ca :安装/对齐 CA,设置全局 CA_FLAG。 # 「有主保留」语义对齐 internal/upgrade/reconcile_coordca.go:dst 已存在且内容 # 与包内不同、无 backfill 标记 ⇒ 该文件不是我们写的,判定有主,保留 + WARN, # 绝不覆盖 —— release-dir 里一个合法但错的 CA(过期/错包)不该毒化全 darwin # 机群的信任锚;保留盘上文件并继续把 --coord-ca 指向它(它在现役,多半就是 # 能握手的那张),换锚交给 reconcile/运维显式做。 # dst 存在且一致 ⇒ 一个字节不动(mtime 都不碰);不存在 ⇒ 从包内安装。 # 有 backfill 标记(.backfill-by-upgrade)= 既往 upgrade 回填写的 ⇒ 允许修复性 # 重写,与 reconcile D8 同款。 install_coord_ca() { ca_src="$1"; ca_dst="$2" if [ -f "$ca_dst" ]; then if cmp -s "$ca_src" "$ca_dst"; then CA_FLAG="--coord-ca=$ca_dst" echo "✓ coord 私有 CA 证书已在位(内容一致,保留不动)→ $ca_dst" return 0 fi if [ ! -f "${ca_dst}.backfill-by-upgrade" ]; then CA_FLAG="--coord-ca=$ca_dst" echo "WARNING: $ca_dst 已存在且与安装包内的 coord-ca.crt 不同," >&2 echo " 判定为有主文件(无 backfill 标记),保留不覆盖。" >&2 echo " 若确需换成随包 CA,请手动删除该文件后重装。" >&2 return 0 fi fi install -d -m 0755 "$(dirname "$ca_dst")" install -m 0644 "$ca_src" "$ca_dst" CA_FLAG="--coord-ca=$ca_dst" echo "✓ 安装 coord 私有 CA 证书 → $ca_dst" } if [ -f "$CA_SRC" ]; then install_coord_ca "$CA_SRC" "$CA_DST" else echo "WARNING: coord-ca.crt 未随本安装包出现(不在脚本旁);https 首次握手将无法验证" >&2 echo " coord 的私有证书,--coord-ca 不会传给 agent。重新从 /download 拉取" >&2 echo " 完整安装包可补上。" >&2 fi # 清 Gatekeeper quarantine + ad-hoc 重签(防 macOS Gatekeeper 拦截 / SIGKILL)。 # binary 经 scp / AirDrop / 解压等方式传入目标机可能带 com.apple.quarantine xattr; # 非公证的 ad-hoc 签名 binary 一旦带 quarantine,首次启动会被 Gatekeeper 拦 # ("Apple 无法验证…是否包含恶意软件")。故先清 quarantine,再 remove 旧签名 → ad-hoc 重签。 for bin in /usr/local/bin/myvpn /usr/local/bin/myvpn-agent; do /usr/bin/xattr -d com.apple.quarantine "$bin" 2>/dev/null || true /usr/bin/codesign --remove-signature "$bin" 2>/dev/null || true /usr/bin/codesign --force --sign - "$bin" echo "✓ 清 quarantine + ad-hoc 签名 $bin" done # #694 D6:pre-#643 生成的 launchd plist argv 没有 --coord-ca,且自升级从不重写 # plist。守卫式修补:plist 存在 + 缺 flag + CA 文件在 → 重跑 autostart on(幂等; # 自带 CA 缺失置空 guard,见 cmd/cli/autostart_darwin.go)。只写 plist 文件, # 绝不 bootout/kickstart —— 新 argv 下次 reboot/手动 kickstart 才生效 # (re-exec 保留旧 argv)。autostart on 需要已登录的 state.json;plist 存在即 # 此前已 autostart on 过、state 应在,万一不在则失败只 WARNING,不阻断安装。 # #688 S-5:grep 判非空值(--coord-ca=/)——plist 里字面 --coord-ca=(空值) # 同样算缺 flag:空值会让 agent 启动时报 coord-ca-invalid,旧 grep 模式会漏判。 plist_needs_coord_ca_patch() { # $1 = plist 路径;缺 flag 或 flag 值为空(--coord-ca= 后无路径)⇒ 需要补。 [ -f "$1" ] || return 1 ! grep -q -- "--coord-ca=/" "$1" } PLIST=/Library/LaunchDaemons/com.myvpn.autostart.plist if plist_needs_coord_ca_patch "$PLIST" && [ -f "$CA_DST" ]; then /usr/local/bin/myvpn autostart on && echo "✓ plist 补 --coord-ca(下次重启生效)" \ || echo "WARNING: autostart on 失败,plist 未更新(不阻断安装)" >&2 fi mkdir -p /var/lib/myvpn && chmod 700 /var/lib/myvpn cat > /usr/local/bin/myvpn-up <<'EOF' #!/bin/bash set -e COORD=COORD_URL_PLACEHOLDER DERP=DERP_PLACEHOLDER SOCK=/var/run/myvpn-agent.sock DIR=/var/lib/myvpn export MYVPN_SOCKET="$SOCK" # Start agent if not already running. if [ -z "$(ps -axww -o pid=,args= 2>/dev/null | grep "[m]yvpn-agent .*$SOCK")" ]; then mkdir -p "$DIR" && chmod 700 "$DIR" # agent 的 Go stdlog 与 stderr pipe 都经 ManagedWriter 写入 --log-file,故把 nohup 的 stdout/stderr # 导到 /dev/null —— 否则 nohup 会在调用方 cwd 留下 nohup.out(sudo 跑时还是 root 拥有, # 会污染目录、卡住后续 scp)。 nohup /usr/local/bin/myvpn-agent --coord="$COORD" --real-tun --derp="$DERP" \ --socket="$SOCK" --state-file="$DIR/state.json" --key-file="$DIR/wg.key" \ --listen-port=51820 --log-file=/var/log/myvpn-agent.log COORD_CA_PLACEHOLDER >/dev/null 2>&1 & sleep 2 fi # v1.21: no account.env anymore. If we already have a live session (persisted # refresh token in state.json), `myvpn up` reconnects with no prompt. Only when # there's no usable session do we drop into an interactive `myvpn login` (the # typed password is used once for auth, then discarded — never written to disk). if /usr/local/bin/myvpn status 2>/dev/null | grep -q "^logged in: yes"; then : else echo "首次启动 — 请输入邮箱+密码登录(密码仅用于本次认证,不会保存到磁盘):" /usr/local/bin/myvpn login fi /usr/local/bin/myvpn up sleep 1 /usr/local/bin/myvpn status EOF chmod 0755 /usr/local/bin/myvpn-up # 注入生产 coord/DERP/CA(heredoc 是 quoted <<'EOF',占位符在这里替换; # 与 install-linux.sh 对 unit 文件的做法同款,mac sed 用 -i '')。CA_FLAG 可能是 # 空串(CA_SRC 缺失时,见上文 WARNING)—— 空串替换后留一个多余空格,shell 里无害 # (不是一个空字符串参数,只是词间多一格)。 sed -i '' -e "s|COORD_URL_PLACEHOLDER|$COORD|g" -e "s|DERP_PLACEHOLDER|$DERP|g" \ -e "s|COORD_CA_PLACEHOLDER|$CA_FLAG|g" /usr/local/bin/myvpn-up # 加固:sed 无匹配时静默返回 0(set -e 抓不住),显式断言占位符已全部注入, # 防「wrapper 带 COORD_URL_PLACEHOLDER 上线」这类漂移。 if grep -q PLACEHOLDER /usr/local/bin/myvpn-up; then echo "ERROR: myvpn-up wrapper 注入失败(仍有 PLACEHOLDER)" >&2; exit 1 fi cat > /usr/local/bin/myvpn-down <<'EOF' #!/bin/bash SOCK=/var/run/myvpn-agent.sock export MYVPN_SOCKET="$SOCK" agent_pids() { # ps works in every context (incl. a no-tty daemon, where the process-grep # tools return empty on macOS). The [m] bracket trick keeps grep from matching # its own cmdline. ps -axww -o pid=,args= 2>/dev/null | grep "[m]yvpn-agent .*$SOCK" | awk '{print $1}' } /usr/local/bin/myvpn down 2>/dev/null || true if [ -z "$(agent_pids)" ]; then echo "myvpn stopped (tunnel down, no agent running)" exit 0 fi kill -TERM $(agent_pids) 2>/dev/null || true waited=0 while [ -n "$(agent_pids)" ]; do [ "$waited" -ge 40 ] && break sleep 0.25 waited=$((waited + 1)) done if [ -n "$(agent_pids)" ]; then kill -KILL $(agent_pids) 2>/dev/null || true waited=0 while [ -n "$(agent_pids)" ]; do [ "$waited" -ge 12 ] && break sleep 0.25 waited=$((waited + 1)) done fi if [ -n "$(agent_pids)" ]; then echo "ERROR: myvpn-agent still running after SIGKILL; not safe to start a new one" >&2 exit 1 fi echo "myvpn stopped (tunnel down, agent stopped)" EOF chmod 0755 /usr/local/bin/myvpn-down cat > /usr/local/bin/myvpn-status <<'EOF' #!/bin/bash # Show the running agent's status (points the CLI at the agent's socket). export MYVPN_SOCKET=/var/run/myvpn-agent.sock exec /usr/local/bin/myvpn status "$@" EOF chmod 0755 /usr/local/bin/myvpn-status # 装时注册(--auth-key / MYVPN_AUTHKEY / TTY 询问):二进制+codesign+/var/lib/myvpn # +wrapper 均已就绪后,root 直跑一次 --enroll-once(注册→换 JWT→写 state.json 后 # 退出,不起 daemon)。之后首次 sudo myvpn-up 的 `grep "^logged in: yes"` 命中, # 跳过交互 login 直接 up。 ENROLLED=0 if [ -n "$AUTH_KEY" ]; then # 前置守卫:agent 在跑则报错退出(不自动杀隧道 —— 由用户显式 myvpn-down)。 # 借用 myvpn-down 的扫法;grep 无匹配返回 1,`|| true` 防 set -e/pipefail 误伤。 if [ -n "$(ps -axww -o pid=,args= 2>/dev/null | grep "[m]yvpn-agent .*$SOCK" || true)" ]; then echo "ERROR: 检测到 myvpn-agent 正在运行,无法注册(会与新注册冲突)。" >&2 echo " 请先执行: sudo myvpn-down 再重跑本安装脚本。" >&2 exit 1 fi echo ">>> 使用 auth-key 注册设备(--enroll-once)..." # 不传 --log-file:用默认 /var/log/myvpn-agent.log(root 可写,即 daemon 日志 # 本身,lumberjack append 不截断)——失败细节(agent: register-with-key failed: ...) # 会落在运维本来就查的地方,而不是被 /dev/null 吞掉。 if ! /usr/local/bin/myvpn-agent --enroll-once \ --auth-key="$AUTH_KEY" --coord="$COORD" $CA_FLAG \ --state-file=/var/lib/myvpn/state.json --key-file=/var/lib/myvpn/wg.key \ --metrics-addr=""; then # 失败语义:安装失败(exit 1),不回滚文件;补救命令打到 stderr。 echo "ERROR: auth-key 注册失败(key 无效/已用过,或 coord 不可达)。" >&2 echo " 安装已中止:文件已就绪、未入网。" >&2 echo " 细节见 /var/log/myvpn-agent.log;确认 key 无误后手动补救:" >&2 echo " sudo /usr/local/bin/myvpn-agent --enroll-once \\" >&2 echo " --auth-key= --coord=$COORD $CA_FLAG \\" >&2 echo " --state-file=/var/lib/myvpn/state.json --key-file=/var/lib/myvpn/wg.key \\" >&2 echo " --metrics-addr=\"\"" >&2 echo " sudo myvpn-up" >&2 exit 1 fi ENROLLED=1 echo ">>> 注册成功(一次性 auth-key 已作废)。" fi # Capture installed CLI version line for the install summary (first line only; # wrap in brace group + "|| true" so pipefail won't kill the script if myvpn # is missing or fails). INSTALLED_VERSION="$({ /usr/local/bin/myvpn version 2>/dev/null || true; } | head -1)" INSTALLED_VERSION="${INSTALLED_VERSION:-(version unknown)}" echo "================================================================" echo "已安装: $INSTALLED_VERSION" echo "================================================================" echo " /usr/local/bin/myvpn-agent /usr/local/bin/myvpn /usr/local/bin/myvpn-up /usr/local/bin/myvpn-down" echo " state dir: /var/lib/myvpn" echo echo "下一步:" if [ "$ENROLLED" -eq 1 ]; then echo " 已用 auth-key 注册,无需再登录:" echo " 1) sudo myvpn-up 起 agent + 加入 mesh(已注册,跳过交互登录)" echo " 2) sudo myvpn autostart on 开机自启" echo echo "一键执行(复制此行):" echo " sudo myvpn-up && sudo myvpn autostart on" else echo " 1) sudo myvpn-down 先停旧 agent(若有);首次安装也无害" echo " 2) sudo myvpn-up 起新 agent + 登录 + 加入 mesh" echo " 首次启动会交互式询问一次邮箱+密码(密码仅用于本次认证,不写磁盘)" echo " 之后由持久化的 refresh token(180 天滑动续期)自动连上,无需再输密码" echo " up 完会问'是否开启登录长期有效?'——常开设备(NAS/服务器)可选 y(refresh 永不过期)" echo " 3) sudo myvpn autostart on 开机自启(需先 myvpn-up 成功登录一次)" echo echo "一键执行(复制此行;首次会暂停问邮箱密码):" echo " sudo myvpn-down && sudo myvpn-up && sudo myvpn autostart on" fi print_command_listing