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.
- package/README.md +1 -0
- package/dist/{bare-2HYDXQEH.js → bare-VRGE5MXH.js} +7 -7
- package/dist/{chunk-TGIINPVM.js → chunk-6LKHXB6T.js} +22 -7
- package/dist/{chunk-R5BHFFAV.js → chunk-6PIYRG5I.js} +56 -17
- package/dist/{chunk-IMBD5PZ5.js → chunk-BS2AXYZX.js} +430 -86
- package/dist/{chunk-SJIFLLX4.js → chunk-CUJXGK7R.js} +321 -85
- package/dist/{chunk-VFINNTUL.js → chunk-EAAGSQXN.js} +23 -8
- package/dist/{chunk-4Z5QO7N6.js → chunk-I6PSTQIR.js} +2 -2
- package/dist/{chunk-XFCDYZUQ.js → chunk-JLKKYUZA.js} +31 -49
- package/dist/{chunk-4TJC4JZQ.js → chunk-K2RPVGPN.js} +23 -4
- package/dist/{chunk-MZ2WEHHY.js → chunk-KNTJ7PSF.js} +1 -1
- package/dist/{chunk-L2VOGB2X.js → chunk-KZOBYJUW.js} +1 -1
- package/dist/{chunk-IPYPMI3S.js → chunk-NNPEG6HM.js} +1 -1
- package/dist/{chunk-UXWAVM4C.js → chunk-NXFIRK2Z.js} +398 -112
- package/dist/{chunk-FXTBHTWY.js → chunk-RB35YN3J.js} +271 -35
- package/dist/{chunk-6APULWSU.js → chunk-UMQP7SYT.js} +429 -66
- package/dist/{chunk-V6AN6XLZ.js → chunk-V7BZMGOT.js} +123 -45
- package/dist/{chunk-VDMQJHPI.js → chunk-YWMODKWL.js} +67 -4
- package/dist/{chunk-OXWDY22G.js → chunk-ZCOQJCZO.js} +1 -1
- package/dist/{cloud-2KXREXMO.js → cloud-3M2WZ624.js} +12 -12
- package/dist/{detect-2XSCFFTF.js → detect-BAJMY6KM.js} +7 -5
- package/dist/{docker-LODK5Y2S.js → docker-2NGBRJFJ.js} +9 -7
- package/dist/{docker-edge-executor-VHW3JPJS.js → docker-edge-executor-5I4M5QT7.js} +1 -1
- package/dist/{edge-import-ZQ4Y5HEA.js → edge-import-RIWNEIXQ.js} +21 -19
- package/dist/{ensure-container-edge-ED25XL3Z.js → ensure-container-edge-3NSYKHRX.js} +6 -6
- package/dist/{executor-YPT6RH6O.js → executor-LOYMPU67.js} +3 -3
- package/dist/index.js +309 -338
- package/dist/{nginx-XWJAOGS4.js → nginx-QE4DOM5J.js} +4 -4
- package/dist/{openresty-lua-4WLGDSER.js → openresty-lua-BTAKECCF.js} +2 -2
- package/dist/server/index.js +51705 -45369
- package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
- package/dist/server/migrations/0074_project_compose_path.sql +1 -0
- package/dist/server/migrations/0075_service_command_argv.sql +1 -0
- package/dist/server/migrations/0076_rollback_retention.sql +17 -0
- package/dist/server/migrations/0077_domain_redirect.sql +13 -0
- package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
- package/dist/server/migrations/0079_project_readiness.sql +18 -0
- package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
- package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
- package/dist/server/migrations/meta/_journal.json +63 -0
- package/dist/{setup-2DMFIA74.js → setup-KELALK7H.js} +7 -7
- package/package.json +1 -1
|
@@ -13,12 +13,13 @@ import {
|
|
|
13
13
|
buildReloadCommand,
|
|
14
14
|
detectOpenRestyPaths,
|
|
15
15
|
luaSourceAvailable
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-EAAGSQXN.js";
|
|
17
17
|
import {
|
|
18
18
|
PROXY_GZIP_TYPES,
|
|
19
|
+
resolveRedirectStatus,
|
|
19
20
|
safeErrorMessage,
|
|
20
21
|
sanitizeProxySettings
|
|
21
|
-
} from "./chunk-
|
|
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 $
|
|
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.
|
|
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.
|
|
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
|
|
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 $
|
|
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
|
-
${
|
|
733
|
+
${FORWARD_VARS}
|
|
602
734
|
|
|
603
|
-
${
|
|
735
|
+
${luaProxy}
|
|
604
736
|
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
${
|
|
619
|
-
${
|
|
620
|
-
location / {
|
|
621
|
-
${locationBody}
|
|
622
|
-
}
|
|
752
|
+
${sharedBody}
|
|
753
|
+
${serveLocation}
|
|
623
754
|
}
|
|
624
755
|
`;
|
|
625
|
-
} else {
|
|
626
|
-
|
|
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
|
-
|
|
770
|
+
|
|
633
771
|
${ACME_CHALLENGE_LOCATION}
|
|
634
|
-
${
|
|
635
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|