openship 0.5.0 → 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 +26 -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-ZCOQJCZO.js → chunk-6ODUBKUB.js} +3 -3
- package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
- package/dist/{chunk-K2RPVGPN.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-V7BZMGOT.js → chunk-EMXI4UCU.js} +5 -5
- package/dist/{chunk-NNPEG6HM.js → chunk-EP2TNALB.js} +2 -2
- package/dist/{chunk-NXFIRK2Z.js → chunk-GUQ3QCJ7.js} +573 -459
- package/dist/chunk-I3DPMTSF.js +29 -0
- package/dist/{chunk-KZOBYJUW.js → chunk-IW2UKBVF.js} +3 -3
- package/dist/{chunk-YWMODKWL.js → chunk-IXM5245F.js} +165 -13
- package/dist/{chunk-KNTJ7PSF.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-UMQP7SYT.js → chunk-OTLSJYAC.js} +1316 -53
- package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
- package/dist/{chunk-JLKKYUZA.js → chunk-PSZSKEPA.js} +7 -7
- package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
- package/dist/{chunk-RB35YN3J.js → chunk-QEH4QUBX.js} +391 -63
- 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-6LKHXB6T.js → chunk-TWJCLD4W.js} +48 -13
- package/dist/{chunk-I6PSTQIR.js → chunk-VDDMADOF.js} +3 -3
- package/dist/{chunk-BS2AXYZX.js → chunk-VE2YOK4I.js} +626 -214
- package/dist/{chunk-6PIYRG5I.js → chunk-VZ2ZHPS2.js} +52 -42
- 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-BAJMY6KM.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-LOYMPU67.js → executor-JELARVKC.js} +8 -8
- package/dist/index.js +1170 -459
- 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 +23172 -7994
- 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/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 +112 -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-VRGE5MXH.js +0 -23
- package/dist/chunk-CUJXGK7R.js +0 -3167
- package/dist/chunk-EAAGSQXN.js +0 -377
- package/dist/cloud-3M2WZ624.js +0 -30
- package/dist/docker-2NGBRJFJ.js +0 -18
- package/dist/docker-edge-executor-5I4M5QT7.js +0 -13
- package/dist/edge-import-RIWNEIXQ.js +0 -47
- package/dist/ensure-container-edge-3NSYKHRX.js +0 -31
- package/dist/nginx-QE4DOM5J.js +0 -24
- package/dist/openresty-lua-BTAKECCF.js +0 -42
- package/dist/setup-KELALK7H.js +0 -20
|
@@ -1,25 +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,
|
|
19
34
|
resolveRedirectStatus,
|
|
20
35
|
safeErrorMessage,
|
|
21
36
|
sanitizeProxySettings
|
|
22
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-OTLSJYAC.js";
|
|
23
38
|
|
|
24
39
|
// ../../packages/adapters/src/infra/nginx.ts
|
|
25
40
|
import {
|
|
@@ -34,7 +49,7 @@ import {
|
|
|
34
49
|
import { execFile as cpExecFile } from "child_process";
|
|
35
50
|
import { createHash, randomBytes } from "crypto";
|
|
36
51
|
import { promisify } from "util";
|
|
37
|
-
import {
|
|
52
|
+
import { posix as edgePath } from "path";
|
|
38
53
|
|
|
39
54
|
// ../../packages/adapters/src/system/proxy/cert-material.ts
|
|
40
55
|
import { X509Certificate, createPrivateKey } from "crypto";
|
|
@@ -43,6 +58,15 @@ import { X509Certificate, createPrivateKey } from "crypto";
|
|
|
43
58
|
function containerCommand(container, command) {
|
|
44
59
|
return `docker exec ${sq(container)} sh -c ${sq(command)}`;
|
|
45
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
|
+
}
|
|
46
70
|
function resolveRef(ref) {
|
|
47
71
|
return typeof ref === "function" ? ref() : ref;
|
|
48
72
|
}
|
|
@@ -69,8 +93,34 @@ async function writeEdgeFileIn(inner, container, path, content) {
|
|
|
69
93
|
function edgeContainerExecutor(inner, container, opts) {
|
|
70
94
|
const inContainer = (command) => containerCommand(container, command);
|
|
71
95
|
const overrides = {
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
},
|
|
74
124
|
// A rename is a FILE op, so it follows the files, never the commands. Stated
|
|
75
125
|
// here rather than left to the Proxy's passthrough: if the inner executor has no
|
|
76
126
|
// `rename`, the fallback must still be the INNER shell (the host) — routing it
|
|
@@ -254,6 +304,7 @@ async function probeStaticOutput(executor, servedPath) {
|
|
|
254
304
|
}
|
|
255
305
|
|
|
256
306
|
// ../../packages/adapters/src/infra/nginx.ts
|
|
307
|
+
var { dirname, join } = edgePath;
|
|
257
308
|
var PROXY_HEADERS = `proxy_set_header Host $host;
|
|
258
309
|
proxy_set_header X-Real-IP $remote_addr;
|
|
259
310
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
@@ -301,27 +352,20 @@ function renderServerHeaders(route) {
|
|
|
301
352
|
return lines.length > 0 ? `
|
|
302
353
|
${lines.join("\n")}` : "";
|
|
303
354
|
}
|
|
304
|
-
function
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (p.proxyReadTimeout) lines.push(` proxy_read_timeout ${p.proxyReadTimeout};`);
|
|
310
|
-
if (p.proxySendTimeout) lines.push(` proxy_send_timeout ${p.proxySendTimeout};`);
|
|
311
|
-
if (p.clientBodyTimeout) lines.push(` client_body_timeout ${p.clientBodyTimeout};`);
|
|
312
|
-
if (p.proxyBuffering !== void 0) {
|
|
313
|
-
lines.push(` proxy_buffering ${p.proxyBuffering ? "on" : "off"};`);
|
|
314
|
-
}
|
|
315
|
-
if (p.gzip !== void 0) {
|
|
316
|
-
lines.push(` gzip ${p.gzip ? "on" : "off"};`);
|
|
317
|
-
if (p.gzip) {
|
|
318
|
-
lines.push(` gzip_types ${PROXY_GZIP_TYPES};`);
|
|
319
|
-
lines.push(` gzip_min_length 1024;`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
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};`);
|
|
322
360
|
return lines.length > 0 ? `
|
|
323
361
|
${lines.join("\n")}` : "";
|
|
324
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
|
+
}
|
|
325
369
|
function renderGeoEntries(whitelist) {
|
|
326
370
|
const entries = [
|
|
327
371
|
" default 0;",
|
|
@@ -336,6 +380,46 @@ function renderGeoEntries(whitelist) {
|
|
|
336
380
|
return entries;
|
|
337
381
|
}
|
|
338
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
|
+
}
|
|
339
423
|
var DOMAIN_RE = /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/;
|
|
340
424
|
function assertValidDomain(domain) {
|
|
341
425
|
if (!DOMAIN_RE.test(domain) || domain.length > 253) {
|
|
@@ -353,6 +437,12 @@ function assertValidUpstream(targetUrl) {
|
|
|
353
437
|
throw new Error(`Invalid proxy target (must be http/https URL): ${targetUrl}`);
|
|
354
438
|
}
|
|
355
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
|
+
}
|
|
356
446
|
var MANAGED_STATIC_BASE = "/opt/openship";
|
|
357
447
|
function assertValidStaticRoot(root, opts) {
|
|
358
448
|
assertNoNginxInjection(root, "static root");
|
|
@@ -369,50 +459,83 @@ function assertValidStaticRoot(root, opts) {
|
|
|
369
459
|
}
|
|
370
460
|
var execFileAsync = promisify(cpExecFile);
|
|
371
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) {
|
|
372
470
|
const text = output || "";
|
|
373
471
|
const pick = (re) => text.match(re)?.[0]?.replace(/\s+/g, " ").trim();
|
|
374
472
|
const detail = pick(/Detail:\s*[^\n]+/i);
|
|
375
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 ?? "");
|
|
376
476
|
let diagnosis;
|
|
377
477
|
if (/timeout during connect|connection refused|failed to connect|Timeout/i.test(text)) {
|
|
378
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.`;
|
|
379
479
|
} else if (/NXDOMAIN|no\s+(A|AAAA)\s+record|DNS problem|could not be resolved|no records? found/i.test(text)) {
|
|
380
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.`;
|
|
381
|
-
} else if (/\b52[56]\b|ssl handshake failed
|
|
481
|
+
} else if (/\b52[56]\b|ssl handshake failed/i.test(text)) {
|
|
382
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.`;
|
|
383
483
|
} else if (/404|invalid response|unauthorized|"?status"?:?\s*40\d/i.test(text)) {
|
|
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.`;
|
|
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.`;
|
|
385
485
|
} else if (/too many certificates|rateLimited|rate limit/i.test(text)) {
|
|
386
486
|
diagnosis = `Let's Encrypt rate limit reached for ${domain} \u2014 wait before retrying.`;
|
|
387
487
|
}
|
|
388
488
|
const parts = [diagnosis, detail, hint].filter(Boolean);
|
|
389
|
-
|
|
390
|
-
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
391
|
-
return lines.slice(-3).join(" \xB7 ") || `certbot failed to issue a certificate for ${domain}`;
|
|
489
|
+
return parts.length > 0 ? parts.join(" \u2014 ") : void 0;
|
|
392
490
|
}
|
|
393
491
|
var NginxProvider = class {
|
|
394
492
|
sitesDir;
|
|
395
493
|
acmeEmail;
|
|
494
|
+
acmeDirectoryUrl;
|
|
495
|
+
acmeEabKid;
|
|
496
|
+
acmeEabHmacKey;
|
|
497
|
+
acmeKeyType;
|
|
498
|
+
acmeCaBundle;
|
|
499
|
+
acmeTosAgreed;
|
|
396
500
|
certDir;
|
|
397
501
|
executor;
|
|
398
502
|
reloadCommand;
|
|
399
503
|
pinPaths;
|
|
400
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;
|
|
401
512
|
constructor(opts) {
|
|
513
|
+
assertValidAcmeOptions(opts);
|
|
402
514
|
this.sitesDir = opts.paths.sitesDir;
|
|
515
|
+
this.nginxVersion = opts.paths.nginxVersion;
|
|
403
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;
|
|
404
523
|
this.certDir = opts.certDir ?? DEFAULT_CERT_DIR;
|
|
405
524
|
this.executor = opts.executor ?? null;
|
|
406
525
|
this.containerEdge = opts.containerEdge ?? false;
|
|
407
526
|
this.reloadCommand = buildReloadCommand(opts.paths, { containerEdge: this.containerEdge });
|
|
408
527
|
this.pinPaths = opts.pinPaths ?? false;
|
|
528
|
+
this.challengeDir = opts.challengeDir ?? EDGE_CHALLENGE_DIR;
|
|
409
529
|
}
|
|
410
530
|
// ── File operation helpers (dual-path: local or remote) ──────────────
|
|
411
|
-
async _writeFile(path, content) {
|
|
531
|
+
async _writeFile(path, content, mode) {
|
|
412
532
|
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
413
533
|
if (this.executor) {
|
|
414
534
|
try {
|
|
415
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
|
+
}
|
|
416
539
|
if (this.executor.rename) {
|
|
417
540
|
await this.executor.rename(tmpPath, path);
|
|
418
541
|
} else {
|
|
@@ -425,7 +548,7 @@ var NginxProvider = class {
|
|
|
425
548
|
} else {
|
|
426
549
|
await fsMkdir(dirname(path), { recursive: true });
|
|
427
550
|
try {
|
|
428
|
-
await fsWriteFile(tmpPath, content, "utf-8");
|
|
551
|
+
await fsWriteFile(tmpPath, content, { encoding: "utf-8", mode });
|
|
429
552
|
await fsRename(tmpPath, path);
|
|
430
553
|
} catch (err) {
|
|
431
554
|
await fsRm(tmpPath).catch(() => void 0);
|
|
@@ -511,14 +634,22 @@ var NginxProvider = class {
|
|
|
511
634
|
* issued anything.
|
|
512
635
|
*
|
|
513
636
|
* Why a missing :443 block is a hard failure, not a soft one: an unmatched SNI
|
|
514
|
-
* falls through to the edge's
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
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.
|
|
522
653
|
*
|
|
523
654
|
* Deliberately NOT written under `certDir`: `certsExist()` / `readCertInfo()`
|
|
524
655
|
* read that path, so a self-signed cert there would short-circuit provisionCert
|
|
@@ -625,19 +756,65 @@ var NginxProvider = class {
|
|
|
625
756
|
* still sees the real cause.
|
|
626
757
|
*/
|
|
627
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;
|
|
628
761
|
if (!onLog || !this.executor) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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;
|
|
632
771
|
}
|
|
633
|
-
const full =
|
|
772
|
+
const full = `${command} ${commandArgs.map(sq).join(" ")}`;
|
|
634
773
|
let output = "";
|
|
635
774
|
const { code } = await this.executor.streamExec(full, (log) => {
|
|
636
775
|
output += log.message;
|
|
637
|
-
onLog(log.message);
|
|
776
|
+
onLog(this.redactAcmeSecrets(log.message));
|
|
638
777
|
});
|
|
639
|
-
if (code !== 0)
|
|
640
|
-
|
|
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;
|
|
641
818
|
}
|
|
642
819
|
async _captureFile(path) {
|
|
643
820
|
if (!await this._exists(path)) {
|
|
@@ -681,7 +858,8 @@ var NginxProvider = class {
|
|
|
681
858
|
${PROXY_HEADERS}`;
|
|
682
859
|
const extraLocations = hostRedirect ? "" : `${renderRedirects(route)}${renderProxyLocations(route)}`;
|
|
683
860
|
const serverHeaders = hostRedirect ? "" : renderServerHeaders(route);
|
|
684
|
-
const proxyOpts = renderProxyOptions(route.proxy);
|
|
861
|
+
const proxyOpts = renderProxyOptions(route.proxy, this.nginxVersion);
|
|
862
|
+
const proxyTlsOpts = renderProxyTlsOptions(route.proxy, this.nginxVersion);
|
|
685
863
|
const webhookLocation = route.webhookProxy && !hostRedirect ? `
|
|
686
864
|
location /_openship/hooks/ {
|
|
687
865
|
proxy_pass ${route.webhookProxy};
|
|
@@ -716,6 +894,8 @@ ${FORWARD_VARS}
|
|
|
716
894
|
${luaProxy}
|
|
717
895
|
|
|
718
896
|
${ACME_CHALLENGE_LOCATION}
|
|
897
|
+
|
|
898
|
+
${EDGE_CHALLENGE_LOCATION}
|
|
719
899
|
${sharedBody}
|
|
720
900
|
${serveLocation}
|
|
721
901
|
}
|
|
@@ -735,6 +915,8 @@ ${FORWARD_VARS}
|
|
|
735
915
|
${luaProxy}
|
|
736
916
|
|
|
737
917
|
${ACME_CHALLENGE_LOCATION}
|
|
918
|
+
|
|
919
|
+
${EDGE_CHALLENGE_LOCATION}
|
|
738
920
|
${sharedBody}
|
|
739
921
|
${redirectOrServeLocation}
|
|
740
922
|
}
|
|
@@ -748,7 +930,7 @@ ${FORWARD_VARS}
|
|
|
748
930
|
${luaProxy}
|
|
749
931
|
|
|
750
932
|
ssl_certificate ${certPath};
|
|
751
|
-
ssl_certificate_key ${keyPath}
|
|
933
|
+
ssl_certificate_key ${keyPath};${proxyTlsOpts}
|
|
752
934
|
${sharedBody}
|
|
753
935
|
${serveLocation}
|
|
754
936
|
}
|
|
@@ -769,6 +951,8 @@ ${FORWARD_VARS}
|
|
|
769
951
|
${luaProxy}
|
|
770
952
|
|
|
771
953
|
${ACME_CHALLENGE_LOCATION}
|
|
954
|
+
|
|
955
|
+
${EDGE_CHALLENGE_LOCATION}
|
|
772
956
|
${sharedBody}
|
|
773
957
|
${serveLocation}
|
|
774
958
|
}
|
|
@@ -782,7 +966,7 @@ ${FORWARD_VARS}
|
|
|
782
966
|
${luaProxy}
|
|
783
967
|
|
|
784
968
|
ssl_certificate ${bootstrap.certPath};
|
|
785
|
-
ssl_certificate_key ${bootstrap.keyPath}
|
|
969
|
+
ssl_certificate_key ${bootstrap.keyPath};${proxyTlsOpts}
|
|
786
970
|
${sharedBody}
|
|
787
971
|
${serveLocation}
|
|
788
972
|
}
|
|
@@ -807,6 +991,24 @@ ${serveLocation}
|
|
|
807
991
|
routeStatePath(slug) {
|
|
808
992
|
return join(this.sitesDir, `${slug}.route.json`);
|
|
809
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
|
+
}
|
|
810
1012
|
/**
|
|
811
1013
|
* Remove a route by deleting its conf + route-state files, then reload.
|
|
812
1014
|
*
|
|
@@ -836,6 +1038,76 @@ ${serveLocation}
|
|
|
836
1038
|
}
|
|
837
1039
|
await this._rm(this.bootstrapCertDir(domain)).catch(() => void 0);
|
|
838
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" };
|
|
1110
|
+
}
|
|
839
1111
|
// ── SSL ──────────────────────────────────────────────────────────────
|
|
840
1112
|
/**
|
|
841
1113
|
* Provision a TLS certificate using certbot.
|
|
@@ -861,8 +1133,10 @@ ${serveLocation}
|
|
|
861
1133
|
const validEmail = !!this.acmeEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.acmeEmail);
|
|
862
1134
|
const emailArgs = validEmail ? ["--email", this.acmeEmail] : ["--register-unsafely-without-email"];
|
|
863
1135
|
let certonlyOut = "";
|
|
1136
|
+
const eabConfig = await this.createEphemeralEabConfig();
|
|
864
1137
|
try {
|
|
865
1138
|
certonlyOut = await this._execCertbot([
|
|
1139
|
+
...eabConfig ? ["--config", eabConfig] : [],
|
|
866
1140
|
"certonly",
|
|
867
1141
|
"--standalone",
|
|
868
1142
|
"--http-01-port",
|
|
@@ -871,15 +1145,19 @@ ${serveLocation}
|
|
|
871
1145
|
domain,
|
|
872
1146
|
"-d",
|
|
873
1147
|
domain,
|
|
1148
|
+
...this.acmeDirectoryUrl ? ["--server", this.acmeDirectoryUrl] : [],
|
|
1149
|
+
...acmeKeyArgs(this.acmeKeyType),
|
|
874
1150
|
...emailArgs,
|
|
875
|
-
"--agree-tos",
|
|
1151
|
+
...this.acmeTosAgreed ? ["--agree-tos"] : [],
|
|
876
1152
|
"--non-interactive",
|
|
877
1153
|
// Forced reissue: certbot would otherwise print "not due for renewal"
|
|
878
1154
|
// (exit 0) when a lineage exists, leaving the stale cert in place.
|
|
879
1155
|
...opts?.force ? ["--force-renewal"] : []
|
|
880
1156
|
], opts?.onLog);
|
|
881
1157
|
} catch (err) {
|
|
882
|
-
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);
|
|
883
1161
|
}
|
|
884
1162
|
const slug = await this.resolveSlug(domain);
|
|
885
1163
|
const configPath = join(this.sitesDir, `${slug}.conf`);
|
|
@@ -892,9 +1170,15 @@ ${serveLocation}
|
|
|
892
1170
|
}
|
|
893
1171
|
try {
|
|
894
1172
|
const existing = await this._readFile(configPath);
|
|
1173
|
+
const scraped = this.scrapeProxySettings(existing);
|
|
895
1174
|
const targetMatch = existing.match(/proxy_pass\s+([^;]+);/);
|
|
896
1175
|
if (targetMatch) {
|
|
897
|
-
await this.registerRoute({
|
|
1176
|
+
await this.registerRoute({
|
|
1177
|
+
domain,
|
|
1178
|
+
targetUrl: targetMatch[1],
|
|
1179
|
+
tls: true,
|
|
1180
|
+
...scraped ? { proxy: scraped } : {}
|
|
1181
|
+
});
|
|
898
1182
|
return this.ensureIssued(domain, certonlyOut);
|
|
899
1183
|
}
|
|
900
1184
|
const rootMatch = existing.match(/root\s+([^;]+);/);
|
|
@@ -903,7 +1187,8 @@ ${serveLocation}
|
|
|
903
1187
|
domain,
|
|
904
1188
|
staticRoot: rootMatch[1],
|
|
905
1189
|
staticRootAdopted: true,
|
|
906
|
-
tls: true
|
|
1190
|
+
tls: true,
|
|
1191
|
+
...scraped ? { proxy: scraped } : {}
|
|
907
1192
|
});
|
|
908
1193
|
return this.ensureIssued(domain, certonlyOut);
|
|
909
1194
|
}
|
|
@@ -915,17 +1200,25 @@ ${serveLocation}
|
|
|
915
1200
|
* After a certbot run that DIDN'T throw, confirm a usable cert actually landed
|
|
916
1201
|
* at the expected path. certbot can exit 0 while producing nothing readable
|
|
917
1202
|
* there (a lineage written elsewhere, "not due for renewal", 0 domains
|
|
918
|
-
* authenticated) — a silent `{missing}` hides why.
|
|
919
|
-
*
|
|
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.
|
|
920
1210
|
*/
|
|
921
1211
|
async ensureIssued(domain, certbotOutput) {
|
|
922
1212
|
const result = await this.readCertInfo(domain);
|
|
923
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);
|
|
924
1217
|
const tail = certbotOutput.trim().slice(-1200);
|
|
925
1218
|
throw new Error(
|
|
926
|
-
|
|
1219
|
+
`certbot exited without an error but ${onDisk}.` + (diagnosis ? ` ${diagnosis}` : "") + (tail ? `
|
|
927
1220
|
|
|
928
|
-
certbot
|
|
1221
|
+
certbot said:
|
|
929
1222
|
${tail}` : "")
|
|
930
1223
|
);
|
|
931
1224
|
}
|
|
@@ -940,7 +1233,16 @@ ${tail}` : "")
|
|
|
940
1233
|
if (!await this.hasCertbotLineage(domain)) {
|
|
941
1234
|
return this.provisionCert(domain, { force: true });
|
|
942
1235
|
}
|
|
943
|
-
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
|
+
]);
|
|
944
1246
|
await this.reload();
|
|
945
1247
|
return this.readCertInfo(domain);
|
|
946
1248
|
}
|
|
@@ -950,9 +1252,31 @@ ${tail}` : "")
|
|
|
950
1252
|
* reads on every `renew`, so its presence is the authoritative answer — the cert
|
|
951
1253
|
* FILES existing is not (they can be adopted, or copied in by hand).
|
|
952
1254
|
*/
|
|
1255
|
+
renewalConfPath(domain) {
|
|
1256
|
+
return join(dirname(this.certDir), "renewal", `${domain}.conf`);
|
|
1257
|
+
}
|
|
953
1258
|
async hasCertbotLineage(domain) {
|
|
954
|
-
|
|
955
|
-
|
|
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;
|
|
956
1280
|
}
|
|
957
1281
|
/**
|
|
958
1282
|
* Install an operator-supplied or adopted certificate (no ACME). Validates the
|
|
@@ -1082,6 +1406,7 @@ ${tail}` : "")
|
|
|
1082
1406
|
try {
|
|
1083
1407
|
const freshPaths = await detectOpenRestyPaths(this.executor);
|
|
1084
1408
|
this.sitesDir = freshPaths.sitesDir;
|
|
1409
|
+
this.nginxVersion = freshPaths.nginxVersion ?? this.nginxVersion;
|
|
1085
1410
|
this.reloadCommand = buildReloadCommand(freshPaths, {
|
|
1086
1411
|
containerEdge: this.containerEdge
|
|
1087
1412
|
});
|
|
@@ -1295,7 +1620,10 @@ export {
|
|
|
1295
1620
|
isSafeCertPath,
|
|
1296
1621
|
readDeclaredPair,
|
|
1297
1622
|
renderProxyOptions,
|
|
1623
|
+
renderProxyTlsOptions,
|
|
1298
1624
|
renderGeoEntries,
|
|
1625
|
+
acmeKeyArgs,
|
|
1626
|
+
assertValidChallengeToken,
|
|
1299
1627
|
MANAGED_STATIC_BASE,
|
|
1300
1628
|
summarizeCertbotFailure,
|
|
1301
1629
|
NginxProvider
|