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.
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /*
5
+ * Caddy 站点块的唯一模板。
6
+ *
7
+ * 三处共用同一份,任何改动只改这里:
8
+ * - install.sh 首次写入 /etc/caddy/dsh-site.conf
9
+ * - gate/server.js 浏览器向导改域名时重写
10
+ * - bin/dsh-vps vpn on/off 切换访问策略
11
+ *
12
+ * 隧道模式(state/vpn.env 里 DSHVPS_VPN=on):Caddy 只放行隧道网段,其余来源 abort。
13
+ * 为什么不用公网出口 IP 白名单:出口 IP 不是身份,换网络 / 宽带重拨就变,会把使用者
14
+ * 自己关在门外。隧道 IP 由我们自己分配(WireGuard),永不变化。
15
+ *
16
+ * CLI: node site-block.js <host> [installRoot] [gatePort]
17
+ */
18
+
19
+ const fs = require("node:fs");
20
+ const net = require("node:net");
21
+ const path = require("node:path");
22
+
23
+ const DEFAULT_SUBNET = "10.7.0.0/24";
24
+ const DEFAULT_GATE_PORT = 3100;
25
+
26
+ /** 读 state/vpn.env → { on, subnet }。文件不存在即未启用隧道。 */
27
+ function readVpnEnv(stateDir) {
28
+ const out = { on: false, subnet: DEFAULT_SUBNET };
29
+ try {
30
+ const txt = fs.readFileSync(path.join(stateDir, "vpn.env"), "utf8");
31
+ if (/^\s*DSHVPS_VPN\s*=\s*on\s*$/m.test(txt)) out.on = true;
32
+ const m = txt.match(/^\s*DSHVPS_VPN_SUBNET\s*=\s*(\S+)\s*$/m);
33
+ if (m) out.subnet = m[1];
34
+ } catch {
35
+ /* 未启用隧道 */
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function proxyLines(port, indent) {
41
+ return (
42
+ `${indent}reverse_proxy 127.0.0.1:${port} {\n` +
43
+ // 只信 Caddy 自己看到的对端地址:显式覆盖,客户端伪造的 X-Forwarded-For 一律作废。
44
+ // gate 的登录限流与审计日志都读这个头,能被伪造就等于把限流关掉。
45
+ `${indent}\theader_up X-Forwarded-For {remote_host}\n` +
46
+ `${indent}}\n`
47
+ );
48
+ }
49
+
50
+ /**
51
+ * @param {string} host 站点地址(域名 / IP / :443)
52
+ * @param {{gatePort?: number, vpn?: {on: boolean, subnet: string}}} opts
53
+ */
54
+ /** 站点地址是否为裸 IP:公网 CA 不给 IP 签证书(至少不稳定),只能由 Caddy 内置 CA 签。 */
55
+ function isIpHost(host) {
56
+ return net.isIP(String(host).replace(/^\[|\]$/g, "")) !== 0;
57
+ }
58
+
59
+ function caddySiteBlock(host, opts = {}) {
60
+ const port = opts.gatePort || DEFAULT_GATE_PORT;
61
+ const vpn = opts.vpn || { on: false, subnet: DEFAULT_SUBNET };
62
+ // IP 站点:浏览器按 IP 访问时不发 SNI,Caddy 会退而按本机网卡地址找证书;
63
+ // 云主机网卡上多是内网地址(公网 IP 经 NAT),于是找不到证书、握手失败。
64
+ // default_sni 让无 SNI 的连接按公网 IP 选证书。本文件被主 Caddyfile 第一行 import,
65
+ // 全局选项块因此仍位于配置开头,合法。
66
+ const ip = isIpHost(host);
67
+ const globals = ip ? `{\n\tdefault_sni ${host}\n}\n\n` : "";
68
+ const tls = ip ? "\ttls internal\n" : "";
69
+ if (!vpn.on) return `${globals}${host} {\n${tls}${proxyLines(port, "\t")}}\n`;
70
+ // 隧道模式:非隧道来源直接断连,连响应体都不给。
71
+ // ACME HTTP-01 挑战由 Caddy 在路由之前处理(fall-through),不受本块影响,证书照常续期。
72
+ return (
73
+ globals +
74
+ `${host} {\n` +
75
+ tls +
76
+ `\t@tunnel remote_ip ${vpn.subnet}\n` +
77
+ `\thandle @tunnel {\n` +
78
+ proxyLines(port, "\t\t") +
79
+ `\t}\n` +
80
+ `\thandle {\n\t\tabort\n\t}\n` +
81
+ `}\n`
82
+ );
83
+ }
84
+
85
+ module.exports = { caddySiteBlock, readVpnEnv, isIpHost, DEFAULT_SUBNET, DEFAULT_GATE_PORT };
86
+
87
+ if (require.main === module) {
88
+ const host = process.argv[2];
89
+ const root = process.argv[3] || process.env.GATE_HOME || "/opt/dsh-vps";
90
+ const gatePort = Number(process.argv[4] || process.env.GATE_PORT || DEFAULT_GATE_PORT);
91
+ if (!host) {
92
+ console.error("用法: node site-block.js <host> [installRoot] [gatePort]");
93
+ process.exit(2);
94
+ }
95
+ process.stdout.write(caddySiteBlock(host, { gatePort, vpn: readVpnEnv(path.join(root, "state")) }));
96
+ }
package/install.sh ADDED
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env bash
2
+ # dsh-vps 一键安装脚本(M2)
3
+ #
4
+ # 用法:
5
+ # curl -fsSL https://raw.githubusercontent.com/AIcivilization/deepseek-harness-vps/main/install.sh \
6
+ # | sudo bash -s -- --domain dsh.example.com
7
+ # # 或在仓库克隆目录内:
8
+ # sudo bash install.sh --domain dsh.example.com [--mirror cn]
9
+ #
10
+ # 目标 OS:Ubuntu 22.04+ / Debian 12+(裸机,无 Docker)
11
+ # 设计依据:dsh-vps-architecture-design.md §4.1 / §4.3 / §4.4 / §5 / §10
12
+ #
13
+ # 说明:仓库已公开,curl 管道模式可直接拉取 gate 代码;若在克隆目录内运行则优先用本地文件。
14
+
15
+ set -euo pipefail
16
+
17
+ ## region: 常量与参数
18
+
19
+ DSH_VERSION="0.1.5-rc.2" # 钉住版本(设计文档 §10 已验证版本表,勿随意改)
20
+ # 只钉顶层包版本是不够的:DSH 各子包的依赖是 ^0.1.5-rc.2 这类浮动范围,上游一发新的
21
+ # 预发布波次,解析结果就整体漂上去。2026-09-22 上游发了 0.1.5-rc.3 波次,但漏发了
22
+ # dsh-client-ui-sidebar-documentpreview,于是 ETARGET 装不上。用 --before 把解析冻结在
23
+ # 验证通过的那个时间点之前,装到的就是验证过的那棵树。升级 DSH_VERSION 时同步推后这个值。
24
+ # 注意:同一天 03:46 上游还先发了一批 cordis 系列(cordis 4.0.3 / cordis-plugin-hmr 1.0.18 等),
25
+ # 与 rc.2 不兼容——web profile 启动即报 "user patch-layer watching requires the Cordis HMR service"。
26
+ # 冻结点必须早于这一批,所以是 03:40 而不是 05:00。
27
+ # 设为 none 可关闭冻结。
28
+ DSH_RESOLVE_BEFORE="${DSH_RESOLVE_BEFORE:-2026-09-22T03:40:00Z}"
29
+ INSTALL_ROOT="/opt/dsh-vps"
30
+ DSH_USER="dsh"
31
+ DSH_HOME_DIR="/home/dsh/.dsh"
32
+ GATE_PORT=3100
33
+ DSH_PORT=3080
34
+ # gate/unit/caddy 模板来源:优先脚本同目录(仓库克隆),否则从 raw 地址下载
35
+ RAW_BASE="${DSHVPS_RAW_BASE:-https://raw.githubusercontent.com/AIcivilization/deepseek-harness-vps/main}"
36
+
37
+ DOMAIN=""
38
+ MIRROR=""
39
+
40
+ log() { printf '\033[1;32m[install]\033[0m %s\n' "$*"; }
41
+ warn() { printf '\033[1;33m[install]\033[0m %s\n' "$*" >&2; }
42
+ die() { printf '\033[1;31m[install]\033[0m %s\n' "$*" >&2; exit 1; }
43
+
44
+ usage() {
45
+ cat <<'EOF'
46
+ dsh-vps 安装脚本
47
+ 用法: sudo bash install.sh [--domain <域名>] [--mirror cn]
48
+ --domain <域名> 访问域名(需已解析到本机)。不传则以公网 IP + 自签证书过渡
49
+ --mirror cn 国内镜像:Node 走 npmmirror 二进制,DSH 走 npmmirror registry
50
+ EOF
51
+ }
52
+
53
+ while [[ $# -gt 0 ]]; do
54
+ case "$1" in
55
+ --domain) DOMAIN="${2:?--domain 需要一个值}"; shift 2 ;;
56
+ --mirror) MIRROR="${2:?--mirror 需要一个值}"; shift 2 ;;
57
+ -h | --help) usage; exit 0 ;;
58
+ *) die "未知参数: $1(--help 查看用法)" ;;
59
+ esac
60
+ done
61
+
62
+ [[ "$MIRROR" == "" || "$MIRROR" == "cn" ]] || die "--mirror 目前仅支持 cn"
63
+ if [[ -n "$DOMAIN" ]]; then
64
+ [[ "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]] \
65
+ || die "域名格式不合法: $DOMAIN"
66
+ fi
67
+
68
+ SCRIPT_DIR=""
69
+ if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
70
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
71
+ fi
72
+
73
+ # 取仓库文件:本地克隆优先,否则走 RAW_BASE
74
+ fetch_file() { # $1=仓库内相对路径 $2=目标路径
75
+ local rel="$1" dst="$2"
76
+ if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/$rel" ]]; then
77
+ install -m 644 "$SCRIPT_DIR/$rel" "$dst"
78
+ else
79
+ curl -fsSL "$RAW_BASE/$rel" -o "$dst" \
80
+ || die "下载 $rel 失败(仓库未发布时请在克隆目录内运行本脚本)"
81
+ fi
82
+ }
83
+
84
+ export DEBIAN_FRONTEND=noninteractive
85
+ trap 'warn "安装失败(行 ${LINENO}),可用 journalctl -u dsh-gate -n 50 排查服务问题"' ERR
86
+
87
+ ## endregion
88
+
89
+ ## region: 步骤 1:前置检查
90
+
91
+ REINSTALL=0
92
+
93
+ step1_prechecks() {
94
+ log "步骤 1/9:前置检查"
95
+ [[ $EUID -eq 0 ]] || die "请用 root 运行(sudo bash install.sh ...)"
96
+ command -v curl >/dev/null 2>&1 || { apt-get update -y >/dev/null; apt-get install -y curl ca-certificates >/dev/null; }
97
+
98
+ # OS 检查
99
+ . /etc/os-release
100
+ local ok=0
101
+ case "${ID:-}" in
102
+ ubuntu) awk -v v="${VERSION_ID:-0}" 'BEGIN{exit !(v>=22.04)}' && ok=1 ;;
103
+ debian) awk -v v="${VERSION_ID:-0}" 'BEGIN{exit !(v>=12)}' && ok=1 ;;
104
+ esac
105
+ [[ $ok -eq 1 ]] || die "不支持的发行版: ${ID:-unknown} ${VERSION_ID:-}(目标:Ubuntu 22.04+ / Debian 12+)"
106
+
107
+ # glibc 检查(Ubuntu 22.04=2.35 / Debian 12=2.36,正常必然通过,仅告警)
108
+ local glibc
109
+ glibc=$(ldd --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+$' || echo 0)
110
+ awk -v v="$glibc" 'BEGIN{exit !(v>=2.28)}' || warn "glibc $glibc < 2.28,DSH 可能无法运行"
111
+
112
+ # 内存检查(< 1.5G 仅告警)
113
+ local mem_kb
114
+ mem_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo)
115
+ [[ "$mem_kb" -lt 1572864 ]] && warn "内存 $((mem_kb / 1024))MB 低于建议的 1.5GB,体验可能不稳定"
116
+
117
+ # 幂等判定:已有安装则进入修复/更新模式(不碰 state/ 内的凭据与 setup.lock)
118
+ [[ -f "$INSTALL_ROOT/state/config.json" ]] && REINSTALL=1
119
+
120
+ # 端口检查(重装时本机服务已占用这些端口,跳过)
121
+ if [[ $REINSTALL -eq 0 ]]; then
122
+ local p
123
+ for p in 80 443 "$DSH_PORT" "$GATE_PORT"; do
124
+ if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}$"; then
125
+ die "端口 $p 已被占用,请先释放(重装场景请保留 $INSTALL_ROOT 后重跑)"
126
+ fi
127
+ done
128
+ else
129
+ log "检测到已有安装(${INSTALL_ROOT}),进入修复/更新模式(保留 state/)"
130
+ fi
131
+ }
132
+
133
+ ## endregion
134
+
135
+ ## region: 步骤 2:Node.js 22
136
+
137
+ install_node_nodesource() {
138
+ curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
139
+ bash /tmp/nodesource_setup.sh >/dev/null
140
+ apt-get install -y nodejs >/dev/null
141
+ }
142
+
143
+ install_node_cn() {
144
+ # npmmirror 二进制镜像:取 latest-v22.x 目录中最新的 linux-x64 包并校验 sha256
145
+ local list pkg
146
+ list=$(curl -fsSL --max-time 30 "https://registry.npmmirror.com/-/binary/node/latest-v22.x/")
147
+ pkg=$(printf '%s' "$list" | grep -o 'node-v22[0-9.]*-linux-x64\.tar\.xz' | sort -Vu | tail -1)
148
+ [[ -n "$pkg" ]] || die "无法从 npmmirror 获取 Node 22 版本号"
149
+ log "下载 Node.js: ${pkg}(npmmirror)"
150
+ curl -fsSL --retry 2 -o "/tmp/$pkg" "https://registry.npmmirror.com/-/binary/node/latest-v22.x/$pkg"
151
+ (cd /tmp && curl -fsSL "https://registry.npmmirror.com/-/binary/node/latest-v22.x/SHASUMS256.txt" \
152
+ | grep "${pkg}\$" | sha256sum -c - >/dev/null) || die "Node 包 sha256 校验失败"
153
+ tar -xJf "/tmp/$pkg" -C /usr/local --strip-components=1
154
+ rm -f "/tmp/$pkg"
155
+ hash -r
156
+ }
157
+
158
+ step2_node() {
159
+ log "步骤 2/9:Node.js 22 + pnpm"
160
+ local major
161
+ major=$(node --version 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9]\{1,\}\).*/\1/p' || true)
162
+ if [[ "${major:-0}" -ge 22 ]]; then
163
+ log "已安装 Node $(node --version),跳过"
164
+ else
165
+ apt-get update -y >/dev/null
166
+ if [[ "$MIRROR" == "cn" ]]; then install_node_cn; else install_node_nodesource; fi
167
+ major=$(node --version | sed -n 's/^v\{0,1\}\([0-9]\{1,\}\).*/\1/p')
168
+ [[ "${major:-0}" -ge 22 ]] || die "Node.js 22 安装失败"
169
+ log "Node $(node --version) 安装完成"
170
+ fi
171
+
172
+ # pnpm:`dsh plugin` 管理插件依赖的包管理器(/setup 向导可选安装插件需要)
173
+ if ! command -v pnpm >/dev/null 2>&1; then
174
+ log "安装 pnpm(插件管理需要)"
175
+ if [[ "$MIRROR" == "cn" ]]; then
176
+ npm install -g pnpm --registry=https://registry.npmmirror.com >/dev/null 2>&1 \
177
+ || npm install -g pnpm >/dev/null 2>&1 || true
178
+ else
179
+ npm install -g pnpm >/dev/null 2>&1 || true
180
+ fi
181
+ fi
182
+ command -v pnpm >/dev/null 2>&1 || warn "pnpm 未安装,插件安装功能不可用(可稍后 npm i -g pnpm)"
183
+ }
184
+
185
+ ## endregion
186
+
187
+ ## region: 步骤 3:DSH 版本化安装
188
+
189
+ step3_dsh() {
190
+ log "步骤 3/9:DeepSeek Harness ${DSH_VERSION}(版本化安装)"
191
+ local prefix="$INSTALL_ROOT/dsh/$DSH_VERSION"
192
+ local bin="$prefix/node_modules/@deepseek-ai/dsh/lib/bin.js"
193
+ if [[ -f "$bin" ]]; then
194
+ log "DSH $DSH_VERSION 已安装,跳过"
195
+ else
196
+ mkdir -p "$prefix"
197
+ # 数组恒不为空:bash 3.2 在 set -u 下展开空数组会报 unbound variable
198
+ local args=(--no-audit --no-fund --loglevel=error)
199
+ [[ "$MIRROR" == "cn" ]] && args+=(--registry=https://registry.npmmirror.com)
200
+ local frozen=1
201
+ [[ "$DSH_RESOLVE_BEFORE" == "none" ]] && frozen=0
202
+ [[ $frozen -eq 1 ]] && args+=(--before="$DSH_RESOLVE_BEFORE")
203
+ if ! npm install --prefix "$prefix" "${args[@]}" "@deepseek-ai/dsh@$DSH_VERSION"; then
204
+ # 冻结解析本身失败(例如镜像源不带发布时间戳)时退化为不冻结,
205
+ # 宁可装到未验证的新预发布版,也别让安装卡死在这一步。
206
+ if [[ $frozen -eq 1 ]]; then
207
+ warn "按时间点冻结解析失败,改为不冻结重试(可能装到未验证的新版本)"
208
+ local args2=(--no-audit --no-fund --loglevel=error)
209
+ [[ "$MIRROR" == "cn" ]] && args2+=(--registry=https://registry.npmmirror.com)
210
+ npm install --prefix "$prefix" "${args2[@]}" "@deepseek-ai/dsh@$DSH_VERSION"
211
+ fi
212
+ fi
213
+ [[ -f "$bin" ]] || die "DSH 安装产物缺失: $bin"
214
+ fi
215
+ ln -sfn "$DSH_VERSION" "$INSTALL_ROOT/dsh/current"
216
+ }
217
+
218
+ ## endregion
219
+
220
+ ## region: 步骤 4:运行身份
221
+
222
+ step4_user() {
223
+ log "步骤 4/9:系统用户 $DSH_USER"
224
+ if ! id "$DSH_USER" >/dev/null 2>&1; then
225
+ useradd --system --shell /usr/sbin/nologin --home-dir /home/dsh --create-home "$DSH_USER"
226
+ fi
227
+ mkdir -p "$DSH_HOME_DIR"
228
+ chown -R "$DSH_USER" "$DSH_HOME_DIR"
229
+ chmod 700 "$DSH_HOME_DIR"
230
+ }
231
+
232
+ ## endregion
233
+
234
+ ## region: 步骤 5:dsh-gate 代码
235
+
236
+ # 一次性启动令牌:初始向导在安装结束到用户首次打开浏览器之间是全网可达的,
237
+ # 谁先提交谁就是管理员。令牌写进 state/setup.token,向导提交成功后由 gate 删除。
238
+ generate_setup_token() {
239
+ SETUP_TOKEN=""
240
+ [[ -f "$INSTALL_ROOT/state/setup.lock" ]] && return 0
241
+ local tok
242
+ tok=$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)
243
+ if [[ ${#tok} -lt 24 ]]; then
244
+ warn "启动令牌生成失败,向导将不校验令牌"
245
+ return 0
246
+ fi
247
+ printf '%s' "$tok" >"$INSTALL_ROOT/state/setup.token"
248
+ chown "$DSH_USER" "$INSTALL_ROOT/state/setup.token" 2>/dev/null || true
249
+ chmod 600 "$INSTALL_ROOT/state/setup.token"
250
+ SETUP_TOKEN="$tok"
251
+ log "已生成一次性启动令牌"
252
+ }
253
+
254
+ step5_gate() {
255
+ log "步骤 5/9:dsh-gate"
256
+ mkdir -p "$INSTALL_ROOT/gate" "$INSTALL_ROOT/bin"
257
+ fetch_file gate/server.js "$INSTALL_ROOT/gate/server.js"
258
+ node --check "$INSTALL_ROOT/gate/server.js" || die "gate/server.js 语法检查失败"
259
+ fetch_file gate/site-block.js "$INSTALL_ROOT/gate/site-block.js"
260
+ node --check "$INSTALL_ROOT/gate/site-block.js" || die "gate/site-block.js 语法检查失败"
261
+ fetch_file bin/dsh-vps "$INSTALL_ROOT/bin/dsh-vps"
262
+ chmod 755 "$INSTALL_ROOT/bin/dsh-vps"
263
+ bash -n "$INSTALL_ROOT/bin/dsh-vps" || die "bin/dsh-vps 语法检查失败"
264
+ ln -sfn "$INSTALL_ROOT/bin/dsh-vps" /usr/local/bin/dsh-vps
265
+ # 公网域名下浏览器判定 isLoopback=false,设置页会报"设置在此浏览器中不可用"。
266
+ # 直接写前端静态文件解除该判定(DSH 每次响应都重读该文件,无需重启)。
267
+ dsh-vps ownshost on || warn "ownsHost 补丁未生效,稍后可手动运行: sudo dsh-vps ownshost on"
268
+ mkdir -p "$INSTALL_ROOT/state"
269
+ chmod 700 "$INSTALL_ROOT/state"
270
+ generate_setup_token
271
+ }
272
+
273
+ ## endregion
274
+
275
+ ## region: 步骤 6:Caddy
276
+
277
+ step6_caddy() {
278
+ log "步骤 6/9:Caddy"
279
+ if ! command -v caddy >/dev/null 2>&1; then
280
+ apt-get install -y debian-keyring debian-archive-keyring apt-transport-https gpg >/dev/null
281
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
282
+ | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
283
+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
284
+ >/etc/apt/sources.list.d/caddy-stable.list
285
+ apt-get update -y >/dev/null
286
+ apt-get install -y caddy >/dev/null
287
+ fi
288
+
289
+ # 主 Caddyfile(静态,import 站点文件)+ 站点文件(向导改域名时由 gate 重写)
290
+ fetch_file caddy/Caddyfile.template /tmp/Caddyfile.dshvps
291
+ if [[ -f /etc/caddy/Caddyfile ]] && ! cmp -s /etc/caddy/Caddyfile /tmp/Caddyfile.dshvps; then
292
+ cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.bak.$(date +%s)"
293
+ fi
294
+ install -m 644 /tmp/Caddyfile.dshvps /etc/caddy/Caddyfile
295
+
296
+ # 站点块:有域名写域名块(自动 HTTPS),无则按公网 IP 写站点、Caddy 内置 CA 自签过渡
297
+ # 属主 dsh(向导重写)、组 caddy(Caddy 读取)、640
298
+ # 站点块由 gate/site-block.js 生成:install.sh 写初始块、gate 改域名、dsh-vps vpn
299
+ # 切换访问策略,三处共用同一份模板。重装时若 state/vpn.env 还开着隧道模式,
300
+ # 这里会直接沿用「仅隧道可访问」,不会把已经关上的门重新敞开。
301
+ local site="$TRUSTED_HOST"
302
+ node "$INSTALL_ROOT/gate/site-block.js" "$site" "$INSTALL_ROOT" "$GATE_PORT" \
303
+ >/etc/caddy/dsh-site.conf || die "生成站点块失败"
304
+ chown "$DSH_USER":caddy /etc/caddy/dsh-site.conf
305
+ chmod 640 /etc/caddy/dsh-site.conf
306
+
307
+ systemctl enable --now caddy >/dev/null 2>&1 || true
308
+ systemctl reload caddy >/dev/null 2>&1 || systemctl restart caddy
309
+ log "Caddyfile 已生效(站点:${site})"
310
+ }
311
+
312
+ ## endregion
313
+
314
+ ## region: 步骤 7:systemd + gate.env + config.json
315
+
316
+ detect_public_ip() {
317
+ local ip url
318
+ for url in https://api.ipify.org https://ifconfig.me https://icanhazip.com; do
319
+ ip=$(curl -4 -fsSL --max-time 5 "$url" 2>/dev/null | tr -d '[:space:]') || continue
320
+ if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
321
+ printf '%s' "$ip"
322
+ return 0
323
+ fi
324
+ done
325
+ ip=$(hostname -I 2>/dev/null | awk '{print $1}')
326
+ [[ -n "$ip" ]] || die "无法探测公网 IP,请显式传 --domain"
327
+ printf '%s' "$ip"
328
+ }
329
+
330
+ # 访问地址(= DSH --trusted-host = Caddy 站点地址)必须在写站点块之前确定:
331
+ # 域名优先;重装且未传 --domain 时沿用已有配置(含向导改过的域名);最后回退公网 IP。
332
+ TRUSTED_HOST=""
333
+ CFG_DOMAIN=""
334
+ resolve_trusted_host() {
335
+ TRUSTED_HOST="${DOMAIN:-}"
336
+ local existing_domain=""
337
+ if [[ -z "$TRUSTED_HOST" && $REINSTALL -eq 1 ]]; then
338
+ if [[ -f "$INSTALL_ROOT/state/gate.env" ]]; then
339
+ TRUSTED_HOST=$(sed -n 's/^DSH_TRUSTED_HOST=//p' "$INSTALL_ROOT/state/gate.env" | tail -1)
340
+ fi
341
+ if [[ -f "$INSTALL_ROOT/state/config.json" ]]; then
342
+ existing_domain=$(sed -n 's/.*"domain": *"\([^"]*\)".*/\1/p' "$INSTALL_ROOT/state/config.json" | tail -1)
343
+ fi
344
+ fi
345
+ if [[ -z "$TRUSTED_HOST" ]]; then
346
+ TRUSTED_HOST=$(detect_public_ip)
347
+ fi
348
+ CFG_DOMAIN="${DOMAIN:-$existing_domain}"
349
+ }
350
+
351
+ step7_systemd() {
352
+ log "步骤 7/9:systemd 服务"
353
+ mkdir -p "$INSTALL_ROOT/state" "$INSTALL_ROOT/backups"
354
+
355
+ local trusted="$TRUSTED_HOST"
356
+ local cfg_domain="$CFG_DOMAIN"
357
+
358
+ # gate.env:集中管理运行环境变量(0600 属主 dsh,M3 向导改域名后重写并 restart 即可)
359
+ cat >"$INSTALL_ROOT/state/gate.env" <<EOF
360
+ GATE_HOME=$INSTALL_ROOT
361
+ DSH_BIN=$INSTALL_ROOT/dsh/current/node_modules/@deepseek-ai/dsh/lib/bin.js
362
+ DSH_HOME=$DSH_HOME_DIR
363
+ DSH_TRUSTED_HOST=$trusted
364
+ EOF
365
+ chmod 600 "$INSTALL_ROOT/state/gate.env"
366
+ chown "$DSH_USER" "$INSTALL_ROOT/state/gate.env"
367
+
368
+ # config.json:安装元数据
369
+ cat >"$INSTALL_ROOT/state/config.json" <<EOF
370
+ {
371
+ "dshVersion": "$DSH_VERSION",
372
+ "domain": "$cfg_domain",
373
+ "trustedHost": "$trusted",
374
+ "gatePort": $GATE_PORT,
375
+ "dshPort": $DSH_PORT,
376
+ "mirror": "${MIRROR:-default}",
377
+ "installedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
378
+ }
379
+ EOF
380
+ chmod 600 "$INSTALL_ROOT/state/config.json"
381
+ chown "$DSH_USER" "$INSTALL_ROOT/state/config.json"
382
+ chown "$DSH_USER" "$INSTALL_ROOT/state" "$INSTALL_ROOT/backups"
383
+ chmod 700 "$INSTALL_ROOT/state"
384
+
385
+ # systemd unit(模板替换 node 路径)
386
+ local node_bin
387
+ node_bin=$(command -v node)
388
+ fetch_file units/dsh-gate.service /tmp/dsh-gate.service.tpl
389
+ sed "s|__NODE_BIN__|${node_bin}|" /tmp/dsh-gate.service.tpl >/etc/systemd/system/dsh-gate.service
390
+ systemctl daemon-reload
391
+ systemctl enable dsh-gate >/dev/null 2>&1
392
+ systemctl restart dsh-gate
393
+ log "dsh-gate.service 已启动(DSH_TRUSTED_HOST=${trusted})"
394
+ }
395
+
396
+ ## endregion
397
+
398
+ ## region: 步骤 8:防火墙
399
+
400
+ step8_firewall() {
401
+ log "步骤 8/9:防火墙"
402
+ if command -v ufw >/dev/null 2>&1; then
403
+ ufw allow 22/tcp >/dev/null
404
+ ufw allow 80/tcp >/dev/null
405
+ ufw allow 443/tcp >/dev/null
406
+ log "ufw 已放行 22/80/443"
407
+ else
408
+ log "未检测到 ufw,跳过(请自行确认云厂商安全组放行 80/443)"
409
+ fi
410
+ }
411
+
412
+ ## endregion
413
+
414
+ ## region: 步骤 9:健康自检 + 完成输出
415
+
416
+ wait_gate_health() {
417
+ local i body
418
+ for i in $(seq 1 30); do
419
+ body=$(curl -fsS --max-time 3 "http://127.0.0.1:$GATE_PORT/gate/health" 2>/dev/null) || { sleep 2; continue; }
420
+ if printf '%s' "$body" | grep -q '"gate":"ok"'; then
421
+ printf '%s' "$body"
422
+ return 0
423
+ fi
424
+ sleep 2
425
+ done
426
+ return 1
427
+ }
428
+
429
+ step9_verify() {
430
+ log "步骤 9/9:健康自检"
431
+ local body healthy=1
432
+ body=$(wait_gate_health) || {
433
+ warn "gate 未就绪,请查看: journalctl -u dsh-gate -n 50"
434
+ die "健康检查失败(gate)"
435
+ }
436
+ # 等待 DSH 子进程启动 + launchToken 兑换完成(冷启动需要几秒到几十秒,小内存机器更久)
437
+ local i
438
+ for i in $(seq 1 60); do
439
+ body=$(curl -fsS --max-time 3 "http://127.0.0.1:$GATE_PORT/gate/health" 2>/dev/null) || true
440
+ if printf '%s' "$body" | grep -q '"launchTokenCaptured":true' \
441
+ && printf '%s' "$body" | grep -q '"dshCookie":{"authority"'; then
442
+ log "gate 正常,DSH 会话兑换成功"
443
+ break
444
+ fi
445
+ if [[ $i -eq 60 ]]; then
446
+ # 不在这里退出:令牌链接只在下面打印,退出了用户就只剩"缺少令牌"页
447
+ healthy=0
448
+ warn "DSH 会话兑换超时(DSH 可能仍在启动,或启动失败)"
449
+ local err
450
+ err=$(printf '%s' "$body" | sed -n 's/.*"lastError":"\([^"]*\)".*/\1/p')
451
+ [[ -n "$err" ]] && warn "最近错误: $err"
452
+ warn "排查: journalctl -u dsh-gate -n 80 --no-pager"
453
+ break
454
+ fi
455
+ sleep 2
456
+ done
457
+
458
+ local url="https://$TRUSTED_HOST"
459
+ local open_url="$url"
460
+ [[ -n "$SETUP_TOKEN" ]] && open_url="$url/setup?token=$SETUP_TOKEN"
461
+
462
+ echo
463
+ echo "============================================================"
464
+ if [[ $healthy -eq 1 ]]; then
465
+ echo " dsh-vps 安装完成"
466
+ else
467
+ echo " dsh-vps 已安装,但 DSH 尚未就绪(见上方警告;页面会显示启动进度与错误)"
468
+ fi
469
+ echo "------------------------------------------------------------"
470
+ echo " DSH 版本 : ${DSH_VERSION}(钉住)"
471
+ echo " 访问地址 : $open_url"
472
+ if [[ -n "$SETUP_TOKEN" ]]; then
473
+ echo " (初始设置需要上面的一次性令牌,别用不带令牌的地址)"
474
+ elif [[ -z "$DOMAIN" ]]; then
475
+ echo " (未传 --domain,当前为自签证书过渡,浏览器会提示证书不受信任;域名可稍后在向导中填写)"
476
+ fi
477
+ echo " 服务/日志 : systemctl status dsh-gate | journalctl -u dsh-gate -f"
478
+ echo " 管理命令 : dsh-vps status | restart | upgrade | rollback | reset-admin | setup-url | backup"
479
+ echo " 仅我可访问: dsh-vps vpn setup(隧道)| dsh-vps tunnel(SSH 转发,零安装)"
480
+ echo "------------------------------------------------------------"
481
+ echo " 下一步:"
482
+ echo " 1. 若使用域名,请先将 A 记录解析到本机(Caddy 会自动签发证书)"
483
+ echo " 2. 浏览器打开上面的访问地址,进入初始设置向导"
484
+ echo " 3. 填写管理员用户名/密码(+ 可选域名、DeepSeek API Key)→ 登录"
485
+ echo " 4. 可选:sudo dsh-vps vpn setup —— 之后公网访问不到登录页,只有隧道内的设备能进"
486
+ if [[ -n "$SETUP_TOKEN" ]]; then
487
+ echo
488
+ echo " 令牌只此一份,链接丢了随时重取:sudo dsh-vps setup-url"
489
+ fi
490
+ echo "============================================================"
491
+ }
492
+
493
+ ## endregion
494
+
495
+ step1_prechecks
496
+ step2_node
497
+ step3_dsh
498
+ step4_user
499
+ step5_gate
500
+ resolve_trusted_host
501
+ step6_caddy
502
+ step7_systemd
503
+ step8_firewall
504
+ step9_verify
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "dsh-vps",
3
+ "version": "1.4.3",
4
+ "description": "Ship DeepSeek Harness to your own VPS: one command installs a zero-dependency login gateway, automatic HTTPS through Caddy, systemd supervision, and optional WireGuard-only access.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "deepseek",
8
+ "dsh",
9
+ "dsh-plugin",
10
+ "dsh-vps",
11
+ "vps",
12
+ "installer",
13
+ "one-click-install",
14
+ "self-hosted",
15
+ "self-host",
16
+ "deploy",
17
+ "deployment",
18
+ "caddy",
19
+ "reverse-proxy",
20
+ "https",
21
+ "wireguard",
22
+ "tunnel",
23
+ "gateway",
24
+ "cli",
25
+ "llm"
26
+ ],
27
+ "license": "MIT",
28
+ "homepage": "https://github.com/AIcivilization/deepseek-harness-vps#readme",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/AIcivilization/deepseek-harness-vps.git"
32
+ },
33
+ "bugs": "https://github.com/AIcivilization/deepseek-harness-vps/issues",
34
+ "bin": {
35
+ "dsh-vps-install": "scripts/bootstrap.mjs"
36
+ },
37
+ "files": [
38
+ "install.sh",
39
+ "uninstall.sh",
40
+ "bin/dsh-vps",
41
+ "gate/server.js",
42
+ "gate/site-block.js",
43
+ "units/dsh-gate.service",
44
+ "caddy/Caddyfile.template",
45
+ "versions.json",
46
+ "scripts/bootstrap.mjs",
47
+ "README.md",
48
+ "README.en.md",
49
+ "LICENSE"
50
+ ],
51
+ "engines": {
52
+ "node": ">=22"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }