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