dsh-vps 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dsh-vps ADDED
@@ -0,0 +1,920 @@
1
+ #!/usr/bin/env bash
2
+ # dsh-vps 管理命令(M3)
3
+ # 设计依据:dsh-vps-architecture-design.md §4.2.6 / §10
4
+ #
5
+ # 子命令:
6
+ # status 服务状态 + 健康 + 版本提示
7
+ # restart 重启 gate(DSH 子进程随之重启并自动重新兑换)
8
+ # upgrade [version] 升级 DSH 版本(默认 latest_verified);--latest 强上 npm 最新版
9
+ # update-gate 更新 gate 代码本身(gate/server.js)并重启
10
+ # rollback 符号链接切回上一版本并重启
11
+ # reset-admin 删除 admin.json 与 setup.lock,重新走向导并换发令牌(应急)
12
+ # setup-url 打印初始设置链接(含一次性令牌)
13
+ # vpn 仅我可访问:WireGuard 隧道(setup/add/revoke/list/status/on/off)
14
+ # tunnel 仅我可访问:SSH 本地端口转发(零安装,打印用法)
15
+ # backup 打包 DSH_HOME + state 到 backups/(保留最近 3 份)
16
+ # selfcheck 手动触发升级回归自检探针
17
+
18
+ set -euo pipefail
19
+
20
+ ROOT="${DSHVPS_ROOT:-/opt/dsh-vps}"
21
+ GATE_PORT="${GATE_PORT:-3100}"
22
+ HEALTH_URL="http://127.0.0.1:$GATE_PORT/gate/health"
23
+ SELFCHECK_URL="http://127.0.0.1:$GATE_PORT/gate/selfcheck"
24
+ DSH_HOME_DIR="${DSH_HOME:-/home/dsh/.dsh}"
25
+ SERVICE="dsh-gate"
26
+ RAW_BASE="${DSHVPS_RAW_BASE:-https://raw.githubusercontent.com/AIcivilization/deepseek-harness-vps/main}"
27
+ VERSIONS_URL="${DSHVPS_VERSIONS_URL:-$RAW_BASE/versions.json}"
28
+ # 依赖解析冻结时间点的兜底值(版本表里带 resolve_before 时以版本表为准)。
29
+ # 只钉顶层包版本不够:DSH 子包依赖是 ^x 浮动范围,上游发新预发布波次时解析结果会整体漂上去
30
+ # ——2026-09-22 上游漏发 rc.3 的 dsh-client-ui-sidebar-documentpreview 就导致过 ETARGET。
31
+ # 同日 03:46 的 cordis 系列新版本同样与 rc.2 不兼容(HMR 服务缺失致启动崩溃),故取 03:40。
32
+ # 升级 latest_verified 时必须同步推后这个值。
33
+ DEFAULT_RESOLVE_BEFORE="${DSHVPS_RESOLVE_BEFORE:-2026-09-22T03:40:00Z}"
34
+ VERSIONS_CACHE="$ROOT/state/versions.cache.json"
35
+ NPM_REGISTRY="${DSHVPS_NPM_REGISTRY:-}"
36
+
37
+ log() { printf '\033[1;32m[dsh-vps]\033[0m %s\n' "$*"; }
38
+ warn() { printf '\033[1;33m[dsh-vps]\033[0m %s\n' "$*" >&2; }
39
+ die() { printf '\033[1;31m[dsh-vps]\033[0m %s\n' "$*" >&2; exit 1; }
40
+
41
+ require_root() { [[ $EUID -eq 0 ]] || die "需要 root(sudo dsh-vps $*)"; }
42
+
43
+ current_version() { readlink "$ROOT/dsh/current" 2>/dev/null || echo "(未安装)"; }
44
+
45
+ ## region: backup
46
+
47
+ cmd_backup() {
48
+ require_root
49
+ mkdir -p "$ROOT/backups"
50
+ local ts="$ROOT/backups/dsh-vps-$(date +%Y%m%d-%H%M%S).tar.gz"
51
+ tar -czf "$ts" -C / "${DSH_HOME_DIR#/}" "${ROOT#/}/state" 2>/dev/null \
52
+ || die "备份失败"
53
+ chmod 600 "$ts"
54
+ log "备份完成: $ts"
55
+ # 保留最近 3 份
56
+ local old
57
+ old=$(ls -1t "$ROOT"/backups/dsh-vps-*.tar.gz 2>/dev/null | tail -n +4)
58
+ [[ -n "$old" ]] && rm -f $old && log "已清理旧备份"
59
+ }
60
+
61
+ ## endregion
62
+
63
+ ## region: update-gate
64
+
65
+ # 安装目录不是 git 仓库(install.sh 从 RAW_BASE 取文件),所以 gate 代码
66
+ # 只能靠重新拉取更新。upgrade 管 DSH 版本,update-gate 管 gate 自身代码。
67
+ cmd_update_gate() {
68
+ require_root
69
+ [[ -f "$ROOT/gate/server.js" ]] || die "未找到 $ROOT/gate/server.js(不是 dsh-vps 安装目录?)"
70
+ # node --check 走 ESM loader,只认 .js/.cjs/.mjs 等已知后缀;用 .cjs 同时
71
+ # 锁定 CommonJS 解析,不受将来可能出现的 package.json {"type":"module"} 影响。
72
+ local tmp="$ROOT/gate/server.update.cjs"
73
+ log "拉取最新 gate/server.js ($RAW_BASE/gate/server.js)"
74
+ curl -fsSL --max-time 30 "$RAW_BASE/gate/server.js" -o "$tmp" \
75
+ || { rm -f "$tmp"; die "拉取失败(离线?或 DSHVPS_RAW_BASE 指向有误)"; }
76
+ node --check "$tmp" \
77
+ || { rm -f "$tmp"; die "新版本语法检查失败,原文件未改动"; }
78
+ if cmp -s "$tmp" "$ROOT/gate/server.js"; then
79
+ rm -f "$tmp"
80
+ log "gate 已是最新版本,无需更新"
81
+ return 0
82
+ fi
83
+ cp -p "$ROOT/gate/server.js" "$ROOT/gate/server.js.bak" && log "已备份旧版: $ROOT/gate/server.js.bak"
84
+ mv "$tmp" "$ROOT/gate/server.js"
85
+ chmod 644 "$ROOT/gate/server.js"
86
+ # 站点块模板与 gate 同源(install.sh、dsh-vps vpn 都用它),顺手同步(幂等)
87
+ local sb="$ROOT/gate/site-block.update.js"
88
+ if curl -fsSL --max-time 30 "$RAW_BASE/gate/site-block.js" -o "$sb" && node --check "$sb"; then
89
+ mv "$sb" "$ROOT/gate/site-block.js"
90
+ chmod 644 "$ROOT/gate/site-block.js"
91
+ else
92
+ rm -f "$sb"
93
+ warn "gate/site-block.js 未更新(保留现有版本)"
94
+ fi
95
+ log "重启 $SERVICE ..."
96
+ systemctl restart "$SERVICE"
97
+ sleep 3
98
+ # 静态补丁与 gate 代码同源管理,顺手补齐(幂等)
99
+ ownshost_apply on >/dev/null 2>&1 || true
100
+ if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
101
+ log "更新完成,gate 已就绪"
102
+ else
103
+ warn "重启后健康检查未通过:journalctl -u $SERVICE -n 50"
104
+ warn "回滚:cp $ROOT/gate/server.js.bak $ROOT/gate/server.js && systemctl restart $SERVICE"
105
+ return 1
106
+ fi
107
+ }
108
+
109
+ ## endregion
110
+
111
+ ## region: status
112
+
113
+ cmd_status() {
114
+ echo "== 服务 =="
115
+ systemctl is-active "$SERVICE" 2>/dev/null | sed 's/^/ dsh-gate: /' || echo " dsh-gate: inactive"
116
+ systemctl is-active caddy 2>/dev/null | sed 's/^/ caddy: /' || echo " caddy: (未安装)"
117
+ echo
118
+ echo "== 配置 =="
119
+ [[ -f "$ROOT/state/config.json" ]] && sed 's/^/ /' "$ROOT/state/config.json" || echo " (无 config.json)"
120
+ echo " 当前 DSH 版本: $(current_version)"
121
+ cmd_ownshost status
122
+ echo
123
+ echo "== 健康 =="
124
+ local health
125
+ if health=$(curl -fsS --max-time 3 "$HEALTH_URL" 2>/dev/null); then
126
+ printf '%s\n' "$health" | sed 's/^/ /'
127
+ else
128
+ echo " gate 不可达(服务未运行或启动中)"
129
+ fi
130
+ echo
131
+ # 版本表提示(24h 缓存)
132
+ local cache_ok=0
133
+ if [[ -f "$VERSIONS_CACHE" ]]; then
134
+ local age=$(( $(date +%s) - $(stat -c %Y "$VERSIONS_CACHE" 2>/dev/null || echo 0) ))
135
+ [[ $age -lt 86400 ]] && cache_ok=1
136
+ fi
137
+ if [[ $cache_ok -eq 0 ]]; then
138
+ curl -fsSL --max-time 8 "$VERSIONS_URL" -o "$VERSIONS_CACHE.tmp" 2>/dev/null \
139
+ && mv "$VERSIONS_CACHE.tmp" "$VERSIONS_CACHE" || rm -f "$VERSIONS_CACHE.tmp"
140
+ fi
141
+ if [[ -f "$VERSIONS_CACHE" ]]; then
142
+ local latest
143
+ latest=$(sed -n 's/.*"latest_verified": *"\([^"]*\)".*/\1/p' "$VERSIONS_CACHE" | head -1)
144
+ local cur
145
+ cur=$(current_version)
146
+ if [[ -n "$latest" && "$latest" != "$cur" ]]; then
147
+ if printf '%s\n%s\n' "$cur" "$latest" | sort -V | tail -1 | grep -qx "$latest"; then
148
+ echo "== 提示:有新验证版本 ${latest}(当前 ${cur}),运行 sudo dsh-vps upgrade 升级 =="
149
+ fi
150
+ else
151
+ echo "== 已是最新验证版本(${cur}) =="
152
+ fi
153
+ else
154
+ echo "== 版本表暂不可达(离线或仓库未发布),跳过升级提示 =="
155
+ fi
156
+ }
157
+
158
+ ## endregion
159
+
160
+ ## region: selfcheck
161
+
162
+ cmd_selfcheck() {
163
+ local out
164
+ out=$(curl -fsS --max-time 15 "$SELFCHECK_URL" 2>/dev/null) || {
165
+ warn "selfcheck 探针不可达(gate 未运行?先 sudo systemctl restart ${SERVICE})"
166
+ return 1
167
+ }
168
+ printf '%s\n' "$out"
169
+ printf '%s' "$out" | grep -q '"pass":true'
170
+ }
171
+
172
+ wait_selfcheck() { # $1=重试次数
173
+ local i
174
+ for i in $(seq 1 "$1"); do
175
+ if cmd_selfcheck >/dev/null 2>&1; then
176
+ return 0
177
+ fi
178
+ warn "自检未通过(第 $i/$1 次),等待 10s 重试..."
179
+ sleep 10
180
+ done
181
+ return 1
182
+ }
183
+
184
+ ## endregion
185
+
186
+ ## region: upgrade / rollback
187
+
188
+ switch_version() { # $1=版本目录名
189
+ ln -sfn "$1" "$ROOT/dsh/current"
190
+ # 新版本的 node_modules 是干净的,静态补丁必须重打
191
+ ownshost_apply on >/dev/null 2>&1 || true
192
+ systemctl restart "$SERVICE"
193
+ }
194
+
195
+ cmd_upgrade() {
196
+ require_root
197
+ local target="" latest_mode=0 assume_yes=0
198
+ while [[ $# -gt 0 ]]; do
199
+ case "$1" in
200
+ --latest) latest_mode=1; shift ;;
201
+ --yes) assume_yes=1; shift ;;
202
+ -*) die "未知参数: $1" ;;
203
+ *) target="$1"; shift ;;
204
+ esac
205
+ done
206
+
207
+ local cur
208
+ cur=$(current_version)
209
+ log "当前版本: $cur"
210
+ # --latest 明确要最新,不冻结解析;其余情况冻结在验证过的时间点之前
211
+ local resolve_before="$DEFAULT_RESOLVE_BEFORE"
212
+
213
+ if [[ $latest_mode -eq 1 ]]; then
214
+ warn "--latest:将安装 npm 上未经 dsh-vps 验证的最新版本,可能破坏兼容性(有备份与自动回滚兜底)"
215
+ [[ $assume_yes -eq 1 ]] || { read -rp "确认继续?[y/N] " ans; [[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"; }
216
+ local reg_arg=(version)
217
+ [[ -n "$NPM_REGISTRY" ]] && reg_arg+=(--registry="$NPM_REGISTRY")
218
+ target=$(npm view @deepseek-ai/dsh "${reg_arg[@]}" 2>/dev/null) || die "无法查询 npm 最新版本"
219
+ elif [[ -z "$target" ]]; then
220
+ # 默认:已验证版本表的 latest_verified
221
+ local versions
222
+ versions=$(curl -fsSL --max-time 10 "$VERSIONS_URL" 2>/dev/null) \
223
+ || die "无法获取版本表(${VERSIONS_URL})。可显式指定版本:dsh-vps upgrade <version>"
224
+ target=$(printf '%s' "$versions" | sed -n 's/.*"latest_verified": *"\([^"]*\)".*/\1/p' | head -1)
225
+ [[ -n "$target" ]] || die "版本表中没有 latest_verified 字段"
226
+ local rb
227
+ rb=$(printf '%s' "$versions" | sed -n 's/.*"resolve_before": *"\([^"]*\)".*/\1/p' | head -1)
228
+ [[ -n "$rb" ]] && resolve_before="$rb"
229
+ # min_gate 提示(gate 无版本戳,仅提醒人工确认)
230
+ if printf '%s' "$versions" | grep -q "\"dsh\": *\"$target\""; then
231
+ local min_gate
232
+ min_gate=$(printf '%s' "$versions" | grep -o "\"min_gate\": *\"[^\"]*\"" | head -1 | sed 's/.*: *"\(.*\)"/\1/')
233
+ [[ -n "$min_gate" ]] && warn "该版本要求 gate >= ${min_gate},请确认 gate 代码已同步更新"
234
+ fi
235
+ fi
236
+
237
+ [[ "$target" == "$cur" ]] && { log "已是 ${target},无需升级"; exit 0; }
238
+ log "目标版本: $target"
239
+
240
+ # 1. 备份(防数据格式不向前兼容)
241
+ cmd_backup
242
+
243
+ # 2. 版本化安装
244
+ local prefix="$ROOT/dsh/$target"
245
+ if [[ ! -f "$prefix/node_modules/@deepseek-ai/dsh/lib/bin.js" ]]; then
246
+ mkdir -p "$prefix"
247
+ # 数组恒不为空:bash 3.2 在 set -u 下展开空数组会报 unbound variable
248
+ local args=(--no-audit --no-fund --loglevel=error)
249
+ [[ -n "$NPM_REGISTRY" ]] && args+=(--registry="$NPM_REGISTRY")
250
+ local frozen=1
251
+ [[ $latest_mode -eq 1 || "$resolve_before" == "none" ]] && frozen=0
252
+ [[ $frozen -eq 1 ]] && args+=(--before="$resolve_before")
253
+ if ! npm install --prefix "$prefix" "${args[@]}" "@deepseek-ai/dsh@$target"; then
254
+ if [[ $frozen -eq 1 ]]; then
255
+ warn "按时间点冻结解析失败,改为不冻结重试(可能装到未验证的新版本)"
256
+ local args2=(--no-audit --no-fund --loglevel=error)
257
+ [[ -n "$NPM_REGISTRY" ]] && args2+=(--registry="$NPM_REGISTRY")
258
+ npm install --prefix "$prefix" "${args2[@]}" "@deepseek-ai/dsh@$target" \
259
+ || die "npm 安装 $target 失败"
260
+ else
261
+ die "npm 安装 $target 失败"
262
+ fi
263
+ fi
264
+ [[ -f "$prefix/node_modules/@deepseek-ai/dsh/lib/bin.js" ]] || die "安装产物缺失: $prefix"
265
+ fi
266
+
267
+ # 3. 切换 + 重启
268
+ log "切换到 $target 并重启..."
269
+ switch_version "$target"
270
+
271
+ # 4. 回归自检(重试 3 次)
272
+ if wait_selfcheck 3; then
273
+ log "升级成功:$cur -> ${target}(自检通过)"
274
+ else
275
+ warn "自检失败,自动回滚到 $cur ..."
276
+ switch_version "$cur"
277
+ die "升级失败已回滚。原因见上方自检输出;可查 journalctl -u $SERVICE -n 50"
278
+ fi
279
+
280
+ # 5. 清理:保留最近 2 个版本目录
281
+ local old_dirs
282
+ old_dirs=$(ls -1d "$ROOT"/dsh/*/ 2>/dev/null | grep -v current | sed 's|/$||' | sort -V | head -n -2)
283
+ for d in $old_dirs; do
284
+ [[ "$(basename "$d")" == "$cur" ]] && continue # 保底:至少留一个可回滚目标
285
+ rm -rf "$d" && log "已清理旧版本目录: $d"
286
+ done
287
+ }
288
+
289
+ cmd_rollback() {
290
+ require_root
291
+ local cur
292
+ cur=$(current_version)
293
+ local prev
294
+ prev=$(ls -1d "$ROOT"/dsh/*/ 2>/dev/null | grep -v current | sed 's|/$||' | xargs -n1 basename 2>/dev/null \
295
+ | grep -vx "$cur" | sort -V | tail -1)
296
+ [[ -n "$prev" ]] || die "没有可回滚的版本目录"
297
+ log "回滚: $cur -> $prev"
298
+ switch_version "$prev"
299
+ sleep 3
300
+ if cmd_selfcheck >/dev/null 2>&1; then
301
+ log "回滚完成,自检通过"
302
+ else
303
+ warn "回滚后自检未通过,请查 journalctl -u $SERVICE -n 50"
304
+ fi
305
+ }
306
+
307
+ ## endregion
308
+
309
+ ## region: 启动令牌
310
+
311
+ # 生成一次性令牌并写入 state/setup.token;令牌经 $SETUP_TOKEN 返回。
312
+ new_setup_token() {
313
+ local tok
314
+ tok=$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)
315
+ if [[ ${#tok} -lt 24 ]]; then
316
+ warn "启动令牌生成失败,向导将不校验令牌"
317
+ SETUP_TOKEN=""
318
+ return 1
319
+ fi
320
+ mkdir -p "$ROOT/state"
321
+ chmod 700 "$ROOT/state"
322
+ printf '%s' "$tok" >"$ROOT/state/setup.token"
323
+ local owner
324
+ owner=$(stat -c %U "$ROOT/state" 2>/dev/null || echo dsh)
325
+ chown "$owner" "$ROOT/state/setup.token" 2>/dev/null || true
326
+ chmod 600 "$ROOT/state/setup.token"
327
+ SETUP_TOKEN="$tok"
328
+ }
329
+
330
+ # 访问地址:优先 gate.env 的 DSH_TRUSTED_HOST(--trusted-host 的实际取值),回退 config.json
331
+ current_host() {
332
+ local host=""
333
+ if [[ -f "$ROOT/state/gate.env" ]]; then
334
+ host=$(sed -n 's/^DSH_TRUSTED_HOST=//p' "$ROOT/state/gate.env" | head -1)
335
+ host=${host%\"}; host=${host#\"}
336
+ host=${host%\'}; host=${host#\'}
337
+ fi
338
+ if [[ -z "$host" && -f "$ROOT/state/config.json" ]]; then
339
+ host=$(sed -n 's/.*"domain"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/state/config.json" | head -1)
340
+ fi
341
+ printf '%s' "$host"
342
+ }
343
+
344
+ cmd_setup_url() {
345
+ require_root
346
+ local host token=""
347
+ host=$(current_host)
348
+ [[ -n "$host" ]] || die "无法确定访问地址($ROOT/state/gate.env 缺失);可手工拼接 https://<你的域名>/setup?token=<令牌>"
349
+ [[ -f "$ROOT/state/setup.token" ]] && token=$(tr -d '[:space:]' <"$ROOT/state/setup.token")
350
+ if [[ -z "$token" ]]; then
351
+ if [[ -f "$ROOT/state/setup.lock" ]]; then
352
+ log "初始设置已完成,无需令牌。直接打开 https://$host 登录。"
353
+ else
354
+ warn "未找到启动令牌($ROOT/state/setup.token),向导当前不校验令牌;直接打开 https://$host 设置。"
355
+ fi
356
+ return 0
357
+ fi
358
+ log "初始设置链接(令牌一次性,设置完成后自动失效):"
359
+ echo " https://$host/setup?token=$token"
360
+ }
361
+
362
+ ## endregion
363
+
364
+ ## region: reset-admin
365
+
366
+ cmd_reset_admin() {
367
+ require_root
368
+ local assume_yes=0
369
+ [[ "${1:-}" == "--yes" ]] && assume_yes=1
370
+ if [[ $assume_yes -eq 0 ]]; then
371
+ warn "将删除管理员账号与向导锁(admin.json + setup.lock),之后需重新走浏览器向导。"
372
+ read -rp "确认继续?[y/N] " ans
373
+ [[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
374
+ fi
375
+ rm -f "$ROOT/state/admin.json" "$ROOT/state/setup.lock"
376
+ # 重开向导等于重新暴露 /setup:必须换发令牌,否则旧令牌(可能已泄露)仍然有效
377
+ new_setup_token || true
378
+ log "已重置,并已换发启动令牌。"
379
+ cmd_setup_url
380
+ }
381
+
382
+ ## endregion
383
+
384
+ ## region: restart
385
+
386
+ cmd_restart() {
387
+ require_root
388
+ systemctl restart "$SERVICE"
389
+ log "已重启 ${SERVICE}(DSH 子进程将自动重启并重新兑换会话)"
390
+ }
391
+
392
+ ## endregion
393
+
394
+ ## region: ownshost
395
+
396
+ # 公网域名下 DSH 前端按 location.hostname 判定 connection.isLoopback=false,
397
+ # 设置镜像随即退化为 memory 持久化,设置页报"设置在此浏览器中不可用",
398
+ # 填 Key、改权限全部废掉。官方预留的 __DSH_TRANSPORT__.ownsHost 声明可解除
399
+ # 该判定(dsh-client-connection: isLoopback = transport?.ownsHost === true || ...)。
400
+ #
401
+ # 早先走的是 gate 代理改写 HTML,已经证明太脆:压缩响应、304、超大 body
402
+ # 任意一个条件不满足就静默失效,且外部完全看不出来。改为直接写前端静态文件:
403
+ # dsh-host-frontend-static 对每个 index 响应都 readFile(dist/index.html),
404
+ # 改完即刻生效,不依赖代理、不怕缓存、不怕压缩。
405
+ ownshost_index() { echo "$ROOT/dsh/current/node_modules/@deepseek-ai/dsh-web-frontend/dist/index.html"; }
406
+
407
+ ownshost_apply() { # $1=on|off;stdout: patched / present / off
408
+ local mode="${1:-on}"
409
+ local idx
410
+ idx=$(ownshost_index)
411
+ if [[ ! -f "$idx" ]]; then
412
+ warn "未找到前端 index.html(${idx}),跳过 ownsHost 补丁"
413
+ return 1
414
+ fi
415
+ local tmp rc=0
416
+ tmp=$(mktemp "${TMPDIR:-/tmp}/dshvps-ownshost.XXXXXX.cjs")
417
+ cat >"$tmp" <<'NODE'
418
+ const fs = require("fs");
419
+ const [file, mode] = process.argv.slice(2);
420
+ const MARK = "dsh-vps:ownshost";
421
+ const SNIPPET =
422
+ '<script data-dsh-vps="ownshost">/* ' + MARK +
423
+ " */window.__DSH_TRANSPORT__=Object.assign(window.__DSH_TRANSPORT__||{},{ownsHost:true});</script>";
424
+ let s = fs.readFileSync(file, "utf8");
425
+ const orig = file + ".orig";
426
+ if (mode === "off") {
427
+ if (fs.existsSync(orig)) fs.copyFileSync(orig, file);
428
+ else fs.writeFileSync(file, s.split("\n").filter((l) => !l.includes(MARK)).join("\n"));
429
+ console.log("off");
430
+ process.exit(0);
431
+ }
432
+ if (s.includes(MARK)) {
433
+ console.log("present");
434
+ process.exit(0);
435
+ }
436
+ if (!fs.existsSync(orig)) fs.copyFileSync(file, orig);
437
+ const m = s.match(/<head(?:\s[^>]*)?>/i);
438
+ if (m === null) {
439
+ console.error("no <head> found in " + file);
440
+ process.exit(1);
441
+ }
442
+ const at = m.index + m[0].length;
443
+ fs.writeFileSync(file, s.slice(0, at) + "\n " + SNIPPET + s.slice(at));
444
+ console.log("patched");
445
+ NODE
446
+ node "$tmp" "$idx" "$mode" || rc=$?
447
+ rm -f "$tmp"
448
+ return $rc
449
+ }
450
+
451
+ cmd_ownshost() {
452
+ local mode="${1:-status}"
453
+ local idx
454
+ idx=$(ownshost_index)
455
+ case "$mode" in
456
+ on)
457
+ require_root
458
+ local out
459
+ out=$(ownshost_apply on) || { warn "ownsHost 补丁失败(见上方错误)"; return 1; }
460
+ if [[ "$out" == "patched" ]]; then
461
+ log "已写入 ownsHost 声明(原件备份为 index.html.orig)"
462
+ log "无需重启:DSH 每次响应都重新读取该文件。浏览器硬刷新一次即可。"
463
+ else
464
+ log "ownsHost 声明已存在,无需重复写入"
465
+ fi
466
+ ;;
467
+ off)
468
+ require_root
469
+ ownshost_apply off >/dev/null || { warn "撤销失败"; return 1; }
470
+ log "已撤销 ownsHost 声明(设置页回到官方默认的不可用状态)"
471
+ ;;
472
+ status)
473
+ if [[ ! -f "$idx" ]]; then
474
+ echo " ownsHost 补丁: 未找到 $idx"
475
+ return 1
476
+ fi
477
+ if DSH_VPS_IDX="$idx" node -e 'process.exit(require("fs").readFileSync(process.env.DSH_VPS_IDX,"utf8").includes("dsh-vps:ownshost")?0:1)'; then
478
+ echo " ownsHost 补丁: 已生效"
479
+ else
480
+ echo " ownsHost 补丁: 未生效 → sudo dsh-vps ownshost on"
481
+ fi
482
+ ;;
483
+ *) die "用法: dsh-vps ownshost [on|off|status]" ;;
484
+ esac
485
+ }
486
+
487
+ ## endregion
488
+
489
+ ## region: 仅我可访问
490
+
491
+ # 两种办法,目标一致:让公网访问不到登录页。
492
+ #
493
+ # 1) vpn WireGuard 自建隧道。给每台设备发一个我们分配的隧道地址(10.7.0.x),
494
+ # 服务端只放行这个网段。出口 IP 怎么变都不影响——它根本不参与判断。
495
+ # 2) tunnel SSH 本地端口转发。零安装,适合只在电脑上用的场景。
496
+ #
497
+ # 不做公网出口 IP 白名单:出口 IP 不是身份,换网络 / 宽带重拨就变,会把使用者
498
+ # 自己关在门外。隧道地址由我们分配,永不变化。
499
+ #
500
+ # 分离隧道:不开 ip_forward、不做 NAT。这台机器只提供 DSH 访问通道,不是全流量 VPN。
501
+
502
+ VPN_IF="wg0"
503
+ VPN_SUBNET_DEFAULT="10.7.0.0/24"
504
+ VPN_PORT_DEFAULT=51820
505
+ VPN_DIR="$ROOT/state/vpn" # 服务端密钥 + 每台设备的记录与客户端配置
506
+ VPN_ENV="$ROOT/state/vpn.env" # DSHVPS_VPN=on|off / DSHVPS_VPN_SUBNET
507
+ WG_DIR="/etc/wireguard"
508
+ WG_CONF="$WG_DIR/$VPN_IF.conf"
509
+
510
+ vpn_env() { # 输出 on|off
511
+ if [[ -f "$VPN_ENV" ]] && grep -qx 'DSHVPS_VPN=on' "$VPN_ENV"; then
512
+ printf 'on'
513
+ else
514
+ printf 'off'
515
+ fi
516
+ }
517
+
518
+ vpn_subnet() {
519
+ local s=""
520
+ [[ -f "$VPN_ENV" ]] && s=$(sed -n 's/^DSHVPS_VPN_SUBNET=//p' "$VPN_ENV" | head -1)
521
+ printf '%s' "${s:-$VPN_SUBNET_DEFAULT}"
522
+ }
523
+
524
+ vpn_listen_port() {
525
+ local p=""
526
+ [[ -f "$WG_CONF" ]] && p=$(sed -n 's/^[[:space:]]*ListenPort[[:space:]]*=[[:space:]]*//p' "$WG_CONF" | head -1)
527
+ printf '%s' "${p:-$VPN_PORT_DEFAULT}"
528
+ }
529
+
530
+ # state/ 属主通常是 dsh(gate 运行身份)。vpn.env 由 root 写入后要交还属主,
531
+ # 否则向导改域名时 gate 读不到,隧道策略会被静默丢掉。
532
+ vpn_state_owner() { stat -c %U "$ROOT/state" 2>/dev/null || echo dsh; }
533
+
534
+ vpn_set_mode() { # $1=on|off
535
+ local mode="$1" owner
536
+ owner=$(vpn_state_owner)
537
+ mkdir -p "$ROOT/state"
538
+ printf 'DSHVPS_VPN=%s\nDSHVPS_VPN_SUBNET=%s\n' "$mode" "$(vpn_subnet)" >"$VPN_ENV"
539
+ chown "$owner" "$VPN_ENV" 2>/dev/null || true
540
+ chmod 600 "$VPN_ENV"
541
+ }
542
+
543
+ site_block_js() {
544
+ if [[ ! -f "$ROOT/gate/site-block.js" ]]; then
545
+ log "缺少 gate/site-block.js,拉取一份..."
546
+ curl -fsSL --max-time 30 "$RAW_BASE/gate/site-block.js" -o "$ROOT/gate/site-block.js" \
547
+ || die "拉取失败(离线?可稍后运行: sudo dsh-vps update-gate)"
548
+ chmod 644 "$ROOT/gate/site-block.js"
549
+ fi
550
+ printf '%s' "$ROOT/gate/site-block.js"
551
+ }
552
+
553
+ # 重写 Caddy 站点块并热加载。root 运行,直接 systemctl reload,不必绕 admin API。
554
+ vpn_apply_site() {
555
+ require_root
556
+ local host js
557
+ host=$(current_host)
558
+ [[ -n "$host" ]] || die "无法确定站点地址($ROOT/state/gate.env 缺失)"
559
+ js=$(site_block_js)
560
+ node "$js" "$host" "$ROOT" "$GATE_PORT" >/etc/caddy/dsh-site.conf || die "生成站点块失败"
561
+ chown "$(vpn_state_owner)":caddy /etc/caddy/dsh-site.conf 2>/dev/null || true
562
+ chmod 640 /etc/caddy/dsh-site.conf
563
+ systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy >/dev/null 2>&1 || true
564
+ }
565
+
566
+ vpn_endpoint_host() { # 客户端 Endpoint 用的主机名:域名优先,其次公网 IP
567
+ local h
568
+ h=$(current_host)
569
+ if [[ -z "$h" ]]; then
570
+ h=$(curl -4 -fsSL --max-time 5 https://api.ipify.org 2>/dev/null | tr -d '[:space:]' || true)
571
+ fi
572
+ [[ -n "$h" ]] || h=$(hostname -I 2>/dev/null | awk '{print $1}')
573
+ printf '%s' "$h"
574
+ }
575
+
576
+ vpn_next_ip() { # 输出下一个未占用的隧道地址(如 10.7.0.2)
577
+ local subnet prefix last=1 f addr oct
578
+ subnet=$(vpn_subnet)
579
+ prefix="${subnet%.*}."
580
+ for f in "$VPN_DIR"/peers/*.env; do
581
+ [[ -f "$f" ]] || continue
582
+ addr=$(sed -n 's/^ADDRESS=//p' "$f" | head -1)
583
+ addr=${addr%/*}
584
+ oct=${addr##*.}
585
+ if [[ "$oct" =~ ^[0-9]+$ ]] && ((oct > last)); then last=$oct; fi
586
+ done
587
+ printf '%s%s' "$prefix" "$((last + 1))"
588
+ }
589
+
590
+ # 增量同步给内核,不重启接口(重启会掐掉已连着的设备)
591
+ vpn_wg_syncconf() {
592
+ wg syncconf "$VPN_IF" <(wg-quick strip "$WG_CONF") \
593
+ || die "同步隧道配置失败(wg syncconf);可尝试 systemctl restart wg-quick@$VPN_IF"
594
+ }
595
+
596
+ vpn_conf_drop_peer() { # $1=名字:按注释标记整段移除 [Peer]
597
+ local tmp
598
+ tmp=$(mktemp "${TMPDIR:-/tmp}/dshvps-wg.XXXXXX")
599
+ awk -v want="# dsh-vps peer: $1" '
600
+ /^\[/ { flush() }
601
+ { buf[++n] = $0 }
602
+ END { flush() }
603
+ function flush( i, keep) {
604
+ if (n == 0) return
605
+ keep = 1
606
+ for (i = 1; i <= n; i++) if (index(buf[i], want) > 0) keep = 0
607
+ if (keep) for (i = 1; i <= n; i++) print buf[i]
608
+ n = 0
609
+ }
610
+ ' "$WG_CONF" >"$tmp"
611
+ mv "$tmp" "$WG_CONF"
612
+ chmod 600 "$WG_CONF"
613
+ }
614
+
615
+ vpn_peer_conf() { # $1=名字 $2=地址 $3=客户端私钥 $4=服务端公钥
616
+ local subnet
617
+ subnet=$(vpn_subnet)
618
+ cat <<EOF
619
+ # dsh-vps 客户端配置 · $1
620
+ # 分离隧道:只有访问 $subnet 会走这台 VPS,其余流量照常走本地网络。
621
+ [Interface]
622
+ PrivateKey = $3
623
+ Address = $2/32
624
+
625
+ [Peer]
626
+ PublicKey = $4
627
+ Endpoint = $(vpn_endpoint_host):$(vpn_listen_port)
628
+ AllowedIPs = $subnet
629
+ PersistentKeepalive = 25
630
+ EOF
631
+ }
632
+
633
+ vpn_show_conf() { # $1=名字 $2=配置文件路径
634
+ local conf="$2"
635
+ echo
636
+ echo "------------------------------------------------------------"
637
+ echo " 设备 $1 的客户端配置(同时保存在 ${conf})"
638
+ echo "------------------------------------------------------------"
639
+ cat "$conf"
640
+ echo "------------------------------------------------------------"
641
+ if command -v qrencode >/dev/null 2>&1; then
642
+ echo " 手机 WireGuard App 扫码导入:"
643
+ qrencode -t ansiutf8 -r "$conf"
644
+ else
645
+ warn "未装 qrencode,想扫码导入可:apt-get install -y qrencode"
646
+ fi
647
+ }
648
+
649
+ cmd_vpn_add() {
650
+ require_root
651
+ local name="${1:-}"
652
+ [[ -n "$name" ]] || die "用法: sudo dsh-vps vpn add <设备名> 例:sudo dsh-vps vpn add macbook"
653
+ [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || die "设备名只能用字母、数字、-、_:$name"
654
+ [[ -d "$VPN_DIR" ]] || die "尚未初始化隧道:sudo dsh-vps vpn setup"
655
+ [[ -f "$VPN_DIR/peers/$name.env" ]] && die "设备已存在:${name}(先 sudo dsh-vps vpn revoke ${name})"
656
+
657
+ local srv_pub ip priv pub
658
+ srv_pub=$(tr -d '[:space:]' <"$VPN_DIR/server.pub")
659
+ ip=$(vpn_next_ip)
660
+ mkdir -p "$VPN_DIR/peers"
661
+ chmod 700 "$VPN_DIR" "$VPN_DIR/peers"
662
+ priv=$(wg genkey)
663
+ pub=$(printf '%s' "$priv" | wg pubkey)
664
+
665
+ printf 'ADDRESS=%s/32\nPUBLIC_KEY=%s\nCREATED_AT=%s\n' \
666
+ "$ip" "$pub" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$VPN_DIR/peers/$name.env"
667
+ chmod 600 "$VPN_DIR/peers/$name.env"
668
+
669
+ # 名字标记写在 [Peer] 之后:吊销时按「从上一个 [ 到下一个 [ 之前」整段删除,
670
+ # 标记若在 [Peer] 之前会被算进上一段,删错对象。
671
+ printf '\n[Peer]\n# dsh-vps peer: %s\nPublicKey = %s\nAllowedIPs = %s/32\n' \
672
+ "$name" "$pub" "$ip" >>"$WG_CONF"
673
+ vpn_wg_syncconf
674
+
675
+ local conf="$VPN_DIR/peers/$name.conf"
676
+ vpn_peer_conf "$name" "$ip" "$priv" "$srv_pub" >"$conf"
677
+ chmod 600 "$conf"
678
+ vpn_show_conf "$name" "$conf"
679
+ log "隧道地址 ${ip}(含私钥,$conf 权限 600;丢了就 revoke 后重新签发)"
680
+ }
681
+
682
+ cmd_vpn_revoke() {
683
+ require_root
684
+ local name="${1:-}"
685
+ [[ -n "$name" ]] || die "用法: sudo dsh-vps vpn revoke <设备名>"
686
+ [[ -f "$VPN_DIR/peers/$name.env" ]] || die "没有这台设备:$name"
687
+ vpn_conf_drop_peer "$name"
688
+ rm -f "$VPN_DIR/peers/$name.env" "$VPN_DIR/peers/$name.conf"
689
+ vpn_wg_syncconf
690
+ log "已吊销 ${name}(它的隧道立即失效)"
691
+ }
692
+
693
+ cmd_vpn_list() {
694
+ # 中文标题按字节算宽度,用 %-Ns 对齐反而错位,交给制表符
695
+ printf ' 设备\t隧道地址\t最近握手\n'
696
+ local found=0 handshakes="" f
697
+ if command -v wg >/dev/null 2>&1; then
698
+ handshakes=$(wg show "$VPN_IF" latest-handshakes 2>/dev/null || true)
699
+ fi
700
+ for f in "$VPN_DIR"/peers/*.env; do
701
+ [[ -f "$f" ]] || continue
702
+ found=1
703
+ local name addr pub hs when="从未"
704
+ name=$(basename "$f" .env)
705
+ addr=$(sed -n 's/^ADDRESS=//p' "$f" | head -1)
706
+ pub=$(sed -n 's/^PUBLIC_KEY=//p' "$f" | head -1)
707
+ hs=$(printf '%s\n' "$handshakes" | awk -v k="$pub" '$1==k {print $2; exit}')
708
+ if [[ -n "$hs" && "$hs" != "0" ]]; then
709
+ when=$(date -d "@$hs" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$hs")
710
+ fi
711
+ printf ' %s\t%s\t%s\n' "$name" "$addr" "$when"
712
+ done
713
+ [[ $found -eq 1 ]] || echo " (还没有签发设备:sudo dsh-vps vpn add <名字>)"
714
+ }
715
+
716
+ cmd_vpn_status() {
717
+ echo "== 访问策略 =="
718
+ if [[ "$(vpn_env)" == "on" ]]; then
719
+ echo " 仅隧道可访问:Caddy 只放行 $(vpn_subnet),其他来源直接断连"
720
+ else
721
+ echo " 公网可访问:任何来源都能打开登录页(凭用户名密码)"
722
+ fi
723
+ echo
724
+ echo "== 隧道接口 =="
725
+ if [[ -d "/sys/class/net/$VPN_IF" ]]; then
726
+ echo " $VPN_IF 已启用(监听 UDP $(vpn_listen_port))"
727
+ wg show "$VPN_IF" 2>/dev/null | sed 's/^/ /'
728
+ else
729
+ echo " $VPN_IF 未启用(sudo dsh-vps vpn setup)"
730
+ fi
731
+ echo
732
+ echo "== 已签发设备 =="
733
+ cmd_vpn_list
734
+ }
735
+
736
+ cmd_vpn_mode() {
737
+ local mode="${1:-}"
738
+ [[ "$mode" == "on" || "$mode" == "off" ]] || die "用法: sudo dsh-vps vpn on|off"
739
+ require_root
740
+ if [[ "$mode" == "on" && ! -f "$WG_CONF" ]]; then
741
+ die "隧道还没建:sudo dsh-vps vpn setup"
742
+ fi
743
+ vpn_set_mode "$mode"
744
+ vpn_apply_site
745
+ if [[ "$mode" == "on" ]]; then
746
+ log "已切为「仅隧道可访问」:公网打不开登录页,隧道里的设备照常访问"
747
+ else
748
+ log "已切为「公网可访问」:隧道还能继续用,只是不再强制"
749
+ fi
750
+ }
751
+
752
+ cmd_vpn_setup() {
753
+ require_root
754
+ local name="${1:-device1}"
755
+ local subnet port addr srv_pub
756
+ subnet=$(vpn_subnet)
757
+ port=$VPN_PORT_DEFAULT
758
+
759
+ if ! command -v wg >/dev/null 2>&1; then
760
+ log "安装 WireGuard..."
761
+ apt-get update -y >/dev/null
762
+ apt-get install -y wireguard >/dev/null || die "WireGuard 安装失败"
763
+ fi
764
+
765
+ mkdir -p "$VPN_DIR/peers"
766
+ chmod 700 "$VPN_DIR" "$VPN_DIR/peers"
767
+
768
+ # 服务端密钥只生成一次:重跑 setup 不换密钥,已签发的设备不受影响
769
+ if [[ ! -f "$VPN_DIR/server.key" ]]; then
770
+ (umask 077 && wg genkey >"$VPN_DIR/server.key")
771
+ chmod 600 "$VPN_DIR/server.key"
772
+ fi
773
+ wg pubkey <"$VPN_DIR/server.key" >"$VPN_DIR/server.pub"
774
+ srv_pub=$(tr -d '[:space:]' <"$VPN_DIR/server.pub")
775
+
776
+ addr="${subnet%.*}.1"
777
+ if [[ ! -f "$WG_CONF" ]]; then
778
+ mkdir -p "$WG_DIR"
779
+ chmod 700 "$WG_DIR"
780
+ cat >"$WG_CONF" <<EOF
781
+ # dsh-vps 隧道接口:给这台机器上的 DSH 开一条私有通道。
782
+ # 分离隧道——不开 IP 转发、不做 NAT。它不是全流量 VPN,只服务 DSH。
783
+ [Interface]
784
+ Address = $addr/24
785
+ ListenPort = $port
786
+ PrivateKey = $(tr -d '[:space:]' <"$VPN_DIR/server.key")
787
+ SaveConfig = false
788
+ EOF
789
+ chmod 600 "$WG_CONF"
790
+ elif ! grep -q "dsh-vps" "$WG_CONF"; then
791
+ # 不接管别人的 wg0:往里追加设备会改掉你原有的隧道,卸载时也分不清该不该删
792
+ die "${WG_CONF} 已存在且不是 dsh-vps 创建的(你自己的 WireGuard?)。为避免改动它,本命令已停止。"
793
+ else
794
+ log "已有 ${WG_CONF},保留接口与已签发的设备"
795
+ fi
796
+
797
+ systemctl enable --now "wg-quick@$VPN_IF" >/dev/null 2>&1 \
798
+ || die "启动 wg-quick@$VPN_IF 失败:journalctl -u wg-quick@$VPN_IF -n 50"
799
+
800
+ if command -v ufw >/dev/null 2>&1; then
801
+ ufw allow "$port"/udp >/dev/null && log "ufw 已放行 $port/udp"
802
+ fi
803
+
804
+ vpn_set_mode on
805
+ vpn_apply_site
806
+ log "已切为「仅隧道可访问」:公网访问登录页会直接断连"
807
+
808
+ cmd_vpn_add "$name"
809
+
810
+ echo
811
+ echo "============================================================"
812
+ echo " 隧道已就绪"
813
+ echo "------------------------------------------------------------"
814
+ echo " 服务端隧道地址 : ${addr}(网段 ${subnet})"
815
+ echo " 监听 : UDP $port"
816
+ echo "------------------------------------------------------------"
817
+ echo " 1. 云厂商安全组放行 UDP ${port}(多数连不上都是卡在这一步)"
818
+ echo " 2. 设备装 WireGuard 官方 App,导入上面这份配置"
819
+ echo " 3. 连上后访问 https://$(vpn_endpoint_host)"
820
+ echo "------------------------------------------------------------"
821
+ echo " 隧道连不上或设备丢了?SSH 登录后:sudo dsh-vps vpn off"
822
+ echo " (公网立即恢复,隧道配置原样保留,修好再 vpn on)"
823
+ echo " 临时访问不想装东西:sudo dsh-vps tunnel(SSH 本地转发)"
824
+ echo "============================================================"
825
+ }
826
+
827
+ cmd_vpn() {
828
+ local sub="${1:-status}"
829
+ shift || true
830
+ case "$sub" in
831
+ setup) cmd_vpn_setup "$@" ;;
832
+ add) cmd_vpn_add "$@" ;;
833
+ revoke) cmd_vpn_revoke "$@" ;;
834
+ list) cmd_vpn_list "$@" ;;
835
+ status) cmd_vpn_status "$@" ;;
836
+ on) cmd_vpn_mode on ;;
837
+ off) cmd_vpn_mode off ;;
838
+ -h | --help) cmd_vpn_help ;;
839
+ *) cmd_vpn_help; die "未知动作: $sub" ;;
840
+ esac
841
+ }
842
+
843
+ cmd_vpn_help() {
844
+ cat <<'EOF'
845
+ 用法: sudo dsh-vps vpn <动作>
846
+ setup [名字] 安装 WireGuard、建隧道、签发第一台设备,并切到「仅隧道可访问」
847
+ add <名字> 再签发一台设备(打印配置与二维码)
848
+ revoke <名字> 吊销设备,立即失效
849
+ list 列出设备与最近握手时间
850
+ status 访问策略 + 隧道状态 + 设备
851
+ on | off 切换「仅隧道可访问」/「公网可访问」
852
+ EOF
853
+ }
854
+
855
+ cmd_tunnel() {
856
+ local host
857
+ host=$(vpn_endpoint_host)
858
+ cat <<EOF
859
+ 零安装方案:SSH 本地端口转发。不装任何东西,效果同样是「只有你能访问 DSH」。
860
+
861
+ 1. 本机开一个窗口执行(关掉窗口隧道就没了):
862
+ ssh -N -L $GATE_PORT:127.0.0.1:$GATE_PORT root@$host
863
+
864
+ 2. 浏览器打开:
865
+ http://127.0.0.1:$GATE_PORT
866
+
867
+ 写进 ~/.ssh/config 之后一条命令:
868
+
869
+ Host dsh
870
+ HostName $host
871
+ User root
872
+ LocalForward $GATE_PORT 127.0.0.1:$GATE_PORT
873
+ ServerAliveInterval 30
874
+
875
+ 之后只要:ssh -N dsh
876
+
877
+ 说明:
878
+ · 只把本机 $GATE_PORT 转发到服务器上同样的端口;断开即失效
879
+ · 公网那扇门仍然开着(要彻底关上用 sudo dsh-vps vpn setup)
880
+ · 手机用不了这种方式,要常连就用 WireGuard 隧道
881
+ EOF
882
+ }
883
+
884
+ ## endregion
885
+
886
+ usage() {
887
+ cat <<'EOF'
888
+ dsh-vps 管理命令
889
+ 用法: sudo dsh-vps <子命令>
890
+ status 服务状态 + 健康 + 版本提示
891
+ restart 重启 gate(DSH 随之重启并重新兑换会话)
892
+ upgrade [version] 升级 DSH 版本;--latest [--yes] 强上 npm 最新版
893
+ update-gate 更新 gate 自身代码(gate/server.js)并重启
894
+ ownshost [on|off|status] 设置页可用性补丁(公网域名下解除"设置不可用")
895
+ rollback 切回上一 DSH 版本(秒级,不依赖网络)
896
+ reset-admin [--yes] 删除管理员与向导锁,重新走初始设置(换发令牌)
897
+ setup-url 打印初始设置链接(含一次性令牌)
898
+ backup 备份数据到 /opt/dsh-vps/backups/(保留 3 份)
899
+ selfcheck 手动触发回归自检探针
900
+ vpn ... 仅我可访问:WireGuard 隧道(setup/add/revoke/list/status/on/off)
901
+ tunnel 仅我可访问:SSH 本地端口转发(零安装,打印用法)
902
+ EOF
903
+ }
904
+
905
+ case "${1:-}" in
906
+ status) cmd_status ;;
907
+ restart) cmd_restart ;;
908
+ upgrade) shift; cmd_upgrade "$@" ;;
909
+ update-gate) cmd_update_gate ;;
910
+ ownshost) shift; cmd_ownshost "$@" ;;
911
+ rollback) cmd_rollback ;;
912
+ reset-admin) shift; cmd_reset_admin "$@" ;;
913
+ setup-url) cmd_setup_url ;;
914
+ backup) cmd_backup ;;
915
+ selfcheck) cmd_selfcheck ;;
916
+ vpn) shift; cmd_vpn "$@" ;;
917
+ tunnel) cmd_tunnel ;;
918
+ "" | -h | --help) usage ;;
919
+ *) usage; die "未知子命令: $1" ;;
920
+ esac