openship 0.6.0 → 0.6.5

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 (63) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-FSUAM5V5.js → bare-XQIPDO4R.js} +10 -9
  3. package/dist/{chunk-PSZSKEPA.js → chunk-3ZAGC5NT.js} +64 -17
  4. package/dist/{chunk-6ODUBKUB.js → chunk-5OUWIPOB.js} +69 -7
  5. package/dist/chunk-6HOA5JT7.js +124 -0
  6. package/dist/{chunk-TWJCLD4W.js → chunk-6JELW75A.js} +4 -4
  7. package/dist/{chunk-GUQ3QCJ7.js → chunk-7334NSJL.js} +399 -369
  8. package/dist/chunk-7S4USQYF.js +1753 -0
  9. package/dist/{chunk-3LDWWY7U.js → chunk-A7EWM2KA.js} +41 -7
  10. package/dist/chunk-ELGRMZOW.js +25 -0
  11. package/dist/chunk-EZPPTWJQ.js +292 -0
  12. package/dist/{chunk-IXM5245F.js → chunk-GRTXMUHA.js} +553 -538
  13. package/dist/chunk-HPXP6LXZ.js +428 -0
  14. package/dist/{chunk-OTLSJYAC.js → chunk-LEYSL46B.js} +2135 -85
  15. package/dist/{chunk-EMXI4UCU.js → chunk-LFCSC4IU.js} +29 -10
  16. package/dist/{chunk-JIMUYEKN.js → chunk-MSZUCNPV.js} +1 -1
  17. package/dist/{chunk-OGY63DGD.js → chunk-PZRRL5PC.js} +71 -28
  18. package/dist/{chunk-VE2YOK4I.js → chunk-QLX7NBEA.js} +1218 -588
  19. package/dist/chunk-RFWJE7ZN.js +68 -0
  20. package/dist/{chunk-VDDMADOF.js → chunk-TM2IQPYU.js} +16 -2
  21. package/dist/{chunk-3FYIXVX5.js → chunk-VDESJSG6.js} +451 -106
  22. package/dist/{chunk-B3CE2OMR.js → chunk-VHMOQL5Y.js} +276 -50
  23. package/dist/{chunk-QEH4QUBX.js → chunk-VLQ3UIVO.js} +393 -53
  24. package/dist/{chunk-VZ2ZHPS2.js → chunk-WE7BXW6L.js} +3 -9
  25. package/dist/chunk-WFRXYWK2.js +444 -0
  26. package/dist/chunk-XRNRT37W.js +316 -0
  27. package/dist/{chunk-EP2TNALB.js → chunk-XVOBL7BC.js} +31 -8
  28. package/dist/cloud-3DODOWTR.js +36 -0
  29. package/dist/{detect-ALF4GXRX.js → detect-P4CIQBRH.js} +3 -3
  30. package/dist/{docker-QECQJ5P4.js → docker-3K2NEBD6.js} +15 -8
  31. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-YZZ6P7IC.js} +5 -5
  32. package/dist/{edge-import-CQWHN2Q3.js → edge-import-MCJNVTZ2.js} +26 -23
  33. package/dist/edge-preflight-ES6FH4JK.js +42 -0
  34. package/dist/{ensure-container-edge-MJVXJQG5.js → ensure-container-edge-YPITNH45.js} +9 -8
  35. package/dist/{executor-JELARVKC.js → executor-DZGX55K2.js} +12 -5
  36. package/dist/host-channel-preflight-MUXE3KZU.js +49 -0
  37. package/dist/index.js +3668 -4063
  38. package/dist/{nginx-AXGNWRKZ.js → nginx-NSAHWGJV.js} +11 -5
  39. package/dist/node-bootstrap.js +241 -0
  40. package/dist/node-entry.js +52 -1
  41. package/dist/{openresty-lua-IE4P5YQX.js → openresty-lua-DR4D3EZ6.js} +6 -2
  42. package/dist/privilege-YTVI4SKF.js +19 -0
  43. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  44. package/dist/server/index.js +21501 -13137
  45. package/dist/server/lua/geo_country.lua +4 -3
  46. package/dist/server/lua/pipe_stream.lua +52 -39
  47. package/dist/server/lua/site_logger.lua +22 -24
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/meta/_journal.json +49 -0
  56. package/dist/{setup-FA4PW4NU.js → setup-WUUZURHS.js} +10 -9
  57. package/dist/up-XEO3SR6Z.js +58 -0
  58. package/package.json +4 -2
  59. package/dist/chunk-IW2UKBVF.js +0 -144
  60. package/dist/chunk-ZCFVHA25.js +0 -89
  61. package/dist/cloud-E5M6VMCG.js +0 -31
  62. package/dist/server/lua/pipe_log.lua +0 -77
  63. /package/dist/{dist-es-JGBPABJI.js → dist-es-ZP5QDSJ3.js} +0 -0
@@ -9,20 +9,27 @@ import {
9
9
  } from "./chunk-I3DPMTSF.js";
