dsh-zen-remote 2.3.10 → 2.4.0
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 +44 -72
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +573 -366
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +575 -370
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-4: the `host` subcommand family — `device pair|list|revoke` and
|
|
3
|
+
* `ingress start|stop|verify|snippet`. Every entry point passes the
|
|
4
|
+
* servesRole gate FIRST (the task discipline: a client never drives the
|
|
5
|
+
* server-owner surfaces; the message names the `setup` switch), then drives
|
|
6
|
+
* the plugin's own admin/wizard routes over IPC.
|
|
7
|
+
*
|
|
8
|
+
* The iron rule for ingress: the CLI NEVER spawns `caddy` or `cloudflared`
|
|
9
|
+
* itself — download, start, stop, status and the three-light verification
|
|
10
|
+
* all happen inside the plugin process through the
|
|
11
|
+
* `/_dsh/zen-remote/admin/wizard/*` routes (SPEC §1). Mode switches mirror
|
|
12
|
+
* `GatewayWizard.tsx`'s discipline: any managed ingress process that does
|
|
13
|
+
* not belong to the new mode is stopped through its stop route before the
|
|
14
|
+
* new mode's flow runs, managed caddy/cloudflared reset `host` back to
|
|
15
|
+
* `127.0.0.1`, and lan rebinds it to `0.0.0.0`.
|
|
16
|
+
*
|
|
17
|
+
* Interactivity follows the bootstrap iron rule: a missing value prompts in
|
|
18
|
+
* a TTY run and throws MissingRequiredOptionError otherwise — except the
|
|
19
|
+
* values with a SAVED fallback (domain, public URL), which the saved wizard
|
|
20
|
+
* status answers before any prompt.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { hostOf } from '../config.js'
|
|
24
|
+
import { normalizeServerUrl } from '../client-pairing.js'
|
|
25
|
+
import { ADMIN_STATUS_ROUTE, ADMIN_PAIR_ROUTE, ADMIN_ACTION_ROUTE } from '../admin-routes.js'
|
|
26
|
+
import {
|
|
27
|
+
WIZARD_STATUS_ROUTE,
|
|
28
|
+
WIZARD_TRUST_PROXY_ROUTE,
|
|
29
|
+
WIZARD_VERIFY_ROUTE,
|
|
30
|
+
WIZARD_CF_TOKEN_ROUTE,
|
|
31
|
+
WIZARD_CADDY_START_ROUTE,
|
|
32
|
+
WIZARD_CADDY_STOP_ROUTE,
|
|
33
|
+
WIZARD_TUNNEL_CHECK_ROUTE,
|
|
34
|
+
WIZARD_TUNNEL_SETUP_ROUTE,
|
|
35
|
+
WIZARD_TUNNEL_STOP_ROUTE,
|
|
36
|
+
} from '../wizard/routes.js'
|
|
37
|
+
import { nginxSnippet, caddySnippet, luckySteps } from '../wizard/proxy-snippets.js'
|
|
38
|
+
import { renderTerminalQr } from './qr-terminal.js'
|
|
39
|
+
import { promptOrFail, MissingRequiredOptionError } from './bootstrap.js'
|
|
40
|
+
import { fetchBody, plainOf } from './commands-status.js'
|
|
41
|
+
import type { ServerRow } from './commands-status.js'
|
|
42
|
+
import type { CliExecutionContext } from './context.js'
|
|
43
|
+
import type { CliTranslator } from './i18n.js'
|
|
44
|
+
|
|
45
|
+
const DEVICE_ROLES = ['web', 'desktop-client'] as const
|
|
46
|
+
const INGRESS_MODES = ['caddy', 'cloudflared', 'external', 'lan'] as const
|
|
47
|
+
const PROXY_KINDS = ['nginx', 'caddy', 'lucky'] as const
|
|
48
|
+
/** The default gateway port, same as config.ts's own default. */
|
|
49
|
+
const DEFAULT_GATEWAY_PORT = 3088
|
|
50
|
+
|
|
51
|
+
/** `host ingress verify`'s phone-pairing poll interval: a poll a second
|
|
52
|
+
* keeps the tunnel quiet; tests shrink it via the seam. */
|
|
53
|
+
const VERIFY_POLL_INTERVAL_DEFAULT_MS = 1000
|
|
54
|
+
/** `lan`'s gateway-restart readiness poll: frequent, because the reload is
|
|
55
|
+
* fast (config-writer triggers checkNow immediately). */
|
|
56
|
+
const LAN_POLL_INTERVAL_DEFAULT_MS = 300
|
|
57
|
+
|
|
58
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
59
|
+
|
|
60
|
+
// ===========================================================================
|
|
61
|
+
// Shared helpers
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The role gate every `host *` command passes first: a fresh ping (the role
|
|
66
|
+
* is a volatile row field), and anything that does not serve answers the
|
|
67
|
+
* bilingual refusal naming the current role.
|
|
68
|
+
*/
|
|
69
|
+
async function requireServesRole(ctx: CliExecutionContext): Promise<boolean> {
|
|
70
|
+
let serves = false
|
|
71
|
+
let role: 'host' | 'client' | 'peer' = 'client'
|
|
72
|
+
try {
|
|
73
|
+
const ping = await ctx.ipc.ping()
|
|
74
|
+
serves = ping.serves
|
|
75
|
+
role = ping.role
|
|
76
|
+
} catch {
|
|
77
|
+
// No live plugin is no role that serves either — same refusal text.
|
|
78
|
+
}
|
|
79
|
+
if (serves) return true
|
|
80
|
+
ctx.stdout.write(`${ctx.t('err.roleGate.hostRequired', { role })}\n`)
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One JSON object over IPC; every failure mode is `null` — "no
|
|
85
|
+
* information", never a thrown command. */
|
|
86
|
+
async function fetchRecord(ctx: CliExecutionContext, method: string, path: string, body?: unknown): Promise<ServerRow | null> {
|
|
87
|
+
return await fetchBody(() => ctx.ipc.request(method, path, body))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The anti-shadowing config write, reporting `env-shadowed` and generic
|
|
91
|
+
* failures through the translator. Shared with `command-setup.ts`'s role
|
|
92
|
+
* switch, which writes the same way. */
|
|
93
|
+
export async function writeConfigOrReport(ctx: CliExecutionContext, set: Record<string, unknown>): Promise<boolean> {
|
|
94
|
+
const { t } = ctx
|
|
95
|
+
const result = await ctx.ipc.writeConfig({ set })
|
|
96
|
+
const body = plainOf(result.body)
|
|
97
|
+
if (result.status !== 200 || body?.ok !== true) {
|
|
98
|
+
const error = plainOf(body?.error)
|
|
99
|
+
if (error?.code === 'env-shadowed') {
|
|
100
|
+
ctx.stdout.write(`${t('err.envShadowed', { field: String(error.field), envVar: String(error.envVar) })}\n`)
|
|
101
|
+
} else {
|
|
102
|
+
ctx.stdout.write(`${t('host.ingress.configWriteFailed')}\n`)
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One ask-text-or-throw: interactive runs prompt through the prompter,
|
|
110
|
+
* anything else raises the structured missing-option error. */
|
|
111
|
+
async function askText(ctx: CliExecutionContext, optionName: string, question: string): Promise<string> {
|
|
112
|
+
return await promptOrFail({
|
|
113
|
+
optionName,
|
|
114
|
+
interactive: ctx.interactive,
|
|
115
|
+
provided: undefined,
|
|
116
|
+
ask: async () => {
|
|
117
|
+
if (ctx.prompter === undefined) throw new MissingRequiredOptionError(optionName)
|
|
118
|
+
return await ctx.prompter.inputText(question, undefined, undefined)
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The gateway child's ACTUAL port (it may have retried past the configured
|
|
124
|
+
* one): the wizard status's live fact, else the admin status's gateway
|
|
125
|
+
* payload, else the configured `port`, else the 3088 default. */
|
|
126
|
+
function gatewayPortOf(wizard: ServerRow | null, admin: ServerRow | null): number {
|
|
127
|
+
if (typeof wizard?.gatewayActualPort === 'number') return wizard.gatewayActualPort
|
|
128
|
+
const gateway = plainOf(admin?.gateway)
|
|
129
|
+
if (typeof gateway?.port === 'number') return gateway.port
|
|
130
|
+
const config = plainOf(admin?.config)
|
|
131
|
+
const values = plainOf(config?.values)
|
|
132
|
+
if (typeof values?.port === 'number') return values.port
|
|
133
|
+
return DEFAULT_GATEWAY_PORT
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The address a phone / other machine can reach this host at: the saved
|
|
137
|
+
* public URL first, then the LAN address assembled with the gateway's
|
|
138
|
+
* actual port. Empty when neither exists. */
|
|
139
|
+
function publicUrlOf(wizard: ServerRow | null, admin: ServerRow | null): string {
|
|
140
|
+
const saved = typeof wizard?.publicUrl === 'string' ? wizard.publicUrl : ''
|
|
141
|
+
if (saved !== '') return saved
|
|
142
|
+
const lan = typeof wizard?.lanAddress === 'string' ? wizard.lanAddress : ''
|
|
143
|
+
if (lan === '') return ''
|
|
144
|
+
return `http://${lan}:${gatewayPortOf(wizard, admin)}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `YYYY-MM-DD HH:mm` in the local timezone, or a dash for absent stamps. */
|
|
148
|
+
function formatStamp(value: unknown): string {
|
|
149
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
|
|
150
|
+
const d = new Date(value)
|
|
151
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
152
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The paired-device rows of an admin/status answer, defensively filtered. */
|
|
156
|
+
function devicesOf(admin: ServerRow | null): ServerRow[] {
|
|
157
|
+
const gateway = plainOf(admin?.gateway)
|
|
158
|
+
if (!Array.isArray(gateway?.devices)) return []
|
|
159
|
+
return (gateway.devices as unknown[])
|
|
160
|
+
.map((device) => plainOf(device))
|
|
161
|
+
.filter((device): device is ServerRow => device !== null)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function deviceLineText(t: CliTranslator, device: ServerRow): string {
|
|
165
|
+
return t('host.device.deviceLine', {
|
|
166
|
+
id: String(device.id ?? ''),
|
|
167
|
+
name: String(device.name ?? ''),
|
|
168
|
+
deviceRole: String(device.role ?? ''),
|
|
169
|
+
kind: String(device.kind ?? ''),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ===========================================================================
|
|
174
|
+
// host device pair
|
|
175
|
+
// ===========================================================================
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `host device pair [--device-role web|desktop-client] [--json]` — mint one
|
|
179
|
+
* pairing code through the admin pair route and print it with everything
|
|
180
|
+
* the receiving side needs: the reachable URL (public URL, or the LAN
|
|
181
|
+
* address assembled with the gateway's actual port), a terminal QR for web
|
|
182
|
+
* devices, the copy-paste `client pair` command for desktop clients, and
|
|
183
|
+
* the code with its validity window (the gateway mints 10-minute codes).
|
|
184
|
+
*/
|
|
185
|
+
export async function runHostDevicePairCommand(ctx: CliExecutionContext): Promise<number> {
|
|
186
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
187
|
+
const { t, args, ipc } = ctx
|
|
188
|
+
const out = ctx.stdout
|
|
189
|
+
let deviceRole = args.options.deviceRole
|
|
190
|
+
if (deviceRole === undefined) {
|
|
191
|
+
if (ctx.interactive && ctx.prompter !== undefined) {
|
|
192
|
+
const picked = await ctx.prompter.selectOne(
|
|
193
|
+
t('host.device.prompt.role'),
|
|
194
|
+
[t('host.device.role.web'), t('host.device.role.desktop')],
|
|
195
|
+
0,
|
|
196
|
+
)
|
|
197
|
+
deviceRole = picked === 1 ? 'desktop-client' : 'web'
|
|
198
|
+
} else {
|
|
199
|
+
// Non-interactive runs default to the web device — the common case.
|
|
200
|
+
deviceRole = 'web'
|
|
201
|
+
}
|
|
202
|
+
} else if (!(DEVICE_ROLES as readonly string[]).includes(deviceRole)) {
|
|
203
|
+
out.write(`${t('host.device.error.badRole', { value: deviceRole })}\n`)
|
|
204
|
+
return 1
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (args.options.json) {
|
|
208
|
+
// --json is a machine-readable run: the structured answer only.
|
|
209
|
+
const pairJson = await ipc.request('POST', ADMIN_PAIR_ROUTE, { role: deviceRole })
|
|
210
|
+
const pairJsonBody = plainOf(pairJson.body)
|
|
211
|
+
const jsonCode = typeof pairJsonBody?.code === 'string' ? pairJsonBody.code : ''
|
|
212
|
+
const jsonExpiresAt = typeof pairJsonBody?.expiresAt === 'number' ? pairJsonBody.expiresAt : 0
|
|
213
|
+
if (pairJson.status < 200 || pairJson.status >= 300 || pairJsonBody?.ok !== true || jsonCode === '') {
|
|
214
|
+
out.write(`${JSON.stringify({ ok: false }, null, 2)}\n`)
|
|
215
|
+
return 1
|
|
216
|
+
}
|
|
217
|
+
const [jsonWizard, jsonAdmin] = await Promise.all([
|
|
218
|
+
fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
|
|
219
|
+
fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
|
|
220
|
+
])
|
|
221
|
+
const jsonUrl = publicUrlOf(jsonWizard, jsonAdmin)
|
|
222
|
+
out.write(`${JSON.stringify({
|
|
223
|
+
ok: true,
|
|
224
|
+
deviceRole,
|
|
225
|
+
code: jsonCode,
|
|
226
|
+
expiresAt: jsonExpiresAt,
|
|
227
|
+
role: deviceRole,
|
|
228
|
+
...(jsonUrl === '' ? {} : { url: jsonUrl }),
|
|
229
|
+
}, null, 2)}\n`)
|
|
230
|
+
return 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
out.write(`${t('host.device.minting')}\n`)
|
|
234
|
+
const pairResult = await ipc.request('POST', ADMIN_PAIR_ROUTE, { role: deviceRole })
|
|
235
|
+
const pairBody = plainOf(pairResult.body)
|
|
236
|
+
const code = typeof pairBody?.code === 'string' ? pairBody.code : ''
|
|
237
|
+
const expiresAt = typeof pairBody?.expiresAt === 'number' ? pairBody.expiresAt : 0
|
|
238
|
+
if (pairResult.status < 200 || pairResult.status >= 300 || pairBody?.ok !== true || code === '') {
|
|
239
|
+
out.write(`${t('host.device.pairFailed')}\n`)
|
|
240
|
+
return 1
|
|
241
|
+
}
|
|
242
|
+
const minutes = Math.max(1, Math.round((expiresAt - Date.now()) / 60_000))
|
|
243
|
+
|
|
244
|
+
const [wizard, admin] = await Promise.all([
|
|
245
|
+
fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
|
|
246
|
+
fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
|
|
247
|
+
])
|
|
248
|
+
const url = publicUrlOf(wizard, admin)
|
|
249
|
+
const desktopCode = typeof pairBody?.desktopCode === 'string' ? pairBody.desktopCode : ''
|
|
250
|
+
|
|
251
|
+
out.write(`${t('host.device.pairTitle')}\n`)
|
|
252
|
+
if (deviceRole === 'web') {
|
|
253
|
+
if (url !== '') {
|
|
254
|
+
out.write(`${t('setup.pairingQr')}\n`)
|
|
255
|
+
const qr = renderTerminalQr(url)
|
|
256
|
+
if (qr !== null) out.write(`${qr}\n`)
|
|
257
|
+
out.write(`${url}\n`)
|
|
258
|
+
} else {
|
|
259
|
+
out.write(`${t('host.device.noAddress')}\n`)
|
|
260
|
+
}
|
|
261
|
+
} else if (url !== '') {
|
|
262
|
+
out.write(`${t('host.device.desktopCommand')}\n`)
|
|
263
|
+
out.write(` dsh-zen-remote client pair ${url} --code ${code}\n`)
|
|
264
|
+
out.write(`${t('host.device.desktopWizard')}\n`)
|
|
265
|
+
} else {
|
|
266
|
+
out.write(`${t('host.device.noAddress')}\n`)
|
|
267
|
+
}
|
|
268
|
+
out.write(`${t('host.device.pairingCode', { code, minutes })}\n`)
|
|
269
|
+
if (deviceRole === 'web' && desktopCode !== '') {
|
|
270
|
+
out.write(`${t('setup.desktopPairingCode', { code: desktopCode })}\n`)
|
|
271
|
+
if (url !== '') {
|
|
272
|
+
out.write(` ${t('setup.desktopPairingCmd', { url, code: desktopCode })}\n`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return 0
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ===========================================================================
|
|
279
|
+
// host device list
|
|
280
|
+
// ===========================================================================
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `host device list [--json]` — the gateway's device table. `--json` echoes
|
|
284
|
+
* the gateway's own devices array verbatim (the machine-readable truth);
|
|
285
|
+
* the terminal mode prints one line per device with its id, name, role,
|
|
286
|
+
* kind and the created / last-seen stamps.
|
|
287
|
+
*/
|
|
288
|
+
export async function runHostDeviceListCommand(ctx: CliExecutionContext): Promise<number> {
|
|
289
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
290
|
+
const { t, args } = ctx
|
|
291
|
+
const out = ctx.stdout
|
|
292
|
+
const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
|
|
293
|
+
const gateway = plainOf(admin?.gateway)
|
|
294
|
+
if (admin === null || gateway === null) {
|
|
295
|
+
out.write(`${t('host.device.listFailed')}\n`)
|
|
296
|
+
return 1
|
|
297
|
+
}
|
|
298
|
+
if (args.options.json) {
|
|
299
|
+
out.write(`${JSON.stringify({ ok: true, devices: gateway.devices ?? [] }, null, 2)}\n`)
|
|
300
|
+
return 0
|
|
301
|
+
}
|
|
302
|
+
const devices = devicesOf(admin)
|
|
303
|
+
out.write(`${t('host.device.listTitle')}\n`)
|
|
304
|
+
if (devices.length === 0) {
|
|
305
|
+
out.write(` ${t('host.device.listEmpty')}\n`)
|
|
306
|
+
return 0
|
|
307
|
+
}
|
|
308
|
+
for (const device of devices) {
|
|
309
|
+
out.write(`${deviceLineText(t, device)}\n`)
|
|
310
|
+
out.write(`${t('host.device.deviceTiming', {
|
|
311
|
+
created: formatStamp(device.createdAt),
|
|
312
|
+
lastSeen: formatStamp(device.lastSeen),
|
|
313
|
+
})}\n`)
|
|
314
|
+
}
|
|
315
|
+
return 0
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ===========================================================================
|
|
319
|
+
// host device revoke
|
|
320
|
+
// ===========================================================================
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* `host device revoke [<id>|--all] [--yes] [--json]` — one revoke POST per
|
|
324
|
+
* invocation. `--all` needs `--yes` outside a TTY confirm (the missing
|
|
325
|
+
* confirmation IS the missing required option); a missing target in an
|
|
326
|
+
* interactive run picks from the current list.
|
|
327
|
+
*/
|
|
328
|
+
export async function runHostDeviceRevokeCommand(ctx: CliExecutionContext): Promise<number> {
|
|
329
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
330
|
+
const { t, args, ipc } = ctx
|
|
331
|
+
const out = ctx.stdout
|
|
332
|
+
|
|
333
|
+
if (args.options.all) {
|
|
334
|
+
const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
|
|
335
|
+
const count = devicesOf(admin).length
|
|
336
|
+
if (!args.options.yes) {
|
|
337
|
+
if (!ctx.interactive || ctx.prompter === undefined) {
|
|
338
|
+
out.write(`${t('err.missingOption', { option: 'yes' })}\n`)
|
|
339
|
+
return 1
|
|
340
|
+
}
|
|
341
|
+
const sure = await ctx.prompter.confirm(t('host.device.revokeConfirmAll'), false)
|
|
342
|
+
if (!sure) {
|
|
343
|
+
out.write(`${t('common.cancel')}\n`)
|
|
344
|
+
return 0
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const result = await ipc.request('POST', ADMIN_ACTION_ROUTE, { action: 'revoke-all' })
|
|
348
|
+
const body = plainOf(result.body)
|
|
349
|
+
if (result.status !== 200 || body?.ok !== true) {
|
|
350
|
+
out.write(`${t('host.device.revokeFailed')}\n`)
|
|
351
|
+
return 1
|
|
352
|
+
}
|
|
353
|
+
if (args.options.json) {
|
|
354
|
+
out.write(`${JSON.stringify({ ok: true, action: 'revoke-all', count }, null, 2)}\n`)
|
|
355
|
+
} else {
|
|
356
|
+
out.write(`${t('host.device.revoked', { count })}\n`)
|
|
357
|
+
}
|
|
358
|
+
return 0
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let target = args.target
|
|
362
|
+
if (target === undefined) {
|
|
363
|
+
if (!ctx.interactive || ctx.prompter === undefined) {
|
|
364
|
+
out.write(`${t('host.device.revokeMissingTarget')}\n`)
|
|
365
|
+
return 1
|
|
366
|
+
}
|
|
367
|
+
const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
|
|
368
|
+
const devices = devicesOf(admin)
|
|
369
|
+
if (devices.length === 0) {
|
|
370
|
+
out.write(` ${t('host.device.listEmpty')}\n`)
|
|
371
|
+
return 0
|
|
372
|
+
}
|
|
373
|
+
const picked = await ctx.prompter.selectOne(
|
|
374
|
+
t('host.device.selectQuestion'),
|
|
375
|
+
devices.map((device) => deviceLineText(t, device)),
|
|
376
|
+
0,
|
|
377
|
+
)
|
|
378
|
+
target = String(devices[picked]?.id ?? '')
|
|
379
|
+
if (target === '') return 1
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const result = await ipc.request('POST', ADMIN_ACTION_ROUTE, { action: 'revoke', id: target })
|
|
383
|
+
const body = plainOf(result.body)
|
|
384
|
+
if (result.status !== 200 || body?.ok !== true) {
|
|
385
|
+
out.write(`${t('host.device.revokeFailed')}\n`)
|
|
386
|
+
return 1
|
|
387
|
+
}
|
|
388
|
+
if (args.options.json) {
|
|
389
|
+
out.write(`${JSON.stringify({ ok: true, action: 'revoke', id: target }, null, 2)}\n`)
|
|
390
|
+
} else {
|
|
391
|
+
out.write(`${t('host.device.revokedOne', { id: target })}\n`)
|
|
392
|
+
}
|
|
393
|
+
return 0
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ===========================================================================
|
|
397
|
+
// ingress snippet rendering (shared with `ingress start external`)
|
|
398
|
+
// ===========================================================================
|
|
399
|
+
|
|
400
|
+
/** Print one reverse-proxy flavor's snippet. Returns false when the
|
|
401
|
+
* generator refused an input (a broken wizard status), true otherwise. */
|
|
402
|
+
function printSnippet(ctx: CliExecutionContext, kind: string, domain: string, port: number): boolean {
|
|
403
|
+
const { t } = ctx
|
|
404
|
+
const lang: 'zh' | 'en' = t('common.done') === 'Done' ? 'en' : 'zh'
|
|
405
|
+
const out = ctx.stdout
|
|
406
|
+
const upstreamHost = '127.0.0.1'
|
|
407
|
+
if (kind === 'nginx') {
|
|
408
|
+
const snippet = nginxSnippet({ domain, upstreamHost, upstreamPort: port, lang })
|
|
409
|
+
if (!snippet.ok) {
|
|
410
|
+
out.write(`${t('host.ingress.error.badDomain')}\n`)
|
|
411
|
+
return false
|
|
412
|
+
}
|
|
413
|
+
out.write(`${t('host.ingress.snippetTitle', { proxy: 'Nginx', port })}\n`)
|
|
414
|
+
out.write(`${snippet.text}\n`)
|
|
415
|
+
return true
|
|
416
|
+
}
|
|
417
|
+
if (kind === 'caddy') {
|
|
418
|
+
const snippet = caddySnippet({ domain, upstreamHost, upstreamPort: port, lang })
|
|
419
|
+
if (!snippet.ok) {
|
|
420
|
+
out.write(`${t('host.ingress.error.badDomain')}\n`)
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
423
|
+
out.write(`${t('host.ingress.snippetTitle', { proxy: 'Caddy', port })}\n`)
|
|
424
|
+
out.write(`${snippet.text}\n`)
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
const steps = luckySteps({ upstreamHost, upstreamPort: port, lang })
|
|
428
|
+
if (!steps.ok) {
|
|
429
|
+
out.write(`${t('host.ingress.error.badDomain')}\n`)
|
|
430
|
+
return false
|
|
431
|
+
}
|
|
432
|
+
out.write(`${t('host.ingress.snippetTitle', { proxy: 'Lucky', port })}\n`)
|
|
433
|
+
for (const step of steps.steps) out.write(`${step}\n`)
|
|
434
|
+
return true
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Which flavors to print: an explicit `--proxy`, one prompted in an
|
|
438
|
+
* interactive run, or all three headlessly. */
|
|
439
|
+
async function resolveSnippetKinds(ctx: CliExecutionContext): Promise<string[] | null> {
|
|
440
|
+
const { t, args } = ctx
|
|
441
|
+
const proxy = args.options.proxy
|
|
442
|
+
if (proxy !== undefined) {
|
|
443
|
+
if (!(PROXY_KINDS as readonly string[]).includes(proxy)) {
|
|
444
|
+
ctx.stdout.write(`${t('host.ingress.error.badProxy', { value: proxy })}\n`)
|
|
445
|
+
return null
|
|
446
|
+
}
|
|
447
|
+
return [proxy]
|
|
448
|
+
}
|
|
449
|
+
if (ctx.interactive && ctx.prompter !== undefined) {
|
|
450
|
+
const picked = await ctx.prompter.selectOne(
|
|
451
|
+
t('host.ingress.snippetQuestion'),
|
|
452
|
+
['Nginx', 'Caddy', 'Lucky'],
|
|
453
|
+
0,
|
|
454
|
+
)
|
|
455
|
+
const kind = ['nginx', 'caddy', 'lucky'][picked]
|
|
456
|
+
return kind === undefined ? null : [kind]
|
|
457
|
+
}
|
|
458
|
+
return ['nginx', 'caddy', 'lucky']
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* `host ingress snippet [--proxy nginx|caddy|lucky]` — the copy-paste
|
|
463
|
+
* reverse-proxy material for the external ingress mode. The upstream port
|
|
464
|
+
* is the gateway's ACTUAL port (a gateway that retried past its configured
|
|
465
|
+
* port must not hand out snippets pointing at the wrong place); the domain
|
|
466
|
+
* falls back to the example placeholder.
|
|
467
|
+
*/
|
|
468
|
+
export async function runHostIngressSnippetCommand(ctx: CliExecutionContext): Promise<number> {
|
|
469
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
470
|
+
const { t } = ctx
|
|
471
|
+
const kinds = await resolveSnippetKinds(ctx)
|
|
472
|
+
if (kinds === null) return 1
|
|
473
|
+
const [wizard, admin] = await Promise.all([
|
|
474
|
+
fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
|
|
475
|
+
fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
|
|
476
|
+
])
|
|
477
|
+
const port = gatewayPortOf(wizard, admin)
|
|
478
|
+
const domain = typeof wizard?.domain === 'string' && wizard.domain !== '' ? wizard.domain : 'dsh.example.com'
|
|
479
|
+
for (const kind of kinds) {
|
|
480
|
+
if (!printSnippet(ctx, kind, domain, port)) return 1
|
|
481
|
+
}
|
|
482
|
+
return 0
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ===========================================================================
|
|
486
|
+
// host ingress start
|
|
487
|
+
// ===========================================================================
|
|
488
|
+
|
|
489
|
+
/** Save a command-line-provided Cloudflare token through the 0600 token
|
|
490
|
+
* route. Absent flags are a no-op; a refused save is the failure. */
|
|
491
|
+
async function saveCfTokenIfProvided(ctx: CliExecutionContext): Promise<boolean> {
|
|
492
|
+
const provided = ctx.args.options.cfToken
|
|
493
|
+
if (provided === undefined || provided === '') return true
|
|
494
|
+
const result = await ctx.ipc.request('POST', WIZARD_CF_TOKEN_ROUTE, { token: provided })
|
|
495
|
+
const body = plainOf(result.body)
|
|
496
|
+
if (result.status !== 200 || body?.ok !== true) {
|
|
497
|
+
ctx.stdout.write(`${ctx.t('host.ingress.cfTokenFailed')}\n`)
|
|
498
|
+
return false
|
|
499
|
+
}
|
|
500
|
+
return true
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Ask for the token exactly when none is saved yet (interactive runs
|
|
504
|
+
* only) — a token already on disk must never be re-asked. Prints the
|
|
505
|
+
* mode-specific Cloudflare permission checklist right before the prompt. */
|
|
506
|
+
async function askCfTokenIfUnconfigured(
|
|
507
|
+
ctx: CliExecutionContext,
|
|
508
|
+
wizard: ServerRow | null,
|
|
509
|
+
mode: 'caddy' | 'cloudflared' = 'caddy',
|
|
510
|
+
): Promise<boolean> {
|
|
511
|
+
if (ctx.args.options.cfToken !== undefined && ctx.args.options.cfToken !== '') return true
|
|
512
|
+
if (wizard?.cfTokenConfigured === true) return true
|
|
513
|
+
if (!ctx.interactive || ctx.prompter === undefined) return true
|
|
514
|
+
ctx.stdout.write(`\n${ctx.t('setup.cfTokenGuide.title')}\n`)
|
|
515
|
+
ctx.stdout.write(`${ctx.t(mode === 'cloudflared' ? 'setup.cfTokenGuide.tunnel' : 'setup.cfTokenGuide.caddy')}\n`)
|
|
516
|
+
const token = await ctx.prompter.inputText(ctx.t('setup.cfTokenQuestion'), undefined, undefined)
|
|
517
|
+
if (token.trim() === '') return true
|
|
518
|
+
const result = await ctx.ipc.request('POST', WIZARD_CF_TOKEN_ROUTE, { token: token.trim() })
|
|
519
|
+
const body = plainOf(result.body)
|
|
520
|
+
if (result.status !== 200 || body?.ok !== true) {
|
|
521
|
+
ctx.stdout.write(`${ctx.t('host.ingress.cfTokenFailed')}\n`)
|
|
522
|
+
return false
|
|
523
|
+
}
|
|
524
|
+
return true
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The domain: the flag wins, then the saved config, then the prompt. The
|
|
528
|
+
* value must pass config.ts's bare-hostname gate (the same admission the
|
|
529
|
+
* wizard cards apply). */
|
|
530
|
+
async function resolveDomain(ctx: CliExecutionContext, wizard: ServerRow | null): Promise<string | null> {
|
|
531
|
+
const { t, args } = ctx
|
|
532
|
+
let provided = args.options.domain
|
|
533
|
+
if (provided === undefined || provided === '') {
|
|
534
|
+
const saved = typeof wizard?.domain === 'string' && wizard.domain !== '' ? wizard.domain : undefined
|
|
535
|
+
if (saved !== undefined) return hostOf(saved) ?? null
|
|
536
|
+
provided = await askText(ctx, 'domain', t('setup.domainQuestion'))
|
|
537
|
+
}
|
|
538
|
+
const domain = hostOf(provided)
|
|
539
|
+
if (domain === undefined) {
|
|
540
|
+
ctx.stdout.write(`${t('host.ingress.error.badDomain')}\n`)
|
|
541
|
+
return null
|
|
542
|
+
}
|
|
543
|
+
return domain
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
interface IngressSources {
|
|
547
|
+
wizard: ServerRow | null
|
|
548
|
+
admin: ServerRow | null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function startFailedCaddy(t: CliTranslator, body: ServerRow | null): string {
|
|
552
|
+
const error = typeof body?.error === 'string' ? body.error : ''
|
|
553
|
+
const detail = typeof body?.detail === 'string' ? body.detail : ''
|
|
554
|
+
const installUrl = typeof body?.installUrl === 'string' ? body.installUrl : ''
|
|
555
|
+
switch (error) {
|
|
556
|
+
case 'already-running': return t('host.ingress.caddyError.alreadyRunning')
|
|
557
|
+
case 'missing-token': return t('host.ingress.caddyError.missingToken')
|
|
558
|
+
case 'bad-config': return t('host.ingress.caddyError.badConfig', { detail })
|
|
559
|
+
case 'download-failed': return t('host.ingress.caddyError.downloadFailed', { detail, installUrl })
|
|
560
|
+
case 'validate-failed': return t('host.ingress.caddyError.validateFailed', { detail })
|
|
561
|
+
case 'spawn-failed': return t('host.ingress.caddyError.spawnFailed', { detail })
|
|
562
|
+
default: return t('host.ingress.caddyError.generic', { error: error === '' ? 'unknown' : error, detail })
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Managed Caddy: token → saved config (loopback host, mode + domain +
|
|
567
|
+
* port + optional ACME email) → the plugin's start route, which downloads
|
|
568
|
+
* and validates the binary inside the plugin process. */
|
|
569
|
+
async function startCaddy(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
|
|
570
|
+
const { t, args, ipc } = ctx
|
|
571
|
+
const out = ctx.stdout
|
|
572
|
+
const domain = await resolveDomain(ctx, sources.wizard)
|
|
573
|
+
if (domain === null) return 1
|
|
574
|
+
const httpsPort = args.options.httpsPort ?? (typeof sources.wizard?.httpsPort === 'number' ? sources.wizard.httpsPort : 443)
|
|
575
|
+
const email = args.options.email
|
|
576
|
+
if (!(await saveCfTokenIfProvided(ctx))) return 1
|
|
577
|
+
if (!(await askCfTokenIfUnconfigured(ctx, sources.wizard))) return 1
|
|
578
|
+
if (!(await writeConfigOrReport(ctx, {
|
|
579
|
+
role: 'host',
|
|
580
|
+
host: '127.0.0.1',
|
|
581
|
+
ingressMode: 'caddy',
|
|
582
|
+
ingressDomain: domain,
|
|
583
|
+
ingressHttpsPort: httpsPort,
|
|
584
|
+
...(email !== undefined && email !== '' ? { ingressAcmeEmail: email } : {}),
|
|
585
|
+
}))) return 1
|
|
586
|
+
const start = await ipc.request('POST', WIZARD_CADDY_START_ROUTE)
|
|
587
|
+
const body = plainOf(start.body)
|
|
588
|
+
if (start.status !== 200 || body?.ok !== true) {
|
|
589
|
+
out.write(`${startFailedCaddy(t, body)}\n`)
|
|
590
|
+
return 1
|
|
591
|
+
}
|
|
592
|
+
if (args.options.json) {
|
|
593
|
+
out.write(`${JSON.stringify({ ok: true, mode: 'caddy' }, null, 2)}\n`)
|
|
594
|
+
return 0
|
|
595
|
+
}
|
|
596
|
+
out.write(`${t('host.ingress.started', { mode: 'caddy' })}\n`)
|
|
597
|
+
return 0
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function tunnelFailureLine(t: CliTranslator, key: 'host.ingress.tunnelCheckFailed' | 'host.ingress.tunnelSetupFailed', body: ServerRow | null): string {
|
|
601
|
+
const reason = typeof body?.reason === 'string' && body.reason !== '' ? body.reason : 'unknown'
|
|
602
|
+
return t(key, { reason })
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Managed Cloudflare Tunnel: token → zone check → saved config → the
|
|
606
|
+
* tunnel setup (create, or adopt an existing same-name tunnel after an
|
|
607
|
+
* interactive confirm). */
|
|
608
|
+
async function startCloudflared(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
|
|
609
|
+
const { t, args, ipc } = ctx
|
|
610
|
+
const out = ctx.stdout
|
|
611
|
+
const domain = await resolveDomain(ctx, sources.wizard)
|
|
612
|
+
if (domain === null) return 1
|
|
613
|
+
if (!(await saveCfTokenIfProvided(ctx))) return 1
|
|
614
|
+
if (!(await askCfTokenIfUnconfigured(ctx, sources.wizard, 'cloudflared'))) return 1
|
|
615
|
+
|
|
616
|
+
const check = await ipc.request('POST', WIZARD_TUNNEL_CHECK_ROUTE, { domain })
|
|
617
|
+
const checkBody = plainOf(check.body)
|
|
618
|
+
if (check.status !== 200 || checkBody?.ok !== true) {
|
|
619
|
+
out.write(`${tunnelFailureLine(t, 'host.ingress.tunnelCheckFailed', checkBody)}\n`)
|
|
620
|
+
if (checkBody?.reason === 'auth' || checkBody?.reason === 'zone-not-found') {
|
|
621
|
+
out.write(`${t('setup.cfTokenGuide.title')}\n${t('setup.cfTokenGuide.tunnel')}\n`)
|
|
622
|
+
}
|
|
623
|
+
return 1
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!(await writeConfigOrReport(ctx, {
|
|
627
|
+
role: 'host',
|
|
628
|
+
host: '127.0.0.1',
|
|
629
|
+
ingressMode: 'cloudflared',
|
|
630
|
+
ingressDomain: domain,
|
|
631
|
+
}))) return 1
|
|
632
|
+
|
|
633
|
+
const tunnelId = args.options.tunnelId
|
|
634
|
+
let setup = await ipc.request('POST', WIZARD_TUNNEL_SETUP_ROUTE, {
|
|
635
|
+
domain,
|
|
636
|
+
...(tunnelId !== undefined && tunnelId !== '' ? { tunnelId } : {}),
|
|
637
|
+
})
|
|
638
|
+
let setupBody = plainOf(setup.body)
|
|
639
|
+
if (setup.status !== 200 || setupBody?.ok !== true) {
|
|
640
|
+
if (setupBody?.reason === 'tunnel-conflict') {
|
|
641
|
+
const existing = Array.isArray(setupBody.existing)
|
|
642
|
+
? (setupBody.existing as unknown[]).map((entry) => plainOf(entry)).filter((entry): entry is ServerRow => entry !== null)
|
|
643
|
+
: []
|
|
644
|
+
const existingId = typeof existing[0]?.id === 'string' ? existing[0].id : ''
|
|
645
|
+
if (existingId !== '') {
|
|
646
|
+
if (ctx.interactive && ctx.prompter !== undefined) {
|
|
647
|
+
const reuse = await ctx.prompter.confirm(t('setup.tunnelConflict', { tunnelId: existingId }), true)
|
|
648
|
+
if (reuse) {
|
|
649
|
+
setup = await ipc.request('POST', WIZARD_TUNNEL_SETUP_ROUTE, { domain, tunnelId: existingId })
|
|
650
|
+
setupBody = plainOf(setup.body)
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
out.write(`${t('host.ingress.tunnelConflictNonInteractive', { tunnelId: existingId })}\n`)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (setup.status !== 200 || setupBody?.ok !== true) {
|
|
658
|
+
out.write(`${tunnelFailureLine(t, 'host.ingress.tunnelSetupFailed', setupBody)}\n`)
|
|
659
|
+
if (setupBody?.reason === 'auth' || setupBody?.reason === 'zone-not-found') {
|
|
660
|
+
out.write(`${t('setup.cfTokenGuide.title')}\n${t('setup.cfTokenGuide.tunnel')}\n`)
|
|
661
|
+
}
|
|
662
|
+
return 1
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const settled = setupBody as ServerRow
|
|
666
|
+
const startedTunnelId = typeof settled.tunnelId === 'string' ? settled.tunnelId : ''
|
|
667
|
+
const tunnelPublicUrl = typeof settled.publicUrl === 'string' ? settled.publicUrl : ''
|
|
668
|
+
if (args.options.json) {
|
|
669
|
+
out.write(`${JSON.stringify({
|
|
670
|
+
ok: true,
|
|
671
|
+
mode: 'cloudflared',
|
|
672
|
+
...(startedTunnelId === '' ? {} : { tunnelId: startedTunnelId }),
|
|
673
|
+
...(tunnelPublicUrl === '' ? {} : { publicUrl: tunnelPublicUrl }),
|
|
674
|
+
}, null, 2)}\n`)
|
|
675
|
+
return 0
|
|
676
|
+
}
|
|
677
|
+
out.write(`${t('host.ingress.started', { mode: 'cloudflared' })}\n`)
|
|
678
|
+
return 0
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** External reverse proxy: validate the https origin, save it, print the
|
|
682
|
+
* snippets, and offer the detected untrusted-proxy IP to the trust list
|
|
683
|
+
* (automatically headlessly, confirmed interactively). */
|
|
684
|
+
async function startExternal(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
|
|
685
|
+
const { t, args, ipc } = ctx
|
|
686
|
+
const out = ctx.stdout
|
|
687
|
+
let provided = args.options.publicUrl
|
|
688
|
+
if (provided === undefined || provided === '') {
|
|
689
|
+
const saved = typeof sources.wizard?.publicUrl === 'string' && sources.wizard.publicUrl !== '' ? sources.wizard.publicUrl : undefined
|
|
690
|
+
if (saved !== undefined) provided = saved
|
|
691
|
+
}
|
|
692
|
+
if (provided === undefined || provided === '') {
|
|
693
|
+
provided = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
|
|
694
|
+
}
|
|
695
|
+
const normalized = normalizeServerUrl(provided)
|
|
696
|
+
if (!normalized.ok) {
|
|
697
|
+
out.write(`${t(normalized.reason === 'insecure-http' ? 'client.error.insecureHttp' : 'client.error.urlInvalid')}\n`)
|
|
698
|
+
return 1
|
|
699
|
+
}
|
|
700
|
+
// External means a real reverse proxy in front: plain http has no
|
|
701
|
+
// X-Forwarded-Proto story at all.
|
|
702
|
+
if (!normalized.url.startsWith('https://')) {
|
|
703
|
+
out.write(`${t('client.error.insecureHttp')}\n`)
|
|
704
|
+
return 1
|
|
705
|
+
}
|
|
706
|
+
if (!(await writeConfigOrReport(ctx, {
|
|
707
|
+
role: 'host',
|
|
708
|
+
ingressMode: 'external',
|
|
709
|
+
ingressPublicUrl: normalized.url,
|
|
710
|
+
}))) return 1
|
|
711
|
+
|
|
712
|
+
// The external flow prints material for ALL three proxies (unless the
|
|
713
|
+
// user named one) — a mid-flow flavor question would just be noise.
|
|
714
|
+
const proxy = args.options.proxy
|
|
715
|
+
if (proxy !== undefined && !(PROXY_KINDS as readonly string[]).includes(proxy)) {
|
|
716
|
+
out.write(`${t('host.ingress.error.badProxy', { value: proxy })}\n`)
|
|
717
|
+
return 1
|
|
718
|
+
}
|
|
719
|
+
const kinds = proxy !== undefined ? [proxy] : ['nginx', 'caddy', 'lucky']
|
|
720
|
+
const snippetDomain = typeof sources.wizard?.domain === 'string' && sources.wizard.domain !== '' ? sources.wizard.domain : 'dsh.example.com'
|
|
721
|
+
for (const kind of kinds) {
|
|
722
|
+
if (!printSnippet(ctx, kind, snippetDomain, gatewayPortOf(sources.wizard, sources.admin))) return 1
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const forwarded = plainOf(sources.wizard?.lastForwardedSource)
|
|
726
|
+
const ip = typeof forwarded?.ip === 'string' ? forwarded.ip : ''
|
|
727
|
+
if (ip !== '') {
|
|
728
|
+
const trust = async (): Promise<boolean> => {
|
|
729
|
+
const result = await ipc.request('POST', WIZARD_TRUST_PROXY_ROUTE, { ip })
|
|
730
|
+
const body = plainOf(result.body)
|
|
731
|
+
return result.status === 200 && body?.ok === true
|
|
732
|
+
}
|
|
733
|
+
if (ctx.interactive && ctx.prompter !== undefined) {
|
|
734
|
+
const sure = await ctx.prompter.confirm(t('host.ingress.trustProxyQuestion', { ip }), true)
|
|
735
|
+
if (sure) {
|
|
736
|
+
if (!(await trust())) {
|
|
737
|
+
out.write(`${t('host.ingress.trustProxyFailed')}\n`)
|
|
738
|
+
return 1
|
|
739
|
+
}
|
|
740
|
+
out.write(`${t('host.ingress.trustProxyDone', { ip })}\n`)
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
// Headless / --yes: the detection is not actionable any other way —
|
|
744
|
+
// trust the proxy automatically and say so.
|
|
745
|
+
if (!(await trust())) {
|
|
746
|
+
out.write(`${t('host.ingress.trustProxyFailed')}\n`)
|
|
747
|
+
return 1
|
|
748
|
+
}
|
|
749
|
+
out.write(`${t('host.ingress.trustProxyAuto', { ip })}\n`)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (args.options.json) {
|
|
754
|
+
out.write(`${JSON.stringify({ ok: true, mode: 'external', publicUrl: normalized.url }, null, 2)}\n`)
|
|
755
|
+
return 0
|
|
756
|
+
}
|
|
757
|
+
out.write(`${t('host.ingress.started', { mode: 'external' })}\n`)
|
|
758
|
+
return 0
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** LAN / Tailscale direct: rebind the gateway to 0.0.0.0, clear the
|
|
762
|
+
* managed ingress mode, and wait (bounded) for the restarted gateway to
|
|
763
|
+
* answer the admin status again on 0.0.0.0. */
|
|
764
|
+
async function startLan(ctx: CliExecutionContext, sources: IngressSources): Promise<number> {
|
|
765
|
+
const { t, args } = ctx
|
|
766
|
+
const out = ctx.stdout
|
|
767
|
+
const port = gatewayPortOf(sources.wizard, sources.admin)
|
|
768
|
+
let lanUrl = args.options.publicUrl
|
|
769
|
+
if (lanUrl === undefined || lanUrl === '') {
|
|
770
|
+
const rawCandidates = Array.isArray(sources.wizard?.lanAddresses) ? sources.wizard.lanAddresses : []
|
|
771
|
+
const candidates = rawCandidates
|
|
772
|
+
.map((item) => plainOf(item))
|
|
773
|
+
.filter((item): item is ServerRow => item !== null && typeof item.ip === 'string' && item.ip !== '')
|
|
774
|
+
if (candidates.length > 1 && ctx.interactive && ctx.prompter !== undefined) {
|
|
775
|
+
const labels = [
|
|
776
|
+
...candidates.map((c) => {
|
|
777
|
+
const tag = c.kind === 'tailscale' ? 'Tailscale' : 'LAN'
|
|
778
|
+
const iface = typeof c.iface === 'string' && c.iface !== '' ? ` · ${c.iface}` : ''
|
|
779
|
+
return `http://${String(c.ip)}:${port} (${tag}${iface})`
|
|
780
|
+
}),
|
|
781
|
+
t('host.ingress.lanPickCustom'),
|
|
782
|
+
]
|
|
783
|
+
const picked = await ctx.prompter.selectOne(t('host.ingress.lanPickQuestion'), labels, 0)
|
|
784
|
+
const chosen = candidates[picked]
|
|
785
|
+
if (chosen !== undefined) {
|
|
786
|
+
lanUrl = `http://${String(chosen.ip)}:${port}`
|
|
787
|
+
} else {
|
|
788
|
+
lanUrl = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
|
|
789
|
+
}
|
|
790
|
+
} else if (candidates.length === 1) {
|
|
791
|
+
lanUrl = `http://${String(candidates[0].ip)}:${port}`
|
|
792
|
+
} else {
|
|
793
|
+
const lan = typeof sources.wizard?.lanAddress === 'string' && sources.wizard.lanAddress !== '' ? sources.wizard.lanAddress : ''
|
|
794
|
+
if (lan !== '') {
|
|
795
|
+
lanUrl = `http://${lan}:${port}`
|
|
796
|
+
} else {
|
|
797
|
+
lanUrl = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (!(await writeConfigOrReport(ctx, {
|
|
802
|
+
role: 'host',
|
|
803
|
+
host: '0.0.0.0',
|
|
804
|
+
ingressMode: 'off',
|
|
805
|
+
ingressPublicUrl: lanUrl,
|
|
806
|
+
}))) return 1
|
|
807
|
+
|
|
808
|
+
const waitSeconds = Math.max(1, args.options.wait)
|
|
809
|
+
out.write(`${t('host.ingress.lanWaiting', { seconds: waitSeconds })}\n`)
|
|
810
|
+
const interval = ctx.pollIntervalMs ?? LAN_POLL_INTERVAL_DEFAULT_MS
|
|
811
|
+
const deadline = Date.now() + waitSeconds * 1000
|
|
812
|
+
const checkHostField = typeof sources.wizard?.gatewayActualHost === 'string'
|
|
813
|
+
await sleep(interval) // the restart watcher needs a beat to see the rebind
|
|
814
|
+
while (true) {
|
|
815
|
+
const admin = await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE)
|
|
816
|
+
if (admin?.gatewayReachable === true) {
|
|
817
|
+
if (!checkHostField) break
|
|
818
|
+
const latestWizard = await fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE)
|
|
819
|
+
const actualHost = latestWizard?.gatewayActualHost
|
|
820
|
+
if (actualHost === undefined || actualHost === '0.0.0.0' || actualHost === '::') break
|
|
821
|
+
}
|
|
822
|
+
if (Date.now() >= deadline) {
|
|
823
|
+
out.write(`${t('host.ingress.lanTimeout', { seconds: waitSeconds })}\n`)
|
|
824
|
+
return 1
|
|
825
|
+
}
|
|
826
|
+
await sleep(interval)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (args.options.json) {
|
|
830
|
+
out.write(`${JSON.stringify({ ok: true, mode: 'lan', publicUrl: lanUrl }, null, 2)}\n`)
|
|
831
|
+
return 0
|
|
832
|
+
}
|
|
833
|
+
out.write(`${t('host.ingress.started', { mode: 'lan' })}\n`)
|
|
834
|
+
return 0
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* `host ingress start [--mode …] …` — the four ingress modes, all driven
|
|
839
|
+
* through the plugin's wizard routes. The mode comes from the flags, then
|
|
840
|
+
* the saved config, then the interactive question; before the new mode's
|
|
841
|
+
* flow runs, every managed process that does not belong to it is stopped
|
|
842
|
+
* through its own stop route (GatewayWizard.tsx's discipline).
|
|
843
|
+
*/
|
|
844
|
+
export async function runHostIngressStartCommand(ctx: CliExecutionContext): Promise<number> {
|
|
845
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
846
|
+
const { t, args } = ctx
|
|
847
|
+
const out = ctx.stdout
|
|
848
|
+
try {
|
|
849
|
+
const sources: IngressSources = {
|
|
850
|
+
wizard: await fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
|
|
851
|
+
admin: await fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let mode = args.options.mode ?? args.options.ingress
|
|
855
|
+
if (mode === undefined) {
|
|
856
|
+
const saved = typeof sources.wizard?.mode === 'string' && (INGRESS_MODES as readonly string[]).includes(sources.wizard.mode)
|
|
857
|
+
? sources.wizard.mode
|
|
858
|
+
: undefined
|
|
859
|
+
if (saved !== undefined) mode = saved
|
|
860
|
+
}
|
|
861
|
+
if (mode === undefined) {
|
|
862
|
+
if (ctx.interactive && ctx.prompter !== undefined) {
|
|
863
|
+
const picked = await ctx.prompter.selectOne(
|
|
864
|
+
t('setup.ingressQuestion'),
|
|
865
|
+
[t('setup.ingressCaddy'), t('setup.ingressCloudflared'), t('setup.ingressExternal'), t('setup.ingressLan')],
|
|
866
|
+
0,
|
|
867
|
+
)
|
|
868
|
+
mode = ['caddy', 'cloudflared', 'external', 'lan'][picked]
|
|
869
|
+
} else {
|
|
870
|
+
throw new MissingRequiredOptionError('mode')
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (!(INGRESS_MODES as readonly string[]).includes(mode)) {
|
|
874
|
+
out.write(`${t('host.ingress.error.badMode', { value: mode })}\n`)
|
|
875
|
+
return 1
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Mode-switch cleanup: an old tunnel or Caddy child never keeps
|
|
879
|
+
// running across a mode change.
|
|
880
|
+
if (mode !== 'caddy') await ctx.ipc.request('POST', WIZARD_CADDY_STOP_ROUTE)
|
|
881
|
+
if (mode !== 'cloudflared') await ctx.ipc.request('POST', WIZARD_TUNNEL_STOP_ROUTE)
|
|
882
|
+
|
|
883
|
+
if (mode === 'caddy') return await startCaddy(ctx, sources)
|
|
884
|
+
if (mode === 'cloudflared') return await startCloudflared(ctx, sources)
|
|
885
|
+
if (mode === 'external') return await startExternal(ctx, sources)
|
|
886
|
+
return await startLan(ctx, sources)
|
|
887
|
+
} catch (error) {
|
|
888
|
+
if (error instanceof MissingRequiredOptionError) {
|
|
889
|
+
out.write(`${t('err.missingOption', { option: error.optionName })}\n`)
|
|
890
|
+
return 1
|
|
891
|
+
}
|
|
892
|
+
throw error
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ===========================================================================
|
|
897
|
+
// host ingress stop
|
|
898
|
+
// ===========================================================================
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* `host ingress stop [--yes] [--json]` — stop both managed ingress
|
|
902
|
+
* processes through their stop routes (the CLI owns no process handles to
|
|
903
|
+
* reach for). `--yes` outside a TTY confirm, same discipline as revoke.
|
|
904
|
+
*/
|
|
905
|
+
export async function runHostIngressStopCommand(ctx: CliExecutionContext): Promise<number> {
|
|
906
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
907
|
+
const { t, args, ipc } = ctx
|
|
908
|
+
const out = ctx.stdout
|
|
909
|
+
if (!args.options.yes) {
|
|
910
|
+
if (!ctx.interactive || ctx.prompter === undefined) {
|
|
911
|
+
out.write(`${t('err.missingOption', { option: 'yes' })}\n`)
|
|
912
|
+
return 1
|
|
913
|
+
}
|
|
914
|
+
const sure = await ctx.prompter.confirm(t('host.ingress.confirmStop'), false)
|
|
915
|
+
if (!sure) {
|
|
916
|
+
out.write(`${t('common.cancel')}\n`)
|
|
917
|
+
return 0
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const caddy = await ipc.request('POST', WIZARD_CADDY_STOP_ROUTE)
|
|
921
|
+
const tunnel = await ipc.request('POST', WIZARD_TUNNEL_STOP_ROUTE)
|
|
922
|
+
const caddyBody = plainOf(caddy.body)
|
|
923
|
+
const tunnelBody = plainOf(tunnel.body)
|
|
924
|
+
if (caddy.status !== 200 || caddyBody?.ok !== true || tunnel.status !== 200 || tunnelBody?.ok !== true) {
|
|
925
|
+
out.write(`${t('host.ingress.stopFailed')}\n`)
|
|
926
|
+
return 1
|
|
927
|
+
}
|
|
928
|
+
if (args.options.json) {
|
|
929
|
+
out.write(`${JSON.stringify({ ok: true }, null, 2)}\n`)
|
|
930
|
+
return 0
|
|
931
|
+
}
|
|
932
|
+
out.write(`${t('host.ingress.stopped')}\n`)
|
|
933
|
+
return 0
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ===========================================================================
|
|
937
|
+
// host ingress verify
|
|
938
|
+
// ===========================================================================
|
|
939
|
+
|
|
940
|
+
/** One verify-start failure's two lights: which of the first two checks
|
|
941
|
+
* the failure belongs to. `probe-failed` happens with the gateway already
|
|
942
|
+
* reachable (light 1 on, light 2 off); `pair-refused` happens with the
|
|
943
|
+
* probe already passed (both on). */
|
|
944
|
+
function renderVerifyFailure(ctx: CliExecutionContext, reason: string, detail: unknown): void {
|
|
945
|
+
const { t } = ctx
|
|
946
|
+
const out = ctx.stdout
|
|
947
|
+
const pass = t('common.done')
|
|
948
|
+
const fail = t('host.ingress.lightFail')
|
|
949
|
+
if (reason === 'probe-failed') {
|
|
950
|
+
out.write(`${t('setup.verifyLight1', { state: pass })}\n`)
|
|
951
|
+
out.write(`${t('setup.verifyLight2', { state: fail })}\n`)
|
|
952
|
+
} else if (reason === 'pair-refused') {
|
|
953
|
+
out.write(`${t('setup.verifyLight1', { state: pass })}\n`)
|
|
954
|
+
out.write(`${t('setup.verifyLight2', { state: pass })}\n`)
|
|
955
|
+
} else {
|
|
956
|
+
out.write(`${t('setup.verifyLight1', { state: fail })}\n`)
|
|
957
|
+
out.write(`${t('setup.verifyLight2', { state: fail })}\n`)
|
|
958
|
+
}
|
|
959
|
+
switch (reason) {
|
|
960
|
+
case 'no-public-url': out.write(`${t('host.ingress.verifyReason.noPublicUrl')}\n`); break
|
|
961
|
+
case 'bad-public-url': out.write(`${t('host.ingress.verifyReason.badPublicUrl')}\n`); break
|
|
962
|
+
case 'runtime-not-ready': out.write(`${t('host.ingress.verifyReason.runtimeNotReady')}\n`); break
|
|
963
|
+
case 'gateway-unreachable': out.write(`${t('host.ingress.verifyReason.gatewayUnreachable')}\n`); break
|
|
964
|
+
case 'probe-failed': out.write(`${t('host.ingress.verifyReason.probeFailed')}\n`); break
|
|
965
|
+
case 'pair-refused': {
|
|
966
|
+
const status = typeof detail === 'number' || typeof detail === 'string' ? String(detail) : ''
|
|
967
|
+
out.write(`${t('host.ingress.verifyReason.pairRefused', { detail: status })}\n`)
|
|
968
|
+
break
|
|
969
|
+
}
|
|
970
|
+
default: out.write(`${t('host.ingress.verifyReason.generic', { reason })}\n`); break
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* `host ingress verify [--public-url <origin>] [--no-wait] [--json]` — the
|
|
976
|
+
* three-light check, driven entirely by the plugin's verify orchestration:
|
|
977
|
+
* POST mints the code and walks the public URL (lights 1 + 2), GET polls
|
|
978
|
+
* for the real phone pairing (light 3). `--no-wait` / `--json` stop after
|
|
979
|
+
* the first two lights; an aborted signal (the entry point's Ctrl-C seam)
|
|
980
|
+
* skips the wait gracefully with exit code 0 — the first two lights'
|
|
981
|
+
* verdict is already on the record.
|
|
982
|
+
*/
|
|
983
|
+
export async function runHostIngressVerifyCommand(ctx: CliExecutionContext): Promise<number> {
|
|
984
|
+
if (!(await requireServesRole(ctx))) return 1
|
|
985
|
+
const { t, args, ipc } = ctx
|
|
986
|
+
const out = ctx.stdout
|
|
987
|
+
try {
|
|
988
|
+
const [wizard, admin] = await Promise.all([
|
|
989
|
+
fetchRecord(ctx, 'GET', WIZARD_STATUS_ROUTE),
|
|
990
|
+
fetchRecord(ctx, 'GET', ADMIN_STATUS_ROUTE),
|
|
991
|
+
])
|
|
992
|
+
|
|
993
|
+
let publicUrl = args.options.publicUrl
|
|
994
|
+
if (publicUrl === undefined || publicUrl === '') publicUrl = publicUrlOf(wizard, admin)
|
|
995
|
+
if (publicUrl === undefined || publicUrl === '') {
|
|
996
|
+
publicUrl = await askText(ctx, 'public-url', t('setup.publicUrlQuestion'))
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
let start = await ipc.request('POST', WIZARD_VERIFY_ROUTE, { publicUrl })
|
|
1000
|
+
let startBody = plainOf(start.body)
|
|
1001
|
+
let started = start.status === 200 && startBody?.ok === true
|
|
1002
|
+
// In LAN / Tailscale mode (`http://` private/Tailscale origin), the original
|
|
1003
|
+
// DSH Web UI only listens on 127.0.0.1, so the user cannot open the Web UI
|
|
1004
|
+
// to see a pairing code — the CLI is the ONLY place that can output it. If
|
|
1005
|
+
// the local gateway is up (Light 1 ready) but the machine's self-probe of
|
|
1006
|
+
// its own LAN/Tailscale IP hit a virtual-interface hairpin or routing
|
|
1007
|
+
// restriction (`probe-failed`), fall back to `allowUnprobed: true` so the
|
|
1008
|
+
// pairing code and QR are still minted for real device verification.
|
|
1009
|
+
if (!started && startBody?.reason === 'probe-failed' && publicUrl.startsWith('http://')) {
|
|
1010
|
+
const retry = await ipc.request('POST', WIZARD_VERIFY_ROUTE, { publicUrl, allowUnprobed: true })
|
|
1011
|
+
const retryBody = plainOf(retry.body)
|
|
1012
|
+
if (retry.status === 200 && retryBody?.ok === true) {
|
|
1013
|
+
start = retry
|
|
1014
|
+
startBody = retryBody
|
|
1015
|
+
started = true
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (args.options.json) {
|
|
1020
|
+
// --json is a machine-readable run: the structured verdict, no
|
|
1021
|
+
// interactive wait either way.
|
|
1022
|
+
if (!started || startBody === null) {
|
|
1023
|
+
const reason = typeof startBody?.reason === 'string' && startBody.reason !== '' ? startBody.reason : 'internal'
|
|
1024
|
+
out.write(`${JSON.stringify({ ok: false, reason }, null, 2)}\n`)
|
|
1025
|
+
return 1
|
|
1026
|
+
}
|
|
1027
|
+
out.write(`${JSON.stringify({
|
|
1028
|
+
ok: true,
|
|
1029
|
+
publicUrl: typeof startBody.publicUrl === 'string' && startBody.publicUrl !== '' ? startBody.publicUrl : publicUrl,
|
|
1030
|
+
pairingCode: typeof startBody.pairingCode === 'string' ? startBody.pairingCode : '',
|
|
1031
|
+
expiresAt: typeof startBody.expiresAt === 'number' ? startBody.expiresAt : 0,
|
|
1032
|
+
}, null, 2)}\n`)
|
|
1033
|
+
return 0
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
out.write(`${t('host.ingress.verifyTitle', { publicUrl })}\n`)
|
|
1037
|
+
if (!started || startBody === null) {
|
|
1038
|
+
const reason = typeof startBody?.reason === 'string' && startBody.reason !== '' ? startBody.reason : 'internal'
|
|
1039
|
+
renderVerifyFailure(ctx, reason, startBody?.detail)
|
|
1040
|
+
return 1
|
|
1041
|
+
}
|
|
1042
|
+
const code = typeof startBody.pairingCode === 'string' ? startBody.pairingCode : ''
|
|
1043
|
+
const desktopCode = typeof startBody.desktopPairingCode === 'string' ? startBody.desktopPairingCode : ''
|
|
1044
|
+
const verifiedUrl = typeof startBody.publicUrl === 'string' && startBody.publicUrl !== '' ? startBody.publicUrl : publicUrl
|
|
1045
|
+
|
|
1046
|
+
out.write(`${t('setup.verifyLight1', { state: t('common.done') })}\n`)
|
|
1047
|
+
if (startBody.probeWarning !== undefined) {
|
|
1048
|
+
out.write(`${t('host.ingress.verifyLanProbeWarn')}\n`)
|
|
1049
|
+
} else {
|
|
1050
|
+
out.write(`${t('setup.verifyLight2', { state: t('common.done') })}\n`)
|
|
1051
|
+
}
|
|
1052
|
+
if (verifiedUrl !== '') {
|
|
1053
|
+
out.write(`\n${t('setup.pairingQr')}\n`)
|
|
1054
|
+
const qr = renderTerminalQr(verifiedUrl)
|
|
1055
|
+
if (qr !== null) out.write(`${qr}\n`)
|
|
1056
|
+
out.write(` ${verifiedUrl}\n`)
|
|
1057
|
+
}
|
|
1058
|
+
out.write(`\n${t('setup.divider')}\n`)
|
|
1059
|
+
out.write(` ${t('setup.pairingCode', { code })}\n`)
|
|
1060
|
+
if (desktopCode !== '') {
|
|
1061
|
+
out.write(` ${t('setup.desktopPairingCode', { code: desktopCode })}\n`)
|
|
1062
|
+
if (verifiedUrl !== '') {
|
|
1063
|
+
out.write(` ${t('setup.desktopPairingCmd', { url: verifiedUrl, code: desktopCode })}\n`)
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
out.write(`${t('setup.divider')}\n`)
|
|
1067
|
+
|
|
1068
|
+
if (args.options.noWait) {
|
|
1069
|
+
out.write(`${t('host.ingress.verifySkipped')}\n`)
|
|
1070
|
+
return 0
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
out.write(`${t('setup.pairingWait')}\n`)
|
|
1074
|
+
const interval = ctx.pollIntervalMs ?? VERIFY_POLL_INTERVAL_DEFAULT_MS
|
|
1075
|
+
while (true) {
|
|
1076
|
+
if (ctx.abortSignal?.aborted) {
|
|
1077
|
+
out.write(`${t('host.ingress.verifySkipped')}\n`)
|
|
1078
|
+
return 0
|
|
1079
|
+
}
|
|
1080
|
+
await sleep(interval)
|
|
1081
|
+
let checkBody: ServerRow | null = null
|
|
1082
|
+
try {
|
|
1083
|
+
const check = await ipc.request('GET', WIZARD_VERIFY_ROUTE)
|
|
1084
|
+
checkBody = plainOf(check.body)
|
|
1085
|
+
} catch {
|
|
1086
|
+
continue // one lost poll is not a verdict — keep waiting
|
|
1087
|
+
}
|
|
1088
|
+
if (ctx.abortSignal?.aborted) {
|
|
1089
|
+
out.write(`${t('host.ingress.verifySkipped')}\n`)
|
|
1090
|
+
return 0
|
|
1091
|
+
}
|
|
1092
|
+
const result = typeof checkBody?.result === 'string' ? checkBody.result : ''
|
|
1093
|
+
if (result === 'pass') {
|
|
1094
|
+
out.write(`${t('setup.verifyLight3', { state: t('common.done') })}\n`)
|
|
1095
|
+
out.write(`${t('setup.pairingDone')}\n`)
|
|
1096
|
+
// Persist the verified URL so a later `status` shows it (a failed
|
|
1097
|
+
// write is a warning — the verification itself already passed).
|
|
1098
|
+
const written = await ctx.ipc.writeConfig({ set: { ingressPublicUrl: verifiedUrl } })
|
|
1099
|
+
const writtenBody = plainOf(written.body)
|
|
1100
|
+
if (written.status !== 200 || writtenBody?.ok !== true) {
|
|
1101
|
+
out.write(`${t('host.ingress.configWriteFailed')}\n`)
|
|
1102
|
+
}
|
|
1103
|
+
return 0
|
|
1104
|
+
}
|
|
1105
|
+
if (result === 'cookie-insecure') {
|
|
1106
|
+
out.write(`${t('host.ingress.verifyCookieInsecure')}\n`)
|
|
1107
|
+
return 1
|
|
1108
|
+
}
|
|
1109
|
+
if (result === 'code-expired') {
|
|
1110
|
+
out.write(`${t('host.ingress.verifyExpired')}\n`)
|
|
1111
|
+
return 1
|
|
1112
|
+
}
|
|
1113
|
+
if (result === 'stale') {
|
|
1114
|
+
out.write(`${t('host.ingress.verifyStale')}\n`)
|
|
1115
|
+
return 1
|
|
1116
|
+
}
|
|
1117
|
+
// pending / not-started / unrecognized: keep polling until the code's
|
|
1118
|
+
// own expiry (the orchestration answers code-expired then).
|
|
1119
|
+
}
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
if (error instanceof MissingRequiredOptionError) {
|
|
1122
|
+
out.write(`${t('err.missingOption', { option: error.optionName })}\n`)
|
|
1123
|
+
return 1
|
|
1124
|
+
}
|
|
1125
|
+
throw error
|
|
1126
|
+
}
|
|
1127
|
+
}
|