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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务器侧安装器生成:把《任务书》里"6 项交付"折叠成**一个可审查、幂等、可卸载的脚本**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是"生成脚本"而不是"插件去改用户的服务器":
|
|
5
|
+
* * 用户的服务器是他的生产机,插件不该直接动它;
|
|
6
|
+
* * 脚本可以 `--dry-run` 先看、可以 `probe` 只探测、可以 `uninstall` 撤销;
|
|
7
|
+
* * 所有写入都落在"本脚本独占的文件"里(conf.d 一个文件 + sshd drop-in + deploy hook),
|
|
8
|
+
* 因此幂等 = 覆盖写,卸载 = 删文件。
|
|
9
|
+
*
|
|
10
|
+
* 生成前对所有插入 bash 的取值做**严格校验**:域名/用户名/路径都会被拼进脚本,
|
|
11
|
+
* 不校验就等于把命令行参数当代码执行。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-plugin-remote-connect-beta/core/serversetup
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const TEMPLATE_PATH = path.join(
|
|
20
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
21
|
+
'assets',
|
|
22
|
+
'server-setup.sh.tpl',
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const HOSTNAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i
|
|
26
|
+
const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/
|
|
27
|
+
const PATH_PATTERN = /^\/[A-Za-z0-9._/-]+$/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 校验并归一化生成参数。
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @returns {{ values: Record<string,string>, problems: string[] }}
|
|
33
|
+
*/
|
|
34
|
+
export function resolveServerSetupValues(options) {
|
|
35
|
+
const problems = []
|
|
36
|
+
const domain = String(options.domain ?? '')
|
|
37
|
+
if (!HOSTNAME_PATTERN.test(domain)) problems.push('domain 必须是合法主机名(例如 dsh.example.com)')
|
|
38
|
+
|
|
39
|
+
const tunnelUser = String(options.tunnelUser ?? 'dshtunnel')
|
|
40
|
+
if (!USERNAME_PATTERN.test(tunnelUser)) problems.push('tunnel-user 必须是合法 Linux 用户名')
|
|
41
|
+
|
|
42
|
+
const authUser = String(options.authUser ?? 'dsh')
|
|
43
|
+
if (!USERNAME_PATTERN.test(authUser)) problems.push('auth-user 必须是合法 Linux 用户名')
|
|
44
|
+
|
|
45
|
+
const remotePort = Number(options.remotePort ?? 8788)
|
|
46
|
+
if (!Number.isSafeInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
|
|
47
|
+
problems.push('remote-port 必须是 1–65535 的整数')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const paths = {
|
|
51
|
+
AUTH_FILE: String(options.authFile ?? '/etc/nginx/.htpasswd-dsh-remote'),
|
|
52
|
+
WEBROOT: String(options.webroot ?? '/var/www/html'),
|
|
53
|
+
NGINX_CONF: String(options.nginxConf ?? '/etc/nginx/conf.d/dsh-remote.conf'),
|
|
54
|
+
SSHD_DROPIN: String(options.sshdDropin ?? '/etc/ssh/sshd_config.d/60-dsh-remote.conf'),
|
|
55
|
+
DEPLOY_HOOK: String(options.deployHook ?? '/etc/letsencrypt/renewal-hooks/deploy/10-reload-web.sh'),
|
|
56
|
+
}
|
|
57
|
+
for (const [key, value] of Object.entries(paths)) {
|
|
58
|
+
if (!PATH_PATTERN.test(value)) problems.push(key + ' 必须是绝对路径且不含引号/空格/变量')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
problems,
|
|
63
|
+
values: {
|
|
64
|
+
DOMAIN: domain,
|
|
65
|
+
REMOTE_PORT: String(remotePort),
|
|
66
|
+
TUNNEL_USER: tunnelUser,
|
|
67
|
+
AUTH_USER: authUser,
|
|
68
|
+
...paths,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 生成服务器安装脚本全文。
|
|
75
|
+
* @param {object} options 见 resolveServerSetupValues
|
|
76
|
+
* @returns {string}
|
|
77
|
+
* @throws {Error} 参数不合法时抛出(生成物会被执行,宁可不生成)
|
|
78
|
+
*/
|
|
79
|
+
export function buildServerSetupScript(options) {
|
|
80
|
+
const { values, problems } = resolveServerSetupValues(options)
|
|
81
|
+
if (problems.length > 0) {
|
|
82
|
+
throw new Error('服务器安装脚本参数不合法:' + problems.join(';'))
|
|
83
|
+
}
|
|
84
|
+
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8')
|
|
85
|
+
let out = template
|
|
86
|
+
for (const [key, value] of Object.entries(values)) {
|
|
87
|
+
out = out.split('{{' + key + '}}').join(value)
|
|
88
|
+
}
|
|
89
|
+
const leftover = /\{\{[A-Z_]+\}\}/.exec(out)
|
|
90
|
+
if (leftover !== null) throw new Error('服务器安装脚本模板占位符未替换:' + leftover[0])
|
|
91
|
+
if (!out.endsWith('\n')) out += '\n'
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 生成**独立**的卸载脚本(不依赖安装脚本还在不在服务器上)。
|
|
97
|
+
* @param {object} options 见 resolveServerSetupValues
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function buildServerUninstallScript(options) {
|
|
101
|
+
const { values, problems } = resolveServerSetupValues(options)
|
|
102
|
+
if (problems.length > 0) {
|
|
103
|
+
throw new Error('卸载脚本参数不合法:' + problems.join(';'))
|
|
104
|
+
}
|
|
105
|
+
return [
|
|
106
|
+
'#!/usr/bin/env bash',
|
|
107
|
+
'# dsh-remote 服务器侧卸载(由 dsh-plugin-remote-connect-beta 生成)',
|
|
108
|
+
'# sudo bash server-uninstall.sh [--purge-user]',
|
|
109
|
+
'# 只删除本方案自己创建的文件;不动你的 nginx/sshd 主配置、不动 DNS、不动 Basic Auth 口令文件。',
|
|
110
|
+
'set -euo pipefail',
|
|
111
|
+
'',
|
|
112
|
+
'TUNNEL_USER="' + values.TUNNEL_USER + '"',
|
|
113
|
+
'NGINX_CONF="' + values.NGINX_CONF + '"',
|
|
114
|
+
'SSHD_DROPIN="' + values.SSHD_DROPIN + '"',
|
|
115
|
+
'DEPLOY_HOOK="' + values.DEPLOY_HOOK + '"',
|
|
116
|
+
'PURGE=0',
|
|
117
|
+
'[ "${1:-}" = "--purge-user" ] && PURGE=1',
|
|
118
|
+
'',
|
|
119
|
+
'[ "$(id -u)" = "0" ] || { echo "✖ 需要 root"; exit 1; }',
|
|
120
|
+
'for f in "$NGINX_CONF" "$SSHD_DROPIN" "$DEPLOY_HOOK"; do',
|
|
121
|
+
' if [ -f "$f" ]; then rm -f "$f"; echo "已删除 $f"; else echo "不存在,跳过 $f"; fi',
|
|
122
|
+
'done',
|
|
123
|
+
'if command -v nginx >/dev/null 2>&1; then nginx -t && systemctl reload nginx || true; fi',
|
|
124
|
+
'if command -v sshd >/dev/null 2>&1; then sshd -t && systemctl reload ssh || true; fi',
|
|
125
|
+
'if [ "$PURGE" = "1" ]; then',
|
|
126
|
+
' if id "$TUNNEL_USER" >/dev/null 2>&1; then userdel -r "$TUNNEL_USER"; echo "已删除账号 $TUNNEL_USER"; fi',
|
|
127
|
+
'else',
|
|
128
|
+
' echo "保留隧道账号 $TUNNEL_USER(加 --purge-user 可一并删除)"',
|
|
129
|
+
'fi',
|
|
130
|
+
'echo "完成。DNS 记录与 Basic Auth 口令文件未动(可能被其它服务共用)。"',
|
|
131
|
+
'',
|
|
132
|
+
].join('\n')
|
|
133
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务器侧配置片段生成:用户拿到就能贴。
|
|
3
|
+
* 目标是把公网域名 → 服务器回环端口(ssh -R 的远端口)→ 本机代理 串起来,
|
|
4
|
+
* 并保证流式(SSE)与 WebSocket 可用。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-plugin-remote-connect-beta/core/snippets
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** nginx 需要的 upgrade 映射(放 http 上下文)。 */
|
|
10
|
+
export const NGINX_LOG_FORMAT = [
|
|
11
|
+
'# ?k= 是敏感凭据:请求行里的密钥绝不能落盘',
|
|
12
|
+
'log_format dsh_nokey \'$remote_addr - $remote_user [$time_local] "$request_method $uri" $status\';',
|
|
13
|
+
].join('\n')
|
|
14
|
+
|
|
15
|
+
export const NGINX_UPGRADE_MAP = [
|
|
16
|
+
'map $http_upgrade $connection_upgrade {',
|
|
17
|
+
' default upgrade;',
|
|
18
|
+
" '' close;",
|
|
19
|
+
'}',
|
|
20
|
+
].join('\n')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成 nginx server block。
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {string} options.domain 公网域名,例如 dsh.example.com
|
|
26
|
+
* @param {number} [options.targetPort=8788] 服务器回环端口(与 ssh -R 的远端口一致)
|
|
27
|
+
* @param {string} [options.certPath] fullchain.pem 路径
|
|
28
|
+
* @param {string} [options.keyPath] privkey.pem 路径
|
|
29
|
+
* @param {string} [options.htpasswdPath='/etc/nginx/.htpasswd-dsh']
|
|
30
|
+
* @param {string} [options.authRealm='DSH']
|
|
31
|
+
* @param {number} [options.maxBodyMb=64] 放宽上传(Harness 会传图/附件)
|
|
32
|
+
* @param {boolean} [options.logRedaction=true] 用脱敏 format 单独记日志(避免 ?k= 落盘)
|
|
33
|
+
*/
|
|
34
|
+
export function nginxServerBlock(options) {
|
|
35
|
+
const domain = options.domain
|
|
36
|
+
const port = options.targetPort ?? 8788
|
|
37
|
+
const certPath = options.certPath ?? '/etc/letsencrypt/live/' + domain + '/fullchain.pem'
|
|
38
|
+
const keyPath = options.keyPath ?? '/etc/letsencrypt/live/' + domain + '/privkey.pem'
|
|
39
|
+
const htpasswdPath = options.htpasswdPath ?? '/etc/nginx/.htpasswd-dsh'
|
|
40
|
+
const realm = options.authRealm ?? 'DSH'
|
|
41
|
+
const maxBodyMb = options.maxBodyMb ?? 64
|
|
42
|
+
const logRedaction = options.logRedaction !== false
|
|
43
|
+
return [
|
|
44
|
+
'# ' + domain + ' —— DSH Harness 公网入口',
|
|
45
|
+
'# 1) upgrade map 与 log_format 放 http {} 上下文(conf.d/dsh-http.conf)',
|
|
46
|
+
'# 2) 刻意不提供 80 端口 server block:http→https 与 ACME challenge 由现有默认 server 承担;',
|
|
47
|
+
'# 另加精确 server_name 的 80 块会顶掉 /.well-known/acme-challenge/,导致签发/续期失败',
|
|
48
|
+
'',
|
|
49
|
+
'server {',
|
|
50
|
+
' listen 443 ssl;',
|
|
51
|
+
' listen [::]:443 ssl;',
|
|
52
|
+
' http2 on;',
|
|
53
|
+
' server_name ' + domain + ';',
|
|
54
|
+
'',
|
|
55
|
+
' ssl_certificate ' + certPath + ';',
|
|
56
|
+
' ssl_certificate_key ' + keyPath + ';',
|
|
57
|
+
'',
|
|
58
|
+
' # 第一道门:边缘口令',
|
|
59
|
+
' auth_basic "' + realm + '";',
|
|
60
|
+
' auth_basic_user_file ' + htpasswdPath + ';',
|
|
61
|
+
'',
|
|
62
|
+
' client_max_body_size ' + String(maxBodyMb) + 'm;',
|
|
63
|
+
'',
|
|
64
|
+
logRedaction ? ' # ?k= 是敏感凭据,禁止完整请求行落盘(access_log off 亦可)' : ' # 未启用脱敏时,?k= 会明文进入访问日志',
|
|
65
|
+
logRedaction ? ' access_log /var/log/nginx/dsh.access.log dsh_nokey;' : ' access_log /var/log/nginx/dsh.access.log;',
|
|
66
|
+
'',
|
|
67
|
+
' location / {',
|
|
68
|
+
' proxy_pass http://127.0.0.1:' + String(port) + ';',
|
|
69
|
+
' proxy_http_version 1.1;',
|
|
70
|
+
'',
|
|
71
|
+
' proxy_set_header Host $host;',
|
|
72
|
+
' proxy_set_header X-Real-IP $remote_addr;',
|
|
73
|
+
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
|
|
74
|
+
' proxy_set_header X-Forwarded-Proto $scheme;',
|
|
75
|
+
' proxy_set_header Upgrade $http_upgrade;',
|
|
76
|
+
' proxy_set_header Connection $connection_upgrade;',
|
|
77
|
+
'',
|
|
78
|
+
' # ★ 关缓冲 + 放长超时:不关的话 Harness 的流式输出会卡死',
|
|
79
|
+
' proxy_buffering off;',
|
|
80
|
+
' proxy_request_buffering off;',
|
|
81
|
+
' proxy_read_timeout 3600s;',
|
|
82
|
+
' proxy_send_timeout 3600s;',
|
|
83
|
+
' proxy_connect_timeout 15s;',
|
|
84
|
+
'',
|
|
85
|
+
' proxy_redirect off;',
|
|
86
|
+
' }',
|
|
87
|
+
'}',
|
|
88
|
+
'',
|
|
89
|
+
'# 注意:这里刻意不提供 80 端口 server block —— 见文件头第 2 条。',
|
|
90
|
+
].join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 生成 Caddy 配置片段(Caddy 自带证书与流式处理)。
|
|
95
|
+
* @param {object} options
|
|
96
|
+
* @param {string} options.domain
|
|
97
|
+
* @param {number} [options.targetPort=8788]
|
|
98
|
+
* @param {string} [options.basicAuthUser='dsh']
|
|
99
|
+
* @param {string} [options.basicAuthHash] caddy hash-password 的输出
|
|
100
|
+
* @param {number} [options.maxBodyMb=64]
|
|
101
|
+
*/
|
|
102
|
+
export function caddySite(options) {
|
|
103
|
+
const port = options.targetPort ?? 8788
|
|
104
|
+
const user = options.basicAuthUser ?? 'dsh'
|
|
105
|
+
const hash = options.basicAuthHash ?? '$2a$14$REPLACE_WITH_caddy_hash_password_OUTPUT'
|
|
106
|
+
const maxBodyMb = options.maxBodyMb ?? 64
|
|
107
|
+
return [
|
|
108
|
+
options.domain + ' {',
|
|
109
|
+
' basic_auth {',
|
|
110
|
+
' ' + user + ' ' + hash,
|
|
111
|
+
' }',
|
|
112
|
+
' reverse_proxy 127.0.0.1:' + String(port) + ' {',
|
|
113
|
+
' flush_interval -1 # ★ 不缓冲,等价于 nginx 的 proxy_buffering off',
|
|
114
|
+
' }',
|
|
115
|
+
' request_body {',
|
|
116
|
+
' max_size ' + String(maxBodyMb) + 'MB',
|
|
117
|
+
' }',
|
|
118
|
+
'}',
|
|
119
|
+
].join('\n')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* authorized_keys 限制行:这把钥匙只能做转发,且只能监听指定回环端口。
|
|
124
|
+
* @param {string} publicKey 形如 "ssh-ed25519 AAAA... comment"
|
|
125
|
+
* @param {number} [targetPort=8788]
|
|
126
|
+
*/
|
|
127
|
+
export function authorizedKeysLine(publicKey, targetPort = 8788) {
|
|
128
|
+
const clean = publicKey.trim().replace(/\s+/g, ' ')
|
|
129
|
+
// remote-port-forwarding(而不是 port-forwarding):我们只用 -R,
|
|
130
|
+
// 这样连"本地转发 -L"都不放开;`restrict` 先关掉一切,再逐项放开。
|
|
131
|
+
return 'restrict,remote-port-forwarding,permitlisten="127.0.0.1:' + String(targetPort) + '" ' + clean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Mac/客户端侧要跑的 ssh 反向隧道命令。
|
|
136
|
+
* @param {object} options
|
|
137
|
+
* @param {string} options.user 服务器上的专用账号
|
|
138
|
+
* @param {string} options.host 服务器地址(域名或 IP)
|
|
139
|
+
* @param {string} [options.keyPath] 私钥路径
|
|
140
|
+
* @param {number} [options.localPort=8788] 本机监听端口(代理的公网口)
|
|
141
|
+
* @param {number} [options.remotePort=8788] 服务器回环端口(与反代目标一致)
|
|
142
|
+
* @param {number} [options.port=22] 服务器 sshd 端口(非 22 时必须显式给出)
|
|
143
|
+
*/
|
|
144
|
+
export function sshTunnelCommand(options) {
|
|
145
|
+
const localPort = options.localPort ?? 8788
|
|
146
|
+
const remotePort = options.remotePort ?? 8788
|
|
147
|
+
const port = options.port ?? 22
|
|
148
|
+
const parts = [
|
|
149
|
+
'ssh -N -T',
|
|
150
|
+
...(port === 22 ? [] : [' -p ' + String(port)]),
|
|
151
|
+
' -o ExitOnForwardFailure=yes',
|
|
152
|
+
' -o ServerAliveInterval=30 -o ServerAliveCountMax=3',
|
|
153
|
+
' -o StrictHostKeyChecking=yes',
|
|
154
|
+
]
|
|
155
|
+
if (options.keyPath) parts.push(' -i ' + options.keyPath)
|
|
156
|
+
parts.push(
|
|
157
|
+
' -R 127.0.0.1:' + String(remotePort) + ':127.0.0.1:' + String(localPort),
|
|
158
|
+
' ' + options.user + '@' + options.host,
|
|
159
|
+
)
|
|
160
|
+
return parts.join(' \\\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 服务器一次性准备步骤(给用户照抄)。 */
|
|
164
|
+
export function serverSetupSteps(options) {
|
|
165
|
+
const targetPort = options.targetPort ?? 8788
|
|
166
|
+
const user = options.user ?? 'dshtunnel'
|
|
167
|
+
return [
|
|
168
|
+
'在服务器上执行(Debian/Ubuntu 为例):',
|
|
169
|
+
'',
|
|
170
|
+
' # 1) 专用账号(不复用 root 或日常账号;隧道只用 -N -T,不需要登录 shell)',
|
|
171
|
+
' sudo useradd -m -s /usr/sbin/nologin ' + user,
|
|
172
|
+
' sudo -u ' + user + ' mkdir -p /home/' + user + '/.ssh',
|
|
173
|
+
' sudo -u ' + user + ' chmod 700 /home/' + user + '/.ssh',
|
|
174
|
+
'',
|
|
175
|
+
' # 2) 把下面这行 authorized_keys 限制写入(公钥用 dsh-remote keygen 生成)',
|
|
176
|
+
' # 见 `dsh-remote keygen` 的输出',
|
|
177
|
+
'',
|
|
178
|
+
' # 3) 确认 sshd 允许转发(被加固过的机器常被关掉)',
|
|
179
|
+
' sudo sshd -T | grep -Ei "allowtcpforwarding|gatewayports"',
|
|
180
|
+
' # 需要 allowtcpforwarding=yes(或 local,remote);gatewayports 保持 no 即可',
|
|
181
|
+
' # 建议同时设 ClientAliveInterval 30(长连接隧道更稳)',
|
|
182
|
+
'',
|
|
183
|
+
' # 4) Basic Auth 口令文件',
|
|
184
|
+
' sudo apt install -y apache2-utils',
|
|
185
|
+
' sudo htpasswd -c /etc/nginx/.htpasswd-dsh dsh',
|
|
186
|
+
'',
|
|
187
|
+
' # 5) 贴上 nginx 片段,然后',
|
|
188
|
+
' sudo nginx -t && sudo systemctl reload nginx',
|
|
189
|
+
'',
|
|
190
|
+
'目标:服务器回环 ' + String(targetPort) + ' 端口 ← 由 Mac 的 ssh -R 提供,',
|
|
191
|
+
' 再由 nginx/Caddy 用 HTTPS + Basic Auth 暴露成公网域名。',
|
|
192
|
+
].join('\n')
|
|
193
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailscale funnel 后端:自己机器就是出口,服务器侧零交付。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:
|
|
5
|
+
* - `tailscale funnel --bg` 是**一次性配置命令**(配完就退出,由 tailscaled 常驻),
|
|
6
|
+
* 与 ssh / cloudflared 那种"长驻子进程"看护模型不同,需要单独处理启动、探测与撤销。
|
|
7
|
+
* - argv 构造与输出解析都是纯函数,便于在没有 tailscale 的机器上测试。
|
|
8
|
+
*
|
|
9
|
+
* 用到的命令都只影响本机 tailscaled,不写任何系统服务、不改 sshd。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-plugin-remote-connect-beta/core/tailscale
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
/** funnel 默认对外暴露的 HTTPS 端口(由 tailscaled 提供证书)。 */
|
|
16
|
+
export const DEFAULT_HTTPS_PORT = 443
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 建立 funnel 映射:把本机回环端口发布为 `https://<node>.<tailnet>.ts.net/`。
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} [options.path='tailscale']
|
|
22
|
+
* @param {number} options.localPort 本机回环端口
|
|
23
|
+
* @param {number} [options.httpsPort=443]
|
|
24
|
+
*/
|
|
25
|
+
export function funnelUpArgv(options) {
|
|
26
|
+
const httpsPort = options.httpsPort ?? DEFAULT_HTTPS_PORT
|
|
27
|
+
return [
|
|
28
|
+
options.path ?? 'tailscale',
|
|
29
|
+
'funnel',
|
|
30
|
+
'--bg',
|
|
31
|
+
'--https=' + String(httpsPort),
|
|
32
|
+
'http://127.0.0.1:' + String(options.localPort),
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 撤销 funnel 映射(只撤这一个 https 端口,不碰其它 serve 配置)。
|
|
38
|
+
* @param {object} options
|
|
39
|
+
* @param {string} [options.path='tailscale']
|
|
40
|
+
* @param {number} [options.httpsPort=443]
|
|
41
|
+
*/
|
|
42
|
+
export function funnelOffArgv(options = {}) {
|
|
43
|
+
const httpsPort = options.httpsPort ?? DEFAULT_HTTPS_PORT
|
|
44
|
+
return [options.path ?? 'tailscale', 'funnel', '--https=' + String(httpsPort), 'off']
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `tailscale status --json`:拿本节点的 DNSName(funnel 对外域名)。 */
|
|
48
|
+
export function statusArgv(options = {}) {
|
|
49
|
+
return [options.path ?? 'tailscale', 'status', '--json']
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `tailscale funnel status`:给人看的当前 funnel 配置。 */
|
|
53
|
+
export function funnelStatusArgv(options = {}) {
|
|
54
|
+
return [options.path ?? 'tailscale', 'funnel', 'status']
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从 `tailscale status --json` 输出里取本节点域名。
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @returns {{ host: string, url: string } | null} 解析不出时返回 null
|
|
61
|
+
*/
|
|
62
|
+
export function parseSelfDnsName(text) {
|
|
63
|
+
let parsed
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
const name = parsed !== null && typeof parsed === 'object' ? parsed.Self?.DNSName : undefined
|
|
70
|
+
if (typeof name !== 'string' || name.trim() === '') return null
|
|
71
|
+
// DNSName 带结尾的点(FQDN),去掉才能拼 URL
|
|
72
|
+
const host = name.trim().replace(/\.$/, '')
|
|
73
|
+
if (!/^[A-Za-z0-9.-]+$/.test(host)) return null
|
|
74
|
+
return { host, url: 'https://' + host + '/' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 把 tailscale 的报错收敛成几个可翻译的原因码。
|
|
79
|
+
* @param {string} text stderr / stdout
|
|
80
|
+
* @returns {'missing'|'loggedout'|'disabled'|'generic'}
|
|
81
|
+
*/
|
|
82
|
+
export function classifyFunnelError(text) {
|
|
83
|
+
const lower = String(text ?? '').toLowerCase()
|
|
84
|
+
if (lower.includes('enoent') || lower.includes('executable file not found') || lower.includes('command not found')) {
|
|
85
|
+
return 'missing'
|
|
86
|
+
}
|
|
87
|
+
if (lower.includes('logged out') || lower.includes('not logged in') || lower.includes('needs login')) return 'loggedout'
|
|
88
|
+
if (lower.includes('funnel is not enabled') || lower.includes('not enabled') || lower.includes('access denied')) {
|
|
89
|
+
return 'disabled'
|
|
90
|
+
}
|
|
91
|
+
return 'generic'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 跑一条一次性命令,收齐输出。
|
|
96
|
+
* @param {string[]} argv
|
|
97
|
+
* @param {object} [options]
|
|
98
|
+
* @param {number} [options.timeoutMs=10000]
|
|
99
|
+
* @param {typeof spawn} [options.spawnImpl]
|
|
100
|
+
* @returns {Promise<{ code: number|null, stdout: string, stderr: string, error?: string }>}
|
|
101
|
+
*/
|
|
102
|
+
export async function runOnce(argv, options = {}) {
|
|
103
|
+
const spawnImpl = options.spawnImpl ?? spawn
|
|
104
|
+
const timeoutMs = options.timeoutMs ?? 10000
|
|
105
|
+
return await new Promise((resolve) => {
|
|
106
|
+
let child
|
|
107
|
+
try {
|
|
108
|
+
child = spawnImpl(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
109
|
+
} catch (error) {
|
|
110
|
+
resolve({ code: null, stdout: '', stderr: '', error: String(error?.message ?? error) })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
let stdout = ''
|
|
114
|
+
let stderr = ''
|
|
115
|
+
let settled = false
|
|
116
|
+
const finish = (result) => {
|
|
117
|
+
if (settled) return
|
|
118
|
+
settled = true
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
resolve(result)
|
|
121
|
+
}
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
try {
|
|
124
|
+
child.kill('SIGKILL')
|
|
125
|
+
} catch {
|
|
126
|
+
/* 已退出 */
|
|
127
|
+
}
|
|
128
|
+
finish({ code: null, stdout, stderr, error: 'timeout' })
|
|
129
|
+
}, timeoutMs)
|
|
130
|
+
child.stdout?.on('data', (chunk) => {
|
|
131
|
+
stdout = (stdout + chunk.toString('utf8')).slice(-8000)
|
|
132
|
+
})
|
|
133
|
+
child.stderr?.on('data', (chunk) => {
|
|
134
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-8000)
|
|
135
|
+
})
|
|
136
|
+
child.on('error', (error) => finish({ code: null, stdout, stderr, error: String(error?.message ?? error) }))
|
|
137
|
+
child.on('exit', (code) => finish({ code, stdout, stderr }))
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 读本节点对外地址(funnel 的域名来自节点名,不来自配置)。
|
|
143
|
+
* @param {object} [options]
|
|
144
|
+
* @param {string} [options.path]
|
|
145
|
+
* @param {typeof spawn} [options.spawnImpl]
|
|
146
|
+
* @returns {Promise<{ ok: boolean, host?: string, url?: string, detail?: string, reason?: string }>}
|
|
147
|
+
*/
|
|
148
|
+
export async function readSelfUrl(options = {}) {
|
|
149
|
+
const result = await runOnce(statusArgv({ path: options.path }), { spawnImpl: options.spawnImpl })
|
|
150
|
+
if (result.error !== undefined) return { ok: false, detail: result.error, reason: classifyFunnelError(result.error) }
|
|
151
|
+
if (result.code !== 0) {
|
|
152
|
+
const text = (result.stderr || result.stdout).trim()
|
|
153
|
+
return { ok: false, detail: text, reason: classifyFunnelError(text) }
|
|
154
|
+
}
|
|
155
|
+
const parsed = parseSelfDnsName(result.stdout)
|
|
156
|
+
if (parsed === null) return { ok: false, detail: 'no Self.DNSName in status output', reason: 'generic' }
|
|
157
|
+
return { ok: true, host: parsed.host, url: parsed.url }
|
|
158
|
+
}
|