openship 0.4.7 → 0.5.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 (42) hide show
  1. package/README.md +1 -0
  2. package/dist/{bare-2HYDXQEH.js → bare-VRGE5MXH.js} +7 -7
  3. package/dist/{chunk-TGIINPVM.js → chunk-6LKHXB6T.js} +22 -7
  4. package/dist/{chunk-R5BHFFAV.js → chunk-6PIYRG5I.js} +56 -17
  5. package/dist/{chunk-IMBD5PZ5.js → chunk-BS2AXYZX.js} +430 -86
  6. package/dist/{chunk-SJIFLLX4.js → chunk-CUJXGK7R.js} +321 -85
  7. package/dist/{chunk-VFINNTUL.js → chunk-EAAGSQXN.js} +23 -8
  8. package/dist/{chunk-4Z5QO7N6.js → chunk-I6PSTQIR.js} +2 -2
  9. package/dist/{chunk-XFCDYZUQ.js → chunk-JLKKYUZA.js} +31 -49
  10. package/dist/{chunk-4TJC4JZQ.js → chunk-K2RPVGPN.js} +23 -4
  11. package/dist/{chunk-MZ2WEHHY.js → chunk-KNTJ7PSF.js} +1 -1
  12. package/dist/{chunk-L2VOGB2X.js → chunk-KZOBYJUW.js} +1 -1
  13. package/dist/{chunk-IPYPMI3S.js → chunk-NNPEG6HM.js} +1 -1
  14. package/dist/{chunk-UXWAVM4C.js → chunk-NXFIRK2Z.js} +398 -112
  15. package/dist/{chunk-FXTBHTWY.js → chunk-RB35YN3J.js} +271 -35
  16. package/dist/{chunk-6APULWSU.js → chunk-UMQP7SYT.js} +429 -66
  17. package/dist/{chunk-V6AN6XLZ.js → chunk-V7BZMGOT.js} +123 -45
  18. package/dist/{chunk-VDMQJHPI.js → chunk-YWMODKWL.js} +67 -4
  19. package/dist/{chunk-OXWDY22G.js → chunk-ZCOQJCZO.js} +1 -1
  20. package/dist/{cloud-2KXREXMO.js → cloud-3M2WZ624.js} +12 -12
  21. package/dist/{detect-2XSCFFTF.js → detect-BAJMY6KM.js} +7 -5
  22. package/dist/{docker-LODK5Y2S.js → docker-2NGBRJFJ.js} +9 -7
  23. package/dist/{docker-edge-executor-VHW3JPJS.js → docker-edge-executor-5I4M5QT7.js} +1 -1
  24. package/dist/{edge-import-ZQ4Y5HEA.js → edge-import-RIWNEIXQ.js} +21 -19
  25. package/dist/{ensure-container-edge-ED25XL3Z.js → ensure-container-edge-3NSYKHRX.js} +6 -6
  26. package/dist/{executor-YPT6RH6O.js → executor-LOYMPU67.js} +3 -3
  27. package/dist/index.js +309 -338
  28. package/dist/{nginx-XWJAOGS4.js → nginx-QE4DOM5J.js} +4 -4
  29. package/dist/{openresty-lua-4WLGDSER.js → openresty-lua-BTAKECCF.js} +2 -2
  30. package/dist/server/index.js +51705 -45369
  31. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  32. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  33. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  34. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  35. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  36. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  37. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  38. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  39. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  40. package/dist/server/migrations/meta/_journal.json +63 -0
  41. package/dist/{setup-2DMFIA74.js → setup-KELALK7H.js} +7 -7
  42. package/package.json +1 -1
@@ -13,12 +13,13 @@ import {
13
13
  buildReloadCommand,
14
14
  detectOpenRestyPaths,
15
15
  luaSourceAvailable
16
- } from "./chunk-VFINNTUL.js";
16
+ } from "./chunk-EAAGSQXN.js";
17
17
  import {
18
18
  PROXY_GZIP_TYPES,
19
+ resolveRedirectStatus,
19
20
  safeErrorMessage,
20
21
  sanitizeProxySettings
21
- } from "./chunk-6APULWSU.js";
22
+ } from "./chunk-UMQP7SYT.js";
22
23
 
