dsh-plugin-remote-connect-beta 0.1.0-beta.1
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/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/README.zh.md +298 -0
- package/SECURITY.md +41 -0
- package/bin/dsh-remote.js +768 -0
- package/docs/client-preview.png +0 -0
- package/docs/market-submission.md +82 -0
- package/docs/multi-tenant.md +136 -0
- package/docs/multi-tenant.zh.md +127 -0
- package/docs/self-host.md +398 -0
- package/docs/self-host.zh.md +369 -0
- package/docs/tenants-preview.png +0 -0
- package/lib/client.js +1120 -0
- package/lib/core/assets/server-setup.sh.tpl +344 -0
- package/lib/core/credential.js +123 -0
- package/lib/core/instance.js +360 -0
- package/lib/core/messages.js +549 -0
- package/lib/core/paths.js +59 -0
- package/lib/core/preflight.js +410 -0
- package/lib/core/proxy.js +924 -0
- package/lib/core/serversetup.js +133 -0
- package/lib/core/snippets.js +193 -0
- package/lib/core/tailscale.js +158 -0
- package/lib/core/tenancy.js +267 -0
- package/lib/core/tenant.js +272 -0
- package/lib/core/tunnel.js +320 -0
- package/lib/index.js +1097 -0
- package/package.json +76 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dsh-remote 服务器侧安装器(由 dsh-plugin-remote-connect-beta 生成,勿手工编辑生成物)
|
|
3
|
+
#
|
|
4
|
+
# sudo bash server-setup.sh probe # 只探测环境,不改任何东西
|
|
5
|
+
# sudo bash server-setup.sh install --dry-run # 打印将要执行的动作
|
|
6
|
+
# sudo bash server-setup.sh install # 幂等安装
|
|
7
|
+
# sudo bash server-setup.sh install --skip-cert # 已有证书时跳过签发
|
|
8
|
+
# printf %s '<边缘口令>' | sudo bash server-setup.sh install --auth-password-stdin
|
|
9
|
+
# # 非交互设置边缘口令(口令不进命令历史)
|
|
10
|
+
# sudo bash server-setup.sh uninstall # 撤销(默认保留隧道账号)
|
|
11
|
+
# sudo bash server-setup.sh uninstall --purge-user
|
|
12
|
+
#
|
|
13
|
+
# 设计原则:
|
|
14
|
+
# * 只写"自己拥有"的文件(conf.d 下的一个文件 + sshd 的 drop-in + 一个 deploy hook),
|
|
15
|
+
# 不去改用户既有的 nginx/sshd 主配置 —— 这样卸载 = 删文件,幂等 = 覆盖写。
|
|
16
|
+
# * 不新增 80 端口 server block:ACME challenge 与 http→https 交给既有默认 server。
|
|
17
|
+
# * 访问日志默认脱敏:?k= 是敏感凭据,禁止完整请求行落盘。
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
DOMAIN="{{DOMAIN}}"
|
|
21
|
+
REMOTE_PORT="{{REMOTE_PORT}}"
|
|
22
|
+
TUNNEL_USER="{{TUNNEL_USER}}"
|
|
23
|
+
AUTH_USER="{{AUTH_USER}}"
|
|
24
|
+
AUTH_FILE="{{AUTH_FILE}}"
|
|
25
|
+
WEBROOT="{{WEBROOT}}"
|
|
26
|
+
NGINX_CONF="{{NGINX_CONF}}"
|
|
27
|
+
SSHD_DROPIN="{{SSHD_DROPIN}}"
|
|
28
|
+
DEPLOY_HOOK="{{DEPLOY_HOOK}}"
|
|
29
|
+
LOG_FILE="/var/log/nginx/dsh-remote.access.log"
|
|
30
|
+
|
|
31
|
+
DRY_RUN=0
|
|
32
|
+
SKIP_CERT=0
|
|
33
|
+
SKIP_DNS=0
|
|
34
|
+
PURGE_USER=0
|
|
35
|
+
ARGS=()
|
|
36
|
+
for arg in "$@"; do
|
|
37
|
+
case "$arg" in
|
|
38
|
+
--dry-run) DRY_RUN=1 ;;
|
|
39
|
+
--skip-cert) SKIP_CERT=1 ;;
|
|
40
|
+
--skip-dns) SKIP_DNS=1 ;;
|
|
41
|
+
--purge-user) PURGE_USER=1 ;;
|
|
42
|
+
--auth-password-stdin) AUTH_PASSWORD_STDIN=1 ;;
|
|
43
|
+
*) ARGS+=("$arg") ;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
COMMAND="${ARGS[0]:-probe}"
|
|
47
|
+
|
|
48
|
+
say() { printf '%s\n' "$*"; }
|
|
49
|
+
step() { printf '\n== %s\n' "$*"; }
|
|
50
|
+
run() {
|
|
51
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
52
|
+
printf ' [dry-run] %s\n' "$*"
|
|
53
|
+
return 0
|
|
54
|
+
fi
|
|
55
|
+
printf ' + %s\n' "$*"
|
|
56
|
+
"$@"
|
|
57
|
+
}
|
|
58
|
+
need_root() {
|
|
59
|
+
if [ "$(id -u)" != "0" ] && [ "$DRY_RUN" != "1" ]; then
|
|
60
|
+
say "✖ 需要 root(或用 --dry-run 只看计划)"; exit 1
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
64
|
+
|
|
65
|
+
# ─────────────────────────────── probe ───────────────────────────────
|
|
66
|
+
probe() {
|
|
67
|
+
step "系统"
|
|
68
|
+
if [ -r /etc/os-release ]; then . /etc/os-release; say "发行版:${PRETTY_NAME:-unknown}"; else say "发行版:未知(无 /etc/os-release)"; fi
|
|
69
|
+
say "内核:$(uname -sr) CPU:$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo '?') 核"
|
|
70
|
+
if have free; then say "内存:$(free -m | awk '/^Mem:/{print $2" MB"}')"; fi
|
|
71
|
+
say "root:$([ "$(id -u)" = "0" ] && echo 是 || echo 否)"
|
|
72
|
+
|
|
73
|
+
step "反向代理"
|
|
74
|
+
if have nginx; then say "nginx:$(nginx -v 2>&1)"; else say "nginx:未安装"; fi
|
|
75
|
+
if have caddy; then say "caddy:$(caddy version 2>/dev/null | head -1)"; else say "caddy:未安装"; fi
|
|
76
|
+
|
|
77
|
+
step "证书"
|
|
78
|
+
if have certbot; then say "certbot:$(certbot --version 2>&1)"; else say "certbot:未安装"; fi
|
|
79
|
+
if [ -d /etc/letsencrypt/renewal-hooks/deploy ]; then
|
|
80
|
+
count=$(find /etc/letsencrypt/renewal-hooks/deploy -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
|
|
81
|
+
say "renewal-hooks/deploy:${count} 个文件$([ "$count" = "0" ] && echo ' ⚠ 空 → 续签后不会 reload,线上会继续发旧证书')"
|
|
82
|
+
else
|
|
83
|
+
say "renewal-hooks/deploy:目录不存在"
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
step "sshd"
|
|
87
|
+
if have sshd; then
|
|
88
|
+
sshd -T 2>/dev/null | grep -Ei '^(port|allowtcpforwarding|gatewayports|clientaliveinterval|passwordauthentication)' | sed 's/^/ /' || true
|
|
89
|
+
else
|
|
90
|
+
say "sshd:未找到(可能用其它 SSH 实现)"
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
step "其它"
|
|
94
|
+
say "htpasswd:$(have htpasswd && echo 有 || echo '无(需 apache2-utils)')"
|
|
95
|
+
say "python3:$(have python3 && echo 有 || echo 无)"
|
|
96
|
+
say "node:$(have node && echo 有 || echo '无(本方案不需要)')"
|
|
97
|
+
if have ufw; then say "ufw:$(ufw status 2>/dev/null | head -1)"; fi
|
|
98
|
+
say "80/443 监听:$( (ss -lntp 2>/dev/null || netstat -lntp 2>/dev/null) | grep -cE ':(80|443)\s' || true) 条"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# ─────────────────────────────── install ───────────────────────────────
|
|
102
|
+
write_nginx_conf() {
|
|
103
|
+
step "反向代理配置 → $NGINX_CONF"
|
|
104
|
+
if [ -f "$NGINX_CONF" ]; then say " 已存在,将覆盖(本文件由本脚本独占)"; fi
|
|
105
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
106
|
+
say " [dry-run] 写入 $NGINX_CONF(map + log_format + 443 server block,不含 80 块)"
|
|
107
|
+
else
|
|
108
|
+
mkdir -p "$(dirname "$NGINX_CONF")"
|
|
109
|
+
cat > "$NGINX_CONF" <<NGINX
|
|
110
|
+
# managed by dsh-remote — 删除本文件即可完整撤销(勿手工编辑)
|
|
111
|
+
map \$http_upgrade \$connection_upgrade {
|
|
112
|
+
default upgrade;
|
|
113
|
+
'' close;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# ?k= 是敏感凭据:只记 $uri(不含 query),禁止完整请求行落盘
|
|
117
|
+
log_format dsh_remote_nokey '\$remote_addr - \$remote_user [\$time_local] "\$request_method \$uri" \$status';
|
|
118
|
+
|
|
119
|
+
server {
|
|
120
|
+
listen 443 ssl;
|
|
121
|
+
listen [::]:443 ssl;
|
|
122
|
+
http2 on;
|
|
123
|
+
server_name ${DOMAIN};
|
|
124
|
+
|
|
125
|
+
# 证书:若为通配符或其它 lineage,改这两行即可
|
|
126
|
+
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
|
127
|
+
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
|
128
|
+
|
|
129
|
+
auth_basic "DSH";
|
|
130
|
+
auth_basic_user_file ${AUTH_FILE};
|
|
131
|
+
|
|
132
|
+
client_max_body_size 64m;
|
|
133
|
+
|
|
134
|
+
access_log ${LOG_FILE} dsh_remote_nokey;
|
|
135
|
+
|
|
136
|
+
location / {
|
|
137
|
+
proxy_pass http://127.0.0.1:${REMOTE_PORT};
|
|
138
|
+
proxy_http_version 1.1;
|
|
139
|
+
|
|
140
|
+
proxy_set_header Host \$host;
|
|
141
|
+
proxy_set_header X-Real-IP \$remote_addr;
|
|
142
|
+
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
143
|
+
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
144
|
+
proxy_set_header Upgrade \$http_upgrade;
|
|
145
|
+
proxy_set_header Connection \$connection_upgrade;
|
|
146
|
+
|
|
147
|
+
# 关缓冲 + 放长超时:不关的话 Harness 的流式输出会卡死
|
|
148
|
+
proxy_buffering off;
|
|
149
|
+
proxy_request_buffering off;
|
|
150
|
+
proxy_read_timeout 3600s;
|
|
151
|
+
proxy_send_timeout 3600s;
|
|
152
|
+
proxy_connect_timeout 15s;
|
|
153
|
+
|
|
154
|
+
proxy_redirect off;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# 刻意不提供 80 端口 server block:ACME challenge 与 http→https 由既有默认 server 承担。
|
|
159
|
+
# 另加精确 server_name 的 80 块会顶掉 /.well-known/acme-challenge/,导致签发/续期失败。
|
|
160
|
+
NGINX
|
|
161
|
+
fi
|
|
162
|
+
run nginx -t
|
|
163
|
+
run systemctl reload nginx
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ensure_auth_file() {
|
|
167
|
+
step "边缘口令 → $AUTH_FILE"
|
|
168
|
+
# 记着我们上次给这个文件设的用户名:改名时必须删掉旧条目,
|
|
169
|
+
# 否则旧用户名仍然能登录(《账密设置规范》§4 幂等要求)
|
|
170
|
+
PREV_USER_FILE="$(dirname "$AUTH_FILE")/.$(basename "$AUTH_FILE").prev-user"
|
|
171
|
+
if [ -f "$PREV_USER_FILE" ]; then
|
|
172
|
+
PREV_USER="$(cat "$PREV_USER_FILE" 2>/dev/null || true)"
|
|
173
|
+
if [ -n "$PREV_USER" ] && [ "$PREV_USER" != "$AUTH_USER" ] && [ -f "$AUTH_FILE" ]; then
|
|
174
|
+
say " 用户名由 $PREV_USER 改为 $AUTH_USER → 先删除旧条目"
|
|
175
|
+
run htpasswd -D "$AUTH_FILE" "$PREV_USER" || true
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
178
|
+
# 非交互:口令从 stdin 传入(printf %s '<口令>' | sudo bash 本脚本 install --auth-password-stdin)
|
|
179
|
+
if [ "$AUTH_PASSWORD_STDIN" = "1" ]; then
|
|
180
|
+
if IFS= read -r AUTH_PASSWORD; then
|
|
181
|
+
if [ -z "$AUTH_PASSWORD" ]; then say " ✖ 从 stdin 读到的口令为空"; return 1; fi
|
|
182
|
+
if have htpasswd; then
|
|
183
|
+
# -c 只在文件不存在时用(它会把文件里其它用户清空)
|
|
184
|
+
if [ -f "$AUTH_FILE" ]; then
|
|
185
|
+
run sh -c "printf '%s' \"\$1\" | htpasswd -i -B \"$AUTH_FILE\" \"$AUTH_USER\"" _ "$AUTH_PASSWORD"
|
|
186
|
+
else
|
|
187
|
+
run sh -c "printf '%s' \"\$1\" | htpasswd -i -B -c \"$AUTH_FILE\" \"$AUTH_USER\"" _ "$AUTH_PASSWORD"
|
|
188
|
+
fi
|
|
189
|
+
else
|
|
190
|
+
hash="$(printf '%s' "$AUTH_PASSWORD" | openssl passwd -apr1 -stdin)"
|
|
191
|
+
printf '%s:%s\n' "$AUTH_USER" "$hash" > "$AUTH_FILE"
|
|
192
|
+
fi
|
|
193
|
+
chmod 640 "$AUTH_FILE"
|
|
194
|
+
if id www-data >/dev/null 2>&1; then chown root:www-data "$AUTH_FILE"; fi
|
|
195
|
+
say " 已写入 $AUTH_FILE(口令来自 stdin,未写入脚本、未进命令历史)"
|
|
196
|
+
printf '%s\n' "$AUTH_USER" > "$PREV_USER_FILE"
|
|
197
|
+
chmod 600 "$PREV_USER_FILE" 2>/dev/null || true
|
|
198
|
+
unset AUTH_PASSWORD
|
|
199
|
+
return 0
|
|
200
|
+
fi
|
|
201
|
+
say " ✖ --auth-password-stdin 需要从管道传入口令"; return 1
|
|
202
|
+
fi
|
|
203
|
+
if ! have htpasswd; then
|
|
204
|
+
say " 缺少 htpasswd:请先安装(Debian/Ubuntu: apt install -y apache2-utils;RHEL: dnf install -y httpd-tools)"
|
|
205
|
+
say " 或手工生成:printf '%s:%s\n' '$AUTH_USER' \"\$(openssl passwd -apr1)\" | sudo tee $AUTH_FILE"
|
|
206
|
+
return 1
|
|
207
|
+
fi
|
|
208
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
209
|
+
say " [dry-run] htpasswd -c $AUTH_FILE $AUTH_USER(交互式输入强口令)"
|
|
210
|
+
return 0
|
|
211
|
+
fi
|
|
212
|
+
htpasswd -c "$AUTH_FILE" "$AUTH_USER"
|
|
213
|
+
printf '%s\n' "$AUTH_USER" > "$PREV_USER_FILE"
|
|
214
|
+
chmod 600 "$PREV_USER_FILE" 2>/dev/null || true
|
|
215
|
+
chmod 640 "$AUTH_FILE"
|
|
216
|
+
if id www-data >/dev/null 2>&1; then chown root:www-data "$AUTH_FILE"; fi
|
|
217
|
+
say " 已写入(口令不会出现在本脚本或日志里)"
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ensure_tunnel_user() {
|
|
221
|
+
step "隧道专用账号 → $TUNNEL_USER"
|
|
222
|
+
if id "$TUNNEL_USER" >/dev/null 2>&1; then
|
|
223
|
+
say " 账号已存在,跳过创建"
|
|
224
|
+
else
|
|
225
|
+
run useradd -m -s /usr/sbin/nologin "$TUNNEL_USER"
|
|
226
|
+
fi
|
|
227
|
+
home="$(getent passwd "$TUNNEL_USER" | cut -d: -f6)"; home="${home:-/home/$TUNNEL_USER}"
|
|
228
|
+
run install -d -m 700 -o "$TUNNEL_USER" -g "$TUNNEL_USER" "$home/.ssh"
|
|
229
|
+
if [ "$DRY_RUN" != "1" ] && [ ! -f "$home/.ssh/authorized_keys" ]; then
|
|
230
|
+
install -m 600 -o "$TUNNEL_USER" -g "$TUNNEL_USER" /dev/null "$home/.ssh/authorized_keys"
|
|
231
|
+
fi
|
|
232
|
+
say " 下一步:把本机公钥写入 $home/.ssh/authorized_keys,行首带限制:"
|
|
233
|
+
say " restrict,remote-port-forwarding,permitlisten=\"127.0.0.1:${REMOTE_PORT}\" <你的公钥> dsh-mac-tunnel"
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ensure_sshd_dropin() {
|
|
237
|
+
step "sshd 转发与保活 → $SSHD_DROPIN"
|
|
238
|
+
if [ -f "$SSHD_DROPIN" ]; then say " 已存在,覆盖写(本文件由本脚本独占)"; fi
|
|
239
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
240
|
+
say " [dry-run] 写入 $SSHD_DROPIN(AllowTcpForwarding yes / ClientAliveInterval 30)"
|
|
241
|
+
else
|
|
242
|
+
mkdir -p "$(dirname "$SSHD_DROPIN")"
|
|
243
|
+
cat > "$SSHD_DROPIN" <<SSHD
|
|
244
|
+
# managed by dsh-remote — 删除本文件即可完整撤销
|
|
245
|
+
AllowTcpForwarding yes
|
|
246
|
+
ClientAliveInterval 30
|
|
247
|
+
SSHD
|
|
248
|
+
fi
|
|
249
|
+
if have sshd; then
|
|
250
|
+
run sshd -t
|
|
251
|
+
run systemctl reload ssh
|
|
252
|
+
else
|
|
253
|
+
say " 未找到 sshd,跳过校验与 reload(请自行确认允许 TCP 转发)"
|
|
254
|
+
fi
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
ensure_cert() {
|
|
258
|
+
step "证书 → $DOMAIN"
|
|
259
|
+
if [ "$SKIP_CERT" = "1" ]; then say " --skip-cert:跳过"; return 0; fi
|
|
260
|
+
if ! have certbot; then
|
|
261
|
+
say " 未安装 certbot,跳过。手动签发后回来跑:bash $0 install --skip-cert"
|
|
262
|
+
return 0
|
|
263
|
+
fi
|
|
264
|
+
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
|
|
265
|
+
say " 已有证书文件,跳过签发(续期由 certbot.timer 负责)"
|
|
266
|
+
else
|
|
267
|
+
run certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" --non-interactive --agree-tos --keep-until-expiring
|
|
268
|
+
fi
|
|
269
|
+
ensure_deploy_hook
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
ensure_deploy_hook() {
|
|
273
|
+
step "续签后自动 reload → $DEPLOY_HOOK"
|
|
274
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
275
|
+
say " [dry-run] 写入 $DEPLOY_HOOK(reload nginx)"
|
|
276
|
+
return 0
|
|
277
|
+
fi
|
|
278
|
+
mkdir -p "$(dirname "$DEPLOY_HOOK")"
|
|
279
|
+
cat > "$DEPLOY_HOOK" <<'HOOK'
|
|
280
|
+
#!/usr/bin/env bash
|
|
281
|
+
# managed by dsh-remote —— 没有它,certbot 续签后 nginx 仍发旧证书(线上表现为"续签成功但浏览器报过期")
|
|
282
|
+
set -euo pipefail
|
|
283
|
+
if command -v nginx >/dev/null 2>&1; then nginx -t && systemctl reload nginx || true; fi
|
|
284
|
+
if command -v caddy >/dev/null 2>&1; then systemctl reload caddy || true; fi
|
|
285
|
+
HOOK
|
|
286
|
+
chmod 755 "$DEPLOY_HOOK"
|
|
287
|
+
say " 已安装:certbot 续签后会自动 reload(根治「续签成功但线上未生效」)"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
verify_live_cert() {
|
|
291
|
+
step "线上实际生效的证书"
|
|
292
|
+
if have openssl; then
|
|
293
|
+
served="$(echo | openssl s_client -connect "127.0.0.1:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null || true)"
|
|
294
|
+
say " 线上发出:${served:-读取失败(443 未就绪?)}"
|
|
295
|
+
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
|
|
296
|
+
onfile="$(openssl x509 -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" -noout -enddate 2>/dev/null || true)"
|
|
297
|
+
say " 磁盘文件:${onfile:-读取失败}"
|
|
298
|
+
[ "$served" = "$onfile" ] || say " ⚠ 两者不一致 → 说明 nginx 还在内存里用旧证书,执行:systemctl reload nginx"
|
|
299
|
+
fingerprint="$(openssl x509 -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f' || true)"
|
|
300
|
+
if [ -n "$fingerprint" ]; then
|
|
301
|
+
say " 把这一行交给本机侧,从外部核对线上发的就是这张证书:"
|
|
302
|
+
say " --expect-cert-sha256 ${fingerprint}"
|
|
303
|
+
fi
|
|
304
|
+
fi
|
|
305
|
+
fi
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
install() {
|
|
309
|
+
need_root
|
|
310
|
+
probe
|
|
311
|
+
write_nginx_conf
|
|
312
|
+
ensure_auth_file || say " (口令文件未就绪,nginx 会 500;先补上再 reload)"
|
|
313
|
+
ensure_tunnel_user
|
|
314
|
+
ensure_sshd_dropin
|
|
315
|
+
ensure_cert
|
|
316
|
+
verify_live_cert
|
|
317
|
+
step "完成"
|
|
318
|
+
say "下一步:在本机(跑 Harness 的那台)上跑"
|
|
319
|
+
say " dsh-remote doctor --domain $DOMAIN --user $AUTH_USER --ssh-user $TUNNEL_USER --ssh-host $DOMAIN --remote-port $REMOTE_PORT"
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
uninstall() {
|
|
323
|
+
need_root
|
|
324
|
+
step "撤销"
|
|
325
|
+
if [ -f "$NGINX_CONF" ]; then run rm -f "$NGINX_CONF"; else say " nginx 配置不存在,跳过"; fi
|
|
326
|
+
if [ -f "$SSHD_DROPIN" ]; then run rm -f "$SSHD_DROPIN"; else say " sshd drop-in 不存在,跳过"; fi
|
|
327
|
+
if [ -f "$DEPLOY_HOOK" ]; then run rm -f "$DEPLOY_HOOK"; else say " deploy hook 不存在,跳过"; fi
|
|
328
|
+
run nginx -t
|
|
329
|
+
run systemctl reload nginx
|
|
330
|
+
if have sshd; then run systemctl reload ssh; fi
|
|
331
|
+
if [ "$PURGE_USER" = "1" ]; then
|
|
332
|
+
if id "$TUNNEL_USER" >/dev/null 2>&1; then run userdel -r "$TUNNEL_USER"; fi
|
|
333
|
+
else
|
|
334
|
+
say " 保留隧道账号 $TUNNEL_USER(要一并删除加 --purge-user)"
|
|
335
|
+
fi
|
|
336
|
+
say " 注意:Basic Auth 口令文件 ${AUTH_FILE} 与 DNS 记录未动(可能被其它服务共用)"
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
case "$COMMAND" in
|
|
340
|
+
probe) probe ;;
|
|
341
|
+
install) install ;;
|
|
342
|
+
uninstall) uninstall ;;
|
|
343
|
+
*) say "用法:bash $0 {probe|install|uninstall} [--dry-run] [--skip-cert] [--skip-dns] [--purge-user]"; exit 2 ;;
|
|
344
|
+
esac
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 边缘凭证(Basic Auth 用户名 / 口令)的生成与设置指引。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:这是公网入口**唯一一道**挡在你机器前面的门,
|
|
5
|
+
* 而它偏偏最容易做错 —— 让用户"自己想一个强口令",结果通常是 `dsh123456`。
|
|
6
|
+
* 这里生成的是一句**能在手机上输进去**的短语,并直接把"在服务器上设置它"的命令一起给出来。
|
|
7
|
+
*
|
|
8
|
+
* 不做口令哈希:服务器侧用 `htpasswd` / `openssl passwd -apr1` 生成即可,
|
|
9
|
+
* 插件不需要(也不应该)自创一套哈希格式。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-plugin-remote-connect-beta/core/credential
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 词表:全小写 ASCII、4–7 字母、彼此不易听错/看错(刻意避开易混的写法)。
|
|
17
|
+
* 64 个词 → 每词 6 bit。
|
|
18
|
+
*/
|
|
19
|
+
export const WORDS = [
|
|
20
|
+
'amber', 'apple', 'arrow', 'bamboo', 'banjo', 'beach', 'berry', 'birch',
|
|
21
|
+
'bison', 'bloom', 'brook', 'cabin', 'cactus', 'camel', 'candy', 'cedar',
|
|
22
|
+
'chalk', 'chess', 'cider', 'cloud', 'cobalt', 'comet', 'coral', 'crane',
|
|
23
|
+
'delta', 'denim', 'drift', 'dune', 'eagle', 'ember', 'fable', 'fabric',
|
|
24
|
+
'fang', 'fern', 'flint', 'frost', 'ginger', 'glacier', 'grape', 'grove',
|
|
25
|
+
'harbor', 'honey', 'ivory', 'jade', 'juniper', 'kettle', 'lantern', 'lemon',
|
|
26
|
+
'lilac', 'mango', 'maple', 'marble', 'meadow', 'melon', 'mint', 'nectar',
|
|
27
|
+
'oasis', 'olive', 'onyx', 'orbit', 'pebble', 'pepper', 'pilot', 'quartz',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
/** 默认词数:64^6 ≈ 6.9e10(约 36 bit),再加 3 位数字 ≈ 46 bit 的在线口令强度。 */
|
|
31
|
+
export const DEFAULT_WORDS = 6
|
|
32
|
+
|
|
33
|
+
/** 从随机源取一个 32 位无符号整数。 */
|
|
34
|
+
function randomUint32(randomBytes) {
|
|
35
|
+
const bytes = randomBytes(4)
|
|
36
|
+
return ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 无偏取的 [0, max) 随机整数(拒绝采样;max 可以大于 256)。 */
|
|
40
|
+
function randomInt(max, randomBytes) {
|
|
41
|
+
const limit = Math.floor(0x100000000 / max) * max
|
|
42
|
+
for (;;) {
|
|
43
|
+
const value = randomUint32(randomBytes)
|
|
44
|
+
if (value < limit) return value % max
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 生成一句好输入的口令:`cobalt-cabin-drift-jade-maple-mint-482`。
|
|
50
|
+
* @param {object} [options]
|
|
51
|
+
* @param {number} [options.words=6] 词数(4–10)
|
|
52
|
+
* @param {number} [options.digits=3] 末尾数字位数(0–6)
|
|
53
|
+
* @param {(size: number) => Buffer} [options.randomBytes] 测试用的确定性随机源
|
|
54
|
+
* @returns {{ password: string, bits: number, words: number }}
|
|
55
|
+
*/
|
|
56
|
+
export function generatePassphrase(options = {}) {
|
|
57
|
+
const randomBytes = options.randomBytes ?? ((size) => crypto.randomBytes(size))
|
|
58
|
+
const words = Math.min(Math.max(Number(options.words ?? DEFAULT_WORDS), 4), 10)
|
|
59
|
+
const digits = Math.min(Math.max(Number(options.digits ?? 3), 0), 6)
|
|
60
|
+
const picked = []
|
|
61
|
+
for (let index = 0; index < words; index += 1) picked.push(WORDS[randomInt(WORDS.length, randomBytes)])
|
|
62
|
+
const tail = digits === 0 ? '' : '-' + String(randomInt(10 ** digits, randomBytes)).padStart(digits, '0')
|
|
63
|
+
const bits = Math.round(words * Math.log2(WORDS.length) + digits * Math.log2(10))
|
|
64
|
+
return { password: picked.join('-') + tail, bits, words }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 纯随机口令(不强求好输入,追求强度):默认 24 位 URL 安全字符 ≈ 143 bit。
|
|
69
|
+
* @param {object} [options]
|
|
70
|
+
* @param {number} [options.length=24]
|
|
71
|
+
* @param {(size: number) => Buffer} [options.randomBytes]
|
|
72
|
+
*/
|
|
73
|
+
export function generateRandomPassword(options = {}) {
|
|
74
|
+
const randomBytes = options.randomBytes ?? ((size) => crypto.randomBytes(size))
|
|
75
|
+
const length = Math.min(Math.max(Number(options.length ?? 24), 16), 128)
|
|
76
|
+
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_'
|
|
77
|
+
const bytes = randomBytes(length * 2)
|
|
78
|
+
let out = ''
|
|
79
|
+
for (let index = 0; index < bytes.length && out.length < length; index += 1) {
|
|
80
|
+
const value = bytes[index]
|
|
81
|
+
if (value < 256 - (256 % alphabet.length)) out += alphabet[value % alphabet.length]
|
|
82
|
+
}
|
|
83
|
+
return { password: out, bits: Math.round(out.length * Math.log2(alphabet.length)), words: 0 }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 在服务器上设置这条口令的命令(用户直接粘贴执行)。
|
|
88
|
+
*
|
|
89
|
+
* 口令通过 **stdin** 传给 htpasswd(`-i`),不出现在命令行参数里 ——
|
|
90
|
+
* 也就不会进 `ps` 与 shell history。
|
|
91
|
+
*
|
|
92
|
+
* @param {object} options
|
|
93
|
+
* @param {string} [options.user='dsh']
|
|
94
|
+
* @param {string} [options.authFile='/etc/nginx/.htpasswd-dsh']
|
|
95
|
+
* @param {string} options.password
|
|
96
|
+
*/
|
|
97
|
+
export function setupCommands(options) {
|
|
98
|
+
const user = options.user ?? 'dsh'
|
|
99
|
+
const authFile = options.authFile ?? '/etc/nginx/.htpasswd-dsh'
|
|
100
|
+
const quoted = JSON.stringify(options.password)
|
|
101
|
+
return {
|
|
102
|
+
// -i 从 stdin 读口令(不进 argv/history);-B 用 bcrypt(默认的 $apr1$ 太弱);
|
|
103
|
+
// 不加 -c:-c 是"重建文件",会把里面别的用户清掉
|
|
104
|
+
htpasswd: [
|
|
105
|
+
'printf %s ' + quoted + ' | sudo htpasswd -i -B ' + authFile + ' ' + user,
|
|
106
|
+
'sudo chmod 640 ' + authFile,
|
|
107
|
+
'(sudo chown root:www-data ' + authFile + ' 2>/dev/null || true)',
|
|
108
|
+
].join(' && '),
|
|
109
|
+
firstTime: [
|
|
110
|
+
'printf %s ' + quoted + ' | sudo htpasswd -i -B -c ' + authFile + ' ' + user,
|
|
111
|
+
'sudo chmod 640 ' + authFile,
|
|
112
|
+
].join(' && '),
|
|
113
|
+
openssl: [
|
|
114
|
+
'# openssl 只能做 $apr1$(比 bcrypt 弱);能用 htpasswd -B 就别用这条',
|
|
115
|
+
'HASH=$(printf %s ' + quoted + ' | openssl passwd -apr1 -stdin)',
|
|
116
|
+
"printf '%s:%s\\n' " + JSON.stringify(user) + ' "$HASH" | sudo tee ' + authFile + ' >/dev/null',
|
|
117
|
+
'sudo chmod 640 ' + authFile,
|
|
118
|
+
'sudo nginx -t && sudo systemctl reload nginx',
|
|
119
|
+
].join(' && '),
|
|
120
|
+
verify: "curl -sS -o /dev/null -w '%{http_code}\\n' https://<域名>/ # 期望 401",
|
|
121
|
+
rotateNote: '改完不用 reload(nginx 每个请求都重读该文件),秒级生效',
|
|
122
|
+
}
|
|
123
|
+
}
|