home-hosted 0.7.2 → 0.7.4
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/AGENTS.md +18 -12
- package/README.md +22 -35
- package/dist/cli.js +1238 -154
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +3 -2
- package/docs/REVERSE_PROXY.md +91 -10
- package/docs/UI_CREATION.md +16 -1
- package/package.json +2 -2
- package/uis/stock/dist/assets/{index-UtOMDd0A.js → index-DoDrajgK.js} +7 -7
- package/uis/stock/dist/index.html +1 -1
- package/uis/stock/dist/ui.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -6,14 +6,14 @@ import path from "node:path";
|
|
|
6
6
|
import readline from "node:readline";
|
|
7
7
|
import { exec, execFile, spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import os from "node:os";
|
|
9
|
-
import { DetailedError } from "@namesmt/utils";
|
|
10
9
|
import { describeRoute, openAPIRouteHandler, resolver, validator } from "hono-openapi";
|
|
11
10
|
import { createFactory } from "hono/factory";
|
|
12
|
-
import { type } from "arktype";
|
|
13
|
-
import crypto, { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
|
|
14
|
-
import { Buffer as Buffer$1 } from "node:buffer";
|
|
15
11
|
import { LogLevels, createConsola } from "consola";
|
|
16
12
|
import { isDevelopment } from "std-env";
|
|
13
|
+
import { DetailedError } from "@namesmt/utils";
|
|
14
|
+
import { type } from "arktype";
|
|
15
|
+
import crypto, { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
|
|
16
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
17
17
|
import { streamSSE } from "hono/streaming";
|
|
18
18
|
import { autoRetry } from "@grammyjs/auto-retry";
|
|
19
19
|
import { Bot } from "grammy";
|
|
@@ -350,6 +350,65 @@ var init_version = __esmMin((() => {
|
|
|
350
350
|
cached = null;
|
|
351
351
|
}));
|
|
352
352
|
//#endregion
|
|
353
|
+
//#region src/helpers/factory.ts
|
|
354
|
+
var appFactory;
|
|
355
|
+
var init_factory = __esmMin((() => {
|
|
356
|
+
appFactory = createFactory();
|
|
357
|
+
}));
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/helpers/logger.ts
|
|
360
|
+
var logger;
|
|
361
|
+
var init_logger = __esmMin((() => {
|
|
362
|
+
logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
|
|
363
|
+
}));
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/middleware/loopback.ts
|
|
366
|
+
/** Client address helpers shared by the auth guard and the setup routes. */
|
|
367
|
+
function requestIp(c) {
|
|
368
|
+
return c.req.raw?.ip ?? null;
|
|
369
|
+
}
|
|
370
|
+
function isLoopback(address) {
|
|
371
|
+
if (!address) return false;
|
|
372
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
373
|
+
}
|
|
374
|
+
function isLoopbackRequest(c) {
|
|
375
|
+
return isLoopback(requestIp(c));
|
|
376
|
+
}
|
|
377
|
+
var init_loopback = __esmMin((() => {}));
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/api/acme.ts
|
|
380
|
+
/**
|
|
381
|
+
* The ACMEProxy endpoint the reverse-proxy engine calls for a DNS-01 challenge.
|
|
382
|
+
*
|
|
383
|
+
* Deliberately outside `/api` — the engine is not a session — and outside `/_hh`,
|
|
384
|
+
* which carries the panel's own local token. Two things guard it: only a loopback
|
|
385
|
+
* peer is answered, and the engine must present the Basic credentials the panel
|
|
386
|
+
* generated for it. The engine never holds DNS credentials; this endpoint writes
|
|
387
|
+
* the record through the workspace account a route names.
|
|
388
|
+
*/
|
|
389
|
+
function createAcmeChallengeRoute(deps) {
|
|
390
|
+
return appFactory.createApp().post("/:action", describeRoute({
|
|
391
|
+
tags: ["proxy"],
|
|
392
|
+
summary: "Answer one ACME DNS-01 challenge (local engine only)",
|
|
393
|
+
responses: {
|
|
394
|
+
200: { description: "Written or removed" },
|
|
395
|
+
401: { description: "Bad credentials" },
|
|
396
|
+
403: { description: "Not a local caller" }
|
|
397
|
+
}
|
|
398
|
+
}), async (c) => {
|
|
399
|
+
if (!isLoopbackRequest(c)) {
|
|
400
|
+
logger.warn("proxy: refused a DNS-01 challenge from a non-loopback caller");
|
|
401
|
+
return c.json({ error: "only this machine may answer a challenge" }, 403);
|
|
402
|
+
}
|
|
403
|
+
return await deps.challenge.handle(c.req.raw, c.req.param("action") ?? "");
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
var init_acme = __esmMin((() => {
|
|
407
|
+
init_factory();
|
|
408
|
+
init_logger();
|
|
409
|
+
init_loopback();
|
|
410
|
+
}));
|
|
411
|
+
//#endregion
|
|
353
412
|
//#region src/helpers/cookies.ts
|
|
354
413
|
function parseCookies(header) {
|
|
355
414
|
const cookies = {};
|
|
@@ -379,19 +438,13 @@ function serializeCookie(name, value, options = {}) {
|
|
|
379
438
|
}
|
|
380
439
|
var init_cookies = __esmMin((() => {}));
|
|
381
440
|
//#endregion
|
|
382
|
-
//#region src/helpers/factory.ts
|
|
383
|
-
var appFactory;
|
|
384
|
-
var init_factory = __esmMin((() => {
|
|
385
|
-
appFactory = createFactory();
|
|
386
|
-
}));
|
|
387
|
-
//#endregion
|
|
388
441
|
//#region src/shared/contracts.ts
|
|
389
442
|
/** Parses a bind value (`local` | `lan` | ipv4); null when it is not one. */
|
|
390
443
|
function parseBind(value) {
|
|
391
444
|
const parsed = bindSchema(value);
|
|
392
445
|
return parsed instanceof type.errors ? null : parsed;
|
|
393
446
|
}
|
|
394
|
-
var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
447
|
+
var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
|
|
395
448
|
var init_contracts = __esmMin((() => {
|
|
396
449
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
397
450
|
workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
|
|
@@ -620,7 +673,9 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
620
673
|
families: ddnsRecordTypeSchema.array(),
|
|
621
674
|
fields: ddnsProviderFieldSchema.array(),
|
|
622
675
|
ttl: "boolean",
|
|
623
|
-
proxied: "boolean"
|
|
676
|
+
proxied: "boolean",
|
|
677
|
+
/** These credentials can also answer an ACME DNS-01 challenge (a TXT record). */
|
|
678
|
+
txt: "boolean"
|
|
624
679
|
});
|
|
625
680
|
ddnsViewSchema = type({
|
|
626
681
|
config: ddnsConfigSchema,
|
|
@@ -886,13 +941,26 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
886
941
|
url: "string = \"\"",
|
|
887
942
|
/** Optional path prefix; empty serves the whole host. */
|
|
888
943
|
path: "string = \"\"",
|
|
889
|
-
tls: proxyTlsModeSchema.default(() => "auto")
|
|
944
|
+
tls: proxyTlsModeSchema.default(() => "auto"),
|
|
945
|
+
/**
|
|
946
|
+
* Which DNS account answers a DNS-01 challenge for this hostname, as
|
|
947
|
+
* `<workspace>/<account>` — the account belongs to a workspace, so the proxy
|
|
948
|
+
* references it rather than owning it. Empty picks the workspace's only
|
|
949
|
+
* account that can write TXT records.
|
|
950
|
+
*/
|
|
951
|
+
dnsAccount: "string = \"\""
|
|
890
952
|
}).onUndeclaredKey("reject");
|
|
891
953
|
proxyCertificateSchema = type({
|
|
892
954
|
id: "/^[a-z0-9][a-z0-9_-]*$/",
|
|
893
955
|
label: "string = \"\""
|
|
894
956
|
}).onUndeclaredKey("reject");
|
|
895
|
-
proxyCertificateStateSchema = type.enumerated("off", "local", "uploaded", "issued", "pending", "failed");
|
|
957
|
+
proxyCertificateStateSchema = type.enumerated("off", "local", "uploaded", "issued", "pending", "failed", "fallback");
|
|
958
|
+
proxyDns01Schema = type({
|
|
959
|
+
/** Off means the engine uses HTTP-01/TLS-ALPN-01, exactly as before. */
|
|
960
|
+
enabled: "boolean = false",
|
|
961
|
+
/** Nameservers Caddy checks the TXT record against; empty uses its own defaults. */
|
|
962
|
+
resolvers: type("string[]").default(() => [])
|
|
963
|
+
}).onUndeclaredKey("reject");
|
|
896
964
|
proxyConfigSchema = type({
|
|
897
965
|
enabled: "boolean = false",
|
|
898
966
|
engine: proxyEngineSchema.default(() => "caddy"),
|
|
@@ -903,6 +971,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
903
971
|
email: "string = \"\"",
|
|
904
972
|
/** The ACME staging endpoint: untrusted certificates, no rate-limit burn. */
|
|
905
973
|
staging: "boolean = false",
|
|
974
|
+
dns01: proxyDns01Schema.default(() => ({})),
|
|
906
975
|
/** PEM pairs a route with `tls: "manual"` may serve; the engine picks by SNI. */
|
|
907
976
|
certificates: proxyCertificateSchema.array().default(() => []),
|
|
908
977
|
routes: proxyRouteSchema.array().default(() => [])
|
|
@@ -916,8 +985,6 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
916
985
|
source: proxyEngineSourceSchema.or(type("null")),
|
|
917
986
|
path: "string | null",
|
|
918
987
|
bytes: "number | null",
|
|
919
|
-
/** Recorded at download time, so a swapped binary is visible. */
|
|
920
|
-
sha256: "string | null",
|
|
921
988
|
error: "string | null"
|
|
922
989
|
});
|
|
923
990
|
proxyEngineInfoSchema = type({
|
|
@@ -938,8 +1005,13 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
938
1005
|
"message": "string | null",
|
|
939
1006
|
/** Where this hostname's certificate stands. Optional: an older panel omits it. */
|
|
940
1007
|
"certificate?": type({
|
|
941
|
-
state: proxyCertificateStateSchema,
|
|
942
|
-
message: "string | null"
|
|
1008
|
+
"state": proxyCertificateStateSchema,
|
|
1009
|
+
"message": "string | null",
|
|
1010
|
+
/**
|
|
1011
|
+
* For `fallback`: minutes until the engine tries the CA again by itself, read
|
|
1012
|
+
* from the certificate it fell back to. Optional, so an older client ignores it.
|
|
1013
|
+
*/
|
|
1014
|
+
"retryInMinutes?": "number"
|
|
943
1015
|
})
|
|
944
1016
|
});
|
|
945
1017
|
proxyRunStateSchema = type.enumerated("off", "stopped", "starting", "running", "error", "unsupported");
|
|
@@ -968,14 +1040,26 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
968
1040
|
hosts: type("string[]"),
|
|
969
1041
|
error: "string | null"
|
|
970
1042
|
});
|
|
1043
|
+
proxyDnsAccountViewSchema = type({
|
|
1044
|
+
workspace: "string",
|
|
1045
|
+
account: "string",
|
|
1046
|
+
provider: "string",
|
|
1047
|
+
label: "string",
|
|
1048
|
+
/** The credentials can write a TXT record, so this account can answer a challenge. */
|
|
1049
|
+
writesTxt: "boolean",
|
|
1050
|
+
/** Credentials are stored; an account without them cannot answer yet. */
|
|
1051
|
+
hasCredentials: "boolean"
|
|
1052
|
+
});
|
|
971
1053
|
proxyViewSchema = type({
|
|
972
|
-
config: proxyConfigSchema,
|
|
973
|
-
engine: proxyEngineStatusSchema,
|
|
974
|
-
engines: proxyEngineInfoSchema.array(),
|
|
975
|
-
status: proxyStatusSchema,
|
|
976
|
-
routes: proxyRouteViewSchema.array(),
|
|
1054
|
+
"config": proxyConfigSchema,
|
|
1055
|
+
"engine": proxyEngineStatusSchema,
|
|
1056
|
+
"engines": proxyEngineInfoSchema.array(),
|
|
1057
|
+
"status": proxyStatusSchema,
|
|
1058
|
+
"routes": proxyRouteViewSchema.array(),
|
|
977
1059
|
/** Every uploaded pair, with what it covers and when it expires. */
|
|
978
|
-
certificates: proxyCertificateViewSchema.array()
|
|
1060
|
+
"certificates": proxyCertificateViewSchema.array(),
|
|
1061
|
+
/** Every DNS account a route may name. Optional: an older panel omits it. */
|
|
1062
|
+
"dnsAccounts?": proxyDnsAccountViewSchema.array()
|
|
979
1063
|
});
|
|
980
1064
|
proxyRoutePatchSchema = type({
|
|
981
1065
|
id: "string",
|
|
@@ -986,7 +1070,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
986
1070
|
server: "string?",
|
|
987
1071
|
url: "string?",
|
|
988
1072
|
path: "string?",
|
|
989
|
-
tls: proxyTlsModeSchema.optional()
|
|
1073
|
+
tls: proxyTlsModeSchema.optional(),
|
|
1074
|
+
dnsAccount: "string?"
|
|
990
1075
|
}).onUndeclaredKey("reject");
|
|
991
1076
|
proxyPatchSchema = type({
|
|
992
1077
|
enabled: "boolean?",
|
|
@@ -995,6 +1080,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
995
1080
|
httpsPort: "1 <= number.integer <= 65535?",
|
|
996
1081
|
email: "string?",
|
|
997
1082
|
staging: "boolean?",
|
|
1083
|
+
/**
|
|
1084
|
+
* A patch shape, not the config's: both keys are optional and merge, so a client
|
|
1085
|
+
* may send one without resetting the other, and an explicit `null` clears a key.
|
|
1086
|
+
*/
|
|
1087
|
+
dns01: type({
|
|
1088
|
+
enabled: "boolean | null?",
|
|
1089
|
+
resolvers: "string[] | null?"
|
|
1090
|
+
}).onUndeclaredKey("reject").optional(),
|
|
998
1091
|
certificates: proxyCertificateSchema.array().optional(),
|
|
999
1092
|
routes: proxyRoutePatchSchema.array().optional()
|
|
1000
1093
|
}).onUndeclaredKey("reject");
|
|
@@ -1413,20 +1506,6 @@ function normalizeIssues(error) {
|
|
|
1413
1506
|
}
|
|
1414
1507
|
var init_validator = __esmMin((() => {}));
|
|
1415
1508
|
//#endregion
|
|
1416
|
-
//#region src/middleware/loopback.ts
|
|
1417
|
-
/** Client address helpers shared by the auth guard and the setup routes. */
|
|
1418
|
-
function requestIp(c) {
|
|
1419
|
-
return c.req.raw?.ip ?? null;
|
|
1420
|
-
}
|
|
1421
|
-
function isLoopback(address) {
|
|
1422
|
-
if (!address) return false;
|
|
1423
|
-
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
1424
|
-
}
|
|
1425
|
-
function isLoopbackRequest(c) {
|
|
1426
|
-
return isLoopback(requestIp(c));
|
|
1427
|
-
}
|
|
1428
|
-
var init_loopback = __esmMin((() => {}));
|
|
1429
|
-
//#endregion
|
|
1430
1509
|
//#region src/helpers/atomic.ts
|
|
1431
1510
|
/**
|
|
1432
1511
|
* Write via a temp file in the same directory, then rename: readers never see a
|
|
@@ -1434,19 +1513,18 @@ var init_loopback = __esmMin((() => {}));
|
|
|
1434
1513
|
*/
|
|
1435
1514
|
function writeFileAtomic(file, content, options = {}) {
|
|
1436
1515
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1437
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1516
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
1517
|
+
try {
|
|
1518
|
+
fs.writeFileSync(tmp, content, options.mode === void 0 ? void 0 : { mode: options.mode });
|
|
1519
|
+
if (options.mode !== void 0) fs.chmodSync(tmp, options.mode);
|
|
1520
|
+
fs.renameSync(tmp, file);
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
fs.rmSync(tmp, { force: true });
|
|
1523
|
+
throw error;
|
|
1524
|
+
}
|
|
1441
1525
|
}
|
|
1442
1526
|
var init_atomic = __esmMin((() => {}));
|
|
1443
1527
|
//#endregion
|
|
1444
|
-
//#region src/helpers/logger.ts
|
|
1445
|
-
var logger;
|
|
1446
|
-
var init_logger = __esmMin((() => {
|
|
1447
|
-
logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
|
|
1448
|
-
}));
|
|
1449
|
-
//#endregion
|
|
1450
1528
|
//#region src/config/secrets.ts
|
|
1451
1529
|
function deriveKey(password, salt, cost, keylen) {
|
|
1452
1530
|
return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
|
|
@@ -2411,6 +2489,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
2411
2489
|
projectDir: () => projectDir,
|
|
2412
2490
|
proxyAdminPath: () => proxyAdminPath,
|
|
2413
2491
|
proxyBinDir: () => proxyBinDir,
|
|
2492
|
+
proxyChallengeAuthPath: () => proxyChallengeAuthPath,
|
|
2414
2493
|
proxyConfigPath: () => proxyConfigPath,
|
|
2415
2494
|
proxyDir: () => proxyDir,
|
|
2416
2495
|
proxyEngineDir: () => proxyEngineDir,
|
|
@@ -2520,7 +2599,7 @@ function resolveUserPath(target, base = projectDir) {
|
|
|
2520
2599
|
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
2521
2600
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
2522
2601
|
}
|
|
2523
|
-
var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSettingsSchemaPath, globalSecretsPath, workspacesPath, workspacesSchemaPath, tlsDir, defaultBackupsDirName, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, legacy;
|
|
2602
|
+
var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSettingsSchemaPath, globalSecretsPath, workspacesPath, workspacesSchemaPath, tlsDir, defaultBackupsDirName, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, proxyChallengeAuthPath, legacy;
|
|
2524
2603
|
var init_paths = __esmMin((() => {
|
|
2525
2604
|
dataRoot = resolveDataRoot();
|
|
2526
2605
|
projectDir = resolveProjectDir();
|
|
@@ -2544,6 +2623,7 @@ var init_paths = __esmMin((() => {
|
|
|
2544
2623
|
proxyStateDir = path.join(proxyDir, "state");
|
|
2545
2624
|
proxyAdminPath = path.join(proxyStateDir, "admin.json");
|
|
2546
2625
|
proxyTlsDir = path.join(proxyDir, "tls");
|
|
2626
|
+
proxyChallengeAuthPath = path.join(proxyStateDir, "challenge.json");
|
|
2547
2627
|
legacy = {
|
|
2548
2628
|
configPath: path.join(dataRoot, "servers.config.json"),
|
|
2549
2629
|
configSchemaPath: path.join(dataRoot, "servers.config.schema.json"),
|
|
@@ -3420,7 +3500,7 @@ function mergeGroup(target, patch) {
|
|
|
3420
3500
|
}
|
|
3421
3501
|
return merged;
|
|
3422
3502
|
}
|
|
3423
|
-
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, EMPTY_MERGE_KEYS;
|
|
3503
|
+
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, EMPTY_MERGE_KEYS;
|
|
3424
3504
|
var init_patch = __esmMin((() => {
|
|
3425
3505
|
SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
|
|
3426
3506
|
"restart",
|
|
@@ -3430,6 +3510,7 @@ var init_patch = __esmMin((() => {
|
|
|
3430
3510
|
CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
|
|
3431
3511
|
NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["telegram"]);
|
|
3432
3512
|
DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
|
|
3513
|
+
PROXY_MERGE_KEYS = /* @__PURE__ */ new Set(["dns01"]);
|
|
3433
3514
|
EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
|
|
3434
3515
|
}));
|
|
3435
3516
|
//#endregion
|
|
@@ -3614,11 +3695,11 @@ var init_settings$1 = __esmMin((() => {
|
|
|
3614
3695
|
this.commit(draft);
|
|
3615
3696
|
return backups;
|
|
3616
3697
|
}
|
|
3617
|
-
/** The reverse proxy. `routes` is a list a patch replaces
|
|
3698
|
+
/** The reverse proxy. `routes` is a list a patch replaces; `dns01` merges key by key. */
|
|
3618
3699
|
updateProxy(patch) {
|
|
3619
3700
|
const draft = structuredClone(this.raw);
|
|
3620
3701
|
draft.proxy = { ...draft.proxy ?? {} };
|
|
3621
|
-
applyPatch(draft.proxy, patch,
|
|
3702
|
+
applyPatch(draft.proxy, patch, PROXY_MERGE_KEYS);
|
|
3622
3703
|
const proxy = this.parseGroup(proxyConfigSchema, draft.proxy, "proxy");
|
|
3623
3704
|
this.commit(draft);
|
|
3624
3705
|
return proxy;
|
|
@@ -3722,7 +3803,7 @@ function authHeaders(credentials) {
|
|
|
3722
3803
|
};
|
|
3723
3804
|
return null;
|
|
3724
3805
|
}
|
|
3725
|
-
async function call(path, headers, context, init = {}) {
|
|
3806
|
+
async function call$1(path, headers, context, init = {}) {
|
|
3726
3807
|
try {
|
|
3727
3808
|
const response = await context.fetch(`${API$5}${path}`, {
|
|
3728
3809
|
...init,
|
|
@@ -3778,7 +3859,7 @@ async function resolveZone(host, headers, context, cached) {
|
|
|
3778
3859
|
const labels = host.split(".");
|
|
3779
3860
|
for (let index = 0; index <= labels.length - 2; index++) {
|
|
3780
3861
|
const candidate = labels.slice(index).join(".");
|
|
3781
|
-
const { data, error } = await call(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
|
|
3862
|
+
const { data, error } = await call$1(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
|
|
3782
3863
|
if (error !== null || data === null) throw new Error(error ?? "could not look up the zone");
|
|
3783
3864
|
const zone = data.result[0];
|
|
3784
3865
|
if (zone !== void 0) return zone;
|
|
@@ -3787,7 +3868,7 @@ async function resolveZone(host, headers, context, cached) {
|
|
|
3787
3868
|
}
|
|
3788
3869
|
async function findRecord(zoneId, record, headers, context, cached) {
|
|
3789
3870
|
if (cached !== void 0) {
|
|
3790
|
-
const fetched = await call(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
|
|
3871
|
+
const fetched = await call$1(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
|
|
3791
3872
|
if (fetched.error === null && fetched.data !== null) return {
|
|
3792
3873
|
record: fetched.data.result,
|
|
3793
3874
|
error: null
|
|
@@ -3797,7 +3878,7 @@ async function findRecord(zoneId, record, headers, context, cached) {
|
|
|
3797
3878
|
error: fetched.error
|
|
3798
3879
|
};
|
|
3799
3880
|
}
|
|
3800
|
-
const listed = await call(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
|
|
3881
|
+
const listed = await call$1(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
|
|
3801
3882
|
if (listed.error !== null) return {
|
|
3802
3883
|
record: null,
|
|
3803
3884
|
error: listed.error
|
|
@@ -3870,10 +3951,10 @@ var init_cloudflare = __esmMin((() => {
|
|
|
3870
3951
|
ttl,
|
|
3871
3952
|
proxied: record.proxied
|
|
3872
3953
|
});
|
|
3873
|
-
const written = existing === null ? await call(`/zones/${zone.id}/dns_records`, headers, context, {
|
|
3954
|
+
const written = existing === null ? await call$1(`/zones/${zone.id}/dns_records`, headers, context, {
|
|
3874
3955
|
method: "POST",
|
|
3875
3956
|
body
|
|
3876
|
-
}) : await call(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
|
|
3957
|
+
}) : await call$1(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
|
|
3877
3958
|
method: "PATCH",
|
|
3878
3959
|
body
|
|
3879
3960
|
});
|
|
@@ -3888,6 +3969,74 @@ var init_cloudflare = __esmMin((() => {
|
|
|
3888
3969
|
} catch (error) {
|
|
3889
3970
|
return failed(error instanceof Error ? error.message : String(error));
|
|
3890
3971
|
}
|
|
3972
|
+
},
|
|
3973
|
+
/**
|
|
3974
|
+
* One `_acme-challenge` TXT record, written and removed around a validation.
|
|
3975
|
+
* Cloudflare has real per-record endpoints, so nothing else in the zone is read
|
|
3976
|
+
* or rewritten — the failure mode that makes a whole-record-set API risky.
|
|
3977
|
+
*/
|
|
3978
|
+
async challenge(record, context) {
|
|
3979
|
+
const headers = authHeaders(context.credentials);
|
|
3980
|
+
if (headers === null) return {
|
|
3981
|
+
ok: false,
|
|
3982
|
+
message: "cloudflare needs an API token (or the legacy email + global key)"
|
|
3983
|
+
};
|
|
3984
|
+
try {
|
|
3985
|
+
const zone = await resolveZone(record.fqdn, headers, context, void 0);
|
|
3986
|
+
if (zone === null) return {
|
|
3987
|
+
ok: false,
|
|
3988
|
+
message: `cloudflare does not have a zone for ${record.fqdn}`
|
|
3989
|
+
};
|
|
3990
|
+
const listed = await call$1(`/zones/${zone.id}/dns_records?type=TXT&name=${encodeURIComponent(record.fqdn)}&per_page=100`, headers, context);
|
|
3991
|
+
if (listed.error !== null) return {
|
|
3992
|
+
ok: false,
|
|
3993
|
+
message: listed.error
|
|
3994
|
+
};
|
|
3995
|
+
const ours = (listed.data?.result ?? []).filter((entry) => entry.id !== void 0).filter((entry) => entry.content === record.value);
|
|
3996
|
+
if (record.action === "present") {
|
|
3997
|
+
if (ours.length > 0) return {
|
|
3998
|
+
ok: true,
|
|
3999
|
+
message: `${record.fqdn} already carries this value`
|
|
4000
|
+
};
|
|
4001
|
+
const written = await call$1(`/zones/${zone.id}/dns_records`, headers, context, {
|
|
4002
|
+
method: "POST",
|
|
4003
|
+
body: JSON.stringify({
|
|
4004
|
+
type: "TXT",
|
|
4005
|
+
name: record.fqdn,
|
|
4006
|
+
content: record.value,
|
|
4007
|
+
ttl: 1
|
|
4008
|
+
})
|
|
4009
|
+
});
|
|
4010
|
+
if (written.error !== null) return {
|
|
4011
|
+
ok: false,
|
|
4012
|
+
message: written.error
|
|
4013
|
+
};
|
|
4014
|
+
return {
|
|
4015
|
+
ok: true,
|
|
4016
|
+
message: `wrote the TXT record for ${record.fqdn}`
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
for (const entry of ours) {
|
|
4020
|
+
const removed = await call$1(`/zones/${zone.id}/dns_records/${entry.id}`, headers, context, { method: "DELETE" });
|
|
4021
|
+
if (removed.error !== null && removed.status !== 404) return {
|
|
4022
|
+
ok: false,
|
|
4023
|
+
message: removed.error
|
|
4024
|
+
};
|
|
4025
|
+
}
|
|
4026
|
+
if (ours.length === 0) return {
|
|
4027
|
+
ok: true,
|
|
4028
|
+
message: `${record.fqdn} had no challenge record to remove`
|
|
4029
|
+
};
|
|
4030
|
+
return {
|
|
4031
|
+
ok: true,
|
|
4032
|
+
message: `removed the TXT record for ${record.fqdn}`
|
|
4033
|
+
};
|
|
4034
|
+
} catch (error) {
|
|
4035
|
+
return {
|
|
4036
|
+
ok: false,
|
|
4037
|
+
message: error instanceof Error ? error.message : String(error)
|
|
4038
|
+
};
|
|
4039
|
+
}
|
|
3891
4040
|
}
|
|
3892
4041
|
};
|
|
3893
4042
|
}));
|
|
@@ -4012,11 +4161,11 @@ var init_dyndns2 = __esmMin((() => {
|
|
|
4012
4161
|
}));
|
|
4013
4162
|
//#endregion
|
|
4014
4163
|
//#region src/providers/ddns/desec.ts
|
|
4015
|
-
var ENDPOINT$
|
|
4164
|
+
var ENDPOINT$7, desecProvider;
|
|
4016
4165
|
var init_desec = __esmMin((() => {
|
|
4017
4166
|
init_dyndns2();
|
|
4018
4167
|
init_types();
|
|
4019
|
-
ENDPOINT$
|
|
4168
|
+
ENDPOINT$7 = "https://update.dedyn.io/";
|
|
4020
4169
|
desecProvider = {
|
|
4021
4170
|
id: "desec",
|
|
4022
4171
|
label: "deSEC",
|
|
@@ -4037,7 +4186,7 @@ var init_desec = __esmMin((() => {
|
|
|
4037
4186
|
const token = (context.credentials.token ?? "").trim();
|
|
4038
4187
|
if (token.length === 0) return failed("desec needs an API token");
|
|
4039
4188
|
const family = record.type === "A" ? `myipv4=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
4040
|
-
const url = `${ENDPOINT$
|
|
4189
|
+
const url = `${ENDPOINT$7}?hostname=${encodeURIComponent(record.host)}&${family}`;
|
|
4041
4190
|
try {
|
|
4042
4191
|
const response = await context.fetch(url, {
|
|
4043
4192
|
headers: { Authorization: basicAuth(record.host, token) },
|
|
@@ -4182,10 +4331,10 @@ function subdomain(host) {
|
|
|
4182
4331
|
const normalized = host.trim().toLowerCase();
|
|
4183
4332
|
return normalized.endsWith(".duckdns.org") ? normalized.slice(0, -12) : normalized;
|
|
4184
4333
|
}
|
|
4185
|
-
var ENDPOINT$
|
|
4334
|
+
var ENDPOINT$6, duckdnsProvider;
|
|
4186
4335
|
var init_duckdns = __esmMin((() => {
|
|
4187
4336
|
init_types();
|
|
4188
|
-
ENDPOINT$
|
|
4337
|
+
ENDPOINT$6 = "https://www.duckdns.org/update";
|
|
4189
4338
|
duckdnsProvider = {
|
|
4190
4339
|
id: "duckdns",
|
|
4191
4340
|
label: "DuckDNS",
|
|
@@ -4206,7 +4355,7 @@ var init_duckdns = __esmMin((() => {
|
|
|
4206
4355
|
const token = (context.credentials.token ?? "").trim();
|
|
4207
4356
|
if (token.length === 0) return failed("duckdns needs the account token");
|
|
4208
4357
|
const family = record.type === "A" ? `ip=${encodeURIComponent(record.ip)}` : `ipv6=${encodeURIComponent(record.ip)}`;
|
|
4209
|
-
const url = `${ENDPOINT$
|
|
4358
|
+
const url = `${ENDPOINT$6}?domains=${encodeURIComponent(subdomain(record.host))}&token=${encodeURIComponent(token)}&${family}`;
|
|
4210
4359
|
try {
|
|
4211
4360
|
const response = await context.fetch(url, { signal: context.signal });
|
|
4212
4361
|
const body = (await response.text()).trim();
|
|
@@ -4220,11 +4369,11 @@ var init_duckdns = __esmMin((() => {
|
|
|
4220
4369
|
}));
|
|
4221
4370
|
//#endregion
|
|
4222
4371
|
//#region src/providers/ddns/dynu.ts
|
|
4223
|
-
var ENDPOINT$
|
|
4372
|
+
var ENDPOINT$5, dynuProvider;
|
|
4224
4373
|
var init_dynu = __esmMin((() => {
|
|
4225
4374
|
init_dyndns2();
|
|
4226
4375
|
init_types();
|
|
4227
|
-
ENDPOINT$
|
|
4376
|
+
ENDPOINT$5 = "https://api.dynu.com/nic/update";
|
|
4228
4377
|
dynuProvider = {
|
|
4229
4378
|
id: "dynu",
|
|
4230
4379
|
label: "Dynu",
|
|
@@ -4251,7 +4400,7 @@ var init_dynu = __esmMin((() => {
|
|
|
4251
4400
|
const password = (context.credentials.password ?? "").trim();
|
|
4252
4401
|
if (username.length === 0 || password.length === 0) return failed("dynu needs the username and password");
|
|
4253
4402
|
const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
4254
|
-
const url = `${ENDPOINT$
|
|
4403
|
+
const url = `${ENDPOINT$5}?hostname=${encodeURIComponent(record.host)}&${address}`;
|
|
4255
4404
|
try {
|
|
4256
4405
|
const response = await context.fetch(url, {
|
|
4257
4406
|
headers: { Authorization: basicAuth(username, password) },
|
|
@@ -4275,10 +4424,10 @@ function updateToken(credentials) {
|
|
|
4275
4424
|
if (raw.length === 0) return "";
|
|
4276
4425
|
return (raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : raw).replace(/^https?:\/\/[^?]*\?/, "");
|
|
4277
4426
|
}
|
|
4278
|
-
var ENDPOINT$
|
|
4427
|
+
var ENDPOINT$4, freednsProvider;
|
|
4279
4428
|
var init_freedns = __esmMin((() => {
|
|
4280
4429
|
init_types();
|
|
4281
|
-
ENDPOINT$
|
|
4430
|
+
ENDPOINT$4 = "https://freedns.afraid.org/dynamic/update.php";
|
|
4282
4431
|
freednsProvider = {
|
|
4283
4432
|
id: "freedns",
|
|
4284
4433
|
label: "FreeDNS (afraid.org)",
|
|
@@ -4299,7 +4448,7 @@ var init_freedns = __esmMin((() => {
|
|
|
4299
4448
|
const token = updateToken(context.credentials);
|
|
4300
4449
|
if (token.length === 0) return failed("freedns needs the per-record Direct URL");
|
|
4301
4450
|
try {
|
|
4302
|
-
const response = await context.fetch(`${ENDPOINT$
|
|
4451
|
+
const response = await context.fetch(`${ENDPOINT$4}?${token}&address=${encodeURIComponent(record.ip)}`, { signal: context.signal });
|
|
4303
4452
|
const body = (await response.text()).trim();
|
|
4304
4453
|
if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
|
|
4305
4454
|
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
@@ -4453,11 +4602,11 @@ var init_godaddy = __esmMin((() => {
|
|
|
4453
4602
|
}));
|
|
4454
4603
|
//#endregion
|
|
4455
4604
|
//#region src/providers/ddns/henet.ts
|
|
4456
|
-
var ENDPOINT$
|
|
4605
|
+
var ENDPOINT$3, henetProvider;
|
|
4457
4606
|
var init_henet = __esmMin((() => {
|
|
4458
4607
|
init_dyndns2();
|
|
4459
4608
|
init_types();
|
|
4460
|
-
ENDPOINT$
|
|
4609
|
+
ENDPOINT$3 = "https://dyn.dns.he.net/nic/update";
|
|
4461
4610
|
henetProvider = {
|
|
4462
4611
|
id: "henet",
|
|
4463
4612
|
label: "Hurricane Electric",
|
|
@@ -4477,7 +4626,7 @@ var init_henet = __esmMin((() => {
|
|
|
4477
4626
|
async update(record, context) {
|
|
4478
4627
|
const key = (context.credentials.key ?? "").trim();
|
|
4479
4628
|
if (key.length === 0) return failed("hurricane electric needs the per-host DDNS key");
|
|
4480
|
-
const url = `${ENDPOINT$
|
|
4629
|
+
const url = `${ENDPOINT$3}?hostname=${encodeURIComponent(record.host)}&password=${encodeURIComponent(key)}&myip=${encodeURIComponent(record.ip)}`;
|
|
4481
4630
|
try {
|
|
4482
4631
|
const response = await context.fetch(url, { signal: context.signal });
|
|
4483
4632
|
return describeDyndnsResponse(response, await response.text(), "hurricane electric");
|
|
@@ -4492,11 +4641,11 @@ var init_henet = __esmMin((() => {
|
|
|
4492
4641
|
function tag(body, name) {
|
|
4493
4642
|
return new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "i").exec(body)?.[1]?.trim() ?? null;
|
|
4494
4643
|
}
|
|
4495
|
-
var ENDPOINT$
|
|
4644
|
+
var ENDPOINT$2, namecheapProvider;
|
|
4496
4645
|
var init_namecheap = __esmMin((() => {
|
|
4497
4646
|
init_types();
|
|
4498
4647
|
init_zone();
|
|
4499
|
-
ENDPOINT$
|
|
4648
|
+
ENDPOINT$2 = "https://dynamicdns.park-your-domain.com/update";
|
|
4500
4649
|
namecheapProvider = {
|
|
4501
4650
|
id: "namecheap",
|
|
4502
4651
|
label: "Namecheap",
|
|
@@ -4518,7 +4667,7 @@ var init_namecheap = __esmMin((() => {
|
|
|
4518
4667
|
if (password.length === 0) return failed("namecheap needs the Dynamic DNS password");
|
|
4519
4668
|
const { zone, name } = splitHost(record.host, record.zone);
|
|
4520
4669
|
const host = name === "@" ? "@" : name;
|
|
4521
|
-
const url = `${ENDPOINT$
|
|
4670
|
+
const url = `${ENDPOINT$2}?host=${encodeURIComponent(host)}&domain=${encodeURIComponent(zone)}&password=${encodeURIComponent(password)}&ip=${encodeURIComponent(record.ip)}`;
|
|
4522
4671
|
try {
|
|
4523
4672
|
const body = await (await context.fetch(url, { signal: context.signal })).text();
|
|
4524
4673
|
const errCount = Number.parseInt(tag(body, "ErrCount") ?? "0", 10);
|
|
@@ -4536,6 +4685,248 @@ var init_namecheap = __esmMin((() => {
|
|
|
4536
4685
|
};
|
|
4537
4686
|
}));
|
|
4538
4687
|
//#endregion
|
|
4688
|
+
//#region src/providers/ddns/namecheap-api.ts
|
|
4689
|
+
/**
|
|
4690
|
+
* Namecheap escapes attribute values, so a value read back has to be unescaped
|
|
4691
|
+
* before it is written out again: the whole-zone rewrite would otherwise turn
|
|
4692
|
+
* `a&b` into `a&b` on every challenge, corrupting a record we did not create.
|
|
4693
|
+
*/
|
|
4694
|
+
function decodeXml(value) {
|
|
4695
|
+
return value.replace(/&(#X?[0-9a-f]+|[a-z]+);/gi, (whole, body) => {
|
|
4696
|
+
if (body.startsWith("#")) {
|
|
4697
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
4698
|
+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
4699
|
+
return Number.isInteger(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
|
|
4700
|
+
}
|
|
4701
|
+
return XML_ENTITIES[body.toLowerCase()] ?? whole;
|
|
4702
|
+
});
|
|
4703
|
+
}
|
|
4704
|
+
function attr(tagText, name) {
|
|
4705
|
+
return decodeXml(new RegExp(`${name}="([^"]*)"`, "i").exec(tagText)?.[1] ?? "").trim();
|
|
4706
|
+
}
|
|
4707
|
+
/** Every `<Host ... />` in a `getHosts` reply, in the order the API listed them. */
|
|
4708
|
+
function parseHosts(body) {
|
|
4709
|
+
const records = [];
|
|
4710
|
+
const pattern = /<Host\b([^>]*?)(?:\/>|>)/gi;
|
|
4711
|
+
let match = pattern.exec(body);
|
|
4712
|
+
while (match !== null) {
|
|
4713
|
+
const attributes = match[1] ?? "";
|
|
4714
|
+
records.push({
|
|
4715
|
+
name: attr(attributes, "Name"),
|
|
4716
|
+
type: attr(attributes, "Type"),
|
|
4717
|
+
address: attr(attributes, "Address"),
|
|
4718
|
+
mxPref: attr(attributes, "MXPref") || "10",
|
|
4719
|
+
ttl: attr(attributes, "TTL") || "1800"
|
|
4720
|
+
});
|
|
4721
|
+
match = pattern.exec(body);
|
|
4722
|
+
}
|
|
4723
|
+
return records;
|
|
4724
|
+
}
|
|
4725
|
+
/** The first `<Err1>`…`<ErrN>` a failed reply carries, as one sentence. */
|
|
4726
|
+
function describeErrors(body) {
|
|
4727
|
+
const errors = [];
|
|
4728
|
+
const pattern = /<Err\d+>([\s\S]*?)<\/Err\d+>/gi;
|
|
4729
|
+
let match = pattern.exec(body);
|
|
4730
|
+
while (match !== null) {
|
|
4731
|
+
const text = (match[1] ?? "").trim();
|
|
4732
|
+
if (text.length > 0) errors.push(text);
|
|
4733
|
+
match = pattern.exec(body);
|
|
4734
|
+
}
|
|
4735
|
+
return errors.length > 0 ? errors.join("; ") : "the API refused the request";
|
|
4736
|
+
}
|
|
4737
|
+
function credentials(store) {
|
|
4738
|
+
const apiUser = (store.apiUser ?? "").trim();
|
|
4739
|
+
const apiKey = (store.apiKey ?? "").trim();
|
|
4740
|
+
const userName = (store.userName ?? "").trim() || apiUser;
|
|
4741
|
+
const clientIp = (store.clientIp ?? "").trim();
|
|
4742
|
+
if (apiUser.length === 0 || apiKey.length === 0 || clientIp.length === 0) return null;
|
|
4743
|
+
return {
|
|
4744
|
+
apiUser,
|
|
4745
|
+
apiKey,
|
|
4746
|
+
userName,
|
|
4747
|
+
clientIp
|
|
4748
|
+
};
|
|
4749
|
+
}
|
|
4750
|
+
/** One XML call. Namecheap answers HTTP 200 whatever happened, so the body decides. */
|
|
4751
|
+
async function call(command, params, context) {
|
|
4752
|
+
const auth = credentials(context.credentials);
|
|
4753
|
+
if (auth === null) return "namecheap needs the API user, the API key and the whitelisted client IP";
|
|
4754
|
+
const query = new URLSearchParams({
|
|
4755
|
+
ApiUser: auth.apiUser,
|
|
4756
|
+
ApiKey: auth.apiKey,
|
|
4757
|
+
UserName: auth.userName,
|
|
4758
|
+
ClientIp: auth.clientIp,
|
|
4759
|
+
Command: command,
|
|
4760
|
+
...params
|
|
4761
|
+
});
|
|
4762
|
+
try {
|
|
4763
|
+
const response = await context.fetch(`${ENDPOINT$1}?${query.toString()}`, { signal: context.signal });
|
|
4764
|
+
const body = await response.text();
|
|
4765
|
+
if (!response.ok && !body.includes("<ApiResponse")) return `namecheap answered HTTP ${response.status}`;
|
|
4766
|
+
const status = /<ApiResponse[^>]*Status="([^"]*)"/i.exec(body)?.[1] ?? "";
|
|
4767
|
+
if (status.toUpperCase() !== "OK") return describeErrors(body);
|
|
4768
|
+
return {
|
|
4769
|
+
status,
|
|
4770
|
+
errors: [],
|
|
4771
|
+
body
|
|
4772
|
+
};
|
|
4773
|
+
} catch (error) {
|
|
4774
|
+
return error instanceof Error ? error.message : String(error);
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
/** The registered domain split into the `SLD`/`TLD` pair the API asks for. */
|
|
4778
|
+
function apexParts(zone) {
|
|
4779
|
+
const apex = normalizeHost(zone);
|
|
4780
|
+
const dot = apex.indexOf(".");
|
|
4781
|
+
if (dot < 0) return {
|
|
4782
|
+
SLD: apex,
|
|
4783
|
+
TLD: ""
|
|
4784
|
+
};
|
|
4785
|
+
return {
|
|
4786
|
+
SLD: apex.slice(0, dot),
|
|
4787
|
+
TLD: apex.slice(dot + 1)
|
|
4788
|
+
};
|
|
4789
|
+
}
|
|
4790
|
+
/** The zone's records, or a sentence saying why they could not be read. */
|
|
4791
|
+
async function readHosts(zone, context) {
|
|
4792
|
+
const reply = await call("namecheap.domains.dns.getHosts", { ...apexParts(zone) }, context);
|
|
4793
|
+
if (typeof reply === "string") return reply;
|
|
4794
|
+
return parseHosts(reply.body);
|
|
4795
|
+
}
|
|
4796
|
+
/** Writes the whole record set back. */
|
|
4797
|
+
async function writeHosts(zone, records, context) {
|
|
4798
|
+
const params = { ...apexParts(zone) };
|
|
4799
|
+
records.forEach((record, index) => {
|
|
4800
|
+
const at = index + 1;
|
|
4801
|
+
params[`HostName${at}`] = record.name;
|
|
4802
|
+
params[`RecordType${at}`] = record.type;
|
|
4803
|
+
params[`Address${at}`] = record.address;
|
|
4804
|
+
params[`TTL${at}`] = record.ttl;
|
|
4805
|
+
params[`MXPref${at}`] = record.mxPref;
|
|
4806
|
+
});
|
|
4807
|
+
const reply = await call("namecheap.domains.dns.setHosts", params, context);
|
|
4808
|
+
return typeof reply === "string" ? reply : null;
|
|
4809
|
+
}
|
|
4810
|
+
/** The record name Namecheap expects for a challenge, relative to the zone. */
|
|
4811
|
+
function challengeName(fqdn, zone) {
|
|
4812
|
+
const normalized = normalizeHost(fqdn);
|
|
4813
|
+
const apex = normalizeHost(zone);
|
|
4814
|
+
if (normalized === apex) return "@";
|
|
4815
|
+
return normalized.endsWith(`.${apex}`) ? normalized.slice(0, -(apex.length + 1)) : normalized;
|
|
4816
|
+
}
|
|
4817
|
+
var ENDPOINT$1, RECORD_LIMIT, CHALLENGE_TTL, XML_ENTITIES, namecheapApiProvider;
|
|
4818
|
+
var init_namecheap_api = __esmMin((() => {
|
|
4819
|
+
init_types();
|
|
4820
|
+
init_zone();
|
|
4821
|
+
ENDPOINT$1 = "https://api.namecheap.com/xml.response";
|
|
4822
|
+
RECORD_LIMIT = 150;
|
|
4823
|
+
CHALLENGE_TTL = 60;
|
|
4824
|
+
XML_ENTITIES = {
|
|
4825
|
+
amp: "&",
|
|
4826
|
+
lt: "<",
|
|
4827
|
+
gt: ">",
|
|
4828
|
+
quot: "\"",
|
|
4829
|
+
apos: "'"
|
|
4830
|
+
};
|
|
4831
|
+
namecheapApiProvider = {
|
|
4832
|
+
id: "namecheap-api",
|
|
4833
|
+
label: "Namecheap (API)",
|
|
4834
|
+
docsUrl: "https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/",
|
|
4835
|
+
families: ["A"],
|
|
4836
|
+
ttl: true,
|
|
4837
|
+
proxied: false,
|
|
4838
|
+
fields: [
|
|
4839
|
+
{
|
|
4840
|
+
key: "apiUser",
|
|
4841
|
+
label: "API user",
|
|
4842
|
+
hint: "Dashboard → Profile → Tools → Namecheap API Access. Usually your account name.",
|
|
4843
|
+
optional: false
|
|
4844
|
+
},
|
|
4845
|
+
{
|
|
4846
|
+
key: "apiKey",
|
|
4847
|
+
label: "API key",
|
|
4848
|
+
hint: "Shown once when API access is enabled.",
|
|
4849
|
+
optional: false
|
|
4850
|
+
},
|
|
4851
|
+
{
|
|
4852
|
+
key: "userName",
|
|
4853
|
+
label: "User name",
|
|
4854
|
+
hint: "Leave empty unless the API user differs from the account.",
|
|
4855
|
+
optional: true
|
|
4856
|
+
},
|
|
4857
|
+
{
|
|
4858
|
+
key: "clientIp",
|
|
4859
|
+
label: "Whitelisted IP",
|
|
4860
|
+
hint: "The public IPv4 this panel calls from. It must be whitelisted under API Access, and error 1017105 means it is not.",
|
|
4861
|
+
optional: false
|
|
4862
|
+
}
|
|
4863
|
+
],
|
|
4864
|
+
validate(store) {
|
|
4865
|
+
if (credentials(store ?? {}) !== null) return null;
|
|
4866
|
+
const missing = [
|
|
4867
|
+
"apiUser",
|
|
4868
|
+
"apiKey",
|
|
4869
|
+
"clientIp"
|
|
4870
|
+
].filter((key) => (store?.[key] ?? "").trim().length === 0);
|
|
4871
|
+
return missing.length === 0 ? null : `set ${missing.join(", ")}`;
|
|
4872
|
+
},
|
|
4873
|
+
/**
|
|
4874
|
+
* There is no per-record update here: `setHosts` replaces everything, so this is
|
|
4875
|
+
* a read-modify-write of the whole zone.
|
|
4876
|
+
*/
|
|
4877
|
+
async challenge(record, context) {
|
|
4878
|
+
const { zone } = splitHost(record.fqdn);
|
|
4879
|
+
const hosts = await readHosts(zone, context);
|
|
4880
|
+
if (typeof hosts === "string") return {
|
|
4881
|
+
ok: false,
|
|
4882
|
+
message: hosts
|
|
4883
|
+
};
|
|
4884
|
+
const name = challengeName(record.fqdn, zone);
|
|
4885
|
+
const isChallenge = (host) => host.type.toUpperCase() === "TXT" && host.name.toLowerCase() === name.toLowerCase();
|
|
4886
|
+
const isOurs = (host) => isChallenge(host) && host.address === record.value;
|
|
4887
|
+
if (record.action === "present") {
|
|
4888
|
+
if (hosts.some(isOurs)) return {
|
|
4889
|
+
ok: true,
|
|
4890
|
+
message: `${record.fqdn} already carries this value`
|
|
4891
|
+
};
|
|
4892
|
+
if (hosts.length >= RECORD_LIMIT) return {
|
|
4893
|
+
ok: false,
|
|
4894
|
+
message: `namecheap's setHosts takes at most ${RECORD_LIMIT} host records per call and ${zone} has ${hosts.length}, so the panel cannot rewrite this zone to add a challenge record`
|
|
4895
|
+
};
|
|
4896
|
+
const failure = await writeHosts(zone, [...hosts, {
|
|
4897
|
+
name,
|
|
4898
|
+
type: "TXT",
|
|
4899
|
+
address: record.value,
|
|
4900
|
+
mxPref: "10",
|
|
4901
|
+
ttl: String(CHALLENGE_TTL)
|
|
4902
|
+
}], context);
|
|
4903
|
+
if (failure !== null) return {
|
|
4904
|
+
ok: false,
|
|
4905
|
+
message: failure
|
|
4906
|
+
};
|
|
4907
|
+
return {
|
|
4908
|
+
ok: true,
|
|
4909
|
+
message: `added a TXT record for ${record.fqdn} (rewrote ${hosts.length} others)`
|
|
4910
|
+
};
|
|
4911
|
+
}
|
|
4912
|
+
const kept = hosts.filter((host) => !isOurs(host));
|
|
4913
|
+
if (kept.length === hosts.length) return {
|
|
4914
|
+
ok: true,
|
|
4915
|
+
message: `${record.fqdn} had no challenge record to remove`
|
|
4916
|
+
};
|
|
4917
|
+
const failure = await writeHosts(zone, kept, context);
|
|
4918
|
+
if (failure !== null) return {
|
|
4919
|
+
ok: false,
|
|
4920
|
+
message: failure
|
|
4921
|
+
};
|
|
4922
|
+
return {
|
|
4923
|
+
ok: true,
|
|
4924
|
+
message: `removed the TXT record for ${record.fqdn} (rewrote ${kept.length} others)`
|
|
4925
|
+
};
|
|
4926
|
+
}
|
|
4927
|
+
};
|
|
4928
|
+
}));
|
|
4929
|
+
//#endregion
|
|
4539
4930
|
//#region src/providers/ddns/noip.ts
|
|
4540
4931
|
var ENDPOINT, DEFAULT_AGENT, noipProvider;
|
|
4541
4932
|
var init_noip = __esmMin((() => {
|
|
@@ -4759,7 +5150,8 @@ function ddnsProviderInfos() {
|
|
|
4759
5150
|
families: provider.families,
|
|
4760
5151
|
fields: provider.fields,
|
|
4761
5152
|
ttl: provider.ttl,
|
|
4762
|
-
proxied: provider.proxied
|
|
5153
|
+
proxied: provider.proxied,
|
|
5154
|
+
txt: provider.challenge !== void 0
|
|
4763
5155
|
}));
|
|
4764
5156
|
}
|
|
4765
5157
|
/**
|
|
@@ -4807,12 +5199,14 @@ var init_ddns$2 = __esmMin((() => {
|
|
|
4807
5199
|
init_godaddy();
|
|
4808
5200
|
init_henet();
|
|
4809
5201
|
init_namecheap();
|
|
5202
|
+
init_namecheap_api();
|
|
4810
5203
|
init_noip();
|
|
4811
5204
|
init_porkbun();
|
|
4812
5205
|
init_spaceship();
|
|
4813
5206
|
DDNS_PROVIDERS = [
|
|
4814
5207
|
cloudflareProvider,
|
|
4815
5208
|
namecheapProvider,
|
|
5209
|
+
namecheapApiProvider,
|
|
4816
5210
|
spaceshipProvider,
|
|
4817
5211
|
porkbunProvider,
|
|
4818
5212
|
godaddyProvider,
|
|
@@ -6626,10 +7020,10 @@ function goArch(arch) {
|
|
|
6626
7020
|
default: return null;
|
|
6627
7021
|
}
|
|
6628
7022
|
}
|
|
6629
|
-
var BUILD_URL,
|
|
7023
|
+
var BUILD_URL, PLUGINS, caddyEngine;
|
|
6630
7024
|
var init_caddy = __esmMin((() => {
|
|
6631
7025
|
BUILD_URL = "https://caddyserver.com/api/download";
|
|
6632
|
-
|
|
7026
|
+
PLUGINS = ["github.com/caddy-dns/acmeproxy"];
|
|
6633
7027
|
caddyEngine = {
|
|
6634
7028
|
info: {
|
|
6635
7029
|
id: "caddy",
|
|
@@ -6638,10 +7032,9 @@ var init_caddy = __esmMin((() => {
|
|
|
6638
7032
|
releaseUrl: "https://github.com/caddyserver/caddy/releases",
|
|
6639
7033
|
acme: true,
|
|
6640
7034
|
internalCa: true,
|
|
6641
|
-
dns01:
|
|
7035
|
+
dns01: true,
|
|
6642
7036
|
tcp: false
|
|
6643
7037
|
},
|
|
6644
|
-
pinnedVersion: PINNED,
|
|
6645
7038
|
binaryName(platform) {
|
|
6646
7039
|
return platform === "win32" ? "hh-caddy.exe" : "hh-caddy";
|
|
6647
7040
|
},
|
|
@@ -6649,10 +7042,11 @@ var init_caddy = __esmMin((() => {
|
|
|
6649
7042
|
const arch = goArch(target.arch);
|
|
6650
7043
|
if (arch === null) return null;
|
|
6651
7044
|
if (target.platform !== "linux" && target.platform !== "darwin" && target.platform !== "win32") return null;
|
|
6652
|
-
const
|
|
7045
|
+
const wanted = target.version.length > 0 ? `&version=${encodeURIComponent(target.version)}` : "";
|
|
7046
|
+
const plugins = PLUGINS.map((plugin) => `&p=${encodeURIComponent(plugin)}`).join("");
|
|
6653
7047
|
return {
|
|
6654
|
-
url: `${BUILD_URL}?os=${target.platform}&arch=${arch}
|
|
6655
|
-
version
|
|
7048
|
+
url: `${BUILD_URL}?os=${target.platform}&arch=${arch}${wanted}${plugins}`,
|
|
7049
|
+
version: target.version
|
|
6656
7050
|
};
|
|
6657
7051
|
},
|
|
6658
7052
|
runArgs(run) {
|
|
@@ -6709,13 +7103,67 @@ var init_proxy$2 = __esmMin((() => {
|
|
|
6709
7103
|
* ZeroSSL is deliberately absent: it needs an EAB key the panel does not collect, so
|
|
6710
7104
|
* listing it only added a failing issuer and a second retry on every name.
|
|
6711
7105
|
*/
|
|
6712
|
-
|
|
7106
|
+
/**
|
|
7107
|
+
* The DNS-01 challenge block, which names the panel as the one that answers it. The
|
|
7108
|
+
* engine holds no DNS credentials: it asks the panel over the ACMEProxy protocol, and
|
|
7109
|
+
* the panel writes the record through the workspace account the route names.
|
|
7110
|
+
*/
|
|
7111
|
+
function challengeBlock(dns01) {
|
|
7112
|
+
return { challenges: { dns: {
|
|
7113
|
+
provider: {
|
|
7114
|
+
name: "acmeproxy",
|
|
7115
|
+
endpoint: dns01.endpoint,
|
|
7116
|
+
username: dns01.username,
|
|
7117
|
+
password: dns01.password
|
|
7118
|
+
},
|
|
7119
|
+
...dns01.resolvers.length > 0 ? { resolvers: dns01.resolvers } : {}
|
|
7120
|
+
} } };
|
|
7121
|
+
}
|
|
7122
|
+
function acmeIssuers(acme, challenge = {}) {
|
|
6713
7123
|
return [{
|
|
6714
7124
|
module: "acme",
|
|
6715
7125
|
...acme.email.length > 0 ? { email: acme.email } : {},
|
|
6716
|
-
...acme.staging ? { ca: ACME_STAGING } : {}
|
|
7126
|
+
...acme.staging ? { ca: ACME_STAGING } : {},
|
|
7127
|
+
...challenge
|
|
6717
7128
|
}, { module: "internal" }];
|
|
6718
7129
|
}
|
|
7130
|
+
/**
|
|
7131
|
+
* The page a visitor gets when the hostname they reached is not routed at all. It is
|
|
7132
|
+
* the answer on both ports, so a request never lands on a redirect into a handshake
|
|
7133
|
+
* that cannot finish.
|
|
7134
|
+
*/
|
|
7135
|
+
function notConfiguredPage() {
|
|
7136
|
+
return `<!doctype html>
|
|
7137
|
+
<html lang="en">
|
|
7138
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7139
|
+
<title>No route for this hostname</title>
|
|
7140
|
+
<style>
|
|
7141
|
+
:root { color-scheme: light dark }
|
|
7142
|
+
body { margin: 0; min-height: 100dvh; display: grid; place-items: center; font: 15px/1.6 system-ui, sans-serif }
|
|
7143
|
+
main { max-width: 34rem; padding: 2rem }
|
|
7144
|
+
h1 { font-size: 1.15rem; margin: 0 0 .75rem }
|
|
7145
|
+
p { margin: 0 0 .75rem }
|
|
7146
|
+
code { font-family: ui-monospace, monospace; font-size: .9em }
|
|
7147
|
+
footer { color: #888; font-size: .85em }
|
|
7148
|
+
</style></head>
|
|
7149
|
+
<body><main>
|
|
7150
|
+
<h1>This route is not configured for any target yet</h1>
|
|
7151
|
+
<p>No route in <strong>home-hosted</strong>'s reverse proxy claims the hostname
|
|
7152
|
+
<code>{http.request.host}</code>, so there is nothing here to serve.</p>
|
|
7153
|
+
<p>Add a route for it under <strong>Others → Reverse Proxy</strong>, or check that you
|
|
7154
|
+
reached the hostname you meant to.</p>
|
|
7155
|
+
<footer>home-hosted reverse proxy</footer>
|
|
7156
|
+
</main></body></html>`;
|
|
7157
|
+
}
|
|
7158
|
+
/** The catch-all: it answers last, so every configured route wins over it. */
|
|
7159
|
+
function notConfiguredRoute() {
|
|
7160
|
+
return { handle: [{
|
|
7161
|
+
handler: "static_response",
|
|
7162
|
+
status_code: 404,
|
|
7163
|
+
headers: { "content-type": ["text/html; charset=utf-8"] },
|
|
7164
|
+
body: notConfiguredPage()
|
|
7165
|
+
}] };
|
|
7166
|
+
}
|
|
6719
7167
|
/** The page a visitor gets when a name has no certificate yet and HTTPS cannot work. */
|
|
6720
7168
|
function noticePage(host, httpPort, httpsPort) {
|
|
6721
7169
|
return `<!doctype html>
|
|
@@ -6802,11 +7250,20 @@ function renderCaddyConfig(input) {
|
|
|
6802
7250
|
const servers = {};
|
|
6803
7251
|
if (managed.length > 0) servers.https = {
|
|
6804
7252
|
listen: [`:${config.httpsPort}`],
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
7253
|
+
tls_connection_policies: [{ default_sni: FALLBACK_NAME }],
|
|
7254
|
+
routes: [
|
|
7255
|
+
...managed.map((route) => ({
|
|
7256
|
+
match: [matchFor(route)],
|
|
7257
|
+
handle: [proxyHandler(route)],
|
|
7258
|
+
terminal: true
|
|
7259
|
+
})),
|
|
7260
|
+
{
|
|
7261
|
+
match: [{ host: [FALLBACK_NAME] }],
|
|
7262
|
+
...notConfiguredRoute(),
|
|
7263
|
+
terminal: true
|
|
7264
|
+
},
|
|
7265
|
+
notConfiguredRoute()
|
|
7266
|
+
]
|
|
6810
7267
|
};
|
|
6811
7268
|
if (routes.length > 0) servers.http = {
|
|
6812
7269
|
listen: [`:${config.httpPort}`],
|
|
@@ -6836,7 +7293,8 @@ function renderCaddyConfig(input) {
|
|
|
6836
7293
|
headers: { "content-type": ["text/html; charset=utf-8"] },
|
|
6837
7294
|
body: noticePage(host, config.httpPort, config.httpsPort)
|
|
6838
7295
|
}]
|
|
6839
|
-
}))
|
|
7296
|
+
})),
|
|
7297
|
+
notConfiguredRoute()
|
|
6840
7298
|
]
|
|
6841
7299
|
};
|
|
6842
7300
|
const adminBlock = { config: { persist: false } };
|
|
@@ -6848,10 +7306,25 @@ function renderCaddyConfig(input) {
|
|
|
6848
7306
|
}
|
|
6849
7307
|
const tlsApp = {};
|
|
6850
7308
|
if (input.manual.length > 0) tlsApp.certificates = { load_files: input.manual };
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
7309
|
+
const policies = [];
|
|
7310
|
+
if (input.acme.subjects.length > 0) {
|
|
7311
|
+
const challenge = input.dns01 === void 0 ? {} : challengeBlock(input.dns01);
|
|
7312
|
+
const dnsSubjects = new Set(input.dns01?.policies.flatMap((policy) => policy.subjects) ?? []);
|
|
7313
|
+
for (const policy of input.dns01?.policies ?? []) policies.push({
|
|
7314
|
+
subjects: policy.subjects,
|
|
7315
|
+
issuers: acmeIssuers(input.acme, challenge)
|
|
7316
|
+
});
|
|
7317
|
+
const rest = input.acme.subjects.filter((subject) => !dnsSubjects.has(subject));
|
|
7318
|
+
if (rest.length > 0) policies.push({
|
|
7319
|
+
subjects: rest,
|
|
7320
|
+
issuers: acmeIssuers(input.acme)
|
|
7321
|
+
});
|
|
7322
|
+
}
|
|
7323
|
+
if (managed.length > 0) policies.push({
|
|
7324
|
+
subjects: [FALLBACK_NAME],
|
|
7325
|
+
issuers: [{ module: "internal" }]
|
|
7326
|
+
});
|
|
7327
|
+
if (policies.length > 0) tlsApp.automation = { policies };
|
|
6855
7328
|
return {
|
|
6856
7329
|
admin: adminBlock,
|
|
6857
7330
|
storage: {
|
|
@@ -6869,9 +7342,10 @@ function renderCaddyConfig(input) {
|
|
|
6869
7342
|
}
|
|
6870
7343
|
};
|
|
6871
7344
|
}
|
|
6872
|
-
var ACME_STAGING;
|
|
7345
|
+
var ACME_STAGING, FALLBACK_NAME;
|
|
6873
7346
|
var init_proxy_config = __esmMin((() => {
|
|
6874
7347
|
ACME_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory";
|
|
7348
|
+
FALLBACK_NAME = "hh-fallback.invalid";
|
|
6875
7349
|
}));
|
|
6876
7350
|
//#endregion
|
|
6877
7351
|
//#region src/services/tls.ts
|
|
@@ -7054,6 +7528,17 @@ function coversHost(hosts, host) {
|
|
|
7054
7528
|
return name.endsWith(suffix) && !name.slice(0, -suffix.length).includes(".");
|
|
7055
7529
|
});
|
|
7056
7530
|
}
|
|
7531
|
+
/**
|
|
7532
|
+
* When the engine tries the CA again, in minutes.
|
|
7533
|
+
*
|
|
7534
|
+
* certmagic renews once a third of a certificate's lifetime is left, so two thirds
|
|
7535
|
+
* of it is the wait; the engine's own CA issues 12 hours by default, which is where
|
|
7536
|
+
* the "about 8 hours" comes from. Read from the certificate, so shortening that
|
|
7537
|
+
* lifetime shortens the wait too.
|
|
7538
|
+
*/
|
|
7539
|
+
function retryWindowMinutes(lifetimeMs) {
|
|
7540
|
+
return Math.max(1, Math.round(lifetimeMs / 6e4 * (2 / 3)));
|
|
7541
|
+
}
|
|
7057
7542
|
/** `host:port`, `http://host:port` or `https://host:port`; `null` when unusable. */
|
|
7058
7543
|
function parseUpstream(value) {
|
|
7059
7544
|
const trimmed = value.trim();
|
|
@@ -7070,8 +7555,36 @@ function parseUpstream(value) {
|
|
|
7070
7555
|
return null;
|
|
7071
7556
|
}
|
|
7072
7557
|
}
|
|
7558
|
+
/**
|
|
7559
|
+
* The engine policies DNS-01 needs: one per account, because a policy carries one
|
|
7560
|
+
* challenge provider. Names that no account answers are left out, and the caller
|
|
7561
|
+
* renders them on the port challenges as before.
|
|
7562
|
+
*
|
|
7563
|
+
* Pure so the grouping can be checked without an engine: getting it wrong means a
|
|
7564
|
+
* hostname is asked for through the wrong zone, which fails with nothing to read.
|
|
7565
|
+
*/
|
|
7566
|
+
function groupDns01Policies(routes, accountFor) {
|
|
7567
|
+
const byAccount = /* @__PURE__ */ new Map();
|
|
7568
|
+
for (const route of routes) {
|
|
7569
|
+
if (route.tls !== "auto" || !isPublicHost(route.host)) continue;
|
|
7570
|
+
const account = accountFor(route.host);
|
|
7571
|
+
if (account === null) continue;
|
|
7572
|
+
const key = `${account.workspaceId}/${account.accountId}`;
|
|
7573
|
+
const existing = byAccount.get(key);
|
|
7574
|
+
if (existing === void 0) byAccount.set(key, {
|
|
7575
|
+
provider: account.provider,
|
|
7576
|
+
subjects: [route.host]
|
|
7577
|
+
});
|
|
7578
|
+
else if (!existing.subjects.includes(route.host)) existing.subjects.push(route.host);
|
|
7579
|
+
}
|
|
7580
|
+
return [...byAccount.entries()].map(([account, group]) => ({
|
|
7581
|
+
account,
|
|
7582
|
+
provider: group.provider,
|
|
7583
|
+
subjects: group.subjects
|
|
7584
|
+
}));
|
|
7585
|
+
}
|
|
7073
7586
|
/** Cross-field problems ArkType cannot see, reported by the API before a save. */
|
|
7074
|
-
function validateProxyConfig(config) {
|
|
7587
|
+
function validateProxyConfig(config, dnsAccount) {
|
|
7075
7588
|
const errors = [];
|
|
7076
7589
|
if (config.httpPort === config.httpsPort) errors.push("the http and https ports must differ");
|
|
7077
7590
|
const publicNames = [...new Set(config.routes.filter((route) => route.tls !== "off").map((route) => route.host.toLowerCase()))].filter((host) => isPublicHost(host));
|
|
@@ -7086,6 +7599,13 @@ function validateProxyConfig(config) {
|
|
|
7086
7599
|
seen.add(key);
|
|
7087
7600
|
if (route.target === "server" && (route.workspace.length === 0 || route.server.length === 0)) errors.push(`route "${route.id}" needs a workspace and a server`);
|
|
7088
7601
|
if (route.target === "external" && parseUpstream(route.url) === null) errors.push(`route "${route.id}" needs an upstream like http://10.0.0.5:8080`);
|
|
7602
|
+
const named = route.dnsAccount.trim();
|
|
7603
|
+
if (named.length === 0 || dnsAccount === void 0) continue;
|
|
7604
|
+
if (!config.dns01.enabled) errors.push(`route "${route.id}" names DNS account "${named}", but DNS-01 is switched off`);
|
|
7605
|
+
const found = dnsAccount(named, route.workspace);
|
|
7606
|
+
if (found === null) errors.push(`route "${route.id}" names unknown DNS account "${named}"`);
|
|
7607
|
+
else if (!found.writesTxt) errors.push(`route "${route.id}": the "${found.provider}" account cannot write TXT records, so it cannot answer a DNS-01 challenge`);
|
|
7608
|
+
else if (!found.hasCredentials) errors.push(`route "${route.id}": the "${found.provider}" account has no credentials stored yet`);
|
|
7089
7609
|
}
|
|
7090
7610
|
return errors;
|
|
7091
7611
|
}
|
|
@@ -7104,12 +7624,14 @@ function engineMessage(body) {
|
|
|
7104
7624
|
return body.trim().length > 0 ? body.trim().slice(0, 400) : null;
|
|
7105
7625
|
}
|
|
7106
7626
|
}
|
|
7107
|
-
var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, engineRecordSchema, appliedRecordSchema, adminRecordSchema, ProxyService, LOCAL_TLDS;
|
|
7627
|
+
var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, ENGINE_COMMAND_TIMEOUT_MS, DNS01_MODULE, engineRecordSchema, challengeAuthSchema, appliedRecordSchema, adminRecordSchema, ProxyService, LOCAL_TLDS;
|
|
7108
7628
|
var init_proxy$1 = __esmMin((() => {
|
|
7109
7629
|
init_atomic();
|
|
7110
7630
|
init_logger();
|
|
7111
7631
|
init_paths();
|
|
7112
7632
|
init_runtime();
|
|
7633
|
+
init_ddns$2();
|
|
7634
|
+
init_types();
|
|
7113
7635
|
init_nanny$2();
|
|
7114
7636
|
init_port();
|
|
7115
7637
|
init_proxy$2();
|
|
@@ -7120,15 +7642,20 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7120
7642
|
READY_TIMEOUT_MS = 2e4;
|
|
7121
7643
|
READY_POLL_MS = 250;
|
|
7122
7644
|
DOWNLOAD_TIMEOUT_MS$1 = 9e5;
|
|
7645
|
+
ENGINE_COMMAND_TIMEOUT_MS = 1e4;
|
|
7646
|
+
DNS01_MODULE = "dns.providers.acmeproxy";
|
|
7123
7647
|
engineRecordSchema = type({
|
|
7124
7648
|
engine: "string",
|
|
7125
7649
|
version: "string",
|
|
7126
7650
|
source: "\"downloaded\" | \"custom\"",
|
|
7127
7651
|
url: "string",
|
|
7128
|
-
sha256: "string",
|
|
7129
7652
|
bytes: "number",
|
|
7130
7653
|
installedAt: "number"
|
|
7131
7654
|
}).onUndeclaredKey("reject");
|
|
7655
|
+
challengeAuthSchema = type({
|
|
7656
|
+
username: "string >= 1",
|
|
7657
|
+
password: "string >= 1"
|
|
7658
|
+
}).onUndeclaredKey("reject");
|
|
7132
7659
|
appliedRecordSchema = type({
|
|
7133
7660
|
httpPort: "1 <= number.integer <= 65535",
|
|
7134
7661
|
httpsPort: "1 <= number.integer <= 65535",
|
|
@@ -7146,6 +7673,10 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7146
7673
|
nanny = null;
|
|
7147
7674
|
admin = null;
|
|
7148
7675
|
lastError = null;
|
|
7676
|
+
/** The installed binary's modules, read once per process; an install clears it. */
|
|
7677
|
+
moduleCache = null;
|
|
7678
|
+
/** A legacy `engine.json` is reported once, not on every state frame. */
|
|
7679
|
+
legacyChecksumWarned = false;
|
|
7149
7680
|
certCache = null;
|
|
7150
7681
|
/** The certificate state the last apply was built from, so a change re-applies once. */
|
|
7151
7682
|
certificateSignature = null;
|
|
@@ -7166,6 +7697,146 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7166
7697
|
get enginePath() {
|
|
7167
7698
|
return path.join(this.options.binDir, this.engine.binaryName(process.platform));
|
|
7168
7699
|
}
|
|
7700
|
+
/**
|
|
7701
|
+
* The Basic credentials the engine uses to reach the panel's DNS-01 endpoint.
|
|
7702
|
+
* Generated on first use and kept 0600: the endpoint is loopback-only, and this
|
|
7703
|
+
* is the second lock on a door that writes into somebody's DNS.
|
|
7704
|
+
*/
|
|
7705
|
+
challengeAuth() {
|
|
7706
|
+
const file = this.options.challengeAuthPath ?? proxyChallengeAuthPath;
|
|
7707
|
+
try {
|
|
7708
|
+
const parsed = challengeAuthSchema(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
7709
|
+
if (!(parsed instanceof type.errors)) return parsed;
|
|
7710
|
+
} catch {}
|
|
7711
|
+
const auth = {
|
|
7712
|
+
username: "hh",
|
|
7713
|
+
password: randomBytes(24).toString("base64url")
|
|
7714
|
+
};
|
|
7715
|
+
fs.mkdirSync(path.dirname(file), {
|
|
7716
|
+
recursive: true,
|
|
7717
|
+
mode: 448
|
|
7718
|
+
});
|
|
7719
|
+
writeFileAtomic(file, `${JSON.stringify(auth, null, 2)}\n`, { mode: 384 });
|
|
7720
|
+
return auth;
|
|
7721
|
+
}
|
|
7722
|
+
/**
|
|
7723
|
+
* The DNS-01 half of the renderer's input, or undefined when it is switched off.
|
|
7724
|
+
*
|
|
7725
|
+
* One entry per account, because an engine policy carries one challenge provider:
|
|
7726
|
+
* a name whose zone is at Cloudflare and one at deSEC need a policy each.
|
|
7727
|
+
*/
|
|
7728
|
+
dns01Input(routes) {
|
|
7729
|
+
if (!this.config.dns01.enabled) return void 0;
|
|
7730
|
+
const endpoint = this.options.control();
|
|
7731
|
+
const auth = this.challengeAuth();
|
|
7732
|
+
const policies = groupDns01Policies(routes, (host) => this.resolveAccount(host));
|
|
7733
|
+
return {
|
|
7734
|
+
resolvers: this.config.dns01.resolvers,
|
|
7735
|
+
endpoint: `${endpoint.protocol}://127.0.0.1:${endpoint.port}/_acme`,
|
|
7736
|
+
username: auth.username,
|
|
7737
|
+
password: auth.password,
|
|
7738
|
+
policies
|
|
7739
|
+
};
|
|
7740
|
+
}
|
|
7741
|
+
/** The workspace a route's DNS account is looked up in. */
|
|
7742
|
+
workspaceOf(route) {
|
|
7743
|
+
if (route.workspace.length > 0) return route.workspace;
|
|
7744
|
+
return this.options.defaultWorkspaceId?.() ?? "";
|
|
7745
|
+
}
|
|
7746
|
+
/**
|
|
7747
|
+
* The account a route names, or null when it names none.
|
|
7748
|
+
*
|
|
7749
|
+
* A bare id belongs to the route's own workspace; `<workspace>/<id>` crosses into
|
|
7750
|
+
* another one, which is what lets one route table cover zones owned by different
|
|
7751
|
+
* workspaces.
|
|
7752
|
+
*/
|
|
7753
|
+
dnsAccountRef(route) {
|
|
7754
|
+
if (route === void 0) return null;
|
|
7755
|
+
const named = route.dnsAccount.trim();
|
|
7756
|
+
if (named.length === 0) return null;
|
|
7757
|
+
const workspaceId = this.workspaceOf(route);
|
|
7758
|
+
if (workspaceId.length === 0) return null;
|
|
7759
|
+
const [owner, accountId] = named.includes("/") ? named.split("/", 2) : [workspaceId, named];
|
|
7760
|
+
const resolved = this.options.resolveDnsAccount?.(owner ?? "", accountId ?? "");
|
|
7761
|
+
if (resolved === void 0 || resolved === null) return null;
|
|
7762
|
+
return {
|
|
7763
|
+
owner: owner ?? "",
|
|
7764
|
+
accountId: accountId ?? "",
|
|
7765
|
+
provider: resolved.provider
|
|
7766
|
+
};
|
|
7767
|
+
}
|
|
7768
|
+
/** The route that answers for a hostname, challenge prefix or not. */
|
|
7769
|
+
routeForHost(host) {
|
|
7770
|
+
return this.config.routes.find((route) => route.enabled && route.host.toLowerCase() === host);
|
|
7771
|
+
}
|
|
7772
|
+
/**
|
|
7773
|
+
* The account that answers a DNS-01 challenge for one hostname.
|
|
7774
|
+
*
|
|
7775
|
+
* The route is the authority. An explicit `dnsAccount` wins; an empty one falls back
|
|
7776
|
+
* to the only account any route names, so a single-account setup needs no per-route
|
|
7777
|
+
* choice — and never to a guess among several, because writing the wrong zone fails
|
|
7778
|
+
* silently.
|
|
7779
|
+
*/
|
|
7780
|
+
challengeAccount(fqdn) {
|
|
7781
|
+
const host = fqdn.startsWith("_acme-challenge.") ? fqdn.slice(16) : fqdn;
|
|
7782
|
+
return this.resolveAccount(normalizeHost(host));
|
|
7783
|
+
}
|
|
7784
|
+
resolveAccount(host) {
|
|
7785
|
+
if (!this.config.dns01.enabled) return null;
|
|
7786
|
+
if (!isPublicHost(host)) return null;
|
|
7787
|
+
if (this.options.resolveDnsAccount === void 0) return null;
|
|
7788
|
+
const route = this.routeForHost(host);
|
|
7789
|
+
if (route === void 0) return null;
|
|
7790
|
+
const named = this.dnsAccountRef(route);
|
|
7791
|
+
if (named !== null) return this.accountOf(named.owner, named.accountId);
|
|
7792
|
+
const declared = /* @__PURE__ */ new Map();
|
|
7793
|
+
for (const entry of this.config.routes) {
|
|
7794
|
+
if (!entry.enabled) continue;
|
|
7795
|
+
const ref = this.dnsAccountRef(entry);
|
|
7796
|
+
if (ref !== null) declared.set(`${ref.owner}/${ref.accountId}`, {
|
|
7797
|
+
owner: ref.owner,
|
|
7798
|
+
accountId: ref.accountId
|
|
7799
|
+
});
|
|
7800
|
+
}
|
|
7801
|
+
if (declared.size === 1) {
|
|
7802
|
+
const only = [...declared.values()][0];
|
|
7803
|
+
return this.accountOf(only.owner, only.accountId);
|
|
7804
|
+
}
|
|
7805
|
+
if (declared.size > 1) return null;
|
|
7806
|
+
const usable = (this.options.listDnsAccounts?.() ?? []).filter((entry) => entry.writesTxt && entry.hasCredentials);
|
|
7807
|
+
if (usable.length !== 1) return null;
|
|
7808
|
+
const only = usable[0];
|
|
7809
|
+
return this.accountOf(only.workspace, only.account);
|
|
7810
|
+
}
|
|
7811
|
+
accountOf(workspaceId, accountId) {
|
|
7812
|
+
const found = this.options.resolveDnsAccount?.(workspaceId, accountId);
|
|
7813
|
+
if (found === void 0 || found === null || found.credentials === null) return null;
|
|
7814
|
+
return {
|
|
7815
|
+
workspaceId,
|
|
7816
|
+
accountId,
|
|
7817
|
+
provider: found.provider,
|
|
7818
|
+
credentials: found.credentials
|
|
7819
|
+
};
|
|
7820
|
+
}
|
|
7821
|
+
/**
|
|
7822
|
+
* What a route's `dnsAccount` refers to, for the save-time check.
|
|
7823
|
+
*
|
|
7824
|
+
* A bare id belongs to the default workspace, which is what a person typing one
|
|
7825
|
+
* account means; `<workspace>/<id>` crosses into another.
|
|
7826
|
+
*/
|
|
7827
|
+
checkDnsAccount(reference, workspaceId = "") {
|
|
7828
|
+
const named = reference.trim();
|
|
7829
|
+
if (named.length === 0) return null;
|
|
7830
|
+
const owner = named.includes("/") ? named.split("/", 2)[0] ?? "" : workspaceId.length > 0 ? workspaceId : this.options.defaultWorkspaceId?.() ?? "";
|
|
7831
|
+
const accountId = named.includes("/") ? named.split("/", 2)[1] ?? "" : named;
|
|
7832
|
+
const found = this.options.resolveDnsAccount?.(owner, accountId);
|
|
7833
|
+
if (found === void 0 || found === null) return null;
|
|
7834
|
+
return {
|
|
7835
|
+
provider: found.provider,
|
|
7836
|
+
writesTxt: ddnsProvider(found.provider)?.challenge !== void 0,
|
|
7837
|
+
hasCredentials: found.credentials !== null
|
|
7838
|
+
};
|
|
7839
|
+
}
|
|
7169
7840
|
/** One uploaded pair, by the id that names it in the config. */
|
|
7170
7841
|
pairStore(id) {
|
|
7171
7842
|
return new TlsStore(this.options.tlsDir, id);
|
|
@@ -7238,6 +7909,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7238
7909
|
if (this.certCache !== null && this.certCache.key === key && Date.now() - this.certCache.at < 15e3) return this.certCache;
|
|
7239
7910
|
const views = this.readCertificateViews();
|
|
7240
7911
|
const issued = this.readIssuedCertificates();
|
|
7912
|
+
const local = this.readLocalCertificates();
|
|
7241
7913
|
const values = [...issued.values()];
|
|
7242
7914
|
const soonest = values.length === 0 ? null : Math.min(...values.map((entry) => entry.notAfter));
|
|
7243
7915
|
this.certCache = {
|
|
@@ -7245,6 +7917,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7245
7917
|
key,
|
|
7246
7918
|
days: soonest === null ? null : Math.floor((soonest - Date.now()) / 864e5),
|
|
7247
7919
|
issued,
|
|
7920
|
+
local,
|
|
7248
7921
|
views
|
|
7249
7922
|
};
|
|
7250
7923
|
return this.certCache;
|
|
@@ -7337,6 +8010,12 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7337
8010
|
message: `expires in ${Math.floor((issued.notAfter - Date.now()) / 864e5)} days`
|
|
7338
8011
|
};
|
|
7339
8012
|
const failure = this.obtainFailures().get(route.host.toLowerCase());
|
|
8013
|
+
const local = this.localCertificates().get(route.host.toLowerCase());
|
|
8014
|
+
if (local !== void 0) return {
|
|
8015
|
+
state: "fallback",
|
|
8016
|
+
message: failure ?? "the CA has not issued for this name",
|
|
8017
|
+
retryInMinutes: retryWindowMinutes(local.lifetimeMs)
|
|
8018
|
+
};
|
|
7340
8019
|
return failure === void 0 ? {
|
|
7341
8020
|
state: "pending",
|
|
7342
8021
|
message: "the engine is waiting for a certificate"
|
|
@@ -7349,6 +8028,40 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7349
8028
|
issuedCertificates() {
|
|
7350
8029
|
return this.certSnapshot().issued;
|
|
7351
8030
|
}
|
|
8031
|
+
/** Certificates the engine's own CA issued, by hostname. */
|
|
8032
|
+
localCertificates() {
|
|
8033
|
+
return this.certSnapshot().local;
|
|
8034
|
+
}
|
|
8035
|
+
/**
|
|
8036
|
+
* The engine's own certificates, which `readIssuedCertificates` skips on purpose.
|
|
8037
|
+
*
|
|
8038
|
+
* A public name in here means the CA would not issue and the engine fell back to
|
|
8039
|
+
* its own CA — a state worth its own name, because the name is served but not by a
|
|
8040
|
+
* trusted issuer, and it clears itself when the engine next tries the CA.
|
|
8041
|
+
*/
|
|
8042
|
+
readLocalCertificates() {
|
|
8043
|
+
const found = /* @__PURE__ */ new Map();
|
|
8044
|
+
const root = path.join(this.options.engineDir, "data", "certificates", "local");
|
|
8045
|
+
let entries;
|
|
8046
|
+
try {
|
|
8047
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
8048
|
+
} catch {
|
|
8049
|
+
return found;
|
|
8050
|
+
}
|
|
8051
|
+
for (const entry of entries) {
|
|
8052
|
+
if (!entry.isDirectory()) continue;
|
|
8053
|
+
try {
|
|
8054
|
+
const x509 = new X509Certificate(fs.readFileSync(path.join(root, entry.name, `${entry.name}.crt`)));
|
|
8055
|
+
const notAfter = new Date(x509.validTo).getTime();
|
|
8056
|
+
const notBefore = new Date(x509.validFrom).getTime();
|
|
8057
|
+
for (const host of namesOf(x509)) found.set(host, {
|
|
8058
|
+
notAfter,
|
|
8059
|
+
lifetimeMs: Math.max(0, notAfter - notBefore)
|
|
8060
|
+
});
|
|
8061
|
+
} catch {}
|
|
8062
|
+
}
|
|
8063
|
+
return found;
|
|
8064
|
+
}
|
|
7352
8065
|
readIssuedCertificates() {
|
|
7353
8066
|
const found = /* @__PURE__ */ new Map();
|
|
7354
8067
|
const root = path.join(this.options.engineDir, "data", "certificates");
|
|
@@ -7406,7 +8119,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7406
8119
|
const identifier = typeof inner.identifier === "string" ? inner.identifier.toLowerCase() : null;
|
|
7407
8120
|
if (identifier === null) continue;
|
|
7408
8121
|
if (inner.logger === "tls.obtain" && typeof inner.error === "string") failures.set(identifier, inner.error);
|
|
7409
|
-
if (inner.msg === "certificate obtained successfully") failures.delete(identifier);
|
|
8122
|
+
if (inner.msg === "certificate obtained successfully" && inner.issuer !== "local") failures.delete(identifier);
|
|
7410
8123
|
}
|
|
7411
8124
|
return failures;
|
|
7412
8125
|
}
|
|
@@ -7448,6 +8161,16 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7448
8161
|
}
|
|
7449
8162
|
}
|
|
7450
8163
|
/** True when a nanny this panel would recognise still owns the engine. */
|
|
8164
|
+
/**
|
|
8165
|
+
* True while a process from the installed binary is alive, however the panel's view
|
|
8166
|
+
* reads. `live()` additionally requires a fresh heartbeat, so a nanny that died and
|
|
8167
|
+
* left its child behind would look gone while the binary is still in use.
|
|
8168
|
+
*/
|
|
8169
|
+
engineRunning() {
|
|
8170
|
+
const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
|
|
8171
|
+
if (state === null || state.serverId !== PROXY_ID) return false;
|
|
8172
|
+
return [state.nannyPid, state.childPid].some((pid) => pid !== null && pid > 0 && isProcessAlive$1(pid));
|
|
8173
|
+
}
|
|
7451
8174
|
live() {
|
|
7452
8175
|
const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
|
|
7453
8176
|
if (state === null || state.serverId !== PROXY_ID) return false;
|
|
@@ -7467,7 +8190,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7467
8190
|
const applied = this.readApplied();
|
|
7468
8191
|
const httpPort = applied?.httpPort ?? config.httpPort;
|
|
7469
8192
|
const httpsPort = applied?.httpsPort ?? config.httpsPort;
|
|
7470
|
-
const urls = [`http://
|
|
8193
|
+
const urls = [`http://127.0.0.1:${httpPort}`, `https://127.0.0.1:${httpsPort}`];
|
|
7471
8194
|
const certExpiryDays = this.certificateExpiryDays();
|
|
7472
8195
|
if (!this.installed()) return {
|
|
7473
8196
|
...base,
|
|
@@ -7536,8 +8259,63 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7536
8259
|
return config;
|
|
7537
8260
|
}
|
|
7538
8261
|
/** Renders the current route table and applies it; the engine keeps serving on failure. */
|
|
7539
|
-
|
|
8262
|
+
/**
|
|
8263
|
+
* Makes the engine ask the CA again for one name, without restarting it.
|
|
8264
|
+
*
|
|
8265
|
+
* Caddy keeps the certificate it holds — in memory and on disk — until the name
|
|
8266
|
+
* leaves its configuration, and the engine's own CA is the last issuer in the
|
|
8267
|
+
* policy, so a name the CA refused is served that untrusted pair and never asks
|
|
8268
|
+
* again until renewal. Reloading the configuration without the route is what makes
|
|
8269
|
+
* the engine let go; the stored pair is dropped while it is out, so putting the
|
|
8270
|
+
* route back leaves nothing to reuse and the engine asks the CA.
|
|
8271
|
+
*
|
|
8272
|
+
* Only this name is out of the configuration, and only for the two reloads: every
|
|
8273
|
+
* other route keeps serving.
|
|
8274
|
+
*/
|
|
8275
|
+
async retryCertificate(routeId) {
|
|
8276
|
+
const route = this.config.routes.find((entry) => entry.id === routeId);
|
|
8277
|
+
if (route === void 0) throw new DetailedError(`unknown route "${routeId}"`, {
|
|
8278
|
+
statusCode: 404,
|
|
8279
|
+
code: "PROXY_ROUTE_UNKNOWN"
|
|
8280
|
+
});
|
|
8281
|
+
if (route.tls !== "auto" || !isPublicHost(route.host)) throw new DetailedError(`"${route.host}" is not a name a CA issues for, so there is nothing to retry`, {
|
|
8282
|
+
statusCode: 400,
|
|
8283
|
+
code: "PROXY_CERT_NOT_MANAGED"
|
|
8284
|
+
});
|
|
8285
|
+
if (!this.installed()) throw new DetailedError("the proxy engine is not installed yet", {
|
|
8286
|
+
statusCode: 400,
|
|
8287
|
+
code: "ENGINE_MISSING"
|
|
8288
|
+
});
|
|
8289
|
+
if (this.status().state !== "running") {
|
|
8290
|
+
this.forgetCertificates(route.host);
|
|
8291
|
+
await this.apply({ force: true });
|
|
8292
|
+
return;
|
|
8293
|
+
}
|
|
8294
|
+
await this.loadIntoEngine(this.render(/* @__PURE__ */ new Set([route.id])));
|
|
8295
|
+
this.forgetCertificates(route.host);
|
|
8296
|
+
await this.apply({ force: true });
|
|
8297
|
+
}
|
|
8298
|
+
/** Removes the engine's stored certificates for one name, and nothing else. */
|
|
8299
|
+
forgetCertificates(host) {
|
|
8300
|
+
const root = path.join(this.options.engineDir, "data", "certificates");
|
|
8301
|
+
let issuers;
|
|
8302
|
+
try {
|
|
8303
|
+
issuers = fs.readdirSync(root);
|
|
8304
|
+
} catch {
|
|
8305
|
+
return;
|
|
8306
|
+
}
|
|
8307
|
+
for (const issuer of issuers) {
|
|
8308
|
+
const dir = path.join(root, issuer, host);
|
|
8309
|
+
if (!dir.startsWith(`${root}${path.sep}`)) continue;
|
|
8310
|
+
fs.rmSync(dir, {
|
|
8311
|
+
recursive: true,
|
|
8312
|
+
force: true
|
|
8313
|
+
});
|
|
8314
|
+
}
|
|
8315
|
+
}
|
|
8316
|
+
async apply(options = {}) {
|
|
7540
8317
|
if (!this.config.enabled || !this.installed()) return;
|
|
8318
|
+
await this.assertDns01Module();
|
|
7541
8319
|
if (this.admin === null) {
|
|
7542
8320
|
const stillThere = this.readAdmin();
|
|
7543
8321
|
if (stillThere === null) return;
|
|
@@ -7545,7 +8323,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7545
8323
|
}
|
|
7546
8324
|
const rendered = this.render();
|
|
7547
8325
|
const text = `${JSON.stringify(rendered, null, 2)}\n`;
|
|
7548
|
-
if (fs.existsSync(this.options.configPath) && fs.readFileSync(this.options.configPath, "utf8") === text) {
|
|
8326
|
+
if (options.force !== true && fs.existsSync(this.options.configPath) && fs.readFileSync(this.options.configPath, "utf8") === text) {
|
|
7549
8327
|
this.lastError = null;
|
|
7550
8328
|
return;
|
|
7551
8329
|
}
|
|
@@ -7566,13 +8344,69 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7566
8344
|
}
|
|
7567
8345
|
this.rememberConfig(text);
|
|
7568
8346
|
this.rememberApplied(this.config.httpPort, this.config.httpsPort);
|
|
8347
|
+
const swept = this.sweepUnroutedCertificates();
|
|
8348
|
+
if (swept > 0) logger.info(`proxy: dropped ${swept} certificate(s) for names no longer routed`);
|
|
7569
8349
|
this.lastError = null;
|
|
7570
8350
|
this.options.onStateChange();
|
|
7571
8351
|
}
|
|
8352
|
+
/**
|
|
8353
|
+
* Certificates the engine holds for names it no longer serves.
|
|
8354
|
+
*
|
|
8355
|
+
* Caddy keeps a certificate until the name leaves its configuration, so a removed
|
|
8356
|
+
* route would leave its pair behind and re-adding the route would serve that stale
|
|
8357
|
+
* certificate instead of asking the CA. A *disabled* route keeps its pair: it is
|
|
8358
|
+
* still configured, and a temporary switch-off should not cost a new issuance.
|
|
8359
|
+
*/
|
|
8360
|
+
sweepUnroutedCertificates() {
|
|
8361
|
+
const managed = new Set(this.config.routes.filter((route) => route.tls !== "off").map((route) => route.host.toLowerCase()));
|
|
8362
|
+
managed.add(FALLBACK_NAME.toLowerCase());
|
|
8363
|
+
const root = path.join(this.options.engineDir, "data", "certificates");
|
|
8364
|
+
let issuers;
|
|
8365
|
+
try {
|
|
8366
|
+
issuers = fs.readdirSync(root);
|
|
8367
|
+
} catch {
|
|
8368
|
+
return 0;
|
|
8369
|
+
}
|
|
8370
|
+
let removed = 0;
|
|
8371
|
+
for (const issuer of issuers) {
|
|
8372
|
+
const dir = path.join(root, issuer);
|
|
8373
|
+
let hosts;
|
|
8374
|
+
try {
|
|
8375
|
+
hosts = fs.readdirSync(dir, { withFileTypes: true });
|
|
8376
|
+
} catch {
|
|
8377
|
+
continue;
|
|
8378
|
+
}
|
|
8379
|
+
for (const host of hosts) {
|
|
8380
|
+
if (!host.isDirectory() || managed.has(host.name.toLowerCase())) continue;
|
|
8381
|
+
fs.rmSync(path.join(dir, host.name), {
|
|
8382
|
+
recursive: true,
|
|
8383
|
+
force: true
|
|
8384
|
+
});
|
|
8385
|
+
removed += 1;
|
|
8386
|
+
}
|
|
8387
|
+
}
|
|
8388
|
+
return removed;
|
|
8389
|
+
}
|
|
8390
|
+
/** Hands a configuration to the running engine without touching the file on disk. */
|
|
8391
|
+
async loadIntoEngine(rendered) {
|
|
8392
|
+
const result = await this.request("POST", "/load", rendered).catch((error) => {
|
|
8393
|
+
throw new DetailedError(`the engine did not accept the configuration: ${error instanceof Error ? error.message : String(error)}`, {
|
|
8394
|
+
statusCode: 502,
|
|
8395
|
+
code: "ENGINE_UNREACHABLE"
|
|
8396
|
+
});
|
|
8397
|
+
});
|
|
8398
|
+
if (result.status >= 400) {
|
|
8399
|
+
const message = engineMessage(result.body) ?? `the engine rejected the configuration (HTTP ${result.status})`;
|
|
8400
|
+
throw new DetailedError(message, {
|
|
8401
|
+
statusCode: 400,
|
|
8402
|
+
code: "PROXY_CONFIG_REJECTED"
|
|
8403
|
+
});
|
|
8404
|
+
}
|
|
8405
|
+
}
|
|
7572
8406
|
/** Advances `previous.json` only for a configuration the engine accepted. */
|
|
7573
8407
|
rememberConfig(text) {
|
|
7574
|
-
if (fs.existsSync(this.options.configPath)) writeFileAtomic(this.options.previousConfigPath, fs.readFileSync(this.options.configPath, "utf8"));
|
|
7575
|
-
writeFileAtomic(this.options.configPath, text);
|
|
8408
|
+
if (fs.existsSync(this.options.configPath)) writeFileAtomic(this.options.previousConfigPath, fs.readFileSync(this.options.configPath, "utf8"), { mode: 384 });
|
|
8409
|
+
writeFileAtomic(this.options.configPath, text, { mode: 384 });
|
|
7576
8410
|
}
|
|
7577
8411
|
/** The ports the engine last bound. */
|
|
7578
8412
|
readApplied() {
|
|
@@ -7641,9 +8475,9 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7641
8475
|
code: "PROXY_CONFIG_REJECTED"
|
|
7642
8476
|
});
|
|
7643
8477
|
}
|
|
7644
|
-
writeFileAtomic(this.options.configPath, previous);
|
|
8478
|
+
writeFileAtomic(this.options.configPath, previous, { mode: 384 });
|
|
7645
8479
|
if (current === null) fs.rmSync(this.options.previousConfigPath, { force: true });
|
|
7646
|
-
else writeFileAtomic(this.options.previousConfigPath, current);
|
|
8480
|
+
else writeFileAtomic(this.options.previousConfigPath, current, { mode: 384 });
|
|
7647
8481
|
this.lastError = null;
|
|
7648
8482
|
this.options.onStateChange();
|
|
7649
8483
|
}
|
|
@@ -7651,19 +8485,20 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7651
8485
|
* The engine configuration for the current route table. Throws when a route cannot
|
|
7652
8486
|
* be built, because a half-built configuration is worse than a refused one.
|
|
7653
8487
|
*/
|
|
7654
|
-
render() {
|
|
7655
|
-
const blocked = this.resolveRoutes().find((view) => view.status === "error");
|
|
8488
|
+
render(exclude) {
|
|
8489
|
+
const blocked = this.resolveRoutes(exclude).find((view) => view.status === "error");
|
|
7656
8490
|
if (blocked !== void 0) throw new DetailedError(blocked.message ?? `${blocked.route.host} cannot be routed`, {
|
|
7657
8491
|
statusCode: 400,
|
|
7658
8492
|
code: "PROXY_ROUTE_INVALID"
|
|
7659
8493
|
});
|
|
7660
|
-
const routes = this.engineRoutes();
|
|
8494
|
+
const routes = this.engineRoutes(exclude);
|
|
7661
8495
|
return renderCaddyConfig({
|
|
7662
8496
|
config: this.config,
|
|
7663
8497
|
admin: this.admin ?? this.unixAdmin(),
|
|
7664
8498
|
engineDir: this.options.engineDir,
|
|
7665
8499
|
manual: this.manualPairs(),
|
|
7666
8500
|
acme: this.acmeAccount(routes),
|
|
8501
|
+
dns01: this.dns01Input(routes),
|
|
7667
8502
|
routes
|
|
7668
8503
|
});
|
|
7669
8504
|
}
|
|
@@ -7684,9 +8519,9 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7684
8519
|
};
|
|
7685
8520
|
}
|
|
7686
8521
|
/** Every route, resolved far enough to render. */
|
|
7687
|
-
resolveRoutes() {
|
|
8522
|
+
resolveRoutes(exclude) {
|
|
7688
8523
|
const seen = /* @__PURE__ */ new Map();
|
|
7689
|
-
return this.config.routes.map((route) => {
|
|
8524
|
+
return this.config.routes.filter((route) => exclude?.has(route.id) !== true).map((route) => {
|
|
7690
8525
|
const key = `${route.host.toLowerCase()}${route.path}`;
|
|
7691
8526
|
const clash = seen.get(key);
|
|
7692
8527
|
if (clash !== void 0) return {
|
|
@@ -7780,8 +8615,8 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7780
8615
|
});
|
|
7781
8616
|
}
|
|
7782
8617
|
/** Only the routes the engine can serve, in the shape the renderer wants. */
|
|
7783
|
-
engineRoutes() {
|
|
7784
|
-
return this.resolveRoutes().filter((resolved) => resolved.status === "ok" && resolved.upstream !== null).map((resolved) => {
|
|
8618
|
+
engineRoutes(exclude) {
|
|
8619
|
+
return this.resolveRoutes(exclude).filter((resolved) => resolved.status === "ok" && resolved.upstream !== null).map((resolved) => {
|
|
7785
8620
|
const certificate = this.certificateFor(resolved.route);
|
|
7786
8621
|
return {
|
|
7787
8622
|
host: resolved.route.host,
|
|
@@ -7813,7 +8648,8 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7813
8648
|
engines: proxyEngineInfos(),
|
|
7814
8649
|
status: this.status(),
|
|
7815
8650
|
routes: this.routeViews(),
|
|
7816
|
-
certificates: this.certificateViews()
|
|
8651
|
+
certificates: this.certificateViews(),
|
|
8652
|
+
dnsAccounts: this.options.listDnsAccounts?.() ?? []
|
|
7817
8653
|
};
|
|
7818
8654
|
}
|
|
7819
8655
|
installed() {
|
|
@@ -7825,8 +8661,18 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7825
8661
|
}
|
|
7826
8662
|
readEngineRecord() {
|
|
7827
8663
|
try {
|
|
7828
|
-
const parsed =
|
|
7829
|
-
|
|
8664
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(this.options.binDir, "engine.json"), "utf8"));
|
|
8665
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
8666
|
+
const record = parsed;
|
|
8667
|
+
if ("sha256" in record) {
|
|
8668
|
+
delete record.sha256;
|
|
8669
|
+
if (!this.legacyChecksumWarned) {
|
|
8670
|
+
this.legacyChecksumWarned = true;
|
|
8671
|
+
logger.warn("proxy: dropped the recorded engine checksum; nothing verifies it");
|
|
8672
|
+
}
|
|
8673
|
+
}
|
|
8674
|
+
const parsedRecord = engineRecordSchema(record);
|
|
8675
|
+
return parsedRecord instanceof type.errors ? null : parsedRecord;
|
|
7830
8676
|
} catch {
|
|
7831
8677
|
return null;
|
|
7832
8678
|
}
|
|
@@ -7840,7 +8686,6 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7840
8686
|
source: null,
|
|
7841
8687
|
path: null,
|
|
7842
8688
|
bytes: null,
|
|
7843
|
-
sha256: null,
|
|
7844
8689
|
error: null
|
|
7845
8690
|
};
|
|
7846
8691
|
let stats;
|
|
@@ -7856,14 +8701,17 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7856
8701
|
version: record?.version ?? null,
|
|
7857
8702
|
source: record?.source ?? (stats.isFile() ? "custom" : null),
|
|
7858
8703
|
path: path_,
|
|
7859
|
-
bytes: record?.bytes ?? stats.size
|
|
7860
|
-
sha256: record?.sha256 ?? null
|
|
8704
|
+
bytes: record?.bytes ?? stats.size
|
|
7861
8705
|
};
|
|
7862
8706
|
}
|
|
7863
8707
|
/**
|
|
7864
|
-
* Downloads a
|
|
7865
|
-
*
|
|
7866
|
-
*
|
|
8708
|
+
* Downloads a release from the engine's own build service. Plugins are compiled
|
|
8709
|
+
* in server-side, so no toolchain is involved. An empty `version` asks for the
|
|
8710
|
+
* current release, which is how a security fix reaches the user by pressing Update.
|
|
8711
|
+
*
|
|
8712
|
+
* Refused while the engine is up: the binary is replaced on disk, and Windows
|
|
8713
|
+
* will not replace a running executable at all. Stopping first is the caller's
|
|
8714
|
+
* move, so the panel never drops a socket that faces the internet on its own.
|
|
7867
8715
|
*/
|
|
7868
8716
|
async install(version = "") {
|
|
7869
8717
|
const engine = this.engine;
|
|
@@ -7871,6 +8719,10 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7871
8719
|
statusCode: 400,
|
|
7872
8720
|
code: "INVALID_ENGINE_VERSION"
|
|
7873
8721
|
});
|
|
8722
|
+
if (this.engineRunning()) throw new DetailedError(`the ${engine.info.label} engine is still running — stop it before installing another build`, {
|
|
8723
|
+
statusCode: 409,
|
|
8724
|
+
code: "ENGINE_BUSY"
|
|
8725
|
+
});
|
|
7874
8726
|
const download = engine.download({
|
|
7875
8727
|
platform: process.platform,
|
|
7876
8728
|
arch: process.arch,
|
|
@@ -7894,13 +8746,11 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7894
8746
|
statusCode: 502,
|
|
7895
8747
|
code: "ENGINE_DOWNLOAD_FAILED"
|
|
7896
8748
|
});
|
|
7897
|
-
const hash = createHash("sha256");
|
|
7898
8749
|
const handle = fs.openSync(target, "w", 493);
|
|
7899
8750
|
let bytes = 0;
|
|
7900
8751
|
try {
|
|
7901
8752
|
for await (const chunk of response.body) {
|
|
7902
8753
|
const buffer = Buffer$1.isBuffer(chunk) ? chunk : Buffer$1.from(chunk);
|
|
7903
|
-
hash.update(buffer);
|
|
7904
8754
|
bytes += buffer.length;
|
|
7905
8755
|
fs.writeSync(handle, buffer);
|
|
7906
8756
|
}
|
|
@@ -7914,30 +8764,33 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7914
8764
|
code: "ENGINE_DOWNLOAD_FAILED"
|
|
7915
8765
|
});
|
|
7916
8766
|
}
|
|
7917
|
-
const sha256 = hash.digest("hex");
|
|
7918
8767
|
if (process.platform !== "win32") fs.chmodSync(target, 493);
|
|
7919
8768
|
fs.renameSync(target, this.enginePath);
|
|
7920
8769
|
if (process.platform !== "win32") fs.chmodSync(this.enginePath, 493);
|
|
7921
8770
|
const probed = await this.probeEngineVersion();
|
|
7922
8771
|
const record = {
|
|
7923
8772
|
engine: engine.info.id,
|
|
7924
|
-
version: probed ?? download.version,
|
|
8773
|
+
version: probed ?? (download.version.length > 0 ? download.version : "unknown"),
|
|
7925
8774
|
source: "downloaded",
|
|
7926
8775
|
url: download.url,
|
|
7927
|
-
sha256,
|
|
7928
8776
|
bytes,
|
|
7929
8777
|
installedAt: Date.now()
|
|
7930
8778
|
};
|
|
7931
8779
|
writeFileAtomic(path.join(this.options.binDir, "engine.json"), `${JSON.stringify(record, null, 2)}\n`, { mode: 384 });
|
|
8780
|
+
this.moduleCache = null;
|
|
7932
8781
|
logger.info(`proxy: installed ${engine.info.label} ${record.version} (${Math.round(bytes / 1024 / 1024)} MB)`);
|
|
7933
8782
|
this.options.onStateChange();
|
|
7934
8783
|
return this.engineStatus();
|
|
7935
8784
|
}
|
|
7936
|
-
/**
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
8785
|
+
/**
|
|
8786
|
+
* Runs the binary for its stdout, with a deadline. The engine is operator-supplied
|
|
8787
|
+
* — a replaced or wrapped binary that never exits must not hang `start()` or the
|
|
8788
|
+
* request that triggered it, so a timeout kills it and reads as "no answer".
|
|
8789
|
+
*/
|
|
8790
|
+
runEngine(args, timeoutMs) {
|
|
8791
|
+
if (!this.installed()) return Promise.resolve(null);
|
|
8792
|
+
return new Promise((resolve) => {
|
|
8793
|
+
const child = spawn(this.enginePath, args, {
|
|
7941
8794
|
stdio: [
|
|
7942
8795
|
"ignore",
|
|
7943
8796
|
"pipe",
|
|
@@ -7946,11 +8799,59 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7946
8799
|
windowsHide: true
|
|
7947
8800
|
});
|
|
7948
8801
|
let output = "";
|
|
8802
|
+
let settled = false;
|
|
8803
|
+
let timer;
|
|
8804
|
+
let killTimer;
|
|
8805
|
+
const finish = (value) => {
|
|
8806
|
+
if (settled) return;
|
|
8807
|
+
settled = true;
|
|
8808
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
8809
|
+
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
8810
|
+
resolve(value);
|
|
8811
|
+
};
|
|
8812
|
+
timer = setTimeout(() => {
|
|
8813
|
+
child.kill();
|
|
8814
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 500);
|
|
8815
|
+
killTimer.unref();
|
|
8816
|
+
finish(null);
|
|
8817
|
+
}, timeoutMs);
|
|
7949
8818
|
child.stdout?.on("data", (chunk) => {
|
|
7950
8819
|
output += chunk.toString("utf8");
|
|
7951
8820
|
});
|
|
7952
|
-
child.on("error", () =>
|
|
7953
|
-
child.on("exit", () =>
|
|
8821
|
+
child.on("error", () => finish(null));
|
|
8822
|
+
child.on("exit", () => finish(output));
|
|
8823
|
+
});
|
|
8824
|
+
}
|
|
8825
|
+
/** Runs `version` once and reports what the binary actually is. */
|
|
8826
|
+
async probeEngineVersion() {
|
|
8827
|
+
const output = await this.runEngine(["version"], this.options.engineCommandTimeoutMs ?? ENGINE_COMMAND_TIMEOUT_MS);
|
|
8828
|
+
return output === null ? null : this.engine.parseVersion(output);
|
|
8829
|
+
}
|
|
8830
|
+
/**
|
|
8831
|
+
* The modules the installed binary carries, by module name.
|
|
8832
|
+
*
|
|
8833
|
+
* A build from an earlier release has no ACMEProxy module, and a configuration
|
|
8834
|
+
* that names it fails inside the engine with something a person cannot act on.
|
|
8835
|
+
* `list-modules` answers in a few milliseconds, so asking is cheaper than the
|
|
8836
|
+
* support question it prevents.
|
|
8837
|
+
*/
|
|
8838
|
+
async installedModules() {
|
|
8839
|
+
if (!this.installed()) return null;
|
|
8840
|
+
if (this.moduleCache !== null) return this.moduleCache;
|
|
8841
|
+
const output = await this.runEngine(["list-modules"], this.options.engineCommandTimeoutMs ?? ENGINE_COMMAND_TIMEOUT_MS);
|
|
8842
|
+
if (output === null) return null;
|
|
8843
|
+
const modules = new Set(output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0));
|
|
8844
|
+
this.moduleCache = modules;
|
|
8845
|
+
return modules;
|
|
8846
|
+
}
|
|
8847
|
+
/** The module the panel's DNS-01 configuration needs the engine to carry. */
|
|
8848
|
+
async assertDns01Module() {
|
|
8849
|
+
if (!this.config.dns01.enabled) return;
|
|
8850
|
+
const modules = await this.installedModules();
|
|
8851
|
+
if (modules === null || modules.has(DNS01_MODULE)) return;
|
|
8852
|
+
throw new DetailedError(`the installed ${this.engine.info.label} build has no ${DNS01_MODULE} module — update the engine to enable DNS-01`, {
|
|
8853
|
+
statusCode: 400,
|
|
8854
|
+
code: "ENGINE_MODULE_NOT_INSTALLED"
|
|
7954
8855
|
});
|
|
7955
8856
|
}
|
|
7956
8857
|
async start() {
|
|
@@ -7968,6 +8869,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7968
8869
|
statusCode: 400,
|
|
7969
8870
|
code: "ENGINE_MISSING"
|
|
7970
8871
|
});
|
|
8872
|
+
await this.assertDns01Module();
|
|
7971
8873
|
if (this.live()) {
|
|
7972
8874
|
const admin = this.readAdmin();
|
|
7973
8875
|
if (admin === null) throw new DetailedError("the engine is already running but its admin endpoint is unknown — stop it first", {
|
|
@@ -7993,6 +8895,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7993
8895
|
engineDir: this.options.engineDir,
|
|
7994
8896
|
manual: this.manualPairs(),
|
|
7995
8897
|
acme: this.acmeAccount(routes),
|
|
8898
|
+
dns01: this.dns01Input(routes),
|
|
7996
8899
|
routes
|
|
7997
8900
|
});
|
|
7998
8901
|
fs.mkdirSync(this.options.engineDir, {
|
|
@@ -8099,6 +9002,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8099
9002
|
this.admin = null;
|
|
8100
9003
|
this.certCache = null;
|
|
8101
9004
|
this.certificateSignature = null;
|
|
9005
|
+
this.lastError = null;
|
|
8102
9006
|
fs.rmSync(nannySpecPath(this.options.stateDir, PROXY_ID), { force: true });
|
|
8103
9007
|
fs.rmSync(this.options.adminPath, { force: true });
|
|
8104
9008
|
fs.rmSync(path.join(this.options.stateDir, "applied.json"), { force: true });
|
|
@@ -8249,7 +9153,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8249
9153
|
/** The patch, applied to the live config the way the store would apply it. */
|
|
8250
9154
|
function candidateConfig(current, patch) {
|
|
8251
9155
|
const merged = { ...current };
|
|
8252
|
-
|
|
9156
|
+
applyPatch(merged, patch, PROXY_MERGE_KEYS);
|
|
8253
9157
|
const parsed = proxyConfigSchema(merged);
|
|
8254
9158
|
if (parsed instanceof type.errors) throw new DetailedError(parsed.summary, {
|
|
8255
9159
|
statusCode: 400,
|
|
@@ -8267,7 +9171,7 @@ function createProxyRoute(deps) {
|
|
|
8267
9171
|
const view = () => proxy().view();
|
|
8268
9172
|
/** Everything that has to hold before a change is written to disk. */
|
|
8269
9173
|
const guard = (candidate) => {
|
|
8270
|
-
const problems = validateProxyConfig(candidate);
|
|
9174
|
+
const problems = validateProxyConfig(candidate, (ref, workspaceId) => proxy().checkDnsAccount(ref, workspaceId));
|
|
8271
9175
|
if (problems.length > 0) throw new DetailedError(`invalid reverse proxy configuration: ${problems.join("; ")}`, {
|
|
8272
9176
|
statusCode: 400,
|
|
8273
9177
|
code: "INVALID_PROXY",
|
|
@@ -8363,6 +9267,20 @@ function createProxyRoute(deps) {
|
|
|
8363
9267
|
}), async (c) => {
|
|
8364
9268
|
await proxy().stop();
|
|
8365
9269
|
return c.json(view());
|
|
9270
|
+
}).post("/proxy/routes/:id/retry-certificate", describeRoute({
|
|
9271
|
+
tags: ["proxy"],
|
|
9272
|
+
summary: "Make the engine ask the CA again for one route",
|
|
9273
|
+
responses: {
|
|
9274
|
+
200: {
|
|
9275
|
+
description: "Retried",
|
|
9276
|
+
content: jsonBody(proxyViewSchema)
|
|
9277
|
+
},
|
|
9278
|
+
400: ERROR_RESPONSES[400],
|
|
9279
|
+
404: ERROR_RESPONSES[404]
|
|
9280
|
+
}
|
|
9281
|
+
}), async (c) => {
|
|
9282
|
+
await proxy().retryCertificate(c.req.param("id"));
|
|
9283
|
+
return c.json(view());
|
|
8366
9284
|
}).post("/proxy/apply", describeRoute({
|
|
8367
9285
|
tags: ["proxy"],
|
|
8368
9286
|
summary: "Regenerate and reload the engine configuration",
|
|
@@ -8427,6 +9345,7 @@ function createProxyRoute(deps) {
|
|
|
8427
9345
|
}
|
|
8428
9346
|
var certificateParam;
|
|
8429
9347
|
var init_proxy = __esmMin((() => {
|
|
9348
|
+
init_patch();
|
|
8430
9349
|
init_settings$1();
|
|
8431
9350
|
init_factory();
|
|
8432
9351
|
init_openapi$1();
|
|
@@ -9295,10 +10214,11 @@ function createRootApp(deps) {
|
|
|
9295
10214
|
const started = Date.now();
|
|
9296
10215
|
await next();
|
|
9297
10216
|
logger.debug(`${c.req.method} ${new URL(c.req.url).pathname} ${c.res.status} ${Date.now() - started}ms`);
|
|
9298
|
-
}).onError(errorHandler).route("/_hh", createControlRoute(deps)).use("/api/*", createAuthGuard({ auth: deps.auth })).route("/api", createAuthRoute(deps)).route("/api", createStateRoute(deps)).route("/api", createEventsRoute(deps)).route("/api", createSettingsRoute(deps)).route("/api", createTlsRoute(deps)).route("/api", createLogsRoute(deps)).route("/api", createNotificationsRoute(deps)).route("/api", createDdnsRoute(deps)).route("/api", createProxyRoute(deps)).route("/api", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/workspaces", createWorkspacesRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
|
|
10217
|
+
}).onError(errorHandler).route("/_hh", createControlRoute(deps)).route("/_acme", createAcmeChallengeRoute(deps)).use("/api/*", createAuthGuard({ auth: deps.auth })).route("/api", createAuthRoute(deps)).route("/api", createStateRoute(deps)).route("/api", createEventsRoute(deps)).route("/api", createSettingsRoute(deps)).route("/api", createTlsRoute(deps)).route("/api", createLogsRoute(deps)).route("/api", createNotificationsRoute(deps)).route("/api", createDdnsRoute(deps)).route("/api", createProxyRoute(deps)).route("/api", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/workspaces", createWorkspacesRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
|
|
9299
10218
|
return app.route("/", setupOpenAPI(app)).route("/", createStaticRoute({ dir: () => deps.ui.resolveDir() }));
|
|
9300
10219
|
}
|
|
9301
10220
|
var init_app = __esmMin((() => {
|
|
10221
|
+
init_acme();
|
|
9302
10222
|
init_$_routes$2();
|
|
9303
10223
|
init_backups$1();
|
|
9304
10224
|
init_control();
|
|
@@ -9695,6 +10615,100 @@ function resolveRecord(record, vars) {
|
|
|
9695
10615
|
}
|
|
9696
10616
|
var init_template = __esmMin((() => {}));
|
|
9697
10617
|
//#endregion
|
|
10618
|
+
//#region src/services/acme-challenge.ts
|
|
10619
|
+
function reply(message, status = 200) {
|
|
10620
|
+
return new Response(JSON.stringify(message), {
|
|
10621
|
+
status,
|
|
10622
|
+
headers: { "content-type": "application/json" }
|
|
10623
|
+
});
|
|
10624
|
+
}
|
|
10625
|
+
function failure(status, message) {
|
|
10626
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
10627
|
+
status,
|
|
10628
|
+
headers: { "content-type": "application/json" }
|
|
10629
|
+
});
|
|
10630
|
+
}
|
|
10631
|
+
/** Constant-time, and length-safe: `timingSafeEqual` throws on differing lengths. */
|
|
10632
|
+
function matches(actual, expected) {
|
|
10633
|
+
const left = Buffer$1.from(actual, "utf8");
|
|
10634
|
+
const right = Buffer$1.from(expected, "utf8");
|
|
10635
|
+
if (left.length !== right.length) return false;
|
|
10636
|
+
return crypto.timingSafeEqual(left, right);
|
|
10637
|
+
}
|
|
10638
|
+
var proxyMessageSchema, AcmeChallengeService;
|
|
10639
|
+
var init_acme_challenge = __esmMin((() => {
|
|
10640
|
+
init_ddns$2();
|
|
10641
|
+
init_types();
|
|
10642
|
+
proxyMessageSchema = type({
|
|
10643
|
+
fqdn: "string >= 1",
|
|
10644
|
+
value: "string >= 1"
|
|
10645
|
+
}).onUndeclaredKey("reject");
|
|
10646
|
+
AcmeChallengeService = class {
|
|
10647
|
+
options;
|
|
10648
|
+
constructor(options) {
|
|
10649
|
+
this.options = options;
|
|
10650
|
+
}
|
|
10651
|
+
async handle(request, action) {
|
|
10652
|
+
if (action !== "present" && action !== "cleanup") return failure(404, `unknown action "${action}"`);
|
|
10653
|
+
const auth = this.options.auth();
|
|
10654
|
+
if (auth === null) return failure(403, "DNS-01 is not switched on for this panel");
|
|
10655
|
+
if (!this.authorized(request, auth)) return failure(401, "authentication required");
|
|
10656
|
+
let parsed;
|
|
10657
|
+
try {
|
|
10658
|
+
parsed = await request.json();
|
|
10659
|
+
} catch {
|
|
10660
|
+
return failure(400, "the body must be JSON");
|
|
10661
|
+
}
|
|
10662
|
+
const message = proxyMessageSchema(parsed);
|
|
10663
|
+
if (message instanceof type.errors) return failure(400, "the body must carry fqdn and value");
|
|
10664
|
+
const fqdn = normalizeHost(message.fqdn);
|
|
10665
|
+
const account = this.options.accountFor(fqdn);
|
|
10666
|
+
if (account === null) return failure(404, `no DNS account answers for ${fqdn}`);
|
|
10667
|
+
const provider = ddnsProvider(account.provider);
|
|
10668
|
+
if (provider?.challenge === void 0) return failure(400, `the "${account.provider}" account cannot write TXT records`);
|
|
10669
|
+
const record = {
|
|
10670
|
+
fqdn,
|
|
10671
|
+
value: message.value,
|
|
10672
|
+
action
|
|
10673
|
+
};
|
|
10674
|
+
const result = await provider.challenge(record, {
|
|
10675
|
+
credentials: account.credentials,
|
|
10676
|
+
fetch: this.options.fetchImpl ?? fetch
|
|
10677
|
+
});
|
|
10678
|
+
this.options.onResult?.({
|
|
10679
|
+
fqdn,
|
|
10680
|
+
action,
|
|
10681
|
+
ok: result.ok,
|
|
10682
|
+
message: result.message,
|
|
10683
|
+
account
|
|
10684
|
+
});
|
|
10685
|
+
if (!result.ok && action === "cleanup") return reply({
|
|
10686
|
+
fqdn: message.fqdn,
|
|
10687
|
+
value: message.value
|
|
10688
|
+
});
|
|
10689
|
+
if (!result.ok) return failure(502, result.message);
|
|
10690
|
+
return reply({
|
|
10691
|
+
fqdn: message.fqdn,
|
|
10692
|
+
value: message.value
|
|
10693
|
+
});
|
|
10694
|
+
}
|
|
10695
|
+
authorized(request, auth) {
|
|
10696
|
+
const header = request.headers.get("authorization") ?? "";
|
|
10697
|
+
if (!header.toLowerCase().startsWith("basic ")) return false;
|
|
10698
|
+
try {
|
|
10699
|
+
const decoded = Buffer$1.from(header.slice(6).trim(), "base64").toString("utf8");
|
|
10700
|
+
const separator = decoded.indexOf(":");
|
|
10701
|
+
if (separator < 0) return false;
|
|
10702
|
+
const user = decoded.slice(0, separator);
|
|
10703
|
+
const password = decoded.slice(separator + 1);
|
|
10704
|
+
return matches(user, auth.username) && matches(password, auth.password);
|
|
10705
|
+
} catch {
|
|
10706
|
+
return false;
|
|
10707
|
+
}
|
|
10708
|
+
}
|
|
10709
|
+
};
|
|
10710
|
+
}));
|
|
10711
|
+
//#endregion
|
|
9698
10712
|
//#region src/helpers/env-file.ts
|
|
9699
10713
|
/** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
|
|
9700
10714
|
function parseEnvFile(text) {
|
|
@@ -13287,7 +14301,12 @@ var init_ddns = __esmMin((() => {
|
|
|
13287
14301
|
...cache.recordId === void 0 ? {} : { recordId: cache.recordId }
|
|
13288
14302
|
} }
|
|
13289
14303
|
};
|
|
13290
|
-
const
|
|
14304
|
+
const update = provider.update;
|
|
14305
|
+
if (update === void 0) {
|
|
14306
|
+
this.remember(key, "skipped", address, `the "${account.provider}" provider does not update addresses`);
|
|
14307
|
+
continue;
|
|
14308
|
+
}
|
|
14309
|
+
const result = await update(record, {
|
|
13291
14310
|
credentials: credentials ?? {},
|
|
13292
14311
|
fetch: fetchImpl,
|
|
13293
14312
|
signal
|
|
@@ -13845,6 +14864,7 @@ var init_panel = __esmMin((() => {
|
|
|
13845
14864
|
init_logger();
|
|
13846
14865
|
init_paths();
|
|
13847
14866
|
init_version();
|
|
14867
|
+
init_ddns$2();
|
|
13848
14868
|
init_config_watch();
|
|
13849
14869
|
init_ddns();
|
|
13850
14870
|
init_history();
|
|
@@ -13904,6 +14924,44 @@ var init_panel = __esmMin((() => {
|
|
|
13904
14924
|
config
|
|
13905
14925
|
};
|
|
13906
14926
|
}
|
|
14927
|
+
/**
|
|
14928
|
+
* One workspace's DDNS account, credentials included, for the panel-wide proxy.
|
|
14929
|
+
*
|
|
14930
|
+
* The account stays where it lives — a workspace owns its secrets and its
|
|
14931
|
+
* notification routing — so the proxy references it instead of holding a copy.
|
|
14932
|
+
*/
|
|
14933
|
+
findDnsAccount(workspaceId, accountId) {
|
|
14934
|
+
const workspace = this.runtimes.get(workspaceId);
|
|
14935
|
+
if (workspace === void 0) return null;
|
|
14936
|
+
const account = workspace.store.ddns.accounts.find((entry) => entry.id === accountId);
|
|
14937
|
+
if (account === void 0) return null;
|
|
14938
|
+
return {
|
|
14939
|
+
workspace,
|
|
14940
|
+
account,
|
|
14941
|
+
credentials: workspace.secrets.getDdnsCredentials(accountId, account.provider)?.values ?? null
|
|
14942
|
+
};
|
|
14943
|
+
}
|
|
14944
|
+
/**
|
|
14945
|
+
* Every DNS account a proxy route may name, across every workspace.
|
|
14946
|
+
*
|
|
14947
|
+
* A route references `<workspace>/<account>`, so the picker needs all of them; the
|
|
14948
|
+
* account itself never leaves the workspace that owns it.
|
|
14949
|
+
*/
|
|
14950
|
+
listDnsAccounts() {
|
|
14951
|
+
const accounts = [];
|
|
14952
|
+
for (const workspace of this.runtimes.values()) for (const account of workspace.store.ddns.accounts) {
|
|
14953
|
+
const provider = ddnsProvider(account.provider);
|
|
14954
|
+
accounts.push({
|
|
14955
|
+
workspace: workspace.id,
|
|
14956
|
+
account: account.id,
|
|
14957
|
+
provider: account.provider,
|
|
14958
|
+
label: account.label,
|
|
14959
|
+
writesTxt: provider?.challenge !== void 0,
|
|
14960
|
+
hasCredentials: workspace.secrets.getDdnsCredentials(account.id, account.provider) !== null
|
|
14961
|
+
});
|
|
14962
|
+
}
|
|
14963
|
+
return accounts;
|
|
14964
|
+
}
|
|
13907
14965
|
serverViews(workspaceId) {
|
|
13908
14966
|
if (workspaceId !== void 0) return this.runtimes.get(workspaceId)?.supervisor.views() ?? [];
|
|
13909
14967
|
return this.workspaces().flatMap((runtime) => runtime.supervisor.views());
|
|
@@ -15000,6 +16058,16 @@ async function runControlPlane(options) {
|
|
|
15000
16058
|
message: null
|
|
15001
16059
|
};
|
|
15002
16060
|
},
|
|
16061
|
+
resolveDnsAccount: (workspaceId, accountId) => {
|
|
16062
|
+
const found = panel?.findDnsAccount(workspaceId, accountId);
|
|
16063
|
+
if (found === void 0 || found === null) return null;
|
|
16064
|
+
return {
|
|
16065
|
+
provider: found.account.provider,
|
|
16066
|
+
credentials: found.credentials
|
|
16067
|
+
};
|
|
16068
|
+
},
|
|
16069
|
+
listDnsAccounts: () => panel?.listDnsAccounts() ?? [],
|
|
16070
|
+
defaultWorkspaceId: () => registry.defaultId,
|
|
15003
16071
|
exposureBlocked: () => checkProxyExposure(settings.proxy, settings.control.auth.enabled, auth.passwordSet, auth.usingDefaultPassword),
|
|
15004
16072
|
onStateChange: () => panel?.notifyStateChange()
|
|
15005
16073
|
});
|
|
@@ -15060,6 +16128,13 @@ async function runControlPlane(options) {
|
|
|
15060
16128
|
backups,
|
|
15061
16129
|
ui,
|
|
15062
16130
|
proxy,
|
|
16131
|
+
challenge: new AcmeChallengeService({
|
|
16132
|
+
auth: () => proxy.config.dns01.enabled ? proxy.challengeAuth() : null,
|
|
16133
|
+
accountFor: (fqdn) => proxy.challengeAccount(fqdn),
|
|
16134
|
+
onResult: (result) => {
|
|
16135
|
+
if (!result.ok) logger.warn(`proxy: the DNS-01 challenge for ${result.fqdn} failed (${result.action}): ${result.message}`);
|
|
16136
|
+
}
|
|
16137
|
+
}),
|
|
15063
16138
|
runtimeToken: token,
|
|
15064
16139
|
onShutdown: () => shutdown("shutdown requested locally")
|
|
15065
16140
|
});
|
|
@@ -15157,6 +16232,7 @@ var init_src = __esmMin((() => {
|
|
|
15157
16232
|
init_template();
|
|
15158
16233
|
init_version();
|
|
15159
16234
|
init_port();
|
|
16235
|
+
init_acme_challenge();
|
|
15160
16236
|
init_auth$1();
|
|
15161
16237
|
init_backups();
|
|
15162
16238
|
init_config_watch();
|
|
@@ -16581,34 +17657,42 @@ async function runNanny(spec, statePath) {
|
|
|
16581
17657
|
text
|
|
16582
17658
|
});
|
|
16583
17659
|
};
|
|
17660
|
+
let stopping = false;
|
|
17661
|
+
let pending = null;
|
|
17662
|
+
let spawned = null;
|
|
17663
|
+
const stopChild = (child, signal) => {
|
|
17664
|
+
emit("system", `${signal} — stopping this entry (grace ${spec.stop.graceMs}ms)`);
|
|
17665
|
+
terminate(child, {
|
|
17666
|
+
signal: spec.stop.signal,
|
|
17667
|
+
killGroup: false,
|
|
17668
|
+
graceMs: spec.stop.graceMs
|
|
17669
|
+
});
|
|
17670
|
+
};
|
|
17671
|
+
const onSignal = (signal) => {
|
|
17672
|
+
if (stopping) return;
|
|
17673
|
+
stopping = true;
|
|
17674
|
+
if (spawned === null) pending = signal;
|
|
17675
|
+
else stopChild(spawned, signal);
|
|
17676
|
+
};
|
|
17677
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
17678
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
16584
17679
|
const child = spawnManaged({
|
|
16585
17680
|
command: spec.command,
|
|
16586
17681
|
args: spec.args,
|
|
16587
17682
|
cwd: spec.cwd,
|
|
16588
17683
|
env: spec.env
|
|
16589
17684
|
}, { detached: false });
|
|
17685
|
+
spawned = child;
|
|
16590
17686
|
childPid = child.pid ?? null;
|
|
16591
17687
|
persistState();
|
|
16592
17688
|
emit("system", `persistent: nanny pid ${process.pid} runs this entry`);
|
|
17689
|
+
if (pending !== null) stopChild(child, pending);
|
|
16593
17690
|
const stdout = new LineSplitter((stream, text) => emit(stream, text));
|
|
16594
17691
|
const stderr = new LineSplitter((stream, text) => emit(stream, text));
|
|
16595
17692
|
child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
|
|
16596
17693
|
child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
|
|
16597
17694
|
const heartbeat = setInterval(persistState, NANNY_HEARTBEAT_MS);
|
|
16598
17695
|
heartbeat.unref();
|
|
16599
|
-
let stopping = false;
|
|
16600
|
-
const onSignal = (signal) => {
|
|
16601
|
-
if (stopping) return;
|
|
16602
|
-
stopping = true;
|
|
16603
|
-
emit("system", `${signal} — stopping this entry (grace ${spec.stop.graceMs}ms)`);
|
|
16604
|
-
terminate(child, {
|
|
16605
|
-
signal: spec.stop.signal,
|
|
16606
|
-
killGroup: false,
|
|
16607
|
-
graceMs: spec.stop.graceMs
|
|
16608
|
-
});
|
|
16609
|
-
};
|
|
16610
|
-
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
16611
|
-
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
16612
17696
|
await new Promise((resolve) => {
|
|
16613
17697
|
let settled = false;
|
|
16614
17698
|
const finish = (code, signal, runtimeMs, note) => {
|