openship 0.6.1 → 0.6.6

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 (67) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-IWS2QHJ3.js → bare-OXJVW3AK.js} +11 -9
  3. package/dist/{chunk-VZ2ZHPS2.js → chunk-2YM3NI2E.js} +3 -9
  4. package/dist/chunk-4I2BM2DT.js +68 -0
  5. package/dist/{chunk-2NZSUXOB.js → chunk-5DMWSTOM.js} +310 -1459
  6. package/dist/{chunk-3FYIXVX5.js → chunk-77QDF6UD.js} +581 -122
  7. package/dist/{chunk-IOT5ZX23.js → chunk-A4GXYE4L.js} +449 -383
  8. package/dist/chunk-CBLD2HS5.js +428 -0
  9. package/dist/chunk-ELGRMZOW.js +25 -0
  10. package/dist/{chunk-TWJCLD4W.js → chunk-FE7ZZQSE.js} +5 -5
  11. package/dist/{chunk-3NZICQHF.js → chunk-HKRQXPTI.js} +31 -8
  12. package/dist/{chunk-IXM5245F.js → chunk-LFE5KQ7Y.js} +553 -538
  13. package/dist/{chunk-VDDMADOF.js → chunk-LH2PLRQO.js} +16 -2
  14. package/dist/{chunk-EMXI4UCU.js → chunk-LUKYPVJ4.js} +29 -10
  15. package/dist/{chunk-UC7YIEHT.js → chunk-MLYSEZQB.js} +352 -47
  16. package/dist/chunk-NK5N5LXN.js +1681 -0
  17. package/dist/{chunk-RRJJGVKE.js → chunk-OBPKYXQL.js} +75 -17
  18. package/dist/{chunk-6ODUBKUB.js → chunk-PCPVTH6M.js} +78 -88
  19. package/dist/chunk-Q7WGTQQ5.js +88 -0
  20. package/dist/{chunk-EW6KB2J7.js → chunk-QVW76WDL.js} +440 -74
  21. package/dist/{chunk-YIPMDGG6.js → chunk-RSR7PFIQ.js} +79 -23
  22. package/dist/{chunk-3LDWWY7U.js → chunk-SM26HDS2.js} +41 -7
  23. package/dist/{chunk-OTLSJYAC.js → chunk-UJ6JZM65.js} +5185 -341
  24. package/dist/chunk-UVNMQM7W.js +444 -0
  25. package/dist/chunk-VTGPEC6B.js +308 -0
  26. package/dist/chunk-WVAML4H6.js +2145 -0
  27. package/dist/{chunk-JIMUYEKN.js → chunk-YIMOCZBZ.js} +1 -1
  28. package/dist/chunk-YMMQRE5J.js +292 -0
  29. package/dist/cloud-75VIT4KV.js +37 -0
  30. package/dist/{detect-ALF4GXRX.js → detect-OIE5V3VZ.js} +3 -3
  31. package/dist/{docker-QECQJ5P4.js → docker-VTR2GK5Y.js} +16 -8
  32. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-22IBXNYJ.js} +5 -5
  33. package/dist/{edge-import-SAYTRJLO.js → edge-import-WJUB4M7F.js} +27 -23
  34. package/dist/edge-preflight-GBNCPNVU.js +42 -0
  35. package/dist/{ensure-container-edge-CFSCPGZK.js → ensure-container-edge-KPI2CVXJ.js} +9 -8
  36. package/dist/{executor-PT6UEJ5M.js → executor-7LXXZPBR.js} +15 -5
  37. package/dist/host-channel-preflight-KE6OONYK.js +50 -0
  38. package/dist/index.js +3783 -4221
  39. package/dist/{nginx-YOEEFBTE.js → nginx-KUFMLMAC.js} +13 -5
  40. package/dist/node-bootstrap.js +241 -0
  41. package/dist/node-entry.js +52 -1
  42. package/dist/{openresty-lua-WGBO2M3Z.js → openresty-lua-ATTH35BE.js} +8 -2
  43. package/dist/privilege-I4W3ZNZK.js +19 -0
  44. package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
  45. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  46. package/dist/server/engine/samples/postfix/master.cf +14 -0
  47. package/dist/server/index.js +77659 -61909
  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/0105_mail_inbound_rules.sql +80 -0
  56. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  57. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  58. package/dist/server/migrations/0108_credential.sql +68 -0
  59. package/dist/server/migrations/meta/_journal.json +77 -0
  60. package/dist/{setup-M2ZDGH3O.js → setup-XGLGUUFW.js} +10 -9
  61. package/dist/ssh-client-JBMA7SN4.js +28 -0
  62. package/dist/up-K4BV7466.js +57 -0
  63. package/package.json +4 -2
  64. package/dist/chunk-IW2UKBVF.js +0 -144
  65. package/dist/chunk-ZCFVHA25.js +0 -89
  66. package/dist/cloud-M2YLWEXO.js +0 -31
  67. /package/dist/{dist-es-JGBPABJI.js → dist-es-AXFRS7PS.js} +0 -0
@@ -2,31 +2,41 @@
2
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
- import {
6
- edgeDownExplanation,
7
- explainEdgeDown,
8
- isEdgeDownFailure
9
- } from "./chunk-I3DPMTSF.js";
10
- import {
11
- edgeFailureReason
12
- } from "./chunk-VZ2ZHPS2.js";
13
5
  import {
14
6
  ACME_CHALLENGE_LOCATION,
15
7
  ACME_HTTP01_PORT,
16
8
  EDGE_CHALLENGE_DIR,
17
9
  EDGE_CHALLENGE_LOCATION,
18
10
  EDGE_CHALLENGE_URL_PREFIX,
11
+ EDGE_SAME_PATH_MOUNTS,
19
12
  LUA_LOGGER_PATH,
13
+ OPENRESTY_DEFAULT_PATHS,
20
14
  RULES_GUARD_PATH,
21
15
  buildReloadCommand,
16
+ cloudEdgeRealIpConf,
22
17
  detectOpenRestyPaths,
23
18
  edgeChallengeVhostConf,
19
+ isCloudFrontedHost,
24
20
  luaSourceAvailable
25
- } from "./chunk-YIPMDGG6.js";
21
+ } from "./chunk-RSR7PFIQ.js";
22
+ import {
23
+ edgeDownExplanation,
24
+ explainEdgeDown,
25
+ isEdgeDownFailure
26
+ } from "./chunk-I3DPMTSF.js";
27
+ import {
28
+ EXECUTOR_DELEGATE
29
+ } from "./chunk-UVNMQM7W.js";
26
30
  import {
27
31
  logEntry,
28
32
  sq
29
33
  } from "./chunk-ZFJXPCQP.js";
