dsh-zen-remote 2.2.8 → 2.3.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.
Files changed (162) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/docs/interface.md +1 -1
  4. package/docs/remote-access.md +20 -0
  5. package/lan-gate.mjs +11 -1
  6. package/lib/adapters/dsh-better-sidebar/index.js +6 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
  9. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  10. package/lib/admin-routes.js +33 -2
  11. package/lib/admin-routes.js.map +1 -1
  12. package/lib/client-routes.js +52 -0
  13. package/lib/client-routes.js.map +1 -1
  14. package/lib/client.js +3390 -10
  15. package/lib/config.js +70 -0
  16. package/lib/config.js.map +1 -1
  17. package/lib/index.js +232 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/intercept.js +43 -3
  20. package/lib/intercept.js.map +1 -1
  21. package/lib/lan-gate-server.cjs +29 -3
  22. package/lib/merge-streams.js +42 -12
  23. package/lib/merge-streams.js.map +1 -1
  24. package/lib/relay-client.js +71 -0
  25. package/lib/relay-client.js.map +1 -1
  26. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  29. package/lib/types/admin-routes.d.ts +13 -0
  30. package/lib/types/admin-routes.d.ts.map +1 -1
  31. package/lib/types/client/index.d.ts.map +1 -1
  32. package/lib/types/client/locales.d.ts +204 -1
  33. package/lib/types/client/locales.d.ts.map +1 -1
  34. package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
  35. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
  36. package/lib/types/client/wizard/WizardCard.d.ts +30 -0
  37. package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
  38. package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
  39. package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
  40. package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
  41. package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
  42. package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
  43. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
  44. package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
  45. package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
  46. package/lib/types/client/wizard/register-wizard.d.ts +51 -0
  47. package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
  48. package/lib/types/client/wizard/wizard-css.d.ts +10 -0
  49. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
  50. package/lib/types/client-data/qr.d.ts +20 -0
  51. package/lib/types/client-data/qr.d.ts.map +1 -0
  52. package/lib/types/client-data/remote-status.d.ts +39 -2
  53. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  54. package/lib/types/client-data/settings-form.d.ts +7 -2
  55. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  56. package/lib/types/client-data/wizard.d.ts +247 -0
  57. package/lib/types/client-data/wizard.d.ts.map +1 -0
  58. package/lib/types/client-routes.d.ts +19 -0
  59. package/lib/types/client-routes.d.ts.map +1 -1
  60. package/lib/types/config.d.ts +27 -0
  61. package/lib/types/config.d.ts.map +1 -1
  62. package/lib/types/index.d.ts.map +1 -1
  63. package/lib/types/intercept.d.ts.map +1 -1
  64. package/lib/types/merge-streams.d.ts.map +1 -1
  65. package/lib/types/relay-client.d.ts +10 -0
  66. package/lib/types/relay-client.d.ts.map +1 -1
  67. package/lib/types/wizard/binary-io.d.ts +111 -0
  68. package/lib/types/wizard/binary-io.d.ts.map +1 -0
  69. package/lib/types/wizard/binary.d.ts +118 -0
  70. package/lib/types/wizard/binary.d.ts.map +1 -0
  71. package/lib/types/wizard/caddy-process.d.ts +101 -0
  72. package/lib/types/wizard/caddy-process.d.ts.map +1 -0
  73. package/lib/types/wizard/caddy.d.ts +53 -0
  74. package/lib/types/wizard/caddy.d.ts.map +1 -0
  75. package/lib/types/wizard/cloudflare.d.ts +112 -0
  76. package/lib/types/wizard/cloudflare.d.ts.map +1 -0
  77. package/lib/types/wizard/cloudflared.d.ts +71 -0
  78. package/lib/types/wizard/cloudflared.d.ts.map +1 -0
  79. package/lib/types/wizard/probe.d.ts +66 -0
  80. package/lib/types/wizard/probe.d.ts.map +1 -0
  81. package/lib/types/wizard/proxy-snippets.d.ts +60 -0
  82. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
  83. package/lib/types/wizard/routes.d.ts +152 -0
  84. package/lib/types/wizard/routes.d.ts.map +1 -0
  85. package/lib/types/wizard/source-detect.d.ts +53 -0
  86. package/lib/types/wizard/source-detect.d.ts.map +1 -0
  87. package/lib/types/wizard/status.d.ts +115 -0
  88. package/lib/types/wizard/status.d.ts.map +1 -0
  89. package/lib/types/wizard/token-store.d.ts +27 -0
  90. package/lib/types/wizard/token-store.d.ts.map +1 -0
  91. package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
  92. package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
  93. package/lib/types/wizard/verify.d.ts +97 -0
  94. package/lib/types/wizard/verify.d.ts.map +1 -0
  95. package/lib/wizard/binary-io.js +386 -0
  96. package/lib/wizard/binary-io.js.map +1 -0
  97. package/lib/wizard/binary.js +138 -0
  98. package/lib/wizard/binary.js.map +1 -0
  99. package/lib/wizard/caddy-process.js +162 -0
  100. package/lib/wizard/caddy-process.js.map +1 -0
  101. package/lib/wizard/caddy.js +80 -0
  102. package/lib/wizard/caddy.js.map +1 -0
  103. package/lib/wizard/cloudflare.js +334 -0
  104. package/lib/wizard/cloudflare.js.map +1 -0
  105. package/lib/wizard/cloudflared.js +158 -0
  106. package/lib/wizard/cloudflared.js.map +1 -0
  107. package/lib/wizard/probe.js +118 -0
  108. package/lib/wizard/probe.js.map +1 -0
  109. package/lib/wizard/proxy-snippets.js +112 -0
  110. package/lib/wizard/proxy-snippets.js.map +1 -0
  111. package/lib/wizard/routes.js +462 -0
  112. package/lib/wizard/routes.js.map +1 -0
  113. package/lib/wizard/source-detect.js +77 -0
  114. package/lib/wizard/source-detect.js.map +1 -0
  115. package/lib/wizard/status.js +94 -0
  116. package/lib/wizard/status.js.map +1 -0
  117. package/lib/wizard/token-store.js +71 -0
  118. package/lib/wizard/token-store.js.map +1 -0
  119. package/lib/wizard/tunnel-orchestrator.js +155 -0
  120. package/lib/wizard/tunnel-orchestrator.js.map +1 -0
  121. package/lib/wizard/verify.js +171 -0
  122. package/lib/wizard/verify.js.map +1 -0
  123. package/package.json +37 -37
  124. package/scripts/check-settings-form.mjs +5 -4
  125. package/scripts/check-wizard-ui.mjs +690 -0
  126. package/src/adapters/dsh-better-sidebar/index.ts +6 -2
  127. package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
  128. package/src/admin-routes.ts +44 -2
  129. package/src/client/index.tsx +9 -0
  130. package/src/client/locales.ts +410 -2
  131. package/src/client/wizard/GatewayWizard.tsx +2220 -0
  132. package/src/client/wizard/WizardCard.tsx +81 -0
  133. package/src/client/wizard/cards/MigrateCard.tsx +42 -0
  134. package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
  135. package/src/client/wizard/cards/ProxyCard.tsx +56 -0
  136. package/src/client/wizard/cards/TokenCard.tsx +81 -0
  137. package/src/client/wizard/register-wizard.ts +99 -0
  138. package/src/client/wizard/wizard-css.ts +948 -0
  139. package/src/client-data/qr.ts +284 -0
  140. package/src/client-data/remote-status.ts +128 -2
  141. package/src/client-data/settings-form.ts +12 -3
  142. package/src/client-data/wizard.ts +432 -0
  143. package/src/client-routes.ts +53 -0
  144. package/src/config.ts +87 -0
  145. package/src/index.ts +227 -2
  146. package/src/intercept.ts +43 -3
  147. package/src/merge-streams.ts +41 -12
  148. package/src/relay-client.ts +76 -0
  149. package/src/wizard/binary-io.ts +449 -0
  150. package/src/wizard/binary.ts +177 -0
  151. package/src/wizard/caddy-process.ts +228 -0
  152. package/src/wizard/caddy.ts +97 -0
  153. package/src/wizard/cloudflare.ts +413 -0
  154. package/src/wizard/cloudflared.ts +196 -0
  155. package/src/wizard/probe.ts +161 -0
  156. package/src/wizard/proxy-snippets.ts +128 -0
  157. package/src/wizard/routes.ts +537 -0
  158. package/src/wizard/source-detect.ts +87 -0
  159. package/src/wizard/status.ts +180 -0
  160. package/src/wizard/token-store.ts +73 -0
  161. package/src/wizard/tunnel-orchestrator.ts +197 -0
  162. package/src/wizard/verify.ts +236 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Managed-Caddy process lifecycle (GW-3). The Caddyfile's content is
