home-hosted 0.6.6 → 0.6.8
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 +37 -20
- package/README.md +28 -12
- package/dist/cli.js +3010 -898
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +84 -0
- package/docs/NOTIFICATIONS.md +3 -0
- package/docs/UI_CREATION.md +1 -1
- package/package.json +2 -1
- package/uis/stock/dist/assets/index-B_4sGjNj.css +2 -0
- package/uis/stock/dist/assets/{index-B8A85L38.js → index-BuB3VX_S.js} +7 -7
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/assets/index-DMo6krK0.css +0 -2
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
22
22
|
import { Scalar } from "@scalar/hono-api-reference";
|
|
23
23
|
import http from "node:http";
|
|
24
24
|
import https from "node:https";
|
|
25
|
-
import net from "node:net";
|
|
25
|
+
import net, { isIP } from "node:net";
|
|
26
26
|
import { parseArgs, promisify } from "node:util";
|
|
27
27
|
import { Readable, Writable } from "node:stream";
|
|
28
28
|
import { finished } from "node:stream/promises";
|
|
@@ -391,7 +391,7 @@ function parseBind(value) {
|
|
|
391
391
|
const parsed = bindSchema(value);
|
|
392
392
|
return parsed instanceof type.errors ? null : parsed;
|
|
393
393
|
}
|
|
394
|
-
var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
394
|
+
var bindSchema, 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, ddnsPatchSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
395
395
|
var init_contracts = __esmMin((() => {
|
|
396
396
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
397
397
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
@@ -510,6 +510,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
510
510
|
onRecovered: "boolean = false",
|
|
511
511
|
/** Host vitals breaches (disk, memory, swap, load, temperature). */
|
|
512
512
|
onHost: "boolean = true",
|
|
513
|
+
/** Dynamic DNS changes and failures. */
|
|
514
|
+
onDdns: "boolean = true",
|
|
513
515
|
/** Per server *and* reason, so a flapping server cannot spam the chat. */
|
|
514
516
|
cooldownMs: "number >= 0 = 120000"
|
|
515
517
|
}).onUndeclaredKey("reject");
|
|
@@ -533,6 +535,109 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
533
535
|
loadPerCpu: "number >= 0 = 2",
|
|
534
536
|
tempCelsius: "number >= 0 = 85"
|
|
535
537
|
}).onUndeclaredKey("reject");
|
|
538
|
+
ddnsRecordTypeSchema = type.enumerated("A", "AAAA");
|
|
539
|
+
ddnsAccountSchema = type({
|
|
540
|
+
id: "/^[a-z0-9][a-z0-9_-]*$/",
|
|
541
|
+
provider: "string >= 1",
|
|
542
|
+
label: "string = \"\""
|
|
543
|
+
}).onUndeclaredKey("reject");
|
|
544
|
+
ddnsDomainSchema = type({
|
|
545
|
+
host: "/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/",
|
|
546
|
+
account: "string >= 1",
|
|
547
|
+
/** Records to keep current; `A` unless the host needs IPv6 too. */
|
|
548
|
+
types: ddnsRecordTypeSchema.array().default(() => ["A"]),
|
|
549
|
+
/**
|
|
550
|
+
* The registered domain (apex) this host sits in. Only needed where a
|
|
551
|
+
* provider cannot look it up and the guess is wrong (three-label suffixes).
|
|
552
|
+
*/
|
|
553
|
+
zone: "string?",
|
|
554
|
+
/** Seconds; `1` means "automatic" where the provider supports it. */
|
|
555
|
+
ttl: "number.integer >= 1?",
|
|
556
|
+
/** Proxying is a property of the record, not the panel: Cloudflare only. */
|
|
557
|
+
proxied: "boolean = false",
|
|
558
|
+
enabled: "boolean = true"
|
|
559
|
+
}).onUndeclaredKey("reject");
|
|
560
|
+
ddnsIpv4Schema = type({
|
|
561
|
+
enabled: "boolean = true",
|
|
562
|
+
/** Override the public-IP endpoint; empty uses the built-in list. */
|
|
563
|
+
url: "string = \"\""
|
|
564
|
+
}).onUndeclaredKey("reject");
|
|
565
|
+
ddnsIpv6Schema = type({
|
|
566
|
+
enabled: "boolean = false",
|
|
567
|
+
url: "string = \"\""
|
|
568
|
+
}).onUndeclaredKey("reject");
|
|
569
|
+
ddnsConfigSchema = type({
|
|
570
|
+
enabled: "boolean = false",
|
|
571
|
+
/** How often the public address is re-checked. */
|
|
572
|
+
intervalMs: "number >= 60000 = 300000",
|
|
573
|
+
ipv4: ddnsIpv4Schema.default(() => ({})),
|
|
574
|
+
ipv6: ddnsIpv6Schema.default(() => ({})),
|
|
575
|
+
/** Default TTL for the records that accept one. */
|
|
576
|
+
ttl: "number.integer >= 1 = 1",
|
|
577
|
+
accounts: ddnsAccountSchema.array().default(() => []),
|
|
578
|
+
domains: ddnsDomainSchema.array().default(() => [])
|
|
579
|
+
}).onUndeclaredKey("reject");
|
|
580
|
+
ddnsRecordStateSchema = type("\"pending\" | \"ok\" | \"unchanged\" | \"error\" | \"skipped\"");
|
|
581
|
+
ddnsRecordViewSchema = type({
|
|
582
|
+
host: "string",
|
|
583
|
+
account: "string",
|
|
584
|
+
provider: "string",
|
|
585
|
+
type: ddnsRecordTypeSchema,
|
|
586
|
+
ip: "string | null",
|
|
587
|
+
state: ddnsRecordStateSchema,
|
|
588
|
+
message: "string | null",
|
|
589
|
+
updatedAt: "number | null"
|
|
590
|
+
});
|
|
591
|
+
ddnsStatusSchema = type({
|
|
592
|
+
enabled: "boolean",
|
|
593
|
+
running: "boolean",
|
|
594
|
+
lastRunAt: "number | null",
|
|
595
|
+
lastResult: "string | null",
|
|
596
|
+
ipv4: "string | null",
|
|
597
|
+
ipv6: "string | null",
|
|
598
|
+
records: ddnsRecordViewSchema.array()
|
|
599
|
+
});
|
|
600
|
+
ddnsProviderFieldSchema = type({
|
|
601
|
+
key: "string",
|
|
602
|
+
label: "string",
|
|
603
|
+
hint: "string = \"\"",
|
|
604
|
+
optional: "boolean = false"
|
|
605
|
+
});
|
|
606
|
+
ddnsProviderInfoSchema = type({
|
|
607
|
+
id: "string",
|
|
608
|
+
label: "string",
|
|
609
|
+
docsUrl: "string",
|
|
610
|
+
families: ddnsRecordTypeSchema.array(),
|
|
611
|
+
fields: ddnsProviderFieldSchema.array(),
|
|
612
|
+
ttl: "boolean",
|
|
613
|
+
proxied: "boolean"
|
|
614
|
+
});
|
|
615
|
+
ddnsViewSchema = type({
|
|
616
|
+
config: ddnsConfigSchema,
|
|
617
|
+
status: ddnsStatusSchema,
|
|
618
|
+
providers: ddnsProviderInfoSchema.array(),
|
|
619
|
+
/** Account ids whose credentials are stored. */
|
|
620
|
+
credentials: type("string[]")
|
|
621
|
+
});
|
|
622
|
+
ddnsCredentialsSchema = type({
|
|
623
|
+
provider: "string >= 1",
|
|
624
|
+
credentials: type("Record<string, string>")
|
|
625
|
+
}).onUndeclaredKey("reject");
|
|
626
|
+
ddnsPatchSchema = type({
|
|
627
|
+
enabled: "boolean?",
|
|
628
|
+
intervalMs: "number >= 60000?",
|
|
629
|
+
ipv4: type({
|
|
630
|
+
enabled: "boolean?",
|
|
631
|
+
url: "string?"
|
|
632
|
+
}).onUndeclaredKey("reject").optional(),
|
|
633
|
+
ipv6: type({
|
|
634
|
+
enabled: "boolean?",
|
|
635
|
+
url: "string?"
|
|
636
|
+
}).onUndeclaredKey("reject").optional(),
|
|
637
|
+
ttl: "number.integer >= 1?",
|
|
638
|
+
accounts: ddnsAccountSchema.array().optional(),
|
|
639
|
+
domains: ddnsDomainSchema.array().optional()
|
|
640
|
+
}).onUndeclaredKey("reject");
|
|
536
641
|
backupsSchema = type({
|
|
537
642
|
enabled: "boolean = true",
|
|
538
643
|
dir: "string = \".backups\"",
|
|
@@ -608,6 +713,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
608
713
|
onForcedRestart: "boolean?",
|
|
609
714
|
onRecovered: "boolean?",
|
|
610
715
|
onHost: "boolean?",
|
|
716
|
+
onDdns: "boolean?",
|
|
611
717
|
cooldownMs: "number >= 0?"
|
|
612
718
|
}).onUndeclaredKey("reject");
|
|
613
719
|
notificationsPatchSchema = type({ telegram: telegramPatchSchema.optional() }).onUndeclaredKey("reject");
|
|
@@ -715,7 +821,9 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
715
821
|
logs: logsPatchSchema.optional(),
|
|
716
822
|
notifications: notificationsPatchSchema.optional(),
|
|
717
823
|
host: hostPatchSchema.optional(),
|
|
718
|
-
backups: backupsPatchSchema.optional()
|
|
824
|
+
backups: backupsPatchSchema.optional(),
|
|
825
|
+
/** Replaces the DDNS lists; see `ddnsPatchSchema`. */
|
|
826
|
+
ddns: ddnsPatchSchema.optional()
|
|
719
827
|
}).onUndeclaredKey("reject");
|
|
720
828
|
authStatusSchema = type({
|
|
721
829
|
enabled: "boolean",
|
|
@@ -762,6 +870,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
762
870
|
onRecovered: "boolean",
|
|
763
871
|
/** Host vitals breaches (disk, memory, swap, load, temperature). */
|
|
764
872
|
onHost: "boolean",
|
|
873
|
+
/** Dynamic DNS, when this panel knows about it (optional for an older panel). */
|
|
874
|
+
onDdns: "boolean?",
|
|
765
875
|
cooldownMs: "number",
|
|
766
876
|
/** Last delivery outcome, for the settings page. */
|
|
767
877
|
lastResult: "string | null",
|
|
@@ -849,6 +959,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
849
959
|
});
|
|
850
960
|
backupPathSchema = type({
|
|
851
961
|
path: "string",
|
|
962
|
+
/** Which item this path is in a backup's `include`; `data:<path>`. */
|
|
963
|
+
id: "string?",
|
|
852
964
|
/** Who declared it: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
|
|
853
965
|
origin: "string",
|
|
854
966
|
/** false when a parent path already covers it, or it would swallow the archive dir. */
|
|
@@ -892,9 +1004,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
892
1004
|
reloaded: "boolean",
|
|
893
1005
|
error: "string?"
|
|
894
1006
|
});
|
|
895
|
-
backupCreateSchema = type({
|
|
896
|
-
|
|
897
|
-
password: passwordValueSchema.optional()
|
|
1007
|
+
backupCreateSchema = type({
|
|
1008
|
+
/** Optional: encrypts the archive. Never stored. */
|
|
1009
|
+
password: passwordValueSchema.optional(),
|
|
1010
|
+
/** Item ids to capture (`config`, `secrets`, `tls`, `data:<path>`); omitted means everything. */
|
|
1011
|
+
include: type("string[] >= 1").optional()
|
|
1012
|
+
}).onUndeclaredKey("reject");
|
|
898
1013
|
restoreRequestSchema = type({
|
|
899
1014
|
name: "string?",
|
|
900
1015
|
password: passwordValueSchema.optional(),
|
|
@@ -956,6 +1071,11 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
956
1071
|
/** `HHOSTED_HOME`: every file home-hosted owns lives under here. */
|
|
957
1072
|
"dataRoot": "string",
|
|
958
1073
|
"logsDir": "string",
|
|
1074
|
+
/**
|
|
1075
|
+
* Dynamic DNS state. Optional so a newer UI still frames a state a panel from
|
|
1076
|
+
* before this field served: the app would blank on a required key.
|
|
1077
|
+
*/
|
|
1078
|
+
"ddns?": ddnsStatusSchema,
|
|
959
1079
|
/** The release this panel is running. Optional: an older panel does not send one. */
|
|
960
1080
|
"version?": "string",
|
|
961
1081
|
"servers": serverViewSchema.array()
|
|
@@ -1144,6 +1264,12 @@ function writeFileAtomic(file, content, options = {}) {
|
|
|
1144
1264
|
}
|
|
1145
1265
|
var init_atomic = __esmMin((() => {}));
|
|
1146
1266
|
//#endregion
|
|
1267
|
+
//#region src/helpers/logger.ts
|
|
1268
|
+
var logger;
|
|
1269
|
+
var init_logger = __esmMin((() => {
|
|
1270
|
+
logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
|
|
1271
|
+
}));
|
|
1272
|
+
//#endregion
|
|
1147
1273
|
//#region src/config/secrets.ts
|
|
1148
1274
|
function deriveKey(password, salt, cost, keylen) {
|
|
1149
1275
|
return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
|
|
@@ -1192,9 +1318,104 @@ function apiTokenRecord(token, now = Date.now()) {
|
|
|
1192
1318
|
updatedAt: now
|
|
1193
1319
|
};
|
|
1194
1320
|
}
|
|
1195
|
-
|
|
1321
|
+
function isRecord$3(value) {
|
|
1322
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1323
|
+
}
|
|
1324
|
+
/** A field map: strings only, empty values dropped; anything else reads as absent. */
|
|
1325
|
+
function readDdnsValues(value) {
|
|
1326
|
+
if (!isRecord$3(value)) return {};
|
|
1327
|
+
const out = {};
|
|
1328
|
+
for (const [key, field] of Object.entries(value)) if (typeof field === "string" && field.length > 0) out[key] = field;
|
|
1329
|
+
return out;
|
|
1330
|
+
}
|
|
1331
|
+
function isSealedDdns(value) {
|
|
1332
|
+
return isRecord$3(value) && value.algo === DDNS_SEALED_ALGO && typeof value.iv === "string" && typeof value.tag === "string" && typeof value.data === "string";
|
|
1333
|
+
}
|
|
1334
|
+
function isPlainDdns(value) {
|
|
1335
|
+
return "plain" in value;
|
|
1336
|
+
}
|
|
1337
|
+
/** Every shape this file has held: sealed, `{ provider, values }`, or a bare field map. */
|
|
1338
|
+
function readStoredDdns(value) {
|
|
1339
|
+
if (!isRecord$3(value)) return {};
|
|
1340
|
+
const out = {};
|
|
1341
|
+
for (const [accountId, entry] of Object.entries(value)) {
|
|
1342
|
+
if (isSealedDdns(entry)) {
|
|
1343
|
+
out[accountId] = entry;
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (!isRecord$3(entry)) continue;
|
|
1347
|
+
const values = readDdnsValues("values" in entry ? entry.values : entry);
|
|
1348
|
+
if (Object.keys(values).length === 0) continue;
|
|
1349
|
+
out[accountId] = {
|
|
1350
|
+
plain: true,
|
|
1351
|
+
provider: typeof entry.provider === "string" ? entry.provider : "",
|
|
1352
|
+
values
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
return out;
|
|
1356
|
+
}
|
|
1357
|
+
function readDdnsKdf(value) {
|
|
1358
|
+
if (!isRecord$3(value) || value.algo !== "scrypt" || typeof value.salt !== "string") return null;
|
|
1359
|
+
const cost = isRecord$3(value.cost) ? value.cost : {};
|
|
1360
|
+
const N = Number(cost.N);
|
|
1361
|
+
const r = Number(cost.r);
|
|
1362
|
+
const p = Number(cost.p);
|
|
1363
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p) || N < 2 || r < 1 || p < 1) return null;
|
|
1364
|
+
return {
|
|
1365
|
+
algo: "scrypt",
|
|
1366
|
+
cost: {
|
|
1367
|
+
N,
|
|
1368
|
+
r,
|
|
1369
|
+
p
|
|
1370
|
+
},
|
|
1371
|
+
salt: value.salt
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
function newDdnsKdf() {
|
|
1375
|
+
return {
|
|
1376
|
+
algo: "scrypt",
|
|
1377
|
+
cost: { ...COST },
|
|
1378
|
+
salt: crypto.randomBytes(SALT_BYTES).toString("base64")
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
/** The account id is the AAD, so a sealed entry cannot be moved to another account. */
|
|
1382
|
+
function ddnsAad(accountId) {
|
|
1383
|
+
return Buffer$1.from(`${DDNS_AAD_PREFIX}${accountId}`, "utf8");
|
|
1384
|
+
}
|
|
1385
|
+
function sealDdns(accountId, record, key) {
|
|
1386
|
+
const iv = crypto.randomBytes(DDNS_IV_BYTES);
|
|
1387
|
+
const cipher = crypto.createCipheriv(DDNS_SEALED_ALGO, key, iv);
|
|
1388
|
+
cipher.setAAD(ddnsAad(accountId));
|
|
1389
|
+
const data = Buffer$1.concat([cipher.update(JSON.stringify(record), "utf8"), cipher.final()]);
|
|
1390
|
+
return {
|
|
1391
|
+
algo: DDNS_SEALED_ALGO,
|
|
1392
|
+
iv: iv.toString("base64"),
|
|
1393
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
1394
|
+
data: data.toString("base64")
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
/** Null when the tag does not verify: a different secret, or a file somebody edited. */
|
|
1398
|
+
function openDdns(accountId, sealed, key) {
|
|
1399
|
+
try {
|
|
1400
|
+
const decipher = crypto.createDecipheriv(DDNS_SEALED_ALGO, key, Buffer$1.from(sealed.iv, "base64"));
|
|
1401
|
+
decipher.setAAD(ddnsAad(accountId));
|
|
1402
|
+
decipher.setAuthTag(Buffer$1.from(sealed.tag, "base64"));
|
|
1403
|
+
const plain = Buffer$1.concat([decipher.update(Buffer$1.from(sealed.data, "base64")), decipher.final()]).toString("utf8");
|
|
1404
|
+
const parsed = JSON.parse(plain);
|
|
1405
|
+
if (typeof parsed?.provider !== "string") return null;
|
|
1406
|
+
const values = readDdnsValues(parsed.values);
|
|
1407
|
+
return Object.keys(values).length === 0 ? null : {
|
|
1408
|
+
provider: parsed.provider,
|
|
1409
|
+
values
|
|
1410
|
+
};
|
|
1411
|
+
} catch {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_SEALED_ALGO, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
|
|
1196
1416
|
var init_secrets = __esmMin((() => {
|
|
1197
1417
|
init_atomic();
|
|
1418
|
+
init_logger();
|
|
1198
1419
|
COST = {
|
|
1199
1420
|
N: 16384,
|
|
1200
1421
|
r: 8,
|
|
@@ -1202,14 +1423,23 @@ var init_secrets = __esmMin((() => {
|
|
|
1202
1423
|
};
|
|
1203
1424
|
KEYLEN = 64;
|
|
1204
1425
|
SALT_BYTES = 16;
|
|
1426
|
+
DDNS_KEYLEN = 32;
|
|
1427
|
+
DDNS_IV_BYTES = 12;
|
|
1428
|
+
DDNS_AAD_PREFIX = "hh-ddns-v1:";
|
|
1429
|
+
DDNS_SEALED_ALGO = "aes-256-gcm";
|
|
1205
1430
|
API_TOKEN_BYTES = 32;
|
|
1206
1431
|
API_TOKEN_HINT_CHARS = 8;
|
|
1207
1432
|
SecretsStore = class {
|
|
1208
1433
|
file;
|
|
1209
1434
|
cache = null;
|
|
1210
1435
|
cacheKey = "";
|
|
1211
|
-
|
|
1436
|
+
ddnsKeyCache = null;
|
|
1437
|
+
warnedAccounts = /* @__PURE__ */ new Set();
|
|
1438
|
+
ddnsSecret;
|
|
1439
|
+
constructor(file, ddnsSecret) {
|
|
1212
1440
|
this.file = file;
|
|
1441
|
+
const candidate = (ddnsSecret ?? process.env.HHOSTED_DDNS_SECRET ?? "").trim();
|
|
1442
|
+
this.ddnsSecret = candidate.length > 0 ? candidate : "hh";
|
|
1213
1443
|
}
|
|
1214
1444
|
get path() {
|
|
1215
1445
|
return this.file;
|
|
@@ -1302,12 +1532,98 @@ var init_secrets = __esmMin((() => {
|
|
|
1302
1532
|
telegram: trimmed.length > 0 ? { botToken: trimmed } : null
|
|
1303
1533
|
});
|
|
1304
1534
|
}
|
|
1535
|
+
/** Ids that have something stored, whether or not a config still declares them. */
|
|
1536
|
+
get ddnsAccountIds() {
|
|
1537
|
+
return Object.keys(this.load().ddns);
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Null when nothing is stored, when the entry belongs to a different provider,
|
|
1541
|
+
* or when the secret cannot open it.
|
|
1542
|
+
*/
|
|
1543
|
+
getDdnsCredentials(accountId, provider) {
|
|
1544
|
+
const stored = this.load().ddns[accountId];
|
|
1545
|
+
if (stored === void 0) return null;
|
|
1546
|
+
const record = this.openStored(accountId, stored);
|
|
1547
|
+
if (record === null) return null;
|
|
1548
|
+
return record.provider.length > 0 && record.provider !== provider ? null : record;
|
|
1549
|
+
}
|
|
1550
|
+
/** An empty field is dropped, so clearing every field forgets the account. */
|
|
1551
|
+
setDdnsCredentials(accountId, provider, credentials) {
|
|
1552
|
+
const current = this.load();
|
|
1553
|
+
const values = readDdnsValues(credentials);
|
|
1554
|
+
const ddns = { ...current.ddns };
|
|
1555
|
+
if (Object.keys(values).length === 0) delete ddns[accountId];
|
|
1556
|
+
else ddns[accountId] = {
|
|
1557
|
+
plain: true,
|
|
1558
|
+
provider,
|
|
1559
|
+
values
|
|
1560
|
+
};
|
|
1561
|
+
this.save({
|
|
1562
|
+
...current,
|
|
1563
|
+
ddns
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
clearDdnsCredentials(accountId) {
|
|
1567
|
+
const current = this.load();
|
|
1568
|
+
if (current.ddns[accountId] === void 0) return;
|
|
1569
|
+
const ddns = { ...current.ddns };
|
|
1570
|
+
delete ddns[accountId];
|
|
1571
|
+
this.save({
|
|
1572
|
+
...current,
|
|
1573
|
+
ddns
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
/** Keeps only the accounts a config still declares; returns how many were dropped. */
|
|
1577
|
+
pruneDdnsCredentials(keep) {
|
|
1578
|
+
const wanted = new Set(keep);
|
|
1579
|
+
const current = this.load();
|
|
1580
|
+
const removed = Object.keys(current.ddns).filter((id) => !wanted.has(id));
|
|
1581
|
+
if (removed.length === 0) return 0;
|
|
1582
|
+
const ddns = { ...current.ddns };
|
|
1583
|
+
for (const id of removed) delete ddns[id];
|
|
1584
|
+
this.save({
|
|
1585
|
+
...current,
|
|
1586
|
+
ddns
|
|
1587
|
+
});
|
|
1588
|
+
return removed.length;
|
|
1589
|
+
}
|
|
1590
|
+
openStored(accountId, stored) {
|
|
1591
|
+
if (isPlainDdns(stored)) return {
|
|
1592
|
+
provider: stored.provider,
|
|
1593
|
+
values: stored.values
|
|
1594
|
+
};
|
|
1595
|
+
const kdf = this.load().ddnsKdf;
|
|
1596
|
+
if (kdf === null) {
|
|
1597
|
+
this.warnOnce(accountId, "the file carries no key parameters");
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
const record = openDdns(accountId, stored, this.ddnsKey(kdf));
|
|
1601
|
+
if (record === null) this.warnOnce(accountId, `the entry could not be opened — is HHOSTED_DDNS_SECRET still the one it was sealed with?`);
|
|
1602
|
+
return record;
|
|
1603
|
+
}
|
|
1604
|
+
/** One derivation per file salt, cached for the life of the process. */
|
|
1605
|
+
ddnsKey(kdf) {
|
|
1606
|
+
if (this.ddnsKeyCache?.salt === kdf.salt) return this.ddnsKeyCache.key;
|
|
1607
|
+
const key = deriveKey(this.ddnsSecret, Buffer$1.from(kdf.salt, "base64"), kdf.cost, DDNS_KEYLEN);
|
|
1608
|
+
this.ddnsKeyCache = {
|
|
1609
|
+
salt: kdf.salt,
|
|
1610
|
+
key
|
|
1611
|
+
};
|
|
1612
|
+
return key;
|
|
1613
|
+
}
|
|
1614
|
+
warnOnce(accountId, reason) {
|
|
1615
|
+
if (this.warnedAccounts.has(accountId)) return;
|
|
1616
|
+
this.warnedAccounts.add(accountId);
|
|
1617
|
+
logger.warn(`ddns: cannot read the credentials for "${accountId}" — ${reason}`);
|
|
1618
|
+
}
|
|
1305
1619
|
read() {
|
|
1306
1620
|
if (!fs.existsSync(this.file)) return {
|
|
1307
1621
|
version: 3,
|
|
1308
1622
|
password: null,
|
|
1309
1623
|
apiToken: null,
|
|
1310
|
-
telegram: null
|
|
1624
|
+
telegram: null,
|
|
1625
|
+
ddns: {},
|
|
1626
|
+
ddnsKdf: null
|
|
1311
1627
|
};
|
|
1312
1628
|
try {
|
|
1313
1629
|
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
@@ -1315,20 +1631,42 @@ var init_secrets = __esmMin((() => {
|
|
|
1315
1631
|
version: 3,
|
|
1316
1632
|
password: parsed?.password ?? null,
|
|
1317
1633
|
apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
|
|
1318
|
-
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null
|
|
1634
|
+
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
|
|
1635
|
+
ddns: readStoredDdns(parsed?.ddns),
|
|
1636
|
+
ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
|
|
1319
1637
|
};
|
|
1320
1638
|
} catch {
|
|
1321
1639
|
return {
|
|
1322
1640
|
version: 3,
|
|
1323
1641
|
password: null,
|
|
1324
1642
|
apiToken: null,
|
|
1325
|
-
telegram: null
|
|
1643
|
+
telegram: null,
|
|
1644
|
+
ddns: {},
|
|
1645
|
+
ddnsKdf: null
|
|
1326
1646
|
};
|
|
1327
1647
|
}
|
|
1328
1648
|
}
|
|
1329
1649
|
save(contents) {
|
|
1330
|
-
|
|
1331
|
-
this.
|
|
1650
|
+
const sealed = this.sealStored(contents);
|
|
1651
|
+
writeFileAtomic(this.file, `${JSON.stringify(sealed, null, 2)}\n`, { mode: 384 });
|
|
1652
|
+
this.cache = sealed;
|
|
1653
|
+
}
|
|
1654
|
+
/** Any write seals the entries still sitting in the clear, so the file never keeps both. */
|
|
1655
|
+
sealStored(contents) {
|
|
1656
|
+
const plain = Object.entries(contents.ddns).filter((entry) => isPlainDdns(entry[1]));
|
|
1657
|
+
if (plain.length === 0) return contents;
|
|
1658
|
+
const kdf = contents.ddnsKdf ?? newDdnsKdf();
|
|
1659
|
+
const key = this.ddnsKey(kdf);
|
|
1660
|
+
const ddns = { ...contents.ddns };
|
|
1661
|
+
for (const [accountId, record] of plain) ddns[accountId] = sealDdns(accountId, {
|
|
1662
|
+
provider: record.provider,
|
|
1663
|
+
values: record.values
|
|
1664
|
+
}, key);
|
|
1665
|
+
return {
|
|
1666
|
+
...contents,
|
|
1667
|
+
ddns,
|
|
1668
|
+
ddnsKdf: kdf
|
|
1669
|
+
};
|
|
1332
1670
|
}
|
|
1333
1671
|
};
|
|
1334
1672
|
}));
|
|
@@ -1833,12 +2171,6 @@ var init_$_routes$1 = __esmMin((() => {
|
|
|
1833
2171
|
init_contracts();
|
|
1834
2172
|
}));
|
|
1835
2173
|
//#endregion
|
|
1836
|
-
//#region src/helpers/logger.ts
|
|
1837
|
-
var logger;
|
|
1838
|
-
var init_logger = __esmMin((() => {
|
|
1839
|
-
logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
|
|
1840
|
-
}));
|
|
1841
|
-
//#endregion
|
|
1842
2174
|
//#region src/helpers/validate.ts
|
|
1843
2175
|
/**
|
|
1844
2176
|
* Validates a value that is not a request target (a query string, a URL
|
|
@@ -1866,6 +2198,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
1866
2198
|
daemonLogPath: () => daemonLogPath,
|
|
1867
2199
|
dataRoot: () => dataRoot,
|
|
1868
2200
|
defaultConfigPath: () => defaultConfigPath,
|
|
2201
|
+
defaultDdnsStatePath: () => defaultDdnsStatePath,
|
|
1869
2202
|
defaultHistoryPath: () => defaultHistoryPath,
|
|
1870
2203
|
defaultLogsDir: () => defaultLogsDir,
|
|
1871
2204
|
defaultNannyDir: () => defaultNannyDir,
|
|
@@ -1906,7 +2239,7 @@ function resolveUserPath(target, base = projectDir) {
|
|
|
1906
2239
|
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
1907
2240
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
1908
2241
|
}
|
|
1909
|
-
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, runtimePath, daemonLogPath;
|
|
2242
|
+
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, defaultDdnsStatePath, runtimePath, daemonLogPath;
|
|
1910
2243
|
var init_paths = __esmMin((() => {
|
|
1911
2244
|
dataRoot = resolveDataRoot();
|
|
1912
2245
|
projectDir = resolveProjectDir();
|
|
@@ -1917,6 +2250,7 @@ var init_paths = __esmMin((() => {
|
|
|
1917
2250
|
defaultHistoryPath = path.join(dataRoot, ".logs", "history.json");
|
|
1918
2251
|
defaultTlsDir = path.join(dataRoot, ".tls");
|
|
1919
2252
|
defaultNannyDir = path.join(dataRoot, ".state");
|
|
2253
|
+
defaultDdnsStatePath = path.join(dataRoot, ".state", "ddns.json");
|
|
1920
2254
|
runtimePath = path.join(dataRoot, "run.json");
|
|
1921
2255
|
daemonLogPath = path.join(dataRoot, ".logs", "home-hosted.log");
|
|
1922
2256
|
}));
|
|
@@ -1966,7 +2300,10 @@ function buildBackupsView(store, backups) {
|
|
|
1966
2300
|
dir: backups.directory,
|
|
1967
2301
|
keep: store.config.backups.keep,
|
|
1968
2302
|
includePaths: store.config.backups.includePaths,
|
|
1969
|
-
paths: backups.paths
|
|
2303
|
+
paths: backups.paths.map((entry) => ({
|
|
2304
|
+
...entry,
|
|
2305
|
+
id: `data:${entry.path}`
|
|
2306
|
+
})),
|
|
1970
2307
|
files: backups.list()
|
|
1971
2308
|
};
|
|
1972
2309
|
}
|
|
@@ -1981,6 +2318,7 @@ function buildAppState(deps) {
|
|
|
1981
2318
|
notifications: { telegram: deps.notifications.status() },
|
|
1982
2319
|
host: buildHostView(deps.hostMonitor),
|
|
1983
2320
|
backups: buildBackupsView(deps.store, deps.backups),
|
|
2321
|
+
ddns: deps.ddns.view,
|
|
1984
2322
|
configPath: deps.store.path,
|
|
1985
2323
|
configError: deps.store.configError,
|
|
1986
2324
|
projectDir,
|
|
@@ -2021,7 +2359,10 @@ function createBackupsRoute(deps) {
|
|
|
2021
2359
|
}
|
|
2022
2360
|
}), validate("json", backupCreateSchema), async (c) => {
|
|
2023
2361
|
const body = c.req.valid("json");
|
|
2024
|
-
const result = await deps.backups.create({
|
|
2362
|
+
const result = await deps.backups.create({
|
|
2363
|
+
password: body.password,
|
|
2364
|
+
include: body.include
|
|
2365
|
+
});
|
|
2025
2366
|
if (!result.ok) throw backupFailed(result.error);
|
|
2026
2367
|
return c.json({
|
|
2027
2368
|
file: result.file,
|
|
@@ -2252,577 +2593,140 @@ var init_control = __esmMin((() => {
|
|
|
2252
2593
|
init_loopback();
|
|
2253
2594
|
}));
|
|
2254
2595
|
//#endregion
|
|
2255
|
-
//#region src/
|
|
2256
|
-
/**
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
}), validate("query", eventsQuery), (c) => {
|
|
2266
|
-
const query = c.req.valid("query");
|
|
2267
|
-
const serverId = query.serverId ?? null;
|
|
2268
|
-
const logOnly = query.logs !== "0";
|
|
2269
|
-
return streamSSE(c, async (stream) => {
|
|
2270
|
-
let pending = 0;
|
|
2271
|
-
let queue = Promise.resolve();
|
|
2272
|
-
let closed = false;
|
|
2273
|
-
const send = (message) => {
|
|
2274
|
-
if (closed) return queue;
|
|
2275
|
-
if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
|
|
2276
|
-
pending += 1;
|
|
2277
|
-
queue = queue.then(() => stream.writeSSE({
|
|
2278
|
-
event: message.type,
|
|
2279
|
-
data: JSON.stringify(message)
|
|
2280
|
-
})).catch(() => {
|
|
2281
|
-
closed = true;
|
|
2282
|
-
}).finally(() => {
|
|
2283
|
-
pending -= 1;
|
|
2284
|
-
});
|
|
2285
|
-
return queue;
|
|
2286
|
-
};
|
|
2287
|
-
const unsubscribe = deps.hub.subscribe(serverId, (message) => {
|
|
2288
|
-
if (!logOnly && message.type === "log") return;
|
|
2289
|
-
send(message);
|
|
2290
|
-
});
|
|
2291
|
-
stream.onAbort(() => {
|
|
2292
|
-
closed = true;
|
|
2293
|
-
unsubscribe();
|
|
2294
|
-
});
|
|
2295
|
-
await send({
|
|
2296
|
-
type: "hello",
|
|
2297
|
-
ts: Date.now(),
|
|
2298
|
-
state: deps.supervisor.getState()
|
|
2299
|
-
});
|
|
2300
|
-
while (true) {
|
|
2301
|
-
await stream.sleep(PING_INTERVAL_MS);
|
|
2302
|
-
if (closed) break;
|
|
2303
|
-
await stream.writeSSE({
|
|
2304
|
-
event: "ping",
|
|
2305
|
-
data: String(Date.now())
|
|
2306
|
-
});
|
|
2307
|
-
}
|
|
2308
|
-
});
|
|
2309
|
-
});
|
|
2596
|
+
//#region src/config/migrations.ts
|
|
2597
|
+
/** What would have to run to bring `from` up to `to`. */
|
|
2598
|
+
function planConfigMigrations(from, options = {}) {
|
|
2599
|
+
const to = options.to ?? 1;
|
|
2600
|
+
return {
|
|
2601
|
+
from,
|
|
2602
|
+
to,
|
|
2603
|
+
steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
|
|
2604
|
+
tooNew: from > to
|
|
2605
|
+
};
|
|
2310
2606
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2607
|
+
/** Applies the plan in order; the caller owns writing the result. */
|
|
2608
|
+
function applyConfigMigrations(config, from, options = {}) {
|
|
2609
|
+
const { steps } = planConfigMigrations(from, options);
|
|
2610
|
+
let current = config;
|
|
2611
|
+
for (const step of steps) current = step.apply(current);
|
|
2612
|
+
return {
|
|
2613
|
+
config: current,
|
|
2614
|
+
applied: steps
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
var configMigrations;
|
|
2618
|
+
var init_migrations = __esmMin((() => {
|
|
2619
|
+
configMigrations = [];
|
|
2323
2620
|
}));
|
|
2324
2621
|
//#endregion
|
|
2325
|
-
//#region src/
|
|
2622
|
+
//#region src/config/schema.ts
|
|
2623
|
+
function isRecord$2(value) {
|
|
2624
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2625
|
+
}
|
|
2326
2626
|
/**
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2627
|
+
* Resolves one entry against the panel's server defaults. A group (`restart`,
|
|
2628
|
+
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
2629
|
+
* member does not silently fall back to the *schema* default for the others — which
|
|
2630
|
+
* is the whole point of the panel having defaults at all.
|
|
2331
2631
|
*/
|
|
2332
|
-
function
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
"status": "\"ok\" | \"degraded\"",
|
|
2341
|
-
"uptimeMs": "number",
|
|
2342
|
-
"servers?": type({
|
|
2343
|
-
total: "number",
|
|
2344
|
-
running: "number",
|
|
2345
|
-
crashed: "number",
|
|
2346
|
-
unhealthy: "number"
|
|
2347
|
-
}),
|
|
2348
|
-
"hostAlerts?": "string[]"
|
|
2349
|
-
}))
|
|
2350
|
-
},
|
|
2351
|
-
503: { description: "An autostart server has crashed" }
|
|
2352
|
-
}
|
|
2353
|
-
}), (c) => {
|
|
2354
|
-
const state = deps.supervisor.getState();
|
|
2355
|
-
const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
2356
|
-
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
2357
|
-
return c.json({
|
|
2358
|
-
status: broken.length > 0 ? "degraded" : "ok",
|
|
2359
|
-
uptimeMs: Math.round(process.uptime() * 1e3),
|
|
2360
|
-
...authenticated ? {
|
|
2361
|
-
servers: {
|
|
2362
|
-
total: state.servers.length,
|
|
2363
|
-
running: state.servers.filter((server) => server.status === "running").length,
|
|
2364
|
-
crashed: state.servers.filter((server) => server.status === "crashed").length,
|
|
2365
|
-
unhealthy: state.servers.filter((server) => server.health === "unhealthy").length
|
|
2366
|
-
},
|
|
2367
|
-
hostAlerts: state.host.alerts
|
|
2368
|
-
} : {}
|
|
2369
|
-
}, broken.length > 0 ? 503 : 200);
|
|
2370
|
-
});
|
|
2632
|
+
function mergeDefaults(defaults, entry) {
|
|
2633
|
+
const merged = { ...entry };
|
|
2634
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
2635
|
+
const current = merged[key];
|
|
2636
|
+
if (current === void 0) merged[key] = value;
|
|
2637
|
+
else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
|
|
2638
|
+
}
|
|
2639
|
+
return merged;
|
|
2371
2640
|
}
|
|
2372
|
-
var
|
|
2373
|
-
|
|
2374
|
-
init_openapi$1();
|
|
2375
|
-
init_auth();
|
|
2376
|
-
}));
|
|
2377
|
-
//#endregion
|
|
2378
|
-
//#region src/api/logs.ts
|
|
2379
|
-
function unknownServer$1(id) {
|
|
2380
|
-
return new DetailedError(`unknown server "${id}"`, {
|
|
2381
|
-
statusCode: 404,
|
|
2382
|
-
code: "UNKNOWN_SERVER"
|
|
2383
|
-
});
|
|
2384
|
-
}
|
|
2385
|
-
function createLogsRoute(deps) {
|
|
2386
|
-
return appFactory.createApp().get("/logs", describeRoute({
|
|
2387
|
-
tags: ["logs"],
|
|
2388
|
-
summary: "Every server with its on-disk log files",
|
|
2389
|
-
responses: { 200: {
|
|
2390
|
-
description: "Log sources",
|
|
2391
|
-
content: jsonBody(logServersViewSchema)
|
|
2392
|
-
} }
|
|
2393
|
-
}), (c) => c.json({ servers: deps.supervisor.views().map((server) => ({
|
|
2394
|
-
serverId: server.id,
|
|
2395
|
-
label: server.config.label ?? server.id,
|
|
2396
|
-
status: server.status,
|
|
2397
|
-
...deps.logFiles.info(server.id)
|
|
2398
|
-
})) })).get("/logs/:id", describeRoute({
|
|
2399
|
-
tags: ["logs"],
|
|
2400
|
-
summary: "Persisted log lines, with search and a stream filter",
|
|
2401
|
-
responses: {
|
|
2402
|
-
200: { description: "Lines" },
|
|
2403
|
-
404: ERROR_RESPONSES[404]
|
|
2404
|
-
}
|
|
2405
|
-
}), validate("param", idParam$1), validate("query", logHistoryQuerySchema), (c) => {
|
|
2406
|
-
const { id } = c.req.valid("param");
|
|
2407
|
-
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
2408
|
-
const query = c.req.valid("query");
|
|
2409
|
-
const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
|
|
2410
|
-
const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
|
|
2411
|
-
const info = deps.logFiles.info(id);
|
|
2412
|
-
const search = query.search?.trim() ?? "";
|
|
2413
|
-
const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
|
|
2414
|
-
let lines = deps.logFiles.readTail(id, window);
|
|
2415
|
-
if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
|
|
2416
|
-
if (search.length > 0) {
|
|
2417
|
-
const needle = search.toLowerCase();
|
|
2418
|
-
lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
2419
|
-
}
|
|
2420
|
-
return c.json({
|
|
2421
|
-
serverId: id,
|
|
2422
|
-
enabled: info.enabled,
|
|
2423
|
-
sizeBytes: info.sizeBytes,
|
|
2424
|
-
files: info.files.map((file) => file.name),
|
|
2425
|
-
searched: search.length > 0 ? window : null,
|
|
2426
|
-
lines: lines.slice(-tail)
|
|
2427
|
-
});
|
|
2428
|
-
}).get("/logs/:id/download", describeRoute({
|
|
2429
|
-
tags: ["logs"],
|
|
2430
|
-
summary: "Download one rotated log file",
|
|
2431
|
-
responses: {
|
|
2432
|
-
200: { description: "application/x-ndjson" },
|
|
2433
|
-
404: ERROR_RESPONSES[404]
|
|
2434
|
-
}
|
|
2435
|
-
}), validate("param", idParam$1), validate("query", downloadQuery), (c) => {
|
|
2436
|
-
const { id } = c.req.valid("param");
|
|
2437
|
-
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
2438
|
-
const requested = c.req.valid("query").file ?? `${id}.log`;
|
|
2439
|
-
if (!deps.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
|
|
2440
|
-
statusCode: 404,
|
|
2441
|
-
code: "UNKNOWN_LOG_FILE"
|
|
2442
|
-
});
|
|
2443
|
-
const file = path.join(deps.logFiles.directory, requested);
|
|
2444
|
-
const body = fs.readFileSync(file);
|
|
2445
|
-
return c.body(body, 200, {
|
|
2446
|
-
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
2447
|
-
"Content-Length": String(body.byteLength),
|
|
2448
|
-
"Content-Disposition": `attachment; filename="${requested}"`
|
|
2449
|
-
});
|
|
2450
|
-
}).delete("/logs/:id", describeRoute({
|
|
2451
|
-
tags: ["logs"],
|
|
2452
|
-
summary: "Delete the persisted logs of one server",
|
|
2453
|
-
responses: {
|
|
2454
|
-
200: { description: "Cleared" },
|
|
2455
|
-
404: ERROR_RESPONSES[404]
|
|
2456
|
-
}
|
|
2457
|
-
}), validate("param", idParam$1), (c) => {
|
|
2458
|
-
const { id } = c.req.valid("param");
|
|
2459
|
-
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
2460
|
-
deps.logFiles.clear(id);
|
|
2461
|
-
return c.json({ ok: true });
|
|
2462
|
-
});
|
|
2463
|
-
}
|
|
2464
|
-
var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$1, downloadQuery;
|
|
2465
|
-
var init_logs = __esmMin((() => {
|
|
2466
|
-
init_factory();
|
|
2467
|
-
init_openapi$1();
|
|
2468
|
-
init_validator();
|
|
2641
|
+
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2642
|
+
var init_schema = __esmMin((() => {
|
|
2469
2643
|
init_contracts();
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
metric("hh_server_crashes_24h", "Crashes in the last 24 hours", state.servers.map((server) => `hh_server_crashes_24h{server="${server.id}"} ${server.history.crashes}`));
|
|
2499
|
-
metric("hh_server_uptime_ratio_24h", "Share of the last 24 hours the server was up", state.servers.filter((server) => server.history.uptimeRatio !== null).map((server) => `hh_server_uptime_ratio_24h{server="${server.id}"} ${server.history.uptimeRatio.toFixed(4)}`));
|
|
2500
|
-
metric("hh_server_response_ms", "Last health probe latency in milliseconds", state.servers.filter((server) => server.responseMs !== null).map((server) => `hh_server_response_ms{server="${server.id}"} ${server.responseMs}`));
|
|
2501
|
-
metric("hh_server_rss_bytes", "RSS of the server process tree", state.servers.filter((server) => server.resources?.rssBytes != null).map((server) => `hh_server_rss_bytes{server="${server.id}"} ${server.resources.rssBytes}`));
|
|
2502
|
-
metric("hh_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
|
|
2503
|
-
metric("hh_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
|
|
2504
|
-
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
2505
|
-
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
2506
|
-
metric("hh_host_load1_per_cpu", "1 minute load average per cpu", [`hh_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
|
|
2507
|
-
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
2508
|
-
});
|
|
2509
|
-
}
|
|
2510
|
-
var init_metrics = __esmMin((() => {
|
|
2511
|
-
init_factory();
|
|
2644
|
+
metaSchema = type({
|
|
2645
|
+
writtenBy: "string = \"\"",
|
|
2646
|
+
schema: "number.integer >= 1 = 1"
|
|
2647
|
+
}).onUndeclaredKey("reject");
|
|
2648
|
+
configSchema = type({
|
|
2649
|
+
$schema: "string?",
|
|
2650
|
+
meta: metaSchema.optional(),
|
|
2651
|
+
control: controlSchema.default(() => ({})),
|
|
2652
|
+
defaults: defaultsSchema.default(() => ({})),
|
|
2653
|
+
logs: logsSchema.default(() => ({})),
|
|
2654
|
+
notifications: notificationsSchema.default(() => ({})),
|
|
2655
|
+
host: hostSchema.default(() => ({})),
|
|
2656
|
+
backups: backupsSchema.default(() => ({})),
|
|
2657
|
+
ddns: ddnsConfigSchema.default(() => ({})),
|
|
2658
|
+
servers: serverSchema.array().default(() => [])
|
|
2659
|
+
}).onUndeclaredKey("reject");
|
|
2660
|
+
CONFIG_KEYS = [
|
|
2661
|
+
"$schema",
|
|
2662
|
+
"meta",
|
|
2663
|
+
"control",
|
|
2664
|
+
"defaults",
|
|
2665
|
+
"logs",
|
|
2666
|
+
"notifications",
|
|
2667
|
+
"host",
|
|
2668
|
+
"backups",
|
|
2669
|
+
"ddns",
|
|
2670
|
+
"servers"
|
|
2671
|
+
];
|
|
2512
2672
|
}));
|
|
2513
2673
|
//#endregion
|
|
2514
|
-
//#region src/
|
|
2515
|
-
function
|
|
2516
|
-
|
|
2517
|
-
if (cached) return cached;
|
|
2518
|
-
const bot = new Bot(token, { client: { timeoutSeconds: 10 } });
|
|
2519
|
-
bot.api.config.use(autoRetry({
|
|
2520
|
-
maxRetryAttempts: 3,
|
|
2521
|
-
maxDelaySeconds: 20
|
|
2522
|
-
}));
|
|
2523
|
-
bots.set(token, bot);
|
|
2524
|
-
return bot;
|
|
2525
|
-
}
|
|
2526
|
-
/** Drops cached clients; used when the token changes or the service shuts down. */
|
|
2527
|
-
function forgetBots() {
|
|
2528
|
-
bots.clear();
|
|
2529
|
-
}
|
|
2530
|
-
function escapeHtml(value) {
|
|
2531
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2532
|
-
}
|
|
2533
|
-
function formatTelegramMessage(title, lines) {
|
|
2534
|
-
const body = lines.filter((line) => line.length > 0).map((line) => `• ${escapeHtml(line)}`).join("\n");
|
|
2535
|
-
return `<b>${escapeHtml(title)}</b>${body.length > 0 ? `\n${body}` : ""}`;
|
|
2674
|
+
//#region src/config/parse.ts
|
|
2675
|
+
function isRecord$1(value) {
|
|
2676
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2536
2677
|
}
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
if (description) return `${description}${candidate.error_code === void 0 ? "" : ` (${candidate.error_code})`}${candidate.parameters?.retry_after === void 0 ? "" : `, retry in ${candidate.parameters.retry_after}s`}`;
|
|
2678
|
+
function deleteAtPath(root, path) {
|
|
2679
|
+
let node = root;
|
|
2680
|
+
for (const key of path.slice(0, -1)) {
|
|
2681
|
+
node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
|
|
2682
|
+
if (node === void 0) return;
|
|
2543
2683
|
}
|
|
2544
|
-
|
|
2684
|
+
const last = path[path.length - 1];
|
|
2685
|
+
if (last === void 0) return;
|
|
2686
|
+
if (isRecord$1(node)) delete node[String(last)];
|
|
2687
|
+
else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
|
|
2545
2688
|
}
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2689
|
+
/**
|
|
2690
|
+
* Validates one object against one schema, tolerating keys the schema does not
|
|
2691
|
+
* know: a config written by a newer release has to keep working here, and losing
|
|
2692
|
+
* the *whole group* to schema defaults would silently reset the listener port,
|
|
2693
|
+
* the bind and the auth policy over a single unrecognized key.
|
|
2694
|
+
*
|
|
2695
|
+
* Only unrecognized keys are dropped, and each one is reported. Anything else is
|
|
2696
|
+
* a real problem, returned for the caller to refuse on.
|
|
2697
|
+
*/
|
|
2698
|
+
function parseTolerant(value, schema, prefix, unknownKeys) {
|
|
2699
|
+
const candidate = structuredClone(value);
|
|
2700
|
+
for (let pass = 0; pass < 25; pass++) {
|
|
2701
|
+
const parsed = schema(candidate);
|
|
2702
|
+
if (!(parsed instanceof type.errors)) return {
|
|
2703
|
+
value: parsed,
|
|
2704
|
+
error: null
|
|
2557
2705
|
};
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
return {
|
|
2563
|
-
ok: true,
|
|
2564
|
-
username: (await getBot(token).api.getMe()).username
|
|
2706
|
+
const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
|
|
2707
|
+
if (removable.length === 0) return {
|
|
2708
|
+
value: null,
|
|
2709
|
+
error: parsed.summary
|
|
2565
2710
|
};
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
error: describeTelegramError(error)
|
|
2711
|
+
if (!isRecord$1(candidate)) return {
|
|
2712
|
+
value: null,
|
|
2713
|
+
error: parsed.summary
|
|
2570
2714
|
};
|
|
2715
|
+
for (const problem of removable) {
|
|
2716
|
+
const at = `${prefix}.${problem.path.join(".")}`;
|
|
2717
|
+
if (!unknownKeys.includes(at)) unknownKeys.push(at);
|
|
2718
|
+
deleteAtPath(candidate, problem.path);
|
|
2719
|
+
}
|
|
2571
2720
|
}
|
|
2721
|
+
return {
|
|
2722
|
+
value: null,
|
|
2723
|
+
error: "too many unrecognized keys to ignore"
|
|
2724
|
+
};
|
|
2572
2725
|
}
|
|
2573
2726
|
/**
|
|
2574
|
-
*
|
|
2575
|
-
*
|
|
2576
|
-
*
|
|
2577
|
-
*/
|
|
2578
|
-
async function listTelegramChats(token) {
|
|
2579
|
-
try {
|
|
2580
|
-
const updates = await getBot(token).api.getUpdates({
|
|
2581
|
-
limit: 100,
|
|
2582
|
-
allowed_updates: [
|
|
2583
|
-
"message",
|
|
2584
|
-
"channel_post",
|
|
2585
|
-
"edited_message"
|
|
2586
|
-
]
|
|
2587
|
-
});
|
|
2588
|
-
const chats = /* @__PURE__ */ new Map();
|
|
2589
|
-
for (const update of updates) {
|
|
2590
|
-
const chat = update.message?.chat ?? update.channel_post?.chat ?? update.edited_message?.chat;
|
|
2591
|
-
if (!chat) continue;
|
|
2592
|
-
const title = "title" in chat && chat.title ? chat.title : "username" in chat && chat.username ? `@${chat.username}` : "first_name" in chat && chat.first_name ? chat.first_name : "private chat";
|
|
2593
|
-
chats.set(String(chat.id), {
|
|
2594
|
-
id: chat.id,
|
|
2595
|
-
title
|
|
2596
|
-
});
|
|
2597
|
-
}
|
|
2598
|
-
return {
|
|
2599
|
-
ok: true,
|
|
2600
|
-
chats: [...chats.values()]
|
|
2601
|
-
};
|
|
2602
|
-
} catch (error) {
|
|
2603
|
-
return {
|
|
2604
|
-
ok: false,
|
|
2605
|
-
chats: [],
|
|
2606
|
-
error: describeTelegramError(error)
|
|
2607
|
-
};
|
|
2608
|
-
}
|
|
2609
|
-
}
|
|
2610
|
-
var bots;
|
|
2611
|
-
var init_telegram = __esmMin((() => {
|
|
2612
|
-
bots = /* @__PURE__ */ new Map();
|
|
2613
|
-
}));
|
|
2614
|
-
//#endregion
|
|
2615
|
-
//#region src/api/notifications.ts
|
|
2616
|
-
/**
|
|
2617
|
-
* The bot token is written straight to the secrets file and never into
|
|
2618
|
-
* `servers.config.json`, so notification *policy* and the *credential* stay
|
|
2619
|
-
* separate.
|
|
2620
|
-
*/
|
|
2621
|
-
function createNotificationsRoute(deps) {
|
|
2622
|
-
return appFactory.createApp().put("/notifications/token", describeRoute({
|
|
2623
|
-
tags: ["notifications"],
|
|
2624
|
-
summary: "Store the Telegram bot token (verified first)",
|
|
2625
|
-
responses: {
|
|
2626
|
-
200: { description: "Stored" },
|
|
2627
|
-
400: ERROR_RESPONSES[400]
|
|
2628
|
-
}
|
|
2629
|
-
}), validate("json", telegramTokenSchema), async (c) => {
|
|
2630
|
-
const { botToken } = c.req.valid("json");
|
|
2631
|
-
const verified = await deps.notifications.verifyToken(botToken);
|
|
2632
|
-
if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
|
|
2633
|
-
statusCode: 400,
|
|
2634
|
-
code: "TELEGRAM_TOKEN_REJECTED"
|
|
2635
|
-
});
|
|
2636
|
-
deps.secrets.setTelegramToken(botToken);
|
|
2637
|
-
forgetBots();
|
|
2638
|
-
return c.json({
|
|
2639
|
-
ok: true,
|
|
2640
|
-
username: verified.username ?? null
|
|
2641
|
-
});
|
|
2642
|
-
}).delete("/notifications/token", describeRoute({
|
|
2643
|
-
tags: ["notifications"],
|
|
2644
|
-
summary: "Forget the Telegram bot token",
|
|
2645
|
-
responses: { 200: { description: "Removed" } }
|
|
2646
|
-
}), (c) => {
|
|
2647
|
-
deps.secrets.setTelegramToken(null);
|
|
2648
|
-
forgetBots();
|
|
2649
|
-
return c.json({ ok: true });
|
|
2650
|
-
}).post("/notifications/test", describeRoute({
|
|
2651
|
-
tags: ["notifications"],
|
|
2652
|
-
summary: "Send a test message",
|
|
2653
|
-
responses: {
|
|
2654
|
-
200: { description: "Sent or refused" },
|
|
2655
|
-
400: ERROR_RESPONSES[400]
|
|
2656
|
-
}
|
|
2657
|
-
}), validate("json", notificationActionSchema), async (c) => {
|
|
2658
|
-
const result = await deps.notifications.sendTest(c.req.valid("json"));
|
|
2659
|
-
if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
|
|
2660
|
-
statusCode: 400,
|
|
2661
|
-
code: "TELEGRAM_SEND_FAILED"
|
|
2662
|
-
});
|
|
2663
|
-
return c.json(result);
|
|
2664
|
-
}).post("/notifications/detect-chats", describeRoute({
|
|
2665
|
-
tags: ["notifications"],
|
|
2666
|
-
summary: "List the chats the bot can see",
|
|
2667
|
-
responses: {
|
|
2668
|
-
200: {
|
|
2669
|
-
description: "Chats",
|
|
2670
|
-
content: jsonBody(type({ chats: type({
|
|
2671
|
-
id: "string | number",
|
|
2672
|
-
title: "string"
|
|
2673
|
-
}).array() }))
|
|
2674
|
-
},
|
|
2675
|
-
400: ERROR_RESPONSES[400]
|
|
2676
|
-
}
|
|
2677
|
-
}), validate("json", notificationActionSchema), async (c) => {
|
|
2678
|
-
const result = await deps.notifications.detectChats(c.req.valid("json"));
|
|
2679
|
-
if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
|
|
2680
|
-
statusCode: 400,
|
|
2681
|
-
code: "TELEGRAM_LIST_FAILED"
|
|
2682
|
-
});
|
|
2683
|
-
return c.json({ chats: result.chats });
|
|
2684
|
-
});
|
|
2685
|
-
}
|
|
2686
|
-
var init_notifications$1 = __esmMin((() => {
|
|
2687
|
-
init_factory();
|
|
2688
|
-
init_openapi$1();
|
|
2689
|
-
init_validator();
|
|
2690
|
-
init_telegram();
|
|
2691
|
-
init_contracts();
|
|
2692
|
-
}));
|
|
2693
|
-
//#endregion
|
|
2694
|
-
//#region src/config/migrations.ts
|
|
2695
|
-
/** What would have to run to bring `from` up to `to`. */
|
|
2696
|
-
function planConfigMigrations(from, options = {}) {
|
|
2697
|
-
const to = options.to ?? 1;
|
|
2698
|
-
return {
|
|
2699
|
-
from,
|
|
2700
|
-
to,
|
|
2701
|
-
steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
|
|
2702
|
-
tooNew: from > to
|
|
2703
|
-
};
|
|
2704
|
-
}
|
|
2705
|
-
/** Applies the plan in order; the caller owns writing the result. */
|
|
2706
|
-
function applyConfigMigrations(config, from, options = {}) {
|
|
2707
|
-
const { steps } = planConfigMigrations(from, options);
|
|
2708
|
-
let current = config;
|
|
2709
|
-
for (const step of steps) current = step.apply(current);
|
|
2710
|
-
return {
|
|
2711
|
-
config: current,
|
|
2712
|
-
applied: steps
|
|
2713
|
-
};
|
|
2714
|
-
}
|
|
2715
|
-
var configMigrations;
|
|
2716
|
-
var init_migrations = __esmMin((() => {
|
|
2717
|
-
configMigrations = [];
|
|
2718
|
-
}));
|
|
2719
|
-
//#endregion
|
|
2720
|
-
//#region src/config/schema.ts
|
|
2721
|
-
function isRecord$2(value) {
|
|
2722
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2723
|
-
}
|
|
2724
|
-
/**
|
|
2725
|
-
* Resolves one entry against the panel's server defaults. A group (`restart`,
|
|
2726
|
-
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
2727
|
-
* member does not silently fall back to the *schema* default for the others — which
|
|
2728
|
-
* is the whole point of the panel having defaults at all.
|
|
2729
|
-
*/
|
|
2730
|
-
function mergeDefaults(defaults, entry) {
|
|
2731
|
-
const merged = { ...entry };
|
|
2732
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
2733
|
-
const current = merged[key];
|
|
2734
|
-
if (current === void 0) merged[key] = value;
|
|
2735
|
-
else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
|
|
2736
|
-
}
|
|
2737
|
-
return merged;
|
|
2738
|
-
}
|
|
2739
|
-
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2740
|
-
var init_schema = __esmMin((() => {
|
|
2741
|
-
init_contracts();
|
|
2742
|
-
metaSchema = type({
|
|
2743
|
-
writtenBy: "string = \"\"",
|
|
2744
|
-
schema: "number.integer >= 1 = 1"
|
|
2745
|
-
}).onUndeclaredKey("reject");
|
|
2746
|
-
configSchema = type({
|
|
2747
|
-
$schema: "string?",
|
|
2748
|
-
meta: metaSchema.optional(),
|
|
2749
|
-
control: controlSchema.default(() => ({})),
|
|
2750
|
-
defaults: defaultsSchema.default(() => ({})),
|
|
2751
|
-
logs: logsSchema.default(() => ({})),
|
|
2752
|
-
notifications: notificationsSchema.default(() => ({})),
|
|
2753
|
-
host: hostSchema.default(() => ({})),
|
|
2754
|
-
backups: backupsSchema.default(() => ({})),
|
|
2755
|
-
servers: serverSchema.array().default(() => [])
|
|
2756
|
-
}).onUndeclaredKey("reject");
|
|
2757
|
-
CONFIG_KEYS = [
|
|
2758
|
-
"$schema",
|
|
2759
|
-
"meta",
|
|
2760
|
-
"control",
|
|
2761
|
-
"defaults",
|
|
2762
|
-
"logs",
|
|
2763
|
-
"notifications",
|
|
2764
|
-
"host",
|
|
2765
|
-
"backups",
|
|
2766
|
-
"servers"
|
|
2767
|
-
];
|
|
2768
|
-
}));
|
|
2769
|
-
//#endregion
|
|
2770
|
-
//#region src/config/parse.ts
|
|
2771
|
-
function isRecord$1(value) {
|
|
2772
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2773
|
-
}
|
|
2774
|
-
function deleteAtPath(root, path) {
|
|
2775
|
-
let node = root;
|
|
2776
|
-
for (const key of path.slice(0, -1)) {
|
|
2777
|
-
node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
|
|
2778
|
-
if (node === void 0) return;
|
|
2779
|
-
}
|
|
2780
|
-
const last = path[path.length - 1];
|
|
2781
|
-
if (last === void 0) return;
|
|
2782
|
-
if (isRecord$1(node)) delete node[String(last)];
|
|
2783
|
-
else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
|
|
2784
|
-
}
|
|
2785
|
-
/**
|
|
2786
|
-
* Validates one object against one schema, tolerating keys the schema does not
|
|
2787
|
-
* know: a config written by a newer release has to keep working here, and losing
|
|
2788
|
-
* the *whole group* to schema defaults would silently reset the listener port,
|
|
2789
|
-
* the bind and the auth policy over a single unrecognized key.
|
|
2790
|
-
*
|
|
2791
|
-
* Only unrecognized keys are dropped, and each one is reported. Anything else is
|
|
2792
|
-
* a real problem, returned for the caller to refuse on.
|
|
2793
|
-
*/
|
|
2794
|
-
function parseTolerant(value, schema, prefix, unknownKeys) {
|
|
2795
|
-
const candidate = structuredClone(value);
|
|
2796
|
-
for (let pass = 0; pass < 25; pass++) {
|
|
2797
|
-
const parsed = schema(candidate);
|
|
2798
|
-
if (!(parsed instanceof type.errors)) return {
|
|
2799
|
-
value: parsed,
|
|
2800
|
-
error: null
|
|
2801
|
-
};
|
|
2802
|
-
const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
|
|
2803
|
-
if (removable.length === 0) return {
|
|
2804
|
-
value: null,
|
|
2805
|
-
error: parsed.summary
|
|
2806
|
-
};
|
|
2807
|
-
if (!isRecord$1(candidate)) return {
|
|
2808
|
-
value: null,
|
|
2809
|
-
error: parsed.summary
|
|
2810
|
-
};
|
|
2811
|
-
for (const problem of removable) {
|
|
2812
|
-
const at = `${prefix}.${problem.path.join(".")}`;
|
|
2813
|
-
if (!unknownKeys.includes(at)) unknownKeys.push(at);
|
|
2814
|
-
deleteAtPath(candidate, problem.path);
|
|
2815
|
-
}
|
|
2816
|
-
}
|
|
2817
|
-
return {
|
|
2818
|
-
value: null,
|
|
2819
|
-
error: "too many unrecognized keys to ignore"
|
|
2820
|
-
};
|
|
2821
|
-
}
|
|
2822
|
-
/**
|
|
2823
|
-
* Reads a `servers.config.json` from any release. Unrecognized keys are reported
|
|
2824
|
-
* and left on disk; blocking problems — including a file this release cannot
|
|
2825
|
-
* understand — land in `errors` and leave `config` null.
|
|
2727
|
+
* Reads a `servers.config.json` from any release. Unrecognized keys are reported
|
|
2728
|
+
* and left on disk; blocking problems — including a file this release cannot
|
|
2729
|
+
* understand — land in `errors` and leave `config` null.
|
|
2826
2730
|
*/
|
|
2827
2731
|
function parseConfig(raw, options = {}) {
|
|
2828
2732
|
const unknownKeys = [];
|
|
@@ -2896,6 +2800,7 @@ function parseConfig(raw, options = {}) {
|
|
|
2896
2800
|
notifications: groups.notifications,
|
|
2897
2801
|
host: groups.host,
|
|
2898
2802
|
backups: groups.backups,
|
|
2803
|
+
ddns: groups.ddns,
|
|
2899
2804
|
servers
|
|
2900
2805
|
};
|
|
2901
2806
|
return result;
|
|
@@ -2946,7 +2851,8 @@ var init_parse = __esmMin((() => {
|
|
|
2946
2851
|
["logs", logsSchema],
|
|
2947
2852
|
["notifications", notificationsSchema],
|
|
2948
2853
|
["host", hostSchema],
|
|
2949
|
-
["backups", backupsSchema]
|
|
2854
|
+
["backups", backupsSchema],
|
|
2855
|
+
["ddns", ddnsConfigSchema]
|
|
2950
2856
|
];
|
|
2951
2857
|
UNDECLARED = "must be removed";
|
|
2952
2858
|
}));
|
|
@@ -2971,324 +2877,2065 @@ var init_seed = __esmMin((() => {
|
|
|
2971
2877
|
};
|
|
2972
2878
|
}));
|
|
2973
2879
|
//#endregion
|
|
2974
|
-
//#region src/
|
|
2975
|
-
function
|
|
2976
|
-
return
|
|
2880
|
+
//#region src/providers/ddns/types.ts
|
|
2881
|
+
function succeeded(message, changed) {
|
|
2882
|
+
return {
|
|
2883
|
+
ok: true,
|
|
2884
|
+
changed,
|
|
2885
|
+
message
|
|
2886
|
+
};
|
|
2977
2887
|
}
|
|
2978
|
-
function
|
|
2979
|
-
return
|
|
2888
|
+
function failed(error) {
|
|
2889
|
+
return {
|
|
2890
|
+
ok: false,
|
|
2891
|
+
changed: false,
|
|
2892
|
+
message: error,
|
|
2893
|
+
error
|
|
2894
|
+
};
|
|
2980
2895
|
}
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2896
|
+
/** Missing required credential keys, as a sentence for the settings page. */
|
|
2897
|
+
function missingFields(credentials, keys) {
|
|
2898
|
+
const missing = keys.filter((key) => (credentials?.[key] ?? "").trim().length === 0);
|
|
2899
|
+
return missing.length === 0 ? null : `set ${missing.join(", ")}`;
|
|
2900
|
+
}
|
|
2901
|
+
function basicAuth(user, password) {
|
|
2902
|
+
return `Basic ${Buffer$1.from(`${user}:${password}`, "utf8").toString("base64")}`;
|
|
2903
|
+
}
|
|
2904
|
+
/** A provider error body is useful, but never worth throwing over. */
|
|
2905
|
+
async function errorText(response, limit = 300) {
|
|
2906
|
+
try {
|
|
2907
|
+
const text = (await response.text()).replace(/\s+/g, " ").trim();
|
|
2908
|
+
return text.length === 0 ? `HTTP ${response.status}` : `HTTP ${response.status}: ${text.slice(0, limit)}`;
|
|
2909
|
+
} catch {
|
|
2910
|
+
return `HTTP ${response.status}`;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
/** `Sub.Domain.COM.` -> `sub.domain.com`; DuckDNS and friends are case-insensitive. */
|
|
2914
|
+
function normalizeHost(host) {
|
|
2915
|
+
return host.trim().toLowerCase().replace(NORMALIZE_DOTS, "");
|
|
2916
|
+
}
|
|
2917
|
+
var NORMALIZE_DOTS;
|
|
2918
|
+
var init_types = __esmMin((() => {
|
|
2919
|
+
NORMALIZE_DOTS = /\.$/;
|
|
2920
|
+
}));
|
|
2921
|
+
//#endregion
|
|
2922
|
+
//#region src/providers/ddns/cloudflare.ts
|
|
2923
|
+
function authHeaders(credentials) {
|
|
2924
|
+
const token = (credentials.apiToken ?? "").trim();
|
|
2925
|
+
if (token.length > 0) return { Authorization: `Bearer ${token}` };
|
|
2926
|
+
const email = (credentials.email ?? "").trim();
|
|
2927
|
+
const key = (credentials.apiKey ?? "").trim();
|
|
2928
|
+
if (email.length > 0 && key.length > 0) return {
|
|
2929
|
+
"X-Auth-Email": email,
|
|
2930
|
+
"X-Auth-Key": key
|
|
2931
|
+
};
|
|
2932
|
+
return null;
|
|
2933
|
+
}
|
|
2934
|
+
async function call(path, headers, context, init = {}) {
|
|
2935
|
+
try {
|
|
2936
|
+
const response = await context.fetch(`${API$5}${path}`, {
|
|
2937
|
+
...init,
|
|
2938
|
+
headers: {
|
|
2939
|
+
...headers,
|
|
2940
|
+
"Content-Type": "application/json",
|
|
2941
|
+
...init.headers ?? {}
|
|
2942
|
+
},
|
|
2943
|
+
signal: context.signal
|
|
2944
|
+
});
|
|
2945
|
+
const text = await response.text();
|
|
2946
|
+
let parsed;
|
|
2947
|
+
try {
|
|
2948
|
+
parsed = JSON.parse(text);
|
|
2949
|
+
} catch {
|
|
2950
|
+
return {
|
|
2951
|
+
data: null,
|
|
2952
|
+
status: response.status,
|
|
2953
|
+
error: `cloudflare answered ${response.status}: ${text.slice(0, 200)}`
|
|
2954
|
+
};
|
|
2987
2955
|
}
|
|
2988
|
-
|
|
2956
|
+
if (!response.ok || parsed.success !== true) {
|
|
2957
|
+
const detail = (parsed.errors ?? []).map((entry) => `${entry.code} ${entry.message}`).join("; ");
|
|
2958
|
+
return {
|
|
2959
|
+
data: parsed,
|
|
2960
|
+
status: response.status,
|
|
2961
|
+
error: detail.length > 0 ? detail : `HTTP ${response.status}`
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
return {
|
|
2965
|
+
data: parsed,
|
|
2966
|
+
status: response.status,
|
|
2967
|
+
error: null
|
|
2968
|
+
};
|
|
2969
|
+
} catch (error) {
|
|
2970
|
+
return {
|
|
2971
|
+
data: null,
|
|
2972
|
+
status: 0,
|
|
2973
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2974
|
+
};
|
|
2989
2975
|
}
|
|
2990
2976
|
}
|
|
2991
2977
|
/**
|
|
2992
|
-
*
|
|
2993
|
-
*
|
|
2994
|
-
*
|
|
2995
|
-
* field is cleared.
|
|
2978
|
+
* Walks the host's parents through `GET /zones?name=`, which is how the
|
|
2979
|
+
* reference image resolves a zone without a pasted zone id; the id is cached in
|
|
2980
|
+
* the panel's state file, so it usually costs nothing.
|
|
2996
2981
|
*/
|
|
2997
|
-
function
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
merged[key] = value;
|
|
2982
|
+
async function resolveZone(host, headers, context, cached) {
|
|
2983
|
+
if (cached !== void 0) return {
|
|
2984
|
+
id: cached,
|
|
2985
|
+
name: host
|
|
2986
|
+
};
|
|
2987
|
+
const labels = host.split(".");
|
|
2988
|
+
for (let index = 0; index <= labels.length - 2; index++) {
|
|
2989
|
+
const candidate = labels.slice(index).join(".");
|
|
2990
|
+
const { data, error } = await call(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
|
|
2991
|
+
if (error !== null || data === null) throw new Error(error ?? "could not look up the zone");
|
|
2992
|
+
const zone = data.result[0];
|
|
2993
|
+
if (zone !== void 0) return zone;
|
|
3010
2994
|
}
|
|
3011
|
-
return
|
|
2995
|
+
return null;
|
|
3012
2996
|
}
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
ConfigError = class extends Error {
|
|
3030
|
-
name = "ConfigError";
|
|
2997
|
+
async function findRecord(zoneId, record, headers, context, cached) {
|
|
2998
|
+
if (cached !== void 0) {
|
|
2999
|
+
const fetched = await call(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
|
|
3000
|
+
if (fetched.error === null && fetched.data !== null) return {
|
|
3001
|
+
record: fetched.data.result,
|
|
3002
|
+
error: null
|
|
3003
|
+
};
|
|
3004
|
+
if (fetched.status !== 404 && fetched.status !== 400) return {
|
|
3005
|
+
record: null,
|
|
3006
|
+
error: fetched.error
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
const listed = await call(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
|
|
3010
|
+
if (listed.error !== null) return {
|
|
3011
|
+
record: null,
|
|
3012
|
+
error: listed.error
|
|
3031
3013
|
};
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
return
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
return structuredClone(this.raw);
|
|
3076
|
-
}
|
|
3077
|
-
onChange(listener) {
|
|
3078
|
-
this.listeners.add(listener);
|
|
3079
|
-
return () => this.listeners.delete(listener);
|
|
3080
|
-
}
|
|
3081
|
-
getServer(id) {
|
|
3082
|
-
return this.servers.find((server) => server.id === id);
|
|
3083
|
-
}
|
|
3084
|
-
/**
|
|
3085
|
-
* Reads the file and tells the listeners, so whatever wrote it — the settings
|
|
3086
|
-
* page, a restored backup, or the file watcher — becomes the live config.
|
|
3087
|
-
*/
|
|
3088
|
-
load() {
|
|
3089
|
-
this.read();
|
|
3090
|
-
this.notify();
|
|
3091
|
-
}
|
|
3092
|
-
/**
|
|
3093
|
-
* The watcher's entry point: re-reads the file only when its bytes changed.
|
|
3094
|
-
*
|
|
3095
|
-
* `changed` means the file on disk is different from what the live config was
|
|
3096
|
-
* read from, `applied` means that difference was accepted — a file this release
|
|
3097
|
-
* cannot read is reported and the config already running is left alone, which is
|
|
3098
|
-
* what keeps an editor's typo from stopping every server.
|
|
3099
|
-
*/
|
|
3100
|
-
reloadFromDisk() {
|
|
3101
|
-
let text;
|
|
3014
|
+
return {
|
|
3015
|
+
record: (listed.data?.result ?? []).find((entry) => entry.id !== void 0) ?? null,
|
|
3016
|
+
error: null
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
3019
|
+
var API$5, cloudflareProvider;
|
|
3020
|
+
var init_cloudflare = __esmMin((() => {
|
|
3021
|
+
init_types();
|
|
3022
|
+
API$5 = "https://api.cloudflare.com/client/v4";
|
|
3023
|
+
cloudflareProvider = {
|
|
3024
|
+
id: "cloudflare",
|
|
3025
|
+
label: "Cloudflare",
|
|
3026
|
+
docsUrl: "https://developers.cloudflare.com/api/resources/dns/subresources/records/",
|
|
3027
|
+
families: ["A", "AAAA"],
|
|
3028
|
+
ttl: true,
|
|
3029
|
+
proxied: true,
|
|
3030
|
+
fields: [
|
|
3031
|
+
{
|
|
3032
|
+
key: "apiToken",
|
|
3033
|
+
label: "API token",
|
|
3034
|
+
hint: "Zone → DNS → Edit (plus Zone → Zone → Read) for your zones. Preferred.",
|
|
3035
|
+
optional: true
|
|
3036
|
+
},
|
|
3037
|
+
{
|
|
3038
|
+
key: "email",
|
|
3039
|
+
label: "Account email",
|
|
3040
|
+
hint: "Only for the legacy Global API Key.",
|
|
3041
|
+
optional: true
|
|
3042
|
+
},
|
|
3043
|
+
{
|
|
3044
|
+
key: "apiKey",
|
|
3045
|
+
label: "Global API key",
|
|
3046
|
+
hint: "Legacy; prefer an API token.",
|
|
3047
|
+
optional: true
|
|
3048
|
+
}
|
|
3049
|
+
],
|
|
3050
|
+
validate(credentials) {
|
|
3051
|
+
if (authHeaders(credentials ?? {}) !== null) return null;
|
|
3052
|
+
return missingFields(credentials, ["apiToken"]);
|
|
3053
|
+
},
|
|
3054
|
+
async update(record, context) {
|
|
3055
|
+
const headers = authHeaders(context.credentials);
|
|
3056
|
+
if (headers === null) return failed("cloudflare needs an API token (or the legacy email + global key)");
|
|
3102
3057
|
try {
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3058
|
+
const zone = await resolveZone(record.host, headers, context, record.cache?.zoneId);
|
|
3059
|
+
if (zone === null) return failed(`cloudflare does not have a zone for ${record.host}`);
|
|
3060
|
+
const ttl = record.proxied ? 1 : record.ttl;
|
|
3061
|
+
const found = await findRecord(zone.id, record, headers, context, record.cache?.recordId);
|
|
3062
|
+
if (found.error !== null) return failed(found.error);
|
|
3063
|
+
const existing = found.record;
|
|
3064
|
+
if (existing !== null && existing.content === record.ip && existing.ttl === ttl && existing.proxied === record.proxied) return {
|
|
3065
|
+
...succeeded(`already ${record.ip}`, false),
|
|
3066
|
+
cache: {
|
|
3067
|
+
zoneId: zone.id,
|
|
3068
|
+
recordId: existing.id
|
|
3069
|
+
}
|
|
3110
3070
|
};
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3071
|
+
const cache = {
|
|
3072
|
+
zoneId: zone.id,
|
|
3073
|
+
recordId: existing?.id
|
|
3074
|
+
};
|
|
3075
|
+
const body = JSON.stringify({
|
|
3076
|
+
type: record.type,
|
|
3077
|
+
name: record.host,
|
|
3078
|
+
content: record.ip,
|
|
3079
|
+
ttl,
|
|
3080
|
+
proxied: record.proxied
|
|
3081
|
+
});
|
|
3082
|
+
const written = existing === null ? await call(`/zones/${zone.id}/dns_records`, headers, context, {
|
|
3083
|
+
method: "POST",
|
|
3084
|
+
body
|
|
3085
|
+
}) : await call(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
|
|
3086
|
+
method: "PATCH",
|
|
3087
|
+
body
|
|
3088
|
+
});
|
|
3089
|
+
if (written.error !== null) return failed(written.error);
|
|
3117
3090
|
return {
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3091
|
+
...succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true),
|
|
3092
|
+
cache: {
|
|
3093
|
+
...cache,
|
|
3094
|
+
recordId: written.data?.result?.id ?? existing?.id
|
|
3095
|
+
}
|
|
3121
3096
|
};
|
|
3097
|
+
} catch (error) {
|
|
3098
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3122
3099
|
}
|
|
3123
|
-
const before = this.resolvedConfig;
|
|
3124
|
-
this.load();
|
|
3125
|
-
return {
|
|
3126
|
-
changed: true,
|
|
3127
|
-
applied: this.resolvedConfig !== before,
|
|
3128
|
-
error: this.error
|
|
3129
|
-
};
|
|
3130
3100
|
}
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3101
|
+
};
|
|
3102
|
+
}));
|
|
3103
|
+
//#endregion
|
|
3104
|
+
//#region src/providers/ddns/dyndns2.ts
|
|
3105
|
+
/** The first word of the body is the code; the rest is provider detail. */
|
|
3106
|
+
function parseDyndnsReply(body) {
|
|
3107
|
+
const trimmed = body.trim();
|
|
3108
|
+
const separator = trimmed.search(/\s/);
|
|
3109
|
+
if (separator < 0) return {
|
|
3110
|
+
code: trimmed.toLowerCase(),
|
|
3111
|
+
detail: ""
|
|
3112
|
+
};
|
|
3113
|
+
return {
|
|
3114
|
+
code: trimmed.slice(0, separator).toLowerCase(),
|
|
3115
|
+
detail: trimmed.slice(separator).trim()
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
/** Maps a dyndns2 body onto the panel's result shape. */
|
|
3119
|
+
function describeDyndnsReply(body, provider) {
|
|
3120
|
+
const { code, detail } = parseDyndnsReply(body);
|
|
3121
|
+
const known = CODES[code];
|
|
3122
|
+
if (known === void 0) return failed(`${provider} answered "${(body.trim() || "an empty body").slice(0, 120)}"`);
|
|
3123
|
+
if (known.ok) return succeeded(`${known.message}${detail.length === 0 ? "" : ` (${detail})`}`, known.changed);
|
|
3124
|
+
return failed(`${known.message}${detail.length === 0 ? "" : `: ${detail}`}`);
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* A 401/400 with a non-dyndns2 body is still an answer worth quoting; a known
|
|
3128
|
+
* code wins, because several of these endpoints report errors with HTTP 200.
|
|
3129
|
+
*/
|
|
3130
|
+
function describeDyndnsResponse(response, body, provider) {
|
|
3131
|
+
const { code } = parseDyndnsReply(body);
|
|
3132
|
+
if (!response.ok && CODES[code] === void 0) {
|
|
3133
|
+
const text = body.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
3134
|
+
return failed(`${provider} answered HTTP ${response.status}${text.length === 0 ? "" : `: ${text}`}`);
|
|
3135
|
+
}
|
|
3136
|
+
return describeDyndnsReply(body, provider);
|
|
3137
|
+
}
|
|
3138
|
+
var CODES;
|
|
3139
|
+
var init_dyndns2 = __esmMin((() => {
|
|
3140
|
+
init_types();
|
|
3141
|
+
CODES = {
|
|
3142
|
+
"good": {
|
|
3143
|
+
ok: true,
|
|
3144
|
+
changed: true,
|
|
3145
|
+
message: "updated",
|
|
3146
|
+
permanent: false
|
|
3147
|
+
},
|
|
3148
|
+
"nochg": {
|
|
3149
|
+
ok: true,
|
|
3150
|
+
changed: false,
|
|
3151
|
+
message: "already current",
|
|
3152
|
+
permanent: false
|
|
3153
|
+
},
|
|
3154
|
+
"nohost": {
|
|
3155
|
+
ok: false,
|
|
3156
|
+
changed: false,
|
|
3157
|
+
message: "the provider does not know this hostname",
|
|
3158
|
+
permanent: true
|
|
3159
|
+
},
|
|
3160
|
+
"notfqdn": {
|
|
3161
|
+
ok: false,
|
|
3162
|
+
changed: false,
|
|
3163
|
+
message: "the provider wants a fully qualified hostname",
|
|
3164
|
+
permanent: true
|
|
3165
|
+
},
|
|
3166
|
+
"numhost": {
|
|
3167
|
+
ok: false,
|
|
3168
|
+
changed: false,
|
|
3169
|
+
message: "too many hostnames for this provider",
|
|
3170
|
+
permanent: true
|
|
3171
|
+
},
|
|
3172
|
+
"badauth": {
|
|
3173
|
+
ok: false,
|
|
3174
|
+
changed: false,
|
|
3175
|
+
message: "the provider rejected the credentials",
|
|
3176
|
+
permanent: true
|
|
3177
|
+
},
|
|
3178
|
+
"badagent": {
|
|
3179
|
+
ok: false,
|
|
3180
|
+
changed: false,
|
|
3181
|
+
message: "the provider rejected this client (User-Agent)",
|
|
3182
|
+
permanent: true
|
|
3183
|
+
},
|
|
3184
|
+
"!donator": {
|
|
3185
|
+
ok: false,
|
|
3186
|
+
changed: false,
|
|
3187
|
+
message: "this feature needs a paid provider plan",
|
|
3188
|
+
permanent: true
|
|
3189
|
+
},
|
|
3190
|
+
"abuse": {
|
|
3191
|
+
ok: false,
|
|
3192
|
+
changed: false,
|
|
3193
|
+
message: "the provider blocked this account for abuse",
|
|
3194
|
+
permanent: true
|
|
3195
|
+
},
|
|
3196
|
+
"dnserr": {
|
|
3197
|
+
ok: false,
|
|
3198
|
+
changed: false,
|
|
3199
|
+
message: "the provider had a DNS error",
|
|
3200
|
+
permanent: false
|
|
3201
|
+
},
|
|
3202
|
+
"servererror": {
|
|
3203
|
+
ok: false,
|
|
3204
|
+
changed: false,
|
|
3205
|
+
message: "the provider had a server error",
|
|
3206
|
+
permanent: false
|
|
3207
|
+
},
|
|
3208
|
+
"911": {
|
|
3209
|
+
ok: false,
|
|
3210
|
+
changed: false,
|
|
3211
|
+
message: "the provider is down; it asks you to wait about 30 minutes",
|
|
3212
|
+
permanent: false
|
|
3213
|
+
},
|
|
3214
|
+
"unknown": {
|
|
3215
|
+
ok: false,
|
|
3216
|
+
changed: false,
|
|
3217
|
+
message: "the provider answered with an unknown error",
|
|
3218
|
+
permanent: false
|
|
3219
|
+
}
|
|
3220
|
+
};
|
|
3221
|
+
}));
|
|
3222
|
+
//#endregion
|
|
3223
|
+
//#region src/providers/ddns/desec.ts
|
|
3224
|
+
var ENDPOINT$6, desecProvider;
|
|
3225
|
+
var init_desec = __esmMin((() => {
|
|
3226
|
+
init_dyndns2();
|
|
3227
|
+
init_types();
|
|
3228
|
+
ENDPOINT$6 = "https://update.dedyn.io/";
|
|
3229
|
+
desecProvider = {
|
|
3230
|
+
id: "desec",
|
|
3231
|
+
label: "deSEC",
|
|
3232
|
+
docsUrl: "https://desec.readthedocs.io/en/latest/dyndns/update-api.html",
|
|
3233
|
+
families: ["A", "AAAA"],
|
|
3234
|
+
ttl: false,
|
|
3235
|
+
proxied: false,
|
|
3236
|
+
fields: [{
|
|
3237
|
+
key: "token",
|
|
3238
|
+
label: "API token",
|
|
3239
|
+
hint: "desec.io → Token management; the token must allow the RRset update.",
|
|
3240
|
+
optional: false
|
|
3241
|
+
}],
|
|
3242
|
+
validate(credentials) {
|
|
3243
|
+
return missingFields(credentials, ["token"]);
|
|
3244
|
+
},
|
|
3245
|
+
async update(record, context) {
|
|
3246
|
+
const token = (context.credentials.token ?? "").trim();
|
|
3247
|
+
if (token.length === 0) return failed("desec needs an API token");
|
|
3248
|
+
const family = record.type === "A" ? `myipv4=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
3249
|
+
const url = `${ENDPOINT$6}?hostname=${encodeURIComponent(record.host)}&${family}`;
|
|
3143
3250
|
try {
|
|
3144
|
-
|
|
3145
|
-
|
|
3251
|
+
const response = await context.fetch(url, {
|
|
3252
|
+
headers: { Authorization: basicAuth(record.host, token) },
|
|
3253
|
+
signal: context.signal
|
|
3254
|
+
});
|
|
3255
|
+
return describeDyndnsResponse(response, await response.text(), "desec");
|
|
3146
3256
|
} catch (error) {
|
|
3147
|
-
|
|
3148
|
-
if (this.resolvedConfig === void 0) {
|
|
3149
|
-
this.raw = {};
|
|
3150
|
-
this.resolvedConfig = this.resolveFallback();
|
|
3151
|
-
}
|
|
3152
|
-
return;
|
|
3257
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3153
3258
|
}
|
|
3154
|
-
this.lastText = text;
|
|
3155
|
-
this.apply(parsed);
|
|
3156
3259
|
}
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3260
|
+
};
|
|
3261
|
+
}));
|
|
3262
|
+
//#endregion
|
|
3263
|
+
//#region src/providers/ddns/zone.ts
|
|
3264
|
+
/**
|
|
3265
|
+
* Splits a hostname into the zone and the record name, which is what every
|
|
3266
|
+
* provider that has no zone lookup needs. `zone` short-circuits the heuristic.
|
|
3267
|
+
*/
|
|
3268
|
+
function splitHost(host, zone) {
|
|
3269
|
+
const normalized = normalizeHost(host);
|
|
3270
|
+
const override = normalizeHost(zone ?? "");
|
|
3271
|
+
const resolved = override.length > 0 ? override : guessZone(normalized);
|
|
3272
|
+
if (normalized === resolved) return {
|
|
3273
|
+
zone: resolved,
|
|
3274
|
+
name: "@"
|
|
3275
|
+
};
|
|
3276
|
+
return {
|
|
3277
|
+
zone: resolved,
|
|
3278
|
+
name: normalized.endsWith(`.${resolved}`) ? normalized.slice(0, -(resolved.length + 1)) : normalized
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
function guessZone(host) {
|
|
3282
|
+
const labels = host.split(".");
|
|
3283
|
+
if (labels.length <= 2) return host;
|
|
3284
|
+
const lastTwo = labels.slice(-2).join(".");
|
|
3285
|
+
const lastThree = labels.slice(-3).join(".");
|
|
3286
|
+
return MULTI_LABEL_SUFFIXES.has(lastTwo) ? lastThree : lastTwo;
|
|
3287
|
+
}
|
|
3288
|
+
var MULTI_LABEL_SUFFIXES;
|
|
3289
|
+
var init_zone = __esmMin((() => {
|
|
3290
|
+
init_types();
|
|
3291
|
+
MULTI_LABEL_SUFFIXES = /* @__PURE__ */ new Set([
|
|
3292
|
+
"ac.uk",
|
|
3293
|
+
"co.in",
|
|
3294
|
+
"co.jp",
|
|
3295
|
+
"co.ke",
|
|
3296
|
+
"co.kr",
|
|
3297
|
+
"co.nz",
|
|
3298
|
+
"co.uk",
|
|
3299
|
+
"co.za",
|
|
3300
|
+
"com.au",
|
|
3301
|
+
"com.br",
|
|
3302
|
+
"com.cn",
|
|
3303
|
+
"com.hk",
|
|
3304
|
+
"com.mx",
|
|
3305
|
+
"com.sg",
|
|
3306
|
+
"com.tr",
|
|
3307
|
+
"com.tw",
|
|
3308
|
+
"edu.au",
|
|
3309
|
+
"gov.uk",
|
|
3310
|
+
"net.au",
|
|
3311
|
+
"net.uk",
|
|
3312
|
+
"org.au",
|
|
3313
|
+
"org.uk"
|
|
3314
|
+
]);
|
|
3315
|
+
}));
|
|
3316
|
+
//#endregion
|
|
3317
|
+
//#region src/providers/ddns/digitalocean.ts
|
|
3318
|
+
function oceanTtl(ttl) {
|
|
3319
|
+
return Math.max(30, ttl);
|
|
3320
|
+
}
|
|
3321
|
+
var API$4, digitaloceanProvider;
|
|
3322
|
+
var init_digitalocean = __esmMin((() => {
|
|
3323
|
+
init_types();
|
|
3324
|
+
init_zone();
|
|
3325
|
+
API$4 = "https://api.digitalocean.com/v2/domains";
|
|
3326
|
+
digitaloceanProvider = {
|
|
3327
|
+
id: "digitalocean",
|
|
3328
|
+
label: "DigitalOcean",
|
|
3329
|
+
docsUrl: "https://docs.digitalocean.com/reference/api/api-reference/#tag/Domain-Records",
|
|
3330
|
+
families: ["A", "AAAA"],
|
|
3331
|
+
ttl: true,
|
|
3332
|
+
proxied: false,
|
|
3333
|
+
fields: [{
|
|
3334
|
+
key: "token",
|
|
3335
|
+
label: "API token",
|
|
3336
|
+
hint: "A personal access token with the `domain` scope.",
|
|
3337
|
+
optional: false
|
|
3338
|
+
}],
|
|
3339
|
+
validate(credentials) {
|
|
3340
|
+
return missingFields(credentials, ["token"]);
|
|
3341
|
+
},
|
|
3342
|
+
async update(record, context) {
|
|
3343
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3344
|
+
const ttl = oceanTtl(record.ttl);
|
|
3345
|
+
const headers = {
|
|
3346
|
+
"Authorization": `Bearer ${(context.credentials.token ?? "").trim()}`,
|
|
3347
|
+
"Content-Type": "application/json"
|
|
3348
|
+
};
|
|
3349
|
+
const collection = `${API$4}/${encodeURIComponent(zone)}/records`;
|
|
3350
|
+
try {
|
|
3351
|
+
const listed = await context.fetch(`${collection}?per_page=200`, {
|
|
3352
|
+
headers,
|
|
3353
|
+
signal: context.signal
|
|
3354
|
+
});
|
|
3355
|
+
if (!listed.ok) return failed(await errorText(listed));
|
|
3356
|
+
const existing = ((await listed.json()).domain_records ?? []).find((entry) => entry.name === name && entry.type === record.type);
|
|
3357
|
+
if (existing !== void 0 && existing.data === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3358
|
+
const written = existing === void 0 ? await context.fetch(collection, {
|
|
3359
|
+
method: "POST",
|
|
3360
|
+
headers,
|
|
3361
|
+
body: JSON.stringify({
|
|
3362
|
+
type: record.type,
|
|
3363
|
+
name,
|
|
3364
|
+
data: record.ip,
|
|
3365
|
+
ttl
|
|
3366
|
+
}),
|
|
3367
|
+
signal: context.signal
|
|
3368
|
+
}) : await context.fetch(`${collection}/${existing.id}`, {
|
|
3369
|
+
method: "PUT",
|
|
3370
|
+
headers,
|
|
3371
|
+
body: JSON.stringify({
|
|
3372
|
+
type: record.type,
|
|
3373
|
+
name,
|
|
3374
|
+
data: record.ip,
|
|
3375
|
+
ttl
|
|
3376
|
+
}),
|
|
3377
|
+
signal: context.signal
|
|
3378
|
+
});
|
|
3379
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3380
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3381
|
+
} catch (error) {
|
|
3382
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3383
|
+
}
|
|
3169
3384
|
}
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3385
|
+
};
|
|
3386
|
+
}));
|
|
3387
|
+
//#endregion
|
|
3388
|
+
//#region src/providers/ddns/duckdns.ts
|
|
3389
|
+
/** DuckDNS addresses one subdomain at a time, so the label is the domain. */
|
|
3390
|
+
function subdomain(host) {
|
|
3391
|
+
const normalized = host.trim().toLowerCase();
|
|
3392
|
+
return normalized.endsWith(".duckdns.org") ? normalized.slice(0, -12) : normalized;
|
|
3393
|
+
}
|
|
3394
|
+
var ENDPOINT$5, duckdnsProvider;
|
|
3395
|
+
var init_duckdns = __esmMin((() => {
|
|
3396
|
+
init_types();
|
|
3397
|
+
ENDPOINT$5 = "https://www.duckdns.org/update";
|
|
3398
|
+
duckdnsProvider = {
|
|
3399
|
+
id: "duckdns",
|
|
3400
|
+
label: "DuckDNS",
|
|
3401
|
+
docsUrl: "https://www.duckdns.org/spec.jsp",
|
|
3402
|
+
families: ["A", "AAAA"],
|
|
3403
|
+
ttl: false,
|
|
3404
|
+
proxied: false,
|
|
3405
|
+
fields: [{
|
|
3406
|
+
key: "token",
|
|
3407
|
+
label: "Account token",
|
|
3408
|
+
hint: "On duckdns.org after signing in. One token covers every subdomain.",
|
|
3409
|
+
optional: false
|
|
3410
|
+
}],
|
|
3411
|
+
validate(credentials) {
|
|
3412
|
+
return missingFields(credentials, ["token"]);
|
|
3413
|
+
},
|
|
3414
|
+
async update(record, context) {
|
|
3415
|
+
const token = (context.credentials.token ?? "").trim();
|
|
3416
|
+
if (token.length === 0) return failed("duckdns needs the account token");
|
|
3417
|
+
const family = record.type === "A" ? `ip=${encodeURIComponent(record.ip)}` : `ipv6=${encodeURIComponent(record.ip)}`;
|
|
3418
|
+
const url = `${ENDPOINT$5}?domains=${encodeURIComponent(subdomain(record.host))}&token=${encodeURIComponent(token)}&${family}`;
|
|
3419
|
+
try {
|
|
3420
|
+
const response = await context.fetch(url, { signal: context.signal });
|
|
3421
|
+
const body = (await response.text()).trim();
|
|
3422
|
+
if (body.toUpperCase() === "OK") return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3423
|
+
return failed(`duckdns answered "${body.slice(0, 120) || `HTTP ${response.status}`}"`);
|
|
3424
|
+
} catch (error) {
|
|
3425
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3426
|
+
}
|
|
3178
3427
|
}
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3428
|
+
};
|
|
3429
|
+
}));
|
|
3430
|
+
//#endregion
|
|
3431
|
+
//#region src/providers/ddns/dynu.ts
|
|
3432
|
+
var ENDPOINT$4, dynuProvider;
|
|
3433
|
+
var init_dynu = __esmMin((() => {
|
|
3434
|
+
init_dyndns2();
|
|
3435
|
+
init_types();
|
|
3436
|
+
ENDPOINT$4 = "https://api.dynu.com/nic/update";
|
|
3437
|
+
dynuProvider = {
|
|
3438
|
+
id: "dynu",
|
|
3439
|
+
label: "Dynu",
|
|
3440
|
+
docsUrl: "https://www.dynu.com/DynamicDNS/IP-Update-Protocol",
|
|
3441
|
+
families: ["A", "AAAA"],
|
|
3442
|
+
ttl: false,
|
|
3443
|
+
proxied: false,
|
|
3444
|
+
fields: [{
|
|
3445
|
+
key: "username",
|
|
3446
|
+
label: "Username",
|
|
3447
|
+
hint: "Dynu username; used for HTTP Basic auth.",
|
|
3448
|
+
optional: false
|
|
3449
|
+
}, {
|
|
3450
|
+
key: "password",
|
|
3451
|
+
label: "Password",
|
|
3452
|
+
hint: "Account password, or its SHA-256 hash.",
|
|
3453
|
+
optional: false
|
|
3454
|
+
}],
|
|
3455
|
+
validate(credentials) {
|
|
3456
|
+
return missingFields(credentials, ["username", "password"]);
|
|
3457
|
+
},
|
|
3458
|
+
async update(record, context) {
|
|
3459
|
+
const username = (context.credentials.username ?? "").trim();
|
|
3460
|
+
const password = (context.credentials.password ?? "").trim();
|
|
3461
|
+
if (username.length === 0 || password.length === 0) return failed("dynu needs the username and password");
|
|
3462
|
+
const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
3463
|
+
const url = `${ENDPOINT$4}?hostname=${encodeURIComponent(record.host)}&${address}`;
|
|
3464
|
+
try {
|
|
3465
|
+
const response = await context.fetch(url, {
|
|
3466
|
+
headers: { Authorization: basicAuth(username, password) },
|
|
3467
|
+
signal: context.signal
|
|
3468
|
+
});
|
|
3469
|
+
return describeDyndnsResponse(response, await response.text(), "dynu");
|
|
3470
|
+
} catch (error) {
|
|
3471
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3472
|
+
}
|
|
3187
3473
|
}
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3474
|
+
};
|
|
3475
|
+
}));
|
|
3476
|
+
//#endregion
|
|
3477
|
+
//#region src/providers/ddns/freedns.ts
|
|
3478
|
+
/**
|
|
3479
|
+
* FreeDNS credentials *are* a URL: the per-record "Direct URL" hash. Both the
|
|
3480
|
+
* bare hash and the whole URL are accepted, because that is what people copy.
|
|
3481
|
+
*/
|
|
3482
|
+
function updateToken(credentials) {
|
|
3483
|
+
const raw = (credentials.token ?? "").trim();
|
|
3484
|
+
if (raw.length === 0) return "";
|
|
3485
|
+
return (raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : raw).replace(/^https?:\/\/[^?]*\?/, "");
|
|
3486
|
+
}
|
|
3487
|
+
var ENDPOINT$3, freednsProvider;
|
|
3488
|
+
var init_freedns = __esmMin((() => {
|
|
3489
|
+
init_types();
|
|
3490
|
+
ENDPOINT$3 = "https://freedns.afraid.org/dynamic/update.php";
|
|
3491
|
+
freednsProvider = {
|
|
3492
|
+
id: "freedns",
|
|
3493
|
+
label: "FreeDNS (afraid.org)",
|
|
3494
|
+
docsUrl: "https://freedns.afraid.org/faq/",
|
|
3495
|
+
families: ["A", "AAAA"],
|
|
3496
|
+
ttl: false,
|
|
3497
|
+
proxied: false,
|
|
3498
|
+
fields: [{
|
|
3499
|
+
key: "token",
|
|
3500
|
+
label: "Direct URL",
|
|
3501
|
+
hint: "FreeDNS → Dynamic DNS → the per-record \"Direct URL\". Paste the whole URL or just its hash.",
|
|
3502
|
+
optional: false
|
|
3503
|
+
}],
|
|
3504
|
+
validate(credentials) {
|
|
3505
|
+
return missingFields(credentials, ["token"]);
|
|
3506
|
+
},
|
|
3507
|
+
async update(record, context) {
|
|
3508
|
+
const token = updateToken(context.credentials);
|
|
3509
|
+
if (token.length === 0) return failed("freedns needs the per-record Direct URL");
|
|
3510
|
+
try {
|
|
3511
|
+
const response = await context.fetch(`${ENDPOINT$3}?${token}&address=${encodeURIComponent(record.ip)}`, { signal: context.signal });
|
|
3512
|
+
const body = (await response.text()).trim();
|
|
3513
|
+
if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
|
|
3514
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3515
|
+
} catch (error) {
|
|
3516
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3517
|
+
}
|
|
3196
3518
|
}
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3519
|
+
};
|
|
3520
|
+
}));
|
|
3521
|
+
//#endregion
|
|
3522
|
+
//#region src/providers/ddns/gandi.ts
|
|
3523
|
+
/** Gandi's documented minimum; `1` (automatic) has no meaning here. */
|
|
3524
|
+
function gandiTtl(ttl) {
|
|
3525
|
+
return Math.min(2592e3, Math.max(300, ttl));
|
|
3526
|
+
}
|
|
3527
|
+
/** A Personal Access Token (`pat_…`) is preferred; the legacy API key is still accepted. */
|
|
3528
|
+
function authHeader(token) {
|
|
3529
|
+
return token.startsWith("pat_") ? `Bearer ${token}` : `Apikey ${token}`;
|
|
3530
|
+
}
|
|
3531
|
+
var API$3, gandiProvider;
|
|
3532
|
+
var init_gandi = __esmMin((() => {
|
|
3533
|
+
init_types();
|
|
3534
|
+
init_zone();
|
|
3535
|
+
API$3 = "https://api.gandi.net/v5/livedns/domains";
|
|
3536
|
+
gandiProvider = {
|
|
3537
|
+
id: "gandi",
|
|
3538
|
+
label: "Gandi",
|
|
3539
|
+
docsUrl: "https://api.gandi.net/docs/livedns/",
|
|
3540
|
+
families: ["A", "AAAA"],
|
|
3541
|
+
ttl: true,
|
|
3542
|
+
proxied: false,
|
|
3543
|
+
fields: [{
|
|
3544
|
+
key: "token",
|
|
3545
|
+
label: "Personal access token",
|
|
3546
|
+
hint: "A `pat_…` token from admin.gandi.net → Organizations. A legacy API key also works.",
|
|
3547
|
+
optional: false
|
|
3548
|
+
}],
|
|
3549
|
+
validate(credentials) {
|
|
3550
|
+
return missingFields(credentials, ["token"]);
|
|
3551
|
+
},
|
|
3552
|
+
async update(record, context) {
|
|
3553
|
+
const token = (context.credentials.token ?? "").trim();
|
|
3554
|
+
if (token.length === 0) return failed("gandi needs an API token");
|
|
3555
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3556
|
+
const ttl = gandiTtl(record.ttl);
|
|
3557
|
+
const url = `${API$3}/${encodeURIComponent(zone)}/records/${encodeURIComponent(name)}/${record.type}`;
|
|
3558
|
+
const headers = {
|
|
3559
|
+
"Authorization": authHeader(token),
|
|
3560
|
+
"Content-Type": "application/json"
|
|
3561
|
+
};
|
|
3562
|
+
try {
|
|
3563
|
+
const current = await context.fetch(url, {
|
|
3564
|
+
headers,
|
|
3565
|
+
signal: context.signal
|
|
3566
|
+
});
|
|
3567
|
+
if (current.ok) {
|
|
3568
|
+
const body = await current.json();
|
|
3569
|
+
if (body.rrset_values?.length === 1 && body.rrset_values[0] === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3570
|
+
}
|
|
3571
|
+
const written = await context.fetch(url, {
|
|
3572
|
+
method: "PUT",
|
|
3573
|
+
headers,
|
|
3574
|
+
body: JSON.stringify({
|
|
3575
|
+
rrset_values: [record.ip],
|
|
3576
|
+
rrset_ttl: ttl
|
|
3577
|
+
}),
|
|
3578
|
+
signal: context.signal
|
|
3579
|
+
});
|
|
3580
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3581
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3582
|
+
} catch (error) {
|
|
3583
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3584
|
+
}
|
|
3205
3585
|
}
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3586
|
+
};
|
|
3587
|
+
}));
|
|
3588
|
+
//#endregion
|
|
3589
|
+
//#region src/providers/ddns/godaddy.ts
|
|
3590
|
+
/** GoDaddy's floor; `1` (automatic) has no meaning here. */
|
|
3591
|
+
function godaddyTtl(ttl) {
|
|
3592
|
+
return Math.min(86400, Math.max(600, ttl));
|
|
3593
|
+
}
|
|
3594
|
+
var API$2, godaddyProvider;
|
|
3595
|
+
var init_godaddy = __esmMin((() => {
|
|
3596
|
+
init_types();
|
|
3597
|
+
init_zone();
|
|
3598
|
+
API$2 = "https://api.godaddy.com/v1/domains";
|
|
3599
|
+
godaddyProvider = {
|
|
3600
|
+
id: "godaddy",
|
|
3601
|
+
label: "GoDaddy",
|
|
3602
|
+
docsUrl: "https://developer.godaddy.com/doc/endpoint/dns",
|
|
3603
|
+
families: ["A", "AAAA"],
|
|
3604
|
+
ttl: true,
|
|
3605
|
+
proxied: false,
|
|
3606
|
+
fields: [
|
|
3607
|
+
{
|
|
3608
|
+
key: "key",
|
|
3609
|
+
label: "API key",
|
|
3610
|
+
hint: "developer.godaddy.com/keys.",
|
|
3611
|
+
optional: false
|
|
3612
|
+
},
|
|
3613
|
+
{
|
|
3614
|
+
key: "secret",
|
|
3615
|
+
label: "API secret",
|
|
3616
|
+
hint: "Shown once with the key.",
|
|
3617
|
+
optional: false
|
|
3618
|
+
},
|
|
3619
|
+
{
|
|
3620
|
+
key: "baseUrl",
|
|
3621
|
+
label: "API base URL",
|
|
3622
|
+
hint: "Optional; use https://api.ote-godaddy.com to rehearse against the test environment.",
|
|
3623
|
+
optional: true
|
|
3624
|
+
}
|
|
3625
|
+
],
|
|
3626
|
+
validate(credentials) {
|
|
3627
|
+
return missingFields(credentials, ["key", "secret"]);
|
|
3628
|
+
},
|
|
3629
|
+
async update(record, context) {
|
|
3630
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3631
|
+
const ttl = godaddyTtl(record.ttl);
|
|
3632
|
+
const url = `${(context.credentials.baseUrl ?? "").trim() || API$2}/${encodeURIComponent(zone)}/records/${record.type}/${encodeURIComponent(name)}`;
|
|
3633
|
+
const headers = {
|
|
3634
|
+
"Authorization": `sso-key ${context.credentials.key ?? ""}:${context.credentials.secret ?? ""}`,
|
|
3635
|
+
"Content-Type": "application/json"
|
|
3636
|
+
};
|
|
3637
|
+
try {
|
|
3638
|
+
const current = await context.fetch(url, {
|
|
3639
|
+
headers,
|
|
3640
|
+
signal: context.signal
|
|
3641
|
+
});
|
|
3642
|
+
if (current.ok) {
|
|
3643
|
+
const body = await current.json();
|
|
3644
|
+
if (body.length === 1 && body[0]?.data === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3645
|
+
}
|
|
3646
|
+
const written = await context.fetch(url, {
|
|
3647
|
+
method: "PUT",
|
|
3648
|
+
headers,
|
|
3649
|
+
body: JSON.stringify([{
|
|
3650
|
+
data: record.ip,
|
|
3651
|
+
ttl
|
|
3652
|
+
}]),
|
|
3653
|
+
signal: context.signal
|
|
3654
|
+
});
|
|
3655
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3656
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3657
|
+
} catch (error) {
|
|
3658
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3659
|
+
}
|
|
3214
3660
|
}
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3661
|
+
};
|
|
3662
|
+
}));
|
|
3663
|
+
//#endregion
|
|
3664
|
+
//#region src/providers/ddns/henet.ts
|
|
3665
|
+
var ENDPOINT$2, henetProvider;
|
|
3666
|
+
var init_henet = __esmMin((() => {
|
|
3667
|
+
init_dyndns2();
|
|
3668
|
+
init_types();
|
|
3669
|
+
ENDPOINT$2 = "https://dyn.dns.he.net/nic/update";
|
|
3670
|
+
henetProvider = {
|
|
3671
|
+
id: "henet",
|
|
3672
|
+
label: "Hurricane Electric",
|
|
3673
|
+
docsUrl: "https://dns.he.net/docs.html",
|
|
3674
|
+
families: ["A", "AAAA"],
|
|
3675
|
+
ttl: false,
|
|
3676
|
+
proxied: false,
|
|
3677
|
+
fields: [{
|
|
3678
|
+
key: "key",
|
|
3679
|
+
label: "DDNS key",
|
|
3680
|
+
hint: "dns.he.net → the key shown next to the host when DDNS is enabled.",
|
|
3681
|
+
optional: false
|
|
3682
|
+
}],
|
|
3683
|
+
validate(credentials) {
|
|
3684
|
+
return missingFields(credentials, ["key"]);
|
|
3685
|
+
},
|
|
3686
|
+
async update(record, context) {
|
|
3687
|
+
const key = (context.credentials.key ?? "").trim();
|
|
3688
|
+
if (key.length === 0) return failed("hurricane electric needs the per-host DDNS key");
|
|
3689
|
+
const url = `${ENDPOINT$2}?hostname=${encodeURIComponent(record.host)}&password=${encodeURIComponent(key)}&myip=${encodeURIComponent(record.ip)}`;
|
|
3690
|
+
try {
|
|
3691
|
+
const response = await context.fetch(url, { signal: context.signal });
|
|
3692
|
+
return describeDyndnsResponse(response, await response.text(), "hurricane electric");
|
|
3693
|
+
} catch (error) {
|
|
3694
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3695
|
+
}
|
|
3223
3696
|
}
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3697
|
+
};
|
|
3698
|
+
}));
|
|
3699
|
+
//#endregion
|
|
3700
|
+
//#region src/providers/ddns/namecheap.ts
|
|
3701
|
+
function tag(body, name) {
|
|
3702
|
+
return new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "i").exec(body)?.[1]?.trim() ?? null;
|
|
3703
|
+
}
|
|
3704
|
+
var ENDPOINT$1, namecheapProvider;
|
|
3705
|
+
var init_namecheap = __esmMin((() => {
|
|
3706
|
+
init_types();
|
|
3707
|
+
init_zone();
|
|
3708
|
+
ENDPOINT$1 = "https://dynamicdns.park-your-domain.com/update";
|
|
3709
|
+
namecheapProvider = {
|
|
3710
|
+
id: "namecheap",
|
|
3711
|
+
label: "Namecheap",
|
|
3712
|
+
docsUrl: "https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-https-request/",
|
|
3713
|
+
families: ["A"],
|
|
3714
|
+
ttl: false,
|
|
3715
|
+
proxied: false,
|
|
3716
|
+
fields: [{
|
|
3717
|
+
key: "password",
|
|
3718
|
+
label: "Dynamic DNS password",
|
|
3719
|
+
hint: "Dashboard → Manage domain → Advanced DNS → Dynamic DNS. Not your account password or API key.",
|
|
3720
|
+
optional: false
|
|
3721
|
+
}],
|
|
3722
|
+
validate(credentials) {
|
|
3723
|
+
return missingFields(credentials, ["password"]);
|
|
3724
|
+
},
|
|
3725
|
+
async update(record, context) {
|
|
3726
|
+
const password = (context.credentials.password ?? "").trim();
|
|
3727
|
+
if (password.length === 0) return failed("namecheap needs the Dynamic DNS password");
|
|
3728
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3729
|
+
const host = name === "@" ? "@" : name;
|
|
3730
|
+
const url = `${ENDPOINT$1}?host=${encodeURIComponent(host)}&domain=${encodeURIComponent(zone)}&password=${encodeURIComponent(password)}&ip=${encodeURIComponent(record.ip)}`;
|
|
3731
|
+
try {
|
|
3732
|
+
const body = await (await context.fetch(url, { signal: context.signal })).text();
|
|
3733
|
+
const errCount = Number.parseInt(tag(body, "ErrCount") ?? "0", 10);
|
|
3734
|
+
const ip = tag(body, "IP");
|
|
3735
|
+
if (errCount > 0) {
|
|
3736
|
+
const first = tag(body, "Err1");
|
|
3737
|
+
return failed(`namecheap rejected the update${first === null ? "" : `: ${first}`}`);
|
|
3738
|
+
}
|
|
3739
|
+
if (ip === null) return failed(`namecheap answered without an IP: ${body.replace(/\s+/g, " ").slice(0, 160)}`);
|
|
3740
|
+
return succeeded(`${host === "@" ? zone : `${host}.${zone}`} now ${ip}`, ip !== record.ip);
|
|
3741
|
+
} catch (error) {
|
|
3742
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3743
|
+
}
|
|
3233
3744
|
}
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3745
|
+
};
|
|
3746
|
+
}));
|
|
3747
|
+
//#endregion
|
|
3748
|
+
//#region src/providers/ddns/noip.ts
|
|
3749
|
+
var ENDPOINT, DEFAULT_AGENT, noipProvider;
|
|
3750
|
+
var init_noip = __esmMin((() => {
|
|
3751
|
+
init_dyndns2();
|
|
3752
|
+
init_types();
|
|
3753
|
+
ENDPOINT = "https://dynupdate.no-ip.com/nic/update";
|
|
3754
|
+
DEFAULT_AGENT = "home-hosted/1.0 (+https://github.com/NamesMT/home-hosted)";
|
|
3755
|
+
noipProvider = {
|
|
3756
|
+
id: "noip",
|
|
3757
|
+
label: "No-IP",
|
|
3758
|
+
docsUrl: "https://www.noip.com/integrate/request",
|
|
3759
|
+
families: ["A", "AAAA"],
|
|
3760
|
+
ttl: false,
|
|
3761
|
+
proxied: false,
|
|
3762
|
+
fields: [
|
|
3763
|
+
{
|
|
3764
|
+
key: "username",
|
|
3765
|
+
label: "Email",
|
|
3766
|
+
hint: "Your No-IP account email.",
|
|
3767
|
+
optional: false
|
|
3768
|
+
},
|
|
3769
|
+
{
|
|
3770
|
+
key: "password",
|
|
3771
|
+
label: "Password",
|
|
3772
|
+
hint: "Your No-IP account password.",
|
|
3773
|
+
optional: false
|
|
3774
|
+
},
|
|
3775
|
+
{
|
|
3776
|
+
key: "userAgent",
|
|
3777
|
+
label: "User-Agent override",
|
|
3778
|
+
hint: "No-IP only guarantees updates for recognised clients; set an approved agent here if yours is registered.",
|
|
3779
|
+
optional: true
|
|
3780
|
+
}
|
|
3781
|
+
],
|
|
3782
|
+
validate(credentials) {
|
|
3783
|
+
return missingFields(credentials, ["username", "password"]);
|
|
3784
|
+
},
|
|
3785
|
+
async update(record, context) {
|
|
3786
|
+
const username = (context.credentials.username ?? "").trim();
|
|
3787
|
+
const password = (context.credentials.password ?? "").trim();
|
|
3788
|
+
if (username.length === 0 || password.length === 0) return failed("no-ip needs the account email and password");
|
|
3789
|
+
const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
3790
|
+
const url = `${ENDPOINT}?hostname=${encodeURIComponent(record.host)}&${address}`;
|
|
3791
|
+
try {
|
|
3792
|
+
const response = await context.fetch(url, {
|
|
3793
|
+
headers: {
|
|
3794
|
+
"Authorization": basicAuth(username, password),
|
|
3795
|
+
"User-Agent": (context.credentials.userAgent ?? "").trim() || DEFAULT_AGENT
|
|
3796
|
+
},
|
|
3797
|
+
signal: context.signal
|
|
3798
|
+
});
|
|
3799
|
+
return describeDyndnsResponse(response, await response.text(), "no-ip");
|
|
3800
|
+
} catch (error) {
|
|
3801
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3802
|
+
}
|
|
3240
3803
|
}
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3804
|
+
};
|
|
3805
|
+
}));
|
|
3806
|
+
//#endregion
|
|
3807
|
+
//#region src/providers/ddns/porkbun.ts
|
|
3808
|
+
/** Porkbun's documented floor; `1` (automatic) maps onto it. */
|
|
3809
|
+
function porkbunTtl(ttl) {
|
|
3810
|
+
return Math.max(600, ttl);
|
|
3811
|
+
}
|
|
3812
|
+
async function post(path, context, body) {
|
|
3813
|
+
try {
|
|
3814
|
+
const response = await context.fetch(`${API$1}${path}`, {
|
|
3815
|
+
method: "POST",
|
|
3816
|
+
headers: {
|
|
3817
|
+
"Content-Type": "application/json",
|
|
3818
|
+
"X-API-Key": context.credentials.apikey ?? "",
|
|
3819
|
+
"X-Secret-API-Key": context.credentials.secretapikey ?? ""
|
|
3820
|
+
},
|
|
3821
|
+
body: JSON.stringify(body),
|
|
3822
|
+
signal: context.signal
|
|
3823
|
+
});
|
|
3824
|
+
const parsed = JSON.parse(await response.text());
|
|
3825
|
+
if (parsed.status !== "SUCCESS") return {
|
|
3826
|
+
data: parsed,
|
|
3827
|
+
error: parsed.message ?? `HTTP ${response.status}`
|
|
3828
|
+
};
|
|
3829
|
+
return {
|
|
3830
|
+
data: parsed,
|
|
3831
|
+
error: null
|
|
3832
|
+
};
|
|
3833
|
+
} catch (error) {
|
|
3834
|
+
return {
|
|
3835
|
+
data: null,
|
|
3836
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3837
|
+
};
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
var API$1, porkbunProvider;
|
|
3841
|
+
var init_porkbun = __esmMin((() => {
|
|
3842
|
+
init_types();
|
|
3843
|
+
init_zone();
|
|
3844
|
+
API$1 = "https://api.porkbun.com/api/json/v3";
|
|
3845
|
+
porkbunProvider = {
|
|
3846
|
+
id: "porkbun",
|
|
3847
|
+
label: "Porkbun",
|
|
3848
|
+
docsUrl: "https://porkbun.com/api/json/v3/documentation",
|
|
3849
|
+
families: ["A", "AAAA"],
|
|
3850
|
+
ttl: true,
|
|
3851
|
+
proxied: false,
|
|
3852
|
+
fields: [{
|
|
3853
|
+
key: "apikey",
|
|
3854
|
+
label: "API key",
|
|
3855
|
+
hint: "porkbun.com/account/api — starts with pk1_.",
|
|
3856
|
+
optional: false
|
|
3857
|
+
}, {
|
|
3858
|
+
key: "secretapikey",
|
|
3859
|
+
label: "Secret API key",
|
|
3860
|
+
hint: "Shown once when the key is created.",
|
|
3861
|
+
optional: false
|
|
3862
|
+
}],
|
|
3863
|
+
validate(credentials) {
|
|
3864
|
+
return missingFields(credentials, ["apikey", "secretapikey"]);
|
|
3865
|
+
},
|
|
3866
|
+
async update(record, context) {
|
|
3867
|
+
const { zone, name: rawName } = splitHost(record.host, record.zone);
|
|
3868
|
+
const name = rawName === "@" ? "" : rawName;
|
|
3869
|
+
const ttl = porkbunTtl(record.ttl);
|
|
3870
|
+
const existing = (await post(`/dns/retrieveByNameType/${encodeURIComponent(zone)}/${record.type}/${encodeURIComponent(name)}`, context, {})).data?.records?.[0];
|
|
3871
|
+
if (existing !== void 0 && existing.content === record.ip && Number.parseInt(existing.ttl, 10) === ttl) return succeeded(`already ${record.ip}`, false);
|
|
3872
|
+
const written = await post(`/dns/editByNameType/${encodeURIComponent(zone)}/${record.type}/${encodeURIComponent(name)}`, context, {
|
|
3873
|
+
content: record.ip,
|
|
3874
|
+
ttl
|
|
3875
|
+
});
|
|
3876
|
+
if (written.error !== null) return failed(written.error);
|
|
3877
|
+
const warnings = written.data?.warnings ?? [];
|
|
3878
|
+
const note = warnings.length > 0 ? ` — porkbun warns: ${warnings.join("; ")}` : "";
|
|
3879
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}${note}`, true);
|
|
3245
3880
|
}
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3881
|
+
};
|
|
3882
|
+
}));
|
|
3883
|
+
//#endregion
|
|
3884
|
+
//#region src/providers/ddns/spaceship.ts
|
|
3885
|
+
function spaceshipTtl(ttl) {
|
|
3886
|
+
return Math.min(3600, Math.max(60, ttl));
|
|
3887
|
+
}
|
|
3888
|
+
var API, PAGE_SIZE, spaceshipProvider;
|
|
3889
|
+
var init_spaceship = __esmMin((() => {
|
|
3890
|
+
init_types();
|
|
3891
|
+
init_zone();
|
|
3892
|
+
API = "https://spaceship.dev/api/v1/dns/records";
|
|
3893
|
+
PAGE_SIZE = 500;
|
|
3894
|
+
spaceshipProvider = {
|
|
3895
|
+
id: "spaceship",
|
|
3896
|
+
label: "Spaceship",
|
|
3897
|
+
docsUrl: "https://docs.spaceship.dev/",
|
|
3898
|
+
families: ["A", "AAAA"],
|
|
3899
|
+
ttl: true,
|
|
3900
|
+
proxied: false,
|
|
3901
|
+
fields: [{
|
|
3902
|
+
key: "apiKey",
|
|
3903
|
+
label: "API key",
|
|
3904
|
+
hint: "spaceship.com → API Manager; needs the `dnsrecords:read`/`write` scopes.",
|
|
3905
|
+
optional: false
|
|
3906
|
+
}, {
|
|
3907
|
+
key: "apiSecret",
|
|
3908
|
+
label: "API secret",
|
|
3909
|
+
hint: "Shown once with the key.",
|
|
3910
|
+
optional: false
|
|
3911
|
+
}],
|
|
3912
|
+
validate(credentials) {
|
|
3913
|
+
return missingFields(credentials, ["apiKey", "apiSecret"]);
|
|
3914
|
+
},
|
|
3915
|
+
async update(record, context) {
|
|
3916
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3917
|
+
const headers = {
|
|
3918
|
+
"X-API-Key": (context.credentials.apiKey ?? "").trim(),
|
|
3919
|
+
"X-API-Secret": (context.credentials.apiSecret ?? "").trim(),
|
|
3920
|
+
"Content-Type": "application/json"
|
|
3252
3921
|
};
|
|
3922
|
+
try {
|
|
3923
|
+
let found;
|
|
3924
|
+
for (let skip = 0; skip < PAGE_SIZE * 5; skip += PAGE_SIZE) {
|
|
3925
|
+
const listed = await context.fetch(`${API}/${encodeURIComponent(zone)}?take=${PAGE_SIZE}&skip=${skip}`, {
|
|
3926
|
+
headers,
|
|
3927
|
+
signal: context.signal
|
|
3928
|
+
});
|
|
3929
|
+
if (!listed.ok) return failed(await errorText(listed));
|
|
3930
|
+
const body = await listed.json();
|
|
3931
|
+
const items = Array.isArray(body) ? body : body.items ?? [];
|
|
3932
|
+
found = items.find((entry) => entry.name === name && entry.type === record.type);
|
|
3933
|
+
if (found !== void 0 || items.length < PAGE_SIZE) break;
|
|
3934
|
+
}
|
|
3935
|
+
if (found?.address === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3936
|
+
const written = await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
|
|
3937
|
+
method: "PUT",
|
|
3938
|
+
headers,
|
|
3939
|
+
body: JSON.stringify({
|
|
3940
|
+
force: true,
|
|
3941
|
+
items: [{
|
|
3942
|
+
type: record.type,
|
|
3943
|
+
name,
|
|
3944
|
+
ttl: spaceshipTtl(record.ttl),
|
|
3945
|
+
address: record.ip
|
|
3946
|
+
}]
|
|
3947
|
+
}),
|
|
3948
|
+
signal: context.signal
|
|
3949
|
+
});
|
|
3950
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3951
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3952
|
+
} catch (error) {
|
|
3953
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
};
|
|
3957
|
+
}));
|
|
3958
|
+
//#endregion
|
|
3959
|
+
//#region src/providers/ddns/index.ts
|
|
3960
|
+
function ddnsProvider(id) {
|
|
3961
|
+
return BY_ID.get(id) ?? null;
|
|
3962
|
+
}
|
|
3963
|
+
function ddnsProviderInfos() {
|
|
3964
|
+
return DDNS_PROVIDERS.map((provider) => ({
|
|
3965
|
+
id: provider.id,
|
|
3966
|
+
label: provider.label,
|
|
3967
|
+
docsUrl: provider.docsUrl,
|
|
3968
|
+
families: provider.families,
|
|
3969
|
+
fields: provider.fields,
|
|
3970
|
+
ttl: provider.ttl,
|
|
3971
|
+
proxied: provider.proxied
|
|
3972
|
+
}));
|
|
3973
|
+
}
|
|
3974
|
+
/**
|
|
3975
|
+
* Cross-field problems ArkType cannot see. Credentials are deliberately not
|
|
3976
|
+
* required here: an account is normally added before its secret is typed in,
|
|
3977
|
+
* and a missing secret is reported per record instead of blocking the save.
|
|
3978
|
+
*/
|
|
3979
|
+
function validateDdnsConfig(config) {
|
|
3980
|
+
const errors = [];
|
|
3981
|
+
const seenAccounts = /* @__PURE__ */ new Set();
|
|
3982
|
+
for (const account of config.accounts) {
|
|
3983
|
+
if (seenAccounts.has(account.id)) {
|
|
3984
|
+
errors.push(`account "${account.id}" is declared twice`);
|
|
3985
|
+
continue;
|
|
3986
|
+
}
|
|
3987
|
+
seenAccounts.add(account.id);
|
|
3988
|
+
if (ddnsProvider(account.provider) === null) errors.push(`account "${account.id}" uses an unknown provider "${account.provider}"`);
|
|
3989
|
+
}
|
|
3990
|
+
const seenRecords = /* @__PURE__ */ new Set();
|
|
3991
|
+
for (const domain of config.domains) {
|
|
3992
|
+
const account = config.accounts.find((entry) => entry.id === domain.account);
|
|
3993
|
+
if (account === void 0) {
|
|
3994
|
+
errors.push(`"${domain.host}" points at unknown account "${domain.account}"`);
|
|
3995
|
+
continue;
|
|
3996
|
+
}
|
|
3997
|
+
const provider = ddnsProvider(account.provider);
|
|
3998
|
+
if (provider !== null) for (const recordType of domain.types) {
|
|
3999
|
+
if (!provider.families.includes(recordType)) errors.push(`${account.label || provider.label} cannot manage ${recordType} records ("${domain.host}")`);
|
|
4000
|
+
const key = `${domain.host.toLowerCase()}|${recordType}`;
|
|
4001
|
+
if (seenRecords.has(key)) errors.push(`"${domain.host}" asks for ${recordType} twice`);
|
|
4002
|
+
seenRecords.add(key);
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
return errors;
|
|
4006
|
+
}
|
|
4007
|
+
var DDNS_PROVIDERS, BY_ID;
|
|
4008
|
+
var init_ddns$2 = __esmMin((() => {
|
|
4009
|
+
init_cloudflare();
|
|
4010
|
+
init_desec();
|
|
4011
|
+
init_digitalocean();
|
|
4012
|
+
init_duckdns();
|
|
4013
|
+
init_dynu();
|
|
4014
|
+
init_freedns();
|
|
4015
|
+
init_gandi();
|
|
4016
|
+
init_godaddy();
|
|
4017
|
+
init_henet();
|
|
4018
|
+
init_namecheap();
|
|
4019
|
+
init_noip();
|
|
4020
|
+
init_porkbun();
|
|
4021
|
+
init_spaceship();
|
|
4022
|
+
DDNS_PROVIDERS = [
|
|
4023
|
+
cloudflareProvider,
|
|
4024
|
+
namecheapProvider,
|
|
4025
|
+
spaceshipProvider,
|
|
4026
|
+
porkbunProvider,
|
|
4027
|
+
godaddyProvider,
|
|
4028
|
+
gandiProvider,
|
|
4029
|
+
digitaloceanProvider,
|
|
4030
|
+
desecProvider,
|
|
4031
|
+
duckdnsProvider,
|
|
4032
|
+
noipProvider,
|
|
4033
|
+
dynuProvider,
|
|
4034
|
+
henetProvider,
|
|
4035
|
+
freednsProvider
|
|
4036
|
+
];
|
|
4037
|
+
BY_ID = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
|
|
4038
|
+
}));
|
|
4039
|
+
//#endregion
|
|
4040
|
+
//#region src/config/store.ts
|
|
4041
|
+
function formatErrors(errors) {
|
|
4042
|
+
return errors.summary;
|
|
4043
|
+
}
|
|
4044
|
+
function isRecord(value) {
|
|
4045
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4046
|
+
}
|
|
4047
|
+
function applyPatch(target, patch, mergeKeys) {
|
|
4048
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
4049
|
+
if (value === void 0) continue;
|
|
4050
|
+
if (mergeKeys.has(key) && isRecord(value) && isRecord(target[key])) {
|
|
4051
|
+
target[key] = mergeGroup(target[key], value);
|
|
4052
|
+
continue;
|
|
4053
|
+
}
|
|
4054
|
+
target[key] = value;
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
/**
|
|
4058
|
+
* Merges one nested group recursively — `health.http` is a group of its own, and
|
|
4059
|
+
* replacing it wholesale would silently reset the siblings a partial patch never
|
|
4060
|
+
* mentioned. An explicit `null` removes a key, which is how a schema-optional
|
|
4061
|
+
* field is cleared.
|
|
4062
|
+
*/
|
|
4063
|
+
function mergeGroup(target, patch) {
|
|
4064
|
+
const merged = { ...target };
|
|
4065
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
4066
|
+
if (value === void 0) continue;
|
|
4067
|
+
if (value === null) {
|
|
4068
|
+
delete merged[key];
|
|
4069
|
+
continue;
|
|
4070
|
+
}
|
|
4071
|
+
if (isRecord(value) && isRecord(merged[key])) {
|
|
4072
|
+
merged[key] = mergeGroup(merged[key], value);
|
|
4073
|
+
continue;
|
|
4074
|
+
}
|
|
4075
|
+
merged[key] = value;
|
|
4076
|
+
}
|
|
4077
|
+
return merged;
|
|
4078
|
+
}
|
|
4079
|
+
var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, EMPTY_MERGE_KEYS, ConfigError, ConfigStore;
|
|
4080
|
+
var init_store = __esmMin((() => {
|
|
4081
|
+
init_migrations();
|
|
4082
|
+
init_parse();
|
|
4083
|
+
init_schema();
|
|
4084
|
+
init_seed();
|
|
4085
|
+
init_atomic();
|
|
4086
|
+
init_paths();
|
|
4087
|
+
init_ddns$2();
|
|
4088
|
+
SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
|
|
4089
|
+
"restart",
|
|
4090
|
+
"health",
|
|
4091
|
+
"stop"
|
|
4092
|
+
]);
|
|
4093
|
+
CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
|
|
4094
|
+
NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["telegram"]);
|
|
4095
|
+
DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
|
|
4096
|
+
EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
|
|
4097
|
+
ConfigError = class extends Error {
|
|
4098
|
+
name = "ConfigError";
|
|
4099
|
+
};
|
|
4100
|
+
ConfigStore = class {
|
|
4101
|
+
file;
|
|
4102
|
+
seed;
|
|
4103
|
+
raw = {};
|
|
4104
|
+
/** The bytes behind the live config, so a watcher can tell a real edit from our own write. */
|
|
4105
|
+
lastText = null;
|
|
4106
|
+
resolvedConfig;
|
|
4107
|
+
error = null;
|
|
4108
|
+
warnings = [];
|
|
4109
|
+
schemaVersion = 1;
|
|
4110
|
+
listeners = /* @__PURE__ */ new Set();
|
|
4111
|
+
constructor(file, seed = SEED_CONFIG) {
|
|
4112
|
+
this.file = file;
|
|
4113
|
+
this.seed = seed;
|
|
4114
|
+
}
|
|
4115
|
+
get path() {
|
|
4116
|
+
return this.file;
|
|
4117
|
+
}
|
|
4118
|
+
get config() {
|
|
4119
|
+
return this.resolvedConfig;
|
|
4120
|
+
}
|
|
4121
|
+
get configError() {
|
|
4122
|
+
return this.error;
|
|
4123
|
+
}
|
|
4124
|
+
/** Keys a newer release wrote that this one ignores; nothing to refuse over. */
|
|
4125
|
+
get configWarnings() {
|
|
4126
|
+
return [...this.warnings];
|
|
4127
|
+
}
|
|
4128
|
+
/** The shape the file declares, as last read. */
|
|
4129
|
+
get configSchemaVersion() {
|
|
4130
|
+
return this.schemaVersion;
|
|
4131
|
+
}
|
|
4132
|
+
/** Steps that would have to run before this release could use the file. */
|
|
4133
|
+
get pendingMigrations() {
|
|
4134
|
+
return planConfigMigrations(this.schemaVersion).steps;
|
|
4135
|
+
}
|
|
4136
|
+
get servers() {
|
|
4137
|
+
return this.resolvedConfig.servers;
|
|
4138
|
+
}
|
|
4139
|
+
get defaults() {
|
|
4140
|
+
return this.resolvedConfig.defaults;
|
|
4141
|
+
}
|
|
4142
|
+
get rawConfig() {
|
|
4143
|
+
return structuredClone(this.raw);
|
|
4144
|
+
}
|
|
4145
|
+
onChange(listener) {
|
|
4146
|
+
this.listeners.add(listener);
|
|
4147
|
+
return () => this.listeners.delete(listener);
|
|
4148
|
+
}
|
|
4149
|
+
getServer(id) {
|
|
4150
|
+
return this.servers.find((server) => server.id === id);
|
|
4151
|
+
}
|
|
4152
|
+
/**
|
|
4153
|
+
* Reads the file and tells the listeners, so whatever wrote it — the settings
|
|
4154
|
+
* page, a restored backup, or the file watcher — becomes the live config.
|
|
4155
|
+
*/
|
|
4156
|
+
load() {
|
|
4157
|
+
this.read();
|
|
4158
|
+
this.notify();
|
|
4159
|
+
}
|
|
4160
|
+
/**
|
|
4161
|
+
* The watcher's entry point: re-reads the file only when its bytes changed.
|
|
4162
|
+
*
|
|
4163
|
+
* `changed` means the file on disk is different from what the live config was
|
|
4164
|
+
* read from, `applied` means that difference was accepted — a file this release
|
|
4165
|
+
* cannot read is reported and the config already running is left alone, which is
|
|
4166
|
+
* what keeps an editor's typo from stopping every server.
|
|
4167
|
+
*/
|
|
4168
|
+
reloadFromDisk() {
|
|
4169
|
+
let text;
|
|
4170
|
+
try {
|
|
4171
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
4172
|
+
} catch {
|
|
4173
|
+
this.error = `${path.basename(this.file)} is gone`;
|
|
4174
|
+
return {
|
|
4175
|
+
changed: false,
|
|
4176
|
+
applied: false,
|
|
4177
|
+
error: this.error
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
if (text === this.lastText) {
|
|
4181
|
+
if (this.error !== null) {
|
|
4182
|
+
this.error = null;
|
|
4183
|
+
this.load();
|
|
4184
|
+
}
|
|
4185
|
+
return {
|
|
4186
|
+
changed: false,
|
|
4187
|
+
applied: false,
|
|
4188
|
+
error: null
|
|
4189
|
+
};
|
|
4190
|
+
}
|
|
4191
|
+
const before = this.resolvedConfig;
|
|
4192
|
+
this.load();
|
|
4193
|
+
return {
|
|
4194
|
+
changed: true,
|
|
4195
|
+
applied: this.resolvedConfig !== before,
|
|
4196
|
+
error: this.error
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
read() {
|
|
4200
|
+
if (!fs.existsSync(this.file)) {
|
|
4201
|
+
const seed = stampConfig(structuredClone(this.seed));
|
|
4202
|
+
const text = `${JSON.stringify(seed, null, 2)}\n`;
|
|
4203
|
+
writeFileAtomic(this.file, text);
|
|
4204
|
+
this.lastText = text;
|
|
4205
|
+
this.raw = seed;
|
|
4206
|
+
this.apply(seed);
|
|
4207
|
+
return;
|
|
4208
|
+
}
|
|
4209
|
+
let text;
|
|
4210
|
+
let parsed;
|
|
4211
|
+
try {
|
|
4212
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
4213
|
+
parsed = JSON.parse(text);
|
|
4214
|
+
} catch (error) {
|
|
4215
|
+
this.error = `cannot parse ${path.basename(this.file)}: ${error.message}`;
|
|
4216
|
+
if (this.resolvedConfig === void 0) {
|
|
4217
|
+
this.raw = {};
|
|
4218
|
+
this.resolvedConfig = this.resolveFallback();
|
|
4219
|
+
}
|
|
4220
|
+
return;
|
|
4221
|
+
}
|
|
4222
|
+
this.lastText = text;
|
|
4223
|
+
this.apply(parsed);
|
|
4224
|
+
}
|
|
4225
|
+
notify() {
|
|
4226
|
+
for (const listener of this.listeners) listener();
|
|
4227
|
+
}
|
|
4228
|
+
updateServer(id, patch) {
|
|
4229
|
+
const index = this.raw.servers?.findIndex((entry) => entry.id === id) ?? -1;
|
|
4230
|
+
if (index < 0) throw new ConfigError(`unknown server "${id}"`);
|
|
4231
|
+
const draft = structuredClone(this.raw);
|
|
4232
|
+
const entry = draft.servers[index];
|
|
4233
|
+
applyPatch(entry, patch, SERVER_MERGE_KEYS);
|
|
4234
|
+
const validated = this.validateServer(entry, `servers[${index}]`);
|
|
4235
|
+
this.commit(draft);
|
|
4236
|
+
return validated;
|
|
4237
|
+
}
|
|
4238
|
+
updateControl(patch) {
|
|
4239
|
+
const draft = structuredClone(this.raw);
|
|
4240
|
+
draft.control = { ...draft.control ?? {} };
|
|
4241
|
+
applyPatch(draft.control, patch, CONTROL_MERGE_KEYS);
|
|
4242
|
+
const control = this.parseGroup(controlSchema, draft.control, "control");
|
|
4243
|
+
this.commit(draft);
|
|
4244
|
+
return control;
|
|
4245
|
+
}
|
|
4246
|
+
updateLogs(patch) {
|
|
4247
|
+
const draft = structuredClone(this.raw);
|
|
4248
|
+
draft.logs = { ...draft.logs ?? {} };
|
|
4249
|
+
applyPatch(draft.logs, patch, EMPTY_MERGE_KEYS);
|
|
4250
|
+
const logs = this.parseGroup(logsSchema, draft.logs, "logs");
|
|
4251
|
+
this.commit(draft);
|
|
4252
|
+
return logs;
|
|
4253
|
+
}
|
|
4254
|
+
updateNotifications(patch) {
|
|
4255
|
+
const draft = structuredClone(this.raw);
|
|
4256
|
+
draft.notifications = { ...draft.notifications ?? {} };
|
|
4257
|
+
applyPatch(draft.notifications, patch, NOTIFICATION_MERGE_KEYS);
|
|
4258
|
+
const notifications = this.parseGroup(notificationsSchema, draft.notifications, "notifications");
|
|
4259
|
+
this.commit(draft);
|
|
4260
|
+
return notifications;
|
|
4261
|
+
}
|
|
4262
|
+
updateHost(patch) {
|
|
4263
|
+
const draft = structuredClone(this.raw);
|
|
4264
|
+
draft.host = { ...draft.host ?? {} };
|
|
4265
|
+
applyPatch(draft.host, patch, EMPTY_MERGE_KEYS);
|
|
4266
|
+
const host = this.parseGroup(hostSchema, draft.host, "host");
|
|
4267
|
+
this.commit(draft);
|
|
4268
|
+
return host;
|
|
4269
|
+
}
|
|
4270
|
+
updateBackups(patch) {
|
|
4271
|
+
const draft = structuredClone(this.raw);
|
|
4272
|
+
draft.backups = { ...draft.backups ?? {} };
|
|
4273
|
+
applyPatch(draft.backups, patch, EMPTY_MERGE_KEYS);
|
|
4274
|
+
const backups = this.parseGroup(backupsSchema, draft.backups, "backups");
|
|
4275
|
+
this.commit(draft);
|
|
4276
|
+
return backups;
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* The DDNS block merges its two nested groups and **replaces** `accounts` and
|
|
4280
|
+
* `domains`: those are lists, and a key-by-key merge cannot express removing a
|
|
4281
|
+
* hostname. A whole-block patch (the `PUT /api/ddns` route) lands the same way.
|
|
4282
|
+
*/
|
|
4283
|
+
updateDdns(patch) {
|
|
4284
|
+
const draft = structuredClone(this.raw);
|
|
4285
|
+
draft.ddns = { ...draft.ddns ?? {} };
|
|
4286
|
+
applyPatch(draft.ddns, patch, DDNS_MERGE_KEYS);
|
|
4287
|
+
const parsed = this.parseGroup(ddnsConfigSchema, draft.ddns, "ddns");
|
|
4288
|
+
const problems = validateDdnsConfig(parsed);
|
|
4289
|
+
if (problems.length > 0) throw new ConfigError(`ddns: ${problems.join("; ")}`);
|
|
4290
|
+
this.commit(draft);
|
|
4291
|
+
return parsed;
|
|
4292
|
+
}
|
|
4293
|
+
updateDefaults(patch) {
|
|
4294
|
+
const draft = structuredClone(this.raw);
|
|
4295
|
+
draft.defaults = { ...draft.defaults ?? {} };
|
|
4296
|
+
applyPatch(draft.defaults, patch, SERVER_MERGE_KEYS);
|
|
4297
|
+
const defaults = this.parseGroup(defaultsSchema, draft.defaults, "defaults");
|
|
4298
|
+
this.commit(draft);
|
|
4299
|
+
return defaults;
|
|
4300
|
+
}
|
|
4301
|
+
addServer(input) {
|
|
4302
|
+
const draft = structuredClone(this.raw);
|
|
4303
|
+
draft.servers ??= [];
|
|
4304
|
+
if (draft.servers.some((entry) => entry.id === input.id)) throw new ConfigError(`server "${String(input.id)}" already exists`);
|
|
4305
|
+
const index = draft.servers.length;
|
|
4306
|
+
draft.servers.push(structuredClone(input));
|
|
4307
|
+
const validated = this.validateServer(draft.servers[index], `servers[${index}]`);
|
|
4308
|
+
this.commit(draft);
|
|
4309
|
+
return validated;
|
|
4310
|
+
}
|
|
4311
|
+
removeServer(id) {
|
|
4312
|
+
const draft = structuredClone(this.raw);
|
|
4313
|
+
const before = draft.servers?.length ?? 0;
|
|
4314
|
+
draft.servers = (draft.servers ?? []).filter((entry) => entry.id !== id);
|
|
4315
|
+
if (draft.servers.length === before) throw new ConfigError(`unknown server "${id}"`);
|
|
4316
|
+
this.commit(draft);
|
|
4317
|
+
}
|
|
4318
|
+
/** Regenerates `servers.config.schema.json` for editor autocomplete. */
|
|
4319
|
+
writeJsonSchema() {
|
|
4320
|
+
const schema = JSON.stringify(configSchema.toJsonSchema(), null, 2);
|
|
4321
|
+
if ((fs.existsSync(configSchemaPath) ? fs.readFileSync(configSchemaPath, "utf8") : null) !== schema) writeFileAtomic(configSchemaPath, schema);
|
|
4322
|
+
}
|
|
4323
|
+
/**
|
|
4324
|
+
* Validates one group the way the reader does: a key this release does not know
|
|
4325
|
+
* is reported and skipped, never fatal. Without this, a config carrying a key a
|
|
4326
|
+
* newer release added — or an older one removed — could not be saved at all.
|
|
4327
|
+
*/
|
|
4328
|
+
parseGroup(schema, value, label) {
|
|
4329
|
+
const parsed = parseTolerant(value, schema, label, []);
|
|
4330
|
+
if (parsed.error !== null) throw new ConfigError(`${label}: ${parsed.error}`);
|
|
4331
|
+
return parsed.value;
|
|
4332
|
+
}
|
|
4333
|
+
validateServer(entry, label) {
|
|
4334
|
+
const parsed = serverSchema(mergeDefaults(this.defaults, entry));
|
|
4335
|
+
if (parsed instanceof type.errors) throw new ConfigError(`${label}: ${formatErrors(parsed)}`);
|
|
4336
|
+
return {
|
|
4337
|
+
...parsed,
|
|
4338
|
+
port: parsed.port ?? null
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
commit(draft) {
|
|
4342
|
+
const stamped = stampConfig(draft);
|
|
4343
|
+
const text = `${JSON.stringify(stamped, null, 2)}\n`;
|
|
4344
|
+
writeFileAtomic(this.file, text);
|
|
4345
|
+
this.lastText = text;
|
|
4346
|
+
this.raw = stamped;
|
|
4347
|
+
this.apply(stamped);
|
|
4348
|
+
this.notify();
|
|
4349
|
+
}
|
|
4350
|
+
resolveFallback() {
|
|
4351
|
+
const control = controlSchema({});
|
|
4352
|
+
const defaults = defaultsSchema({});
|
|
4353
|
+
if (control instanceof type.errors || defaults instanceof type.errors) throw new ConfigError("internal: default config failed validation");
|
|
4354
|
+
const logs = logsSchema({});
|
|
4355
|
+
const notifications = notificationsSchema({});
|
|
4356
|
+
const host = hostSchema({});
|
|
4357
|
+
const backups = backupsSchema({});
|
|
4358
|
+
const ddns = ddnsConfigSchema({});
|
|
4359
|
+
if (logs instanceof type.errors || notifications instanceof type.errors || host instanceof type.errors || backups instanceof type.errors || ddns instanceof type.errors) throw new ConfigError("internal: default settings failed validation");
|
|
4360
|
+
return {
|
|
4361
|
+
control,
|
|
4362
|
+
defaults,
|
|
4363
|
+
logs,
|
|
4364
|
+
notifications,
|
|
4365
|
+
host,
|
|
4366
|
+
backups,
|
|
4367
|
+
ddns,
|
|
4368
|
+
servers: []
|
|
4369
|
+
};
|
|
4370
|
+
}
|
|
4371
|
+
apply(raw) {
|
|
4372
|
+
this.raw = raw;
|
|
4373
|
+
const parsed = parseConfig(raw);
|
|
4374
|
+
this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
|
|
4375
|
+
this.schemaVersion = parsed.schemaVersion;
|
|
4376
|
+
this.warnings = [...parsed.warnings, ...parsed.unknownKeys.length === 0 ? [] : [`${path.basename(this.file)} carries ${parsed.unknownKeys.length} unrecognized key(s) this release ignores: ${parsed.unknownKeys.join(", ")}`]];
|
|
4377
|
+
if (parsed.config !== null) this.resolvedConfig = parsed.config;
|
|
4378
|
+
else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
|
|
4379
|
+
}
|
|
4380
|
+
};
|
|
4381
|
+
}));
|
|
4382
|
+
//#endregion
|
|
4383
|
+
//#region src/api/ddns.ts
|
|
4384
|
+
/**
|
|
4385
|
+
* Dynamic DNS is configured as one whole block, not as settings patches: its
|
|
4386
|
+
* accounts and hostnames are lists, and a key-by-key merge would leave a removed
|
|
4387
|
+
* account or hostname behind. Provider credentials never come back over the API —
|
|
4388
|
+
* the view only says which accounts have them.
|
|
4389
|
+
*/
|
|
4390
|
+
function createDdnsRoute(deps) {
|
|
4391
|
+
const view = () => {
|
|
4392
|
+
const config = deps.store.config.ddns;
|
|
4393
|
+
return {
|
|
4394
|
+
config,
|
|
4395
|
+
status: deps.ddns.view,
|
|
4396
|
+
providers: ddnsProviderInfos(),
|
|
4397
|
+
credentials: deps.secrets.ddnsAccountIds.filter((id) => {
|
|
4398
|
+
const account = config.accounts.find((entry) => entry.id === id);
|
|
4399
|
+
return account === void 0 || deps.secrets.getDdnsCredentials(id, account.provider) !== null;
|
|
4400
|
+
})
|
|
4401
|
+
};
|
|
4402
|
+
};
|
|
4403
|
+
return appFactory.createApp().get("/ddns", describeRoute({
|
|
4404
|
+
tags: ["ddns"],
|
|
4405
|
+
summary: "Dynamic DNS policy, live state and the providers this build knows",
|
|
4406
|
+
responses: { 200: {
|
|
4407
|
+
description: "The DDNS view",
|
|
4408
|
+
content: jsonBody(ddnsViewSchema)
|
|
4409
|
+
} }
|
|
4410
|
+
}), (c) => c.json(view())).put("/ddns", describeRoute({
|
|
4411
|
+
tags: ["ddns"],
|
|
4412
|
+
summary: "Replace the dynamic DNS block",
|
|
4413
|
+
responses: {
|
|
4414
|
+
200: {
|
|
4415
|
+
description: "Saved",
|
|
4416
|
+
content: jsonBody(ddnsViewSchema)
|
|
4417
|
+
},
|
|
4418
|
+
400: ERROR_RESPONSES[400]
|
|
4419
|
+
}
|
|
4420
|
+
}), validate("json", ddnsConfigSchema), (c) => {
|
|
4421
|
+
const config = c.req.valid("json");
|
|
4422
|
+
const problems = validateDdnsConfig(config);
|
|
4423
|
+
if (problems.length > 0) throw new DetailedError(`invalid DDNS configuration: ${problems.join("; ")}`, {
|
|
4424
|
+
statusCode: 400,
|
|
4425
|
+
code: "INVALID_DDNS",
|
|
4426
|
+
detail: problems
|
|
4427
|
+
});
|
|
4428
|
+
try {
|
|
4429
|
+
deps.store.updateDdns(config);
|
|
4430
|
+
} catch (error) {
|
|
4431
|
+
if (error instanceof ConfigError) throw new DetailedError(error.message, {
|
|
4432
|
+
statusCode: 400,
|
|
4433
|
+
code: "INVALID_DDNS"
|
|
4434
|
+
});
|
|
4435
|
+
throw error;
|
|
4436
|
+
}
|
|
4437
|
+
const pruned = deps.secrets.pruneDdnsCredentials(config.accounts.map((account) => account.id));
|
|
4438
|
+
if (pruned > 0) logger.info(`ddns: dropped stored credentials for ${pruned} account(s) the config no longer declares`);
|
|
4439
|
+
deps.ddns.refresh();
|
|
4440
|
+
return c.json(view());
|
|
4441
|
+
}).put("/ddns/credentials/:id", describeRoute({
|
|
4442
|
+
tags: ["ddns"],
|
|
4443
|
+
summary: "Store the credentials of one DDNS account",
|
|
4444
|
+
responses: {
|
|
4445
|
+
200: {
|
|
4446
|
+
description: "Stored",
|
|
4447
|
+
content: jsonBody(ddnsViewSchema)
|
|
4448
|
+
},
|
|
4449
|
+
400: ERROR_RESPONSES[400]
|
|
4450
|
+
}
|
|
4451
|
+
}), validate("param", accountParam), validate("json", ddnsCredentialsSchema), (c) => {
|
|
4452
|
+
const { id } = c.req.valid("param");
|
|
4453
|
+
const { provider: providerId, credentials } = c.req.valid("json");
|
|
4454
|
+
const provider = ddnsProvider(providerId);
|
|
4455
|
+
if (provider === null) throw new DetailedError(`unknown DDNS provider "${providerId}"`, {
|
|
4456
|
+
statusCode: 400,
|
|
4457
|
+
code: "UNKNOWN_DDNS_PROVIDER"
|
|
4458
|
+
});
|
|
4459
|
+
const problem = provider.validate(credentials);
|
|
4460
|
+
if (problem !== null) throw new DetailedError(`${provider.label}: ${problem}`, {
|
|
4461
|
+
statusCode: 400,
|
|
4462
|
+
code: "INVALID_DDNS_CREDENTIALS"
|
|
4463
|
+
});
|
|
4464
|
+
deps.secrets.setDdnsCredentials(id, providerId, credentials);
|
|
4465
|
+
deps.ddns.refresh();
|
|
4466
|
+
return c.json(view());
|
|
4467
|
+
}).delete("/ddns/credentials/:id", describeRoute({
|
|
4468
|
+
tags: ["ddns"],
|
|
4469
|
+
summary: "Forget the credentials of one DDNS account",
|
|
4470
|
+
responses: { 200: {
|
|
4471
|
+
description: "Removed",
|
|
4472
|
+
content: jsonBody(ddnsViewSchema)
|
|
4473
|
+
} }
|
|
4474
|
+
}), validate("param", accountParam), (c) => {
|
|
4475
|
+
deps.secrets.clearDdnsCredentials(c.req.valid("param").id);
|
|
4476
|
+
deps.ddns.refresh();
|
|
4477
|
+
return c.json(view());
|
|
4478
|
+
}).post("/ddns/check", describeRoute({
|
|
4479
|
+
tags: ["ddns"],
|
|
4480
|
+
summary: "Run a dynamic DNS pass now",
|
|
4481
|
+
responses: { 200: {
|
|
4482
|
+
description: "The pass, with its result",
|
|
4483
|
+
content: jsonBody(ddnsViewSchema)
|
|
4484
|
+
} }
|
|
4485
|
+
}), async (c) => {
|
|
4486
|
+
await deps.ddns.run({ force: true });
|
|
4487
|
+
return c.json(view());
|
|
4488
|
+
});
|
|
4489
|
+
}
|
|
4490
|
+
var accountParam;
|
|
4491
|
+
var init_ddns$1 = __esmMin((() => {
|
|
4492
|
+
init_store();
|
|
4493
|
+
init_factory();
|
|
4494
|
+
init_logger();
|
|
4495
|
+
init_openapi$1();
|
|
4496
|
+
init_validator();
|
|
4497
|
+
init_ddns$2();
|
|
4498
|
+
init_contracts();
|
|
4499
|
+
accountParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
|
|
4500
|
+
}));
|
|
4501
|
+
//#endregion
|
|
4502
|
+
//#region src/api/events.ts
|
|
4503
|
+
/**
|
|
4504
|
+
* One SSE stream per subscriber. Pass `?serverId=<id>` to receive only that
|
|
4505
|
+
* server's messages; the first frame always carries the full state snapshot.
|
|
4506
|
+
*/
|
|
4507
|
+
function createEventsRoute(deps) {
|
|
4508
|
+
return appFactory.createApp().get("/events", describeRoute({
|
|
4509
|
+
tags: ["panel"],
|
|
4510
|
+
summary: "Panel state and log frames as server-sent events",
|
|
4511
|
+
responses: { 200: { description: "text/event-stream" } }
|
|
4512
|
+
}), validate("query", eventsQuery), (c) => {
|
|
4513
|
+
const query = c.req.valid("query");
|
|
4514
|
+
const serverId = query.serverId ?? null;
|
|
4515
|
+
const logOnly = query.logs !== "0";
|
|
4516
|
+
return streamSSE(c, async (stream) => {
|
|
4517
|
+
let pending = 0;
|
|
4518
|
+
let queue = Promise.resolve();
|
|
4519
|
+
let closed = false;
|
|
4520
|
+
const send = (message) => {
|
|
4521
|
+
if (closed) return queue;
|
|
4522
|
+
if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
|
|
4523
|
+
pending += 1;
|
|
4524
|
+
queue = queue.then(() => stream.writeSSE({
|
|
4525
|
+
event: message.type,
|
|
4526
|
+
data: JSON.stringify(message)
|
|
4527
|
+
})).catch(() => {
|
|
4528
|
+
closed = true;
|
|
4529
|
+
}).finally(() => {
|
|
4530
|
+
pending -= 1;
|
|
4531
|
+
});
|
|
4532
|
+
return queue;
|
|
4533
|
+
};
|
|
4534
|
+
const unsubscribe = deps.hub.subscribe(serverId, (message) => {
|
|
4535
|
+
if (!logOnly && message.type === "log") return;
|
|
4536
|
+
send(message);
|
|
4537
|
+
});
|
|
4538
|
+
stream.onAbort(() => {
|
|
4539
|
+
closed = true;
|
|
4540
|
+
unsubscribe();
|
|
4541
|
+
});
|
|
4542
|
+
await send({
|
|
4543
|
+
type: "hello",
|
|
4544
|
+
ts: Date.now(),
|
|
4545
|
+
state: deps.supervisor.getState()
|
|
4546
|
+
});
|
|
4547
|
+
while (true) {
|
|
4548
|
+
await stream.sleep(PING_INTERVAL_MS);
|
|
4549
|
+
if (closed) break;
|
|
4550
|
+
await stream.writeSSE({
|
|
4551
|
+
event: "ping",
|
|
4552
|
+
data: String(Date.now())
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4555
|
+
});
|
|
4556
|
+
});
|
|
4557
|
+
}
|
|
4558
|
+
var MAX_PENDING_WRITES$1, PING_INTERVAL_MS, eventsQuery;
|
|
4559
|
+
var init_events$1 = __esmMin((() => {
|
|
4560
|
+
init_factory();
|
|
4561
|
+
init_validator();
|
|
4562
|
+
MAX_PENDING_WRITES$1 = 200;
|
|
4563
|
+
PING_INTERVAL_MS = 15e3;
|
|
4564
|
+
eventsQuery = type({
|
|
4565
|
+
/** Only this server's frames. */
|
|
4566
|
+
"serverId?": "string",
|
|
4567
|
+
/** `logs=0` drops log frames; the first frame is always the full state. */
|
|
4568
|
+
"logs?": "string"
|
|
4569
|
+
});
|
|
4570
|
+
}));
|
|
4571
|
+
//#endregion
|
|
4572
|
+
//#region src/api/health.ts
|
|
4573
|
+
/**
|
|
4574
|
+
* Liveness for external monitors. Mounted outside `/api`, so it answers without a
|
|
4575
|
+
* session: it reports whether the panel itself is serving, and 503 when an
|
|
4576
|
+
* autostart server has crashed. Server details are only included for an
|
|
4577
|
+
* authenticated caller.
|
|
4578
|
+
*/
|
|
4579
|
+
function createHealthRoute(deps) {
|
|
4580
|
+
return appFactory.createApp().get("/healthz", describeRoute({
|
|
4581
|
+
tags: ["panel"],
|
|
4582
|
+
summary: "Liveness for monitors — no session required",
|
|
4583
|
+
responses: {
|
|
4584
|
+
200: {
|
|
4585
|
+
description: "Serving",
|
|
4586
|
+
content: jsonBody(type({
|
|
4587
|
+
"status": "\"ok\" | \"degraded\"",
|
|
4588
|
+
"uptimeMs": "number",
|
|
4589
|
+
"servers?": type({
|
|
4590
|
+
total: "number",
|
|
4591
|
+
running: "number",
|
|
4592
|
+
crashed: "number",
|
|
4593
|
+
unhealthy: "number"
|
|
4594
|
+
}),
|
|
4595
|
+
"hostAlerts?": "string[]"
|
|
4596
|
+
}))
|
|
4597
|
+
},
|
|
4598
|
+
503: { description: "An autostart server has crashed" }
|
|
4599
|
+
}
|
|
4600
|
+
}), (c) => {
|
|
4601
|
+
const state = deps.supervisor.getState();
|
|
4602
|
+
const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
4603
|
+
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
4604
|
+
return c.json({
|
|
4605
|
+
status: broken.length > 0 ? "degraded" : "ok",
|
|
4606
|
+
uptimeMs: Math.round(process.uptime() * 1e3),
|
|
4607
|
+
...authenticated ? {
|
|
4608
|
+
servers: {
|
|
4609
|
+
total: state.servers.length,
|
|
4610
|
+
running: state.servers.filter((server) => server.status === "running").length,
|
|
4611
|
+
crashed: state.servers.filter((server) => server.status === "crashed").length,
|
|
4612
|
+
unhealthy: state.servers.filter((server) => server.health === "unhealthy").length
|
|
4613
|
+
},
|
|
4614
|
+
hostAlerts: state.host.alerts
|
|
4615
|
+
} : {}
|
|
4616
|
+
}, broken.length > 0 ? 503 : 200);
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
var init_health = __esmMin((() => {
|
|
4620
|
+
init_factory();
|
|
4621
|
+
init_openapi$1();
|
|
4622
|
+
init_auth();
|
|
4623
|
+
}));
|
|
4624
|
+
//#endregion
|
|
4625
|
+
//#region src/api/logs.ts
|
|
4626
|
+
function unknownServer$1(id) {
|
|
4627
|
+
return new DetailedError(`unknown server "${id}"`, {
|
|
4628
|
+
statusCode: 404,
|
|
4629
|
+
code: "UNKNOWN_SERVER"
|
|
4630
|
+
});
|
|
4631
|
+
}
|
|
4632
|
+
function createLogsRoute(deps) {
|
|
4633
|
+
return appFactory.createApp().get("/logs", describeRoute({
|
|
4634
|
+
tags: ["logs"],
|
|
4635
|
+
summary: "Every server with its on-disk log files",
|
|
4636
|
+
responses: { 200: {
|
|
4637
|
+
description: "Log sources",
|
|
4638
|
+
content: jsonBody(logServersViewSchema)
|
|
4639
|
+
} }
|
|
4640
|
+
}), (c) => c.json({ servers: deps.supervisor.views().map((server) => ({
|
|
4641
|
+
serverId: server.id,
|
|
4642
|
+
label: server.config.label ?? server.id,
|
|
4643
|
+
status: server.status,
|
|
4644
|
+
...deps.logFiles.info(server.id)
|
|
4645
|
+
})) })).get("/logs/:id", describeRoute({
|
|
4646
|
+
tags: ["logs"],
|
|
4647
|
+
summary: "Persisted log lines, with search and a stream filter",
|
|
4648
|
+
responses: {
|
|
4649
|
+
200: { description: "Lines" },
|
|
4650
|
+
404: ERROR_RESPONSES[404]
|
|
4651
|
+
}
|
|
4652
|
+
}), validate("param", idParam$1), validate("query", logHistoryQuerySchema), (c) => {
|
|
4653
|
+
const { id } = c.req.valid("param");
|
|
4654
|
+
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
4655
|
+
const query = c.req.valid("query");
|
|
4656
|
+
const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
|
|
4657
|
+
const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
|
|
4658
|
+
const info = deps.logFiles.info(id);
|
|
4659
|
+
const search = query.search?.trim() ?? "";
|
|
4660
|
+
const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
|
|
4661
|
+
let lines = deps.logFiles.readTail(id, window);
|
|
4662
|
+
if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
|
|
4663
|
+
if (search.length > 0) {
|
|
4664
|
+
const needle = search.toLowerCase();
|
|
4665
|
+
lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
4666
|
+
}
|
|
4667
|
+
return c.json({
|
|
4668
|
+
serverId: id,
|
|
4669
|
+
enabled: info.enabled,
|
|
4670
|
+
sizeBytes: info.sizeBytes,
|
|
4671
|
+
files: info.files.map((file) => file.name),
|
|
4672
|
+
searched: search.length > 0 ? window : null,
|
|
4673
|
+
lines: lines.slice(-tail)
|
|
4674
|
+
});
|
|
4675
|
+
}).get("/logs/:id/download", describeRoute({
|
|
4676
|
+
tags: ["logs"],
|
|
4677
|
+
summary: "Download one rotated log file",
|
|
4678
|
+
responses: {
|
|
4679
|
+
200: { description: "application/x-ndjson" },
|
|
4680
|
+
404: ERROR_RESPONSES[404]
|
|
4681
|
+
}
|
|
4682
|
+
}), validate("param", idParam$1), validate("query", downloadQuery), (c) => {
|
|
4683
|
+
const { id } = c.req.valid("param");
|
|
4684
|
+
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
4685
|
+
const requested = c.req.valid("query").file ?? `${id}.log`;
|
|
4686
|
+
if (!deps.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
|
|
4687
|
+
statusCode: 404,
|
|
4688
|
+
code: "UNKNOWN_LOG_FILE"
|
|
4689
|
+
});
|
|
4690
|
+
const file = path.join(deps.logFiles.directory, requested);
|
|
4691
|
+
const body = fs.readFileSync(file);
|
|
4692
|
+
return c.body(body, 200, {
|
|
4693
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
4694
|
+
"Content-Length": String(body.byteLength),
|
|
4695
|
+
"Content-Disposition": `attachment; filename="${requested}"`
|
|
4696
|
+
});
|
|
4697
|
+
}).delete("/logs/:id", describeRoute({
|
|
4698
|
+
tags: ["logs"],
|
|
4699
|
+
summary: "Delete the persisted logs of one server",
|
|
4700
|
+
responses: {
|
|
4701
|
+
200: { description: "Cleared" },
|
|
4702
|
+
404: ERROR_RESPONSES[404]
|
|
4703
|
+
}
|
|
4704
|
+
}), validate("param", idParam$1), (c) => {
|
|
4705
|
+
const { id } = c.req.valid("param");
|
|
4706
|
+
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
4707
|
+
deps.logFiles.clear(id);
|
|
4708
|
+
return c.json({ ok: true });
|
|
4709
|
+
});
|
|
4710
|
+
}
|
|
4711
|
+
var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$1, downloadQuery;
|
|
4712
|
+
var init_logs = __esmMin((() => {
|
|
4713
|
+
init_factory();
|
|
4714
|
+
init_openapi$1();
|
|
4715
|
+
init_validator();
|
|
4716
|
+
init_contracts();
|
|
4717
|
+
MIN_TAIL = 50;
|
|
4718
|
+
MAX_TAIL = 5e3;
|
|
4719
|
+
DEFAULT_TAIL = 500;
|
|
4720
|
+
idParam$1 = type({ id: "string >= 1" });
|
|
4721
|
+
downloadQuery = type({ "file?": "string" });
|
|
4722
|
+
}));
|
|
4723
|
+
//#endregion
|
|
4724
|
+
//#region src/api/metrics.ts
|
|
4725
|
+
/**
|
|
4726
|
+
* Prometheus text for whatever wants to scrape the panel (Beszel, Grafana,
|
|
4727
|
+
* `curl`). Lives under `/api`, so it needs a session like every other route.
|
|
4728
|
+
*/
|
|
4729
|
+
function createMetricsRoute(deps) {
|
|
4730
|
+
return appFactory.createApp().get("/metrics", describeRoute({
|
|
4731
|
+
tags: ["panel"],
|
|
4732
|
+
summary: "Prometheus text for whatever scrapes the panel",
|
|
4733
|
+
responses: { 200: { description: "text/plain; version=0.0.4" } }
|
|
4734
|
+
}), (c) => {
|
|
4735
|
+
const state = deps.supervisor.getState();
|
|
4736
|
+
const lines = [];
|
|
4737
|
+
const metric = (name, help, samples) => {
|
|
4738
|
+
if (samples.length === 0) return;
|
|
4739
|
+
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
4740
|
+
};
|
|
4741
|
+
metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
|
|
4742
|
+
metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
|
|
4743
|
+
metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
|
|
4744
|
+
metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
|
|
4745
|
+
metric("hh_server_crashes_24h", "Crashes in the last 24 hours", state.servers.map((server) => `hh_server_crashes_24h{server="${server.id}"} ${server.history.crashes}`));
|
|
4746
|
+
metric("hh_server_uptime_ratio_24h", "Share of the last 24 hours the server was up", state.servers.filter((server) => server.history.uptimeRatio !== null).map((server) => `hh_server_uptime_ratio_24h{server="${server.id}"} ${server.history.uptimeRatio.toFixed(4)}`));
|
|
4747
|
+
metric("hh_server_response_ms", "Last health probe latency in milliseconds", state.servers.filter((server) => server.responseMs !== null).map((server) => `hh_server_response_ms{server="${server.id}"} ${server.responseMs}`));
|
|
4748
|
+
metric("hh_server_rss_bytes", "RSS of the server process tree", state.servers.filter((server) => server.resources?.rssBytes != null).map((server) => `hh_server_rss_bytes{server="${server.id}"} ${server.resources.rssBytes}`));
|
|
4749
|
+
metric("hh_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
|
|
4750
|
+
metric("hh_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
|
|
4751
|
+
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
4752
|
+
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
4753
|
+
metric("hh_host_load1_per_cpu", "1 minute load average per cpu", [`hh_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
|
|
4754
|
+
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
4755
|
+
});
|
|
4756
|
+
}
|
|
4757
|
+
var init_metrics = __esmMin((() => {
|
|
4758
|
+
init_factory();
|
|
4759
|
+
}));
|
|
4760
|
+
//#endregion
|
|
4761
|
+
//#region src/providers/telegram.ts
|
|
4762
|
+
function getBot(token) {
|
|
4763
|
+
const cached = bots.get(token);
|
|
4764
|
+
if (cached) return cached;
|
|
4765
|
+
const bot = new Bot(token, { client: { timeoutSeconds: 10 } });
|
|
4766
|
+
bot.api.config.use(autoRetry({
|
|
4767
|
+
maxRetryAttempts: 3,
|
|
4768
|
+
maxDelaySeconds: 20
|
|
4769
|
+
}));
|
|
4770
|
+
bots.set(token, bot);
|
|
4771
|
+
return bot;
|
|
4772
|
+
}
|
|
4773
|
+
/** Drops cached clients; used when the token changes or the service shuts down. */
|
|
4774
|
+
function forgetBots() {
|
|
4775
|
+
bots.clear();
|
|
4776
|
+
}
|
|
4777
|
+
function escapeHtml(value) {
|
|
4778
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4779
|
+
}
|
|
4780
|
+
function formatTelegramMessage(title, lines) {
|
|
4781
|
+
const body = lines.filter((line) => line.length > 0).map((line) => `• ${escapeHtml(line)}`).join("\n");
|
|
4782
|
+
return `<b>${escapeHtml(title)}</b>${body.length > 0 ? `\n${body}` : ""}`;
|
|
4783
|
+
}
|
|
4784
|
+
/** Turns a grammY error into something worth showing in the settings page. */
|
|
4785
|
+
function describeTelegramError(error) {
|
|
4786
|
+
if (typeof error === "object" && error !== null) {
|
|
4787
|
+
const candidate = error;
|
|
4788
|
+
const description = candidate.description ?? candidate.message;
|
|
4789
|
+
if (description) return `${description}${candidate.error_code === void 0 ? "" : ` (${candidate.error_code})`}${candidate.parameters?.retry_after === void 0 ? "" : `, retry in ${candidate.parameters.retry_after}s`}`;
|
|
4790
|
+
}
|
|
4791
|
+
return error instanceof Error ? error.message : String(error);
|
|
4792
|
+
}
|
|
4793
|
+
async function sendTelegramMessage(token, chatId, html) {
|
|
4794
|
+
try {
|
|
4795
|
+
await getBot(token).api.sendMessage(chatId, html, {
|
|
4796
|
+
parse_mode: "HTML",
|
|
4797
|
+
link_preview_options: { is_disabled: true }
|
|
4798
|
+
});
|
|
4799
|
+
return { ok: true };
|
|
4800
|
+
} catch (error) {
|
|
4801
|
+
return {
|
|
4802
|
+
ok: false,
|
|
4803
|
+
error: describeTelegramError(error)
|
|
4804
|
+
};
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4807
|
+
async function verifyTelegramToken(token) {
|
|
4808
|
+
try {
|
|
4809
|
+
return {
|
|
4810
|
+
ok: true,
|
|
4811
|
+
username: (await getBot(token).api.getMe()).username
|
|
4812
|
+
};
|
|
4813
|
+
} catch (error) {
|
|
4814
|
+
return {
|
|
4815
|
+
ok: false,
|
|
4816
|
+
error: describeTelegramError(error)
|
|
4817
|
+
};
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
/**
|
|
4821
|
+
* Recent chats that talked to the bot, so a chat id can be picked instead of
|
|
4822
|
+
* hunted down by hand. Telegram only reports chats with pending updates, so the
|
|
4823
|
+
* caller is told to message the bot first.
|
|
4824
|
+
*/
|
|
4825
|
+
async function listTelegramChats(token) {
|
|
4826
|
+
try {
|
|
4827
|
+
const updates = await getBot(token).api.getUpdates({
|
|
4828
|
+
limit: 100,
|
|
4829
|
+
allowed_updates: [
|
|
4830
|
+
"message",
|
|
4831
|
+
"channel_post",
|
|
4832
|
+
"edited_message"
|
|
4833
|
+
]
|
|
4834
|
+
});
|
|
4835
|
+
const chats = /* @__PURE__ */ new Map();
|
|
4836
|
+
for (const update of updates) {
|
|
4837
|
+
const chat = update.message?.chat ?? update.channel_post?.chat ?? update.edited_message?.chat;
|
|
4838
|
+
if (!chat) continue;
|
|
4839
|
+
const title = "title" in chat && chat.title ? chat.title : "username" in chat && chat.username ? `@${chat.username}` : "first_name" in chat && chat.first_name ? chat.first_name : "private chat";
|
|
4840
|
+
chats.set(String(chat.id), {
|
|
4841
|
+
id: chat.id,
|
|
4842
|
+
title
|
|
4843
|
+
});
|
|
3253
4844
|
}
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
4845
|
+
return {
|
|
4846
|
+
ok: true,
|
|
4847
|
+
chats: [...chats.values()]
|
|
4848
|
+
};
|
|
4849
|
+
} catch (error) {
|
|
4850
|
+
return {
|
|
4851
|
+
ok: false,
|
|
4852
|
+
chats: [],
|
|
4853
|
+
error: describeTelegramError(error)
|
|
4854
|
+
};
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
var bots;
|
|
4858
|
+
var init_telegram = __esmMin((() => {
|
|
4859
|
+
bots = /* @__PURE__ */ new Map();
|
|
4860
|
+
}));
|
|
4861
|
+
//#endregion
|
|
4862
|
+
//#region src/api/notifications.ts
|
|
4863
|
+
/**
|
|
4864
|
+
* The bot token is written straight to the secrets file and never into
|
|
4865
|
+
* `servers.config.json`, so notification *policy* and the *credential* stay
|
|
4866
|
+
* separate.
|
|
4867
|
+
*/
|
|
4868
|
+
function createNotificationsRoute(deps) {
|
|
4869
|
+
return appFactory.createApp().put("/notifications/token", describeRoute({
|
|
4870
|
+
tags: ["notifications"],
|
|
4871
|
+
summary: "Store the Telegram bot token (verified first)",
|
|
4872
|
+
responses: {
|
|
4873
|
+
200: { description: "Stored" },
|
|
4874
|
+
400: ERROR_RESPONSES[400]
|
|
3262
4875
|
}
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
4876
|
+
}), validate("json", telegramTokenSchema), async (c) => {
|
|
4877
|
+
const { botToken } = c.req.valid("json");
|
|
4878
|
+
const verified = await deps.notifications.verifyToken(botToken);
|
|
4879
|
+
if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
|
|
4880
|
+
statusCode: 400,
|
|
4881
|
+
code: "TELEGRAM_TOKEN_REJECTED"
|
|
4882
|
+
});
|
|
4883
|
+
deps.secrets.setTelegramToken(botToken);
|
|
4884
|
+
forgetBots();
|
|
4885
|
+
return c.json({
|
|
4886
|
+
ok: true,
|
|
4887
|
+
username: verified.username ?? null
|
|
4888
|
+
});
|
|
4889
|
+
}).delete("/notifications/token", describeRoute({
|
|
4890
|
+
tags: ["notifications"],
|
|
4891
|
+
summary: "Forget the Telegram bot token",
|
|
4892
|
+
responses: { 200: { description: "Removed" } }
|
|
4893
|
+
}), (c) => {
|
|
4894
|
+
deps.secrets.setTelegramToken(null);
|
|
4895
|
+
forgetBots();
|
|
4896
|
+
return c.json({ ok: true });
|
|
4897
|
+
}).post("/notifications/test", describeRoute({
|
|
4898
|
+
tags: ["notifications"],
|
|
4899
|
+
summary: "Send a test message",
|
|
4900
|
+
responses: {
|
|
4901
|
+
200: { description: "Sent or refused" },
|
|
4902
|
+
400: ERROR_RESPONSES[400]
|
|
3281
4903
|
}
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
4904
|
+
}), validate("json", notificationActionSchema), async (c) => {
|
|
4905
|
+
const result = await deps.notifications.sendTest(c.req.valid("json"));
|
|
4906
|
+
if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
|
|
4907
|
+
statusCode: 400,
|
|
4908
|
+
code: "TELEGRAM_SEND_FAILED"
|
|
4909
|
+
});
|
|
4910
|
+
return c.json(result);
|
|
4911
|
+
}).post("/notifications/detect-chats", describeRoute({
|
|
4912
|
+
tags: ["notifications"],
|
|
4913
|
+
summary: "List the chats the bot can see",
|
|
4914
|
+
responses: {
|
|
4915
|
+
200: {
|
|
4916
|
+
description: "Chats",
|
|
4917
|
+
content: jsonBody(type({ chats: type({
|
|
4918
|
+
id: "string | number",
|
|
4919
|
+
title: "string"
|
|
4920
|
+
}).array() }))
|
|
4921
|
+
},
|
|
4922
|
+
400: ERROR_RESPONSES[400]
|
|
3290
4923
|
}
|
|
3291
|
-
}
|
|
4924
|
+
}), validate("json", notificationActionSchema), async (c) => {
|
|
4925
|
+
const result = await deps.notifications.detectChats(c.req.valid("json"));
|
|
4926
|
+
if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
|
|
4927
|
+
statusCode: 400,
|
|
4928
|
+
code: "TELEGRAM_LIST_FAILED"
|
|
4929
|
+
});
|
|
4930
|
+
return c.json({ chats: result.chats });
|
|
4931
|
+
});
|
|
4932
|
+
}
|
|
4933
|
+
var init_notifications$1 = __esmMin((() => {
|
|
4934
|
+
init_factory();
|
|
4935
|
+
init_openapi$1();
|
|
4936
|
+
init_validator();
|
|
4937
|
+
init_telegram();
|
|
4938
|
+
init_contracts();
|
|
3292
4939
|
}));
|
|
3293
4940
|
//#endregion
|
|
3294
4941
|
//#region src/api/servers/$.routes.ts
|
|
@@ -3609,6 +5256,7 @@ function createSettingsRoute(deps) {
|
|
|
3609
5256
|
if (patch.notifications !== void 0) deps.store.updateNotifications(patch.notifications);
|
|
3610
5257
|
if (patch.host !== void 0) deps.store.updateHost(patch.host);
|
|
3611
5258
|
if (patch.backups !== void 0) deps.store.updateBackups(patch.backups);
|
|
5259
|
+
if (patch.ddns !== void 0) deps.store.updateDdns(patch.ddns);
|
|
3612
5260
|
if (patch.control !== void 0) deps.store.updateControl(patch.control);
|
|
3613
5261
|
} catch (error) {
|
|
3614
5262
|
if (error instanceof ConfigError) throw new DetailedError(error.message, {
|
|
@@ -3617,6 +5265,7 @@ function createSettingsRoute(deps) {
|
|
|
3617
5265
|
});
|
|
3618
5266
|
throw error;
|
|
3619
5267
|
}
|
|
5268
|
+
if (patch.ddns !== void 0) deps.ddns.refresh();
|
|
3620
5269
|
const next = deps.store.config.control;
|
|
3621
5270
|
const endpointChanged = next.host !== deps.controlServer.endpoint.host || next.port !== deps.controlServer.endpoint.port;
|
|
3622
5271
|
const proxyChanged = next.auth.trustProxy !== previous.trustProxy;
|
|
@@ -3992,13 +5641,14 @@ function createRootApp(deps) {
|
|
|
3992
5641
|
const started = Date.now();
|
|
3993
5642
|
await next();
|
|
3994
5643
|
logger.debug(`${c.req.method} ${new URL(c.req.url).pathname} ${c.res.status} ${Date.now() - started}ms`);
|
|
3995
|
-
}).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", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
|
|
5644
|
+
}).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", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
|
|
3996
5645
|
return app.route("/", setupOpenAPI(app)).route("/", createStaticRoute({ dir: () => deps.ui.resolveDir() }));
|
|
3997
5646
|
}
|
|
3998
5647
|
var init_app = __esmMin((() => {
|
|
3999
5648
|
init_$_routes$1();
|
|
4000
5649
|
init_backups$1();
|
|
4001
5650
|
init_control();
|
|
5651
|
+
init_ddns$1();
|
|
4002
5652
|
init_events$1();
|
|
4003
5653
|
init_health();
|
|
4004
5654
|
init_logs();
|
|
@@ -5671,9 +7321,20 @@ var init_log_relay = __esmMin((() => {
|
|
|
5671
7321
|
this.start();
|
|
5672
7322
|
return history;
|
|
5673
7323
|
}
|
|
7324
|
+
/**
|
|
7325
|
+
* Stops following and hands back whatever the file still held unread.
|
|
7326
|
+
*
|
|
7327
|
+
* A nanny writes its last lines and exits in the same breath, so the tailer can be
|
|
7328
|
+
* dropped between two polls with its final lines — the child's crash output — still
|
|
7329
|
+
* on disk. Losing them here is what made a persistent entry's crash silent in the
|
|
7330
|
+
* panel, so the caller gets them to ingest rather than an empty hand.
|
|
7331
|
+
*/
|
|
5674
7332
|
unfollow(serverId) {
|
|
7333
|
+
const current = this.followed.get(serverId);
|
|
7334
|
+
const rest = current === void 0 ? [] : current.tailer.read();
|
|
5675
7335
|
this.followed.delete(serverId);
|
|
5676
7336
|
if (this.followed.size === 0) this.stopTimer();
|
|
7337
|
+
return rest;
|
|
5677
7338
|
}
|
|
5678
7339
|
followedIds() {
|
|
5679
7340
|
return [...this.followed.keys()];
|
|
@@ -5708,6 +7369,18 @@ var init_log_relay = __esmMin((() => {
|
|
|
5708
7369
|
}));
|
|
5709
7370
|
//#endregion
|
|
5710
7371
|
//#region src/services/supervisor.ts
|
|
7372
|
+
/** One line's exact identity: a timestamp this panel minted, its stream and its text. */
|
|
7373
|
+
function echoedKey(line) {
|
|
7374
|
+
return `${line.ts}\u0000${line.stream}\u0000${line.text}`;
|
|
7375
|
+
}
|
|
7376
|
+
function rememberEchoedLine(set, line) {
|
|
7377
|
+
set.add(echoedKey(line));
|
|
7378
|
+
while (set.size > ECHOED_LINE_LIMIT) set.delete(set.values().next().value);
|
|
7379
|
+
}
|
|
7380
|
+
/** True when this line is this panel's own echo, so it is only ever reported once. */
|
|
7381
|
+
function forgetEchoedLine(set, line) {
|
|
7382
|
+
return set.delete(echoedKey(line));
|
|
7383
|
+
}
|
|
5711
7384
|
function delay(ms) {
|
|
5712
7385
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5713
7386
|
}
|
|
@@ -5727,7 +7400,7 @@ function serverTemplateVars(config) {
|
|
|
5727
7400
|
home: os.homedir()
|
|
5728
7401
|
};
|
|
5729
7402
|
}
|
|
5730
|
-
var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, Supervisor;
|
|
7403
|
+
var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, ECHOED_LINE_LIMIT, Supervisor;
|
|
5731
7404
|
var init_supervisor = __esmMin((() => {
|
|
5732
7405
|
init_backoff();
|
|
5733
7406
|
init_bind();
|
|
@@ -5752,6 +7425,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5752
7425
|
PORT_RELEASE_RECHECK_MS = 300;
|
|
5753
7426
|
RESOURCE_SAMPLE_INTERVAL_MS = 5e3;
|
|
5754
7427
|
PERSISTENT_BACKFILL_LINES = 200;
|
|
7428
|
+
ECHOED_LINE_LIMIT = 100;
|
|
5755
7429
|
Supervisor = class {
|
|
5756
7430
|
store;
|
|
5757
7431
|
hub;
|
|
@@ -6101,7 +7775,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6101
7775
|
clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
|
|
6102
7776
|
clearNannySpec(nannySpecPath(this.options.nannyDir, entry.config.id));
|
|
6103
7777
|
} else this.log(entry, "system", `pid ${survivor} is still alive after the stop — its state file is kept`);
|
|
6104
|
-
this.
|
|
7778
|
+
this.drainRelay(entry);
|
|
6105
7779
|
}
|
|
6106
7780
|
entry.stopping = false;
|
|
6107
7781
|
entry.child = null;
|
|
@@ -6143,6 +7817,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6143
7817
|
stopping: false,
|
|
6144
7818
|
bootstrapDone: !config.bootstrap,
|
|
6145
7819
|
logs: new LogBuffer(config.logBufferLines),
|
|
7820
|
+
echoed: /* @__PURE__ */ new Set(),
|
|
6146
7821
|
responseMs: null,
|
|
6147
7822
|
resources: null,
|
|
6148
7823
|
resourcesSampledAt: 0,
|
|
@@ -6162,8 +7837,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6162
7837
|
continue;
|
|
6163
7838
|
}
|
|
6164
7839
|
const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
|
|
7840
|
+
if (entry.config.persistent && !config.persistent) this.drainRelay(entry);
|
|
6165
7841
|
entry.config = config;
|
|
6166
|
-
if (!config.persistent) this.relay.unfollow(id);
|
|
6167
7842
|
if (bufferChanged) {
|
|
6168
7843
|
const kept = entry.logs.list(config.logBufferLines);
|
|
6169
7844
|
entry.logs = new LogBuffer(config.logBufferLines);
|
|
@@ -6323,8 +7998,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6323
7998
|
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
6324
7999
|
entry.startedAt = Date.now();
|
|
6325
8000
|
entry.lastError = null;
|
|
8001
|
+
if (entry.config.persistent) this.followPersistent(entry, true);
|
|
6326
8002
|
this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
|
|
6327
|
-
if (entry.config.persistent) this.followPersistent(entry);
|
|
6328
8003
|
this.publishServer(entry);
|
|
6329
8004
|
return { ok: true };
|
|
6330
8005
|
}
|
|
@@ -6559,25 +8234,41 @@ var init_supervisor = __esmMin((() => {
|
|
|
6559
8234
|
ingestExternalLines(serverId, lines) {
|
|
6560
8235
|
const entry = this.entries.get(serverId);
|
|
6561
8236
|
if (entry === void 0 || !entry.config.persistent || lines.length === 0) return;
|
|
6562
|
-
|
|
8237
|
+
const kept = lines.filter((line) => !forgetEchoedLine(entry.echoed, line));
|
|
8238
|
+
if (kept.length === 0) return;
|
|
8239
|
+
for (const line of kept) entry.logs.push(line);
|
|
6563
8240
|
this.hub.publish({
|
|
6564
8241
|
type: "log",
|
|
6565
|
-
ts:
|
|
8242
|
+
ts: kept[kept.length - 1].ts,
|
|
6566
8243
|
serverId,
|
|
6567
|
-
lines
|
|
8244
|
+
lines: kept
|
|
6568
8245
|
});
|
|
6569
8246
|
}
|
|
6570
8247
|
/**
|
|
8248
|
+
* Whatever the relay had not read yet, ingested before its tailer goes: a nanny's
|
|
8249
|
+
* exit and a stop both end with lines the panel has never seen.
|
|
8250
|
+
*/
|
|
8251
|
+
drainRelay(entry) {
|
|
8252
|
+
const rest = this.relay.unfollow(entry.config.id);
|
|
8253
|
+
if (rest.length > 0) this.ingestExternalLines(entry.config.id, rest);
|
|
8254
|
+
}
|
|
8255
|
+
/**
|
|
6571
8256
|
* Follows a persistent entry's log file, filling the ring buffer from what is on disk.
|
|
6572
8257
|
*
|
|
6573
8258
|
* The backfill is read *through* the tailer, which is the only thing that may own the
|
|
6574
8259
|
* offset: reading it from `LogFiles` first and seeking to EOF second would either
|
|
6575
8260
|
* replay a line as news or skip one as history, depending on which order the two
|
|
6576
8261
|
* syscalls happen to land in.
|
|
8262
|
+
*
|
|
8263
|
+
* `attach` is the caller's word for "this entry is already running, and its history is
|
|
8264
|
+
* new to this panel": only then is the file's tail history. The buffer itself cannot
|
|
8265
|
+
* answer that — spawn, adopt and reattach all log a line of their own — which is why a
|
|
8266
|
+
* `logs.size === 0` test left this backfill dead. An empty buffer is still required, so
|
|
8267
|
+
* a second attach to an entry we already hold lines for cannot replay older ones on top.
|
|
6577
8268
|
*/
|
|
6578
|
-
followPersistent(entry) {
|
|
8269
|
+
followPersistent(entry, attach = false) {
|
|
6579
8270
|
const id = entry.config.id;
|
|
6580
|
-
const backfill = entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
|
|
8271
|
+
const backfill = attach && entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
|
|
6581
8272
|
const history = this.relay.follow(id, nannyLogFile(this.options.logFiles.directory, id), { backfill });
|
|
6582
8273
|
if (history.length > 0) entry.logs.extend(history);
|
|
6583
8274
|
}
|
|
@@ -6600,8 +8291,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6600
8291
|
entry.lastError = null;
|
|
6601
8292
|
entry.nannyExit = null;
|
|
6602
8293
|
const child = state.childPid === null ? "" : `, server pid ${state.childPid}`;
|
|
8294
|
+
this.followPersistent(entry, true);
|
|
6603
8295
|
this.log(entry, "system", `persistent: still running (nanny pid ${state.nannyPid}${child}) — reattached`);
|
|
6604
|
-
this.followPersistent(entry);
|
|
6605
8296
|
this.publishServer(entry);
|
|
6606
8297
|
return true;
|
|
6607
8298
|
}
|
|
@@ -6640,7 +8331,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6640
8331
|
const state = readNannyState(statePath);
|
|
6641
8332
|
if (state?.lastExit !== void 0) entry.nannyExit = state.lastExit;
|
|
6642
8333
|
clearNannyState(statePath);
|
|
6643
|
-
this.
|
|
8334
|
+
this.drainRelay(entry);
|
|
6644
8335
|
}
|
|
6645
8336
|
/** One probe using the configured mode (TCP or HTTP), with timing. */
|
|
6646
8337
|
async probeEntryHealth(entry) {
|
|
@@ -6759,6 +8450,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6759
8450
|
if (this.disposed) return;
|
|
6760
8451
|
const now = Date.now();
|
|
6761
8452
|
await this.options.hostMonitor.tick(now);
|
|
8453
|
+
this.options.ddns.tick(now);
|
|
6762
8454
|
await this.sampleResources(now);
|
|
6763
8455
|
await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
|
|
6764
8456
|
for (const entry of this.entries.values()) {
|
|
@@ -6904,6 +8596,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6904
8596
|
};
|
|
6905
8597
|
entry.logs.push(line);
|
|
6906
8598
|
this.options.logFiles.append(entry.config.id, line);
|
|
8599
|
+
if (entry.config.persistent && this.options.logFiles.config.persist) rememberEchoedLine(entry.echoed, line);
|
|
6907
8600
|
this.hub.publish({
|
|
6908
8601
|
type: "log",
|
|
6909
8602
|
ts: line.ts,
|
|
@@ -6923,19 +8616,30 @@ var init_supervisor = __esmMin((() => {
|
|
|
6923
8616
|
}
|
|
6924
8617
|
publishState() {
|
|
6925
8618
|
const state = this.getState();
|
|
6926
|
-
const
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
server
|
|
6937
|
-
|
|
6938
|
-
|
|
8619
|
+
const ddns = state.ddns;
|
|
8620
|
+
const signature = [
|
|
8621
|
+
state.configError ?? "",
|
|
8622
|
+
ddns === void 0 ? "" : [
|
|
8623
|
+
ddns.running,
|
|
8624
|
+
ddns.lastRunAt,
|
|
8625
|
+
ddns.ipv4,
|
|
8626
|
+
ddns.ipv6,
|
|
8627
|
+
...ddns.records.map((record) => `${record.host}:${record.type}:${record.state}:${record.ip}`)
|
|
8628
|
+
].join(":"),
|
|
8629
|
+
...state.servers.map((server) => [
|
|
8630
|
+
server.id,
|
|
8631
|
+
server.status,
|
|
8632
|
+
server.health,
|
|
8633
|
+
server.portState,
|
|
8634
|
+
server.pid,
|
|
8635
|
+
server.restarts,
|
|
8636
|
+
server.nextRetryAt,
|
|
8637
|
+
server.lastError,
|
|
8638
|
+
server.bufferedLines,
|
|
8639
|
+
server.responseMs,
|
|
8640
|
+
server.resources?.sampledAt
|
|
8641
|
+
].join(":"))
|
|
8642
|
+
].join("|");
|
|
6939
8643
|
if (signature === this.lastStateSignature) return;
|
|
6940
8644
|
this.lastStateSignature = signature;
|
|
6941
8645
|
this.hub.publish({
|
|
@@ -7132,7 +8836,7 @@ function safeSlug(slug) {
|
|
|
7132
8836
|
return slug;
|
|
7133
8837
|
}
|
|
7134
8838
|
/** A flag is valid for one exact file revision, not for the name alone. */
|
|
7135
|
-
function cacheKey(file) {
|
|
8839
|
+
function cacheKey$1(file) {
|
|
7136
8840
|
return `${file.sizeBytes}:${file.createdAt}`;
|
|
7137
8841
|
}
|
|
7138
8842
|
/** Stable, filesystem-safe name for a data path inside the archive. */
|
|
@@ -7197,13 +8901,13 @@ var init_backups = __esmMin((() => {
|
|
|
7197
8901
|
const files = this.scan();
|
|
7198
8902
|
for (const file of files) {
|
|
7199
8903
|
const cached = this.flags.get(file.name);
|
|
7200
|
-
if (cached === void 0 || cached.key !== cacheKey(file)) this.scheduleRefresh();
|
|
8904
|
+
if (cached === void 0 || cached.key !== cacheKey$1(file)) this.scheduleRefresh();
|
|
7201
8905
|
}
|
|
7202
8906
|
return files.map((file) => {
|
|
7203
8907
|
const cached = this.flags.get(file.name);
|
|
7204
8908
|
return {
|
|
7205
8909
|
...file,
|
|
7206
|
-
encrypted: cached !== void 0 && cached.key === cacheKey(file) ? cached.encrypted : false
|
|
8910
|
+
encrypted: cached !== void 0 && cached.key === cacheKey$1(file) ? cached.encrypted : false
|
|
7207
8911
|
};
|
|
7208
8912
|
});
|
|
7209
8913
|
}
|
|
@@ -7220,6 +8924,8 @@ var init_backups = __esmMin((() => {
|
|
|
7220
8924
|
error: "backups are disabled"
|
|
7221
8925
|
};
|
|
7222
8926
|
const password = options.password !== void 0 && options.password.length > 0 ? options.password : null;
|
|
8927
|
+
const wanted = options.include === void 0 ? null : new Set(options.include);
|
|
8928
|
+
const captures = (id) => wanted === null || wanted.has(id);
|
|
7223
8929
|
const dir = this.resolveDir();
|
|
7224
8930
|
const sources = this.options.getSources();
|
|
7225
8931
|
const staging = path.join(dir, `.staging-${Date.now()}`);
|
|
@@ -7228,12 +8934,12 @@ var init_backups = __esmMin((() => {
|
|
|
7228
8934
|
const destination = path.join(dir, name);
|
|
7229
8935
|
try {
|
|
7230
8936
|
fs.mkdirSync(staging, { recursive: true });
|
|
7231
|
-
this.copyInto(staging, "config/servers.config.json", sources.configPath);
|
|
7232
|
-
this.copyInto(staging, "secrets/control-secrets.json", sources.secretsPath);
|
|
7233
|
-
this.copyInto(staging, "tls", sources.tlsDir);
|
|
8937
|
+
if (captures("config")) this.copyInto(staging, "config/servers.config.json", sources.configPath);
|
|
8938
|
+
if (captures("secrets")) this.copyInto(staging, "secrets/control-secrets.json", sources.secretsPath);
|
|
8939
|
+
if (captures("tls")) this.copyInto(staging, "tls", sources.tlsDir);
|
|
7234
8940
|
const data = [];
|
|
7235
8941
|
for (const declared of this.paths) {
|
|
7236
|
-
if (!declared.included || !fs.existsSync(declared.path)) continue;
|
|
8942
|
+
if (!captures(`data:${declared.path}`) || !declared.included || !fs.existsSync(declared.path)) continue;
|
|
7237
8943
|
const slug = slugifyPath(declared.path);
|
|
7238
8944
|
if (data.some((entry) => entry.slug === slug)) continue;
|
|
7239
8945
|
this.copyInto(staging, path.join("data", slug), declared.path, declared.ignoreGenerated === true);
|
|
@@ -7243,6 +8949,16 @@ var init_backups = __esmMin((() => {
|
|
|
7243
8949
|
origin: declared.origin
|
|
7244
8950
|
});
|
|
7245
8951
|
}
|
|
8952
|
+
if (wanted !== null && fs.readdirSync(staging).length === 0) {
|
|
8953
|
+
fs.rmSync(staging, {
|
|
8954
|
+
recursive: true,
|
|
8955
|
+
force: true
|
|
8956
|
+
});
|
|
8957
|
+
return {
|
|
8958
|
+
ok: false,
|
|
8959
|
+
error: "nothing was selected to back up"
|
|
8960
|
+
};
|
|
8961
|
+
}
|
|
7246
8962
|
const manifest = {
|
|
7247
8963
|
version: 1,
|
|
7248
8964
|
createdAt,
|
|
@@ -7420,12 +9136,26 @@ var init_backups = __esmMin((() => {
|
|
|
7420
9136
|
});
|
|
7421
9137
|
}
|
|
7422
9138
|
const fromArchive = archiveTargets(restoredConfig);
|
|
7423
|
-
const
|
|
9139
|
+
const archiveCandidates = actions.has("config") ? fromArchive : [];
|
|
9140
|
+
const instanceCandidates = this.paths.filter((entry) => entry.included).map((entry) => ({
|
|
7424
9141
|
path: entry.path,
|
|
7425
9142
|
origin: entry.origin
|
|
7426
|
-
}))
|
|
9143
|
+
}));
|
|
9144
|
+
const candidates = [...archiveCandidates, ...instanceCandidates];
|
|
9145
|
+
const cursors = {
|
|
9146
|
+
archive: /* @__PURE__ */ new Map(),
|
|
9147
|
+
instance: /* @__PURE__ */ new Map()
|
|
9148
|
+
};
|
|
9149
|
+
const matchByOrigin = (list, cursor, origin) => {
|
|
9150
|
+
if (origin === void 0) return void 0;
|
|
9151
|
+
const same = list.filter((candidate) => candidate.origin === origin);
|
|
9152
|
+
if (same.length === 0) return void 0;
|
|
9153
|
+
const used = cursor.get(origin) ?? 0;
|
|
9154
|
+
cursor.set(origin, used + 1);
|
|
9155
|
+
return same[used] ?? same[0];
|
|
9156
|
+
};
|
|
7427
9157
|
for (const entry of manifest.data ?? []) {
|
|
7428
|
-
const target =
|
|
9158
|
+
const target = matchByOrigin(archiveCandidates, cursors.archive, entry.origin) ?? matchByOrigin(instanceCandidates, cursors.instance, entry.origin) ?? candidates.find((candidate) => candidate.path === entry.path);
|
|
7429
9159
|
const from = path.join(staging, "data", safeSlug(entry.slug) ?? slugifyPath(entry.path));
|
|
7430
9160
|
const common = {
|
|
7431
9161
|
id: `data:${entry.path}`,
|
|
@@ -7528,7 +9258,7 @@ var init_backups = __esmMin((() => {
|
|
|
7528
9258
|
const dir = this.resolveDir();
|
|
7529
9259
|
const listed = this.scan();
|
|
7530
9260
|
for (const file of listed) {
|
|
7531
|
-
const key = cacheKey(file);
|
|
9261
|
+
const key = cacheKey$1(file);
|
|
7532
9262
|
if (this.flags.get(file.name)?.key === key) continue;
|
|
7533
9263
|
try {
|
|
7534
9264
|
const entries = await listZip(path.join(dir, file.name));
|
|
@@ -7764,6 +9494,370 @@ var init_control_server = __esmMin((() => {
|
|
|
7764
9494
|
};
|
|
7765
9495
|
}));
|
|
7766
9496
|
//#endregion
|
|
9497
|
+
//#region src/providers/ddns/ip.ts
|
|
9498
|
+
/** RFC1918, loopback, link-local and CGNAT are never a public DNS target. */
|
|
9499
|
+
function isPublicAddress(ip, family) {
|
|
9500
|
+
if (family === "A") {
|
|
9501
|
+
const parts = ip.split(".").map((part) => Number.parseInt(part, 10));
|
|
9502
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
9503
|
+
const [a, b] = parts;
|
|
9504
|
+
return !(a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127);
|
|
9505
|
+
}
|
|
9506
|
+
const lower = ip.toLowerCase();
|
|
9507
|
+
if (lower === "::1" || lower === "::") return false;
|
|
9508
|
+
return !(lower.startsWith("fc") || lower.startsWith("fd") || /^fe[89ab]/.test(lower));
|
|
9509
|
+
}
|
|
9510
|
+
/**
|
|
9511
|
+
* Reads the address out of whatever the endpoint answered: a bare address, a
|
|
9512
|
+
* Cloudflare `cdn-cgi/trace` body (`ip=…`), or a small JSON object.
|
|
9513
|
+
*/
|
|
9514
|
+
function parseAddressResponse(text, family) {
|
|
9515
|
+
const trimmed = text.trim();
|
|
9516
|
+
if (trimmed.length === 0) return null;
|
|
9517
|
+
const candidates = [];
|
|
9518
|
+
const trace = /^ip=(.+)$/m.exec(trimmed)?.[1];
|
|
9519
|
+
if (trace !== void 0) candidates.push(trace.trim());
|
|
9520
|
+
if (trimmed.startsWith("{")) try {
|
|
9521
|
+
const parsed = JSON.parse(trimmed);
|
|
9522
|
+
for (const key of [
|
|
9523
|
+
"ip",
|
|
9524
|
+
"address",
|
|
9525
|
+
"ip_addr",
|
|
9526
|
+
"query"
|
|
9527
|
+
]) {
|
|
9528
|
+
const value = parsed[key];
|
|
9529
|
+
if (typeof value === "string") candidates.push(value.trim());
|
|
9530
|
+
}
|
|
9531
|
+
} catch {}
|
|
9532
|
+
candidates.push(trimmed);
|
|
9533
|
+
const wanted = family === "A" ? 4 : 6;
|
|
9534
|
+
for (const candidate of candidates) {
|
|
9535
|
+
const value = candidate.split(/[\s,]/)[0] ?? "";
|
|
9536
|
+
if (isIP(value) === wanted && isPublicAddress(value, family)) return value;
|
|
9537
|
+
}
|
|
9538
|
+
return null;
|
|
9539
|
+
}
|
|
9540
|
+
/**
|
|
9541
|
+
* Tries the configured endpoint, or the built-in list in order, and returns the
|
|
9542
|
+
* first address that is really public. A transient failure never deletes a
|
|
9543
|
+
* record; it just leaves the previous one in place.
|
|
9544
|
+
*/
|
|
9545
|
+
async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
|
|
9546
|
+
const configured = overrideUrl.trim();
|
|
9547
|
+
const endpoints = configured.length > 0 ? [configured] : family === "A" ? DEFAULT_IPV4_ENDPOINTS : DEFAULT_IPV6_ENDPOINTS;
|
|
9548
|
+
let lastError = null;
|
|
9549
|
+
for (const endpoint of endpoints) try {
|
|
9550
|
+
const response = await fetchImpl(endpoint, {
|
|
9551
|
+
signal,
|
|
9552
|
+
headers: { Accept: "text/plain, application/json" }
|
|
9553
|
+
});
|
|
9554
|
+
if (!response.ok) {
|
|
9555
|
+
lastError = `${endpoint} answered ${response.status}`;
|
|
9556
|
+
continue;
|
|
9557
|
+
}
|
|
9558
|
+
const parsed = parseAddressResponse(await response.text(), family);
|
|
9559
|
+
if (parsed !== null) return {
|
|
9560
|
+
ip: parsed,
|
|
9561
|
+
error: null
|
|
9562
|
+
};
|
|
9563
|
+
lastError = `${endpoint} did not answer with a public ${family === "A" ? "IPv4" : "IPv6"} address`;
|
|
9564
|
+
} catch (error) {
|
|
9565
|
+
lastError = `${endpoint}: ${error instanceof Error ? error.message : String(error)}`;
|
|
9566
|
+
}
|
|
9567
|
+
return {
|
|
9568
|
+
ip: null,
|
|
9569
|
+
error: lastError ?? `no endpoint for ${family}`
|
|
9570
|
+
};
|
|
9571
|
+
}
|
|
9572
|
+
var DEFAULT_IPV4_ENDPOINTS, DEFAULT_IPV6_ENDPOINTS;
|
|
9573
|
+
var init_ip = __esmMin((() => {
|
|
9574
|
+
DEFAULT_IPV4_ENDPOINTS = [
|
|
9575
|
+
"https://api4.ipify.org",
|
|
9576
|
+
"https://ipv4.icanhazip.com",
|
|
9577
|
+
"https://checkip.amazonaws.com",
|
|
9578
|
+
"https://4.ident.me",
|
|
9579
|
+
"https://cloudflare.com/cdn-cgi/trace"
|
|
9580
|
+
];
|
|
9581
|
+
DEFAULT_IPV6_ENDPOINTS = [
|
|
9582
|
+
"https://api6.ipify.org",
|
|
9583
|
+
"https://ipv6.icanhazip.com",
|
|
9584
|
+
"https://6.ident.me",
|
|
9585
|
+
"https://cloudflare.com/cdn-cgi/trace"
|
|
9586
|
+
];
|
|
9587
|
+
}));
|
|
9588
|
+
//#endregion
|
|
9589
|
+
//#region src/services/ddns.ts
|
|
9590
|
+
function emptyState() {
|
|
9591
|
+
return {
|
|
9592
|
+
version: 1,
|
|
9593
|
+
updatedAt: 0,
|
|
9594
|
+
records: {}
|
|
9595
|
+
};
|
|
9596
|
+
}
|
|
9597
|
+
function readState(file) {
|
|
9598
|
+
try {
|
|
9599
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
9600
|
+
const records = {};
|
|
9601
|
+
for (const [key, value] of Object.entries(parsed?.records ?? {})) if (typeof value?.ip === "string") records[key] = value;
|
|
9602
|
+
return {
|
|
9603
|
+
version: 1,
|
|
9604
|
+
updatedAt: typeof parsed?.updatedAt === "number" ? parsed.updatedAt : 0,
|
|
9605
|
+
records
|
|
9606
|
+
};
|
|
9607
|
+
} catch {
|
|
9608
|
+
return emptyState();
|
|
9609
|
+
}
|
|
9610
|
+
}
|
|
9611
|
+
function cacheKey(host, type) {
|
|
9612
|
+
return `${normalizeHost(host)}|${type}`;
|
|
9613
|
+
}
|
|
9614
|
+
var RUN_TIMEOUT_MS, DdnsService;
|
|
9615
|
+
var init_ddns = __esmMin((() => {
|
|
9616
|
+
init_atomic();
|
|
9617
|
+
init_logger();
|
|
9618
|
+
init_ddns$2();
|
|
9619
|
+
init_ip();
|
|
9620
|
+
init_types();
|
|
9621
|
+
RUN_TIMEOUT_MS = 6e4;
|
|
9622
|
+
DdnsService = class {
|
|
9623
|
+
deps;
|
|
9624
|
+
results = /* @__PURE__ */ new Map();
|
|
9625
|
+
persisted;
|
|
9626
|
+
running = false;
|
|
9627
|
+
disposed = false;
|
|
9628
|
+
/** Null until the first pass, so a fresh panel looks immediately. */
|
|
9629
|
+
lastStartedAt = null;
|
|
9630
|
+
lastRunAt = null;
|
|
9631
|
+
lastResult = null;
|
|
9632
|
+
ipv4 = null;
|
|
9633
|
+
ipv6 = null;
|
|
9634
|
+
constructor(deps) {
|
|
9635
|
+
this.deps = deps;
|
|
9636
|
+
this.persisted = readState(deps.statePath);
|
|
9637
|
+
}
|
|
9638
|
+
now() {
|
|
9639
|
+
return this.deps.now?.() ?? Date.now();
|
|
9640
|
+
}
|
|
9641
|
+
/** What the state frame and the settings page read. */
|
|
9642
|
+
get view() {
|
|
9643
|
+
return {
|
|
9644
|
+
enabled: this.deps.getConfig().enabled,
|
|
9645
|
+
running: this.running,
|
|
9646
|
+
lastRunAt: this.lastRunAt,
|
|
9647
|
+
lastResult: this.lastResult,
|
|
9648
|
+
ipv4: this.ipv4,
|
|
9649
|
+
ipv6: this.ipv6,
|
|
9650
|
+
records: this.records()
|
|
9651
|
+
};
|
|
9652
|
+
}
|
|
9653
|
+
/** One entry per configured target, with whatever the last pass learned. */
|
|
9654
|
+
records() {
|
|
9655
|
+
const config = this.deps.getConfig();
|
|
9656
|
+
const views = [];
|
|
9657
|
+
for (const domain of config.domains) {
|
|
9658
|
+
const account = config.accounts.find((entry) => entry.id === domain.account);
|
|
9659
|
+
const provider = account === void 0 ? null : ddnsProvider(account.provider);
|
|
9660
|
+
for (const type of domain.types) {
|
|
9661
|
+
const found = this.results.get(cacheKey(domain.host, type));
|
|
9662
|
+
views.push({
|
|
9663
|
+
host: domain.host,
|
|
9664
|
+
account: domain.account,
|
|
9665
|
+
provider: account?.provider ?? "",
|
|
9666
|
+
type,
|
|
9667
|
+
ip: found?.ip ?? this.persisted.records[cacheKey(domain.host, type)]?.ip ?? null,
|
|
9668
|
+
state: found?.state ?? (provider === null ? "skipped" : "pending"),
|
|
9669
|
+
message: found?.message ?? (account === void 0 ? `unknown account "${domain.account}"` : provider === null ? `unknown provider "${account.provider}"` : null),
|
|
9670
|
+
updatedAt: found?.updatedAt ?? null
|
|
9671
|
+
});
|
|
9672
|
+
}
|
|
9673
|
+
}
|
|
9674
|
+
return views;
|
|
9675
|
+
}
|
|
9676
|
+
/** Makes the next tick run immediately, after a config or credential save. */
|
|
9677
|
+
refresh() {
|
|
9678
|
+
this.lastStartedAt = null;
|
|
9679
|
+
}
|
|
9680
|
+
/**
|
|
9681
|
+
* Cheap enough to call every supervisor tick, and deliberately non-blocking:
|
|
9682
|
+
* a slow provider must never hold up health probes.
|
|
9683
|
+
*/
|
|
9684
|
+
tick(now = this.now()) {
|
|
9685
|
+
if (this.disposed || this.running) return;
|
|
9686
|
+
const config = this.deps.getConfig();
|
|
9687
|
+
if (!config.enabled) return;
|
|
9688
|
+
if (this.lastStartedAt !== null && now - this.lastStartedAt < config.intervalMs) return;
|
|
9689
|
+
this.lastStartedAt = now;
|
|
9690
|
+
this.run().catch((error) => {
|
|
9691
|
+
logger.error("ddns pass failed", error);
|
|
9692
|
+
});
|
|
9693
|
+
}
|
|
9694
|
+
/** Runs one pass now; `force` re-sends addresses the cache already knows. */
|
|
9695
|
+
async run(options = {}) {
|
|
9696
|
+
if (this.running || this.disposed) return this.view;
|
|
9697
|
+
this.running = true;
|
|
9698
|
+
const config = this.deps.getConfig();
|
|
9699
|
+
const signal = AbortSignal.timeout(RUN_TIMEOUT_MS);
|
|
9700
|
+
const fetchImpl = this.deps.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
9701
|
+
let changed = 0;
|
|
9702
|
+
let unchanged = 0;
|
|
9703
|
+
let failed = 0;
|
|
9704
|
+
let skipped = 0;
|
|
9705
|
+
try {
|
|
9706
|
+
const wanted = this.wantedFamilies(config);
|
|
9707
|
+
const detected = {
|
|
9708
|
+
ipv4: wanted.ipv4 ? await detectPublicAddress("A", config.ipv4.url, fetchImpl, signal) : {
|
|
9709
|
+
ip: null,
|
|
9710
|
+
error: null
|
|
9711
|
+
},
|
|
9712
|
+
ipv6: wanted.ipv6 ? await detectPublicAddress("AAAA", config.ipv6.url, fetchImpl, signal) : {
|
|
9713
|
+
ip: null,
|
|
9714
|
+
error: null
|
|
9715
|
+
}
|
|
9716
|
+
};
|
|
9717
|
+
if (detected.ipv4.ip !== null) this.ipv4 = detected.ipv4.ip;
|
|
9718
|
+
if (detected.ipv6.ip !== null) this.ipv6 = detected.ipv6.ip;
|
|
9719
|
+
if (detected.ipv4.error !== null) logger.warn(`ddns: could not detect the public IPv4 address (${detected.ipv4.error})`);
|
|
9720
|
+
if (detected.ipv6.error !== null) logger.warn(`ddns: could not detect the public IPv6 address (${detected.ipv6.error})`);
|
|
9721
|
+
for (const domain of config.domains) {
|
|
9722
|
+
if (!domain.enabled) continue;
|
|
9723
|
+
const account = config.accounts.find((entry) => entry.id === domain.account);
|
|
9724
|
+
const provider = account === void 0 ? null : ddnsProvider(account.provider);
|
|
9725
|
+
const credentials = account === void 0 ? null : this.deps.getCredentials(account.id, account.provider);
|
|
9726
|
+
for (const type of domain.types) {
|
|
9727
|
+
const key = cacheKey(domain.host, type);
|
|
9728
|
+
const family = type === "A" ? "ipv4" : "ipv6";
|
|
9729
|
+
const familyConfig = type === "A" ? config.ipv4 : config.ipv6;
|
|
9730
|
+
const address = type === "A" ? detected.ipv4.ip : detected.ipv6.ip;
|
|
9731
|
+
if (provider === null || account === void 0) {
|
|
9732
|
+
this.remember(key, "skipped", address, `unknown account or provider for "${domain.account}"`);
|
|
9733
|
+
skipped += 1;
|
|
9734
|
+
continue;
|
|
9735
|
+
}
|
|
9736
|
+
if (!familyConfig.enabled) {
|
|
9737
|
+
this.remember(key, "skipped", address, `${type} records need ${family} detection, which is off`);
|
|
9738
|
+
skipped += 1;
|
|
9739
|
+
continue;
|
|
9740
|
+
}
|
|
9741
|
+
if (address === null) {
|
|
9742
|
+
this.remember(key, "skipped", null, detected[family].error ?? `no public ${family} address was found`);
|
|
9743
|
+
skipped += 1;
|
|
9744
|
+
continue;
|
|
9745
|
+
}
|
|
9746
|
+
if (!provider.families.includes(type)) {
|
|
9747
|
+
this.remember(key, "skipped", address, `${provider.label} cannot manage ${type} records`);
|
|
9748
|
+
skipped += 1;
|
|
9749
|
+
continue;
|
|
9750
|
+
}
|
|
9751
|
+
if (!options.force && this.persisted.records[key]?.ip === address) {
|
|
9752
|
+
this.remember(key, "unchanged", address, null);
|
|
9753
|
+
unchanged += 1;
|
|
9754
|
+
continue;
|
|
9755
|
+
}
|
|
9756
|
+
const cache = this.persisted.records[key];
|
|
9757
|
+
const record = {
|
|
9758
|
+
host: domain.host,
|
|
9759
|
+
type,
|
|
9760
|
+
ip: address,
|
|
9761
|
+
ttl: domain.ttl ?? config.ttl,
|
|
9762
|
+
proxied: domain.proxied,
|
|
9763
|
+
...domain.zone === void 0 ? {} : { zone: domain.zone },
|
|
9764
|
+
...cache === void 0 ? {} : { cache: {
|
|
9765
|
+
...cache.zoneId === void 0 ? {} : { zoneId: cache.zoneId },
|
|
9766
|
+
...cache.recordId === void 0 ? {} : { recordId: cache.recordId }
|
|
9767
|
+
} }
|
|
9768
|
+
};
|
|
9769
|
+
const result = await provider.update(record, {
|
|
9770
|
+
credentials: credentials ?? {},
|
|
9771
|
+
fetch: fetchImpl,
|
|
9772
|
+
signal
|
|
9773
|
+
});
|
|
9774
|
+
if (result.ok) {
|
|
9775
|
+
this.persisted.records[key] = {
|
|
9776
|
+
ip: address,
|
|
9777
|
+
updatedAt: this.now(),
|
|
9778
|
+
...result.cache?.zoneId === void 0 ? {} : { zoneId: result.cache.zoneId },
|
|
9779
|
+
...result.cache?.recordId === void 0 ? {} : { recordId: result.cache.recordId }
|
|
9780
|
+
};
|
|
9781
|
+
this.remember(key, result.changed ? "ok" : "unchanged", address, result.message);
|
|
9782
|
+
if (result.changed) changed += 1;
|
|
9783
|
+
else unchanged += 1;
|
|
9784
|
+
} else {
|
|
9785
|
+
this.remember(key, "error", address, result.error ?? result.message);
|
|
9786
|
+
failed += 1;
|
|
9787
|
+
logger.warn(`ddns: ${domain.host} ${type}: ${result.error ?? result.message}`);
|
|
9788
|
+
}
|
|
9789
|
+
}
|
|
9790
|
+
}
|
|
9791
|
+
} finally {
|
|
9792
|
+
this.running = false;
|
|
9793
|
+
this.lastRunAt = this.now();
|
|
9794
|
+
this.persisted.updatedAt = this.lastRunAt;
|
|
9795
|
+
this.writeState();
|
|
9796
|
+
this.lastResult = this.describe(changed, unchanged, failed, skipped);
|
|
9797
|
+
this.announce({
|
|
9798
|
+
changed,
|
|
9799
|
+
failed
|
|
9800
|
+
});
|
|
9801
|
+
}
|
|
9802
|
+
return this.view;
|
|
9803
|
+
}
|
|
9804
|
+
/** Only the families a configured target actually asks for. */
|
|
9805
|
+
wantedFamilies(config) {
|
|
9806
|
+
return {
|
|
9807
|
+
ipv4: config.ipv4.enabled && config.domains.some((domain) => domain.enabled && domain.types.includes("A")),
|
|
9808
|
+
ipv6: config.ipv6.enabled && config.domains.some((domain) => domain.enabled && domain.types.includes("AAAA"))
|
|
9809
|
+
};
|
|
9810
|
+
}
|
|
9811
|
+
remember(key, state, ip, message) {
|
|
9812
|
+
this.results.set(key, {
|
|
9813
|
+
state,
|
|
9814
|
+
ip,
|
|
9815
|
+
message,
|
|
9816
|
+
updatedAt: this.now()
|
|
9817
|
+
});
|
|
9818
|
+
}
|
|
9819
|
+
/** Counts only: the detected addresses are shown beside it, and are the status's own. */
|
|
9820
|
+
describe(changed, unchanged, failed, skipped) {
|
|
9821
|
+
const parts = [
|
|
9822
|
+
changed > 0 ? `${changed} updated` : null,
|
|
9823
|
+
unchanged > 0 ? `${unchanged} already current` : null,
|
|
9824
|
+
failed > 0 ? `${failed} failed` : null,
|
|
9825
|
+
skipped > 0 ? `${skipped} skipped` : null
|
|
9826
|
+
].filter((part) => part !== null);
|
|
9827
|
+
return parts.length === 0 ? "nothing to update" : parts.join(", ");
|
|
9828
|
+
}
|
|
9829
|
+
/** One message per pass, so a flapping provider cannot flood the chat. */
|
|
9830
|
+
announce(counts) {
|
|
9831
|
+
if (counts.changed === 0 && counts.failed === 0) return;
|
|
9832
|
+
if (counts.failed > 0) {
|
|
9833
|
+
this.deps.notifications.notify({
|
|
9834
|
+
serverId: "ddns",
|
|
9835
|
+
label: "DDNS",
|
|
9836
|
+
reason: "ddns-error",
|
|
9837
|
+
detail: this.lastResult ?? "a dynamic DNS update failed"
|
|
9838
|
+
});
|
|
9839
|
+
return;
|
|
9840
|
+
}
|
|
9841
|
+
this.deps.notifications.notify({
|
|
9842
|
+
serverId: "ddns",
|
|
9843
|
+
label: "DDNS",
|
|
9844
|
+
reason: "ddns",
|
|
9845
|
+
detail: this.lastResult ?? "a dynamic DNS record changed"
|
|
9846
|
+
});
|
|
9847
|
+
}
|
|
9848
|
+
writeState() {
|
|
9849
|
+
try {
|
|
9850
|
+
writeFileAtomic(this.deps.statePath, `${JSON.stringify(this.persisted, null, 2)}\n`);
|
|
9851
|
+
} catch (error) {
|
|
9852
|
+
logger.warn(`ddns: could not save its state (${error instanceof Error ? error.message : String(error)})`);
|
|
9853
|
+
}
|
|
9854
|
+
}
|
|
9855
|
+
dispose() {
|
|
9856
|
+
this.disposed = true;
|
|
9857
|
+
}
|
|
9858
|
+
};
|
|
9859
|
+
}));
|
|
9860
|
+
//#endregion
|
|
7767
9861
|
//#region src/services/events.ts
|
|
7768
9862
|
var ALL, EventHub;
|
|
7769
9863
|
var init_events = __esmMin((() => {
|
|
@@ -8277,7 +10371,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8277
10371
|
"recovered": "recovered",
|
|
8278
10372
|
"rss": "exceeded its memory limit",
|
|
8279
10373
|
"host": "host thresholds breached",
|
|
8280
|
-
"host-recovered": "host thresholds recovered"
|
|
10374
|
+
"host-recovered": "host thresholds recovered",
|
|
10375
|
+
"ddns": "updated a dynamic DNS record",
|
|
10376
|
+
"ddns-error": "hit a dynamic DNS error"
|
|
8281
10377
|
};
|
|
8282
10378
|
TITLE = {
|
|
8283
10379
|
"crash": "🔴 server down",
|
|
@@ -8286,7 +10382,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8286
10382
|
"recovered": "🟢 server recovered",
|
|
8287
10383
|
"rss": "🔴 server over its memory limit",
|
|
8288
10384
|
"host": "🟠 host warning",
|
|
8289
|
-
"host-recovered": "🟢 host recovered"
|
|
10385
|
+
"host-recovered": "🟢 host recovered",
|
|
10386
|
+
"ddns": "🌐 DNS updated",
|
|
10387
|
+
"ddns-error": "🌐 DNS update failed"
|
|
8290
10388
|
};
|
|
8291
10389
|
NotificationService = class {
|
|
8292
10390
|
secrets;
|
|
@@ -8314,6 +10412,7 @@ var init_notifications = __esmMin((() => {
|
|
|
8314
10412
|
onForcedRestart: telegram.onForcedRestart,
|
|
8315
10413
|
onRecovered: telegram.onRecovered,
|
|
8316
10414
|
onHost: telegram.onHost,
|
|
10415
|
+
onDdns: telegram.onDdns,
|
|
8317
10416
|
cooldownMs: telegram.cooldownMs,
|
|
8318
10417
|
lastResult: this.lastResult,
|
|
8319
10418
|
lastResultAt: this.lastResultAt
|
|
@@ -8330,7 +10429,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8330
10429
|
"recovered": telegram.onRecovered,
|
|
8331
10430
|
"rss": telegram.onCrash,
|
|
8332
10431
|
"host": telegram.onHost,
|
|
8333
|
-
"host-recovered": telegram.onHost
|
|
10432
|
+
"host-recovered": telegram.onHost,
|
|
10433
|
+
"ddns": telegram.onDdns,
|
|
10434
|
+
"ddns-error": telegram.onDdns
|
|
8334
10435
|
}[event.reason]) return false;
|
|
8335
10436
|
const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
|
|
8336
10437
|
return !(telegram.cooldownMs > 0 && until > now);
|
|
@@ -9310,6 +11411,12 @@ async function runControlPlane(options) {
|
|
|
9310
11411
|
dataRoot,
|
|
9311
11412
|
home: os.homedir()
|
|
9312
11413
|
})), notifications);
|
|
11414
|
+
const ddns = new DdnsService({
|
|
11415
|
+
getConfig: () => store.config.ddns,
|
|
11416
|
+
getCredentials: (accountId, provider) => secrets.getDdnsCredentials(accountId, provider)?.values ?? null,
|
|
11417
|
+
notifications,
|
|
11418
|
+
statePath: defaultDdnsStatePath
|
|
11419
|
+
});
|
|
9313
11420
|
let onConfigRestored;
|
|
9314
11421
|
const backups = new BackupService({
|
|
9315
11422
|
dataRoot,
|
|
@@ -9393,6 +11500,7 @@ async function runControlPlane(options) {
|
|
|
9393
11500
|
notifications,
|
|
9394
11501
|
hostMonitor,
|
|
9395
11502
|
backups,
|
|
11503
|
+
ddns,
|
|
9396
11504
|
logsDir: logFiles.directory,
|
|
9397
11505
|
views
|
|
9398
11506
|
}),
|
|
@@ -9400,6 +11508,7 @@ async function runControlPlane(options) {
|
|
|
9400
11508
|
logFiles,
|
|
9401
11509
|
notifications,
|
|
9402
11510
|
hostMonitor,
|
|
11511
|
+
ddns,
|
|
9403
11512
|
nannyDir: defaultNannyDir
|
|
9404
11513
|
});
|
|
9405
11514
|
/**
|
|
@@ -9447,6 +11556,7 @@ async function runControlPlane(options) {
|
|
|
9447
11556
|
clearRuntime();
|
|
9448
11557
|
configWatch.dispose();
|
|
9449
11558
|
auth.dispose();
|
|
11559
|
+
ddns.dispose();
|
|
9450
11560
|
await supervisor.dispose();
|
|
9451
11561
|
logFiles.dispose();
|
|
9452
11562
|
history.dispose();
|
|
@@ -9464,6 +11574,7 @@ async function runControlPlane(options) {
|
|
|
9464
11574
|
logFiles,
|
|
9465
11575
|
notifications,
|
|
9466
11576
|
backups,
|
|
11577
|
+
ddns,
|
|
9467
11578
|
ui,
|
|
9468
11579
|
runtimeToken: token,
|
|
9469
11580
|
onShutdown: () => shutdown("shutdown requested locally")
|
|
@@ -9543,6 +11654,7 @@ var init_src = __esmMin((() => {
|
|
|
9543
11654
|
init_backups();
|
|
9544
11655
|
init_config_watch();
|
|
9545
11656
|
init_control_server();
|
|
11657
|
+
init_ddns();
|
|
9546
11658
|
init_events();
|
|
9547
11659
|
init_exposure();
|
|
9548
11660
|
init_history();
|