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