3
+ * caddy.ts's job; this module owns everything with a heartbeat:
4
+ *
5
+ * start: ensureBinary (GW-2, cached after the first download) →
6
+ * generateCaddyfile (typed refusals) → write the Caddyfile as a
7
+ * `.part` sibling → `caddy validate --config <part> --adapter
8
+ * caddyfile` → only on a clean validate rename it to the real
9
+ * path → `caddy run --config <Caddyfile>`. The ticket's "validate
10
+ * before write" is honored as validate-before-PUBLISH: the final
11
+ * Caddyfile on disk only ever holds bytes Caddy accepted, and a
12
+ * failed validate leaves neither a start nor a published file.
13
+ *
14
+ * The Cloudflare API token travels in the child's ENVIRONMENT
15
+ * (`CF_API_TOKEN`, what the generated `{env.CF_API_TOKEN}` placeholder
16
+ * reads) — never in argv, which other processes on the machine can see.
17
+ *
18
+ * Idempotency: start with a live process refuses (`already-running`) —
19
+ * switching configuration stops first (design: 切换路时先停旧进程再启新的).
20
+ * stop is idempotent; an exited child clears its own slot so a later
21
+ * start works. The host wiring hands stop to ctx.effect, so the row's
22
+ * disposal kills Caddy too.
23
+ *
24
+ * The subprocess service is structural (the tests feed a recorder), and
25
+ * `fetch` is injectable for the ensureBinary step.
26
+ */
27
+
28
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
29
+ import { ensureBinary, BinaryDownloadError, type DownloadProgress } from './binary-io.js'
30
+ import { caddyDataDir, caddyfilePath, ingressDir, type BinaryPlatform } from './binary.js'
31
+ import { generateCaddyfile, type CaddyfileInput } from './caddy.js'
32
+
33
+ /** The slice of DSH's subprocess service the manager drives (structural so
34
+ * tests record instead of exec). */
35
+ export interface SubprocessHandleLike {
36
+ terminate(): unknown
37
+ done: Promise<{ exitCode?: number | null; signal?: string | null }>
38
+ }
39
+
40
+ export interface SubprocessServiceLike {
41
+ spawn(options: {
42
+ argv: string[]
43
+ cwd?: string
44
+ env?: NodeJS.ProcessEnv
45
+ stdio?: unknown
46
+ graceMs?: number
47
+ }): SubprocessHandleLike
48
+ }
49
+
50
+ export interface CaddyManagerOptions {
51
+ /** The host's subprocess service (ctx.subprocess). */
52
+ subprocess: SubprocessServiceLike
53
+ /** `<DSH_HOME>` — binaries, Caddyfile and caddy-data all live under it. */
54
+ dshHome: string
55
+ /** Injected transport for the one-time binary download (tests point it
56
+ * at a mock server). */
57
+ fetchImpl?: typeof fetch
58
+ /** Forced platform mapping (tests). */
59
+ platform?: BinaryPlatform
60
+ /** Download progress relay (bytes, declared total or null). */
61
+ onProgress?: DownloadProgress
62
+ /** Stdio byte caps for the child, both streams. Default 64 KiB — enough
63
+ * for the startup banner and an error or two. */
64
+ maxLogBytes?: number
65
+ }
66
+
67
+ export type CaddyStartResult =
68
+ | { ok: true; binaryPath: string; caddyfile: string; alreadyRunning: false }
69
+ | {
70
+ ok: false
71
+ error: 'already-running' | 'missing-token' | 'bad-config' | 'download-failed' | 'validate-failed' | 'spawn-failed'
72
+ /** The generateCaddyfile error code for `bad-config`, or a short
73
+ * human-readable line for the transport/spawn failures. */
74
+ detail?: string
75
+ /** Official manual-install link, present for `download-failed`. */
76
+ installUrl?: string
77
+ }
78
+
79
+ export interface CaddyManager {
80
+ /**
81
+ * Idempotent front door: a live Caddy refuses a second start (stop
82
+ * first to switch configuration). Throws nothing — every failure is a
83
+ * value. The storage directory is this manager's own (under dshHome) —
84
+ * callers supply only the user-facing fields.
85
+ */
86
+ start(input: Omit<CaddyfileInput, 'storageDir'> & { cfToken: unknown }): Promise<CaddyStartResult>
87
+ /** Idempotent terminate; safe before any start. */
88
+ stop(): void
89
+ /** Whether a child is currently held (true between spawn and exit). */
90
+ isRunning(): boolean
91
+ /** The live child's Caddyfile path, or undefined. */
92
+ runningCaddyfile(): string | undefined
93
+ /** Whether any start has succeeded in this manager's lifetime. */
94
+ hasStarted(): boolean
95
+ }
96
+
97
+ export function createCaddyManager(options: CaddyManagerOptions): CaddyManager {
98
+ const { subprocess, dshHome } = options
99
+ const maxLogBytes = options.maxLogBytes ?? 64 * 1024
100
+ let handle: SubprocessHandleLike | null = null
101
+ let caddyfileArg: string | undefined
102
+ let startedOnce = false
103
+
104
+ // GW-fix-2/L3: a WHITELIST, not the host's full environment — the host
105
+ // process carries the relay secret and DSH's own credentials in env, and
106
+ // a child that only needs to reach the internet and the local disk has
107
+ // no business inheriting them. Include Windows system/profile variables so
108
+ // Go's TLS root-cert store and winsock initialize cleanly on Windows.
109
+ const childEnv = (cfToken: string): NodeJS.ProcessEnv => {
110
+ const env: NodeJS.ProcessEnv = {}
111
+ for (const key of [
112
+ 'PATH', 'HOME', 'TMPDIR', 'LANG', 'TZ',
113
+ 'SystemRoot', 'SYSTEMROOT', 'windir', 'WINDIR',
114
+ 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'TEMP', 'TMP',
115
+ ]) {
116
+ if (process.env[key] !== undefined) env[key] = process.env[key]
117
+ }
118
+ // The one secret this child needs, by env only (the Caddyfile reads
119
+ // {env.CF_API_TOKEN}) — never argv.
120
+ env.CF_API_TOKEN = cfToken
121
+ return env
122
+ }
123
+
124
+ const spawnChild = (argv: string[], env: NodeJS.ProcessEnv): SubprocessHandleLike =>
125
+ subprocess.spawn({
126
+ argv,
127
+ cwd: dshHome,
128
+ env,
129
+ stdio: {
130
+ stdin: 'ignore',
131
+ stdout: { maxBytes: maxLogBytes },
132
+ stderr: { maxBytes: maxLogBytes },
133
+ },
134
+ graceMs: 3000,
135
+ })
136
+
137
+ return {
138
+ async start(input) {
139
+ if (handle !== null) return { ok: false, error: 'already-running' }
140
+ const cfToken = typeof input.cfToken === 'string' && input.cfToken.trim() !== '' ? input.cfToken : undefined
141
+ if (cfToken === undefined) return { ok: false, error: 'missing-token' }
142
+ const generated = generateCaddyfile({ ...input, storageDir: caddyDataDir(dshHome) })
143
+ if (!generated.ok) return { ok: false, error: 'bad-config', detail: generated.error }
144
+
145
+ let binaryPath: string
146
+ try {
147
+ binaryPath = (await ensureBinary(dshHome, 'caddy', {
148
+ fetchImpl: options.fetchImpl,
149
+ platform: options.platform,
150
+ onProgress: options.onProgress,
151
+ })).path
152
+ } catch (error) {
153
+ if (error instanceof BinaryDownloadError) {
154
+ return { ok: false, error: 'download-failed', detail: error.message, installUrl: error.installUrl }
155
+ }
156
+ return { ok: false, error: 'download-failed', detail: String((error as Error)?.message ?? error) }
157
+ }
158
+
159
+ // Write → validate → publish: the `.part` file carries the exact
160
+ // bytes under validation, so what lands at the real path is exactly
161
+ // what Caddy accepted.
162
+ const finalPath = caddyfilePath(dshHome)
163
+ const partPath = `${finalPath}.part`
164
+ try {
165
+ mkdirSync(ingressDir(dshHome), { recursive: true })
166
+ writeFileSync(partPath, generated.caddyfile, { mode: 0o600 })
167
+ const validate = spawnChild([binaryPath, 'validate', '--config', partPath, '--adapter', 'caddyfile'], childEnv(cfToken))
168
+ const outcome = await validate.done
169
+ if ((outcome.exitCode ?? 1) !== 0) {
170
+ rmSync(partPath, { force: true })
171
+ return { ok: false, error: 'validate-failed' }
172
+ }
173
+ renameSync(partPath, finalPath)
174
+ } catch (error) {
175
+ rmSync(partPath, { force: true })
176
+ return { ok: false, error: 'spawn-failed', detail: String((error as Error)?.message ?? error) }
177
+ }
178
+
179
+ const env = childEnv(cfToken)
180
+ try {
181
+ const child = spawnChild([binaryPath, 'run', '--config', finalPath], env)
182
+ handle = child
183
+ caddyfileArg = finalPath
184
+ startedOnce = true
185
+ // An exited child clears its own slot: isRunning() goes honest and
186
+ // a later start() works without a manual stop.
187
+ void child.done.then(() => {
188
+ if (handle === child) {
189
+ handle = null
190
+ caddyfileArg = undefined
191
+ }
192
+ }).catch(() => {
193
+ if (handle === child) {
194
+ handle = null
195
+ caddyfileArg = undefined
196
+ }
197
+ })
198
+ return { ok: true, binaryPath, caddyfile: finalPath, alreadyRunning: false }
199
+ } catch (error) {
200
+ handle = null
201
+ caddyfileArg = undefined
202
+ return { ok: false, error: 'spawn-failed', detail: String((error as Error)?.message ?? error) }
203
+ }
204
+ },
205
+
206
+ stop() {
207
+ if (handle === null) return
208
+ const child = handle
209
+ handle = null
210
+ caddyfileArg = undefined
211
+ try {
212
+ void child.terminate()
213
+ } catch {
214
+ // A dead child's terminate is a no-op or a throw — either way done.
215
+ }
216
+ },
217
+
218
+ isRunning: () => handle !== null,
219
+ runningCaddyfile: () => caddyfileArg,
220
+ /** Whether a start has EVER succeeded in this row's lifetime — the
221
+ * three-state status needs it to tell 已退出 (degraded) from 未启动. */
222
+ hasStarted: () => startedOnce,
223
+ }
224
+ }
225
+
226
+ /** Re-exported so the wiring (and later the routes) need one import for
227
+ * the whole ingress-directory layout. */
228
+ export { caddyDataDir, caddyfilePath, ingressDir }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Caddyfile generation (GW-3), pure logic — every byte the managed Caddy
3
+ * will run comes out of {@link generateCaddyfile}, so the validation and
4
+ * the injection guards live HERE and the process half (caddy-process.ts)
5
+ * only writes, validates and spawns what this answered.
6
+ *
7
+ * Shape (design.md 路 A), with one deliberate correction to the design
8
+ * sketch: the global DNS-01 option in a real Caddyfile is `acme_dns
9
+ * <provider> <token-placeholder>` — the sketch's bare `dns cloudflare`
10
+ * line is site-block `tls` syntax and is not valid as a global option.
11
+ * The DNS-01 challenge itself is the point: the certificate is ordered
12
+ * through Cloudflare's API (the token rides the `CF_API_TOKEN`
13
+ * environment variable, never the file), so neither 80 nor 443 needs to
14
+ * be reachable from outside for issuance; only the HTTPS service port
15
+ * must be.
16
+ *
17
+ * Injection posture: every interpolated value passes a charset gate —
18
+ * the domain through config.ts's `hostOf` (the exact validator the row
19
+ * layer applies), the storage path free of control characters, quotes and
20
+ * braces (Caddyfile's block delimiters), the env-var name as a legal
21
+ * POSIX name. Nothing else from the outside world reaches the output.
22
+ */
23
+
24
+ import { hostOf } from '../config.js'
25
+
26
+ /** The default environment variable the Cloudflare token rides in on. */
27
+ export const CF_TOKEN_ENV_DEFAULT = 'CF_API_TOKEN'
28
+
29
+ export interface CaddyfileInput {
30
+ /** Public ingress domain, bare hostname (validated like the row field). */
31
+ domain: unknown
32
+ /** HTTPS port the managed Caddy listens on. [1, 65535]. */
33
+ httpsPort: unknown
34
+ /** The gateway's ACTUAL listening port — the caller reads it from the
35
+ * running gateway, never the configured one (it may have retried). */
36
+ gatewayPort: unknown
37
+ /** Caddy storage directory (certificates + ACME account). */
38
+ storageDir: unknown
39
+ /** Environment variable name the Cloudflare token arrives in. */
40
+ cfTokenEnvName?: unknown
41
+ }
42
+
43
+ export type CaddyfileResult =
44
+ | { ok: true; caddyfile: string; domain: string; httpsPort: number; gatewayPort: number }
45
+ | { ok: false; error: 'bad-domain' | 'bad-https-port' | 'bad-gateway-port' | 'bad-storage' | 'bad-token-env' }
46
+
47
+ /** Integers in [1, 65535] only. */
48
+ function portOf(value: unknown): number | undefined {
49
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535 ? value : undefined
50
+ }
51
+
52
+ /** Storage paths go into the Caddyfile quoted; anything that could break
53
+ * out of the quoting or forge a block delimiter is refused — control
54
+ * characters (newlines foremost), quotes, braces. */
55
+ function storageDirOf(value: unknown): string | undefined {
56
+ if (typeof value !== 'string' || value === '') return undefined
57
+ // eslint-disable-next-line no-control-regex
58
+ if (/[\u0000-\u001f\u007f"{}]/.test(value)) return undefined
59
+ return value
60
+ }
61
+
62
+ /** Env names: POSIX uppercase convention, strictly. */
63
+ const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/
64
+
65
+ /**
66
+ * Build the complete Caddyfile for the managed Caddy. Refuses (typed)
67
+ * instead of throwing so the caller can map each refusal to a form error.
68
+ */
69
+ export function generateCaddyfile(input: CaddyfileInput): CaddyfileResult {
70
+ const domain = hostOf(input.domain)
71
+ if (domain === undefined) return { ok: false, error: 'bad-domain' }
72
+ const httpsPort = portOf(input.httpsPort)
73
+ if (httpsPort === undefined) return { ok: false, error: 'bad-https-port' }
74
+ const gatewayPort = portOf(input.gatewayPort)
75
+ if (gatewayPort === undefined) return { ok: false, error: 'bad-gateway-port' }
76
+ const storageDir = storageDirOf(input.storageDir)
77
+ if (storageDir === undefined) return { ok: false, error: 'bad-storage' }
78
+ const tokenEnv = typeof input.cfTokenEnvName === 'string' && ENV_NAME_PATTERN.test(input.cfTokenEnvName)
79
+ ? input.cfTokenEnvName
80
+ : CF_TOKEN_ENV_DEFAULT
81
+ if (input.cfTokenEnvName !== undefined && input.cfTokenEnvName !== '' && tokenEnv !== input.cfTokenEnvName) {
82
+ return { ok: false, error: 'bad-token-env' }
83
+ }
84
+ const caddyfile = [
85
+ '{',
86
+ `\tacme_dns cloudflare {env.${tokenEnv}}`,
87
+ `\tstorage file_system "${storageDir}"`,
88
+ '\tadmin off',
89
+ '}',
90
+ '',
91
+ `https://${domain}:${httpsPort} {`,
92
+ `\treverse_proxy 127.0.0.1:${gatewayPort}`,
93
+ '}',
94
+ '',
95
+ ].join('\n')
96
+ return { ok: true, caddyfile, domain, httpsPort, gatewayPort }
97
+ }