10
10
  import {
11
11
  edgeFailureReason
12
- } from "./chunk-VZ2ZHPS2.js";
12
+ } from "./chunk-WE7BXW6L.js";
13
13
  import {
14
14
  ACME_CHALLENGE_LOCATION,
15
15
  ACME_HTTP01_PORT,
16
16
  EDGE_CHALLENGE_DIR,
17
17
  EDGE_CHALLENGE_LOCATION,
18
18
  EDGE_CHALLENGE_URL_PREFIX,
19
+ EDGE_SAME_PATH_MOUNTS,
19
20
  LUA_LOGGER_PATH,
21
+ OPENRESTY_DEFAULT_PATHS,
20
22
  RULES_GUARD_PATH,
21
23
  buildReloadCommand,
24
+ cloudEdgeRealIpConf,
22
25
  detectOpenRestyPaths,
23
26
  edgeChallengeVhostConf,
27
+ isCloudFrontedHost,
24
28
  luaSourceAvailable
25
- } from "./chunk-OGY63DGD.js";
29
+ } from "./chunk-PZRRL5PC.js";
30
+ import {
31
+ EXECUTOR_DELEGATE
32
+ } from "./chunk-WFRXYWK2.js";
26
33
  import {
27
34
  logEntry,
28
35
  sq
@@ -34,7 +41,7 @@ import {
34
41
  resolveRedirectStatus,
35
42
  safeErrorMessage,
36
43
  sanitizeProxySettings
37
- } from "./chunk-OTLSJYAC.js";
44
+ } from "./chunk-LEYSL46B.js";
38
45
 
39
46
  // ../../packages/adapters/src/infra/nginx.ts
