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,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主侧文案目录(CLI 与插件面板共用一份,避免同一句话写两遍)。
|
|
3
|
+
*
|
|
4
|
+
* 分工:
|
|
5
|
+
* - 这里放**宿主生成**的文案:前置检查(DNS/TLS/HTTPS/隧道)与 CLI 提示。
|
|
6
|
+
* - 面板自己的界面文案在 `lib/client.js` 的 zh/en 字典里(组件只通过 t() 取词)。
|
|
7
|
+
* - 隧道运行状态不发文案,只发 `code` + `params`,由调用方按自己的语言渲染。
|
|
8
|
+
*
|
|
9
|
+
* 键集事实来源是 `en`:新增键时先写 en,再补 zh,`npm test` 会检查两份键集一致。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-plugin-remote-connect-beta/core/messages
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 支持的语言(与 harness 的 locale 服务一致)。 */
|
|
15
|
+
export const LOCALES = ['en', 'zh']
|
|
16
|
+
|
|
17
|
+
/** 兜底语言:键缺失或语言未知时用它。 */
|
|
18
|
+
export const DEFAULT_LOCALE = 'en'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 把任意 BCP 47 风格的语言标记收敛到受支持的取值。
|
|
22
|
+
* @param {unknown} value
|
|
23
|
+
* @returns {'en'|'zh'|undefined} 无法识别时返回 undefined(交给调用方兜底)
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeLocale(value) {
|
|
26
|
+
if (typeof value !== 'string') return undefined
|
|
27
|
+
const lower = value.trim().toLowerCase().replace(/_/g, '-')
|
|
28
|
+
if (lower === '') return undefined
|
|
29
|
+
const primary = lower.split('-')[0]
|
|
30
|
+
return LOCALES.includes(primary) ? primary : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从环境变量推断语言:显式覆盖 > LC_ALL > LC_MESSAGES > LANG。
|
|
35
|
+
* @param {Record<string, string|undefined>} [env=process.env]
|
|
36
|
+
* @returns {'en'|'zh'} 从不返回 undefined —— CLI 需要一个确定的结果
|
|
37
|
+
*/
|
|
38
|
+
export function resolveLocale(env = process.env) {
|
|
39
|
+
const candidates = [env.DSH_REMOTE_LANG, env.LC_ALL, env.LC_MESSAGES, env.LANG]
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
const locale = normalizeLocale(candidate)
|
|
42
|
+
if (locale !== undefined) return locale
|
|
43
|
+
}
|
|
44
|
+
return DEFAULT_LOCALE
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 英文文案(键集的事实来源)。 */
|
|
48
|
+
const en = {
|
|
49
|
+
// ---- 前置检查:条目名 ----
|
|
50
|
+
'check.dns.name': 'DNS',
|
|
51
|
+
'check.tls.name': 'TLS certificate',
|
|
52
|
+
'check.https.name': 'HTTPS and edge password',
|
|
53
|
+
'check.tunnel.name': 'ssh reverse tunnel',
|
|
54
|
+
'check.tailscale.name': 'tailscale funnel',
|
|
55
|
+
'check.meta.name': 'Harness upstream',
|
|
56
|
+
|
|
57
|
+
// ---- DNS ----
|
|
58
|
+
'preflight.dns.ok': '{domain} -> {addresses}',
|
|
59
|
+
'preflight.dns.none': '{domain} has no DNS records',
|
|
60
|
+
'preflight.dns.none.hint': 'Add an A record at your DNS provider pointing at the server public IP',
|
|
61
|
+
'preflight.dns.mismatch': '{domain} resolves to {addresses}, not the expected {expectIp}',
|
|
62
|
+
'preflight.dns.mismatch.hint': 'Check the record; a CDN proxy would hide the origin (set DNS only first)',
|
|
63
|
+
'preflight.dns.failed': 'Lookup of {domain} failed: {code}',
|
|
64
|
+
'preflight.dns.failed.hint': 'The name may not be configured yet, or DNS has not propagated',
|
|
65
|
+
|
|
66
|
+
// ---- TLS ----
|
|
67
|
+
'preflight.tls.ok': 'Certificate valid until {date}',
|
|
68
|
+
'preflight.tls.expiring.hint': 'The certificate expires within 7 days; renew it now',
|
|
69
|
+
'preflight.tls.nocert': 'No certificate was presented',
|
|
70
|
+
'preflight.tls.nocert.hint': 'The server has no certificate for this domain yet',
|
|
71
|
+
'preflight.tls.uncovered': 'Certificate does not cover {domain} (SAN: {names})',
|
|
72
|
+
'preflight.tls.uncovered.hint': 'Extend your existing certificate to include this domain, or use a covered one',
|
|
73
|
+
'preflight.tls.chain': 'Certificate chain did not verify (self-signed or incomplete)',
|
|
74
|
+
'preflight.tls.chain.hint': 'Reissue with certbot and install the full chain',
|
|
75
|
+
'preflight.tls.timeout': 'Connection to {domain}:{port} timed out',
|
|
76
|
+
'preflight.tls.timeout.hint': 'Make sure 443 is open inbound and not blocked by a firewall or cloud security group',
|
|
77
|
+
'preflight.tls.failed': 'TLS connection failed: {code}',
|
|
78
|
+
'preflight.tls.failed.hint': 'Check that 443 is open, a certificate is installed and DNS resolves',
|
|
79
|
+
|
|
80
|
+
// ---- HTTPS / 边缘口令 ----
|
|
81
|
+
'preflight.https.requestFailed': 'HTTPS request failed: {error}',
|
|
82
|
+
'preflight.https.requestFailed.hint': 'Fix DNS / port 443 / certificate first',
|
|
83
|
+
'preflight.https.noAuth': 'Without credentials the server returned {status}, expected 401 (edge password inactive)',
|
|
84
|
+
'preflight.https.noAuth.hint': 'Check that nginx auth_basic or Caddy basic_auth was reloaded',
|
|
85
|
+
'preflight.https.skipped': 'Without credentials: 401 (edge password works); no credentials given, skipping the authenticated check',
|
|
86
|
+
'preflight.https.still401': 'Still 401 with credentials',
|
|
87
|
+
'preflight.https.still401.hint': 'Wrong user or password, or the htpasswd path/permissions are wrong',
|
|
88
|
+
'preflight.https.ok': 'With credentials: {status} (404 is normal; the local access key ?k= is still required)',
|
|
89
|
+
|
|
90
|
+
// ---- ssh 反向隧道实连 ----
|
|
91
|
+
'preflight.tunnel.spawnFailed': 'Could not start ssh: {message}',
|
|
92
|
+
'preflight.tunnel.spawnFailed.hint': 'Make sure an ssh client is installed on this machine',
|
|
93
|
+
'preflight.tunnel.notKept': 'The ssh tunnel did not stay up (exit {code}): {output}',
|
|
94
|
+
'preflight.tunnel.notKept.hint.denied': 'The server rejected the key: check the authorized_keys content and its 600 permissions',
|
|
95
|
+
'preflight.tunnel.existing': 'Remote port {remotePort} is already held by a tunnel on this machine (pid {pid}) — the entry is up',
|
|
96
|
+
'preflight.tunnel.existing.hint': 'That is the tunnel that is serving traffic right now. To run a real reconnect test, stop it first (the plugin will start a new one), then check again',
|
|
97
|
+
'preflight.tunnel.notKept.hint.listen': 'Remote port {remotePort} is unusable — either it is already held by another tunnel on this machine, or the server does not allow it. Check the running tunnels first; only then look at permitlisten 127.0.0.1:{remotePort} in authorized_keys',
|
|
98
|
+
'preflight.tunnel.notKept.hint.forwarding': 'sshd disabled forwarding on the server: AllowTcpForwarding yes is required',
|
|
99
|
+
'preflight.tunnel.notKept.hint.generic': 'Check the network, the port and the sshd configuration',
|
|
100
|
+
'preflight.tunnel.ok': 'ssh tunnel established and held for {seconds}s (remote port 127.0.0.1:{remotePort})',
|
|
101
|
+
|
|
102
|
+
// ---- tailscale funnel 实连 ----
|
|
103
|
+
'preflight.tailscale.ok': 'tailscale funnel is serving {url}',
|
|
104
|
+
'preflight.tailscale.failed': 'tailscale funnel did not come up: {output}',
|
|
105
|
+
'preflight.tailscale.failed.hint.missing': 'Install the tailscale client on this machine first',
|
|
106
|
+
'preflight.tailscale.failed.hint.loggedout': 'Run `tailscale up` and sign in to your tailnet',
|
|
107
|
+
'preflight.tailscale.failed.hint.disabled': 'Enable Funnel for this node in the Tailscale admin console (HTTPS certificates must be on)',
|
|
108
|
+
'preflight.tailscale.failed.hint.generic': 'Run `tailscale funnel status` on this machine to see the full output',
|
|
109
|
+
|
|
110
|
+
// ---- 多租户:每个租户一个独立 Harness 实例 ----
|
|
111
|
+
'tenant.idle': 'Not started',
|
|
112
|
+
'tenant.starting': 'Starting a Harness instance for tenant {id} (port {port})…',
|
|
113
|
+
'tenant.up': 'Tenant {id} is serving on 127.0.0.1:{port}',
|
|
114
|
+
'tenant.stopped': 'Tenant {id} stopped',
|
|
115
|
+
'tenant.restarting': 'Tenant {id} exited ({reason}); restarting in {seconds}s',
|
|
116
|
+
'tenant.crashed': 'Tenant {id} kept crashing ({reason}); giving up — fix it, then start it again',
|
|
117
|
+
'tenant.spawnFailed': 'Could not start the tenant Harness: {message}',
|
|
118
|
+
'tenant.ready': 'Tenant {id} ready: {url}',
|
|
119
|
+
'gate.down.title': 'This entry cannot reach the Harness right now',
|
|
120
|
+
'gate.down.body': 'The tunnel or the local Harness is not answering, so there is nothing to show. Open the plugin panel on the host machine and check the tunnel state (the health endpoint reports it too).',
|
|
121
|
+
'gate.nokey.title': 'No access key was sent',
|
|
122
|
+
'gate.nokey.body': 'This address only answers a link that carries an access key (the ?k= part). Open the link you were given, or scan the QR code again.',
|
|
123
|
+
'gate.bad.title': 'That access key is not correct',
|
|
124
|
+
'gate.bad.body': 'The value after ?k= does not match this machine\'s current key. It looks like a truncated or mistyped link — copy it again in full, or ask for a fresh one.',
|
|
125
|
+
'gate.unusable.title': 'That access key is no longer valid',
|
|
126
|
+
'gate.unusable.body': 'The key has the right shape but is not the current one: it was rotated (or reset) after that link was made. Ask for the current link and scan again.',
|
|
127
|
+
'gate.longLived': 'The access key itself does not expire; only the browser session it issues lasts 12 hours. Rotating or resetting the key invalidates every earlier link at once.',
|
|
128
|
+
'gate.invalid.title': 'This access key is no longer valid',
|
|
129
|
+
'gate.invalid.body':
|
|
130
|
+
'The link you opened carries an access key that this machine does not accept any more. It was probably rotated. Ask the owner for the current link (or scan the QR code again).',
|
|
131
|
+
'gate.invalid.hint':
|
|
132
|
+
'This is the second gate of the remote entry. A wrong or expired key is answered with 404 on purpose, so that a stranger who merely guesses the address learns nothing.',
|
|
133
|
+
'gateway.tenantNotReady': 'This tenant\'s Harness is not running yet (tenant {id}). Start it in the host window, then reload.',
|
|
134
|
+
|
|
135
|
+
// ---- 隧道运行状态(host 发码,面板/CLI 按语言渲染)----
|
|
136
|
+
'tunnel.idle': 'Not started',
|
|
137
|
+
'tunnel.connecting': 'Starting {command}…',
|
|
138
|
+
'tunnel.up': 'Tunnel connected',
|
|
139
|
+
'tunnel.stopped': 'Stopped',
|
|
140
|
+
'tunnel.reconnecting': 'Tunnel dropped ({reason}); reconnecting in {seconds}s',
|
|
141
|
+
'tunnel.spawnFailed': 'Cannot execute {command}: {message}',
|
|
142
|
+
'tunnel.urlDiscovered': 'Public address: {url}',
|
|
143
|
+
'tunnel.funnelUp': 'Funnel is serving {url}',
|
|
144
|
+
'tunnel.funnelFailed': 'tailscale funnel failed: {output}',
|
|
145
|
+
'tunnel.funnelOff': 'Funnel mapping removed',
|
|
146
|
+
|
|
147
|
+
// ---- 宿主侧接口错误(面板/CLI 都会看到,按请求语言渲染)----
|
|
148
|
+
'host.error.keyMismatch': 'Current password is incorrect ({remaining} attempts left)',
|
|
149
|
+
'host.error.keyBlocked': 'Too many failed attempts — try again in {minutes} minute(s)',
|
|
150
|
+
'host.error.keyConfirmMismatch': 'The two new passwords do not match',
|
|
151
|
+
'host.error.keySameAsOld': 'The new password must differ from the current one',
|
|
152
|
+
'host.error.keyResetNeedsAck': 'Resetting needs an explicit confirmation (it invalidates old links and sessions)',
|
|
153
|
+
'host.error.keyWeak': 'That password is too weak: {reasons}',
|
|
154
|
+
'host.error.tenantsOff': 'Multi-tenancy is not enabled (set tenants.enabled in the plugin config)',
|
|
155
|
+
'host.error.tenantIdRequired': 'A tenant id is required',
|
|
156
|
+
'host.error.tenantUnknown': 'No such tenant: {id}',
|
|
157
|
+
'host.error.needDomain': 'public.domain is not configured, so the public entry cannot start',
|
|
158
|
+
'host.error.needDomainCheck': 'public.domain is not configured; nothing to check (tailscale mode needs no domain)',
|
|
159
|
+
'host.error.gate.lan': 'LAN visitors cannot toggle services: use the DSH window on the host machine',
|
|
160
|
+
'host.error.gate.public': 'Public visitors cannot toggle services: use the DSH window on the host machine',
|
|
161
|
+
'host.error.busy': 'Another operation is in progress, please wait',
|
|
162
|
+
'host.error.method': 'Only {method} is supported',
|
|
163
|
+
'host.error.unknownRoute': 'Unknown endpoint: {route}',
|
|
164
|
+
|
|
165
|
+
// ---- CLI ----
|
|
166
|
+
'cli.error.tenantNameRequired': 'tenant add needs a name: --name "Alice" (or --id <id>)',
|
|
167
|
+
'cli.setup.edgePassword': 'Edge password for user {user} (generated once — save it in your password manager):',
|
|
168
|
+
'cli.setup.edgeApply': 'Apply it on the server without typing it again (~{bits} bit, works over stdin, never in ps/history):',
|
|
169
|
+
'cli.setup.edgePrompt': '--edge-password prompt: the installer will ask for the password interactively (it never reaches argv or shell history).',
|
|
170
|
+
'cli.setup.edgeNote': 'Day to day you only need the plugin\'s access password (the ?k= in the link); this edge password usually stays untouched.',
|
|
171
|
+
'cli.cred.title': 'Edge credential for the public entry',
|
|
172
|
+
'cli.cred.user': 'Username:',
|
|
173
|
+
'cli.cred.password': 'Password:',
|
|
174
|
+
'cli.cred.strength': '(≈ {bits} bit if guessed online; typeable on a phone)',
|
|
175
|
+
'cli.cred.step1': '1) Set it on the server (password goes in over stdin, never in the process list):',
|
|
176
|
+
'cli.cred.step1b': ' no apache2-utils? use openssl instead:',
|
|
177
|
+
'cli.cred.step2': '2) Verify the gate answers 401 without credentials:',
|
|
178
|
+
'cli.cred.warnSave': '• Save the password in your password manager now — this is the only time it is printed.',
|
|
179
|
+
'cli.cred.warnIndependent': '• Rotating this password does NOT affect the ?k= key, and vice versa.',
|
|
180
|
+
'cli.cred.warnNoUrl': '• Never put the password in the URL (https://user:pass@…) — browsers strip it and it leaks into history.',
|
|
181
|
+
'cli.multi.registry': 'Multi-tenant gateway: {count} tenant(s) in {file}',
|
|
182
|
+
'cli.multi.started': 'Tenant Harness instances started: {count}',
|
|
183
|
+
'cli.multi.tenant': 'Tenant {name} ({id}) entry:',
|
|
184
|
+
'cli.multi.note': 'Each link carries that tenant\'s access key. Instances stop when this command exits.',
|
|
185
|
+
'cli.tenant.empty': 'No tenants yet. Add one: dsh-remote tenant add --name "Alice"',
|
|
186
|
+
'cli.tenant.added': 'Tenant {id} created (home: {home})',
|
|
187
|
+
'cli.tenant.removed': 'Tenant {id} removed from the registry (their data is left at {home})',
|
|
188
|
+
'cli.tenant.rotated': 'New access key for tenant {id} (old links stop working immediately)',
|
|
189
|
+
'cli.tenant.key': 'Access key:',
|
|
190
|
+
'cli.tenant.lan': 'LAN entry:',
|
|
191
|
+
'cli.tenant.public': 'Public entry:',
|
|
192
|
+
'cli.tenant.noAutostart': ' (autostart off)',
|
|
193
|
+
'cli.tenant.instancesNote':
|
|
194
|
+
'Note: instances are owned by the process running the gateway (the DSH plugin, or `serve --multi`). Start/stop them in the host window panel.',
|
|
195
|
+
'cli.tenant.notFound': 'No such tenant: {id}',
|
|
196
|
+
'cli.error.tenantUnknownAction': 'Unknown tenant action: {action} (list | add | rm | rotate | key)',
|
|
197
|
+
'cli.usage.title': 'dsh-remote — LAN / public entry for the DSH Harness',
|
|
198
|
+
'cli.usage.serve': ' serve --public --key <key> --domain dsh.example.com --tunnel ssh --ssh-user dshtunnel --ssh-host dsh.example.com',
|
|
199
|
+
'cli.usage.check': ' dsh-remote check --domain dsh.example.com --user dsh --password *** --ssh-user dshtunnel --ssh-host dsh.example.com',
|
|
200
|
+
'cli.usage.tunnel': ' --tunnel <ssh|cloudflared|tailscale|none> also start a tunnel',
|
|
201
|
+
'cli.result.line': '{mark} {name}: {detail}',
|
|
202
|
+
'cli.result.hint': ' > {hint}',
|
|
203
|
+
'cli.result.summary': '{passed}/{total} checks passed',
|
|
204
|
+
'cli.error.needDomain': '{command} needs --domain <domain>',
|
|
205
|
+
'cli.error.needKey': 'serve --public needs --key <access key> (refusing to expose the Harness without a key gate)',
|
|
206
|
+
'cli.error.needSshHost': '--tunnel ssh needs --ssh-user and --ssh-host',
|
|
207
|
+
'cli.error.unknownCommand': 'Unknown command: {command}',
|
|
208
|
+
'cli.tunnel.started': 'Tunnel: {phase} (auto-reconnects if it drops)',
|
|
209
|
+
'cli.tunnel.url': 'Public address: {url}',
|
|
210
|
+
'cli.serve.started': 'Started.',
|
|
211
|
+
'cli.serve.listen': ' Listening: {listenHost}:{port} (upstream {upstream})',
|
|
212
|
+
'cli.serve.lan': ' LAN address: {url}',
|
|
213
|
+
'cli.serve.public': ' Public entry: {url}',
|
|
214
|
+
'cli.serve.tunnel': ' Tunnel: {phase} (auto-reconnects if it drops)',
|
|
215
|
+
'cli.serve.ctrlC': ' Ctrl-C to stop.',
|
|
216
|
+
'cli.serve.noKeyWarning': '! Access key gate disabled: the edge password is now the only protection — make sure nginx/Caddy auth is active.',
|
|
217
|
+
'cli.serve.stopping': 'Stopping…',
|
|
218
|
+
'cli.notice.zhOnly': 'Note: this command prints its detailed report in Chinese for now.',
|
|
219
|
+
'cli.help': [
|
|
220
|
+
'dsh-remote — a LAN / public entry for the DSH Harness',
|
|
221
|
+
'',
|
|
222
|
+
'Usage:',
|
|
223
|
+
' dsh-remote serve [options] start the proxy (optionally with a tunnel)',
|
|
224
|
+
' dsh-remote check [options] check DNS / certificate / edge password / ssh tunnel',
|
|
225
|
+
' dsh-remote snippets [options] print server config snippets (nginx / Caddy / authorized_keys)',
|
|
226
|
+
' dsh-remote keygen [options] create a tunnel-only key pair and print the restricted authorized_keys line',
|
|
227
|
+
' dsh-remote setup-server [options] render the server-side install script (prints only; never touches your server)',
|
|
228
|
+
' dsh-remote uninstall-server [options] render a standalone uninstall script (removing files undoes everything)',
|
|
229
|
+
' dsh-remote doctor [options] one-shot health report: upstream/token source, LAN entry, public checks, live certificate',
|
|
230
|
+
' dsh-remote tenant <list|add|rm|rotate|key> [options] tenants: one Harness instance each',
|
|
231
|
+
' dsh-remote credential [options] generate the edge password and print the server-side setup commands',
|
|
232
|
+
'',
|
|
233
|
+
'serve options:',
|
|
234
|
+
' --public public mode: bind 127.0.0.1 only and enforce the access key gate',
|
|
235
|
+
' --key <key> access key (required in public mode; becomes ...?k=<key>)',
|
|
236
|
+
' --allow-no-key explicitly drop the key gate in public mode (not recommended)',
|
|
237
|
+
' --domain <domain> only accept this Host (repeatable; recommended in public mode)',
|
|
238
|
+
' --port <port> proxy port (LAN default 8787, public default 8788)',
|
|
239
|
+
' --upstream <port> upstream Harness port (auto-discovered by default)',
|
|
240
|
+
' --token <token> explicit launch token (otherwise discovered from the log)',
|
|
241
|
+
' --no-mobile do not inject the mobile adaptation styles',
|
|
242
|
+
' --tunnel <ssh|cloudflared|tailscale|none> also start a tunnel',
|
|
243
|
+
' --ssh-user / --ssh-host / --ssh-key / --ssh-port / --remote-port tunnel parameters',
|
|
244
|
+
' (ssh-port defaults to 22; set it if sshd moved)',
|
|
245
|
+
' --json print the readiness payload as JSON',
|
|
246
|
+
' --multi multi-tenant gateway: send each ?k= to that tenant’s own Harness',
|
|
247
|
+
'',
|
|
248
|
+
'check options:',
|
|
249
|
+
' --domain <domain> --expect-ip <ip> --user <user> --password <password>',
|
|
250
|
+
' --expect-cert-sha256 <fingerprint> output of `openssl x509 -fingerprint -sha256` on the server',
|
|
251
|
+
' --ssh-user <user> --ssh-host <host> --ssh-key <key> --ssh-port <port>',
|
|
252
|
+
'',
|
|
253
|
+
'snippets options:',
|
|
254
|
+
' --domain <domain> --kind <nginx|caddy> --target-port <port>',
|
|
255
|
+
' --public-key "<ssh-ed25519 AAAA...>" also print the restricted authorized_keys line',
|
|
256
|
+
'',
|
|
257
|
+
'tenant options:',
|
|
258
|
+
' --registry <path> tenant registry JSON (default: $DSH_HOME/remote-connect/tenants.json)',
|
|
259
|
+
' --base-dir <dir> where each tenant’s DSH_HOME lives (default: ~/DSH-tenants)',
|
|
260
|
+
' --name <name> display name for `add` (the id is derived from it)',
|
|
261
|
+
' --id <id> explicit tenant id (2–32 lowercase letters/digits/hyphens)',
|
|
262
|
+
' --harness-bin <path> @deepseek-ai/dsh/lib/bin.js (auto-detected when omitted)',
|
|
263
|
+
' --node <path> a real node binary (NOT the desktop Electron shim)',
|
|
264
|
+
'',
|
|
265
|
+
'setup-server options:',
|
|
266
|
+
' --edge-user <name> edge username (default: dsh; renameable — the old entry is deleted)',
|
|
267
|
+
' --edge-password auto generate a password and print it once (default)',
|
|
268
|
+
' --edge-password prompt let the server-side installer ask for it interactively',
|
|
269
|
+
' --edge-password <text> use your own (validated: >=12 chars, not a common weak one)',
|
|
270
|
+
'',
|
|
271
|
+
'credential options:',
|
|
272
|
+
' --user <name> edge username (default: dsh)',
|
|
273
|
+
' --auth-file <path> htpasswd file on the server (default: /etc/nginx/.htpasswd-dsh)',
|
|
274
|
+
' --words <n> words in the passphrase (4–10, default 6; more is stronger)',
|
|
275
|
+
' --random ignore the word list: a 24-character random password instead',
|
|
276
|
+
' --json print the password and commands as JSON',
|
|
277
|
+
'',
|
|
278
|
+
' `tenant` only edits the registry: the Harness instances are owned by whoever runs the',
|
|
279
|
+
' gateway (the DSH plugin, or `serve --multi`). Start/stop them in the host window panel.',
|
|
280
|
+
'',
|
|
281
|
+
'Global:',
|
|
282
|
+
' --lang <en|zh> output language (default: DSH_REMOTE_LANG / LC_ALL / LANG, else English)',
|
|
283
|
+
].join('\n'),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 简体中文,键集与 en 一致。 */
|
|
287
|
+
const zh = {
|
|
288
|
+
'check.dns.name': 'DNS 解析',
|
|
289
|
+
'check.tls.name': 'TLS 证书',
|
|
290
|
+
'check.https.name': 'HTTPS 与边缘口令',
|
|
291
|
+
'check.tunnel.name': 'ssh 反向隧道',
|
|
292
|
+
'check.tailscale.name': 'tailscale funnel',
|
|
293
|
+
'check.meta.name': 'Harness 上游',
|
|
294
|
+
|
|
295
|
+
'preflight.dns.ok': '{domain} -> {addresses}',
|
|
296
|
+
'preflight.dns.none': '{domain} 没有解析结果',
|
|
297
|
+
'preflight.dns.none.hint': '到 DNS 服务商添加 A 记录指向服务器公网 IP',
|
|
298
|
+
'preflight.dns.mismatch': '{domain} 解析到 {addresses},不是预期的 {expectIp}',
|
|
299
|
+
'preflight.dns.mismatch.hint': '检查 DNS 记录,或注意是否开了 CDN 代理(建议先设 DNS only / 灰云)',
|
|
300
|
+
'preflight.dns.failed': '{domain} 解析失败:{code}',
|
|
301
|
+
'preflight.dns.failed.hint': '域名可能还没配置或还没生效(TTL 传播需要时间)',
|
|
302
|
+
|
|
303
|
+
'preflight.tls.ok': '证书有效至 {date}',
|
|
304
|
+
'preflight.tls.expiring.hint': '证书 7 天内过期,尽快续签',
|
|
305
|
+
'preflight.tls.nocert': '未拿到证书',
|
|
306
|
+
'preflight.tls.nocert.hint': '服务器上还没为该域名配置证书',
|
|
307
|
+
'preflight.tls.uncovered': '证书不覆盖 {domain}(SAN: {names})',
|
|
308
|
+
'preflight.tls.uncovered.hint': '给现有证书扩签该域名,或改用一个被覆盖的域名',
|
|
309
|
+
'preflight.tls.chain': '证书链校验未通过(可能是自签或链不全)',
|
|
310
|
+
'preflight.tls.chain.hint': '用 certbot 等重新签发,确保 fullchain 完整',
|
|
311
|
+
'preflight.tls.timeout': '连接 {domain}:{port} 超时',
|
|
312
|
+
'preflight.tls.timeout.hint': '确认服务器 443 可入站、没被防火墙或云安全组挡住',
|
|
313
|
+
'preflight.tls.failed': 'TLS 连接失败:{code}',
|
|
314
|
+
'preflight.tls.failed.hint': '确认 443 端口开放、证书已配置、DNS 已生效',
|
|
315
|
+
|
|
316
|
+
'preflight.https.requestFailed': 'HTTPS 请求失败:{error}',
|
|
317
|
+
'preflight.https.requestFailed.hint': '先解决 DNS / 443 / 证书问题',
|
|
318
|
+
'preflight.https.noAuth': '不带凭据返回 {status},期望 401(说明边缘口令没生效)',
|
|
319
|
+
'preflight.https.noAuth.hint': '检查 nginx 的 auth_basic / Caddy 的 basic_auth 是否已 reload',
|
|
320
|
+
'preflight.https.skipped': '不带凭据 401(边缘口令已生效);未提供凭据,跳过带凭据检查',
|
|
321
|
+
'preflight.https.still401': '带凭据仍返回 401',
|
|
322
|
+
'preflight.https.still401.hint': '用户名或口令不对,或 htpasswd 文件权限/路径不对',
|
|
323
|
+
'preflight.https.ok': '带凭据返回 {status}(404 属正常:还需要本机侧的访问密钥 ?k=)',
|
|
324
|
+
|
|
325
|
+
'preflight.tunnel.spawnFailed': 'ssh 启动失败:{message}',
|
|
326
|
+
'preflight.tunnel.spawnFailed.hint': '确认本机有 ssh 客户端',
|
|
327
|
+
'preflight.tunnel.notKept': 'ssh 隧道未能保持(exit {code}):{output}',
|
|
328
|
+
'preflight.tunnel.notKept.hint.denied': '公钥没被服务器接受:确认 authorized_keys 内容与权限(600)',
|
|
329
|
+
'preflight.tunnel.existing': '远端口 {remotePort} 已被本机的一条隧道占用(pid {pid})—— 入口本来就是通的',
|
|
330
|
+
'preflight.tunnel.existing.hint': '这条就是正在提供服务的隧道。想真的测一次重连,就先把它停掉(插件会自动重开一条)再检查',
|
|
331
|
+
'preflight.tunnel.notKept.hint.listen': '远端口 {remotePort} 用不了:可能是本机已有隧道占着,也可能是服务器没放行。先看有没有在跑的隧道,再去查 authorized_keys 的 permitlisten 是否恰好是 127.0.0.1:{remotePort}',
|
|
332
|
+
'preflight.tunnel.notKept.hint.forwarding': '服务器 sshd 关闭了转发:需要 AllowTcpForwarding yes',
|
|
333
|
+
'preflight.tunnel.notKept.hint.generic': '检查网络、端口与 sshd 配置',
|
|
334
|
+
'preflight.tunnel.ok': 'ssh 隧道已建立并保持 {seconds} 秒(远端口 127.0.0.1:{remotePort})',
|
|
335
|
+
|
|
336
|
+
'preflight.tailscale.ok': 'tailscale funnel 正在提供 {url}',
|
|
337
|
+
'preflight.tailscale.failed': 'tailscale funnel 未能建立:{output}',
|
|
338
|
+
'preflight.tailscale.failed.hint.missing': '先在本机安装 tailscale 客户端',
|
|
339
|
+
'preflight.tailscale.failed.hint.loggedout': '先 `tailscale up` 登录你的 tailnet',
|
|
340
|
+
'preflight.tailscale.failed.hint.disabled': '到 Tailscale 管理后台为这个节点启用 Funnel(需要打开 HTTPS 证书)',
|
|
341
|
+
'preflight.tailscale.failed.hint.generic': '在本机跑 `tailscale funnel status` 看完整输出',
|
|
342
|
+
|
|
343
|
+
'tunnel.idle': '未启动',
|
|
344
|
+
'tunnel.connecting': '正在启动 {command}…',
|
|
345
|
+
'tunnel.up': '隧道已连接',
|
|
346
|
+
'tunnel.stopped': '已停止',
|
|
347
|
+
'tunnel.reconnecting': '隧道退出({reason});{seconds}s 后重连',
|
|
348
|
+
'tunnel.spawnFailed': '无法执行 {command}:{message}',
|
|
349
|
+
'tunnel.urlDiscovered': '公网地址:{url}',
|
|
350
|
+
'tunnel.funnelUp': 'funnel 正在提供 {url}',
|
|
351
|
+
'tunnel.funnelFailed': 'tailscale funnel 失败:{output}',
|
|
352
|
+
'tunnel.funnelOff': '已撤销 funnel 映射',
|
|
353
|
+
|
|
354
|
+
'tenant.idle': '未启动',
|
|
355
|
+
'tenant.starting': '正在为租户 {id} 启动独立 Harness 实例(端口 {port})…',
|
|
356
|
+
'tenant.up': '租户 {id} 已在 127.0.0.1:{port} 提供服务',
|
|
357
|
+
'tenant.stopped': '租户 {id} 已停止',
|
|
358
|
+
'tenant.restarting': '租户 {id} 退出({reason});{seconds}s 后重启',
|
|
359
|
+
'tenant.crashed': '租户 {id} 反复崩溃({reason});已停手,处理完再手动启动',
|
|
360
|
+
'tenant.spawnFailed': '租户 Harness 启动失败:{message}',
|
|
361
|
+
'tenant.ready': '租户 {id} 就绪:{url}',
|
|
362
|
+
'gate.down.title': '这个入口现在连不上 Harness',
|
|
363
|
+
'gate.down.body': '隧道或本机 Harness 没有响应,所以没有内容可显示。请在宿主机的插件面板里看隧道状态(/_dsh/health 也会报告)。',
|
|
364
|
+
'gate.nokey.title': '这次请求没有带访问密钥',
|
|
365
|
+
'gate.nokey.body': '这个地址只回应带访问密钥的链接(就是 ?k= 那一段)。请打开别人给你的完整链接,或重新扫一次二维码。',
|
|
366
|
+
'gate.bad.title': '访问密钥不正确',
|
|
367
|
+
'gate.bad.body': '?k= 后面的值与这台机器当前的密钥不一致,看起来像是链接被截断或抄错了。请重新完整复制,或向对方要一条新的。',
|
|
368
|
+
'gate.unusable.title': '访问密钥已经失效',
|
|
369
|
+
'gate.unusable.body': '这个密钥形态是对的,但不是当前这一个:它是在这条链接生成之后被轮换(或重置)掉的。请要一条当前链接,重新扫码。',
|
|
370
|
+
'gate.longLived': '访问密钥本身长期有效,只有它换来的浏览器会话是 12 小时。轮换/重置密钥会让此前所有链接一次性失效。',
|
|
371
|
+
'gate.invalid.title': '这个访问密钥已经无效了',
|
|
372
|
+
'gate.invalid.body': '你打开的链接带的访问密钥已被轮换(房主换过密钥)。向房主要一条新链接,或者重新扫一次二维码即可。',
|
|
373
|
+
'gate.invalid.hint': '这是远程入口的第二道门。密钥不对或过期时故意返回 404,这样"只是猜到了地址"的人什么也看不到。',
|
|
374
|
+
'gateway.tenantNotReady': '该租户({id})的 Harness 还没起来:请在宿主窗口里启动后再刷新。',
|
|
375
|
+
|
|
376
|
+
'host.error.keyMismatch': '当前口令不正确(还可尝试 {remaining} 次)',
|
|
377
|
+
'host.error.keyBlocked': '失败次数过多,请 {minutes} 分钟后再试',
|
|
378
|
+
'host.error.keyConfirmMismatch': '两次输入的新口令不一致',
|
|
379
|
+
'host.error.keySameAsOld': '新口令不能和当前口令相同',
|
|
380
|
+
'host.error.keyResetNeedsAck': '重置需要显式确认(旧链接与旧会话会全部失效)',
|
|
381
|
+
'host.error.keyWeak': '这个口令太弱:{reasons}',
|
|
382
|
+
'host.error.tenantsOff': '多租户未启用(请在插件配置里打开 tenants.enabled)',
|
|
383
|
+
'host.error.tenantIdRequired': '缺少租户 id',
|
|
384
|
+
'host.error.tenantUnknown': '没有这个租户:{id}',
|
|
385
|
+
'host.error.needDomain': '未配置 public.domain,无法启动公网入口',
|
|
386
|
+
'host.error.needDomainCheck': '未配置 public.domain,没有可检查的域名(tailscale 模式不需要域名)',
|
|
387
|
+
'host.error.gate.lan': '局域网访客不能开关服务:请在宿主机的 DSH 窗口里操作',
|
|
388
|
+
'host.error.gate.public': '公网访客不能开关服务:请在宿主机的 DSH 窗口里操作',
|
|
389
|
+
'host.error.busy': '已有操作在进行,请稍候',
|
|
390
|
+
'host.error.method': '只支持 {method}',
|
|
391
|
+
'host.error.unknownRoute': '未知接口:{route}',
|
|
392
|
+
|
|
393
|
+
'cli.error.tenantNameRequired': 'tenant add 需要名字:--name "张三"(或 --id <id>)',
|
|
394
|
+
'cli.setup.edgePassword': '边缘口令(用户 {user},只生成这一次,请存进密码管理器):',
|
|
395
|
+
'cli.setup.edgeApply': '在服务器上应用它(约 {bits} bit,走 stdin,不会出现在 ps/历史里):',
|
|
396
|
+
'cli.setup.edgePrompt': '--edge-password prompt:安装脚本会交互式询问口令(不进 argv,也不进 shell 历史)。',
|
|
397
|
+
'cli.setup.edgeNote': '日常你只需要插件的「访问口令」(链接里的 ?k=);这把边缘口令平时不用碰。',
|
|
398
|
+
'cli.cred.title': '公网入口的边缘凭证',
|
|
399
|
+
'cli.cred.user': '用户名:',
|
|
400
|
+
'cli.cred.password': '口令:',
|
|
401
|
+
'cli.cred.strength': '(在线爆破约 {bits} bit;手机上手输得进去)',
|
|
402
|
+
'cli.cred.step1': '1) 在服务器上设置它(口令经 stdin 传入,不会出现在进程列表里):',
|
|
403
|
+
'cli.cred.step1b': ' 没有 apache2-utils?用 openssl 兜底:',
|
|
404
|
+
'cli.cred.step2': '2) 验证这道门:不带凭据应返回 401',
|
|
405
|
+
'cli.cred.warnSave': '• 现在就把口令存进密码管理器 —— 这是它唯一一次被打印出来。',
|
|
406
|
+
'cli.cred.warnIndependent': '• 轮换它不影响 ?k= 密钥;反过来也一样。',
|
|
407
|
+
'cli.cred.warnNoUrl': '• 别把口令写进 URL(https://user:pass@…):浏览器会剥离,而且会漏进历史记录。',
|
|
408
|
+
'cli.multi.registry': '多租户网关:{file} 里有 {count} 个租户',
|
|
409
|
+
'cli.multi.started': '已拉起租户实例:{count} 个',
|
|
410
|
+
'cli.multi.tenant': '租户 {name}({id})的入口:',
|
|
411
|
+
'cli.multi.note': '每条链接都带对应租户的访问密钥。本命令退出时,这些实例会一起停掉。',
|
|
412
|
+
'cli.tenant.empty': '还没有租户。加一个:dsh-remote tenant add --name "张三"',
|
|
413
|
+
'cli.tenant.added': '租户 {id} 已创建(home:{home})',
|
|
414
|
+
'cli.tenant.removed': '租户 {id} 已从注册表移除(他的数据仍留在 {home},没有替你删)',
|
|
415
|
+
'cli.tenant.rotated': '租户 {id} 的访问密钥已轮换(旧链接立刻失效)',
|
|
416
|
+
'cli.tenant.key': '访问密钥:',
|
|
417
|
+
'cli.tenant.lan': '局域网入口:',
|
|
418
|
+
'cli.tenant.public': '公网入口:',
|
|
419
|
+
'cli.tenant.noAutostart': '(未开自动拉起)',
|
|
420
|
+
'cli.tenant.instancesNote':
|
|
421
|
+
'注意:Harness 实例归"跑网关的那个进程"所有(DSH 插件或 serve --multi)。启停请在宿主窗口的面板里做。',
|
|
422
|
+
'cli.tenant.notFound': '没有这个租户:{id}',
|
|
423
|
+
'cli.error.tenantUnknownAction': '未知的租户操作:{action}(list | add | rm | rotate | key)',
|
|
424
|
+
'cli.usage.title': 'dsh-remote —— DSH Harness 的局域网 / 公网入口',
|
|
425
|
+
'cli.usage.serve': ' serve --public --key <密钥> --domain dsh.example.com --tunnel ssh --ssh-user dshtunnel --ssh-host dsh.example.com',
|
|
426
|
+
'cli.usage.check': ' dsh-remote check --domain dsh.example.com --user dsh --password *** --ssh-user dshtunnel --ssh-host dsh.example.com',
|
|
427
|
+
'cli.usage.tunnel': ' --tunnel <ssh|cloudflared|tailscale|none> 同时起隧道',
|
|
428
|
+
'cli.result.line': '{mark} {name}:{detail}',
|
|
429
|
+
'cli.result.hint': ' ↳ {hint}',
|
|
430
|
+
'cli.result.summary': '{passed}/{total} 项通过',
|
|
431
|
+
'cli.error.needDomain': '{command} 需要 --domain <域名>',
|
|
432
|
+
'cli.error.needKey': 'serve --public 需要 --key <访问密钥>(拒绝在没有密钥门的情况下暴露 Harness)',
|
|
433
|
+
'cli.error.needSshHost': '--tunnel ssh 需要同时给出 --ssh-user 与 --ssh-host',
|
|
434
|
+
'cli.error.unknownCommand': '未知命令:{command}',
|
|
435
|
+
'cli.tunnel.started': '隧道:{phase}(掉线会自动重连)',
|
|
436
|
+
'cli.tunnel.url': '公网地址:{url}',
|
|
437
|
+
'cli.help': [
|
|
438
|
+
'dsh-remote —— 给 DSH Harness 开一条局域网 / 公网入口',
|
|
439
|
+
'',
|
|
440
|
+
'用法:',
|
|
441
|
+
' dsh-remote serve [选项] 启动代理(可选同时起隧道)',
|
|
442
|
+
' dsh-remote check [选项] 检查域名 / 证书 / 边缘口令 / ssh 隧道',
|
|
443
|
+
' dsh-remote snippets [选项] 打印服务器配置片段(nginx / Caddy / authorized_keys)',
|
|
444
|
+
' dsh-remote keygen [选项] 生成隧道专用密钥并打印受限 authorized_keys 行',
|
|
445
|
+
' dsh-remote setup-server [选项] 生成服务器侧安装脚本(默认只打印,不碰你的服务器)',
|
|
446
|
+
' dsh-remote uninstall-server [选项] 生成独立卸载脚本(删文件即可撤销)',
|
|
447
|
+
' dsh-remote doctor [选项] 一条命令体检:上游/令牌来源、局域网入口、公网四检、证书线上生效性',
|
|
448
|
+
' dsh-remote tenant <list|add|rm|rotate|key> [选项] 管理租户(每人一个独立 Harness 实例)',
|
|
449
|
+
' dsh-remote credential [选项] 生成边缘口令,并打印在服务器上设置它的命令',
|
|
450
|
+
'',
|
|
451
|
+
'serve 选项:',
|
|
452
|
+
' --public 公网模式:只绑 127.0.0.1,并强制访问密钥门',
|
|
453
|
+
' --key <密钥> 访问密钥(公网模式必填;会拼成 ...?k=<密钥>)',
|
|
454
|
+
' --allow-no-key 公网模式下显式放弃密钥门(不推荐)',
|
|
455
|
+
' --domain <域名> 只接受该 Host(可重复;建议公网模式必填)',
|
|
456
|
+
' --port <端口> 代理监听端口(局域网默认 8787,公网默认 8788)',
|
|
457
|
+
' --upstream <端口> 上游 Harness 端口(默认自动发现)',
|
|
458
|
+
' --token <令牌> 显式指定启动令牌(默认从日志自动发现)',
|
|
459
|
+
' --no-mobile 不注入手机适配样式',
|
|
460
|
+
' --tunnel <ssh|cloudflared|tailscale|none> 同时起隧道',
|
|
461
|
+
' --ssh-user / --ssh-host / --ssh-key / --ssh-port / --remote-port 隧道参数',
|
|
462
|
+
' (ssh-port 默认 22;服务器改了 sshd 端口就必须给)',
|
|
463
|
+
' --json 以 JSON 打印就绪信息',
|
|
464
|
+
' --multi 多租户网关:按 ?k= 把每个人送到他自己的 Harness 实例',
|
|
465
|
+
'',
|
|
466
|
+
'check 选项:',
|
|
467
|
+
' --domain <域名> --expect-ip <IP> --user <用户名> --password <口令>',
|
|
468
|
+
' --expect-cert-sha256 <指纹> 服务器上 `openssl x509 -fingerprint -sha256` 的输出(安装脚本会打印)',
|
|
469
|
+
' --ssh-user <账号> --ssh-host <地址> --ssh-key <私钥> --ssh-port <端口>',
|
|
470
|
+
'',
|
|
471
|
+
'snippets 选项:',
|
|
472
|
+
' --domain <域名> --kind <nginx|caddy> --target-port <端口>',
|
|
473
|
+
' --public-key "<ssh-ed25519 AAAA...>" 一并打印 authorized_keys 限制行',
|
|
474
|
+
'',
|
|
475
|
+
'tenant 选项:',
|
|
476
|
+
' --registry <路径> 租户注册表 JSON(默认 $DSH_HOME/remote-connect/tenants.json)',
|
|
477
|
+
' --base-dir <目录> 每个租户的 DSH_HOME 放哪(默认 ~/DSH-tenants)',
|
|
478
|
+
' --name <名字> add 时的显示名(id 由名字推导)',
|
|
479
|
+
' --id <id> 显式指定租户 id(2–32 位小写字母/数字/连字符)',
|
|
480
|
+
' --harness-bin <路径> @deepseek-ai/dsh/lib/bin.js(不给就自动探测)',
|
|
481
|
+
' --node <路径> 真 node(不是桌面版的 Electron shim)',
|
|
482
|
+
'',
|
|
483
|
+
'setup-server 选项:',
|
|
484
|
+
' --edge-user <名字> 边缘用户名(默认 dsh;改名会同时删掉旧条目)',
|
|
485
|
+
' --edge-password auto 生成口令并只显示一次(默认)',
|
|
486
|
+
' --edge-password prompt 让服务器侧安装脚本交互式询问',
|
|
487
|
+
' --edge-password <口令> 用你自己的(会校验:≥12 位、非常见弱口令)',
|
|
488
|
+
'',
|
|
489
|
+
'credential 选项:',
|
|
490
|
+
' --user <名字> 边缘用户名(默认 dsh)',
|
|
491
|
+
' --auth-file <路径> 服务器上的 htpasswd 文件(默认 /etc/nginx/.htpasswd-dsh)',
|
|
492
|
+
' --words <n> 口令里的词数(4–10,默认 6;越多越强)',
|
|
493
|
+
' --random 不用词表,直接给 24 位随机串',
|
|
494
|
+
' --json 以 JSON 输出口令与命令',
|
|
495
|
+
'',
|
|
496
|
+
' 注意:tenant 只改注册表。Harness 实例归"跑网关的那个进程"所有(DSH 插件或 serve --multi),',
|
|
497
|
+
' 启停请在宿主窗口的面板里做。',
|
|
498
|
+
'',
|
|
499
|
+
'全局:',
|
|
500
|
+
' --lang <en|zh> 输出语言(默认按 DSH_REMOTE_LANG / LC_ALL / LANG,兜底英文)',
|
|
501
|
+
].join('\n'),
|
|
502
|
+
'cli.serve.started': '已启动。',
|
|
503
|
+
'cli.serve.listen': ' 监听:{listenHost}:{port}(上游 {upstream})',
|
|
504
|
+
'cli.serve.lan': ' 局域网地址:{url}',
|
|
505
|
+
'cli.serve.public': ' 公网入口:{url}',
|
|
506
|
+
'cli.serve.tunnel': ' 隧道:{phase}(掉线会自动重连)',
|
|
507
|
+
'cli.serve.ctrlC': ' Ctrl-C 停止。',
|
|
508
|
+
'cli.serve.noKeyWarning': '⚠ 已放弃访问密钥门:边缘口令是唯一防线,请确认 nginx/Caddy 的 auth 已生效。',
|
|
509
|
+
'cli.serve.stopping': '正在停止…',
|
|
510
|
+
'cli.notice.zhOnly': '(此命令暂无英文输出)',
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** 全部语言目录,键集必须一致(测试会断言)。 */
|
|
514
|
+
export const CATALOG = { en, zh }
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 用 `{name}` 占位符做极简插值:缺失的参数原样保留,便于一眼看出漏传。
|
|
518
|
+
* @param {string} template
|
|
519
|
+
* @param {Record<string, unknown>} [params]
|
|
520
|
+
*/
|
|
521
|
+
export function interpolate(template, params) {
|
|
522
|
+
if (params === undefined || params === null) return template
|
|
523
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) => {
|
|
524
|
+
const value = params[key]
|
|
525
|
+
return value === undefined || value === null ? whole : String(value)
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 取一条文案:目标语言缺失时退回 en,两边都没有时返回键名(不抛异常,界面不会因为漏词崩掉)。
|
|
531
|
+
* @param {'en'|'zh'} locale
|
|
532
|
+
* @param {string} key
|
|
533
|
+
* @param {Record<string, unknown>} [params]
|
|
534
|
+
*/
|
|
535
|
+
export function translate(locale, key, params) {
|
|
536
|
+
const table = CATALOG[locale] ?? CATALOG[DEFAULT_LOCALE]
|
|
537
|
+
const template = table[key] ?? CATALOG[DEFAULT_LOCALE][key]
|
|
538
|
+
if (template === undefined) return key
|
|
539
|
+
return interpolate(template, params)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 绑定一种语言,返回 `t(key, params)`。
|
|
544
|
+
* @param {unknown} [locale] 未知或缺失时按 DEFAULT_LOCALE 处理
|
|
545
|
+
*/
|
|
546
|
+
export function translator(locale) {
|
|
547
|
+
const resolved = normalizeLocale(locale) ?? DEFAULT_LOCALE
|
|
548
|
+
return (key, params) => translate(resolved, key, params)
|
|
549
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自己的路径约定(host 半与 CLI 共用,避免两边算出不同的位置)。
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-plugin-remote-connect-beta/core/paths
|
|
5
|
+
*/
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 插件状态目录(0600 语义):租户注册表、Cookie 签名密钥都落在这里。
|
|
13
|
+
* 默认跟 harness home 走(`$DSH_HOME/remote-connect`),没有 DSH_HOME 时退回 `~/.dsh`。
|
|
14
|
+
*/
|
|
15
|
+
export function pluginStateDir(env = process.env) {
|
|
16
|
+
const home =
|
|
17
|
+
typeof env.DSH_HOME === 'string' && env.DSH_HOME !== '' ? env.DSH_HOME : path.join(os.homedir(), '.dsh')
|
|
18
|
+
return path.join(home, 'remote-connect')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 默认的租户注册表位置。 */
|
|
22
|
+
export function defaultRegistryPath(env = process.env) {
|
|
23
|
+
return path.join(pluginStateDir(env), 'tenants.json')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Cookie 签名密钥的默认位置。 */
|
|
27
|
+
export function defaultGateSecretPath(env = process.env) {
|
|
28
|
+
return path.join(pluginStateDir(env), 'gate.secret')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cookie 签名密钥:读不到就生成一个并落盘(0600)。
|
|
33
|
+
*
|
|
34
|
+
* 为什么要独立于任何租户的 accessKey:多租户下轮换某个租户的密钥,
|
|
35
|
+
* 不能顺带把别人的登录态弄失效。为什么必须落盘:否则每次重启全员重新登录。
|
|
36
|
+
*
|
|
37
|
+
* @param {string} file
|
|
38
|
+
* @param {(line: string) => void} [onProblem] 落盘失败时的回调(不抛异常)
|
|
39
|
+
* @returns {{ secret: string, created: boolean, persisted: boolean }}
|
|
40
|
+
*/
|
|
41
|
+
export function loadOrCreateGateSecret(file, onProblem) {
|
|
42
|
+
try {
|
|
43
|
+
const existing = fs.readFileSync(file, 'utf8').trim()
|
|
44
|
+
if (existing.length >= 32) return { secret: existing, created: false, persisted: true }
|
|
45
|
+
} catch {
|
|
46
|
+
/* 首次运行:下面生成 */
|
|
47
|
+
}
|
|
48
|
+
const secret = crypto.randomBytes(32).toString('base64url')
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
51
|
+
fs.writeFileSync(file, secret + '\n', { mode: 0o600 })
|
|
52
|
+
return { secret, created: true, persisted: true }
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (typeof onProblem === 'function') {
|
|
55
|
+
onProblem('Cookie 签名密钥落盘失败(重启后所有会话要重新登录):' + String(error?.message ?? error))
|
|
56
|
+
}
|
|
57
|
+
return { secret, created: true, persisted: false }
|
|
58
|
+
}
|
|
59
|
+
}
|