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/LICENSE +21 -0
- package/README.en.md +215 -0
- package/README.md +225 -0
- package/bin/dsh-vps +920 -0
- package/caddy/Caddyfile.template +10 -0
- package/gate/server.js +1718 -0
- package/gate/site-block.js +96 -0
- package/install.sh +504 -0
- package/package.json +57 -0
- package/scripts/bootstrap.mjs +54 -0
- package/uninstall.sh +196 -0
- package/units/dsh-gate.service +43 -0
- package/versions.json +13 -0
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
|