34
+ import {
35
+ edgeFailureReason
36
+ } from "./chunk-2YM3NI2E.js";
37
+ import {
38
+ probeListeningPort
39
+ } from "./chunk-SM26HDS2.js";
30
40
  import {
31
41
  PROXY_DIRECTIVES,
32
42
  parseProxyValue,
@@ -34,7 +44,7 @@ import {
34
44
  resolveRedirectStatus,
35
45
  safeErrorMessage,
36
46
  sanitizeProxySettings
37
- } from "./chunk-OTLSJYAC.js";
47
+ } from "./chunk-UJ6JZM65.js";
38
48
 
39
49
  // ../../packages/adapters/src/infra/nginx.ts
40
50
  import {
@@ -43,6 +53,7 @@ import {
43
53
  rm as fsRm,
44
54
  mkdir as fsMkdir,
45
55
  readFile as fsReadFile,
56
+ readdir as fsReaddir,
46
57
  rename as fsRename,
47
58
  stat as fsStat
48
59
  } from "fs/promises";
@@ -168,6 +179,7 @@ ${result.output}` };
168
179
  }
169
180
  return new Proxy(inner, {
170
181
  get(target, prop) {
182
+ if (prop === EXECUTOR_DELEGATE) return void 0;
171
183
  if (Object.prototype.hasOwnProperty.call(overrides, prop)) {
172
184
  return overrides[prop];
173
185
  }
@@ -305,29 +317,84 @@ async function probeStaticOutput(executor, servedPath) {
305
317
 
306
318
  // ../../packages/adapters/src/infra/nginx.ts
307
319
  var { dirname, join } = edgePath;
320
+ var CTL = "\\x00-\\x1f\\x7f";
308
321
  var PROXY_HEADERS = `proxy_set_header Host $host;
309
322
  proxy_set_header X-Real-IP $remote_addr;
310
323
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
311
324
  proxy_set_header X-Forwarded-Proto $openship_fwd_proto;
312
325
  proxy_http_version 1.1;
313
326
  proxy_set_header Upgrade $http_upgrade;
314
- proxy_set_header Connection "upgrade";`;
327
+ proxy_set_header Connection "upgrade";
328
+ proxy_pass_header X-Accel-Buffering;`;
315
329
  var FORWARD_VARS = ` set $openship_fwd_proto $scheme;
316
330
  set $openship_redirect_https 1;
317
331
  if ($http_x_forwarded_proto = "https") {
318
332
  set $openship_fwd_proto https;
319
333
  set $openship_redirect_https 0;
320
334
  }`;
321
- function renderProxyLocations(route) {
335
+ var VHOST_GENERATION = 2;
336
+ var GENERATION_MARKER = `# openship-vhost-gen: ${VHOST_GENERATION}`;
337
+ var GENERATED_HEADER = `# Auto-generated by Openship - do not edit manually
338
+ ${GENERATION_MARKER}`;
339
+ function readVhostGeneration(conf) {
340
+ const m = conf.match(/^# openship-vhost-gen: (\d+)$/m);
341
+ return m ? Number(m[1]) : null;
342
+ }
343
+ var HTTPS_UPGRADE = `if ($openship_redirect_https) {
344
+ return 301 https://$server_name$request_uri;
345
+ }
346
+
347
+ `;
348
+ var WEBHOOK_LOCATION_PREFIX = "/_openship/hooks/";
349
+ function renderProxyLocations(route, opts) {
322
350
  if (!route.proxyLocations || route.proxyLocations.length === 0) return "";
323
- return route.proxyLocations.map((loc) => {
351
+ const head = `${opts.httpsUpgrade ? HTTPS_UPGRADE : ""}${renderRedirectRules(route, " ")}`;
352
+ const claimed = /* @__PURE__ */ new Set(["/", WEBHOOK_LOCATION_PREFIX]);
353
+ const blocks = [];
354
+ for (const loc of route.proxyLocations) {
324
355
  assertValidUpstream(loc.targetUrl);
325
- return `
326
- location ${loc.pathPrefix} {
356
+ assertNotReservedLocation(loc.pathPrefix);
357
+ const headers = loc.external ? externalProxyHeaders(loc.targetUrl) : PROXY_HEADERS;
358
+ if (!loc.pattern || !loc.upstreamPath) {
359
+ assertValidLiteralPath(loc.pathPrefix, "proxy location prefix");
360
+ if (claimed.has(loc.pathPrefix)) continue;
361
+ claimed.add(loc.pathPrefix);
362
+ blocks.push(`
363
+ location ^~ ${loc.pathPrefix} {
364
+ ${head}proxy_pass ${loc.targetUrl};
365
+ ${headers}
366
+ }`);
367
+ continue;
368
+ }
369
+ assertValidRedirectPattern(loc.pattern);
370
+ assertNotReservedLocation(loc.pattern);
371
+ assertNoNginxInjection(loc.upstreamPath, "rewrite upstream path");
372
+ assertNoRuntimeVariable(loc.upstreamPath, "rewrite upstream path", true);
373
+ const match = `^${toNginxPattern(loc.pattern)}$`;
374
+ if (claimed.has(`~ ${match}`)) continue;
375
+ claimed.add(`~ ${match}`);
376
+ blocks.push(`
377
+ location ~ ${match} {
378
+ ${head}rewrite ${match} ${loc.upstreamPath} break;
327
379
  proxy_pass ${loc.targetUrl};
328
- ${PROXY_HEADERS}
329
- }`;
330
- }).join("");
380
+ ${headers}
381
+ }`);
382
+ }
383
+ return blocks.join("");
384
+ }
385
+ function assertNotReservedLocation(pathOrPattern) {
386
+ if (pathOrPattern.startsWith(WELL_KNOWN_PREFIX)) {
387
+ throw new Error(
388
+ `Refusing to generate a location under ${WELL_KNOWN_PREFIX}: ${pathOrPattern}. That namespace belongs to the edge \u2014 a location there can hijack certificate issuance.`
389
+ );
390
+ }
391
+ }
392
+ var WELL_KNOWN_PREFIX = "/.well-known/";
393
+ function externalProxyHeaders(targetUrl) {
394
+ const host = new URL(targetUrl).host;
395
+ assertNoNginxInjection(host, "external proxy host");
396
+ return `${PROXY_HEADERS.replace("Host $host;", `Host ${host};`)}
397
+ proxy_ssl_server_name on;`;
331
398
  }
332
399
  function renderHostRedirect(route) {
333
400
  const redirect = route.redirectHost;
@@ -335,23 +402,98 @@ function renderHostRedirect(route) {
335
402
  assertValidDomain(redirect.target);
336
403
  return `return ${resolveRedirectStatus(redirect.statusCode)} https://${redirect.target}$request_uri;`;
337
404
  }
338
- function renderRedirects(route) {
405
+ function renderRedirectRules(route, indent) {
339
406
  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("");
407
+ const rules = route.redirects.map((r) => {
408
+ assertValidRedirect(r);
409
+ const match = r.pattern ? `^${toNginxPattern(r.pattern, "?")}(?:[?].*)?$` : r.exact ? `^${escapeLiteralPath(r.path)}(?:[?].*)?$` : `^${escapeLiteralPath(r.path)}`;
410
+ const query = r.destination.includes("?") ? "" : "$is_args$args";
411
+ return `${indent}if ($request_uri ~ "${match}") {
412
+ ${indent} return ${r.statusCode} ${r.destination}${query};
413
+ ${indent}}`;
414
+ });
415
+ return `${rules.join("\n")}
416
+
417
+ ${indent}`;
346
418
  }
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;`)
419
+ function assertValidHeader(key, value) {
420
+ if (!/^[A-Za-z0-9-]+$/.test(key)) throw new Error(`Invalid response header name: ${key}`);
421
+ if (/[\r\n]/.test(value)) throw new Error(`Invalid response header value for ${key}`);
422
+ assertNoRuntimeVariable(value, `response header value for ${key}`);
423
+ }
424
+ function addHeaderLine(key, value) {
425
+ assertValidHeader(key, value);
426
+ return ` add_header ${key} "${value.replace(/"/g, '\\"')}" always;`;
427
+ }
428
+ function renderServerHeaders(route, slug) {
429
+ const lines = headerEntries(route, slug).map(
430
+ (e) => e.variable === null ? addHeaderLine(e.header.key, e.header.value) : ` add_header ${e.header.key} $${e.variable} always;`
351
431
  );
352
432
  return lines.length > 0 ? `
353
433
  ${lines.join("\n")}` : "";
354
434
  }
435
+ function headerEntries(route, slug) {
436
+ const prefix = `osh_hdr_${slug.replace(/[^A-Za-z0-9]+/g, "_")}`;
437
+ let n = 0;
438
+ return (route.headerRules ?? []).flatMap(
439
+ (rule) => rule.headers.map((header) => ({
440
+ rule,
441
+ header,
442
+ variable: rule.path === "/" ? null : `${prefix}_${n++}`
443
+ }))
444
+ );
445
+ }
446
+ function renderHeaderMaps(route, slug) {
447
+ const scoped = headerEntries(route, slug).filter((e) => e.variable !== null);
448
+ if (scoped.length === 0) return "";
449
+ const blocks = scoped.map((e) => {
450
+ assertValidLiteralPath(e.rule.path, "header rule path");
451
+ assertValidHeader(e.header.key, e.header.value);
452
+ return `map $request_uri $${e.variable} {
453
+ default "";
454
+ ~^${escapeLiteralPath(e.rule.path)} "${e.header.value.replace(/"/g, '\\"')}";
455
+ }`;
456
+ });
457
+ return `${blocks.join("\n")}
458
+ `;
459
+ }
460
+ function renderUrlShape(route) {
461
+ if (!("staticRoot" in route) || !route.staticRoot) return "";
462
+ const rules = [];
463
+ if (route.cleanUrls) {
464
+ rules.push(` if ($request_uri ~ "^([^?]*)[.]html([?].*)?$") {
465
+ return 308 $1$2;
466
+ }`);
467
+ }
468
+ if (route.trailingSlash === false) {
469
+ rules.push(` if ($request_uri ~ "^([^?]+)/([?].*)?$") {
470
+ return 308 $1$2;
471
+ }`);
472
+ }
473
+ return rules.length > 0 ? `${rules.join("\n").trimStart()}
474
+
475
+ ` : "";
476
+ }
477
+ var SLASH_FALLBACK_NAME = "@osh_add_slash";
478
+ function renderSlashFallback(staticRoot) {
479
+ return ` location ${SLASH_FALLBACK_NAME} {
480
+ root ${staticRoot};
481
+
482
+ if ($request_uri ~ "^([^?]*[^/?])([?].*)?$") {
483
+ return 308 $1/$2;
484
+ }
485
+
486
+ try_files /index.html =404;
487
+ }`;
488
+ }
489
+ function renderStaticTryFiles(route) {
490
+ const parts = ["$uri"];
491
+ if (route.cleanUrls) parts.push("$uri.html");
492
+ if (route.trailingSlash === false) parts.push("$uri/index.html");
493
+ else parts.push("$uri/");
494
+ parts.push(route.trailingSlash === true ? SLASH_FALLBACK_NAME : "/index.html");
495
+ return `try_files ${parts.join(" ")};`;
496
+ }
355
497
  function renderProxyDirectives(proxy, opts) {
356
498
  const lines = resolveProxyDirectives(proxy, {
357
499
  scope: opts.tlsOnly ? "tls" : "shared",
@@ -427,16 +569,58 @@ function assertValidDomain(domain) {
427
569
  }
428
570
  }
429
571
  function assertNoNginxInjection(value, what) {
430
- if (/[\s;{}#\\]/.test(value)) {
572
+ if (/[\s;{}#\\'"]/.test(value)) {
431
573
  throw new Error(`Invalid ${what} (contains characters that could inject nginx config): ${value}`);
432
574
  }
433
575
  }
576
+ function assertNoRuntimeVariable(value, what, allowCaptures = false) {
577
+ const residue = allowCaptures ? value.replace(/\$[1-9]/g, "") : value;
578
+ if (residue.includes("$")) {
579
+ throw new Error(`Invalid ${what} (contains an nginx variable reference): ${value}`);
580
+ }
581
+ }
434
582
  function assertValidUpstream(targetUrl) {
435
583
  assertNoNginxInjection(targetUrl, "proxy target");
584
+ assertNoRuntimeVariable(targetUrl, "proxy target");
436
585
  if (!/^https?:\/\/.+/.test(targetUrl)) {
437
586
  throw new Error(`Invalid proxy target (must be http/https URL): ${targetUrl}`);
438
587
  }
439
588
  }
589
+ var REDIRECT_PATTERN_RE = /^\/(?:[A-Za-z0-9_~\-/]|\\\.|\(\.\*\)|\(\.\+\)|\(\[\^\/\][*+]\))*$/;
590
+ function assertValidRedirectPattern(pattern) {
591
+ if (!REDIRECT_PATTERN_RE.test(pattern)) {
592
+ throw new Error(`Invalid capture pattern: ${pattern}`);
593
+ }
594
+ }
595
+ var REGEX_METACHARS = /[*+?^$()[\]|]/;
596
+ function assertValidLiteralPath(value, what) {
597
+ assertNoNginxInjection(value, what);
598
+ if (!value.startsWith("/") || REGEX_METACHARS.test(value)) {
599
+ throw new Error(`Invalid ${what} (not a plain path): ${value}`);
600
+ }
601
+ }
602
+ function escapeLiteralPath(value) {
603
+ return value.replace(/\./g, "[.]");
604
+ }
605
+ function assertValidRedirect(redirect) {
606
+ if (redirect.pattern !== void 0) assertValidRedirectPattern(redirect.pattern);
607
+ assertValidLiteralPath(redirect.path, "redirect path");
608
+ assertNoNginxInjection(redirect.destination, "redirect destination");
609
+ assertNoRuntimeVariable(redirect.destination, "redirect destination", true);
610
+ }
611
+ var NGINX_CAPTURE = {
612
+ "(.*)": `([^${CTL}]*)`,
613
+ "(.+)": `([^${CTL}]+)`,
614
+ "([^/]+)": `([^/${CTL}]+)`,
615
+ "([^/]*)": `([^/${CTL}]*)`
616
+ };
617
+ function toNginxPattern(pattern, alsoExclude = "") {
618
+ return pattern.replace(/\(\.[*+]\)|\(\[\^\/\][*+]\)/g, (group) => {
619
+ const hardened = NGINX_CAPTURE[group];
620
+ if (!hardened) return group;
621
+ return alsoExclude ? hardened.replace(`${CTL}]`, `${CTL}${alsoExclude}]`) : hardened;
622
+ });
623
+ }
440
624
  var CHALLENGE_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._~-]{7,127}$/;
441
625
  function assertValidChallengeToken(token) {
442
626
  if (!CHALLENGE_TOKEN_RE.test(token) || token.includes("..")) {
@@ -458,6 +642,11 @@ function assertValidStaticRoot(root, opts) {
458
642
  }
459
643
  }
460
644
  var execFileAsync = promisify(cpExecFile);
645
+ function isAcmePortBindFailure(output) {
646
+ return /could not bind (?:tcp )?port|address already in use|problem binding to port/i.test(
647
+ output || ""
648
+ );
649
+ }
461
650
  function summarizeCertbotFailure(output, domain) {
462
651
  const edgeDown = edgeDownExplanation(output);
463
652
  if (edgeDown) return edgeDown;
@@ -474,7 +663,9 @@ function certbotDiagnosis(output, domain) {
474
663
  const challengeUrl = pick(/https?:\/\/\S*\.well-known\/acme-challenge\/\S*/i);
475
664
  const redirectedToHttps = /^https:/i.test(challengeUrl ?? "");
476
665
  let diagnosis;
477
- if (/timeout during connect|connection refused|failed to connect|Timeout/i.test(text)) {
666
+ if (isAcmePortBindFailure(text)) {
667
+ diagnosis = `Couldn't start the certificate challenge listener for ${domain} on port ${ACME_HTTP01_PORT}: something on this server is already holding it. That port is used only while a certificate is being issued, so this is almost always a certificate run from an earlier attempt that hasn't exited \u2014 retry, and if it persists find the holder with \`ss -ltnp | grep ${ACME_HTTP01_PORT}\` and stop it. Nothing was requested from Let's Encrypt, so this is not a DNS or routing problem.`;
668
+ } else if (/timeout during connect|connection refused|failed to connect|Timeout/i.test(text)) {
478
669
  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.`;
479
670
  } else if (/NXDOMAIN|no\s+(A|AAAA)\s+record|DNS problem|could not be resolved|no records? found/i.test(text)) {
480
671
  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.`;
@@ -531,10 +722,11 @@ var NginxProvider = class {
531
722
  async _writeFile(path, content, mode) {
532
723
  const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
533
724
  if (this.executor) {
725
+ if (mode !== void 0) this._assertChmodReaches(path, mode);
534
726
  try {
535
727
  await this.executor.writeFile(tmpPath, content);
536
728
  if (mode !== void 0) {
537
- await this.executor.exec(`chmod ${sq(mode.toString(8))} ${sq(tmpPath)}`);
729
+ await this._chmod(tmpPath, mode);
538
730
  }
539
731
  if (this.executor.rename) {
540
732
  await this.executor.rename(tmpPath, path);
@@ -590,6 +782,46 @@ var NginxProvider = class {
590
782
  await fsMkdir(path, { recursive: true });
591
783
  }
592
784
  }
785
+ /**
786
+ * Do a FILE op and a COMMAND at `path` reach the same bytes?
787
+ *
788
+ * On a container edge they travel different namespaces: `writeFile`/`mkdir` land on
789
+ * the HOST (bind mount) while `exec` runs INSIDE the container. They name the same
790
+ * file only where a mount carries the same string on both sides
791
+ * ({@link EDGE_SAME_PATH_MOUNTS}). A bare or remote edge has one namespace, so
792
+ * everything qualifies.
793
+ */
794
+ _sharesHostNamespace(path) {
795
+ if (!this.containerEdge) return true;
796
+ return EDGE_SAME_PATH_MOUNTS.some((m) => path === m || path.startsWith(`${m}/`));
797
+ }
798
+ /**
799
+ * Refuse an operation that mixes the two channels at a path where they are not the
800
+ * same file. `action` completes "Refusing to …" and must name the path.
801
+ *
802
+ * Never "skip it and continue", because both failure modes are silent. A `chmod`
803
+ * either misses or tightens a DIFFERENT file that does exist in the container,
804
+ * leaving a TLS private key or the ACME EAB HMAC key at the default mode and
805
+ * reporting success. An `openssl -keyout` writes the key into the container's own
806
+ * filesystem, where the host path OpenResty reads never sees it — and a vhost
807
+ * pointing at material the host lacks fails `openresty -t` for the WHOLE edge.
808
+ */
809
+ _assertOneNamespace(path, action) {
810
+ if (this._sharesHostNamespace(path)) return;
811
+ throw new Error(
812
+ `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.`
813
+ );
814
+ }
815
+ /** The chmod half of {@link _assertOneNamespace}: the write travels the file channel, the mode the command channel. */
816
+ _assertChmodReaches(path, mode) {
817
+ this._assertOneNamespace(path, `write ${path} with mode 0${mode.toString(8)}`);
818
+ }
819
+ /** No-op without an executor: the local path sets the mode at creation instead. */
820
+ async _chmod(path, mode) {
821
+ if (!this.executor) return;
822
+ this._assertChmodReaches(path, mode);
823
+ await this.executor.exec(`chmod ${sq(mode.toString(8))} ${sq(path)}`);
824
+ }
593
825
  /**
594
826
  * Put `fullchain.pem` + `privkey.pem` in `dir` as ONE atomic step.
595
827
  *
@@ -606,7 +838,7 @@ var NginxProvider = class {
606
838
  try {
607
839
  await this._mkdir(staging);
608
840
  await this._writeFile(join(staging, "fullchain.pem"), cert.certPem);
609
- await this._writeFile(join(staging, "privkey.pem"), cert.keyPem);
841
+ await this._writeFile(join(staging, "privkey.pem"), cert.keyPem, 384);
610
842
  if (this.executor) {
611
843
  await this._mkdir(dir);
612
844
  await this.executor.exec(
@@ -666,6 +898,12 @@ var NginxProvider = class {
666
898
  const dir = this.bootstrapCertDir(domain);
667
899
  const certPath = join(dir, "fullchain.pem");
668
900
  const keyPath = join(dir, "privkey.pem");
901
+ if (!this._sharesHostNamespace(dir)) {
902
+ console.warn(
903
+ `[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.`
904
+ );
905
+ return null;
906
+ }
669
907
  try {
670
908
  if (await this._exists(certPath) && await this._exists(keyPath)) {
671
909
  return { certPath, keyPath };
@@ -693,6 +931,7 @@ var NginxProvider = class {
693
931
  "-addext",
694
932
  `subjectAltName=DNS:${domain}`
695
933
  ]);
934
+ await this._chmod(join(staging, "privkey.pem"), 384);
696
935
  await this._mkdir(dir);
697
936
  if (this.executor) {
698
937
  await this.executor.exec(
@@ -796,13 +1035,10 @@ var NginxProvider = class {
796
1035
  if (!this.acmeEabKid || !this.acmeEabHmacKey) return null;
797
1036
  const dir = join(dirname(this.certDir), `.openship-eab-${randomBytes(12).toString("hex")}`);
798
1037
  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
- }
1038
+ if (this.executor) await this.executor.mkdir(dir);
1039
+ else await fsMkdir(dir, { recursive: true, mode: 448 });
805
1040
  try {
1041
+ await this._chmod(dir, 448);
806
1042
  await this._writeFile(
807
1043
  path,
808
1044
  `eab-kid = ${this.acmeEabKid}
@@ -854,14 +1090,20 @@ eab-hmac-key = ${this.acmeEabHmacKey}
854
1090
  const hostRedirect = renderHostRedirect(route);
855
1091
  const locationBody = hostRedirect ? hostRedirect : "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
856
1092
  index index.html;
857
- try_files $uri $uri/ /index.html;` : `proxy_pass ${route.targetUrl};
1093
+ ${renderStaticTryFiles(route)}` : `proxy_pass ${route.targetUrl};
858
1094
  ${PROXY_HEADERS}`;
859
- const extraLocations = hostRedirect ? "" : `${renderRedirects(route)}${renderProxyLocations(route)}`;
860
- const serverHeaders = hostRedirect ? "" : renderServerHeaders(route);
1095
+ const urlShape = hostRedirect ? "" : renderUrlShape(route);
1096
+ const redirectRules = hostRedirect ? "" : renderRedirectRules(route, " ");
1097
+ const extraLocations = hostRedirect ? "" : renderProxyLocations(route, { httpsUpgrade: false });
1098
+ const upgradingExtraLocations = hostRedirect ? "" : renderProxyLocations(route, { httpsUpgrade: true });
1099
+ const serverHeaders = hostRedirect ? "" : renderServerHeaders(route, slug);
1100
+ const prelude = hostRedirect ? "" : renderHeaderMaps(route, slug);
861
1101
  const proxyOpts = renderProxyOptions(route.proxy, this.nginxVersion);
862
1102
  const proxyTlsOpts = renderProxyTlsOptions(route.proxy, this.nginxVersion);
1103
+ const preamble = isCloudFrontedHost(route.domain) ? `${cloudEdgeRealIpConf()}
1104
+ ${FORWARD_VARS}` : FORWARD_VARS;
863
1105
  const webhookLocation = route.webhookProxy && !hostRedirect ? `
864
- location /_openship/hooks/ {
1106
+ location ^~ ${WEBHOOK_LOCATION_PREFIX} {
865
1107
  proxy_pass ${route.webhookProxy};
866
1108
  proxy_set_header Host $host;
867
1109
  proxy_set_header X-Real-IP $remote_addr;
@@ -872,24 +1114,25 @@ eab-hmac-key = ${this.acmeEabHmacKey}
872
1114
  const edgeLua = luaSourceAvailable();
873
1115
  const luaProxy = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};
874
1116
  access_by_lua_file ${RULES_GUARD_PATH};` : "";
1117
+ const slashFallback = !hostRedirect && "staticRoot" in route && route.staticRoot && route.trailingSlash === true ? `
1118
+
1119
+ ${renderSlashFallback(route.staticRoot)}` : "";
875
1120
  const serveLocation = ` location / {
876
- ${locationBody}
877
- }`;
1121
+ ${redirectRules}${urlShape}${locationBody}
1122
+ }${slashFallback}`;
878
1123
  const redirectOrServeLocation = hostRedirect ? serveLocation : ` location / {
879
- if ($openship_redirect_https) {
880
- return 301 https://$server_name$request_uri;
881
- }
882
-
883
- ${locationBody}
884
- }`;
1124
+ ${HTTPS_UPGRADE}${redirectRules}${urlShape}${locationBody}
1125
+ }${slashFallback}`;
885
1126
  const sharedBody = `${serverHeaders}${proxyOpts}
886
1127
  ${webhookLocation}${extraLocations}`;
887
- const httpOnlyBlock = `# Auto-generated by Openship - do not edit manually
1128
+ const httpsUpgradeBody = `${serverHeaders}${proxyOpts}
1129
+ ${webhookLocation}${upgradingExtraLocations}`;
1130
+ const httpOnlyBlock = `${GENERATED_HEADER}
888
1131
  server {
889
1132
  listen 80;
890
1133
  server_name ${route.domain};
891
1134
 
892
- ${FORWARD_VARS}
1135
+ ${preamble}
893
1136
 
894
1137
  ${luaProxy}
895
1138
 
@@ -905,19 +1148,19 @@ ${serveLocation}
905
1148
  if (route.tls && await this.certsExist(route.domain)) {
906
1149
  const certPath = join(this.certDir, route.domain, "fullchain.pem");
907
1150
  const keyPath = join(this.certDir, route.domain, "privkey.pem");
908
- serverBlock = `# Auto-generated by Openship - do not edit manually
1151
+ serverBlock = `${GENERATED_HEADER}
909
1152
  server {
910
1153
  listen 80;
911
1154
  server_name ${route.domain};
912
1155
 
913
- ${FORWARD_VARS}
1156
+ ${preamble}
914
1157
 
915
1158
  ${luaProxy}
916
1159
 
917
1160
  ${ACME_CHALLENGE_LOCATION}
918
1161
 
919
1162
  ${EDGE_CHALLENGE_LOCATION}
920
- ${sharedBody}
1163
+ ${httpsUpgradeBody}
921
1164
  ${redirectOrServeLocation}
922
1165
  }
923
1166
 
@@ -925,7 +1168,7 @@ server {
925
1168
  listen 443 ssl;
926
1169
  server_name ${route.domain};
927
1170
 
928
- ${FORWARD_VARS}
1171
+ ${preamble}
929
1172
 
930
1173
  ${luaProxy}
931
1174
 
@@ -938,7 +1181,7 @@ ${serveLocation}
938
1181
  } else if (route.tls && route.terminatesTlsLocally) {
939
1182
  const bootstrap = await this.ensureBootstrapCert(route.domain);
940
1183
  bootstrapServed = !!bootstrap;
941
- serverBlock = bootstrap ? `# Auto-generated by Openship - do not edit manually
1184
+ serverBlock = bootstrap ? `${GENERATED_HEADER}
942
1185
  # openship-bootstrap-tls: ${route.domain} is serving a TEMPORARY self-signed
943
1186
  # certificate because its real certificate has not been issued yet. Openship
944
1187
  # replaces this file automatically once it lands. Do not adopt this material.
@@ -946,7 +1189,7 @@ server {
946
1189
  listen 80;
947
1190
  server_name ${route.domain};
948
1191
 
949
- ${FORWARD_VARS}
1192
+ ${preamble}
950
1193
 
951
1194
  ${luaProxy}
952
1195
 
@@ -961,7 +1204,7 @@ server {
961
1204
  listen 443 ssl;
962
1205
  server_name ${route.domain};
963
1206
 
964
- ${FORWARD_VARS}
1207
+ ${preamble}
965
1208
 
966
1209
  ${luaProxy}
967
1210
 
@@ -975,7 +1218,7 @@ ${serveLocation}
975
1218
  serverBlock = httpOnlyBlock;
976
1219
  }
977
1220
  const snapshot = await this._captureFile(configPath);
978
- await this._writeFile(configPath, serverBlock);
1221
+ await this._writeFile(configPath, `${prelude}${serverBlock}`);
979
1222
  try {
980
1223
  await this.reload();
981
1224
  } catch (err) {
@@ -991,6 +1234,112 @@ ${serveLocation}
991
1234
  routeStatePath(slug) {
992
1235
  return join(this.sitesDir, `${slug}.route.json`);
993
1236
  }
1237
+ /**
1238
+ * Re-emit every vhost an older generator wrote, from its own persisted RouteConfig.
1239
+ *
1240
+ * Why this exists: a fix to the EMITTED shape otherwise never reaches a running box.
1241
+ * `registerRoute` is the only writer, and the only things that call it are a deploy, a
1242
+ * domain save, a Retry, and cert issuance — so a project nobody redeploys keeps the vhost
1243
+ * it was first given for as long as it lives. That is how the Cloud-fronted `X-Real-IP`
1244
+ * block shipped and then applied to nothing: correct code, upgraded box, unchanged file.
1245
+ *
1246
+ * Cheap and idempotent by construction: a vhost already stamped at
1247
+ * {@link VHOST_GENERATION} is read and skipped, so the pass writes nothing and reloads
1248
+ * nothing once converged. Only the FIRST run after a bump does work.
1249
+ *
1250
+ * That first run is not free, and the release that introduced the marker is its worst
1251
+ * case: every conf written before it is unstamped, so a box with N routes reapplies all
1252
+ * N. `registerRoute` writes and reloads per route by design — a conf that fails
1253
+ * `openresty -t` is rolled back and reloaded on its own, which is why one bad route
1254
+ * cannot take the other N-1 down — so that run costs N whole-config tests and N reloads,
1255
+ * serially, on a deploy. Batching them behind a single test would trade that isolation
1256
+ * for the seconds it saves once per box, which is the wrong trade. What it does mean is
1257
+ * that a VHOST_GENERATION bump is a fleet-wide cost, not a free redeploy: bump it for a
1258
+ * fix that has to reach existing boxes, not to tidy the emitted text.
1259
+ *
1260
+ * Never throws and never aborts the sweep. One route whose sidecar is unreadable, or
1261
+ * whose re-render fails `openresty -t` (registerRoute rolls that file back on its own),
1262
+ * must not stop the rest — a half-converged edge with a reported failure is strictly
1263
+ * better than a wedged one. A route with no sidecar is left ALONE rather than
1264
+ * reconstructed from its conf: the scrape recovers only the primary target, so replaying
1265
+ * it would drop composite locations and the webhook proxy.
1266
+ */
1267
+ async reapplyStoredRoutes() {
1268
+ const repaired = [];
1269
+ const failed = [];
1270
+ let entries;
1271
+ try {
1272
+ entries = await this._lsSites();
1273
+ } catch (err) {
1274
+ return { scanned: 0, repaired, failed: [{ slug: "*", reason: safeErrorMessage(err) }] };
1275
+ }
1276
+ const slugs = entries.filter((name) => name.endsWith(".route.json")).map((name) => name.slice(0, -".route.json".length));
1277
+ const stale = await this._staleVhostSlugs().catch(() => null);
1278
+ for (const slug of slugs) {
1279
+ if (stale && !stale.has(slug)) continue;
1280
+ try {
1281
+ const conf = await this._readFile(join(this.sitesDir, `${slug}.conf`)).catch(() => null);
1282
+ if (conf === null) continue;
1283
+ const gen = readVhostGeneration(conf);
1284
+ if (gen !== null && gen >= VHOST_GENERATION) continue;
1285
+ const saved = JSON.parse(await this._readFile(this.routeStatePath(slug)));
1286
+ if (!saved?.domain) {
1287
+ failed.push({ slug, reason: "sidecar has no domain" });
1288
+ continue;
1289
+ }
1290
+ await this.registerRoute(saved);
1291
+ repaired.push(saved.domain);
1292
+ } catch (err) {
1293
+ failed.push({ slug, reason: safeErrorMessage(err) });
1294
+ }
1295
+ }
1296
+ return { scanned: slugs.length, repaired, failed };
1297
+ }
1298
+ /**
1299
+ * Names in the sites directory.
1300
+ *
1301
+ * Listing goes through the COMMAND channel, so on a container edge it must name the
1302
+ * path commands see: `sitesDir` is the HOST side of the bind mount and does not exist
1303
+ * inside the container. Same directory either way, and only basenames are used — the
1304
+ * reads that follow go back through the file channel at the host path.
1305
+ */
1306
+ async _lsSites() {
1307
+ if (!this.executor) return fsReaddir(this.sitesDir);
1308
+ const out = await this.executor.exec(`ls -1 ${sq(this._commandSitesDir())} 2>/dev/null || true`);
1309
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
1310
+ }
1311
+ /**
1312
+ * Slugs whose `.conf` does not carry the CURRENT generation marker — `null` when the
1313
+ * question can't be answered in one command and the caller should fall back to reading.
1314
+ *
1315
+ * `grep -L` on the whole directory, so the steady state (everything current) costs one
1316
+ * round trip instead of one per route. The pattern is anchored on both ends and built from
1317
+ * the same constant the writer emits, so `gen: 1` cannot pass for `gen: 10` and the two
1318
+ * halves cannot drift apart.
1319
+ */
1320
+ async _staleVhostSlugs() {
1321
+ if (!this.executor) return null;
1322
+ const dir = this._commandSitesDir();
1323
+ const out = await this.executor.exec(
1324
+ `grep -L ${sq(`^${GENERATION_MARKER}$`)} ${sq(dir)}/*.conf 2>/dev/null || true`
1325
+ );
1326
+ const slugs = /* @__PURE__ */ new Set();
1327
+ for (const line of out.split("\n")) {
1328
+ const name = line.trim().split("/").pop();
1329
+ if (name?.endsWith(".conf")) slugs.add(name.slice(0, -".conf".length));
1330
+ }
1331
+ return slugs;
1332
+ }
1333
+ /**
1334
+ * The sites directory as COMMANDS see it.
1335
+ *
1336
+ * On a container edge `sitesDir` is the HOST side of the bind mount and does not exist
1337
+ * inside the container, where `exec` runs. Same directory, two names; only basenames are
1338
+ * used, and the reads that follow go back through the file channel at the host path.
1339
+ */
1340
+ _commandSitesDir() {
1341
+ return this.containerEdge ? OPENRESTY_DEFAULT_PATHS.sitesDir : this.sitesDir;
1342
+ }
994
1343
  /**
995
1344
  * Recover the proxy tunables from a vhost we already wrote, for the legacy
996
1345
  * no-sidecar re-register below. Rewriting the conf without them would silently
@@ -1083,7 +1432,7 @@ ${serveLocation}
1083
1432
  const path2 = join(this.sitesDir, `${stem}.conf`);
1084
1433
  const conf = await this._readFile(path2).catch(() => "");
1085
1434
  if (!conf || !this.serverNamesIn(conf).includes(host)) continue;
1086
- if (conf.includes(`location ${EDGE_CHALLENGE_URL_PREFIX}`)) {
1435
+ if (conf.includes(`${EDGE_CHALLENGE_URL_PREFIX} {`)) {
1087
1436
  return { served: true, via: "existing-vhost" };
1088
1437
  }
1089
1438
  return {
@@ -1112,18 +1461,24 @@ ${serveLocation}
1112
1461
  /**
1113
1462
  * Provision a TLS certificate using certbot.
1114
1463
  *
1115
- * Runs `certbot certonly` in webroot mode using the ACME challenge
1116
- * directory served by OpenResty, then rewrites the config to include
1117
- * SSL and reloads.
1464
+ * HTTP-01 ONLY there is no DNS-01 path anywhere in this file, and the DNS credentials the
1465
+ * product stores are for record MANAGEMENT, not for challenges.
1466
+ *
1467
+ * The authenticator is `--standalone` on {@link ACME_HTTP01_PORT} (a loopback alt-port), with
1468
+ * the edge proxying `/.well-known/acme-challenge/` to it — see `ACME_CHALLENGE_LOCATION`. So
1469
+ * Let's Encrypt fetches the challenge over :80 from OpenResty, which forwards it to certbot's
1470
+ * transient listener. No port-80 fight, no webroot directory to keep in sync, and zero
1471
+ * downtime: certbot holds that port only while issuing.
1118
1472
  *
1119
- * Only --webroot is attempted. The previous --standalone fallback was
1120
- * dead code on any normal install: certbot --standalone binds to port
1121
- * 80 itself, but OpenResty already owns 80 on the same box, so the
1122
- * fallback would always fail with EADDRINUSE amplifying a transient
1123
- * --webroot failure into an immediate hard error. The caller
1124
- * (route-registration.ts) already wraps provisionCert in try/catch so
1125
- * a webroot failure becomes a "deploy continues on HTTP, retry from
1126
- * Domains tab" warning instead of a deploy abort.
1473
+ * THIS COMMENT USED TO DESCRIBE `--webroot`, and argued that `--standalone` was dead code
1474
+ * because "certbot --standalone binds to port 80 itself, but OpenResty already owns 80". That
1475
+ * was true of plain `--standalone`, and it is exactly the problem `--http-01-port` solves
1476
+ * the two sat contradicting each other in one file, with the doc describing a mechanism the
1477
+ * code below had stopped using. Left recorded because the stale version's reasoning is
1478
+ * persuasive enough to "fix" the code back.
1479
+ *
1480
+ * The caller (route-registration.ts) wraps this in try/catch, so a failure becomes a "deploy
1481
+ * continues on HTTP, retry from the Domains tab" warning rather than a deploy abort.
1127
1482
  */
1128
1483
  async provisionCert(domain, opts) {
1129
1484
  assertValidDomain(domain);
@@ -1155,7 +1510,15 @@ ${serveLocation}
1155
1510
  ...opts?.force ? ["--force-renewal"] : []
1156
1511
  ], opts?.onLog);
1157
1512
  } catch (err) {
1158
- throw new Error(summarizeCertbotFailure(this.redactAcmeSecrets(safeErrorMessage(err)), domain));
1513
+ const raw = this.redactAcmeSecrets(safeErrorMessage(err));
1514
+ let summary = summarizeCertbotFailure(raw, domain);
1515
+ if (isAcmePortBindFailure(raw) && this.executor) {
1516
+ const occupant = await probeListeningPort(this.executor, ACME_HTTP01_PORT).catch(() => null);
1517
+ if (occupant) {
1518
+ summary += ` Currently held by ${occupant.command}` + (occupant.pid !== null ? ` (pid ${occupant.pid})` : "") + (occupant.systemdUnit ? ` via ${occupant.systemdUnit}` : "") + `.`;
1519
+ }
1520
+ }
1521
+ throw new Error(summary);
1159
1522
  } finally {
1160
1523
  if (eabConfig) await this._rm(dirname(eabConfig)).catch(() => void 0);
1161
1524
  }
@@ -1619,12 +1982,15 @@ export {
1619
1982
  validateCertFor,
1620
1983
  isSafeCertPath,
1621
1984
  readDeclaredPair,
1985
+ VHOST_GENERATION,
1986
+ readVhostGeneration,
1622
1987
  renderProxyOptions,
1623
1988
  renderProxyTlsOptions,
1624
1989
  renderGeoEntries,
1625
1990
  acmeKeyArgs,
1626
1991
  assertValidChallengeToken,
1627
1992
  MANAGED_STATIC_BASE,
1993
+ isAcmePortBindFailure,
1628
1994
  summarizeCertbotFailure,
1629
1995
  NginxProvider
1630
1996
  };