openship 0.4.8 → 0.6.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.
Files changed (112) hide show
  1. package/README.md +27 -1
  2. package/dist/bare-FSUAM5V5.js +23 -0
  3. package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
  4. package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
  5. package/dist/chunk-3FYIXVX5.js +8051 -0
  6. package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
  7. package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
  8. package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
  9. package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
  10. package/dist/{chunk-OXWDY22G.js → chunk-6ODUBKUB.js} +3 -3
  11. package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
  12. package/dist/{chunk-XPPZ7B2J.js → chunk-B3CE2OMR.js} +14 -12
  13. package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
  14. package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
  15. package/dist/{chunk-V6AN6XLZ.js → chunk-EMXI4UCU.js} +125 -47
  16. package/dist/{chunk-GKHUSZCD.js → chunk-EP2TNALB.js} +2 -2
  17. package/dist/{chunk-SLTYVHU7.js → chunk-GUQ3QCJ7.js} +942 -519
  18. package/dist/chunk-I3DPMTSF.js +29 -0
  19. package/dist/{chunk-L2VOGB2X.js → chunk-IW2UKBVF.js} +3 -3
  20. package/dist/{chunk-VDMQJHPI.js → chunk-IXM5245F.js} +228 -13
  21. package/dist/{chunk-MZ2WEHHY.js → chunk-JIMUYEKN.js} +4 -4
  22. package/dist/chunk-OGY63DGD.js +705 -0
  23. package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
  24. package/dist/{chunk-6APULWSU.js → chunk-OTLSJYAC.js} +1744 -118
  25. package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
  26. package/dist/{chunk-5NYUCONN.js → chunk-PSZSKEPA.js} +33 -51
  27. package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
  28. package/dist/{chunk-FXTBHTWY.js → chunk-QEH4QUBX.js} +649 -85
  29. package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
  30. package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
  31. package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
  32. package/dist/{chunk-TGIINPVM.js → chunk-TWJCLD4W.js} +60 -10
  33. package/dist/{chunk-4Z5QO7N6.js → chunk-VDDMADOF.js} +3 -3
  34. package/dist/{chunk-BBHX7T4R.js → chunk-VE2YOK4I.js} +991 -254
  35. package/dist/{chunk-2D3I5G6P.js → chunk-VZ2ZHPS2.js} +73 -43
  36. package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
  37. package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
  38. package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
  39. package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
  40. package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
  41. package/dist/cloud-E5M6VMCG.js +31 -0
  42. package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
  43. package/dist/{detect-AZLOHUFX.js → detect-ALF4GXRX.js} +7 -8
  44. package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
  45. package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
  46. package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
  47. package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
  48. package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
  49. package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
  50. package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
  51. package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
  52. package/dist/docker-QECQJ5P4.js +32 -0
  53. package/dist/docker-edge-executor-62N64MGS.js +17 -0
  54. package/dist/edge-import-CQWHN2Q3.js +48 -0
  55. package/dist/ensure-container-edge-MJVXJQG5.js +32 -0
  56. package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
  57. package/dist/{executor-XJ6RP4Q2.js → executor-JELARVKC.js} +8 -8
  58. package/dist/index.js +1209 -560
  59. package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
  60. package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
  61. package/dist/nginx-AXGNWRKZ.js +31 -0
  62. package/dist/node-entry.js +4 -0
  63. package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
  64. package/dist/openresty-lua-IE4P5YQX.js +81 -0
  65. package/dist/server/index.js +116248 -94749
  66. package/dist/server/lua/geo_country.lua +31 -6
  67. package/dist/server/lua/maxminddb.lua +403 -0
  68. package/dist/server/lua/mgmt_api.lua +431 -68
  69. package/dist/server/lua/pipe_log.lua +10 -4
  70. package/dist/server/lua/site_logger.lua +220 -12
  71. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  72. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  73. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  74. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  75. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  76. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  77. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  78. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  79. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  80. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  81. package/dist/server/migrations/0083_service_incident.sql +84 -0
  82. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  83. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  84. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  85. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  86. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  87. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  88. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  89. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  90. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  91. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  92. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  93. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  94. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  95. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  96. package/dist/server/migrations/meta/_journal.json +175 -0
  97. package/dist/setup-FA4PW4NU.js +21 -0
  98. package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
  99. package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
  100. package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
  101. package/package.json +6 -3
  102. package/dist/bare-JY5Y5ELU.js +0 -23
  103. package/dist/chunk-SJIFLLX4.js +0 -2931
  104. package/dist/chunk-VFINNTUL.js +0 -362
  105. package/dist/cloud-KYWY3RP4.js +0 -30
  106. package/dist/docker-LODK5Y2S.js +0 -16
  107. package/dist/docker-edge-executor-VHW3JPJS.js +0 -13
  108. package/dist/edge-import-ABUDKKJI.js +0 -45
  109. package/dist/ensure-container-edge-SW6HENSG.js +0 -31
  110. package/dist/nginx-3L4SIBJA.js +0 -24
  111. package/dist/openresty-lua-4WLGDSER.js +0 -42
  112. package/dist/setup-NU6T2PDU.js +0 -20
@@ -1,24 +1,40 @@
1
1
  #!/usr/bin/env sh
2
- ":" //# ; exec "$(command -v bun || command -v node)" "$0" "$@"
2
+ ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
3
  import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
- sq
7
- } from "./chunk-FPRHYBY2.js";
6
+ edgeDownExplanation,
7
+ explainEdgeDown,
8
+ isEdgeDownFailure
9
+ } from "./chunk-I3DPMTSF.js";
10
+ import {
11
+ edgeFailureReason
12
+ } from "./chunk-VZ2ZHPS2.js";
8
13
  import {
9
14
  ACME_CHALLENGE_LOCATION,
10
15
  ACME_HTTP01_PORT,
16
+ EDGE_CHALLENGE_DIR,
17
+ EDGE_CHALLENGE_LOCATION,
18
+ EDGE_CHALLENGE_URL_PREFIX,
11
19
  LUA_LOGGER_PATH,
12
20
  RULES_GUARD_PATH,
13
21
  buildReloadCommand,
14
22
  detectOpenRestyPaths,
23
+ edgeChallengeVhostConf,
15
24
  luaSourceAvailable
16
- } from "./chunk-VFINNTUL.js";
25
+ } from "./chunk-OGY63DGD.js";
26
+ import {
27
+ logEntry,
28
+ sq
29
+ } from "./chunk-ZFJXPCQP.js";
17
30
  import {
18
- PROXY_GZIP_TYPES,
31
+ PROXY_DIRECTIVES,
32
+ parseProxyValue,
33
+ resolveProxyDirectives,
34
+ resolveRedirectStatus,
19
35
  safeErrorMessage,
20
36
  sanitizeProxySettings
21
- } from "./chunk-6APULWSU.js";
37
+ } from "./chunk-OTLSJYAC.js";
22
38
 
23
39
  // ../../packages/adapters/src/infra/nginx.ts