23
24
  // ../../packages/adapters/src/infra/nginx.ts
24
25
  import {
@@ -31,7 +32,7 @@ import {
31
32
  stat as fsStat
32
33
  } from "fs/promises";
33
34
  import { execFile as cpExecFile } from "child_process";
34
- import { randomBytes } from "crypto";
35
+ import { createHash, randomBytes } from "crypto";
35
36
  import { promisify } from "util";
36
37
  import { dirname, join } from "path";
37
38
 
@@ -218,8 +219,13 @@ function validateCertFor(host, pems, source) {
218
219
  function isSafeCertPath(p) {
219
220
  return /^\/[A-Za-z0-9._/-]+$/.test(p) && !p.includes("..");
220
221
  }
222
+ var BOOTSTRAP_CERT_SEGMENT = "openship-bootstrap";
223
+ function isBootstrapCertPath(p) {
224
+ return p.split("/").includes(BOOTSTRAP_CERT_SEGMENT);
225
+ }
221
226
  async function readDeclaredPair(exec, certPath, keyPath, container) {
222
227
  if (!isSafeCertPath(certPath) || !isSafeCertPath(keyPath)) return null;
228
+ if (isBootstrapCertPath(certPath) || isBootstrapCertPath(keyPath)) return null;
223
229
  const certPem = await readMaybeInContainer(exec, certPath, container);
224
230
  const keyPem = await readMaybeInContainer(exec, keyPath, container);
225
231
  return certPem && keyPem ? { certPem, keyPem } : null;
@@ -251,10 +257,16 @@ async function probeStaticOutput(executor, servedPath) {
251
257
  var PROXY_HEADERS = `proxy_set_header Host $host;
252
258
  proxy_set_header X-Real-IP $remote_addr;
253
259
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
254
- proxy_set_header X-Forwarded-Proto $scheme;
260
+ proxy_set_header X-Forwarded-Proto $openship_fwd_proto;
255
261
  proxy_http_version 1.1;
256
262
  proxy_set_header Upgrade $http_upgrade;
257
263
  proxy_set_header Connection "upgrade";`;
264
+ var FORWARD_VARS = ` set $openship_fwd_proto $scheme;
265
+ set $openship_redirect_https 1;
266
+ if ($http_x_forwarded_proto = "https") {
267
+ set $openship_fwd_proto https;
268
+ set $openship_redirect_https 0;
269
+ }`;
258
270
  function renderProxyLocations(route) {
259
271
  if (!route.proxyLocations || route.proxyLocations.length === 0) return "";
260
272
  return route.proxyLocations.map((loc) => {
@@ -266,6 +278,12 @@ function renderProxyLocations(route) {
266
278
  }`;
267
279
  }).join("");
268
280
  }
281
+ function renderHostRedirect(route) {
282
+ const redirect = route.redirectHost;
283
+ if (!redirect?.target) return null;
284
+ assertValidDomain(redirect.target);
285
+ return `return ${resolveRedirectStatus(redirect.statusCode)} https://${redirect.target}$request_uri;`;
286
+ }
269
287
  function renderRedirects(route) {
270
288
  if (!route.redirects || route.redirects.length === 0) return "";
271
289
  return route.redirects.map(
@@ -360,6 +378,8 @@ function summarizeCertbotFailure(output, domain) {
360
378
  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
379
  } else if (/NXDOMAIN|no\s+(A|AAAA)\s+record|DNS problem|could not be resolved|no records? found/i.test(text)) {
362
380
  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.`;
381
+ } else if (/\b52[56]\b|ssl handshake failed|cloudflare/i.test(text)) {
382
+ 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
383
  } else if (/404|invalid response|unauthorized|"?status"?:?\s*40\d/i.test(text)) {
364
384
  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.`;
365
385
  } else if (/too many certificates|rateLimited|rate limit/i.test(text)) {
@@ -377,12 +397,14 @@ var NginxProvider = class {
377
397
  executor;
378
398
  reloadCommand;
379
399
  pinPaths;
400
+ containerEdge;
380
401
  constructor(opts) {
381
402
  this.sitesDir = opts.paths.sitesDir;
382
403
  this.acmeEmail = opts.acmeEmail;
383
404
  this.certDir = opts.certDir ?? DEFAULT_CERT_DIR;
384
405
  this.executor = opts.executor ?? null;
385
- this.reloadCommand = buildReloadCommand(opts.paths);
406
+ this.containerEdge = opts.containerEdge ?? false;
407
+ this.reloadCommand = buildReloadCommand(opts.paths, { containerEdge: this.containerEdge });
386
408
  this.pinPaths = opts.pinPaths ?? false;
387
409
  }
388
410
  // ── File operation helpers (dual-path: local or remote) ──────────────
@@ -433,7 +455,7 @@ var NginxProvider = class {
433
455
  await this.executor.rm(path);
434
456
  } else {
435
457
  try {
436
- await fsRm(path);
458
+ await fsRm(path, { recursive: true, force: true });
437
459
  } catch {
438
460
  }
439
461
  }
@@ -476,6 +498,89 @@ var NginxProvider = class {
476
498
  await this._rm(staging).catch(() => void 0);
477
499
  }
478
500
  }
501
+ /**
502
+ * Where a domain's temporary self-signed cert lives — a SIBLING of certbot's
503
+ * `live/`, deliberately not inside it. See {@link ensureBootstrapCert}.
504
+ */
505
+ bootstrapCertDir(domain) {
506
+ return join(dirname(this.certDir), BOOTSTRAP_CERT_SEGMENT, domain);
507
+ }
508
+ /**
509
+ * Ensure a self-signed placeholder cert exists for `domain`, so a route whose TLS
510
+ * we terminate can ALWAYS complete a TLS handshake — including before certbot has
511
+ * issued anything.
512
+ *
513
+ * Why a missing :443 block is a hard failure, not a soft one: an unmatched SNI
514
+ * falls through to the edge's `ssl_reject_handshake` default (correct — it stops
515
+ * unrouted hosts being cross-served someone else's cert). But that means the
516
+ * origin REFUSES the handshake for a domain we do route. Behind Cloudflare's
517
+ * proxy every visitor sees error 525, and issuance deadlocks: Cloudflare answers
518
+ * the HTTP-01 challenge with a 301 to https, then can't reach an origin that
519
+ * refuses TLS, so the challenge fails and the vhost stays HTTP-only — forever
520
+ * (#308). A placeholder breaks the loop: Cloudflare "Full" accepts it, the
521
+ * challenge completes, and the real cert takes over.
522
+ *
523
+ * Deliberately NOT written under `certDir`: `certsExist()` / `readCertInfo()`
524
+ * read that path, so a self-signed cert there would short-circuit provisionCert
525
+ * and report an untrusted cert as `active`, with the renewal scheduler and the
526
+ * verify-pending sweep both satisfied. Keeping it in a sibling dir means cert
527
+ * STATUS is untouched — the domain still reads provisioning/error and keeps
528
+ * retrying until a real cert lands.
529
+ *
530
+ * Returns null when nothing could be produced (no `openssl` on the executor, exec
531
+ * failure); the caller falls back to an HTTP-only vhost, exactly as before.
532
+ * Never throws — a placeholder must not be able to fail a deploy.
533
+ */
534
+ async ensureBootstrapCert(domain) {
535
+ const dir = this.bootstrapCertDir(domain);
536
+ const certPath = join(dir, "fullchain.pem");
537
+ const keyPath = join(dir, "privkey.pem");
538
+ try {
539
+ if (await this._exists(certPath) && await this._exists(keyPath)) {
540
+ return { certPath, keyPath };
541
+ }
542
+ } catch {
543
+ return null;
544
+ }
545
+ const staging = `${dir}.staging-${process.pid}-${randomBytes(4).toString("hex")}`;
546
+ try {
547
+ await this._mkdir(staging);
548
+ await this._exec("openssl", [
549
+ "req",
550
+ "-x509",
551
+ "-newkey",
552
+ "rsa:2048",
553
+ "-nodes",
554
+ "-keyout",
555
+ join(staging, "privkey.pem"),
556
+ "-out",
557
+ join(staging, "fullchain.pem"),
558
+ "-days",
559
+ "3650",
560
+ "-subj",
561
+ `/CN=${domain}`,
562
+ "-addext",
563
+ `subjectAltName=DNS:${domain}`
564
+ ]);
565
+ await this._mkdir(dir);
566
+ if (this.executor) {
567
+ await this.executor.exec(
568
+ `mv -f ${sq(join(staging, "fullchain.pem"))} ${sq(join(staging, "privkey.pem"))} ${sq(dir)}/`
569
+ );
570
+ } else {
571
+ await fsRename(join(staging, "fullchain.pem"), certPath);
572
+ await fsRename(join(staging, "privkey.pem"), keyPath);
573
+ }
574
+ return { certPath, keyPath };
575
+ } catch (err) {
576
+ console.warn(
577
+ `[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)}`
578
+ );
579
+ return null;
580
+ } finally {
581
+ await this._rm(staging).catch(() => void 0);
582
+ }
583
+ }
479
584
  async _exec(command, args = []) {
480
585
  if (this.executor) {
481
586
  const full = args.length ? `${command} ${args.map(sq).join(" ")}` : command;
@@ -562,34 +667,61 @@ var NginxProvider = class {
562
667
  async registerRoute(route) {
563
668
  assertValidDomain(route.domain);
564
669
  await this._mkdir(this.sitesDir);
565
- const slug = this.domainSlug(route.domain);
670
+ const slug = await this.resolveSlug(route.domain);
566
671
  const configPath = join(this.sitesDir, `${slug}.conf`);
567
672
  if ("staticRoot" in route && route.staticRoot) {
568
673
  assertValidStaticRoot(route.staticRoot, { adopted: route.staticRootAdopted });
569
674
  } else {
570
675
  assertValidUpstream(route.targetUrl);
571
676
  }
572
- const locationBody = "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
677
+ const hostRedirect = renderHostRedirect(route);
678
+ const locationBody = hostRedirect ? hostRedirect : "staticRoot" in route && route.staticRoot ? `root ${route.staticRoot};
573
679
  index index.html;
574
680
  try_files $uri $uri/ /index.html;` : `proxy_pass ${route.targetUrl};
575
681
  ${PROXY_HEADERS}`;
576
- const extraLocations = `${renderRedirects(route)}${renderProxyLocations(route)}`;
577
- const serverHeaders = renderServerHeaders(route);
682
+ const extraLocations = hostRedirect ? "" : `${renderRedirects(route)}${renderProxyLocations(route)}`;
683
+ const serverHeaders = hostRedirect ? "" : renderServerHeaders(route);
578
684
  const proxyOpts = renderProxyOptions(route.proxy);
579
- const webhookLocation = route.webhookProxy ? `
685
+ const webhookLocation = route.webhookProxy && !hostRedirect ? `
580
686
  location /_openship/hooks/ {
581
687
  proxy_pass ${route.webhookProxy};
582
688
  proxy_set_header Host $host;
583
689
  proxy_set_header X-Real-IP $remote_addr;
584
690
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
585
- proxy_set_header X-Forwarded-Proto $scheme;
691
+ proxy_set_header X-Forwarded-Proto $openship_fwd_proto;
586
692
  }
587
693
  ` : "";
588
694
  const edgeLua = luaSourceAvailable();
589
- const luaLogOnly = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};` : "";
590
695
  const luaProxy = edgeLua ? ` log_by_lua_file ${LUA_LOGGER_PATH};
591
696
  access_by_lua_file ${RULES_GUARD_PATH};` : "";
697
+ const serveLocation = ` location / {
698
+ ${locationBody}
699
+ }`;
700
+ const redirectOrServeLocation = hostRedirect ? serveLocation : ` location / {
701
+ if ($openship_redirect_https) {
702
+ return 301 https://$server_name$request_uri;
703
+ }
704
+
705
+ ${locationBody}
706
+ }`;
707
+ const sharedBody = `${serverHeaders}${proxyOpts}
708
+ ${webhookLocation}${extraLocations}`;
709
+ const httpOnlyBlock = `# Auto-generated by Openship - do not edit manually
710
+ server {
711
+ listen 80;
712
+ server_name ${route.domain};
713
+
714
+ ${FORWARD_VARS}
715
+
716
+ ${luaProxy}
717
+
718
+ ${ACME_CHALLENGE_LOCATION}
719
+ ${sharedBody}
720
+ ${serveLocation}
721
+ }
722
+ `;
592
723
  let serverBlock;
724
+ let bootstrapServed = false;
593
725
  if (route.tls && await this.certsExist(route.domain)) {
594
726
  const certPath = join(this.certDir, route.domain, "fullchain.pem");
595
727
  const keyPath = join(this.certDir, route.domain, "privkey.pem");
@@ -598,45 +730,65 @@ server {
598
730
  listen 80;
599
731
  server_name ${route.domain};
600
732
 
601
- ${luaLogOnly}
733
+ ${FORWARD_VARS}
602
734
 
603
- ${ACME_CHALLENGE_LOCATION}
735
+ ${luaProxy}
604
736
 
605
- location / {
606
- return 301 https://$server_name$request_uri;
607
- }
737
+ ${ACME_CHALLENGE_LOCATION}
738
+ ${sharedBody}
739
+ ${redirectOrServeLocation}
608
740
  }
609
741
 
610
742
  server {
611
743
  listen 443 ssl;
612
744
  server_name ${route.domain};
613
745
 
746
+ ${FORWARD_VARS}
747
+
614
748
  ${luaProxy}
615
749
 
616
750
  ssl_certificate ${certPath};
617
751
  ssl_certificate_key ${keyPath};
618
- ${serverHeaders}${proxyOpts}
619
- ${webhookLocation}${extraLocations}
620
- location / {
621
- ${locationBody}
622
- }
752
+ ${sharedBody}
753
+ ${serveLocation}
623
754
  }
624
755
  `;
625
- } else {
626
- serverBlock = `# Auto-generated by Openship - do not edit manually
756
+ } else if (route.tls && route.terminatesTlsLocally) {
757
+ const bootstrap = await this.ensureBootstrapCert(route.domain);
758
+ bootstrapServed = !!bootstrap;
759
+ serverBlock = bootstrap ? `# Auto-generated by Openship - do not edit manually
760
+ # openship-bootstrap-tls: ${route.domain} is serving a TEMPORARY self-signed
761
+ # certificate because its real certificate has not been issued yet. Openship
762
+ # replaces this file automatically once it lands. Do not adopt this material.
627
763
  server {
628
764
  listen 80;
629
765
  server_name ${route.domain};
630
766
 
767
+ ${FORWARD_VARS}
768
+
631
769
  ${luaProxy}
632
- ${serverHeaders}${proxyOpts}
770
+
633
771
  ${ACME_CHALLENGE_LOCATION}
634
- ${webhookLocation}${extraLocations}
635
- location / {
636
- ${locationBody}
637
- }
772
+ ${sharedBody}
773
+ ${serveLocation}
638
774
  }
639
- `;
775
+
776
+ server {
777
+ listen 443 ssl;
778
+ server_name ${route.domain};
779
+
780
+ ${FORWARD_VARS}
781
+
782
+ ${luaProxy}
783
+
784
+ ssl_certificate ${bootstrap.certPath};
785
+ ssl_certificate_key ${bootstrap.keyPath};
786
+ ${sharedBody}
787
+ ${serveLocation}
788
+ }
789
+ ` : httpOnlyBlock;
790
+ } else {
791
+ serverBlock = httpOnlyBlock;
640
792
  }
641
793
  const snapshot = await this._captureFile(configPath);
642
794
  await this._writeFile(configPath, serverBlock);
@@ -647,6 +799,9 @@ ${webhookLocation}${extraLocations}
647
799
  await this.reload().catch(() => void 0);
648
800
  throw err;
649
801
  }
802
+ if (!bootstrapServed) {
803
+ await this._rm(this.bootstrapCertDir(route.domain)).catch(() => void 0);
804
+ }
650
805
  await this._writeFile(this.routeStatePath(slug), JSON.stringify(route)).catch(() => void 0);
651
806
  }
652
807
  routeStatePath(slug) {
@@ -664,7 +819,7 @@ ${webhookLocation}${extraLocations}
664
819
  */
665
820
  async removeRoute(domain) {
666
821
  assertValidDomain(domain);
667
- const slug = this.domainSlug(domain);
822
+ const slug = await this.resolveSlug(domain);
668
823
  const configPath = join(this.sitesDir, `${slug}.conf`);
669
824
  const statePath = this.routeStatePath(slug);
670
825
  const confSnapshot = await this._captureFile(configPath);
@@ -679,6 +834,7 @@ ${webhookLocation}${extraLocations}
679
834
  await this.reload().catch(() => void 0);
680
835
  throw err;
681
836
  }
837
+ await this._rm(this.bootstrapCertDir(domain)).catch(() => void 0);
682
838
  }
683
839
  // ── SSL ──────────────────────────────────────────────────────────────
684
840
  /**
@@ -725,7 +881,7 @@ ${webhookLocation}${extraLocations}
725
881
  } catch (err) {
726
882
  throw new Error(summarizeCertbotFailure(safeErrorMessage(err), domain));
727
883
  }
728
- const slug = this.domainSlug(domain);
884
+ const slug = await this.resolveSlug(domain);
729
885
  const configPath = join(this.sitesDir, `${slug}.conf`);
730
886
  try {
731
887
  const state = await this._readFile(this.routeStatePath(slug));
@@ -816,7 +972,7 @@ ${tail}` : "")
816
972
  const { expiresAt } = candidate.cert;
817
973
  const dir = join(this.certDir, domain);
818
974
  await this.stageCertDir(dir, cert);
819
- const slug = this.domainSlug(domain);
975
+ const slug = await this.resolveSlug(domain);
820
976
  try {
821
977
  const state = await this._readFile(this.routeStatePath(slug));
822
978
  const saved = JSON.parse(state);
@@ -832,9 +988,87 @@ ${tail}` : "")
832
988
  };
833
989
  }
834
990
  // ── Helpers ──────────────────────────────────────────────────────────
991
+ /**
992
+ * Filename stem for a domain. NOT injective — see `resolveSlug`.
993
+ *
994
+ * Kept exactly as-is on purpose: it names every vhost file on every existing
995
+ * install, so changing it would rename the lot and leave the old names behind
996
+ * still serving. `resolveSlug` handles the collisions instead.
997
+ */
835
998
  domainSlug(domain) {
836
999
  return domain.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-");
837
1000
  }
1001
+ /** Deterministic per-domain disambiguator for a slug collision. */
1002
+ slugSuffix(domain) {
1003
+ return createHash("sha1").update(domain).digest("hex").slice(0, 8);
1004
+ }
1005
+ /** Every `server_name` token declared in a vhost body. */
1006
+ serverNamesIn(conf) {
1007
+ const names = [];
1008
+ for (const m of conf.matchAll(/server_name\s+([^;]+);/g)) {
1009
+ for (const tok of m[1].trim().split(/\s+/)) {
1010
+ if (tok) names.push(tok.toLowerCase().replace(/\.$/, ""));
1011
+ }
1012
+ }
1013
+ return names;
1014
+ }
1015
+ /**
1016
+ * The slug THIS domain's files live under.
1017
+ *
1018
+ * `domainSlug` folds every non-alphanumeric to `-`, so a dot and a literal dash
1019
+ * collapse together and two different, separately-claimable hostnames land on
1020
+ * one filename:
1021
+ *
1022
+ * staging.app.example.com -> staging-app-example-com
1023
+ * staging-app.example.com -> staging-app-example-com
1024
+ *
1025
+ * Both are ordinary hostnames and can belong to different projects — even
1026
+ * different orgs, since one edge fronts the whole box. Whichever registered last
1027
+ * silently overwrote the other's vhost, and the loser's traffic fell through to
1028
+ * `default_server`: a silent outage of somebody else's domain. `removeRoute` on
1029
+ * either one deleted the shared file and took out both.
1030
+ *
1031
+ * Resolution, in this order, and the order is the whole correctness argument:
1032
+ *
1033
+ * 1. A disambiguated file for this domain already exists → keep using it.
1034
+ * MUST come first. If the incumbent is later removed, step 3 would hand
1035
+ * this domain the now-free base name while its real conf stayed on disk
1036
+ * under the suffixed name — two files serving one hostname, which is the
1037
+ * exact orphan class this whole change exists to remove.
1038
+ * 2. The base file exists and does NOT list this domain in its `server_name`
1039
+ * → it is someone else's. Take a suffixed name; the incumbent is untouched.
1040
+ * 3. Otherwise the base name is ours (free, or already ours).
1041
+ *
1042
+ * Containment, not equality, in step 2 — and the reason is narrower than it
1043
+ * looks. Everything WE write carries exactly one `server_name` (see the server
1044
+ * blocks above), and a takeover fans out one `registerRoute` per hostname
1045
+ * (`registerImportedSites`), so no Openship-managed conf ever lists two. The
1046
+ * only file that can is one the operator placed in `sites-enabled` themselves.
1047
+ *
1048
+ * For that file, reusing it is the lesser evil, not a free win: we rewrite it
1049
+ * with a single `server_name`, so a sibling hostname it also served stops being
1050
+ * served. The alternative is worse — a second vhost answering a name the first
1051
+ * one already claims makes OpenResty warn "conflicting server name" and silently
1052
+ * keep the FIRST, so the route we were asked to create would not take effect at
1053
+ * all. Losing the sibling is at least the outcome the caller asked for.
1054
+ */
1055
+ async resolveSlug(domain) {
1056
+ const base = this.domainSlug(domain);
1057
+ const suffixed = `${base}-${this.slugSuffix(domain)}`;
1058
+ const host = domain.toLowerCase().replace(/\.$/, "");
1059
+ if (await this._exists(join(this.sitesDir, `${suffixed}.conf`)).catch(() => false)) {
1060
+ return suffixed;
1061
+ }
1062
+ const basePath = join(this.sitesDir, `${base}.conf`);
1063
+ if (await this._exists(basePath).catch(() => false)) {
1064
+ const conf = await this._readFile(basePath).catch(() => "");
1065
+ if (conf) {
1066
+ const names = this.serverNamesIn(conf);
1067
+ if (names.length > 0 && !names.includes(host)) return suffixed;
1068
+ }
1069
+ }
1070
+ return base;
1071
+ }
838
1072
  /**
839
1073
  * Re-detect OpenResty paths and reload.
840
1074
  *
@@ -848,7 +1082,9 @@ ${tail}` : "")
848
1082
  try {
849
1083
  const freshPaths = await detectOpenRestyPaths(this.executor);
850
1084
  this.sitesDir = freshPaths.sitesDir;
851
- this.reloadCommand = buildReloadCommand(freshPaths);
1085
+ this.reloadCommand = buildReloadCommand(freshPaths, {
1086
+ containerEdge: this.containerEdge
1087
+ });
852
1088
  } catch {
853
1089
  }
854
1090
  }