40
47
  import {
@@ -43,6 +50,7 @@ import {
43
50
  rm as fsRm,
44
51
  mkdir as fsMkdir,
45
52
  readFile as fsReadFile,
53
+ readdir as fsReaddir,
46
54
  rename as fsRename,
47
55
  stat as fsStat
48
56
  } from "fs/promises";
@@ -168,6 +176,7 @@ ${result.output}` };
168
176
  }
169
177
  return new Proxy(inner, {
170
178
  get(target, prop) {
179
+ if (prop === EXECUTOR_DELEGATE) return void 0;
171
180
  if (Object.prototype.hasOwnProperty.call(overrides, prop)) {
172
181
  return overrides[prop];
173
182
  }
@@ -305,6 +314,7 @@ async function probeStaticOutput(executor, servedPath) {
305
314
 
306
315
  // ../../packages/adapters/src/infra/nginx.ts
307
316
  var { dirname, join } = edgePath;
317
+ var CTL = "\\x00-\\x1f\\x7f";
308
318
  var PROXY_HEADERS = `proxy_set_header Host $host;
309
319
  proxy_set_header X-Real-IP $remote_addr;
310
320
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -318,16 +328,69 @@ var FORWARD_VARS = ` set $openship_fwd_proto $scheme;
318
328
  set $openship_fwd_proto https;
319
329
  set $openship_redirect_https 0;
320
330
  }`;
321
- function renderProxyLocations(route) {
331
+ var VHOST_GENERATION = 1;
332
+ var GENERATION_MARKER = `# openship-vhost-gen: ${VHOST_GENERATION}`;
333
+ var GENERATED_HEADER = `# Auto-generated by Openship - do not edit manually
334
+ ${GENERATION_MARKER}`;
335
+ function readVhostGeneration(conf) {
336
+ const m = conf.match(/^# openship-vhost-gen: (\d+)$/m);
337
+ return m ? Number(m[1]) : null;
338
+ }
339
+ var HTTPS_UPGRADE = `if ($openship_redirect_https) {
340
+ return 301 https://$server_name$request_uri;
341
+ }
342
+
343
+ `;
344
+ var WEBHOOK_LOCATION_PREFIX = "/_openship/hooks/";
345
+ function renderProxyLocations(route, opts) {
322
346
  if (!route.proxyLocations || route.proxyLocations.length === 0) return "";
323
- return route.proxyLocations.map((loc) => {
347
+ const head = `${opts.httpsUpgrade ? HTTPS_UPGRADE : ""}${renderRedirectRules(route, " ")}`;
348
+ const claimed = /* @__PURE__ */ new Set(["/", WEBHOOK_LOCATION_PREFIX]);
349
+ const blocks = [];
350
+ for (const loc of route.proxyLocations) {
324
351
  assertValidUpstream(loc.targetUrl);
325
- return `
326
- location ${loc.pathPrefix} {
352
+ assertNotReservedLocation(loc.pathPrefix);
353
+ const headers = loc.external ? externalProxyHeaders(loc.targetUrl) : PROXY_HEADERS;
354
+ if (!loc.pattern || !loc.upstreamPath) {
355
+ assertValidLiteralPath(loc.pathPrefix, "proxy location prefix");
356
+ if (claimed.has(loc.pathPrefix)) continue;
357
+ claimed.add(loc.pathPrefix);
358
+ blocks.push(`
359
+ location ^~ ${loc.pathPrefix} {
360
+ ${head}proxy_pass ${loc.targetUrl};
361
+ ${headers}
362
+ }`);
363
+ continue;
364
+ }
365
+ assertValidRedirectPattern(loc.pattern);
366
+ assertNotReservedLocation(loc.pattern);
367
+ assertNoNginxInjection(loc.upstreamPath, "rewrite upstream path");
368
+ assertNoRuntimeVariable(loc.upstreamPath, "rewrite upstream path", true);
369
+ const match = `^${toNginxPattern(loc.pattern)}$`;
370
+ if (claimed.has(`~ ${match}`)) continue;
371
+ claimed.add(`~ ${match}`);
372
+ blocks.push(`
373
+ location ~ ${match} {
374
+ ${head}rewrite ${match} ${loc.upstreamPath} break;
327
375
  proxy_pass ${loc.targetUrl};
328
- ${PROXY_HEADERS}
329
- }`;
330
- }).join("");
376
+ ${headers}
377
+ }`);
378
+ }
379
+ return blocks.join("");
380
+ }
381
+ function assertNotReservedLocation(pathOrPattern) {
382
+ if (pathOrPattern.startsWith(WELL_KNOWN_PREFIX)) {
383
+ throw new Error(
384
+ `Refusing to generate a location under ${WELL_KNOWN_PREFIX}: ${pathOrPattern}. That namespace belongs to the edge \u2014 a location there can hijack certificate issuance.`
385
+ );
386
+ }
387
+ }
388
+ var WELL_KNOWN_PREFIX = "/.well-known/";
389
+ function externalProxyHeaders(targetUrl) {
390
+ const host = new URL(targetUrl).host;
391
+ assertNoNginxInjection(host, "external proxy host");
392
+ return `${PROXY_HEADERS.replace("Host $host;", `Host ${host};`)}
393
+ proxy_ssl_server_name on;`;
331
394
  }
332
395
  function renderHostRedirect(route) {
333
396
  const redirect = route.redirectHost;
@@ -335,23 +398,98 @@ function renderHostRedirect(route) {
335
398
  assertValidDomain(redirect.target);
336
399
  return `return ${resolveRedirectStatus(redirect.statusCode)} https://${redirect.target}$request_uri;`;
337
400
  }
338
- function renderRedirects(route) {
401
+ function renderRedirectRules(route, indent) {
339
402
  if (!route.redirects || route.redirects.length === 0) return "";
340
- return route.redirects.map(
341
- (r) => `
342
- location ${r.exact ? "= " : ""}${r.path} {
343
- return ${r.statusCode} ${r.destination};
344
- }`
345
- ).join("");
403
+ const rules = route.redirects.map((r) => {
404
+ assertValidRedirect(r);
405
+ const match = r.pattern ? `^${toNginxPattern(r.pattern, "?")}(?:[?].*)?$` : r.exact ? `^${escapeLiteralPath(r.path)}(?:[?].*)?$` : `^${escapeLiteralPath(r.path)}`;
406
+ const query = r.destination.includes("?") ? "" : "$is_args$args";
407
+ return `${indent}if ($request_uri ~ "${match}") {
408
+ ${indent} return ${r.statusCode} ${r.destination}${query};
409
+ ${indent}}`;
410
+ });
411
+ return `${rules.join("\n")}
412
+
413
+ ${indent}`;
414
+ }
415
+ function assertValidHeader(key, value) {
416
+ if (!/^[A-Za-z0-9-]+$/.test(key)) throw new Error(`Invalid response header name: ${key}`);
417
+ if (/[\r\n]/.test(value)) throw new Error(`Invalid response header value for ${key}`);
418
+ assertNoRuntimeVariable(value, `response header value for ${key}`);
346
419
  }
347
- function renderServerHeaders(route) {
348
- const global = (route.headerRules ?? []).filter((rule) => rule.path === "/");
349
- const lines = global.flatMap(
350
- (rule) => rule.headers.map((h) => ` add_header ${h.key} "${h.value.replace(/"/g, '\\"')}" always;`)
420
+ function addHeaderLine(key, value) {
421
+ assertValidHeader(key, value);
422
+ return ` add_header ${key} "${value.replace(/"/g, '\\"')}" always;`;
423
+ }
424
+ function renderServerHeaders(route, slug) {
425
+ const lines = headerEntries(route, slug).map(
426
+ (e) => e.variable === null ? addHeaderLine(e.header.key, e.header.value) : ` add_header ${e.header.key} $${e.variable} always;`
351
427
  );
352
428
  return lines.length > 0 ? `
353
429
  ${lines.join("\n")}` : "";
354
430
  }
431
+ function headerEntries(route, slug) {
432
+ const prefix = `osh_hdr_${slug.replace(/[^A-Za-z0-9]+/g, "_")}`;
433
+ let n = 0;
434
+ return (route.headerRules ?? []).flatMap(
435
+ (rule) => rule.headers.map((header) => ({
436
+ rule,
437
+ header,
438
+ variable: rule.path === "/" ? null : `${prefix}_${n++}`
439
+ }))
440
+ );
441
+ }
442
+ function renderHeaderMaps(route, slug) {
443
+ const scoped = headerEntries(route, slug).filter((e) => e.variable !== null);
444
+ if (scoped.length === 0) return "";
445
+ const blocks = scoped.map((e) => {
446
+ assertValidLiteralPath(e.rule.path, "header rule path");
447
+ assertValidHeader(e.header.key, e.header.value);
448
+ return `map $request_uri $${e.variable} {
449
+ default "";
450
+ ~^${escapeLiteralPath(e.rule.path)} "${e.header.value.replace(/"/g, '\\"')}";
451
+ }`;
452
+ });
453
+ return `${blocks.join("\n")}
454
+ `;
455
+ }
456
+ function renderUrlShape(route) {
457
+ if (!("staticRoot" in route) || !route.staticRoot) return "";
458
+ const rules = [];
459
+ if (route.cleanUrls) {
460
+ rules.push(` if ($request_uri ~ "^([^?]*)[.]html([?].*)?$") {
461
+ return 308 $1$2;
462
+ }`);
463
+ }
464
+ if (route.trailingSlash === false) {
465
+ rules.push(` if ($request_uri ~ "^([^?]+)/([?].*)?$") {
466
+ return 308 $1$2;
467
+ }`);
468
+ }
469
+ return rules.length > 0 ? `${rules.join("\n").trimStart()}
470
+
471
+ ` : "";
472
+ }
473
+ var SLASH_FALLBACK_NAME = "@osh_add_slash";
474
+ function renderSlashFallback(staticRoot) {
475
+ return ` location ${SLASH_FALLBACK_NAME} {
476
+ root ${staticRoot};
477
+
478
+ if ($request_uri ~ "^([^?]*[^/?])([?].*)?$") {
479
+ return 308 $1/$2;
480
+ }
481
+
482
+ try_files /index.html =404;
483
+ }`;
484
+ }
485
+ function renderStaticTryFiles(route) {
486
+ const parts = ["$uri"];
487
+ if (route.cleanUrls) parts.push("$uri.html");
488
+ if (route.trailingSlash === false) parts.push("$uri/index.html");
489
+ else parts.push("$uri/");
490
+ parts.push(route.trailingSlash === true ? SLASH_FALLBACK_NAME : "/index.html");
491
+ return `try_files ${parts.join(" ")};`;
492
+ }
355
493
  function renderProxyDirectives(proxy, opts) {
356
494
  const lines = resolveProxyDirectives(proxy, {
357
495
  scope: opts.tlsOnly ? "tls" : "shared",
@@ -427,16 +565,58 @@ function assertValidDomain(domain) {
427
565
  }
428
566
  }
429
567
  function assertNoNginxInjection(value, what) {
430
- if (/[\s;{}#\\]/.test(value)) {
568
+ if (/[\s;{}#\\'"]/.test(value)) {
431
569
  throw new Error(`Invalid ${what} (contains characters that could inject nginx config): ${value}`);
432
570
  }
433
571
  }
572
+ function assertNoRuntimeVariable(value, what, allowCaptures = false) {
573
+ const residue = allowCaptures ? value.replace(/\$[1-9]/g, "") : value;
574
+ if (residue.includes("$")) {
575
+ throw new Error(`Invalid ${what} (contains an nginx variable reference): ${value}`);
576
+ }
577
+ }
434
578
  function assertValidUpstream(targetUrl) {
435
579
  assertNoNginxInjection(targetUrl, "proxy target");
580
+ assertNoRuntimeVariable(targetUrl, "proxy target");
436
581
  if (!/^https?:\/\/.+/.test(targetUrl)) {
437
582
  throw new Error(`Invalid proxy target (must be http/https URL): ${targetUrl}`);
438
583
  }
439
584
  }
585
+ var REDIRECT_PATTERN_RE = /^\/(?:[A-Za-z0-9_~\-/]|\\\.|\(\.\*\)|\(\.\+\)|\(\[\^\/\][*+]\))*$/;
586
+ function assertValidRedirectPattern(pattern) {
587
+ if (!REDIRECT_PATTERN_RE.test(pattern)) {
588
+ throw new Error(`Invalid capture pattern: ${pattern}`);
589
+ }
590
+ }
591
+ var REGEX_METACHARS = /[*+?^$()[\]|]/;
592
+ function assertValidLiteralPath(value, what) {
593
+ assertNoNginxInjection(value, what);
594
+ if (!value.startsWith("/") || REGEX_METACHARS.test(value)) {
595
+ throw new Error(`Invalid ${what} (not a plain path): ${value}`);
596
+ }
597
+ }
598
+ function escapeLiteralPath(value) {
599
+ return value.replace(/\./g, "[.]");
600
+ }
601
+ function assertValidRedirect(redirect) {
602
+ if (redirect.pattern !== void 0) assertValidRedirectPattern(redirect.pattern);
603
+ assertValidLiteralPath(redirect.path, "redirect path");
604
+ assertNoNginxInjection(redirect.destination, "redirect destination");
605
+ assertNoRuntimeVariable(redirect.destination, "redirect destination", true);
606
+ }
607
+ var NGINX_CAPTURE = {
608
+ "(.*)": `([^${CTL}]*)`,
609
+ "(.+)": `([^${CTL}]+)`,
610
+ "([^/]+)": `([^/${CTL}]+)`,
611
+ "([^/]*)": `([^/${CTL}]*)`
612
+ };
613
+ function toNginxPattern(pattern, alsoExclude = "") {
614
+ return pattern.replace(/\(\.[*+]\)|\(\[\^\/\][*+]\)/g, (group) => {
615
+ const hardened = NGINX_CAPTURE[group];
616
+ if (!hardened) return group;
617
+ return alsoExclude ? hardened.replace(`${CTL}]`, `${CTL}${alsoExclude}]`) : hardened;
618
+ });
619
+ }
440
620
  var CHALLENGE_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._~-]{7,127}$/;
441
621
  function assertValidChallengeToken(token) {
442
622
  if (!CHALLENGE_TOKEN_RE.test(token) || token.includes("..")) {
@@ -531,10 +711,11 @@ var NginxProvider = class {
531
711
  async _writeFile(path, content, mode) {
532
712
  const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
533
713
  if (this.executor) {
714
+ if (mode !== void 0) this._assertChmodReaches(path, mode);
534
715
  try {
535
716
  await this.executor.writeFile(tmpPath, content);
536
717
  if (mode !== void 0) {
537
- await this.executor.exec(`chmod ${sq(mode.toString(8))} ${sq(tmpPath)}`);
718
+ await this._chmod(tmpPath, mode);
538
719
  }
539
720
  if (this.executor.rename) {
540
721
  await this.executor.rename(tmpPath, path);
@@ -590,6 +771,46 @@ var NginxProvider = class {
590
771
  await fsMkdir(path, { recursive: true });
591
772
  }
592
773
  }
774
+ /**
775
+ * Do a FILE op and a COMMAND at `path` reach the same bytes?
776
+ *
777
+ * On a container edge they travel different namespaces: `writeFile`/`mkdir` land on
778
+ * the HOST (bind mount) while `exec` runs INSIDE the container. They name the same
779
+ * file only where a mount carries the same string on both sides
780
+ * ({@link EDGE_SAME_PATH_MOUNTS}). A bare or remote edge has one namespace, so
781
+ * everything qualifies.
782
+ */
783
+ _sharesHostNamespace(path) {
784
+ if (!this.containerEdge) return true;
785
+ return EDGE_SAME_PATH_MOUNTS.some((m) => path === m || path.startsWith(`${m}/`));
786
+ }
787
+ /**
788
+ * Refuse an operation that mixes the two channels at a path where they are not the
789
+ * same file. `action` completes "Refusing to …" and must name the path.
790
+ *
791
+ * Never "skip it and continue", because both failure modes are silent. A `chmod`
792
+ * either misses or tightens a DIFFERENT file that does exist in the container,
793
+ * leaving a TLS private key or the ACME EAB HMAC key at the default mode and
794
+ * reporting success. An `openssl -keyout` writes the key into the container's own
795
+ * filesystem, where the host path OpenResty reads never sees it — and a vhost
796
+ * pointing at material the host lacks fails `openresty -t` for the WHOLE edge.
797
+ */
798
+ _assertOneNamespace(path, action) {
799
+ if (this._sharesHostNamespace(path)) return;
800
+ throw new Error(
801
+ `Refusing to ${action}: this edge runs in a container, so file operations land on the host while commands run inside it. Only paths under ${EDGE_SAME_PATH_MOUNTS.join(", ")} are the same path on both sides.`
802
+ );
803
+ }
804
+ /** The chmod half of {@link _assertOneNamespace}: the write travels the file channel, the mode the command channel. */
805
+ _assertChmodReaches(path, mode) {
806
+ this._assertOneNamespace(path, `write ${path} with mode 0${mode.toString(8)}`);
807
+ }
808
+ /** No-op without an executor: the local path sets the mode at creation instead. */
809
+ async _chmod(path, mode) {
810
+ if (!this.executor) return;
811
+ this._assertChmodReaches(path, mode);
812
+ await this.executor.exec(`chmod ${sq(mode.toString(8))} ${sq(path)}`);
813
+ }
593
814
  /**
594
815
  * Put `fullchain.pem` + `privkey.pem` in `dir` as ONE atomic step.
595
816
  *
@@ -606,7 +827,7 @@ var NginxProvider = class {
606
827
  try {
607
828
  await this._mkdir(staging);
608
829
  await this._writeFile(join(staging, "fullchain.pem"), cert.certPem);
609
- await this._writeFile(join(staging, "privkey.pem"), cert.keyPem);
830
+ await this._writeFile(join(staging, "privkey.pem"), cert.keyPem, 384);
610
831
  if (this.executor) {
611
832
  await this._mkdir(dir);
612
833
  await this.executor.exec(
@@ -666,6 +887,12 @@ var NginxProvider = class {
666
887
  const dir = this.bootstrapCertDir(domain);
667
888
  const certPath = join(dir, "fullchain.pem");
668
889
  const keyPath = join(dir, "privkey.pem");
890
+ if (!this._sharesHostNamespace(dir)) {
891
+ console.warn(
892
+ `[SSL] not creating a temporary certificate for ${domain}: ${dir} is not the same path inside the edge container as on the host, so OpenResty could not read the key \u2014 serving HTTP only until a real certificate is issued.`
893
+ );
894
+ return null;
895
+ }
669
896
  try {
670
897
  if (await this._exists(certPath) && await this._exists(keyPath)) {
671
898
  return { certPath, keyPath };
@@ -693,6 +920,7 @@ var NginxProvider = class {
693
920
  "-addext",
694
921
  `subjectAltName=DNS:${domain}`
695
922
  ]);
923
+ await this._chmod(join(staging, "privkey.pem"), 384);
696
924
  await this._mkdir(dir);
697
925
  if (this.executor) {
698
926
  await this.executor.exec(
@@ -796,13 +1024,10 @@ var NginxProvider = class {
796
1024
  if (!this.acmeEabKid || !this.acmeEabHmacKey) return null;
797
1025
  const dir = join(dirname(this.certDir), `.openship-eab-${randomBytes(12).toString("hex")}`);
798
1026
  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
- }
1027
+ if (this.executor) await this.executor.mkdir(dir);
1028
+ else await fsMkdir(dir, { recursive: true, mode: 448 });
805
1029
  try {
1030
+ await this._chmod(dir, 448);
806
1031
  await this._writeFile(
807
1032
  path,
808
1033
  `eab-kid = ${this.acmeEabKid}
@@ -854,14 +1079,20 @@ eab-hmac-key = ${this.acmeEabHmacKey}
854
1079
  const hostRedirect = renderHostRedirect(route);
855
1080
  const locationBody = hostRedirect ? hostRedirect : "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
856
1081
  index index.html;
857
- try_files $uri $uri/ /index.html;` : `proxy_pass ${route.targetUrl};
1082
+ ${renderStaticTryFiles(route)}` : `proxy_pass ${route.targetUrl};
858
1083
  ${PROXY_HEADERS}`;
859
- const extraLocations = hostRedirect ? "" : `${renderRedirects(route)}${renderProxyLocations(route)}`;
860
- const serverHeaders = hostRedirect ? "" : renderServerHeaders(route);
1084
+ const urlShape = hostRedirect ? "" : renderUrlShape(route);
1085
+ const redirectRules = hostRedirect ? "" : renderRedirectRules(route, " ");
1086
+ const extraLocations = hostRedirect ? "" : renderProxyLocations(route, { httpsUpgrade: false });
1087
+ const upgradingExtraLocations = hostRedirect ? "" : renderProxyLocations(route, { httpsUpgrade: true });
1088
+ const serverHeaders = hostRedirect ? "" : renderServerHeaders(route, slug);
1089
+ const prelude = hostRedirect ? "" : renderHeaderMaps(route, slug);
861
1090
  const proxyOpts = renderProxyOptions(route.proxy, this.nginxVersion);
862
1091
  const proxyTlsOpts = renderProxyTlsOptions(route.proxy, this.nginxVersion);
1092
+ const preamble = isCloudFrontedHost(route.domain) ? `${cloudEdgeRealIpConf()}
1093
+ ${FORWARD_VARS}` : FORWARD_VARS;
863
1094
  const webhookLocation = route.webhookProxy && !hostRedirect ? `
864
- location /_openship/hooks/ {
1095
+ location ^~ ${WEBHOOK_LOCATION_PREFIX} {
865
1096
  proxy_pass ${route.webhookProxy};
866
1097
  proxy_set_header Host $host;
867
1098
  proxy_set_header X-Real-IP $remote_addr;
@@ -872,24 +1103,25 @@ eab-hmac-key = ${this.acmeEabHmacKey}
872
1103
  const edgeLua = luaSourceAvailable();
873
1104
  const luaProxy = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};
874
1105
  access_by_lua_file ${RULES_GUARD_PATH};` : "";
1106
+ const slashFallback = !hostRedirect && "staticRoot" in route && route.staticRoot && route.trailingSlash === true ? `
1107
+
1108
+ ${renderSlashFallback(route.staticRoot)}` : "";
875
1109
  const serveLocation = ` location / {
876
- ${locationBody}
877
- }`;
1110
+ ${redirectRules}${urlShape}${locationBody}
1111
+ }${slashFallback}`;
878
1112
  const redirectOrServeLocation = hostRedirect ? serveLocation : ` location / {
879
- if ($openship_redirect_https) {
880
- return 301 https://$server_name$request_uri;
881
- }
882
-
883
- ${locationBody}
884
- }`;
1113
+ ${HTTPS_UPGRADE}${redirectRules}${urlShape}${locationBody}
1114
+ }${slashFallback}`;
885
1115
  const sharedBody = `${serverHeaders}${proxyOpts}
886
1116
  ${webhookLocation}${extraLocations}`;
887
- const httpOnlyBlock = `# Auto-generated by Openship - do not edit manually
1117
+ const httpsUpgradeBody = `${serverHeaders}${proxyOpts}
1118
+ ${webhookLocation}${upgradingExtraLocations}`;
1119
+ const httpOnlyBlock = `${GENERATED_HEADER}
888
1120
  server {
889
1121
  listen 80;
890
1122
  server_name ${route.domain};
891
1123
 
892
- ${FORWARD_VARS}
1124
+ ${preamble}
893
1125
 
894
1126
  ${luaProxy}
895
1127
 
@@ -905,19 +1137,19 @@ ${serveLocation}
905
1137
  if (route.tls && await this.certsExist(route.domain)) {
906
1138
  const certPath = join(this.certDir, route.domain, "fullchain.pem");
907
1139
  const keyPath = join(this.certDir, route.domain, "privkey.pem");
908
- serverBlock = `# Auto-generated by Openship - do not edit manually
1140
+ serverBlock = `${GENERATED_HEADER}
909
1141
  server {
910
1142
  listen 80;
911
1143
  server_name ${route.domain};
912
1144
 
913
- ${FORWARD_VARS}
1145
+ ${preamble}
914
1146
 
915
1147
  ${luaProxy}
916
1148
 
917
1149
  ${ACME_CHALLENGE_LOCATION}
918
1150
 
919
1151
  ${EDGE_CHALLENGE_LOCATION}
920
- ${sharedBody}
1152
+ ${httpsUpgradeBody}
921
1153
  ${redirectOrServeLocation}
922
1154
  }
923
1155
 
@@ -925,7 +1157,7 @@ server {
925
1157
  listen 443 ssl;
926
1158
  server_name ${route.domain};
927
1159
 
928
- ${FORWARD_VARS}
1160
+ ${preamble}
929
1161
 
930
1162
  ${luaProxy}
931
1163
 
@@ -938,7 +1170,7 @@ ${serveLocation}
938
1170
  } else if (route.tls && route.terminatesTlsLocally) {
939
1171
  const bootstrap = await this.ensureBootstrapCert(route.domain);
940
1172
  bootstrapServed = !!bootstrap;
941
- serverBlock = bootstrap ? `# Auto-generated by Openship - do not edit manually
1173
+ serverBlock = bootstrap ? `${GENERATED_HEADER}
942
1174
  # openship-bootstrap-tls: ${route.domain} is serving a TEMPORARY self-signed
943
1175
  # certificate because its real certificate has not been issued yet. Openship
944
1176
  # replaces this file automatically once it lands. Do not adopt this material.
@@ -946,7 +1178,7 @@ server {
946
1178
  listen 80;
947
1179
  server_name ${route.domain};
948
1180
 
949
- ${FORWARD_VARS}
1181
+ ${preamble}
950
1182
 
951
1183
  ${luaProxy}
952
1184
 
@@ -961,7 +1193,7 @@ server {
961
1193
  listen 443 ssl;
962
1194
  server_name ${route.domain};
963
1195
 
964
- ${FORWARD_VARS}
1196
+ ${preamble}
965
1197
 
966
1198
  ${luaProxy}
967
1199
 
@@ -975,7 +1207,7 @@ ${serveLocation}
975
1207
  serverBlock = httpOnlyBlock;
976
1208
  }
977
1209
  const snapshot = await this._captureFile(configPath);
978
- await this._writeFile(configPath, serverBlock);
1210
+ await this._writeFile(configPath, `${prelude}${serverBlock}`);
979
1211
  try {
980
1212
  await this.reload();
981
1213
  } catch (err) {
@@ -991,6 +1223,112 @@ ${serveLocation}
991
1223
  routeStatePath(slug) {
992
1224
  return join(this.sitesDir, `${slug}.route.json`);
993
1225
  }
1226
+ /**
1227
+ * Re-emit every vhost an older generator wrote, from its own persisted RouteConfig.
1228
+ *
1229
+ * Why this exists: a fix to the EMITTED shape otherwise never reaches a running box.
1230
+ * `registerRoute` is the only writer, and the only things that call it are a deploy, a
1231
+ * domain save, a Retry, and cert issuance — so a project nobody redeploys keeps the vhost
1232
+ * it was first given for as long as it lives. That is how the Cloud-fronted `X-Real-IP`
1233
+ * block shipped and then applied to nothing: correct code, upgraded box, unchanged file.
1234
+ *
1235
+ * Cheap and idempotent by construction: a vhost already stamped at
1236
+ * {@link VHOST_GENERATION} is read and skipped, so the pass writes nothing and reloads
1237
+ * nothing once converged. Only the FIRST run after a bump does work.
1238
+ *
1239
+ * That first run is not free, and the release that introduced the marker is its worst
1240
+ * case: every conf written before it is unstamped, so a box with N routes reapplies all
1241
+ * N. `registerRoute` writes and reloads per route by design — a conf that fails
1242
+ * `openresty -t` is rolled back and reloaded on its own, which is why one bad route
1243
+ * cannot take the other N-1 down — so that run costs N whole-config tests and N reloads,
1244
+ * serially, on a deploy. Batching them behind a single test would trade that isolation
1245
+ * for the seconds it saves once per box, which is the wrong trade. What it does mean is
1246
+ * that a VHOST_GENERATION bump is a fleet-wide cost, not a free redeploy: bump it for a
1247
+ * fix that has to reach existing boxes, not to tidy the emitted text.
1248
+ *
1249
+ * Never throws and never aborts the sweep. One route whose sidecar is unreadable, or
1250
+ * whose re-render fails `openresty -t` (registerRoute rolls that file back on its own),
1251
+ * must not stop the rest — a half-converged edge with a reported failure is strictly
1252
+ * better than a wedged one. A route with no sidecar is left ALONE rather than
1253
+ * reconstructed from its conf: the scrape recovers only the primary target, so replaying
1254
+ * it would drop composite locations and the webhook proxy.
1255
+ */
1256
+ async reapplyStoredRoutes() {
1257
+ const repaired = [];
1258
+ const failed = [];
1259
+ let entries;
1260
+ try {
1261
+ entries = await this._lsSites();
1262
+ } catch (err) {
1263
+ return { scanned: 0, repaired, failed: [{ slug: "*", reason: safeErrorMessage(err) }] };
1264
+ }
1265
+ const slugs = entries.filter((name) => name.endsWith(".route.json")).map((name) => name.slice(0, -".route.json".length));
1266
+ const stale = await this._staleVhostSlugs().catch(() => null);
1267
+ for (const slug of slugs) {
1268
+ if (stale && !stale.has(slug)) continue;
1269
+ try {
1270
+ const conf = await this._readFile(join(this.sitesDir, `${slug}.conf`)).catch(() => null);
1271
+ if (conf === null) continue;
1272
+ const gen = readVhostGeneration(conf);
1273
+ if (gen !== null && gen >= VHOST_GENERATION) continue;
1274
+ const saved = JSON.parse(await this._readFile(this.routeStatePath(slug)));
1275
+ if (!saved?.domain) {
1276
+ failed.push({ slug, reason: "sidecar has no domain" });
1277
+ continue;
1278
+ }
1279
+ await this.registerRoute(saved);
1280
+ repaired.push(saved.domain);
1281
+ } catch (err) {
1282
+ failed.push({ slug, reason: safeErrorMessage(err) });
1283
+ }
1284
+ }
1285
+ return { scanned: slugs.length, repaired, failed };
1286
+ }
1287
+ /**
1288
+ * Names in the sites directory.
1289
+ *
1290
+ * Listing goes through the COMMAND channel, so on a container edge it must name the
1291
+ * path commands see: `sitesDir` is the HOST side of the bind mount and does not exist
1292
+ * inside the container. Same directory either way, and only basenames are used — the
1293
+ * reads that follow go back through the file channel at the host path.
1294
+ */
1295
+ async _lsSites() {
1296
+ if (!this.executor) return fsReaddir(this.sitesDir);
1297
+ const out = await this.executor.exec(`ls -1 ${sq(this._commandSitesDir())} 2>/dev/null || true`);
1298
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
1299
+ }
1300
+ /**
1301
+ * Slugs whose `.conf` does not carry the CURRENT generation marker — `null` when the
1302
+ * question can't be answered in one command and the caller should fall back to reading.
1303
+ *
1304
+ * `grep -L` on the whole directory, so the steady state (everything current) costs one
1305
+ * round trip instead of one per route. The pattern is anchored on both ends and built from
1306
+ * the same constant the writer emits, so `gen: 1` cannot pass for `gen: 10` and the two
1307
+ * halves cannot drift apart.
1308
+ */
1309
+ async _staleVhostSlugs() {
1310
+ if (!this.executor) return null;
1311
+ const dir = this._commandSitesDir();
1312
+ const out = await this.executor.exec(
1313
+ `grep -L ${sq(`^${GENERATION_MARKER}$`)} ${sq(dir)}/*.conf 2>/dev/null || true`
1314
+ );
1315
+ const slugs = /* @__PURE__ */ new Set();
1316
+ for (const line of out.split("\n")) {
1317
+ const name = line.trim().split("/").pop();
1318
+ if (name?.endsWith(".conf")) slugs.add(name.slice(0, -".conf".length));
1319
+ }
1320
+ return slugs;
1321
+ }
1322
+ /**
1323
+ * The sites directory as COMMANDS see it.
1324
+ *
1325
+ * On a container edge `sitesDir` is the HOST side of the bind mount and does not exist
1326
+ * inside the container, where `exec` runs. Same directory, two names; only basenames are
1327
+ * used, and the reads that follow go back through the file channel at the host path.
1328
+ */
1329
+ _commandSitesDir() {
1330
+ return this.containerEdge ? OPENRESTY_DEFAULT_PATHS.sitesDir : this.sitesDir;
1331
+ }
994
1332
  /**
995
1333
  * Recover the proxy tunables from a vhost we already wrote, for the legacy
996
1334
  * no-sidecar re-register below. Rewriting the conf without them would silently
@@ -1083,7 +1421,7 @@ ${serveLocation}
1083
1421
  const path2 = join(this.sitesDir, `${stem}.conf`);
1084
1422
  const conf = await this._readFile(path2).catch(() => "");
1085
1423
  if (!conf || !this.serverNamesIn(conf).includes(host)) continue;
1086
- if (conf.includes(`location ${EDGE_CHALLENGE_URL_PREFIX}`)) {
1424
+ if (conf.includes(`${EDGE_CHALLENGE_URL_PREFIX} {`)) {
1087
1425
  return { served: true, via: "existing-vhost" };
1088
1426
  }
1089
1427
  return {
@@ -1619,6 +1957,8 @@ export {
1619
1957
  validateCertFor,
1620
1958
  isSafeCertPath,
1621
1959
  readDeclaredPair,
1960
+ VHOST_GENERATION,
1961
+ readVhostGeneration,
1622
1962
  renderProxyOptions,
1623
1963
  renderProxyTlsOptions,
1624
1964
  renderGeoEntries,