24
40
  import {
@@ -31,9 +47,9 @@ import {
31
47
  stat as fsStat
32
48
  } from "fs/promises";
33
49
  import { execFile as cpExecFile } from "child_process";
34
- import { randomBytes } from "crypto";
50
+ import { createHash, randomBytes } from "crypto";
35
51
  import { promisify } from "util";
36
- import { dirname, join } from "path";
52
+ import { posix as edgePath } from "path";
37
53
 
38
54
  // ../../packages/adapters/src/system/proxy/cert-material.ts
39
55
  import { X509Certificate, createPrivateKey } from "crypto";
@@ -42,6 +58,15 @@ import { X509Certificate, createPrivateKey } from "crypto";
42
58
  function containerCommand(container, command) {
43
59
  return `docker exec ${sq(container)} sh -c ${sq(command)}`;
44
60
  }
61
+ async function diagnoseEdgeDown(inner, container) {
62
+ const status = await inner.exec(`docker inspect -f '{{.State.Status}}' ${sq(container)} 2>/dev/null`).catch(() => null);
63
+ const logs = await inner.exec(`docker logs --tail 40 ${sq(container)} 2>&1`).catch(() => null);
64
+ return explainEdgeDown({
65
+ container,
66
+ status: status?.trim() || null,
67
+ reason: logs ? edgeFailureReason(logs) : null
68
+ });
69
+ }
45
70
  function resolveRef(ref) {
46
71
  return typeof ref === "function" ? ref() : ref;
47
72
  }
@@ -68,8 +93,34 @@ async function writeEdgeFileIn(inner, container, path, content) {
68
93
  function edgeContainerExecutor(inner, container, opts) {
69
94
  const inContainer = (command) => containerCommand(container, command);
70
95
  const overrides = {
71
- exec: (command, execOpts) => inner.exec(inContainer(command), execOpts),
72
- streamExec: (command, onLog) => inner.streamExec(inContainer(command), onLog),
96
+ // Both command paths enrich a "the container isn't up" failure with the
97
+ // container's name, state and `[emerg]` — see `diagnoseEdgeDown`. Gated on the
98
+ // narrow daemon/nginx signatures (`isEdgeDownFailure`), because a non-zero exit
99
+ // is ordinary here: the container-file probes report a missing path that way, and
100
+ // paying two `docker` round-trips per miss would be a real cost on every read.
101
+ exec: async (command, execOpts) => {
102
+ try {
103
+ return await inner.exec(inContainer(command), execOpts);
104
+ } catch (err) {
105
+ const message = err?.message ?? String(err);
106
+ if (!isEdgeDownFailure(message)) throw err;
107
+ throw new Error(`${await diagnoseEdgeDown(inner, container)}
108
+ ${message}`);
109
+ }
110
+ },
111
+ // streamExec REPORTS failure instead of throwing it, and its callers build the
112
+ // error they raise out of what was streamed (`_execCertbot` accumulates the log
113
+ // lines and throws that text). So the explanation has to go through `onLog` to
114
+ // reach them — appending it only to the return value would leave certbot's
115
+ // caller raising the daemon's bare container id, which is the original bug.
116
+ streamExec: async (command, onLog, opts2) => {
117
+ const result = await inner.streamExec(inContainer(command), onLog, opts2);
118
+ if (result.code === 0 || !isEdgeDownFailure(result.output)) return result;
119
+ const explanation = await diagnoseEdgeDown(inner, container);
120
+ onLog(logEntry(explanation, "error"));
121
+ return { code: result.code, output: `${explanation}
122
+ ${result.output}` };
123
+ },
73
124
  // A rename is a FILE op, so it follows the files, never the commands. Stated
74
125
  // here rather than left to the Proxy's passthrough: if the inner executor has no
75
126
  // `rename`, the fallback must still be the INNER shell (the host) — routing it
@@ -218,8 +269,13 @@ function validateCertFor(host, pems, source) {
218
269
  function isSafeCertPath(p) {
219
270
  return /^\/[A-Za-z0-9._/-]+$/.test(p) && !p.includes("..");
220
271
  }
272
+ var BOOTSTRAP_CERT_SEGMENT = "openship-bootstrap";
273
+ function isBootstrapCertPath(p) {
274
+ return p.split("/").includes(BOOTSTRAP_CERT_SEGMENT);
275
+ }
221
276
  async function readDeclaredPair(exec, certPath, keyPath, container) {
222
277
  if (!isSafeCertPath(certPath) || !isSafeCertPath(keyPath)) return null;
278
+ if (isBootstrapCertPath(certPath) || isBootstrapCertPath(keyPath)) return null;
223
279
  const certPem = await readMaybeInContainer(exec, certPath, container);
224
280
  const keyPem = await readMaybeInContainer(exec, keyPath, container);
225
281
  return certPem && keyPem ? { certPem, keyPem } : null;
@@ -248,13 +304,20 @@ async function probeStaticOutput(executor, servedPath) {
248
304
  }
249
305
 
250
306
  // ../../packages/adapters/src/infra/nginx.ts
307
+ var { dirname, join } = edgePath;
251
308
  var PROXY_HEADERS = `proxy_set_header Host $host;
252
309
  proxy_set_header X-Real-IP $remote_addr;
253
310
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
254
- proxy_set_header X-Forwarded-Proto $scheme;
311
+ proxy_set_header X-Forwarded-Proto $openship_fwd_proto;
255
312
  proxy_http_version 1.1;
256
313
  proxy_set_header Upgrade $http_upgrade;
257
314
  proxy_set_header Connection "upgrade";`;
315
+ var FORWARD_VARS = ` set $openship_fwd_proto $scheme;
316
+ set $openship_redirect_https 1;
317
+ if ($http_x_forwarded_proto = "https") {
318
+ set $openship_fwd_proto https;
319
+ set $openship_redirect_https 0;
320
+ }`;
258
321
  function renderProxyLocations(route) {
259
322
  if (!route.proxyLocations || route.proxyLocations.length === 0) return "";
260
323
  return route.proxyLocations.map((loc) => {
@@ -266,6 +329,12 @@ function renderProxyLocations(route) {
266
329
  }`;
267
330
  }).join("");
268
331
  }
332
+ function renderHostRedirect(route) {
333
+ const redirect = route.redirectHost;
334
+ if (!redirect?.target) return null;
335
+ assertValidDomain(redirect.target);
336
+ return `return ${resolveRedirectStatus(redirect.statusCode)} https://${redirect.target}$request_uri;`;
337
+ }
269
338
  function renderRedirects(route) {
270
339
  if (!route.redirects || route.redirects.length === 0) return "";
271
340
  return route.redirects.map(
@@ -283,27 +352,20 @@ function renderServerHeaders(route) {
283
352
  return lines.length > 0 ? `
284
353
  ${lines.join("\n")}` : "";
285
354
  }
286
- function renderProxyOptions(proxy) {
287
- const p = sanitizeProxySettings(proxy);
288
- if (!p) return "";
289
- const lines = [];
290
- if (p.clientMaxBodySize) lines.push(` client_max_body_size ${p.clientMaxBodySize};`);
291
- if (p.proxyReadTimeout) lines.push(` proxy_read_timeout ${p.proxyReadTimeout};`);
292
- if (p.proxySendTimeout) lines.push(` proxy_send_timeout ${p.proxySendTimeout};`);
293
- if (p.clientBodyTimeout) lines.push(` client_body_timeout ${p.clientBodyTimeout};`);
294
- if (p.proxyBuffering !== void 0) {
295
- lines.push(` proxy_buffering ${p.proxyBuffering ? "on" : "off"};`);
296
- }
297
- if (p.gzip !== void 0) {
298
- lines.push(` gzip ${p.gzip ? "on" : "off"};`);
299
- if (p.gzip) {
300
- lines.push(` gzip_types ${PROXY_GZIP_TYPES};`);
301
- lines.push(` gzip_min_length 1024;`);
302
- }
303
- }
355
+ function renderProxyDirectives(proxy, opts) {
356
+ const lines = resolveProxyDirectives(proxy, {
357
+ scope: opts.tlsOnly ? "tls" : "shared",
358
+ ...opts.nginxVersion !== void 0 ? { nginxVersion: opts.nginxVersion } : {}
359
+ }).map((d) => ` ${d.directive} ${d.text};`);
304
360
  return lines.length > 0 ? `
305
361
  ${lines.join("\n")}` : "";
306
362
  }
363
+ function renderProxyOptions(proxy, nginxVersion) {
364
+ return renderProxyDirectives(proxy, { tlsOnly: false, nginxVersion });
365
+ }
366
+ function renderProxyTlsOptions(proxy, nginxVersion) {
367
+ return renderProxyDirectives(proxy, { tlsOnly: true, nginxVersion });
368
+ }
307
369
  function renderGeoEntries(whitelist) {
308
370
  const entries = [
309
371
  " default 0;",
@@ -318,6 +380,46 @@ function renderGeoEntries(whitelist) {
318
380
  return entries;
319
381
  }
320
382
  var DEFAULT_CERT_DIR = "/etc/letsencrypt/live";
383
+ var LETSENCRYPT_PRODUCTION_DIRECTORY = "https://acme-v02.api.letsencrypt.org/directory";
384
+ function assertValidAcmeOptions(opts) {
385
+ if (!!opts.acmeEabKid !== !!opts.acmeEabHmacKey) {
386
+ throw new Error("ACME EAB requires both a key identifier and an HMAC key");
387
+ }
388
+ if (opts.acmeDirectoryUrl) {
389
+ let directory;
390
+ try {
391
+ directory = new URL(opts.acmeDirectoryUrl);
392
+ } catch {
393
+ throw new Error("ACME directory must be a valid http(s) URL");
394
+ }
395
+ if (directory.protocol !== "https:" && directory.protocol !== "http:") {
396
+ throw new Error("ACME directory must use http or https");
397
+ }
398
+ }
399
+ if (opts.acmeEabKid && (!/^[\x20-\x7E]+$/.test(opts.acmeEabKid) || opts.acmeEabKid.length > 512)) {
400
+ throw new Error("ACME EAB key identifier must be printable ASCII (maximum 512 characters)");
401
+ }
402
+ if (opts.acmeEabHmacKey && !/^[A-Za-z0-9_-]+={0,2}$/.test(opts.acmeEabHmacKey)) {
403
+ throw new Error("ACME EAB HMAC key must be base64url encoded");
404
+ }
405
+ if (opts.acmeCaBundle && !opts.acmeCaBundle.startsWith("/")) {
406
+ throw new Error("ACME CA bundle must be an absolute path in the Certbot environment");
407
+ }
408
+ }
409
+ function acmeKeyArgs(keyType) {
410
+ switch (keyType) {
411
+ case "ec256":
412
+ return ["--key-type", "ecdsa", "--elliptic-curve", "secp256r1"];
413
+ case "ec384":
414
+ return ["--key-type", "ecdsa", "--elliptic-curve", "secp384r1"];
415
+ case "rsa2048":
416
+ return ["--key-type", "rsa", "--rsa-key-size", "2048"];
417
+ case "rsa4096":
418
+ return ["--key-type", "rsa", "--rsa-key-size", "4096"];
419
+ default:
420
+ return [];
421
+ }
422
+ }
321
423
  var DOMAIN_RE = /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/;
322
424
  function assertValidDomain(domain) {
323
425
  if (!DOMAIN_RE.test(domain) || domain.length > 253) {
@@ -335,6 +437,12 @@ function assertValidUpstream(targetUrl) {
335
437
  throw new Error(`Invalid proxy target (must be http/https URL): ${targetUrl}`);
336
438
  }
337
439
  }
440
+ var CHALLENGE_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._~-]{7,127}$/;
441
+ function assertValidChallengeToken(token) {
442
+ if (!CHALLENGE_TOKEN_RE.test(token) || token.includes("..")) {
443
+ throw new Error(`Invalid edge-target challenge token: ${token}`);
444
+ }
445
+ }
338
446
  var MANAGED_STATIC_BASE = "/opt/openship";
339
447
  function assertValidStaticRoot(root, opts) {
340
448
  assertNoNginxInjection(root, "static root");
@@ -351,46 +459,83 @@ function assertValidStaticRoot(root, opts) {
351
459
  }
352
460
  var execFileAsync = promisify(cpExecFile);
353
461
  function summarizeCertbotFailure(output, domain) {
462
+ const edgeDown = edgeDownExplanation(output);
463
+ if (edgeDown) return edgeDown;
464
+ const structured = certbotDiagnosis(output, domain);
465
+ if (structured) return structured;
466
+ const lines = (output || "").split("\n").map((l) => l.trim()).filter(Boolean);
467
+ return lines.slice(-3).join(" \xB7 ") || `certbot failed to issue a certificate for ${domain}`;
468
+ }
469
+ function certbotDiagnosis(output, domain) {
354
470
  const text = output || "";
355
471
  const pick = (re) => text.match(re)?.[0]?.replace(/\s+/g, " ").trim();
356
472
  const detail = pick(/Detail:\s*[^\n]+/i);
357
473
  const hint = pick(/Hint:\s*[^\n]+/i);
474
+ const challengeUrl = pick(/https?:\/\/\S*\.well-known\/acme-challenge\/\S*/i);
475
+ const redirectedToHttps = /^https:/i.test(challengeUrl ?? "");
358
476
  let diagnosis;
359
477
  if (/timeout during connect|connection refused|failed to connect|Timeout/i.test(text)) {
360
478
  diagnosis = `Port 80 for ${domain} isn't reachable from the internet \u2014 a firewall / cloud security group is blocking it, the domain doesn't point at this server, or another proxy is still bound to :80.`;
361
479
  } else if (/NXDOMAIN|no\s+(A|AAAA)\s+record|DNS problem|could not be resolved|no records? found/i.test(text)) {
362
480
  diagnosis = `${domain} doesn't resolve to this server yet \u2014 the DNS A record is missing or hasn't propagated. Wait for propagation, then retry from the Domains tab.`;
481
+ } else if (/\b52[56]\b|ssl handshake failed/i.test(text)) {
482
+ diagnosis = `Cloudflare's proxy is answering the certificate challenge for ${domain} instead of forwarding it to this server: it redirects the request to HTTPS, then rejects the origin's TLS. Fix it one of three ways \u2014 set SSL/TLS \u2192 Overview to "Full" (not "Full (strict)") until the first certificate is issued, or turn the orange cloud off for this record until it is, or upload a Cloudflare Origin CA certificate from the Domains tab and skip Let's Encrypt entirely.`;
363
483
  } else if (/404|invalid response|unauthorized|"?status"?:?\s*40\d/i.test(text)) {
364
- diagnosis = `Port 80 answered but not with our ACME challenge \u2014 another web server is still serving :80, so the takeover didn't complete. Re-run and choose take-over / migrate.`;
484
+ diagnosis = redirectedToHttps ? `The challenge for ${domain} was redirected from HTTP to HTTPS and then answered with a 404 \u2014 so whatever served it isn't routing that hostname. If ${domain} is behind Cloudflare, its "Always Use HTTPS" is doing the redirect: set SSL/TLS \u2192 Overview to "Full" (not "Full (strict)") until the first certificate is issued, or turn the orange cloud off for this record. Otherwise check the A record points at this server and the domain is registered here. Nothing is occupying :80, so take-over / migrate will not help.` : `Port 80 answered but not with our ACME challenge \u2014 another web server is still serving :80, so the takeover didn't complete. Re-run and choose take-over / migrate.`;
365
485
  } else if (/too many certificates|rateLimited|rate limit/i.test(text)) {
366
486
  diagnosis = `Let's Encrypt rate limit reached for ${domain} \u2014 wait before retrying.`;
367
487
  }
368
488
  const parts = [diagnosis, detail, hint].filter(Boolean);
369
- if (parts.length > 0) return parts.join(" \u2014 ");
370
- const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
371
- return lines.slice(-3).join(" \xB7 ") || `certbot failed to issue a certificate for ${domain}`;
489
+ return parts.length > 0 ? parts.join(" \u2014 ") : void 0;
372
490
  }
373
491
  var NginxProvider = class {
374
492
  sitesDir;
375
493
  acmeEmail;
494
+ acmeDirectoryUrl;
495
+ acmeEabKid;
496
+ acmeEabHmacKey;
497
+ acmeKeyType;
498
+ acmeCaBundle;
499
+ acmeTosAgreed;
376
500
  certDir;
377
501
  executor;
378
502
  reloadCommand;
379
503
  pinPaths;
504
+ containerEdge;
505
+ challengeDir;
506
+ /**
507
+ * nginx version of the edge, when `detectOpenRestyPaths` could read it. Gates the
508
+ * one directive whose syntax is version-dependent (`http2`); undefined = unknown,
509
+ * which `proxyDirectiveAllowed` treats as allow.
510
+ */
511
+ nginxVersion;
380
512
  constructor(opts) {
513
+ assertValidAcmeOptions(opts);
381
514
  this.sitesDir = opts.paths.sitesDir;
515
+ this.nginxVersion = opts.paths.nginxVersion;
382
516
  this.acmeEmail = opts.acmeEmail;
517
+ this.acmeDirectoryUrl = opts.acmeDirectoryUrl;
518
+ this.acmeEabKid = opts.acmeEabKid;
519
+ this.acmeEabHmacKey = opts.acmeEabHmacKey;
520
+ this.acmeKeyType = opts.acmeKeyType;
521
+ this.acmeCaBundle = opts.acmeCaBundle;
522
+ this.acmeTosAgreed = opts.acmeTosAgreed ?? true;
383
523
  this.certDir = opts.certDir ?? DEFAULT_CERT_DIR;
384
524
  this.executor = opts.executor ?? null;
385
- this.reloadCommand = buildReloadCommand(opts.paths);
525
+ this.containerEdge = opts.containerEdge ?? false;
526
+ this.reloadCommand = buildReloadCommand(opts.paths, { containerEdge: this.containerEdge });
386
527
  this.pinPaths = opts.pinPaths ?? false;
528
+ this.challengeDir = opts.challengeDir ?? EDGE_CHALLENGE_DIR;
387
529
  }
388
530
  // ── File operation helpers (dual-path: local or remote) ──────────────
389
- async _writeFile(path, content) {
531
+ async _writeFile(path, content, mode) {
390
532
  const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
391
533
  if (this.executor) {
392
534
  try {
393
535
  await this.executor.writeFile(tmpPath, content);
536
+ if (mode !== void 0) {
537
+ await this.executor.exec(`chmod ${sq(mode.toString(8))} ${sq(tmpPath)}`);
538
+ }
394
539
  if (this.executor.rename) {
395
540
  await this.executor.rename(tmpPath, path);
396
541
  } else {
@@ -403,7 +548,7 @@ var NginxProvider = class {
403
548
  } else {
404
549
  await fsMkdir(dirname(path), { recursive: true });
405
550
  try {
406
- await fsWriteFile(tmpPath, content, "utf-8");
551
+ await fsWriteFile(tmpPath, content, { encoding: "utf-8", mode });
407
552
  await fsRename(tmpPath, path);
408
553
  } catch (err) {
409
554
  await fsRm(tmpPath).catch(() => void 0);
@@ -433,7 +578,7 @@ var NginxProvider = class {
433
578
  await this.executor.rm(path);
434
579
  } else {
435
580
  try {
436
- await fsRm(path);
581
+ await fsRm(path, { recursive: true, force: true });
437
582
  } catch {
438
583
  }
439
584
  }
@@ -476,6 +621,97 @@ var NginxProvider = class {
476
621
  await this._rm(staging).catch(() => void 0);
477
622
  }
478
623
  }
624
+ /**
625
+ * Where a domain's temporary self-signed cert lives — a SIBLING of certbot's
626
+ * `live/`, deliberately not inside it. See {@link ensureBootstrapCert}.
627
+ */
628
+ bootstrapCertDir(domain) {
629
+ return join(dirname(this.certDir), BOOTSTRAP_CERT_SEGMENT, domain);
630
+ }
631
+ /**
632
+ * Ensure a self-signed placeholder cert exists for `domain`, so a route whose TLS
633
+ * we terminate can ALWAYS complete a TLS handshake — including before certbot has
634
+ * issued anything.
635
+ *
636
+ * Why a missing :443 block is a hard failure, not a soft one: an unmatched SNI
637
+ * falls through to the edge's :443 catch-all, which answers with the "service not
638
+ * found" page (#431) under a placeholder certificate that belongs to no domain.
639
+ * For a domain we DO route that is a broken site: the visitor is told nothing is
640
+ * deployed here, and behind Cloudflare "Full" — which accepts any origin cert —
641
+ * they get that page rather than the app. Issuance deadlocks with it: Cloudflare
642
+ * answers the HTTP-01 challenge with a 301 to https, the catch-all deliberately
643
+ * carries no ACME location on :443, so the challenge 404s and the vhost stays
644
+ * HTTP-only — forever (#308). A per-domain placeholder breaks the loop: the
645
+ * domain gets its OWN name-bound :443 block, the challenge completes, and the
646
+ * real cert takes over.
647
+ *
648
+ * So the :443 catch-all having gained a certificate of its own does NOT make this
649
+ * redundant — it only changed the symptom from a raw TLS error (#431's old error
650
+ * 525) into a wrong page. The catch-all's cert is deliberately valid for nothing;
651
+ * serving it for a domain we route is the cross-serving that block exists to
652
+ * prevent.
653
+ *
654
+ * Deliberately NOT written under `certDir`: `certsExist()` / `readCertInfo()`
655
+ * read that path, so a self-signed cert there would short-circuit provisionCert
656
+ * and report an untrusted cert as `active`, with the renewal scheduler and the
657
+ * verify-pending sweep both satisfied. Keeping it in a sibling dir means cert
658
+ * STATUS is untouched — the domain still reads provisioning/error and keeps
659
+ * retrying until a real cert lands.
660
+ *
661
+ * Returns null when nothing could be produced (no `openssl` on the executor, exec
662
+ * failure); the caller falls back to an HTTP-only vhost, exactly as before.
663
+ * Never throws — a placeholder must not be able to fail a deploy.
664
+ */
665
+ async ensureBootstrapCert(domain) {
666
+ const dir = this.bootstrapCertDir(domain);
667
+ const certPath = join(dir, "fullchain.pem");
668
+ const keyPath = join(dir, "privkey.pem");
669
+ try {
670
+ if (await this._exists(certPath) && await this._exists(keyPath)) {
671
+ return { certPath, keyPath };
672
+ }
673
+ } catch {
674
+ return null;
675
+ }
676
+ const staging = `${dir}.staging-${process.pid}-${randomBytes(4).toString("hex")}`;
677
+ try {
678
+ await this._mkdir(staging);
679
+ await this._exec("openssl", [
680
+ "req",
681
+ "-x509",
682
+ "-newkey",
683
+ "rsa:2048",
684
+ "-nodes",
685
+ "-keyout",
686
+ join(staging, "privkey.pem"),
687
+ "-out",
688
+ join(staging, "fullchain.pem"),
689
+ "-days",
690
+ "3650",
691
+ "-subj",
692
+ `/CN=${domain}`,
693
+ "-addext",
694
+ `subjectAltName=DNS:${domain}`
695
+ ]);
696
+ await this._mkdir(dir);
697
+ if (this.executor) {
698
+ await this.executor.exec(
699
+ `mv -f ${sq(join(staging, "fullchain.pem"))} ${sq(join(staging, "privkey.pem"))} ${sq(dir)}/`
700
+ );
701
+ } else {
702
+ await fsRename(join(staging, "fullchain.pem"), certPath);
703
+ await fsRename(join(staging, "privkey.pem"), keyPath);
704
+ }
705
+ return { certPath, keyPath };
706
+ } catch (err) {
707
+ console.warn(
708
+ `[SSL] could not create a temporary certificate for ${domain} \u2014 serving HTTP only until a real certificate is issued (is openssl installed on the edge?): ${safeErrorMessage(err)}`
709
+ );
710
+ return null;
711
+ } finally {
712
+ await this._rm(staging).catch(() => void 0);
713
+ }
714
+ }
479
715
  async _exec(command, args = []) {
480
716
  if (this.executor) {
481
717
  const full = args.length ? `${command} ${args.map(sq).join(" ")}` : command;
@@ -520,19 +756,65 @@ var NginxProvider = class {
520
756
  * still sees the real cause.
521
757
  */
522
758
  async _execCertbot(args, onLog) {
759
+ const command = this.acmeCaBundle ? "env" : "certbot";
760
+ const commandArgs = this.acmeCaBundle ? [`REQUESTS_CA_BUNDLE=${this.acmeCaBundle}`, "certbot", ...args] : args;
523
761
  if (!onLog || !this.executor) {
524
- const out = await this._exec("certbot", args);
525
- if (out) onLog?.(out);
526
- return out;
762
+ let out;
763
+ try {
764
+ out = await this._exec(command, commandArgs);
765
+ } catch (err) {
766
+ throw new Error(this.redactAcmeSecrets(safeErrorMessage(err)));
767
+ }
768
+ const redacted = this.redactAcmeSecrets(out);
769
+ if (redacted) onLog?.(redacted);
770
+ return redacted;
527
771
  }
528
- const full = `certbot ${args.map(sq).join(" ")}`;
772
+ const full = `${command} ${commandArgs.map(sq).join(" ")}`;
529
773
  let output = "";
530
774
  const { code } = await this.executor.streamExec(full, (log) => {
531
775
  output += log.message;
532
- onLog(log.message);
776
+ onLog(this.redactAcmeSecrets(log.message));
533
777
  });
534
- if (code !== 0) throw new Error(output.trim() || `certbot exited ${code}`);
535
- return output;
778
+ if (code !== 0) {
779
+ throw new Error(this.redactAcmeSecrets(output).trim() || `certbot exited ${code}`);
780
+ }
781
+ return this.redactAcmeSecrets(output);
782
+ }
783
+ redactAcmeSecrets(value) {
784
+ return this.acmeEabHmacKey ? value.split(this.acmeEabHmacKey).join("[REDACTED]") : value;
785
+ }
786
+ /**
787
+ * Keep EAB HMAC material out of argv/process listings and remove it after use.
788
+ *
789
+ * Same layout contract as runtime/git-ssh-material.ts (the repo's other
790
+ * ephemeral-secret-on-disk site): the material lives in its OWN 0700 directory
791
+ * with the file at 0600, the bytes never travel through a shell command line,
792
+ * and cleanup removes the directory as a unit. The 0700 parent goes first, so
793
+ * even the temp file _writeFile stages is born unreadable to other users.
794
+ */
795
+ async createEphemeralEabConfig() {
796
+ if (!this.acmeEabKid || !this.acmeEabHmacKey) return null;
797
+ const dir = join(dirname(this.certDir), `.openship-eab-${randomBytes(12).toString("hex")}`);
798
+ const path = join(dir, "eab.ini");
799
+ if (this.executor) {
800
+ await this.executor.mkdir(dir);
801
+ await this._exec("chmod", ["700", dir]);
802
+ } else {
803
+ await fsMkdir(dir, { recursive: true, mode: 448 });
804
+ }
805
+ try {
806
+ await this._writeFile(
807
+ path,
808
+ `eab-kid = ${this.acmeEabKid}
809
+ eab-hmac-key = ${this.acmeEabHmacKey}
810
+ `,
811
+ 384
812
+ );
813
+ } catch (err) {
814
+ await this._rm(dir).catch(() => void 0);
815
+ throw err;
816
+ }
817
+ return path;
536
818
  }
537
819
  async _captureFile(path) {
538
820
  if (!await this._exists(path)) {
@@ -562,34 +844,64 @@ var NginxProvider = class {
562
844
  async registerRoute(route) {
563
845
  assertValidDomain(route.domain);
564
846
  await this._mkdir(this.sitesDir);
565
- const slug = this.domainSlug(route.domain);
847
+ const slug = await this.resolveSlug(route.domain);
566
848
  const configPath = join(this.sitesDir, `${slug}.conf`);
567
849
  if ("staticRoot" in route && route.staticRoot) {
568
850
  assertValidStaticRoot(route.staticRoot, { adopted: route.staticRootAdopted });
569
851
  } else {
570
852
  assertValidUpstream(route.targetUrl);
571
853
  }
572
- const locationBody = "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
854
+ const hostRedirect = renderHostRedirect(route);
855
+ const locationBody = hostRedirect ? hostRedirect : "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
573
856
  index index.html;
574
857
  try_files $uri $uri/ /index.html;` : `proxy_pass ${route.targetUrl};
575
858
  ${PROXY_HEADERS}`;
576
- const extraLocations = `${renderRedirects(route)}${renderProxyLocations(route)}`;
577
- const serverHeaders = renderServerHeaders(route);
578
- const proxyOpts = renderProxyOptions(route.proxy);
579
- const webhookLocation = route.webhookProxy ? `
859
+ const extraLocations = hostRedirect ? "" : `${renderRedirects(route)}${renderProxyLocations(route)}`;
860
+ const serverHeaders = hostRedirect ? "" : renderServerHeaders(route);
861
+ const proxyOpts = renderProxyOptions(route.proxy, this.nginxVersion);
862
+ const proxyTlsOpts = renderProxyTlsOptions(route.proxy, this.nginxVersion);
863
+ const webhookLocation = route.webhookProxy && !hostRedirect ? `
580
864
  location /_openship/hooks/ {
581
865
  proxy_pass ${route.webhookProxy};
582
866
  proxy_set_header Host $host;
583
867
  proxy_set_header X-Real-IP $remote_addr;
584
868
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
585
- proxy_set_header X-Forwarded-Proto $scheme;
869
+ proxy_set_header X-Forwarded-Proto $openship_fwd_proto;
586
870
  }
587
871
  ` : "";
588
872
  const edgeLua = luaSourceAvailable();
589
- const luaLogOnly = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};` : "";
590
873
  const luaProxy = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};
591
874
  access_by_lua_file ${RULES_GUARD_PATH};` : "";
875
+ const serveLocation = ` location / {
876
+ ${locationBody}
877
+ }`;
878
+ const redirectOrServeLocation = hostRedirect ? serveLocation : ` location / {
879
+ if ($openship_redirect_https) {
880
+ return 301 https://$server_name$request_uri;
881
+ }
882
+
883
+ ${locationBody}
884
+ }`;
885
+ const sharedBody = `${serverHeaders}${proxyOpts}
886
+ ${webhookLocation}${extraLocations}`;
887
+ const httpOnlyBlock = `# Auto-generated by Openship - do not edit manually
888
+ server {
889
+ listen 80;
890
+ server_name ${route.domain};
891
+
892
+ ${FORWARD_VARS}
893
+
894
+ ${luaProxy}
895
+
896
+ ${ACME_CHALLENGE_LOCATION}
897
+
898
+ ${EDGE_CHALLENGE_LOCATION}
899
+ ${sharedBody}
900
+ ${serveLocation}
901
+ }
902
+ `;
592
903
  let serverBlock;
904
+ let bootstrapServed = false;
593
905
  if (route.tls && await this.certsExist(route.domain)) {
594
906
  const certPath = join(this.certDir, route.domain, "fullchain.pem");
595
907
  const keyPath = join(this.certDir, route.domain, "privkey.pem");
@@ -598,45 +910,69 @@ server {
598
910
  listen 80;
599
911
  server_name ${route.domain};
600
912
 
601
- ${luaLogOnly}
913
+ ${FORWARD_VARS}
914
+
915
+ ${luaProxy}
602
916
 
603
917
  ${ACME_CHALLENGE_LOCATION}
604
918
 
605
- location / {
606
- return 301 https://$server_name$request_uri;
607
- }
919
+ ${EDGE_CHALLENGE_LOCATION}
920
+ ${sharedBody}
921
+ ${redirectOrServeLocation}
608
922
  }
609
923
 
610
924
  server {
611
925
  listen 443 ssl;
612
926
  server_name ${route.domain};
613
927
 
928
+ ${FORWARD_VARS}
929
+
614
930
  ${luaProxy}
615
931
 
616
932
  ssl_certificate ${certPath};
617
- ssl_certificate_key ${keyPath};
618
- ${serverHeaders}${proxyOpts}
619
- ${webhookLocation}${extraLocations}
620
- location / {
621
- ${locationBody}
622
- }
933
+ ssl_certificate_key ${keyPath};${proxyTlsOpts}
934
+ ${sharedBody}
935
+ ${serveLocation}
623
936
  }
624
937
  `;
625
- } else {
626
- serverBlock = `# Auto-generated by Openship - do not edit manually
938
+ } else if (route.tls && route.terminatesTlsLocally) {
939
+ const bootstrap = await this.ensureBootstrapCert(route.domain);
940
+ bootstrapServed = !!bootstrap;
941
+ serverBlock = bootstrap ? `# Auto-generated by Openship - do not edit manually
942
+ # openship-bootstrap-tls: ${route.domain} is serving a TEMPORARY self-signed
943
+ # certificate because its real certificate has not been issued yet. Openship
944
+ # replaces this file automatically once it lands. Do not adopt this material.
627
945
  server {
628
946
  listen 80;
629
947
  server_name ${route.domain};
630
948
 
949
+ ${FORWARD_VARS}
950
+
631
951
  ${luaProxy}
632
- ${serverHeaders}${proxyOpts}
952
+
633
953
  ${ACME_CHALLENGE_LOCATION}
634
- ${webhookLocation}${extraLocations}
635
- location / {
636
- ${locationBody}
637
- }
954
+
955
+ ${EDGE_CHALLENGE_LOCATION}
956
+ ${sharedBody}
957
+ ${serveLocation}
638
958
  }
639
- `;
959
+
960
+ server {
961
+ listen 443 ssl;
962
+ server_name ${route.domain};
963
+
964
+ ${FORWARD_VARS}
965
+
966
+ ${luaProxy}
967
+
968
+ ssl_certificate ${bootstrap.certPath};
969
+ ssl_certificate_key ${bootstrap.keyPath};${proxyTlsOpts}
970
+ ${sharedBody}
971
+ ${serveLocation}
972
+ }
973
+ ` : httpOnlyBlock;
974
+ } else {
975
+ serverBlock = httpOnlyBlock;
640
976
  }
641
977
  const snapshot = await this._captureFile(configPath);
642
978
  await this._writeFile(configPath, serverBlock);
@@ -647,11 +983,32 @@ ${webhookLocation}${extraLocations}
647
983
  await this.reload().catch(() => void 0);
648
984
  throw err;
649
985
  }
986
+ if (!bootstrapServed) {
987
+ await this._rm(this.bootstrapCertDir(route.domain)).catch(() => void 0);
988
+ }
650
989
  await this._writeFile(this.routeStatePath(slug), JSON.stringify(route)).catch(() => void 0);
651
990
  }
652
991
  routeStatePath(slug) {
653
992
  return join(this.sitesDir, `${slug}.route.json`);
654
993
  }
994
+ /**
995
+ * Recover the proxy tunables from a vhost we already wrote, for the legacy
996
+ * no-sidecar re-register below. Rewriting the conf without them would silently
997
+ * revert a site to nginx's 1 MB / 60 s defaults as a side effect of issuing a
998
+ * certificate — the one moment an operator is least likely to look for it.
999
+ *
1000
+ * Same table as the renderer, read backwards, so the two cannot drift.
1001
+ */
1002
+ scrapeProxySettings(conf) {
1003
+ const found = {};
1004
+ for (const spec of PROXY_DIRECTIVES) {
1005
+ const m = conf.match(new RegExp(`^\\s*${spec.directive}\\s+([^;\\n]+);`, "m"));
1006
+ if (!m?.[1]) continue;
1007
+ const parsed = parseProxyValue(spec, m[1].trim());
1008
+ if (parsed !== void 0) found[spec.key] = parsed;
1009
+ }
1010
+ return sanitizeProxySettings(found);
1011
+ }
655
1012
  /**
656
1013
  * Remove a route by deleting its conf + route-state files, then reload.
657
1014
  *
@@ -664,7 +1021,7 @@ ${webhookLocation}${extraLocations}
664
1021
  */
665
1022
  async removeRoute(domain) {
666
1023
  assertValidDomain(domain);
667
- const slug = this.domainSlug(domain);
1024
+ const slug = await this.resolveSlug(domain);
668
1025
  const configPath = join(this.sitesDir, `${slug}.conf`);
669
1026
  const statePath = this.routeStatePath(slug);
670
1027
  const confSnapshot = await this._captureFile(configPath);
@@ -679,6 +1036,77 @@ ${webhookLocation}${extraLocations}
679
1036
  await this.reload().catch(() => void 0);
680
1037
  throw err;
681
1038
  }
1039
+ await this._rm(this.bootstrapCertDir(domain)).catch(() => void 0);
1040
+ }
1041
+ // ── Edge-target challenge ────────────────────────────────────────────
1042
+ /** Filename for a host's challenge vhost. The leading `_` is a namespace
1043
+ * `resolveSlug` can never produce (`domainSlug` emits only `[A-Za-z0-9-]`), so no
1044
+ * `registerRoute`/`removeRoute` for any domain can overwrite or delete it —
1045
+ * matching the existing `_default.conf` / `_management.conf` convention. */
1046
+ challengeVhostPath(host) {
1047
+ return join(this.sitesDir, `_oblien-challenge-${this.domainSlug(host)}.conf`);
1048
+ }
1049
+ /**
1050
+ * Make `host` able to answer edge-target challenges, and serve any tokens given.
1051
+ *
1052
+ * Called TWO ways, and the split is the point:
1053
+ * - `{ host }` alone, from edge-ensure (install + every deploy) — writes only the
1054
+ * vhost, so the box can answer before any verification exists.
1055
+ * - `{ host, tokens }`, from the verify flow — writes the token files too.
1056
+ *
1057
+ * That the first form is possible at all is what makes this ready from first
1058
+ * install rather than only once a challenge is in flight: the location serves a
1059
+ * DIRECTORY, so it is valid with nothing in it (a missing token is a 404, which is
1060
+ * the right answer). And because the vhost lands in the bind-mounted sites-enabled
1061
+ * written by our own code, it works on ANY edge image — including one predating
1062
+ * this feature, and including a rollback. The baked image's catch-all cannot make
1063
+ * that claim, which is why it is not the mechanism.
1064
+ *
1065
+ * Idempotent: skips the vhost write AND the reload when what is on disk already
1066
+ * matches, so re-asserting on every deploy costs a couple of stats.
1067
+ *
1068
+ * `tokens` is a list because a re-issued verification resets the token while an
1069
+ * older record may still be probed, and because two orgs can target one box. All
1070
+ * are served; none is ever removed — Oblien re-probes the SAME token near its
1071
+ * 90-day expiry, so dropping one silently kills that route ~83 days later.
1072
+ */
1073
+ async serveEdgeChallenge(input) {
1074
+ const host = input.host.trim().toLowerCase().replace(/\.$/, "");
1075
+ assertValidDomain(host);
1076
+ const tokens = input.tokens ?? [];
1077
+ for (const token of tokens) assertValidChallengeToken(token);
1078
+ for (const token of tokens) {
1079
+ await this._writeFile(join(this.challengeDir, token), token);
1080
+ }
1081
+ const base = this.domainSlug(host);
1082
+ for (const stem of [base, `${base}-${this.slugSuffix(host)}`]) {
1083
+ const path2 = join(this.sitesDir, `${stem}.conf`);
1084
+ const conf = await this._readFile(path2).catch(() => "");
1085
+ if (!conf || !this.serverNamesIn(conf).includes(host)) continue;
1086
+ if (conf.includes(`location ${EDGE_CHALLENGE_URL_PREFIX}`)) {
1087
+ return { served: true, via: "existing-vhost" };
1088
+ }
1089
+ return {
1090
+ served: false,
1091
+ via: null,
1092
+ claimedBy: path2,
1093
+ reason: `${host} is already served by ${path2}, which predates edge-target challenge support. Re-register that domain (a deploy or a Domains-tab save) to refresh its vhost, then retry.`
1094
+ };
1095
+ }
1096
+ const vhost = edgeChallengeVhostConf(host);
1097
+ const path = this.challengeVhostPath(host);
1098
+ const existing = await this._readFile(path).catch(() => "");
1099
+ if (existing === vhost) return { served: true, via: "challenge-vhost" };
1100
+ const snapshot = await this._captureFile(path);
1101
+ await this._writeFile(path, vhost);
1102
+ try {
1103
+ await this.reload();
1104
+ } catch (err) {
1105
+ await this._restoreFile(path, snapshot);
1106
+ await this.reload().catch(() => void 0);
1107
+ throw err;
1108
+ }
1109
+ return { served: true, via: "challenge-vhost" };
682
1110
  }
683
1111
  // ── SSL ──────────────────────────────────────────────────────────────
684
1112
  /**
@@ -705,8 +1133,10 @@ ${webhookLocation}${extraLocations}
705
1133
  const validEmail = !!this.acmeEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.acmeEmail);
706
1134
  const emailArgs = validEmail ? ["--email", this.acmeEmail] : ["--register-unsafely-without-email"];
707
1135
  let certonlyOut = "";
1136
+ const eabConfig = await this.createEphemeralEabConfig();
708
1137
  try {
709
1138
  certonlyOut = await this._execCertbot([
1139
+ ...eabConfig ? ["--config", eabConfig] : [],
710
1140
  "certonly",
711
1141
  "--standalone",
712
1142
  "--http-01-port",
@@ -715,17 +1145,21 @@ ${webhookLocation}${extraLocations}
715
1145
  domain,
716
1146
  "-d",
717
1147
  domain,
1148
+ ...this.acmeDirectoryUrl ? ["--server", this.acmeDirectoryUrl] : [],
1149
+ ...acmeKeyArgs(this.acmeKeyType),
718
1150
  ...emailArgs,
719
- "--agree-tos",
1151
+ ...this.acmeTosAgreed ? ["--agree-tos"] : [],
720
1152
  "--non-interactive",
721
1153
  // Forced reissue: certbot would otherwise print "not due for renewal"
722
1154
  // (exit 0) when a lineage exists, leaving the stale cert in place.
723
1155
  ...opts?.force ? ["--force-renewal"] : []
724
1156
  ], opts?.onLog);
725
1157
  } catch (err) {
726
- throw new Error(summarizeCertbotFailure(safeErrorMessage(err), domain));
1158
+ throw new Error(summarizeCertbotFailure(this.redactAcmeSecrets(safeErrorMessage(err)), domain));
1159
+ } finally {
1160
+ if (eabConfig) await this._rm(dirname(eabConfig)).catch(() => void 0);
727
1161
  }
728
- const slug = this.domainSlug(domain);
1162
+ const slug = await this.resolveSlug(domain);
729
1163
  const configPath = join(this.sitesDir, `${slug}.conf`);
730
1164
  try {
731
1165
  const state = await this._readFile(this.routeStatePath(slug));
@@ -736,9 +1170,15 @@ ${webhookLocation}${extraLocations}
736
1170
  }
737
1171
  try {
738
1172
  const existing = await this._readFile(configPath);
1173
+ const scraped = this.scrapeProxySettings(existing);
739
1174
  const targetMatch = existing.match(/proxy_pass\s+([^;]+);/);
740
1175
  if (targetMatch) {
741
- await this.registerRoute({ domain, targetUrl: targetMatch[1], tls: true });
1176
+ await this.registerRoute({
1177
+ domain,
1178
+ targetUrl: targetMatch[1],
1179
+ tls: true,
1180
+ ...scraped ? { proxy: scraped } : {}
1181
+ });
742
1182
  return this.ensureIssued(domain, certonlyOut);
743
1183
  }
744
1184
  const rootMatch = existing.match(/root\s+([^;]+);/);
@@ -747,7 +1187,8 @@ ${webhookLocation}${extraLocations}
747
1187
  domain,
748
1188
  staticRoot: rootMatch[1],
749
1189
  staticRootAdopted: true,
750
- tls: true
1190
+ tls: true,
1191
+ ...scraped ? { proxy: scraped } : {}
751
1192
  });
752
1193
  return this.ensureIssued(domain, certonlyOut);
753
1194
  }
@@ -759,17 +1200,25 @@ ${webhookLocation}${extraLocations}
759
1200
  * After a certbot run that DIDN'T throw, confirm a usable cert actually landed
760
1201
  * at the expected path. certbot can exit 0 while producing nothing readable
761
1202
  * there (a lineage written elsewhere, "not due for renewal", 0 domains
762
- * authenticated) — a silent `{missing}` hides why. Surface certbot's real
763
- * output so the operator sees the actual reason instead of a generic message.
1203
+ * authenticated) — a silent `{missing}` hides why.
1204
+ *
1205
+ * The exit code plus this read of `live/<domain>` is the WHOLE verdict. certbot's
1206
+ * stdout is evidence for the operator, never the test: a zero-exit run ends with
1207
+ * the donation footer, and running the failure summarizer over it reported
1208
+ * "Donating to ISRG / Let's Encrypt: …" as the cause of a cert that had in fact
1209
+ * been issued. Only a diagnosis that matches a KNOWN failure shape is quoted.
764
1210
  */
765
1211
  async ensureIssued(domain, certbotOutput) {
766
1212
  const result = await this.readCertInfo(domain);
767
1213
  if (result.verified) return result;
1214
+ const dir = join(this.certDir, domain);
1215
+ const onDisk = result.reason === "read_error" ? `a certificate is at ${dir} but couldn't be read (permissions, or a partial write)` : result.reason === "invalid" ? `the certificate at ${dir} isn't usable for ${domain} (expired, wrong hostname, or the key doesn't match)` : `no certificate is at ${dir}`;
1216
+ const diagnosis = certbotDiagnosis(certbotOutput, domain);
768
1217
  const tail = certbotOutput.trim().slice(-1200);
769
1218
  throw new Error(
770
- summarizeCertbotFailure(certbotOutput, domain) + (tail ? `
1219
+ `certbot exited without an error but ${onDisk}.` + (diagnosis ? ` ${diagnosis}` : "") + (tail ? `
771
1220
 
772
- certbot exited without an error but no readable certificate is at ${join(this.certDir, domain)}. certbot said:
1221
+ certbot said:
773
1222
  ${tail}` : "")
774
1223
  );
775
1224
  }
@@ -784,7 +1233,16 @@ ${tail}` : "")
784
1233
  if (!await this.hasCertbotLineage(domain)) {
785
1234
  return this.provisionCert(domain, { force: true });
786
1235
  }
787
- await this._exec("certbot", ["renew", "--cert-name", domain, "--non-interactive"]);
1236
+ if (!await this.lineageServerMatches(domain)) {
1237
+ return this.provisionCert(domain, { force: true });
1238
+ }
1239
+ await this._execCertbot([
1240
+ "renew",
1241
+ "--cert-name",
1242
+ domain,
1243
+ ...acmeKeyArgs(this.acmeKeyType),
1244
+ "--non-interactive"
1245
+ ]);
788
1246
  await this.reload();
789
1247
  return this.readCertInfo(domain);
790
1248
  }
@@ -794,9 +1252,31 @@ ${tail}` : "")
794
1252
  * reads on every `renew`, so its presence is the authoritative answer — the cert
795
1253
  * FILES existing is not (they can be adopted, or copied in by hand).
796
1254
  */
1255
+ renewalConfPath(domain) {
1256
+ return join(dirname(this.certDir), "renewal", `${domain}.conf`);
1257
+ }
797
1258
  async hasCertbotLineage(domain) {
798
- const renewalPath = join(dirname(this.certDir), "renewal", `${domain}.conf`);
799
- return this._exists(renewalPath);
1259
+ return this._exists(this.renewalConfPath(domain));
1260
+ }
1261
+ /**
1262
+ * Was this lineage issued by the CURRENTLY configured ACME directory? The
1263
+ * renewal conf records the issuing `server`, and `certbot renew` only holds an
1264
+ * account for THAT server — after the operator points at a different CA, renewing
1265
+ * a pre-switch lineage would need an account registration `renew` cannot perform
1266
+ * (and EAB material it is never given). An unreadable/unparsable conf answers
1267
+ * `true` so the plain renew still runs and surfaces certbot's own error.
1268
+ */
1269
+ async lineageServerMatches(domain) {
1270
+ let conf;
1271
+ try {
1272
+ conf = await this._readFile(this.renewalConfPath(domain));
1273
+ } catch {
1274
+ return true;
1275
+ }
1276
+ const recorded = conf.match(/^\s*server\s*=\s*(\S+)\s*$/m)?.[1];
1277
+ if (!recorded) return true;
1278
+ const effective = this.acmeDirectoryUrl ?? LETSENCRYPT_PRODUCTION_DIRECTORY;
1279
+ return recorded === effective;
800
1280
  }
801
1281
  /**
802
1282
  * Install an operator-supplied or adopted certificate (no ACME). Validates the
@@ -816,7 +1296,7 @@ ${tail}` : "")
816
1296
  const { expiresAt } = candidate.cert;
817
1297
  const dir = join(this.certDir, domain);
818
1298
  await this.stageCertDir(dir, cert);
819
- const slug = this.domainSlug(domain);
1299
+ const slug = await this.resolveSlug(domain);
820
1300
  try {
821
1301
  const state = await this._readFile(this.routeStatePath(slug));
822
1302
  const saved = JSON.parse(state);
@@ -832,9 +1312,87 @@ ${tail}` : "")
832
1312
  };
833
1313
  }
834
1314
  // ── Helpers ──────────────────────────────────────────────────────────
1315
+ /**
1316
+ * Filename stem for a domain. NOT injective — see `resolveSlug`.
1317
+ *
1318
+ * Kept exactly as-is on purpose: it names every vhost file on every existing
1319
+ * install, so changing it would rename the lot and leave the old names behind
1320
+ * still serving. `resolveSlug` handles the collisions instead.
1321
+ */
835
1322
  domainSlug(domain) {
836
1323
  return domain.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-");
837
1324
  }
1325
+ /** Deterministic per-domain disambiguator for a slug collision. */
1326
+ slugSuffix(domain) {
1327
+ return createHash("sha1").update(domain).digest("hex").slice(0, 8);
1328
+ }
1329
+ /** Every `server_name` token declared in a vhost body. */
1330
+ serverNamesIn(conf) {
1331
+ const names = [];
1332
+ for (const m of conf.matchAll(/server_name\s+([^;]+);/g)) {
1333
+ for (const tok of m[1].trim().split(/\s+/)) {
1334
+ if (tok) names.push(tok.toLowerCase().replace(/\.$/, ""));
1335
+ }
1336
+ }
1337
+ return names;
1338
+ }
1339
+ /**
1340
+ * The slug THIS domain's files live under.
1341
+ *
1342
+ * `domainSlug` folds every non-alphanumeric to `-`, so a dot and a literal dash
1343
+ * collapse together and two different, separately-claimable hostnames land on
1344
+ * one filename:
1345
+ *
1346
+ * staging.app.example.com -> staging-app-example-com
1347
+ * staging-app.example.com -> staging-app-example-com
1348
+ *
1349
+ * Both are ordinary hostnames and can belong to different projects — even
1350
+ * different orgs, since one edge fronts the whole box. Whichever registered last
1351
+ * silently overwrote the other's vhost, and the loser's traffic fell through to
1352
+ * `default_server`: a silent outage of somebody else's domain. `removeRoute` on
1353
+ * either one deleted the shared file and took out both.
1354
+ *
1355
+ * Resolution, in this order, and the order is the whole correctness argument:
1356
+ *
1357
+ * 1. A disambiguated file for this domain already exists → keep using it.
1358
+ * MUST come first. If the incumbent is later removed, step 3 would hand
1359
+ * this domain the now-free base name while its real conf stayed on disk
1360
+ * under the suffixed name — two files serving one hostname, which is the
1361
+ * exact orphan class this whole change exists to remove.
1362
+ * 2. The base file exists and does NOT list this domain in its `server_name`
1363
+ * → it is someone else's. Take a suffixed name; the incumbent is untouched.
1364
+ * 3. Otherwise the base name is ours (free, or already ours).
1365
+ *
1366
+ * Containment, not equality, in step 2 — and the reason is narrower than it
1367
+ * looks. Everything WE write carries exactly one `server_name` (see the server
1368
+ * blocks above), and a takeover fans out one `registerRoute` per hostname
1369
+ * (`registerImportedSites`), so no Openship-managed conf ever lists two. The
1370
+ * only file that can is one the operator placed in `sites-enabled` themselves.
1371
+ *
1372
+ * For that file, reusing it is the lesser evil, not a free win: we rewrite it
1373
+ * with a single `server_name`, so a sibling hostname it also served stops being
1374
+ * served. The alternative is worse — a second vhost answering a name the first
1375
+ * one already claims makes OpenResty warn "conflicting server name" and silently
1376
+ * keep the FIRST, so the route we were asked to create would not take effect at
1377
+ * all. Losing the sibling is at least the outcome the caller asked for.
1378
+ */
1379
+ async resolveSlug(domain) {
1380
+ const base = this.domainSlug(domain);
1381
+ const suffixed = `${base}-${this.slugSuffix(domain)}`;
1382
+ const host = domain.toLowerCase().replace(/\.$/, "");
1383
+ if (await this._exists(join(this.sitesDir, `${suffixed}.conf`)).catch(() => false)) {
1384
+ return suffixed;
1385
+ }
1386
+ const basePath = join(this.sitesDir, `${base}.conf`);
1387
+ if (await this._exists(basePath).catch(() => false)) {
1388
+ const conf = await this._readFile(basePath).catch(() => "");
1389
+ if (conf) {
1390
+ const names = this.serverNamesIn(conf);
1391
+ if (names.length > 0 && !names.includes(host)) return suffixed;
1392
+ }
1393
+ }
1394
+ return base;
1395
+ }
838
1396
  /**
839
1397
  * Re-detect OpenResty paths and reload.
840
1398
  *
@@ -848,7 +1406,10 @@ ${tail}` : "")
848
1406
  try {
849
1407
  const freshPaths = await detectOpenRestyPaths(this.executor);
850
1408
  this.sitesDir = freshPaths.sitesDir;
851
- this.reloadCommand = buildReloadCommand(freshPaths);
1409
+ this.nginxVersion = freshPaths.nginxVersion ?? this.nginxVersion;
1410
+ this.reloadCommand = buildReloadCommand(freshPaths, {
1411
+ containerEdge: this.containerEdge
1412
+ });
852
1413
  } catch {
853
1414
  }
854
1415
  }
@@ -1059,7 +1620,10 @@ export {
1059
1620
  isSafeCertPath,
1060
1621
  readDeclaredPair,
1061
1622
  renderProxyOptions,
1623
+ renderProxyTlsOptions,
1062
1624
  renderGeoEntries,
1625
+ acmeKeyArgs,
1626
+ assertValidChallengeToken,
1063
1627
  MANAGED_STATIC_BASE,
1064
1628
  summarizeCertbotFailure,
1065
1629
  NginxProvider