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
|
@@ -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
|
+
}
|