home-hosted 0.6.6 → 0.6.7
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 +22 -18
- package/README.md +16 -6
- package/dist/cli.js +2501 -626
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +80 -0
- package/docs/NOTIFICATIONS.md +3 -0
- package/package.json +2 -1
- package/uis/stock/dist/assets/{index-B8A85L38.js → index-C6tbSbCK.js} +7 -7
- package/uis/stock/dist/assets/index-Do5DvzS4.css +2 -0
- 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, 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,95 @@ 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
|
+
/** Cloudflare only. */
|
|
557
|
+
proxied: "boolean?",
|
|
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
|
+
/** Default proxy flag, where the provider has one (Cloudflare). */
|
|
578
|
+
proxied: "boolean = false",
|
|
579
|
+
/** Tell Telegram about a change, and about an update that failed. */
|
|
580
|
+
notify: "boolean = true",
|
|
581
|
+
accounts: ddnsAccountSchema.array().default(() => []),
|
|
582
|
+
domains: ddnsDomainSchema.array().default(() => [])
|
|
583
|
+
}).onUndeclaredKey("reject");
|
|
584
|
+
ddnsRecordStateSchema = type("\"pending\" | \"ok\" | \"unchanged\" | \"error\" | \"skipped\"");
|
|
585
|
+
ddnsRecordViewSchema = type({
|
|
586
|
+
host: "string",
|
|
587
|
+
account: "string",
|
|
588
|
+
provider: "string",
|
|
589
|
+
type: ddnsRecordTypeSchema,
|
|
590
|
+
ip: "string | null",
|
|
591
|
+
state: ddnsRecordStateSchema,
|
|
592
|
+
message: "string | null",
|
|
593
|
+
updatedAt: "number | null"
|
|
594
|
+
});
|
|
595
|
+
ddnsStatusSchema = type({
|
|
596
|
+
enabled: "boolean",
|
|
597
|
+
running: "boolean",
|
|
598
|
+
lastRunAt: "number | null",
|
|
599
|
+
lastResult: "string | null",
|
|
600
|
+
ipv4: "string | null",
|
|
601
|
+
ipv6: "string | null",
|
|
602
|
+
records: ddnsRecordViewSchema.array()
|
|
603
|
+
});
|
|
604
|
+
ddnsProviderFieldSchema = type({
|
|
605
|
+
key: "string",
|
|
606
|
+
label: "string",
|
|
607
|
+
hint: "string = \"\"",
|
|
608
|
+
optional: "boolean = false"
|
|
609
|
+
});
|
|
610
|
+
ddnsProviderInfoSchema = type({
|
|
611
|
+
id: "string",
|
|
612
|
+
label: "string",
|
|
613
|
+
docsUrl: "string",
|
|
614
|
+
families: ddnsRecordTypeSchema.array(),
|
|
615
|
+
fields: ddnsProviderFieldSchema.array(),
|
|
616
|
+
ttl: "boolean",
|
|
617
|
+
proxied: "boolean"
|
|
618
|
+
});
|
|
619
|
+
ddnsViewSchema = type({
|
|
620
|
+
config: ddnsConfigSchema,
|
|
621
|
+
status: ddnsStatusSchema,
|
|
622
|
+
providers: ddnsProviderInfoSchema.array(),
|
|
623
|
+
/** Account ids whose credentials are stored. */
|
|
624
|
+
credentials: type("string[]")
|
|
625
|
+
});
|
|
626
|
+
ddnsCredentialsSchema = type({ credentials: type("Record<string, string>") }).onUndeclaredKey("reject");
|
|
536
627
|
backupsSchema = type({
|
|
537
628
|
enabled: "boolean = true",
|
|
538
629
|
dir: "string = \".backups\"",
|
|
@@ -608,6 +699,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
608
699
|
onForcedRestart: "boolean?",
|
|
609
700
|
onRecovered: "boolean?",
|
|
610
701
|
onHost: "boolean?",
|
|
702
|
+
onDdns: "boolean?",
|
|
611
703
|
cooldownMs: "number >= 0?"
|
|
612
704
|
}).onUndeclaredKey("reject");
|
|
613
705
|
notificationsPatchSchema = type({ telegram: telegramPatchSchema.optional() }).onUndeclaredKey("reject");
|
|
@@ -762,6 +854,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
762
854
|
onRecovered: "boolean",
|
|
763
855
|
/** Host vitals breaches (disk, memory, swap, load, temperature). */
|
|
764
856
|
onHost: "boolean",
|
|
857
|
+
/** Dynamic DNS, when this panel knows about it (optional for an older panel). */
|
|
858
|
+
onDdns: "boolean?",
|
|
765
859
|
cooldownMs: "number",
|
|
766
860
|
/** Last delivery outcome, for the settings page. */
|
|
767
861
|
lastResult: "string | null",
|
|
@@ -956,6 +1050,11 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
956
1050
|
/** `HHOSTED_HOME`: every file home-hosted owns lives under here. */
|
|
957
1051
|
"dataRoot": "string",
|
|
958
1052
|
"logsDir": "string",
|
|
1053
|
+
/**
|
|
1054
|
+
* Dynamic DNS state. Optional so a newer UI still frames a state a panel from
|
|
1055
|
+
* before this field served: the app would blank on a required key.
|
|
1056
|
+
*/
|
|
1057
|
+
"ddns?": ddnsStatusSchema,
|
|
959
1058
|
/** The release this panel is running. Optional: an older panel does not send one. */
|
|
960
1059
|
"version?": "string",
|
|
961
1060
|
"servers": serverViewSchema.array()
|
|
@@ -1192,6 +1291,21 @@ function apiTokenRecord(token, now = Date.now()) {
|
|
|
1192
1291
|
updatedAt: now
|
|
1193
1292
|
};
|
|
1194
1293
|
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Only account → string map → string survives; a hand-edited file with a number
|
|
1296
|
+
* or a nested object in there reads as "no credentials" instead of throwing.
|
|
1297
|
+
*/
|
|
1298
|
+
function readDdnsCredentials(value) {
|
|
1299
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
1300
|
+
const out = {};
|
|
1301
|
+
for (const [accountId, fields] of Object.entries(value)) {
|
|
1302
|
+
if (typeof fields !== "object" || fields === null || Array.isArray(fields)) continue;
|
|
1303
|
+
const record = {};
|
|
1304
|
+
for (const [key, field] of Object.entries(fields)) if (typeof field === "string" && field.length > 0) record[key] = field;
|
|
1305
|
+
if (Object.keys(record).length > 0) out[accountId] = record;
|
|
1306
|
+
}
|
|
1307
|
+
return out;
|
|
1308
|
+
}
|
|
1195
1309
|
var COST, KEYLEN, SALT_BYTES, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
|
|
1196
1310
|
var init_secrets = __esmMin((() => {
|
|
1197
1311
|
init_atomic();
|
|
@@ -1302,12 +1416,38 @@ var init_secrets = __esmMin((() => {
|
|
|
1302
1416
|
telegram: trimmed.length > 0 ? { botToken: trimmed } : null
|
|
1303
1417
|
});
|
|
1304
1418
|
}
|
|
1419
|
+
getDdnsCredentials(accountId) {
|
|
1420
|
+
return this.load().ddns[accountId] ?? null;
|
|
1421
|
+
}
|
|
1422
|
+
get ddnsAccountIds() {
|
|
1423
|
+
return Object.keys(this.load().ddns);
|
|
1424
|
+
}
|
|
1425
|
+
/** An empty field is dropped, so clearing every field forgets the account. */
|
|
1426
|
+
setDdnsCredentials(accountId, credentials) {
|
|
1427
|
+
const entries = Object.entries(credentials).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value.length > 0);
|
|
1428
|
+
const ddns = { ...this.load().ddns };
|
|
1429
|
+
if (entries.length === 0) delete ddns[accountId];
|
|
1430
|
+
else ddns[accountId] = Object.fromEntries(entries);
|
|
1431
|
+
this.save({
|
|
1432
|
+
...this.load(),
|
|
1433
|
+
ddns
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
clearDdnsCredentials(accountId) {
|
|
1437
|
+
const ddns = { ...this.load().ddns };
|
|
1438
|
+
delete ddns[accountId];
|
|
1439
|
+
this.save({
|
|
1440
|
+
...this.load(),
|
|
1441
|
+
ddns
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1305
1444
|
read() {
|
|
1306
1445
|
if (!fs.existsSync(this.file)) return {
|
|
1307
1446
|
version: 3,
|
|
1308
1447
|
password: null,
|
|
1309
1448
|
apiToken: null,
|
|
1310
|
-
telegram: null
|
|
1449
|
+
telegram: null,
|
|
1450
|
+
ddns: {}
|
|
1311
1451
|
};
|
|
1312
1452
|
try {
|
|
1313
1453
|
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
@@ -1315,14 +1455,16 @@ var init_secrets = __esmMin((() => {
|
|
|
1315
1455
|
version: 3,
|
|
1316
1456
|
password: parsed?.password ?? null,
|
|
1317
1457
|
apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
|
|
1318
|
-
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null
|
|
1458
|
+
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
|
|
1459
|
+
ddns: readDdnsCredentials(parsed?.ddns)
|
|
1319
1460
|
};
|
|
1320
1461
|
} catch {
|
|
1321
1462
|
return {
|
|
1322
1463
|
version: 3,
|
|
1323
1464
|
password: null,
|
|
1324
1465
|
apiToken: null,
|
|
1325
|
-
telegram: null
|
|
1466
|
+
telegram: null,
|
|
1467
|
+
ddns: {}
|
|
1326
1468
|
};
|
|
1327
1469
|
}
|
|
1328
1470
|
}
|
|
@@ -1866,6 +2008,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
1866
2008
|
daemonLogPath: () => daemonLogPath,
|
|
1867
2009
|
dataRoot: () => dataRoot,
|
|
1868
2010
|
defaultConfigPath: () => defaultConfigPath,
|
|
2011
|
+
defaultDdnsStatePath: () => defaultDdnsStatePath,
|
|
1869
2012
|
defaultHistoryPath: () => defaultHistoryPath,
|
|
1870
2013
|
defaultLogsDir: () => defaultLogsDir,
|
|
1871
2014
|
defaultNannyDir: () => defaultNannyDir,
|
|
@@ -1906,7 +2049,7 @@ function resolveUserPath(target, base = projectDir) {
|
|
|
1906
2049
|
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
1907
2050
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
1908
2051
|
}
|
|
1909
|
-
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, runtimePath, daemonLogPath;
|
|
2052
|
+
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, defaultDdnsStatePath, runtimePath, daemonLogPath;
|
|
1910
2053
|
var init_paths = __esmMin((() => {
|
|
1911
2054
|
dataRoot = resolveDataRoot();
|
|
1912
2055
|
projectDir = resolveProjectDir();
|
|
@@ -1917,6 +2060,7 @@ var init_paths = __esmMin((() => {
|
|
|
1917
2060
|
defaultHistoryPath = path.join(dataRoot, ".logs", "history.json");
|
|
1918
2061
|
defaultTlsDir = path.join(dataRoot, ".tls");
|
|
1919
2062
|
defaultNannyDir = path.join(dataRoot, ".state");
|
|
2063
|
+
defaultDdnsStatePath = path.join(dataRoot, ".state", "ddns.json");
|
|
1920
2064
|
runtimePath = path.join(dataRoot, "run.json");
|
|
1921
2065
|
daemonLogPath = path.join(dataRoot, ".logs", "home-hosted.log");
|
|
1922
2066
|
}));
|
|
@@ -1981,6 +2125,7 @@ function buildAppState(deps) {
|
|
|
1981
2125
|
notifications: { telegram: deps.notifications.status() },
|
|
1982
2126
|
host: buildHostView(deps.hostMonitor),
|
|
1983
2127
|
backups: buildBackupsView(deps.store, deps.backups),
|
|
2128
|
+
ddns: deps.ddns.view,
|
|
1984
2129
|
configPath: deps.store.path,
|
|
1985
2130
|
configError: deps.store.configError,
|
|
1986
2131
|
projectDir,
|
|
@@ -2252,577 +2397,140 @@ var init_control = __esmMin((() => {
|
|
|
2252
2397
|
init_loopback();
|
|
2253
2398
|
}));
|
|
2254
2399
|
//#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
|
-
});
|
|
2400
|
+
//#region src/config/migrations.ts
|
|
2401
|
+
/** What would have to run to bring `from` up to `to`. */
|
|
2402
|
+
function planConfigMigrations(from, options = {}) {
|
|
2403
|
+
const to = options.to ?? 1;
|
|
2404
|
+
return {
|
|
2405
|
+
from,
|
|
2406
|
+
to,
|
|
2407
|
+
steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
|
|
2408
|
+
tooNew: from > to
|
|
2409
|
+
};
|
|
2310
2410
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2411
|
+
/** Applies the plan in order; the caller owns writing the result. */
|
|
2412
|
+
function applyConfigMigrations(config, from, options = {}) {
|
|
2413
|
+
const { steps } = planConfigMigrations(from, options);
|
|
2414
|
+
let current = config;
|
|
2415
|
+
for (const step of steps) current = step.apply(current);
|
|
2416
|
+
return {
|
|
2417
|
+
config: current,
|
|
2418
|
+
applied: steps
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
var configMigrations;
|
|
2422
|
+
var init_migrations = __esmMin((() => {
|
|
2423
|
+
configMigrations = [];
|
|
2323
2424
|
}));
|
|
2324
2425
|
//#endregion
|
|
2325
|
-
//#region src/
|
|
2426
|
+
//#region src/config/schema.ts
|
|
2427
|
+
function isRecord$2(value) {
|
|
2428
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2429
|
+
}
|
|
2326
2430
|
/**
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2431
|
+
* Resolves one entry against the panel's server defaults. A group (`restart`,
|
|
2432
|
+
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
2433
|
+
* member does not silently fall back to the *schema* default for the others — which
|
|
2434
|
+
* is the whole point of the panel having defaults at all.
|
|
2331
2435
|
*/
|
|
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
|
-
});
|
|
2436
|
+
function mergeDefaults(defaults, entry) {
|
|
2437
|
+
const merged = { ...entry };
|
|
2438
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
2439
|
+
const current = merged[key];
|
|
2440
|
+
if (current === void 0) merged[key] = value;
|
|
2441
|
+
else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
|
|
2442
|
+
}
|
|
2443
|
+
return merged;
|
|
2371
2444
|
}
|
|
2372
|
-
var
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2445
|
+
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2446
|
+
var init_schema = __esmMin((() => {
|
|
2447
|
+
init_contracts();
|
|
2448
|
+
metaSchema = type({
|
|
2449
|
+
writtenBy: "string = \"\"",
|
|
2450
|
+
schema: "number.integer >= 1 = 1"
|
|
2451
|
+
}).onUndeclaredKey("reject");
|
|
2452
|
+
configSchema = type({
|
|
2453
|
+
$schema: "string?",
|
|
2454
|
+
meta: metaSchema.optional(),
|
|
2455
|
+
control: controlSchema.default(() => ({})),
|
|
2456
|
+
defaults: defaultsSchema.default(() => ({})),
|
|
2457
|
+
logs: logsSchema.default(() => ({})),
|
|
2458
|
+
notifications: notificationsSchema.default(() => ({})),
|
|
2459
|
+
host: hostSchema.default(() => ({})),
|
|
2460
|
+
backups: backupsSchema.default(() => ({})),
|
|
2461
|
+
ddns: ddnsConfigSchema.default(() => ({})),
|
|
2462
|
+
servers: serverSchema.array().default(() => [])
|
|
2463
|
+
}).onUndeclaredKey("reject");
|
|
2464
|
+
CONFIG_KEYS = [
|
|
2465
|
+
"$schema",
|
|
2466
|
+
"meta",
|
|
2467
|
+
"control",
|
|
2468
|
+
"defaults",
|
|
2469
|
+
"logs",
|
|
2470
|
+
"notifications",
|
|
2471
|
+
"host",
|
|
2472
|
+
"backups",
|
|
2473
|
+
"ddns",
|
|
2474
|
+
"servers"
|
|
2475
|
+
];
|
|
2376
2476
|
}));
|
|
2377
2477
|
//#endregion
|
|
2378
|
-
//#region src/
|
|
2379
|
-
function
|
|
2380
|
-
return
|
|
2381
|
-
statusCode: 404,
|
|
2382
|
-
code: "UNKNOWN_SERVER"
|
|
2383
|
-
});
|
|
2478
|
+
//#region src/config/parse.ts
|
|
2479
|
+
function isRecord$1(value) {
|
|
2480
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2384
2481
|
}
|
|
2385
|
-
function
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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
|
-
});
|
|
2482
|
+
function deleteAtPath(root, path) {
|
|
2483
|
+
let node = root;
|
|
2484
|
+
for (const key of path.slice(0, -1)) {
|
|
2485
|
+
node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
|
|
2486
|
+
if (node === void 0) return;
|
|
2487
|
+
}
|
|
2488
|
+
const last = path[path.length - 1];
|
|
2489
|
+
if (last === void 0) return;
|
|
2490
|
+
if (isRecord$1(node)) delete node[String(last)];
|
|
2491
|
+
else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
|
|
2463
2492
|
}
|
|
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();
|
|
2469
|
-
init_contracts();
|
|
2470
|
-
MIN_TAIL = 50;
|
|
2471
|
-
MAX_TAIL = 5e3;
|
|
2472
|
-
DEFAULT_TAIL = 500;
|
|
2473
|
-
idParam$1 = type({ id: "string >= 1" });
|
|
2474
|
-
downloadQuery = type({ "file?": "string" });
|
|
2475
|
-
}));
|
|
2476
|
-
//#endregion
|
|
2477
|
-
//#region src/api/metrics.ts
|
|
2478
2493
|
/**
|
|
2479
|
-
*
|
|
2480
|
-
*
|
|
2494
|
+
* Validates one object against one schema, tolerating keys the schema does not
|
|
2495
|
+
* know: a config written by a newer release has to keep working here, and losing
|
|
2496
|
+
* the *whole group* to schema defaults would silently reset the listener port,
|
|
2497
|
+
* the bind and the auth policy over a single unrecognized key.
|
|
2498
|
+
*
|
|
2499
|
+
* Only unrecognized keys are dropped, and each one is reported. Anything else is
|
|
2500
|
+
* a real problem, returned for the caller to refuse on.
|
|
2481
2501
|
*/
|
|
2482
|
-
function
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
const lines = [];
|
|
2490
|
-
const metric = (name, help, samples) => {
|
|
2491
|
-
if (samples.length === 0) return;
|
|
2492
|
-
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
2493
|
-
};
|
|
2494
|
-
metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
|
|
2495
|
-
metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
|
|
2496
|
-
metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
|
|
2497
|
-
metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
|
|
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();
|
|
2512
|
-
}));
|
|
2513
|
-
//#endregion
|
|
2514
|
-
//#region src/providers/telegram.ts
|
|
2515
|
-
function getBot(token) {
|
|
2516
|
-
const cached = bots.get(token);
|
|
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}` : ""}`;
|
|
2536
|
-
}
|
|
2537
|
-
/** Turns a grammY error into something worth showing in the settings page. */
|
|
2538
|
-
function describeTelegramError(error) {
|
|
2539
|
-
if (typeof error === "object" && error !== null) {
|
|
2540
|
-
const candidate = error;
|
|
2541
|
-
const description = candidate.description ?? candidate.message;
|
|
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`}`;
|
|
2543
|
-
}
|
|
2544
|
-
return error instanceof Error ? error.message : String(error);
|
|
2545
|
-
}
|
|
2546
|
-
async function sendTelegramMessage(token, chatId, html) {
|
|
2547
|
-
try {
|
|
2548
|
-
await getBot(token).api.sendMessage(chatId, html, {
|
|
2549
|
-
parse_mode: "HTML",
|
|
2550
|
-
link_preview_options: { is_disabled: true }
|
|
2551
|
-
});
|
|
2552
|
-
return { ok: true };
|
|
2553
|
-
} catch (error) {
|
|
2554
|
-
return {
|
|
2555
|
-
ok: false,
|
|
2556
|
-
error: describeTelegramError(error)
|
|
2502
|
+
function parseTolerant(value, schema, prefix, unknownKeys) {
|
|
2503
|
+
const candidate = structuredClone(value);
|
|
2504
|
+
for (let pass = 0; pass < 25; pass++) {
|
|
2505
|
+
const parsed = schema(candidate);
|
|
2506
|
+
if (!(parsed instanceof type.errors)) return {
|
|
2507
|
+
value: parsed,
|
|
2508
|
+
error: null
|
|
2557
2509
|
};
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
return {
|
|
2563
|
-
ok: true,
|
|
2564
|
-
username: (await getBot(token).api.getMe()).username
|
|
2510
|
+
const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
|
|
2511
|
+
if (removable.length === 0) return {
|
|
2512
|
+
value: null,
|
|
2513
|
+
error: parsed.summary
|
|
2565
2514
|
};
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
error: describeTelegramError(error)
|
|
2515
|
+
if (!isRecord$1(candidate)) return {
|
|
2516
|
+
value: null,
|
|
2517
|
+
error: parsed.summary
|
|
2570
2518
|
};
|
|
2571
|
-
|
|
2572
|
-
}
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
* hunted down by hand. Telegram only reports chats with pending updates, so the
|
|
2576
|
-
* caller is told to message the bot first.
|
|
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
|
-
});
|
|
2519
|
+
for (const problem of removable) {
|
|
2520
|
+
const at = `${prefix}.${problem.path.join(".")}`;
|
|
2521
|
+
if (!unknownKeys.includes(at)) unknownKeys.push(at);
|
|
2522
|
+
deleteAtPath(candidate, problem.path);
|
|
2597
2523
|
}
|
|
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
2524
|
}
|
|
2525
|
+
return {
|
|
2526
|
+
value: null,
|
|
2527
|
+
error: "too many unrecognized keys to ignore"
|
|
2528
|
+
};
|
|
2609
2529
|
}
|
|
2610
|
-
var bots;
|
|
2611
|
-
var init_telegram = __esmMin((() => {
|
|
2612
|
-
bots = /* @__PURE__ */ new Map();
|
|
2613
|
-
}));
|
|
2614
|
-
//#endregion
|
|
2615
|
-
//#region src/api/notifications.ts
|
|
2616
2530
|
/**
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
2619
|
-
*
|
|
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.
|
|
2531
|
+
* Reads a `servers.config.json` from any release. Unrecognized keys are reported
|
|
2532
|
+
* and left on disk; blocking problems — including a file this release cannot
|
|
2533
|
+
* understand — land in `errors` and leave `config` null.
|
|
2826
2534
|
*/
|
|
2827
2535
|
function parseConfig(raw, options = {}) {
|
|
2828
2536
|
const unknownKeys = [];
|
|
@@ -2896,6 +2604,7 @@ function parseConfig(raw, options = {}) {
|
|
|
2896
2604
|
notifications: groups.notifications,
|
|
2897
2605
|
host: groups.host,
|
|
2898
2606
|
backups: groups.backups,
|
|
2607
|
+
ddns: groups.ddns,
|
|
2899
2608
|
servers
|
|
2900
2609
|
};
|
|
2901
2610
|
return result;
|
|
@@ -2946,7 +2655,8 @@ var init_parse = __esmMin((() => {
|
|
|
2946
2655
|
["logs", logsSchema],
|
|
2947
2656
|
["notifications", notificationsSchema],
|
|
2948
2657
|
["host", hostSchema],
|
|
2949
|
-
["backups", backupsSchema]
|
|
2658
|
+
["backups", backupsSchema],
|
|
2659
|
+
["ddns", ddnsConfigSchema]
|
|
2950
2660
|
];
|
|
2951
2661
|
UNDECLARED = "must be removed";
|
|
2952
2662
|
}));
|
|
@@ -3212,6 +2922,18 @@ var init_store = __esmMin((() => {
|
|
|
3212
2922
|
this.commit(draft);
|
|
3213
2923
|
return backups;
|
|
3214
2924
|
}
|
|
2925
|
+
/**
|
|
2926
|
+
* The DDNS block is replaced as a whole: its accounts and domains are lists,
|
|
2927
|
+
* and a key-by-key merge would leave a removed account or hostname behind.
|
|
2928
|
+
*/
|
|
2929
|
+
updateDdns(config) {
|
|
2930
|
+
const parsed = ddnsConfigSchema(config);
|
|
2931
|
+
if (parsed instanceof type.errors) throw new ConfigError(`ddns: ${formatErrors(parsed)}`);
|
|
2932
|
+
const draft = structuredClone(this.raw);
|
|
2933
|
+
draft.ddns = parsed;
|
|
2934
|
+
this.commit(draft);
|
|
2935
|
+
return parsed;
|
|
2936
|
+
}
|
|
3215
2937
|
updateDefaults(patch) {
|
|
3216
2938
|
const draft = structuredClone(this.raw);
|
|
3217
2939
|
draft.defaults = { ...draft.defaults ?? {} };
|
|
@@ -3251,44 +2973,1760 @@ var init_store = __esmMin((() => {
|
|
|
3251
2973
|
port: parsed.port ?? null
|
|
3252
2974
|
};
|
|
3253
2975
|
}
|
|
3254
|
-
commit(draft) {
|
|
3255
|
-
const stamped = stampConfig(draft);
|
|
3256
|
-
const text = `${JSON.stringify(stamped, null, 2)}\n`;
|
|
3257
|
-
writeFileAtomic(this.file, text);
|
|
3258
|
-
this.lastText = text;
|
|
3259
|
-
this.raw = stamped;
|
|
3260
|
-
this.apply(stamped);
|
|
3261
|
-
this.notify();
|
|
2976
|
+
commit(draft) {
|
|
2977
|
+
const stamped = stampConfig(draft);
|
|
2978
|
+
const text = `${JSON.stringify(stamped, null, 2)}\n`;
|
|
2979
|
+
writeFileAtomic(this.file, text);
|
|
2980
|
+
this.lastText = text;
|
|
2981
|
+
this.raw = stamped;
|
|
2982
|
+
this.apply(stamped);
|
|
2983
|
+
this.notify();
|
|
2984
|
+
}
|
|
2985
|
+
resolveFallback() {
|
|
2986
|
+
const control = controlSchema({});
|
|
2987
|
+
const defaults = defaultsSchema({});
|
|
2988
|
+
if (control instanceof type.errors || defaults instanceof type.errors) throw new ConfigError("internal: default config failed validation");
|
|
2989
|
+
const logs = logsSchema({});
|
|
2990
|
+
const notifications = notificationsSchema({});
|
|
2991
|
+
const host = hostSchema({});
|
|
2992
|
+
const backups = backupsSchema({});
|
|
2993
|
+
const ddns = ddnsConfigSchema({});
|
|
2994
|
+
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");
|
|
2995
|
+
return {
|
|
2996
|
+
control,
|
|
2997
|
+
defaults,
|
|
2998
|
+
logs,
|
|
2999
|
+
notifications,
|
|
3000
|
+
host,
|
|
3001
|
+
backups,
|
|
3002
|
+
ddns,
|
|
3003
|
+
servers: []
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
apply(raw) {
|
|
3007
|
+
this.raw = raw;
|
|
3008
|
+
const parsed = parseConfig(raw);
|
|
3009
|
+
this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
|
|
3010
|
+
this.schemaVersion = parsed.schemaVersion;
|
|
3011
|
+
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(", ")}`]];
|
|
3012
|
+
if (parsed.config !== null) this.resolvedConfig = parsed.config;
|
|
3013
|
+
else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
|
|
3014
|
+
}
|
|
3015
|
+
};
|
|
3016
|
+
}));
|
|
3017
|
+
//#endregion
|
|
3018
|
+
//#region src/providers/ddns/types.ts
|
|
3019
|
+
function succeeded(message, changed) {
|
|
3020
|
+
return {
|
|
3021
|
+
ok: true,
|
|
3022
|
+
changed,
|
|
3023
|
+
message
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
function failed(error) {
|
|
3027
|
+
return {
|
|
3028
|
+
ok: false,
|
|
3029
|
+
changed: false,
|
|
3030
|
+
message: error,
|
|
3031
|
+
error
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
/** Missing required credential keys, as a sentence for the settings page. */
|
|
3035
|
+
function missingFields(credentials, keys) {
|
|
3036
|
+
const missing = keys.filter((key) => (credentials?.[key] ?? "").trim().length === 0);
|
|
3037
|
+
return missing.length === 0 ? null : `set ${missing.join(", ")}`;
|
|
3038
|
+
}
|
|
3039
|
+
function basicAuth(user, password) {
|
|
3040
|
+
return `Basic ${Buffer$1.from(`${user}:${password}`, "utf8").toString("base64")}`;
|
|
3041
|
+
}
|
|
3042
|
+
/** A provider error body is useful, but never worth throwing over. */
|
|
3043
|
+
async function errorText(response, limit = 300) {
|
|
3044
|
+
try {
|
|
3045
|
+
const text = (await response.text()).replace(/\s+/g, " ").trim();
|
|
3046
|
+
return text.length === 0 ? `HTTP ${response.status}` : `HTTP ${response.status}: ${text.slice(0, limit)}`;
|
|
3047
|
+
} catch {
|
|
3048
|
+
return `HTTP ${response.status}`;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
/** `Sub.Domain.COM.` -> `sub.domain.com`; DuckDNS and friends are case-insensitive. */
|
|
3052
|
+
function normalizeHost(host) {
|
|
3053
|
+
return host.trim().toLowerCase().replace(NORMALIZE_DOTS, "");
|
|
3054
|
+
}
|
|
3055
|
+
var NORMALIZE_DOTS;
|
|
3056
|
+
var init_types = __esmMin((() => {
|
|
3057
|
+
NORMALIZE_DOTS = /\.$/;
|
|
3058
|
+
}));
|
|
3059
|
+
//#endregion
|
|
3060
|
+
//#region src/providers/ddns/cloudflare.ts
|
|
3061
|
+
function authHeaders(credentials) {
|
|
3062
|
+
const token = (credentials.apiToken ?? "").trim();
|
|
3063
|
+
if (token.length > 0) return { Authorization: `Bearer ${token}` };
|
|
3064
|
+
const email = (credentials.email ?? "").trim();
|
|
3065
|
+
const key = (credentials.apiKey ?? "").trim();
|
|
3066
|
+
if (email.length > 0 && key.length > 0) return {
|
|
3067
|
+
"X-Auth-Email": email,
|
|
3068
|
+
"X-Auth-Key": key
|
|
3069
|
+
};
|
|
3070
|
+
return null;
|
|
3071
|
+
}
|
|
3072
|
+
async function call(path, headers, context, init = {}) {
|
|
3073
|
+
try {
|
|
3074
|
+
const response = await context.fetch(`${API$5}${path}`, {
|
|
3075
|
+
...init,
|
|
3076
|
+
headers: {
|
|
3077
|
+
...headers,
|
|
3078
|
+
"Content-Type": "application/json",
|
|
3079
|
+
...init.headers ?? {}
|
|
3080
|
+
},
|
|
3081
|
+
signal: context.signal
|
|
3082
|
+
});
|
|
3083
|
+
const text = await response.text();
|
|
3084
|
+
let parsed;
|
|
3085
|
+
try {
|
|
3086
|
+
parsed = JSON.parse(text);
|
|
3087
|
+
} catch {
|
|
3088
|
+
return {
|
|
3089
|
+
data: null,
|
|
3090
|
+
status: response.status,
|
|
3091
|
+
error: `cloudflare answered ${response.status}: ${text.slice(0, 200)}`
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
if (!response.ok || parsed.success !== true) {
|
|
3095
|
+
const detail = (parsed.errors ?? []).map((entry) => `${entry.code} ${entry.message}`).join("; ");
|
|
3096
|
+
return {
|
|
3097
|
+
data: parsed,
|
|
3098
|
+
status: response.status,
|
|
3099
|
+
error: detail.length > 0 ? detail : `HTTP ${response.status}`
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
return {
|
|
3103
|
+
data: parsed,
|
|
3104
|
+
status: response.status,
|
|
3105
|
+
error: null
|
|
3106
|
+
};
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
return {
|
|
3109
|
+
data: null,
|
|
3110
|
+
status: 0,
|
|
3111
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3112
|
+
};
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
/**
|
|
3116
|
+
* Walks the host's parents through `GET /zones?name=`, which is how the
|
|
3117
|
+
* reference image resolves a zone without a pasted zone id; the id is cached in
|
|
3118
|
+
* the panel's state file, so it usually costs nothing.
|
|
3119
|
+
*/
|
|
3120
|
+
async function resolveZone(host, headers, context, cached) {
|
|
3121
|
+
if (cached !== void 0) return {
|
|
3122
|
+
id: cached,
|
|
3123
|
+
name: host
|
|
3124
|
+
};
|
|
3125
|
+
const labels = host.split(".");
|
|
3126
|
+
for (let index = 0; index <= labels.length - 2; index++) {
|
|
3127
|
+
const candidate = labels.slice(index).join(".");
|
|
3128
|
+
const { data, error } = await call(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
|
|
3129
|
+
if (error !== null || data === null) throw new Error(error ?? "could not look up the zone");
|
|
3130
|
+
const zone = data.result[0];
|
|
3131
|
+
if (zone !== void 0) return zone;
|
|
3132
|
+
}
|
|
3133
|
+
return null;
|
|
3134
|
+
}
|
|
3135
|
+
async function findRecord(zoneId, record, headers, context, cached) {
|
|
3136
|
+
if (cached !== void 0) {
|
|
3137
|
+
const fetched = await call(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
|
|
3138
|
+
if (fetched.error === null && fetched.data !== null) return {
|
|
3139
|
+
record: fetched.data.result,
|
|
3140
|
+
error: null
|
|
3141
|
+
};
|
|
3142
|
+
if (fetched.status !== 404 && fetched.status !== 400) return {
|
|
3143
|
+
record: null,
|
|
3144
|
+
error: fetched.error
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
const listed = await call(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
|
|
3148
|
+
if (listed.error !== null) return {
|
|
3149
|
+
record: null,
|
|
3150
|
+
error: listed.error
|
|
3151
|
+
};
|
|
3152
|
+
return {
|
|
3153
|
+
record: (listed.data?.result ?? []).find((entry) => entry.id !== void 0) ?? null,
|
|
3154
|
+
error: null
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
3157
|
+
var API$5, cloudflareProvider;
|
|
3158
|
+
var init_cloudflare = __esmMin((() => {
|
|
3159
|
+
init_types();
|
|
3160
|
+
API$5 = "https://api.cloudflare.com/client/v4";
|
|
3161
|
+
cloudflareProvider = {
|
|
3162
|
+
id: "cloudflare",
|
|
3163
|
+
label: "Cloudflare",
|
|
3164
|
+
docsUrl: "https://developers.cloudflare.com/api/resources/dns/subresources/records/",
|
|
3165
|
+
families: ["A", "AAAA"],
|
|
3166
|
+
ttl: true,
|
|
3167
|
+
proxied: true,
|
|
3168
|
+
fields: [
|
|
3169
|
+
{
|
|
3170
|
+
key: "apiToken",
|
|
3171
|
+
label: "API token",
|
|
3172
|
+
hint: "Zone → DNS → Edit (plus Zone → Zone → Read) for your zones. Preferred.",
|
|
3173
|
+
optional: true
|
|
3174
|
+
},
|
|
3175
|
+
{
|
|
3176
|
+
key: "email",
|
|
3177
|
+
label: "Account email",
|
|
3178
|
+
hint: "Only for the legacy Global API Key.",
|
|
3179
|
+
optional: true
|
|
3180
|
+
},
|
|
3181
|
+
{
|
|
3182
|
+
key: "apiKey",
|
|
3183
|
+
label: "Global API key",
|
|
3184
|
+
hint: "Legacy; prefer an API token.",
|
|
3185
|
+
optional: true
|
|
3186
|
+
}
|
|
3187
|
+
],
|
|
3188
|
+
validate(credentials) {
|
|
3189
|
+
if (authHeaders(credentials ?? {}) !== null) return null;
|
|
3190
|
+
return missingFields(credentials, ["apiToken"]);
|
|
3191
|
+
},
|
|
3192
|
+
async update(record, context) {
|
|
3193
|
+
const headers = authHeaders(context.credentials);
|
|
3194
|
+
if (headers === null) return failed("cloudflare needs an API token (or the legacy email + global key)");
|
|
3195
|
+
try {
|
|
3196
|
+
const zone = await resolveZone(record.host, headers, context, record.cache?.zoneId);
|
|
3197
|
+
if (zone === null) return failed(`cloudflare does not have a zone for ${record.host}`);
|
|
3198
|
+
const ttl = record.proxied ? 1 : record.ttl;
|
|
3199
|
+
const found = await findRecord(zone.id, record, headers, context, record.cache?.recordId);
|
|
3200
|
+
if (found.error !== null) return failed(found.error);
|
|
3201
|
+
const existing = found.record;
|
|
3202
|
+
if (existing !== null && existing.content === record.ip && existing.ttl === ttl && existing.proxied === record.proxied) return {
|
|
3203
|
+
...succeeded(`already ${record.ip}`, false),
|
|
3204
|
+
cache: {
|
|
3205
|
+
zoneId: zone.id,
|
|
3206
|
+
recordId: existing.id
|
|
3207
|
+
}
|
|
3208
|
+
};
|
|
3209
|
+
const cache = {
|
|
3210
|
+
zoneId: zone.id,
|
|
3211
|
+
recordId: existing?.id
|
|
3212
|
+
};
|
|
3213
|
+
const body = JSON.stringify({
|
|
3214
|
+
type: record.type,
|
|
3215
|
+
name: record.host,
|
|
3216
|
+
content: record.ip,
|
|
3217
|
+
ttl,
|
|
3218
|
+
proxied: record.proxied
|
|
3219
|
+
});
|
|
3220
|
+
const written = existing === null ? await call(`/zones/${zone.id}/dns_records`, headers, context, {
|
|
3221
|
+
method: "POST",
|
|
3222
|
+
body
|
|
3223
|
+
}) : await call(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
|
|
3224
|
+
method: "PATCH",
|
|
3225
|
+
body
|
|
3226
|
+
});
|
|
3227
|
+
if (written.error !== null) return failed(written.error);
|
|
3228
|
+
return {
|
|
3229
|
+
...succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true),
|
|
3230
|
+
cache: {
|
|
3231
|
+
...cache,
|
|
3232
|
+
recordId: written.data?.result?.id ?? existing?.id
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
} catch (error) {
|
|
3236
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
};
|
|
3240
|
+
}));
|
|
3241
|
+
//#endregion
|
|
3242
|
+
//#region src/providers/ddns/dyndns2.ts
|
|
3243
|
+
/** The first word of the body is the code; the rest is provider detail. */
|
|
3244
|
+
function parseDyndnsReply(body) {
|
|
3245
|
+
const trimmed = body.trim();
|
|
3246
|
+
const separator = trimmed.search(/\s/);
|
|
3247
|
+
if (separator < 0) return {
|
|
3248
|
+
code: trimmed.toLowerCase(),
|
|
3249
|
+
detail: ""
|
|
3250
|
+
};
|
|
3251
|
+
return {
|
|
3252
|
+
code: trimmed.slice(0, separator).toLowerCase(),
|
|
3253
|
+
detail: trimmed.slice(separator).trim()
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
3256
|
+
/** Maps a dyndns2 body onto the panel's result shape. */
|
|
3257
|
+
function describeDyndnsReply(body, provider) {
|
|
3258
|
+
const { code, detail } = parseDyndnsReply(body);
|
|
3259
|
+
const known = CODES[code];
|
|
3260
|
+
if (known === void 0) return failed(`${provider} answered "${(body.trim() || "an empty body").slice(0, 120)}"`);
|
|
3261
|
+
if (known.ok) return succeeded(`${known.message}${detail.length === 0 ? "" : ` (${detail})`}`, known.changed);
|
|
3262
|
+
return failed(`${known.message}${detail.length === 0 ? "" : `: ${detail}`}`);
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* A 401/400 with a non-dyndns2 body is still an answer worth quoting; a known
|
|
3266
|
+
* code wins, because several of these endpoints report errors with HTTP 200.
|
|
3267
|
+
*/
|
|
3268
|
+
function describeDyndnsResponse(response, body, provider) {
|
|
3269
|
+
const { code } = parseDyndnsReply(body);
|
|
3270
|
+
if (!response.ok && CODES[code] === void 0) {
|
|
3271
|
+
const text = body.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
3272
|
+
return failed(`${provider} answered HTTP ${response.status}${text.length === 0 ? "" : `: ${text}`}`);
|
|
3273
|
+
}
|
|
3274
|
+
return describeDyndnsReply(body, provider);
|
|
3275
|
+
}
|
|
3276
|
+
var CODES;
|
|
3277
|
+
var init_dyndns2 = __esmMin((() => {
|
|
3278
|
+
init_types();
|
|
3279
|
+
CODES = {
|
|
3280
|
+
"good": {
|
|
3281
|
+
ok: true,
|
|
3282
|
+
changed: true,
|
|
3283
|
+
message: "updated",
|
|
3284
|
+
permanent: false
|
|
3285
|
+
},
|
|
3286
|
+
"nochg": {
|
|
3287
|
+
ok: true,
|
|
3288
|
+
changed: false,
|
|
3289
|
+
message: "already current",
|
|
3290
|
+
permanent: false
|
|
3291
|
+
},
|
|
3292
|
+
"nohost": {
|
|
3293
|
+
ok: false,
|
|
3294
|
+
changed: false,
|
|
3295
|
+
message: "the provider does not know this hostname",
|
|
3296
|
+
permanent: true
|
|
3297
|
+
},
|
|
3298
|
+
"notfqdn": {
|
|
3299
|
+
ok: false,
|
|
3300
|
+
changed: false,
|
|
3301
|
+
message: "the provider wants a fully qualified hostname",
|
|
3302
|
+
permanent: true
|
|
3303
|
+
},
|
|
3304
|
+
"numhost": {
|
|
3305
|
+
ok: false,
|
|
3306
|
+
changed: false,
|
|
3307
|
+
message: "too many hostnames for this provider",
|
|
3308
|
+
permanent: true
|
|
3309
|
+
},
|
|
3310
|
+
"badauth": {
|
|
3311
|
+
ok: false,
|
|
3312
|
+
changed: false,
|
|
3313
|
+
message: "the provider rejected the credentials",
|
|
3314
|
+
permanent: true
|
|
3315
|
+
},
|
|
3316
|
+
"badagent": {
|
|
3317
|
+
ok: false,
|
|
3318
|
+
changed: false,
|
|
3319
|
+
message: "the provider rejected this client (User-Agent)",
|
|
3320
|
+
permanent: true
|
|
3321
|
+
},
|
|
3322
|
+
"!donator": {
|
|
3323
|
+
ok: false,
|
|
3324
|
+
changed: false,
|
|
3325
|
+
message: "this feature needs a paid provider plan",
|
|
3326
|
+
permanent: true
|
|
3327
|
+
},
|
|
3328
|
+
"abuse": {
|
|
3329
|
+
ok: false,
|
|
3330
|
+
changed: false,
|
|
3331
|
+
message: "the provider blocked this account for abuse",
|
|
3332
|
+
permanent: true
|
|
3333
|
+
},
|
|
3334
|
+
"dnserr": {
|
|
3335
|
+
ok: false,
|
|
3336
|
+
changed: false,
|
|
3337
|
+
message: "the provider had a DNS error",
|
|
3338
|
+
permanent: false
|
|
3339
|
+
},
|
|
3340
|
+
"servererror": {
|
|
3341
|
+
ok: false,
|
|
3342
|
+
changed: false,
|
|
3343
|
+
message: "the provider had a server error",
|
|
3344
|
+
permanent: false
|
|
3345
|
+
},
|
|
3346
|
+
"911": {
|
|
3347
|
+
ok: false,
|
|
3348
|
+
changed: false,
|
|
3349
|
+
message: "the provider is down; it asks you to wait about 30 minutes",
|
|
3350
|
+
permanent: false
|
|
3351
|
+
},
|
|
3352
|
+
"unknown": {
|
|
3353
|
+
ok: false,
|
|
3354
|
+
changed: false,
|
|
3355
|
+
message: "the provider answered with an unknown error",
|
|
3356
|
+
permanent: false
|
|
3357
|
+
}
|
|
3358
|
+
};
|
|
3359
|
+
}));
|
|
3360
|
+
//#endregion
|
|
3361
|
+
//#region src/providers/ddns/desec.ts
|
|
3362
|
+
var ENDPOINT$6, desecProvider;
|
|
3363
|
+
var init_desec = __esmMin((() => {
|
|
3364
|
+
init_dyndns2();
|
|
3365
|
+
init_types();
|
|
3366
|
+
ENDPOINT$6 = "https://update.dedyn.io/";
|
|
3367
|
+
desecProvider = {
|
|
3368
|
+
id: "desec",
|
|
3369
|
+
label: "deSEC",
|
|
3370
|
+
docsUrl: "https://desec.readthedocs.io/en/latest/dyndns/update-api.html",
|
|
3371
|
+
families: ["A", "AAAA"],
|
|
3372
|
+
ttl: false,
|
|
3373
|
+
proxied: false,
|
|
3374
|
+
fields: [{
|
|
3375
|
+
key: "token",
|
|
3376
|
+
label: "API token",
|
|
3377
|
+
hint: "desec.io → Token management; the token must allow the RRset update.",
|
|
3378
|
+
optional: false
|
|
3379
|
+
}],
|
|
3380
|
+
validate(credentials) {
|
|
3381
|
+
return missingFields(credentials, ["token"]);
|
|
3382
|
+
},
|
|
3383
|
+
async update(record, context) {
|
|
3384
|
+
const token = (context.credentials.token ?? "").trim();
|
|
3385
|
+
if (token.length === 0) return failed("desec needs an API token");
|
|
3386
|
+
const family = record.type === "A" ? `myipv4=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
3387
|
+
const url = `${ENDPOINT$6}?hostname=${encodeURIComponent(record.host)}&${family}`;
|
|
3388
|
+
try {
|
|
3389
|
+
const response = await context.fetch(url, {
|
|
3390
|
+
headers: { Authorization: basicAuth(record.host, token) },
|
|
3391
|
+
signal: context.signal
|
|
3392
|
+
});
|
|
3393
|
+
return describeDyndnsResponse(response, await response.text(), "desec");
|
|
3394
|
+
} catch (error) {
|
|
3395
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
};
|
|
3399
|
+
}));
|
|
3400
|
+
//#endregion
|
|
3401
|
+
//#region src/providers/ddns/zone.ts
|
|
3402
|
+
/**
|
|
3403
|
+
* Splits a hostname into the zone and the record name, which is what every
|
|
3404
|
+
* provider that has no zone lookup needs. `zone` short-circuits the heuristic.
|
|
3405
|
+
*/
|
|
3406
|
+
function splitHost(host, zone) {
|
|
3407
|
+
const normalized = normalizeHost(host);
|
|
3408
|
+
const override = normalizeHost(zone ?? "");
|
|
3409
|
+
const resolved = override.length > 0 ? override : guessZone(normalized);
|
|
3410
|
+
if (normalized === resolved) return {
|
|
3411
|
+
zone: resolved,
|
|
3412
|
+
name: "@"
|
|
3413
|
+
};
|
|
3414
|
+
return {
|
|
3415
|
+
zone: resolved,
|
|
3416
|
+
name: normalized.endsWith(`.${resolved}`) ? normalized.slice(0, -(resolved.length + 1)) : normalized
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
function guessZone(host) {
|
|
3420
|
+
const labels = host.split(".");
|
|
3421
|
+
if (labels.length <= 2) return host;
|
|
3422
|
+
const lastTwo = labels.slice(-2).join(".");
|
|
3423
|
+
const lastThree = labels.slice(-3).join(".");
|
|
3424
|
+
return MULTI_LABEL_SUFFIXES.has(lastTwo) ? lastThree : lastTwo;
|
|
3425
|
+
}
|
|
3426
|
+
var MULTI_LABEL_SUFFIXES;
|
|
3427
|
+
var init_zone = __esmMin((() => {
|
|
3428
|
+
init_types();
|
|
3429
|
+
MULTI_LABEL_SUFFIXES = /* @__PURE__ */ new Set([
|
|
3430
|
+
"ac.uk",
|
|
3431
|
+
"co.in",
|
|
3432
|
+
"co.jp",
|
|
3433
|
+
"co.ke",
|
|
3434
|
+
"co.kr",
|
|
3435
|
+
"co.nz",
|
|
3436
|
+
"co.uk",
|
|
3437
|
+
"co.za",
|
|
3438
|
+
"com.au",
|
|
3439
|
+
"com.br",
|
|
3440
|
+
"com.cn",
|
|
3441
|
+
"com.hk",
|
|
3442
|
+
"com.mx",
|
|
3443
|
+
"com.sg",
|
|
3444
|
+
"com.tr",
|
|
3445
|
+
"com.tw",
|
|
3446
|
+
"edu.au",
|
|
3447
|
+
"gov.uk",
|
|
3448
|
+
"net.au",
|
|
3449
|
+
"net.uk",
|
|
3450
|
+
"org.au",
|
|
3451
|
+
"org.uk"
|
|
3452
|
+
]);
|
|
3453
|
+
}));
|
|
3454
|
+
//#endregion
|
|
3455
|
+
//#region src/providers/ddns/digitalocean.ts
|
|
3456
|
+
function oceanTtl(ttl) {
|
|
3457
|
+
return Math.max(30, ttl);
|
|
3458
|
+
}
|
|
3459
|
+
var API$4, digitaloceanProvider;
|
|
3460
|
+
var init_digitalocean = __esmMin((() => {
|
|
3461
|
+
init_types();
|
|
3462
|
+
init_zone();
|
|
3463
|
+
API$4 = "https://api.digitalocean.com/v2/domains";
|
|
3464
|
+
digitaloceanProvider = {
|
|
3465
|
+
id: "digitalocean",
|
|
3466
|
+
label: "DigitalOcean",
|
|
3467
|
+
docsUrl: "https://docs.digitalocean.com/reference/api/api-reference/#tag/Domain-Records",
|
|
3468
|
+
families: ["A", "AAAA"],
|
|
3469
|
+
ttl: true,
|
|
3470
|
+
proxied: false,
|
|
3471
|
+
fields: [{
|
|
3472
|
+
key: "token",
|
|
3473
|
+
label: "API token",
|
|
3474
|
+
hint: "A personal access token with the `domain` scope.",
|
|
3475
|
+
optional: false
|
|
3476
|
+
}],
|
|
3477
|
+
validate(credentials) {
|
|
3478
|
+
return missingFields(credentials, ["token"]);
|
|
3479
|
+
},
|
|
3480
|
+
async update(record, context) {
|
|
3481
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3482
|
+
const ttl = oceanTtl(record.ttl);
|
|
3483
|
+
const headers = {
|
|
3484
|
+
"Authorization": `Bearer ${(context.credentials.token ?? "").trim()}`,
|
|
3485
|
+
"Content-Type": "application/json"
|
|
3486
|
+
};
|
|
3487
|
+
const collection = `${API$4}/${encodeURIComponent(zone)}/records`;
|
|
3488
|
+
try {
|
|
3489
|
+
const listed = await context.fetch(`${collection}?per_page=200`, {
|
|
3490
|
+
headers,
|
|
3491
|
+
signal: context.signal
|
|
3492
|
+
});
|
|
3493
|
+
if (!listed.ok) return failed(await errorText(listed));
|
|
3494
|
+
const existing = ((await listed.json()).domain_records ?? []).find((entry) => entry.name === name && entry.type === record.type);
|
|
3495
|
+
if (existing !== void 0 && existing.data === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3496
|
+
const written = existing === void 0 ? await context.fetch(collection, {
|
|
3497
|
+
method: "POST",
|
|
3498
|
+
headers,
|
|
3499
|
+
body: JSON.stringify({
|
|
3500
|
+
type: record.type,
|
|
3501
|
+
name,
|
|
3502
|
+
data: record.ip,
|
|
3503
|
+
ttl
|
|
3504
|
+
}),
|
|
3505
|
+
signal: context.signal
|
|
3506
|
+
}) : await context.fetch(`${collection}/${existing.id}`, {
|
|
3507
|
+
method: "PUT",
|
|
3508
|
+
headers,
|
|
3509
|
+
body: JSON.stringify({
|
|
3510
|
+
type: record.type,
|
|
3511
|
+
name,
|
|
3512
|
+
data: record.ip,
|
|
3513
|
+
ttl
|
|
3514
|
+
}),
|
|
3515
|
+
signal: context.signal
|
|
3516
|
+
});
|
|
3517
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3518
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3519
|
+
} catch (error) {
|
|
3520
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
};
|
|
3524
|
+
}));
|
|
3525
|
+
//#endregion
|
|
3526
|
+
//#region src/providers/ddns/duckdns.ts
|
|
3527
|
+
/** DuckDNS addresses one subdomain at a time, so the label is the domain. */
|
|
3528
|
+
function subdomain(host) {
|
|
3529
|
+
const normalized = host.trim().toLowerCase();
|
|
3530
|
+
return normalized.endsWith(".duckdns.org") ? normalized.slice(0, -12) : normalized;
|
|
3531
|
+
}
|
|
3532
|
+
var ENDPOINT$5, duckdnsProvider;
|
|
3533
|
+
var init_duckdns = __esmMin((() => {
|
|
3534
|
+
init_types();
|
|
3535
|
+
ENDPOINT$5 = "https://www.duckdns.org/update";
|
|
3536
|
+
duckdnsProvider = {
|
|
3537
|
+
id: "duckdns",
|
|
3538
|
+
label: "DuckDNS",
|
|
3539
|
+
docsUrl: "https://www.duckdns.org/spec.jsp",
|
|
3540
|
+
families: ["A", "AAAA"],
|
|
3541
|
+
ttl: false,
|
|
3542
|
+
proxied: false,
|
|
3543
|
+
fields: [{
|
|
3544
|
+
key: "token",
|
|
3545
|
+
label: "Account token",
|
|
3546
|
+
hint: "On duckdns.org after signing in. One token covers every subdomain.",
|
|
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("duckdns needs the account token");
|
|
3555
|
+
const family = record.type === "A" ? `ip=${encodeURIComponent(record.ip)}` : `ipv6=${encodeURIComponent(record.ip)}`;
|
|
3556
|
+
const url = `${ENDPOINT$5}?domains=${encodeURIComponent(subdomain(record.host))}&token=${encodeURIComponent(token)}&${family}`;
|
|
3557
|
+
try {
|
|
3558
|
+
const response = await context.fetch(url, { signal: context.signal });
|
|
3559
|
+
const body = (await response.text()).trim();
|
|
3560
|
+
if (body.toUpperCase() === "OK") return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3561
|
+
return failed(`duckdns answered "${body.slice(0, 120) || `HTTP ${response.status}`}"`);
|
|
3562
|
+
} catch (error) {
|
|
3563
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
};
|
|
3567
|
+
}));
|
|
3568
|
+
//#endregion
|
|
3569
|
+
//#region src/providers/ddns/dynu.ts
|
|
3570
|
+
var ENDPOINT$4, dynuProvider;
|
|
3571
|
+
var init_dynu = __esmMin((() => {
|
|
3572
|
+
init_dyndns2();
|
|
3573
|
+
init_types();
|
|
3574
|
+
ENDPOINT$4 = "https://api.dynu.com/nic/update";
|
|
3575
|
+
dynuProvider = {
|
|
3576
|
+
id: "dynu",
|
|
3577
|
+
label: "Dynu",
|
|
3578
|
+
docsUrl: "https://www.dynu.com/DynamicDNS/IP-Update-Protocol",
|
|
3579
|
+
families: ["A", "AAAA"],
|
|
3580
|
+
ttl: false,
|
|
3581
|
+
proxied: false,
|
|
3582
|
+
fields: [{
|
|
3583
|
+
key: "username",
|
|
3584
|
+
label: "Username",
|
|
3585
|
+
hint: "Dynu username; used for HTTP Basic auth.",
|
|
3586
|
+
optional: false
|
|
3587
|
+
}, {
|
|
3588
|
+
key: "password",
|
|
3589
|
+
label: "Password",
|
|
3590
|
+
hint: "Account password, or its SHA-256 hash.",
|
|
3591
|
+
optional: false
|
|
3592
|
+
}],
|
|
3593
|
+
validate(credentials) {
|
|
3594
|
+
return missingFields(credentials, ["username", "password"]);
|
|
3595
|
+
},
|
|
3596
|
+
async update(record, context) {
|
|
3597
|
+
const username = (context.credentials.username ?? "").trim();
|
|
3598
|
+
const password = (context.credentials.password ?? "").trim();
|
|
3599
|
+
if (username.length === 0 || password.length === 0) return failed("dynu needs the username and password");
|
|
3600
|
+
const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
3601
|
+
const url = `${ENDPOINT$4}?hostname=${encodeURIComponent(record.host)}&${address}`;
|
|
3602
|
+
try {
|
|
3603
|
+
const response = await context.fetch(url, {
|
|
3604
|
+
headers: { Authorization: basicAuth(username, password) },
|
|
3605
|
+
signal: context.signal
|
|
3606
|
+
});
|
|
3607
|
+
return describeDyndnsResponse(response, await response.text(), "dynu");
|
|
3608
|
+
} catch (error) {
|
|
3609
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
};
|
|
3613
|
+
}));
|
|
3614
|
+
//#endregion
|
|
3615
|
+
//#region src/providers/ddns/freedns.ts
|
|
3616
|
+
/**
|
|
3617
|
+
* FreeDNS credentials *are* a URL: the per-record "Direct URL" hash. Both the
|
|
3618
|
+
* bare hash and the whole URL are accepted, because that is what people copy.
|
|
3619
|
+
*/
|
|
3620
|
+
function updateToken(credentials) {
|
|
3621
|
+
const raw = (credentials.token ?? "").trim();
|
|
3622
|
+
if (raw.length === 0) return "";
|
|
3623
|
+
return (raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : raw).replace(/^https?:\/\/[^?]*\?/, "");
|
|
3624
|
+
}
|
|
3625
|
+
var ENDPOINT$3, freednsProvider;
|
|
3626
|
+
var init_freedns = __esmMin((() => {
|
|
3627
|
+
init_types();
|
|
3628
|
+
ENDPOINT$3 = "https://freedns.afraid.org/dynamic/update.php";
|
|
3629
|
+
freednsProvider = {
|
|
3630
|
+
id: "freedns",
|
|
3631
|
+
label: "FreeDNS (afraid.org)",
|
|
3632
|
+
docsUrl: "https://freedns.afraid.org/faq/",
|
|
3633
|
+
families: ["A", "AAAA"],
|
|
3634
|
+
ttl: false,
|
|
3635
|
+
proxied: false,
|
|
3636
|
+
fields: [{
|
|
3637
|
+
key: "token",
|
|
3638
|
+
label: "Direct URL",
|
|
3639
|
+
hint: "FreeDNS → Dynamic DNS → the per-record \"Direct URL\". Paste the whole URL or just its hash.",
|
|
3640
|
+
optional: false
|
|
3641
|
+
}],
|
|
3642
|
+
validate(credentials) {
|
|
3643
|
+
return missingFields(credentials, ["token"]);
|
|
3644
|
+
},
|
|
3645
|
+
async update(record, context) {
|
|
3646
|
+
const token = updateToken(context.credentials);
|
|
3647
|
+
if (token.length === 0) return failed("freedns needs the per-record Direct URL");
|
|
3648
|
+
try {
|
|
3649
|
+
const response = await context.fetch(`${ENDPOINT$3}?${token}&address=${encodeURIComponent(record.ip)}`, { signal: context.signal });
|
|
3650
|
+
const body = (await response.text()).trim();
|
|
3651
|
+
if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
|
|
3652
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3653
|
+
} catch (error) {
|
|
3654
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
};
|
|
3658
|
+
}));
|
|
3659
|
+
//#endregion
|
|
3660
|
+
//#region src/providers/ddns/gandi.ts
|
|
3661
|
+
/** Gandi's documented minimum; `1` (automatic) has no meaning here. */
|
|
3662
|
+
function gandiTtl(ttl) {
|
|
3663
|
+
return Math.min(2592e3, Math.max(300, ttl));
|
|
3664
|
+
}
|
|
3665
|
+
/** A Personal Access Token (`pat_…`) is preferred; the legacy API key is still accepted. */
|
|
3666
|
+
function authHeader(token) {
|
|
3667
|
+
return token.startsWith("pat_") ? `Bearer ${token}` : `Apikey ${token}`;
|
|
3668
|
+
}
|
|
3669
|
+
var API$3, gandiProvider;
|
|
3670
|
+
var init_gandi = __esmMin((() => {
|
|
3671
|
+
init_types();
|
|
3672
|
+
init_zone();
|
|
3673
|
+
API$3 = "https://api.gandi.net/v5/livedns/domains";
|
|
3674
|
+
gandiProvider = {
|
|
3675
|
+
id: "gandi",
|
|
3676
|
+
label: "Gandi",
|
|
3677
|
+
docsUrl: "https://api.gandi.net/docs/livedns/",
|
|
3678
|
+
families: ["A", "AAAA"],
|
|
3679
|
+
ttl: true,
|
|
3680
|
+
proxied: false,
|
|
3681
|
+
fields: [{
|
|
3682
|
+
key: "token",
|
|
3683
|
+
label: "Personal access token",
|
|
3684
|
+
hint: "A `pat_…` token from admin.gandi.net → Organizations. A legacy API key also works.",
|
|
3685
|
+
optional: false
|
|
3686
|
+
}],
|
|
3687
|
+
validate(credentials) {
|
|
3688
|
+
return missingFields(credentials, ["token"]);
|
|
3689
|
+
},
|
|
3690
|
+
async update(record, context) {
|
|
3691
|
+
const token = (context.credentials.token ?? "").trim();
|
|
3692
|
+
if (token.length === 0) return failed("gandi needs an API token");
|
|
3693
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3694
|
+
const ttl = gandiTtl(record.ttl);
|
|
3695
|
+
const url = `${API$3}/${encodeURIComponent(zone)}/records/${encodeURIComponent(name)}/${record.type}`;
|
|
3696
|
+
const headers = {
|
|
3697
|
+
"Authorization": authHeader(token),
|
|
3698
|
+
"Content-Type": "application/json"
|
|
3699
|
+
};
|
|
3700
|
+
try {
|
|
3701
|
+
const current = await context.fetch(url, {
|
|
3702
|
+
headers,
|
|
3703
|
+
signal: context.signal
|
|
3704
|
+
});
|
|
3705
|
+
if (current.ok) {
|
|
3706
|
+
const body = await current.json();
|
|
3707
|
+
if (body.rrset_values?.length === 1 && body.rrset_values[0] === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3708
|
+
}
|
|
3709
|
+
const written = await context.fetch(url, {
|
|
3710
|
+
method: "PUT",
|
|
3711
|
+
headers,
|
|
3712
|
+
body: JSON.stringify({
|
|
3713
|
+
rrset_values: [record.ip],
|
|
3714
|
+
rrset_ttl: ttl
|
|
3715
|
+
}),
|
|
3716
|
+
signal: context.signal
|
|
3717
|
+
});
|
|
3718
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3719
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3720
|
+
} catch (error) {
|
|
3721
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
}));
|
|
3726
|
+
//#endregion
|
|
3727
|
+
//#region src/providers/ddns/godaddy.ts
|
|
3728
|
+
/** GoDaddy's floor; `1` (automatic) has no meaning here. */
|
|
3729
|
+
function godaddyTtl(ttl) {
|
|
3730
|
+
return Math.min(86400, Math.max(600, ttl));
|
|
3731
|
+
}
|
|
3732
|
+
var API$2, godaddyProvider;
|
|
3733
|
+
var init_godaddy = __esmMin((() => {
|
|
3734
|
+
init_types();
|
|
3735
|
+
init_zone();
|
|
3736
|
+
API$2 = "https://api.godaddy.com/v1/domains";
|
|
3737
|
+
godaddyProvider = {
|
|
3738
|
+
id: "godaddy",
|
|
3739
|
+
label: "GoDaddy",
|
|
3740
|
+
docsUrl: "https://developer.godaddy.com/doc/endpoint/dns",
|
|
3741
|
+
families: ["A", "AAAA"],
|
|
3742
|
+
ttl: true,
|
|
3743
|
+
proxied: false,
|
|
3744
|
+
fields: [
|
|
3745
|
+
{
|
|
3746
|
+
key: "key",
|
|
3747
|
+
label: "API key",
|
|
3748
|
+
hint: "developer.godaddy.com/keys.",
|
|
3749
|
+
optional: false
|
|
3750
|
+
},
|
|
3751
|
+
{
|
|
3752
|
+
key: "secret",
|
|
3753
|
+
label: "API secret",
|
|
3754
|
+
hint: "Shown once with the key.",
|
|
3755
|
+
optional: false
|
|
3756
|
+
},
|
|
3757
|
+
{
|
|
3758
|
+
key: "baseUrl",
|
|
3759
|
+
label: "API base URL",
|
|
3760
|
+
hint: "Optional; use https://api.ote-godaddy.com to rehearse against the test environment.",
|
|
3761
|
+
optional: true
|
|
3762
|
+
}
|
|
3763
|
+
],
|
|
3764
|
+
validate(credentials) {
|
|
3765
|
+
return missingFields(credentials, ["key", "secret"]);
|
|
3766
|
+
},
|
|
3767
|
+
async update(record, context) {
|
|
3768
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3769
|
+
const ttl = godaddyTtl(record.ttl);
|
|
3770
|
+
const url = `${(context.credentials.baseUrl ?? "").trim() || API$2}/${encodeURIComponent(zone)}/records/${record.type}/${encodeURIComponent(name)}`;
|
|
3771
|
+
const headers = {
|
|
3772
|
+
"Authorization": `sso-key ${context.credentials.key ?? ""}:${context.credentials.secret ?? ""}`,
|
|
3773
|
+
"Content-Type": "application/json"
|
|
3774
|
+
};
|
|
3775
|
+
try {
|
|
3776
|
+
const current = await context.fetch(url, {
|
|
3777
|
+
headers,
|
|
3778
|
+
signal: context.signal
|
|
3779
|
+
});
|
|
3780
|
+
if (current.ok) {
|
|
3781
|
+
const body = await current.json();
|
|
3782
|
+
if (body.length === 1 && body[0]?.data === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
3783
|
+
}
|
|
3784
|
+
const written = await context.fetch(url, {
|
|
3785
|
+
method: "PUT",
|
|
3786
|
+
headers,
|
|
3787
|
+
body: JSON.stringify([{
|
|
3788
|
+
data: record.ip,
|
|
3789
|
+
ttl
|
|
3790
|
+
}]),
|
|
3791
|
+
signal: context.signal
|
|
3792
|
+
});
|
|
3793
|
+
if (!written.ok) return failed(await errorText(written));
|
|
3794
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
3795
|
+
} catch (error) {
|
|
3796
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
};
|
|
3800
|
+
}));
|
|
3801
|
+
//#endregion
|
|
3802
|
+
//#region src/providers/ddns/henet.ts
|
|
3803
|
+
var ENDPOINT$2, henetProvider;
|
|
3804
|
+
var init_henet = __esmMin((() => {
|
|
3805
|
+
init_dyndns2();
|
|
3806
|
+
init_types();
|
|
3807
|
+
ENDPOINT$2 = "https://dyn.dns.he.net/nic/update";
|
|
3808
|
+
henetProvider = {
|
|
3809
|
+
id: "henet",
|
|
3810
|
+
label: "Hurricane Electric",
|
|
3811
|
+
docsUrl: "https://dns.he.net/docs.html",
|
|
3812
|
+
families: ["A", "AAAA"],
|
|
3813
|
+
ttl: false,
|
|
3814
|
+
proxied: false,
|
|
3815
|
+
fields: [{
|
|
3816
|
+
key: "key",
|
|
3817
|
+
label: "DDNS key",
|
|
3818
|
+
hint: "dns.he.net → the key shown next to the host when DDNS is enabled.",
|
|
3819
|
+
optional: false
|
|
3820
|
+
}],
|
|
3821
|
+
validate(credentials) {
|
|
3822
|
+
return missingFields(credentials, ["key"]);
|
|
3823
|
+
},
|
|
3824
|
+
async update(record, context) {
|
|
3825
|
+
const key = (context.credentials.key ?? "").trim();
|
|
3826
|
+
if (key.length === 0) return failed("hurricane electric needs the per-host DDNS key");
|
|
3827
|
+
const url = `${ENDPOINT$2}?hostname=${encodeURIComponent(record.host)}&password=${encodeURIComponent(key)}&myip=${encodeURIComponent(record.ip)}`;
|
|
3828
|
+
try {
|
|
3829
|
+
const response = await context.fetch(url, { signal: context.signal });
|
|
3830
|
+
return describeDyndnsResponse(response, await response.text(), "hurricane electric");
|
|
3831
|
+
} catch (error) {
|
|
3832
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
};
|
|
3836
|
+
}));
|
|
3837
|
+
//#endregion
|
|
3838
|
+
//#region src/providers/ddns/namecheap.ts
|
|
3839
|
+
function tag(body, name) {
|
|
3840
|
+
return new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "i").exec(body)?.[1]?.trim() ?? null;
|
|
3841
|
+
}
|
|
3842
|
+
var ENDPOINT$1, namecheapProvider;
|
|
3843
|
+
var init_namecheap = __esmMin((() => {
|
|
3844
|
+
init_types();
|
|
3845
|
+
init_zone();
|
|
3846
|
+
ENDPOINT$1 = "https://dynamicdns.park-your-domain.com/update";
|
|
3847
|
+
namecheapProvider = {
|
|
3848
|
+
id: "namecheap",
|
|
3849
|
+
label: "Namecheap",
|
|
3850
|
+
docsUrl: "https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-https-request/",
|
|
3851
|
+
families: ["A"],
|
|
3852
|
+
ttl: false,
|
|
3853
|
+
proxied: false,
|
|
3854
|
+
fields: [{
|
|
3855
|
+
key: "password",
|
|
3856
|
+
label: "Dynamic DNS password",
|
|
3857
|
+
hint: "Dashboard → Manage domain → Advanced DNS → Dynamic DNS. Not your account password or API key.",
|
|
3858
|
+
optional: false
|
|
3859
|
+
}],
|
|
3860
|
+
validate(credentials) {
|
|
3861
|
+
return missingFields(credentials, ["password"]);
|
|
3862
|
+
},
|
|
3863
|
+
async update(record, context) {
|
|
3864
|
+
const password = (context.credentials.password ?? "").trim();
|
|
3865
|
+
if (password.length === 0) return failed("namecheap needs the Dynamic DNS password");
|
|
3866
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
3867
|
+
const host = name === "@" ? "@" : name;
|
|
3868
|
+
const url = `${ENDPOINT$1}?host=${encodeURIComponent(host)}&domain=${encodeURIComponent(zone)}&password=${encodeURIComponent(password)}&ip=${encodeURIComponent(record.ip)}`;
|
|
3869
|
+
try {
|
|
3870
|
+
const body = await (await context.fetch(url, { signal: context.signal })).text();
|
|
3871
|
+
const errCount = Number.parseInt(tag(body, "ErrCount") ?? "0", 10);
|
|
3872
|
+
const ip = tag(body, "IP");
|
|
3873
|
+
if (errCount > 0) {
|
|
3874
|
+
const first = tag(body, "Err1");
|
|
3875
|
+
return failed(`namecheap rejected the update${first === null ? "" : `: ${first}`}`);
|
|
3876
|
+
}
|
|
3877
|
+
if (ip === null) return failed(`namecheap answered without an IP: ${body.replace(/\s+/g, " ").slice(0, 160)}`);
|
|
3878
|
+
return succeeded(`${host === "@" ? zone : `${host}.${zone}`} now ${ip}`, ip !== record.ip);
|
|
3879
|
+
} catch (error) {
|
|
3880
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
};
|
|
3884
|
+
}));
|
|
3885
|
+
//#endregion
|
|
3886
|
+
//#region src/providers/ddns/noip.ts
|
|
3887
|
+
var ENDPOINT, DEFAULT_AGENT, noipProvider;
|
|
3888
|
+
var init_noip = __esmMin((() => {
|
|
3889
|
+
init_dyndns2();
|
|
3890
|
+
init_types();
|
|
3891
|
+
ENDPOINT = "https://dynupdate.no-ip.com/nic/update";
|
|
3892
|
+
DEFAULT_AGENT = "home-hosted/1.0 (+https://github.com/NamesMT/home-hosted)";
|
|
3893
|
+
noipProvider = {
|
|
3894
|
+
id: "noip",
|
|
3895
|
+
label: "No-IP",
|
|
3896
|
+
docsUrl: "https://www.noip.com/integrate/request",
|
|
3897
|
+
families: ["A", "AAAA"],
|
|
3898
|
+
ttl: false,
|
|
3899
|
+
proxied: false,
|
|
3900
|
+
fields: [
|
|
3901
|
+
{
|
|
3902
|
+
key: "username",
|
|
3903
|
+
label: "Email",
|
|
3904
|
+
hint: "Your No-IP account email.",
|
|
3905
|
+
optional: false
|
|
3906
|
+
},
|
|
3907
|
+
{
|
|
3908
|
+
key: "password",
|
|
3909
|
+
label: "Password",
|
|
3910
|
+
hint: "Your No-IP account password.",
|
|
3911
|
+
optional: false
|
|
3912
|
+
},
|
|
3913
|
+
{
|
|
3914
|
+
key: "userAgent",
|
|
3915
|
+
label: "User-Agent override",
|
|
3916
|
+
hint: "No-IP only guarantees updates for recognised clients; set an approved agent here if yours is registered.",
|
|
3917
|
+
optional: true
|
|
3918
|
+
}
|
|
3919
|
+
],
|
|
3920
|
+
validate(credentials) {
|
|
3921
|
+
return missingFields(credentials, ["username", "password"]);
|
|
3922
|
+
},
|
|
3923
|
+
async update(record, context) {
|
|
3924
|
+
const username = (context.credentials.username ?? "").trim();
|
|
3925
|
+
const password = (context.credentials.password ?? "").trim();
|
|
3926
|
+
if (username.length === 0 || password.length === 0) return failed("no-ip needs the account email and password");
|
|
3927
|
+
const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
|
|
3928
|
+
const url = `${ENDPOINT}?hostname=${encodeURIComponent(record.host)}&${address}`;
|
|
3929
|
+
try {
|
|
3930
|
+
const response = await context.fetch(url, {
|
|
3931
|
+
headers: {
|
|
3932
|
+
"Authorization": basicAuth(username, password),
|
|
3933
|
+
"User-Agent": (context.credentials.userAgent ?? "").trim() || DEFAULT_AGENT
|
|
3934
|
+
},
|
|
3935
|
+
signal: context.signal
|
|
3936
|
+
});
|
|
3937
|
+
return describeDyndnsResponse(response, await response.text(), "no-ip");
|
|
3938
|
+
} catch (error) {
|
|
3939
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
};
|
|
3943
|
+
}));
|
|
3944
|
+
//#endregion
|
|
3945
|
+
//#region src/providers/ddns/porkbun.ts
|
|
3946
|
+
/** Porkbun's documented floor; `1` (automatic) maps onto it. */
|
|
3947
|
+
function porkbunTtl(ttl) {
|
|
3948
|
+
return Math.max(600, ttl);
|
|
3949
|
+
}
|
|
3950
|
+
async function post(path, context, body) {
|
|
3951
|
+
try {
|
|
3952
|
+
const response = await context.fetch(`${API$1}${path}`, {
|
|
3953
|
+
method: "POST",
|
|
3954
|
+
headers: {
|
|
3955
|
+
"Content-Type": "application/json",
|
|
3956
|
+
"X-API-Key": context.credentials.apikey ?? "",
|
|
3957
|
+
"X-Secret-API-Key": context.credentials.secretapikey ?? ""
|
|
3958
|
+
},
|
|
3959
|
+
body: JSON.stringify(body),
|
|
3960
|
+
signal: context.signal
|
|
3961
|
+
});
|
|
3962
|
+
const parsed = JSON.parse(await response.text());
|
|
3963
|
+
if (parsed.status !== "SUCCESS") return {
|
|
3964
|
+
data: parsed,
|
|
3965
|
+
error: parsed.message ?? `HTTP ${response.status}`
|
|
3966
|
+
};
|
|
3967
|
+
return {
|
|
3968
|
+
data: parsed,
|
|
3969
|
+
error: null
|
|
3970
|
+
};
|
|
3971
|
+
} catch (error) {
|
|
3972
|
+
return {
|
|
3973
|
+
data: null,
|
|
3974
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3975
|
+
};
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
var API$1, porkbunProvider;
|
|
3979
|
+
var init_porkbun = __esmMin((() => {
|
|
3980
|
+
init_types();
|
|
3981
|
+
init_zone();
|
|
3982
|
+
API$1 = "https://api.porkbun.com/api/json/v3";
|
|
3983
|
+
porkbunProvider = {
|
|
3984
|
+
id: "porkbun",
|
|
3985
|
+
label: "Porkbun",
|
|
3986
|
+
docsUrl: "https://porkbun.com/api/json/v3/documentation",
|
|
3987
|
+
families: ["A", "AAAA"],
|
|
3988
|
+
ttl: true,
|
|
3989
|
+
proxied: false,
|
|
3990
|
+
fields: [{
|
|
3991
|
+
key: "apikey",
|
|
3992
|
+
label: "API key",
|
|
3993
|
+
hint: "porkbun.com/account/api — starts with pk1_.",
|
|
3994
|
+
optional: false
|
|
3995
|
+
}, {
|
|
3996
|
+
key: "secretapikey",
|
|
3997
|
+
label: "Secret API key",
|
|
3998
|
+
hint: "Shown once when the key is created.",
|
|
3999
|
+
optional: false
|
|
4000
|
+
}],
|
|
4001
|
+
validate(credentials) {
|
|
4002
|
+
return missingFields(credentials, ["apikey", "secretapikey"]);
|
|
4003
|
+
},
|
|
4004
|
+
async update(record, context) {
|
|
4005
|
+
const { zone, name: rawName } = splitHost(record.host, record.zone);
|
|
4006
|
+
const name = rawName === "@" ? "" : rawName;
|
|
4007
|
+
const ttl = porkbunTtl(record.ttl);
|
|
4008
|
+
const existing = (await post(`/dns/retrieveByNameType/${encodeURIComponent(zone)}/${record.type}/${encodeURIComponent(name)}`, context, {})).data?.records?.[0];
|
|
4009
|
+
if (existing !== void 0 && existing.content === record.ip && Number.parseInt(existing.ttl, 10) === ttl) return succeeded(`already ${record.ip}`, false);
|
|
4010
|
+
const written = await post(`/dns/editByNameType/${encodeURIComponent(zone)}/${record.type}/${encodeURIComponent(name)}`, context, {
|
|
4011
|
+
content: record.ip,
|
|
4012
|
+
ttl
|
|
4013
|
+
});
|
|
4014
|
+
if (written.error !== null) return failed(written.error);
|
|
4015
|
+
const warnings = written.data?.warnings ?? [];
|
|
4016
|
+
const note = warnings.length > 0 ? ` — porkbun warns: ${warnings.join("; ")}` : "";
|
|
4017
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}${note}`, true);
|
|
4018
|
+
}
|
|
4019
|
+
};
|
|
4020
|
+
}));
|
|
4021
|
+
//#endregion
|
|
4022
|
+
//#region src/providers/ddns/spaceship.ts
|
|
4023
|
+
function spaceshipTtl(ttl) {
|
|
4024
|
+
return Math.min(3600, Math.max(60, ttl));
|
|
4025
|
+
}
|
|
4026
|
+
var API, PAGE_SIZE, spaceshipProvider;
|
|
4027
|
+
var init_spaceship = __esmMin((() => {
|
|
4028
|
+
init_types();
|
|
4029
|
+
init_zone();
|
|
4030
|
+
API = "https://spaceship.dev/api/v1/dns/records";
|
|
4031
|
+
PAGE_SIZE = 500;
|
|
4032
|
+
spaceshipProvider = {
|
|
4033
|
+
id: "spaceship",
|
|
4034
|
+
label: "Spaceship",
|
|
4035
|
+
docsUrl: "https://docs.spaceship.dev/",
|
|
4036
|
+
families: ["A", "AAAA"],
|
|
4037
|
+
ttl: true,
|
|
4038
|
+
proxied: false,
|
|
4039
|
+
fields: [{
|
|
4040
|
+
key: "apiKey",
|
|
4041
|
+
label: "API key",
|
|
4042
|
+
hint: "spaceship.com → API Manager; needs the `dnsrecords:read`/`write` scopes.",
|
|
4043
|
+
optional: false
|
|
4044
|
+
}, {
|
|
4045
|
+
key: "apiSecret",
|
|
4046
|
+
label: "API secret",
|
|
4047
|
+
hint: "Shown once with the key.",
|
|
4048
|
+
optional: false
|
|
4049
|
+
}],
|
|
4050
|
+
validate(credentials) {
|
|
4051
|
+
return missingFields(credentials, ["apiKey", "apiSecret"]);
|
|
4052
|
+
},
|
|
4053
|
+
async update(record, context) {
|
|
4054
|
+
const { zone, name } = splitHost(record.host, record.zone);
|
|
4055
|
+
const headers = {
|
|
4056
|
+
"X-API-Key": (context.credentials.apiKey ?? "").trim(),
|
|
4057
|
+
"X-API-Secret": (context.credentials.apiSecret ?? "").trim(),
|
|
4058
|
+
"Content-Type": "application/json"
|
|
4059
|
+
};
|
|
4060
|
+
try {
|
|
4061
|
+
let found;
|
|
4062
|
+
for (let skip = 0; skip < PAGE_SIZE * 5; skip += PAGE_SIZE) {
|
|
4063
|
+
const listed = await context.fetch(`${API}/${encodeURIComponent(zone)}?take=${PAGE_SIZE}&skip=${skip}`, {
|
|
4064
|
+
headers,
|
|
4065
|
+
signal: context.signal
|
|
4066
|
+
});
|
|
4067
|
+
if (!listed.ok) return failed(await errorText(listed));
|
|
4068
|
+
const body = await listed.json();
|
|
4069
|
+
const items = Array.isArray(body) ? body : body.items ?? [];
|
|
4070
|
+
found = items.find((entry) => entry.name === name && entry.type === record.type);
|
|
4071
|
+
if (found !== void 0 || items.length < PAGE_SIZE) break;
|
|
4072
|
+
}
|
|
4073
|
+
if (found?.address === record.ip) return succeeded(`already ${record.ip}`, false);
|
|
4074
|
+
const written = await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
|
|
4075
|
+
method: "PUT",
|
|
4076
|
+
headers,
|
|
4077
|
+
body: JSON.stringify({
|
|
4078
|
+
force: true,
|
|
4079
|
+
items: [{
|
|
4080
|
+
type: record.type,
|
|
4081
|
+
name,
|
|
4082
|
+
ttl: spaceshipTtl(record.ttl),
|
|
4083
|
+
address: record.ip
|
|
4084
|
+
}]
|
|
4085
|
+
}),
|
|
4086
|
+
signal: context.signal
|
|
4087
|
+
});
|
|
4088
|
+
if (!written.ok) return failed(await errorText(written));
|
|
4089
|
+
return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
|
|
4090
|
+
} catch (error) {
|
|
4091
|
+
return failed(error instanceof Error ? error.message : String(error));
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
};
|
|
4095
|
+
}));
|
|
4096
|
+
//#endregion
|
|
4097
|
+
//#region src/providers/ddns/index.ts
|
|
4098
|
+
function ddnsProvider(id) {
|
|
4099
|
+
return BY_ID.get(id) ?? null;
|
|
4100
|
+
}
|
|
4101
|
+
function ddnsProviderInfos() {
|
|
4102
|
+
return DDNS_PROVIDERS.map((provider) => ({
|
|
4103
|
+
id: provider.id,
|
|
4104
|
+
label: provider.label,
|
|
4105
|
+
docsUrl: provider.docsUrl,
|
|
4106
|
+
families: provider.families,
|
|
4107
|
+
fields: provider.fields,
|
|
4108
|
+
ttl: provider.ttl,
|
|
4109
|
+
proxied: provider.proxied
|
|
4110
|
+
}));
|
|
4111
|
+
}
|
|
4112
|
+
/**
|
|
4113
|
+
* Cross-field problems ArkType cannot see. Credentials are deliberately not
|
|
4114
|
+
* required here: an account is normally added before its secret is typed in,
|
|
4115
|
+
* and a missing secret is reported per record instead of blocking the save.
|
|
4116
|
+
*/
|
|
4117
|
+
function validateDdnsConfig(config) {
|
|
4118
|
+
const errors = [];
|
|
4119
|
+
const seenAccounts = /* @__PURE__ */ new Set();
|
|
4120
|
+
for (const account of config.accounts) {
|
|
4121
|
+
if (seenAccounts.has(account.id)) {
|
|
4122
|
+
errors.push(`account "${account.id}" is declared twice`);
|
|
4123
|
+
continue;
|
|
4124
|
+
}
|
|
4125
|
+
seenAccounts.add(account.id);
|
|
4126
|
+
if (ddnsProvider(account.provider) === null) errors.push(`account "${account.id}" uses an unknown provider "${account.provider}"`);
|
|
4127
|
+
}
|
|
4128
|
+
const seenRecords = /* @__PURE__ */ new Set();
|
|
4129
|
+
for (const domain of config.domains) {
|
|
4130
|
+
const account = config.accounts.find((entry) => entry.id === domain.account);
|
|
4131
|
+
if (account === void 0) {
|
|
4132
|
+
errors.push(`"${domain.host}" points at unknown account "${domain.account}"`);
|
|
4133
|
+
continue;
|
|
4134
|
+
}
|
|
4135
|
+
const provider = ddnsProvider(account.provider);
|
|
4136
|
+
if (provider !== null) for (const recordType of domain.types) {
|
|
4137
|
+
if (!provider.families.includes(recordType)) errors.push(`${account.label || provider.label} cannot manage ${recordType} records ("${domain.host}")`);
|
|
4138
|
+
const key = `${domain.host.toLowerCase()}|${recordType}`;
|
|
4139
|
+
if (seenRecords.has(key)) errors.push(`"${domain.host}" asks for ${recordType} twice`);
|
|
4140
|
+
seenRecords.add(key);
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
return errors;
|
|
4144
|
+
}
|
|
4145
|
+
var DDNS_PROVIDERS, BY_ID;
|
|
4146
|
+
var init_ddns$2 = __esmMin((() => {
|
|
4147
|
+
init_cloudflare();
|
|
4148
|
+
init_desec();
|
|
4149
|
+
init_digitalocean();
|
|
4150
|
+
init_duckdns();
|
|
4151
|
+
init_dynu();
|
|
4152
|
+
init_freedns();
|
|
4153
|
+
init_gandi();
|
|
4154
|
+
init_godaddy();
|
|
4155
|
+
init_henet();
|
|
4156
|
+
init_namecheap();
|
|
4157
|
+
init_noip();
|
|
4158
|
+
init_porkbun();
|
|
4159
|
+
init_spaceship();
|
|
4160
|
+
DDNS_PROVIDERS = [
|
|
4161
|
+
cloudflareProvider,
|
|
4162
|
+
namecheapProvider,
|
|
4163
|
+
spaceshipProvider,
|
|
4164
|
+
porkbunProvider,
|
|
4165
|
+
godaddyProvider,
|
|
4166
|
+
gandiProvider,
|
|
4167
|
+
digitaloceanProvider,
|
|
4168
|
+
desecProvider,
|
|
4169
|
+
duckdnsProvider,
|
|
4170
|
+
noipProvider,
|
|
4171
|
+
dynuProvider,
|
|
4172
|
+
henetProvider,
|
|
4173
|
+
freednsProvider
|
|
4174
|
+
];
|
|
4175
|
+
BY_ID = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
|
|
4176
|
+
}));
|
|
4177
|
+
//#endregion
|
|
4178
|
+
//#region src/api/ddns.ts
|
|
4179
|
+
/**
|
|
4180
|
+
* Dynamic DNS is configured as one whole block, not as settings patches: its
|
|
4181
|
+
* accounts and hostnames are lists, and a key-by-key merge would leave a removed
|
|
4182
|
+
* account or hostname behind. Provider credentials never come back over the API —
|
|
4183
|
+
* the view only says which accounts have them.
|
|
4184
|
+
*/
|
|
4185
|
+
function createDdnsRoute(deps) {
|
|
4186
|
+
const view = () => ({
|
|
4187
|
+
config: deps.store.config.ddns,
|
|
4188
|
+
status: deps.ddns.view,
|
|
4189
|
+
providers: ddnsProviderInfos(),
|
|
4190
|
+
credentials: deps.secrets.ddnsAccountIds
|
|
4191
|
+
});
|
|
4192
|
+
return appFactory.createApp().get("/ddns", describeRoute({
|
|
4193
|
+
tags: ["ddns"],
|
|
4194
|
+
summary: "Dynamic DNS policy, live state and the providers this build knows",
|
|
4195
|
+
responses: { 200: {
|
|
4196
|
+
description: "The DDNS view",
|
|
4197
|
+
content: jsonBody(ddnsViewSchema)
|
|
4198
|
+
} }
|
|
4199
|
+
}), (c) => c.json(view())).put("/ddns", describeRoute({
|
|
4200
|
+
tags: ["ddns"],
|
|
4201
|
+
summary: "Replace the dynamic DNS block",
|
|
4202
|
+
responses: {
|
|
4203
|
+
200: {
|
|
4204
|
+
description: "Saved",
|
|
4205
|
+
content: jsonBody(ddnsViewSchema)
|
|
4206
|
+
},
|
|
4207
|
+
400: ERROR_RESPONSES[400]
|
|
4208
|
+
}
|
|
4209
|
+
}), validate("json", ddnsConfigSchema), (c) => {
|
|
4210
|
+
const config = c.req.valid("json");
|
|
4211
|
+
const problems = validateDdnsConfig(config);
|
|
4212
|
+
if (problems.length > 0) throw new DetailedError(`invalid DDNS configuration: ${problems.join("; ")}`, {
|
|
4213
|
+
statusCode: 400,
|
|
4214
|
+
code: "INVALID_DDNS",
|
|
4215
|
+
detail: problems
|
|
4216
|
+
});
|
|
4217
|
+
try {
|
|
4218
|
+
deps.store.updateDdns(config);
|
|
4219
|
+
} catch (error) {
|
|
4220
|
+
if (error instanceof ConfigError) throw new DetailedError(error.message, {
|
|
4221
|
+
statusCode: 400,
|
|
4222
|
+
code: "INVALID_DDNS"
|
|
4223
|
+
});
|
|
4224
|
+
throw error;
|
|
4225
|
+
}
|
|
4226
|
+
deps.ddns.refresh();
|
|
4227
|
+
return c.json(view());
|
|
4228
|
+
}).put("/ddns/credentials/:id", describeRoute({
|
|
4229
|
+
tags: ["ddns"],
|
|
4230
|
+
summary: "Store the credentials of one DDNS account",
|
|
4231
|
+
responses: {
|
|
4232
|
+
200: {
|
|
4233
|
+
description: "Stored",
|
|
4234
|
+
content: jsonBody(ddnsViewSchema)
|
|
4235
|
+
},
|
|
4236
|
+
400: ERROR_RESPONSES[400]
|
|
4237
|
+
}
|
|
4238
|
+
}), validate("param", accountParam), validate("json", ddnsCredentialsSchema), (c) => {
|
|
4239
|
+
const { id } = c.req.valid("param");
|
|
4240
|
+
const account = deps.store.config.ddns.accounts.find((entry) => entry.id === id);
|
|
4241
|
+
if (account === void 0) throw new DetailedError(`unknown DDNS account "${id}"`, {
|
|
4242
|
+
statusCode: 400,
|
|
4243
|
+
code: "UNKNOWN_DDNS_ACCOUNT"
|
|
4244
|
+
});
|
|
4245
|
+
const provider = ddnsProvider(account.provider);
|
|
4246
|
+
if (provider === null) throw new DetailedError(`unknown DDNS provider "${account.provider}"`, {
|
|
4247
|
+
statusCode: 400,
|
|
4248
|
+
code: "UNKNOWN_DDNS_PROVIDER"
|
|
4249
|
+
});
|
|
4250
|
+
const { credentials } = c.req.valid("json");
|
|
4251
|
+
const problem = provider.validate(credentials);
|
|
4252
|
+
if (problem !== null) throw new DetailedError(`${provider.label}: ${problem}`, {
|
|
4253
|
+
statusCode: 400,
|
|
4254
|
+
code: "INVALID_DDNS_CREDENTIALS"
|
|
4255
|
+
});
|
|
4256
|
+
deps.secrets.setDdnsCredentials(id, credentials);
|
|
4257
|
+
deps.ddns.refresh();
|
|
4258
|
+
return c.json(view());
|
|
4259
|
+
}).delete("/ddns/credentials/:id", describeRoute({
|
|
4260
|
+
tags: ["ddns"],
|
|
4261
|
+
summary: "Forget the credentials of one DDNS account",
|
|
4262
|
+
responses: { 200: {
|
|
4263
|
+
description: "Removed",
|
|
4264
|
+
content: jsonBody(ddnsViewSchema)
|
|
4265
|
+
} }
|
|
4266
|
+
}), validate("param", accountParam), (c) => {
|
|
4267
|
+
deps.secrets.clearDdnsCredentials(c.req.valid("param").id);
|
|
4268
|
+
deps.ddns.refresh();
|
|
4269
|
+
return c.json(view());
|
|
4270
|
+
}).post("/ddns/check", describeRoute({
|
|
4271
|
+
tags: ["ddns"],
|
|
4272
|
+
summary: "Run a dynamic DNS pass now",
|
|
4273
|
+
responses: { 200: {
|
|
4274
|
+
description: "The pass, with its result",
|
|
4275
|
+
content: jsonBody(ddnsViewSchema)
|
|
4276
|
+
} }
|
|
4277
|
+
}), async (c) => {
|
|
4278
|
+
await deps.ddns.run({ force: true });
|
|
4279
|
+
return c.json(view());
|
|
4280
|
+
});
|
|
4281
|
+
}
|
|
4282
|
+
var accountParam;
|
|
4283
|
+
var init_ddns$1 = __esmMin((() => {
|
|
4284
|
+
init_store();
|
|
4285
|
+
init_factory();
|
|
4286
|
+
init_openapi$1();
|
|
4287
|
+
init_validator();
|
|
4288
|
+
init_ddns$2();
|
|
4289
|
+
init_contracts();
|
|
4290
|
+
accountParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
|
|
4291
|
+
}));
|
|
4292
|
+
//#endregion
|
|
4293
|
+
//#region src/api/events.ts
|
|
4294
|
+
/**
|
|
4295
|
+
* One SSE stream per subscriber. Pass `?serverId=<id>` to receive only that
|
|
4296
|
+
* server's messages; the first frame always carries the full state snapshot.
|
|
4297
|
+
*/
|
|
4298
|
+
function createEventsRoute(deps) {
|
|
4299
|
+
return appFactory.createApp().get("/events", describeRoute({
|
|
4300
|
+
tags: ["panel"],
|
|
4301
|
+
summary: "Panel state and log frames as server-sent events",
|
|
4302
|
+
responses: { 200: { description: "text/event-stream" } }
|
|
4303
|
+
}), validate("query", eventsQuery), (c) => {
|
|
4304
|
+
const query = c.req.valid("query");
|
|
4305
|
+
const serverId = query.serverId ?? null;
|
|
4306
|
+
const logOnly = query.logs !== "0";
|
|
4307
|
+
return streamSSE(c, async (stream) => {
|
|
4308
|
+
let pending = 0;
|
|
4309
|
+
let queue = Promise.resolve();
|
|
4310
|
+
let closed = false;
|
|
4311
|
+
const send = (message) => {
|
|
4312
|
+
if (closed) return queue;
|
|
4313
|
+
if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
|
|
4314
|
+
pending += 1;
|
|
4315
|
+
queue = queue.then(() => stream.writeSSE({
|
|
4316
|
+
event: message.type,
|
|
4317
|
+
data: JSON.stringify(message)
|
|
4318
|
+
})).catch(() => {
|
|
4319
|
+
closed = true;
|
|
4320
|
+
}).finally(() => {
|
|
4321
|
+
pending -= 1;
|
|
4322
|
+
});
|
|
4323
|
+
return queue;
|
|
4324
|
+
};
|
|
4325
|
+
const unsubscribe = deps.hub.subscribe(serverId, (message) => {
|
|
4326
|
+
if (!logOnly && message.type === "log") return;
|
|
4327
|
+
send(message);
|
|
4328
|
+
});
|
|
4329
|
+
stream.onAbort(() => {
|
|
4330
|
+
closed = true;
|
|
4331
|
+
unsubscribe();
|
|
4332
|
+
});
|
|
4333
|
+
await send({
|
|
4334
|
+
type: "hello",
|
|
4335
|
+
ts: Date.now(),
|
|
4336
|
+
state: deps.supervisor.getState()
|
|
4337
|
+
});
|
|
4338
|
+
while (true) {
|
|
4339
|
+
await stream.sleep(PING_INTERVAL_MS);
|
|
4340
|
+
if (closed) break;
|
|
4341
|
+
await stream.writeSSE({
|
|
4342
|
+
event: "ping",
|
|
4343
|
+
data: String(Date.now())
|
|
4344
|
+
});
|
|
4345
|
+
}
|
|
4346
|
+
});
|
|
4347
|
+
});
|
|
4348
|
+
}
|
|
4349
|
+
var MAX_PENDING_WRITES$1, PING_INTERVAL_MS, eventsQuery;
|
|
4350
|
+
var init_events$1 = __esmMin((() => {
|
|
4351
|
+
init_factory();
|
|
4352
|
+
init_validator();
|
|
4353
|
+
MAX_PENDING_WRITES$1 = 200;
|
|
4354
|
+
PING_INTERVAL_MS = 15e3;
|
|
4355
|
+
eventsQuery = type({
|
|
4356
|
+
/** Only this server's frames. */
|
|
4357
|
+
"serverId?": "string",
|
|
4358
|
+
/** `logs=0` drops log frames; the first frame is always the full state. */
|
|
4359
|
+
"logs?": "string"
|
|
4360
|
+
});
|
|
4361
|
+
}));
|
|
4362
|
+
//#endregion
|
|
4363
|
+
//#region src/api/health.ts
|
|
4364
|
+
/**
|
|
4365
|
+
* Liveness for external monitors. Mounted outside `/api`, so it answers without a
|
|
4366
|
+
* session: it reports whether the panel itself is serving, and 503 when an
|
|
4367
|
+
* autostart server has crashed. Server details are only included for an
|
|
4368
|
+
* authenticated caller.
|
|
4369
|
+
*/
|
|
4370
|
+
function createHealthRoute(deps) {
|
|
4371
|
+
return appFactory.createApp().get("/healthz", describeRoute({
|
|
4372
|
+
tags: ["panel"],
|
|
4373
|
+
summary: "Liveness for monitors — no session required",
|
|
4374
|
+
responses: {
|
|
4375
|
+
200: {
|
|
4376
|
+
description: "Serving",
|
|
4377
|
+
content: jsonBody(type({
|
|
4378
|
+
"status": "\"ok\" | \"degraded\"",
|
|
4379
|
+
"uptimeMs": "number",
|
|
4380
|
+
"servers?": type({
|
|
4381
|
+
total: "number",
|
|
4382
|
+
running: "number",
|
|
4383
|
+
crashed: "number",
|
|
4384
|
+
unhealthy: "number"
|
|
4385
|
+
}),
|
|
4386
|
+
"hostAlerts?": "string[]"
|
|
4387
|
+
}))
|
|
4388
|
+
},
|
|
4389
|
+
503: { description: "An autostart server has crashed" }
|
|
4390
|
+
}
|
|
4391
|
+
}), (c) => {
|
|
4392
|
+
const state = deps.supervisor.getState();
|
|
4393
|
+
const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
4394
|
+
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
4395
|
+
return c.json({
|
|
4396
|
+
status: broken.length > 0 ? "degraded" : "ok",
|
|
4397
|
+
uptimeMs: Math.round(process.uptime() * 1e3),
|
|
4398
|
+
...authenticated ? {
|
|
4399
|
+
servers: {
|
|
4400
|
+
total: state.servers.length,
|
|
4401
|
+
running: state.servers.filter((server) => server.status === "running").length,
|
|
4402
|
+
crashed: state.servers.filter((server) => server.status === "crashed").length,
|
|
4403
|
+
unhealthy: state.servers.filter((server) => server.health === "unhealthy").length
|
|
4404
|
+
},
|
|
4405
|
+
hostAlerts: state.host.alerts
|
|
4406
|
+
} : {}
|
|
4407
|
+
}, broken.length > 0 ? 503 : 200);
|
|
4408
|
+
});
|
|
4409
|
+
}
|
|
4410
|
+
var init_health = __esmMin((() => {
|
|
4411
|
+
init_factory();
|
|
4412
|
+
init_openapi$1();
|
|
4413
|
+
init_auth();
|
|
4414
|
+
}));
|
|
4415
|
+
//#endregion
|
|
4416
|
+
//#region src/api/logs.ts
|
|
4417
|
+
function unknownServer$1(id) {
|
|
4418
|
+
return new DetailedError(`unknown server "${id}"`, {
|
|
4419
|
+
statusCode: 404,
|
|
4420
|
+
code: "UNKNOWN_SERVER"
|
|
4421
|
+
});
|
|
4422
|
+
}
|
|
4423
|
+
function createLogsRoute(deps) {
|
|
4424
|
+
return appFactory.createApp().get("/logs", describeRoute({
|
|
4425
|
+
tags: ["logs"],
|
|
4426
|
+
summary: "Every server with its on-disk log files",
|
|
4427
|
+
responses: { 200: {
|
|
4428
|
+
description: "Log sources",
|
|
4429
|
+
content: jsonBody(logServersViewSchema)
|
|
4430
|
+
} }
|
|
4431
|
+
}), (c) => c.json({ servers: deps.supervisor.views().map((server) => ({
|
|
4432
|
+
serverId: server.id,
|
|
4433
|
+
label: server.config.label ?? server.id,
|
|
4434
|
+
status: server.status,
|
|
4435
|
+
...deps.logFiles.info(server.id)
|
|
4436
|
+
})) })).get("/logs/:id", describeRoute({
|
|
4437
|
+
tags: ["logs"],
|
|
4438
|
+
summary: "Persisted log lines, with search and a stream filter",
|
|
4439
|
+
responses: {
|
|
4440
|
+
200: { description: "Lines" },
|
|
4441
|
+
404: ERROR_RESPONSES[404]
|
|
4442
|
+
}
|
|
4443
|
+
}), validate("param", idParam$1), validate("query", logHistoryQuerySchema), (c) => {
|
|
4444
|
+
const { id } = c.req.valid("param");
|
|
4445
|
+
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
4446
|
+
const query = c.req.valid("query");
|
|
4447
|
+
const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
|
|
4448
|
+
const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
|
|
4449
|
+
const info = deps.logFiles.info(id);
|
|
4450
|
+
const search = query.search?.trim() ?? "";
|
|
4451
|
+
const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
|
|
4452
|
+
let lines = deps.logFiles.readTail(id, window);
|
|
4453
|
+
if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
|
|
4454
|
+
if (search.length > 0) {
|
|
4455
|
+
const needle = search.toLowerCase();
|
|
4456
|
+
lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
4457
|
+
}
|
|
4458
|
+
return c.json({
|
|
4459
|
+
serverId: id,
|
|
4460
|
+
enabled: info.enabled,
|
|
4461
|
+
sizeBytes: info.sizeBytes,
|
|
4462
|
+
files: info.files.map((file) => file.name),
|
|
4463
|
+
searched: search.length > 0 ? window : null,
|
|
4464
|
+
lines: lines.slice(-tail)
|
|
4465
|
+
});
|
|
4466
|
+
}).get("/logs/:id/download", describeRoute({
|
|
4467
|
+
tags: ["logs"],
|
|
4468
|
+
summary: "Download one rotated log file",
|
|
4469
|
+
responses: {
|
|
4470
|
+
200: { description: "application/x-ndjson" },
|
|
4471
|
+
404: ERROR_RESPONSES[404]
|
|
4472
|
+
}
|
|
4473
|
+
}), validate("param", idParam$1), validate("query", downloadQuery), (c) => {
|
|
4474
|
+
const { id } = c.req.valid("param");
|
|
4475
|
+
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
4476
|
+
const requested = c.req.valid("query").file ?? `${id}.log`;
|
|
4477
|
+
if (!deps.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
|
|
4478
|
+
statusCode: 404,
|
|
4479
|
+
code: "UNKNOWN_LOG_FILE"
|
|
4480
|
+
});
|
|
4481
|
+
const file = path.join(deps.logFiles.directory, requested);
|
|
4482
|
+
const body = fs.readFileSync(file);
|
|
4483
|
+
return c.body(body, 200, {
|
|
4484
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
4485
|
+
"Content-Length": String(body.byteLength),
|
|
4486
|
+
"Content-Disposition": `attachment; filename="${requested}"`
|
|
4487
|
+
});
|
|
4488
|
+
}).delete("/logs/:id", describeRoute({
|
|
4489
|
+
tags: ["logs"],
|
|
4490
|
+
summary: "Delete the persisted logs of one server",
|
|
4491
|
+
responses: {
|
|
4492
|
+
200: { description: "Cleared" },
|
|
4493
|
+
404: ERROR_RESPONSES[404]
|
|
4494
|
+
}
|
|
4495
|
+
}), validate("param", idParam$1), (c) => {
|
|
4496
|
+
const { id } = c.req.valid("param");
|
|
4497
|
+
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
4498
|
+
deps.logFiles.clear(id);
|
|
4499
|
+
return c.json({ ok: true });
|
|
4500
|
+
});
|
|
4501
|
+
}
|
|
4502
|
+
var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$1, downloadQuery;
|
|
4503
|
+
var init_logs = __esmMin((() => {
|
|
4504
|
+
init_factory();
|
|
4505
|
+
init_openapi$1();
|
|
4506
|
+
init_validator();
|
|
4507
|
+
init_contracts();
|
|
4508
|
+
MIN_TAIL = 50;
|
|
4509
|
+
MAX_TAIL = 5e3;
|
|
4510
|
+
DEFAULT_TAIL = 500;
|
|
4511
|
+
idParam$1 = type({ id: "string >= 1" });
|
|
4512
|
+
downloadQuery = type({ "file?": "string" });
|
|
4513
|
+
}));
|
|
4514
|
+
//#endregion
|
|
4515
|
+
//#region src/api/metrics.ts
|
|
4516
|
+
/**
|
|
4517
|
+
* Prometheus text for whatever wants to scrape the panel (Beszel, Grafana,
|
|
4518
|
+
* `curl`). Lives under `/api`, so it needs a session like every other route.
|
|
4519
|
+
*/
|
|
4520
|
+
function createMetricsRoute(deps) {
|
|
4521
|
+
return appFactory.createApp().get("/metrics", describeRoute({
|
|
4522
|
+
tags: ["panel"],
|
|
4523
|
+
summary: "Prometheus text for whatever scrapes the panel",
|
|
4524
|
+
responses: { 200: { description: "text/plain; version=0.0.4" } }
|
|
4525
|
+
}), (c) => {
|
|
4526
|
+
const state = deps.supervisor.getState();
|
|
4527
|
+
const lines = [];
|
|
4528
|
+
const metric = (name, help, samples) => {
|
|
4529
|
+
if (samples.length === 0) return;
|
|
4530
|
+
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
4531
|
+
};
|
|
4532
|
+
metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
|
|
4533
|
+
metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
|
|
4534
|
+
metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
|
|
4535
|
+
metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
|
|
4536
|
+
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}`));
|
|
4537
|
+
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)}`));
|
|
4538
|
+
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}`));
|
|
4539
|
+
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}`));
|
|
4540
|
+
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}`));
|
|
4541
|
+
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)}`));
|
|
4542
|
+
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
4543
|
+
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
4544
|
+
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)}`]);
|
|
4545
|
+
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
4546
|
+
});
|
|
4547
|
+
}
|
|
4548
|
+
var init_metrics = __esmMin((() => {
|
|
4549
|
+
init_factory();
|
|
4550
|
+
}));
|
|
4551
|
+
//#endregion
|
|
4552
|
+
//#region src/providers/telegram.ts
|
|
4553
|
+
function getBot(token) {
|
|
4554
|
+
const cached = bots.get(token);
|
|
4555
|
+
if (cached) return cached;
|
|
4556
|
+
const bot = new Bot(token, { client: { timeoutSeconds: 10 } });
|
|
4557
|
+
bot.api.config.use(autoRetry({
|
|
4558
|
+
maxRetryAttempts: 3,
|
|
4559
|
+
maxDelaySeconds: 20
|
|
4560
|
+
}));
|
|
4561
|
+
bots.set(token, bot);
|
|
4562
|
+
return bot;
|
|
4563
|
+
}
|
|
4564
|
+
/** Drops cached clients; used when the token changes or the service shuts down. */
|
|
4565
|
+
function forgetBots() {
|
|
4566
|
+
bots.clear();
|
|
4567
|
+
}
|
|
4568
|
+
function escapeHtml(value) {
|
|
4569
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4570
|
+
}
|
|
4571
|
+
function formatTelegramMessage(title, lines) {
|
|
4572
|
+
const body = lines.filter((line) => line.length > 0).map((line) => `• ${escapeHtml(line)}`).join("\n");
|
|
4573
|
+
return `<b>${escapeHtml(title)}</b>${body.length > 0 ? `\n${body}` : ""}`;
|
|
4574
|
+
}
|
|
4575
|
+
/** Turns a grammY error into something worth showing in the settings page. */
|
|
4576
|
+
function describeTelegramError(error) {
|
|
4577
|
+
if (typeof error === "object" && error !== null) {
|
|
4578
|
+
const candidate = error;
|
|
4579
|
+
const description = candidate.description ?? candidate.message;
|
|
4580
|
+
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`}`;
|
|
4581
|
+
}
|
|
4582
|
+
return error instanceof Error ? error.message : String(error);
|
|
4583
|
+
}
|
|
4584
|
+
async function sendTelegramMessage(token, chatId, html) {
|
|
4585
|
+
try {
|
|
4586
|
+
await getBot(token).api.sendMessage(chatId, html, {
|
|
4587
|
+
parse_mode: "HTML",
|
|
4588
|
+
link_preview_options: { is_disabled: true }
|
|
4589
|
+
});
|
|
4590
|
+
return { ok: true };
|
|
4591
|
+
} catch (error) {
|
|
4592
|
+
return {
|
|
4593
|
+
ok: false,
|
|
4594
|
+
error: describeTelegramError(error)
|
|
4595
|
+
};
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
async function verifyTelegramToken(token) {
|
|
4599
|
+
try {
|
|
4600
|
+
return {
|
|
4601
|
+
ok: true,
|
|
4602
|
+
username: (await getBot(token).api.getMe()).username
|
|
4603
|
+
};
|
|
4604
|
+
} catch (error) {
|
|
4605
|
+
return {
|
|
4606
|
+
ok: false,
|
|
4607
|
+
error: describeTelegramError(error)
|
|
4608
|
+
};
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
/**
|
|
4612
|
+
* Recent chats that talked to the bot, so a chat id can be picked instead of
|
|
4613
|
+
* hunted down by hand. Telegram only reports chats with pending updates, so the
|
|
4614
|
+
* caller is told to message the bot first.
|
|
4615
|
+
*/
|
|
4616
|
+
async function listTelegramChats(token) {
|
|
4617
|
+
try {
|
|
4618
|
+
const updates = await getBot(token).api.getUpdates({
|
|
4619
|
+
limit: 100,
|
|
4620
|
+
allowed_updates: [
|
|
4621
|
+
"message",
|
|
4622
|
+
"channel_post",
|
|
4623
|
+
"edited_message"
|
|
4624
|
+
]
|
|
4625
|
+
});
|
|
4626
|
+
const chats = /* @__PURE__ */ new Map();
|
|
4627
|
+
for (const update of updates) {
|
|
4628
|
+
const chat = update.message?.chat ?? update.channel_post?.chat ?? update.edited_message?.chat;
|
|
4629
|
+
if (!chat) continue;
|
|
4630
|
+
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";
|
|
4631
|
+
chats.set(String(chat.id), {
|
|
4632
|
+
id: chat.id,
|
|
4633
|
+
title
|
|
4634
|
+
});
|
|
4635
|
+
}
|
|
4636
|
+
return {
|
|
4637
|
+
ok: true,
|
|
4638
|
+
chats: [...chats.values()]
|
|
4639
|
+
};
|
|
4640
|
+
} catch (error) {
|
|
4641
|
+
return {
|
|
4642
|
+
ok: false,
|
|
4643
|
+
chats: [],
|
|
4644
|
+
error: describeTelegramError(error)
|
|
4645
|
+
};
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
var bots;
|
|
4649
|
+
var init_telegram = __esmMin((() => {
|
|
4650
|
+
bots = /* @__PURE__ */ new Map();
|
|
4651
|
+
}));
|
|
4652
|
+
//#endregion
|
|
4653
|
+
//#region src/api/notifications.ts
|
|
4654
|
+
/**
|
|
4655
|
+
* The bot token is written straight to the secrets file and never into
|
|
4656
|
+
* `servers.config.json`, so notification *policy* and the *credential* stay
|
|
4657
|
+
* separate.
|
|
4658
|
+
*/
|
|
4659
|
+
function createNotificationsRoute(deps) {
|
|
4660
|
+
return appFactory.createApp().put("/notifications/token", describeRoute({
|
|
4661
|
+
tags: ["notifications"],
|
|
4662
|
+
summary: "Store the Telegram bot token (verified first)",
|
|
4663
|
+
responses: {
|
|
4664
|
+
200: { description: "Stored" },
|
|
4665
|
+
400: ERROR_RESPONSES[400]
|
|
3262
4666
|
}
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
4667
|
+
}), validate("json", telegramTokenSchema), async (c) => {
|
|
4668
|
+
const { botToken } = c.req.valid("json");
|
|
4669
|
+
const verified = await deps.notifications.verifyToken(botToken);
|
|
4670
|
+
if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
|
|
4671
|
+
statusCode: 400,
|
|
4672
|
+
code: "TELEGRAM_TOKEN_REJECTED"
|
|
4673
|
+
});
|
|
4674
|
+
deps.secrets.setTelegramToken(botToken);
|
|
4675
|
+
forgetBots();
|
|
4676
|
+
return c.json({
|
|
4677
|
+
ok: true,
|
|
4678
|
+
username: verified.username ?? null
|
|
4679
|
+
});
|
|
4680
|
+
}).delete("/notifications/token", describeRoute({
|
|
4681
|
+
tags: ["notifications"],
|
|
4682
|
+
summary: "Forget the Telegram bot token",
|
|
4683
|
+
responses: { 200: { description: "Removed" } }
|
|
4684
|
+
}), (c) => {
|
|
4685
|
+
deps.secrets.setTelegramToken(null);
|
|
4686
|
+
forgetBots();
|
|
4687
|
+
return c.json({ ok: true });
|
|
4688
|
+
}).post("/notifications/test", describeRoute({
|
|
4689
|
+
tags: ["notifications"],
|
|
4690
|
+
summary: "Send a test message",
|
|
4691
|
+
responses: {
|
|
4692
|
+
200: { description: "Sent or refused" },
|
|
4693
|
+
400: ERROR_RESPONSES[400]
|
|
3281
4694
|
}
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
4695
|
+
}), validate("json", notificationActionSchema), async (c) => {
|
|
4696
|
+
const result = await deps.notifications.sendTest(c.req.valid("json"));
|
|
4697
|
+
if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
|
|
4698
|
+
statusCode: 400,
|
|
4699
|
+
code: "TELEGRAM_SEND_FAILED"
|
|
4700
|
+
});
|
|
4701
|
+
return c.json(result);
|
|
4702
|
+
}).post("/notifications/detect-chats", describeRoute({
|
|
4703
|
+
tags: ["notifications"],
|
|
4704
|
+
summary: "List the chats the bot can see",
|
|
4705
|
+
responses: {
|
|
4706
|
+
200: {
|
|
4707
|
+
description: "Chats",
|
|
4708
|
+
content: jsonBody(type({ chats: type({
|
|
4709
|
+
id: "string | number",
|
|
4710
|
+
title: "string"
|
|
4711
|
+
}).array() }))
|
|
4712
|
+
},
|
|
4713
|
+
400: ERROR_RESPONSES[400]
|
|
3290
4714
|
}
|
|
3291
|
-
}
|
|
4715
|
+
}), validate("json", notificationActionSchema), async (c) => {
|
|
4716
|
+
const result = await deps.notifications.detectChats(c.req.valid("json"));
|
|
4717
|
+
if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
|
|
4718
|
+
statusCode: 400,
|
|
4719
|
+
code: "TELEGRAM_LIST_FAILED"
|
|
4720
|
+
});
|
|
4721
|
+
return c.json({ chats: result.chats });
|
|
4722
|
+
});
|
|
4723
|
+
}
|
|
4724
|
+
var init_notifications$1 = __esmMin((() => {
|
|
4725
|
+
init_factory();
|
|
4726
|
+
init_openapi$1();
|
|
4727
|
+
init_validator();
|
|
4728
|
+
init_telegram();
|
|
4729
|
+
init_contracts();
|
|
3292
4730
|
}));
|
|
3293
4731
|
//#endregion
|
|
3294
4732
|
//#region src/api/servers/$.routes.ts
|
|
@@ -3992,13 +5430,14 @@ function createRootApp(deps) {
|
|
|
3992
5430
|
const started = Date.now();
|
|
3993
5431
|
await next();
|
|
3994
5432
|
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));
|
|
5433
|
+
}).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
5434
|
return app.route("/", setupOpenAPI(app)).route("/", createStaticRoute({ dir: () => deps.ui.resolveDir() }));
|
|
3997
5435
|
}
|
|
3998
5436
|
var init_app = __esmMin((() => {
|
|
3999
5437
|
init_$_routes$1();
|
|
4000
5438
|
init_backups$1();
|
|
4001
5439
|
init_control();
|
|
5440
|
+
init_ddns$1();
|
|
4002
5441
|
init_events$1();
|
|
4003
5442
|
init_health();
|
|
4004
5443
|
init_logs();
|
|
@@ -5671,9 +7110,20 @@ var init_log_relay = __esmMin((() => {
|
|
|
5671
7110
|
this.start();
|
|
5672
7111
|
return history;
|
|
5673
7112
|
}
|
|
7113
|
+
/**
|
|
7114
|
+
* Stops following and hands back whatever the file still held unread.
|
|
7115
|
+
*
|
|
7116
|
+
* A nanny writes its last lines and exits in the same breath, so the tailer can be
|
|
7117
|
+
* dropped between two polls with its final lines — the child's crash output — still
|
|
7118
|
+
* on disk. Losing them here is what made a persistent entry's crash silent in the
|
|
7119
|
+
* panel, so the caller gets them to ingest rather than an empty hand.
|
|
7120
|
+
*/
|
|
5674
7121
|
unfollow(serverId) {
|
|
7122
|
+
const current = this.followed.get(serverId);
|
|
7123
|
+
const rest = current === void 0 ? [] : current.tailer.read();
|
|
5675
7124
|
this.followed.delete(serverId);
|
|
5676
7125
|
if (this.followed.size === 0) this.stopTimer();
|
|
7126
|
+
return rest;
|
|
5677
7127
|
}
|
|
5678
7128
|
followedIds() {
|
|
5679
7129
|
return [...this.followed.keys()];
|
|
@@ -5708,6 +7158,18 @@ var init_log_relay = __esmMin((() => {
|
|
|
5708
7158
|
}));
|
|
5709
7159
|
//#endregion
|
|
5710
7160
|
//#region src/services/supervisor.ts
|
|
7161
|
+
/** One line's exact identity: a timestamp this panel minted, its stream and its text. */
|
|
7162
|
+
function echoedKey(line) {
|
|
7163
|
+
return `${line.ts}\u0000${line.stream}\u0000${line.text}`;
|
|
7164
|
+
}
|
|
7165
|
+
function rememberEchoedLine(set, line) {
|
|
7166
|
+
set.add(echoedKey(line));
|
|
7167
|
+
while (set.size > ECHOED_LINE_LIMIT) set.delete(set.values().next().value);
|
|
7168
|
+
}
|
|
7169
|
+
/** True when this line is this panel's own echo, so it is only ever reported once. */
|
|
7170
|
+
function forgetEchoedLine(set, line) {
|
|
7171
|
+
return set.delete(echoedKey(line));
|
|
7172
|
+
}
|
|
5711
7173
|
function delay(ms) {
|
|
5712
7174
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5713
7175
|
}
|
|
@@ -5727,7 +7189,7 @@ function serverTemplateVars(config) {
|
|
|
5727
7189
|
home: os.homedir()
|
|
5728
7190
|
};
|
|
5729
7191
|
}
|
|
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;
|
|
7192
|
+
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
7193
|
var init_supervisor = __esmMin((() => {
|
|
5732
7194
|
init_backoff();
|
|
5733
7195
|
init_bind();
|
|
@@ -5752,6 +7214,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5752
7214
|
PORT_RELEASE_RECHECK_MS = 300;
|
|
5753
7215
|
RESOURCE_SAMPLE_INTERVAL_MS = 5e3;
|
|
5754
7216
|
PERSISTENT_BACKFILL_LINES = 200;
|
|
7217
|
+
ECHOED_LINE_LIMIT = 100;
|
|
5755
7218
|
Supervisor = class {
|
|
5756
7219
|
store;
|
|
5757
7220
|
hub;
|
|
@@ -6101,7 +7564,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6101
7564
|
clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
|
|
6102
7565
|
clearNannySpec(nannySpecPath(this.options.nannyDir, entry.config.id));
|
|
6103
7566
|
} else this.log(entry, "system", `pid ${survivor} is still alive after the stop — its state file is kept`);
|
|
6104
|
-
this.
|
|
7567
|
+
this.drainRelay(entry);
|
|
6105
7568
|
}
|
|
6106
7569
|
entry.stopping = false;
|
|
6107
7570
|
entry.child = null;
|
|
@@ -6143,6 +7606,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6143
7606
|
stopping: false,
|
|
6144
7607
|
bootstrapDone: !config.bootstrap,
|
|
6145
7608
|
logs: new LogBuffer(config.logBufferLines),
|
|
7609
|
+
echoed: /* @__PURE__ */ new Set(),
|
|
6146
7610
|
responseMs: null,
|
|
6147
7611
|
resources: null,
|
|
6148
7612
|
resourcesSampledAt: 0,
|
|
@@ -6162,8 +7626,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6162
7626
|
continue;
|
|
6163
7627
|
}
|
|
6164
7628
|
const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
|
|
7629
|
+
if (entry.config.persistent && !config.persistent) this.drainRelay(entry);
|
|
6165
7630
|
entry.config = config;
|
|
6166
|
-
if (!config.persistent) this.relay.unfollow(id);
|
|
6167
7631
|
if (bufferChanged) {
|
|
6168
7632
|
const kept = entry.logs.list(config.logBufferLines);
|
|
6169
7633
|
entry.logs = new LogBuffer(config.logBufferLines);
|
|
@@ -6323,8 +7787,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6323
7787
|
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
6324
7788
|
entry.startedAt = Date.now();
|
|
6325
7789
|
entry.lastError = null;
|
|
7790
|
+
if (entry.config.persistent) this.followPersistent(entry, true);
|
|
6326
7791
|
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
7792
|
this.publishServer(entry);
|
|
6329
7793
|
return { ok: true };
|
|
6330
7794
|
}
|
|
@@ -6559,25 +8023,41 @@ var init_supervisor = __esmMin((() => {
|
|
|
6559
8023
|
ingestExternalLines(serverId, lines) {
|
|
6560
8024
|
const entry = this.entries.get(serverId);
|
|
6561
8025
|
if (entry === void 0 || !entry.config.persistent || lines.length === 0) return;
|
|
6562
|
-
|
|
8026
|
+
const kept = lines.filter((line) => !forgetEchoedLine(entry.echoed, line));
|
|
8027
|
+
if (kept.length === 0) return;
|
|
8028
|
+
for (const line of kept) entry.logs.push(line);
|
|
6563
8029
|
this.hub.publish({
|
|
6564
8030
|
type: "log",
|
|
6565
|
-
ts:
|
|
8031
|
+
ts: kept[kept.length - 1].ts,
|
|
6566
8032
|
serverId,
|
|
6567
|
-
lines
|
|
8033
|
+
lines: kept
|
|
6568
8034
|
});
|
|
6569
8035
|
}
|
|
6570
8036
|
/**
|
|
8037
|
+
* Whatever the relay had not read yet, ingested before its tailer goes: a nanny's
|
|
8038
|
+
* exit and a stop both end with lines the panel has never seen.
|
|
8039
|
+
*/
|
|
8040
|
+
drainRelay(entry) {
|
|
8041
|
+
const rest = this.relay.unfollow(entry.config.id);
|
|
8042
|
+
if (rest.length > 0) this.ingestExternalLines(entry.config.id, rest);
|
|
8043
|
+
}
|
|
8044
|
+
/**
|
|
6571
8045
|
* Follows a persistent entry's log file, filling the ring buffer from what is on disk.
|
|
6572
8046
|
*
|
|
6573
8047
|
* The backfill is read *through* the tailer, which is the only thing that may own the
|
|
6574
8048
|
* offset: reading it from `LogFiles` first and seeking to EOF second would either
|
|
6575
8049
|
* replay a line as news or skip one as history, depending on which order the two
|
|
6576
8050
|
* syscalls happen to land in.
|
|
8051
|
+
*
|
|
8052
|
+
* `attach` is the caller's word for "this entry is already running, and its history is
|
|
8053
|
+
* new to this panel": only then is the file's tail history. The buffer itself cannot
|
|
8054
|
+
* answer that — spawn, adopt and reattach all log a line of their own — which is why a
|
|
8055
|
+
* `logs.size === 0` test left this backfill dead. An empty buffer is still required, so
|
|
8056
|
+
* a second attach to an entry we already hold lines for cannot replay older ones on top.
|
|
6577
8057
|
*/
|
|
6578
|
-
followPersistent(entry) {
|
|
8058
|
+
followPersistent(entry, attach = false) {
|
|
6579
8059
|
const id = entry.config.id;
|
|
6580
|
-
const backfill = entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
|
|
8060
|
+
const backfill = attach && entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
|
|
6581
8061
|
const history = this.relay.follow(id, nannyLogFile(this.options.logFiles.directory, id), { backfill });
|
|
6582
8062
|
if (history.length > 0) entry.logs.extend(history);
|
|
6583
8063
|
}
|
|
@@ -6600,8 +8080,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6600
8080
|
entry.lastError = null;
|
|
6601
8081
|
entry.nannyExit = null;
|
|
6602
8082
|
const child = state.childPid === null ? "" : `, server pid ${state.childPid}`;
|
|
8083
|
+
this.followPersistent(entry, true);
|
|
6603
8084
|
this.log(entry, "system", `persistent: still running (nanny pid ${state.nannyPid}${child}) — reattached`);
|
|
6604
|
-
this.followPersistent(entry);
|
|
6605
8085
|
this.publishServer(entry);
|
|
6606
8086
|
return true;
|
|
6607
8087
|
}
|
|
@@ -6640,7 +8120,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6640
8120
|
const state = readNannyState(statePath);
|
|
6641
8121
|
if (state?.lastExit !== void 0) entry.nannyExit = state.lastExit;
|
|
6642
8122
|
clearNannyState(statePath);
|
|
6643
|
-
this.
|
|
8123
|
+
this.drainRelay(entry);
|
|
6644
8124
|
}
|
|
6645
8125
|
/** One probe using the configured mode (TCP or HTTP), with timing. */
|
|
6646
8126
|
async probeEntryHealth(entry) {
|
|
@@ -6759,6 +8239,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6759
8239
|
if (this.disposed) return;
|
|
6760
8240
|
const now = Date.now();
|
|
6761
8241
|
await this.options.hostMonitor.tick(now);
|
|
8242
|
+
this.options.ddns.tick(now);
|
|
6762
8243
|
await this.sampleResources(now);
|
|
6763
8244
|
await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
|
|
6764
8245
|
for (const entry of this.entries.values()) {
|
|
@@ -6904,6 +8385,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6904
8385
|
};
|
|
6905
8386
|
entry.logs.push(line);
|
|
6906
8387
|
this.options.logFiles.append(entry.config.id, line);
|
|
8388
|
+
if (entry.config.persistent && this.options.logFiles.config.persist) rememberEchoedLine(entry.echoed, line);
|
|
6907
8389
|
this.hub.publish({
|
|
6908
8390
|
type: "log",
|
|
6909
8391
|
ts: line.ts,
|
|
@@ -6923,19 +8405,30 @@ var init_supervisor = __esmMin((() => {
|
|
|
6923
8405
|
}
|
|
6924
8406
|
publishState() {
|
|
6925
8407
|
const state = this.getState();
|
|
6926
|
-
const
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
server
|
|
6937
|
-
|
|
6938
|
-
|
|
8408
|
+
const ddns = state.ddns;
|
|
8409
|
+
const signature = [
|
|
8410
|
+
state.configError ?? "",
|
|
8411
|
+
ddns === void 0 ? "" : [
|
|
8412
|
+
ddns.running,
|
|
8413
|
+
ddns.lastRunAt,
|
|
8414
|
+
ddns.ipv4,
|
|
8415
|
+
ddns.ipv6,
|
|
8416
|
+
...ddns.records.map((record) => `${record.host}:${record.type}:${record.state}:${record.ip}`)
|
|
8417
|
+
].join(":"),
|
|
8418
|
+
...state.servers.map((server) => [
|
|
8419
|
+
server.id,
|
|
8420
|
+
server.status,
|
|
8421
|
+
server.health,
|
|
8422
|
+
server.portState,
|
|
8423
|
+
server.pid,
|
|
8424
|
+
server.restarts,
|
|
8425
|
+
server.nextRetryAt,
|
|
8426
|
+
server.lastError,
|
|
8427
|
+
server.bufferedLines,
|
|
8428
|
+
server.responseMs,
|
|
8429
|
+
server.resources?.sampledAt
|
|
8430
|
+
].join(":"))
|
|
8431
|
+
].join("|");
|
|
6939
8432
|
if (signature === this.lastStateSignature) return;
|
|
6940
8433
|
this.lastStateSignature = signature;
|
|
6941
8434
|
this.hub.publish({
|
|
@@ -7132,7 +8625,7 @@ function safeSlug(slug) {
|
|
|
7132
8625
|
return slug;
|
|
7133
8626
|
}
|
|
7134
8627
|
/** A flag is valid for one exact file revision, not for the name alone. */
|
|
7135
|
-
function cacheKey(file) {
|
|
8628
|
+
function cacheKey$1(file) {
|
|
7136
8629
|
return `${file.sizeBytes}:${file.createdAt}`;
|
|
7137
8630
|
}
|
|
7138
8631
|
/** Stable, filesystem-safe name for a data path inside the archive. */
|
|
@@ -7197,13 +8690,13 @@ var init_backups = __esmMin((() => {
|
|
|
7197
8690
|
const files = this.scan();
|
|
7198
8691
|
for (const file of files) {
|
|
7199
8692
|
const cached = this.flags.get(file.name);
|
|
7200
|
-
if (cached === void 0 || cached.key !== cacheKey(file)) this.scheduleRefresh();
|
|
8693
|
+
if (cached === void 0 || cached.key !== cacheKey$1(file)) this.scheduleRefresh();
|
|
7201
8694
|
}
|
|
7202
8695
|
return files.map((file) => {
|
|
7203
8696
|
const cached = this.flags.get(file.name);
|
|
7204
8697
|
return {
|
|
7205
8698
|
...file,
|
|
7206
|
-
encrypted: cached !== void 0 && cached.key === cacheKey(file) ? cached.encrypted : false
|
|
8699
|
+
encrypted: cached !== void 0 && cached.key === cacheKey$1(file) ? cached.encrypted : false
|
|
7207
8700
|
};
|
|
7208
8701
|
});
|
|
7209
8702
|
}
|
|
@@ -7528,7 +9021,7 @@ var init_backups = __esmMin((() => {
|
|
|
7528
9021
|
const dir = this.resolveDir();
|
|
7529
9022
|
const listed = this.scan();
|
|
7530
9023
|
for (const file of listed) {
|
|
7531
|
-
const key = cacheKey(file);
|
|
9024
|
+
const key = cacheKey$1(file);
|
|
7532
9025
|
if (this.flags.get(file.name)?.key === key) continue;
|
|
7533
9026
|
try {
|
|
7534
9027
|
const entries = await listZip(path.join(dir, file.name));
|
|
@@ -7764,6 +9257,370 @@ var init_control_server = __esmMin((() => {
|
|
|
7764
9257
|
};
|
|
7765
9258
|
}));
|
|
7766
9259
|
//#endregion
|
|
9260
|
+
//#region src/providers/ddns/ip.ts
|
|
9261
|
+
/** RFC1918, loopback, link-local and CGNAT are never a public DNS target. */
|
|
9262
|
+
function isPublicAddress(ip, family) {
|
|
9263
|
+
if (family === "A") {
|
|
9264
|
+
const parts = ip.split(".").map((part) => Number.parseInt(part, 10));
|
|
9265
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
9266
|
+
const [a, b] = parts;
|
|
9267
|
+
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);
|
|
9268
|
+
}
|
|
9269
|
+
const lower = ip.toLowerCase();
|
|
9270
|
+
if (lower === "::1" || lower === "::") return false;
|
|
9271
|
+
return !(lower.startsWith("fc") || lower.startsWith("fd") || /^fe[89ab]/.test(lower));
|
|
9272
|
+
}
|
|
9273
|
+
/**
|
|
9274
|
+
* Reads the address out of whatever the endpoint answered: a bare address, a
|
|
9275
|
+
* Cloudflare `cdn-cgi/trace` body (`ip=…`), or a small JSON object.
|
|
9276
|
+
*/
|
|
9277
|
+
function parseAddressResponse(text, family) {
|
|
9278
|
+
const trimmed = text.trim();
|
|
9279
|
+
if (trimmed.length === 0) return null;
|
|
9280
|
+
const candidates = [];
|
|
9281
|
+
const trace = /^ip=(.+)$/m.exec(trimmed)?.[1];
|
|
9282
|
+
if (trace !== void 0) candidates.push(trace.trim());
|
|
9283
|
+
if (trimmed.startsWith("{")) try {
|
|
9284
|
+
const parsed = JSON.parse(trimmed);
|
|
9285
|
+
for (const key of [
|
|
9286
|
+
"ip",
|
|
9287
|
+
"address",
|
|
9288
|
+
"ip_addr",
|
|
9289
|
+
"query"
|
|
9290
|
+
]) {
|
|
9291
|
+
const value = parsed[key];
|
|
9292
|
+
if (typeof value === "string") candidates.push(value.trim());
|
|
9293
|
+
}
|
|
9294
|
+
} catch {}
|
|
9295
|
+
candidates.push(trimmed);
|
|
9296
|
+
const wanted = family === "A" ? 4 : 6;
|
|
9297
|
+
for (const candidate of candidates) {
|
|
9298
|
+
const value = candidate.split(/[\s,]/)[0] ?? "";
|
|
9299
|
+
if (isIP(value) === wanted && isPublicAddress(value, family)) return value;
|
|
9300
|
+
}
|
|
9301
|
+
return null;
|
|
9302
|
+
}
|
|
9303
|
+
/**
|
|
9304
|
+
* Tries the configured endpoint, or the built-in list in order, and returns the
|
|
9305
|
+
* first address that is really public. A transient failure never deletes a
|
|
9306
|
+
* record; it just leaves the previous one in place.
|
|
9307
|
+
*/
|
|
9308
|
+
async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
|
|
9309
|
+
const configured = overrideUrl.trim();
|
|
9310
|
+
const endpoints = configured.length > 0 ? [configured] : family === "A" ? DEFAULT_IPV4_ENDPOINTS : DEFAULT_IPV6_ENDPOINTS;
|
|
9311
|
+
let lastError = null;
|
|
9312
|
+
for (const endpoint of endpoints) try {
|
|
9313
|
+
const response = await fetchImpl(endpoint, {
|
|
9314
|
+
signal,
|
|
9315
|
+
headers: { Accept: "text/plain, application/json" }
|
|
9316
|
+
});
|
|
9317
|
+
if (!response.ok) {
|
|
9318
|
+
lastError = `${endpoint} answered ${response.status}`;
|
|
9319
|
+
continue;
|
|
9320
|
+
}
|
|
9321
|
+
const parsed = parseAddressResponse(await response.text(), family);
|
|
9322
|
+
if (parsed !== null) return {
|
|
9323
|
+
ip: parsed,
|
|
9324
|
+
error: null
|
|
9325
|
+
};
|
|
9326
|
+
lastError = `${endpoint} did not answer with a public ${family === "A" ? "IPv4" : "IPv6"} address`;
|
|
9327
|
+
} catch (error) {
|
|
9328
|
+
lastError = `${endpoint}: ${error instanceof Error ? error.message : String(error)}`;
|
|
9329
|
+
}
|
|
9330
|
+
return {
|
|
9331
|
+
ip: null,
|
|
9332
|
+
error: lastError ?? `no endpoint for ${family}`
|
|
9333
|
+
};
|
|
9334
|
+
}
|
|
9335
|
+
var DEFAULT_IPV4_ENDPOINTS, DEFAULT_IPV6_ENDPOINTS;
|
|
9336
|
+
var init_ip = __esmMin((() => {
|
|
9337
|
+
DEFAULT_IPV4_ENDPOINTS = [
|
|
9338
|
+
"https://api4.ipify.org",
|
|
9339
|
+
"https://ipv4.icanhazip.com",
|
|
9340
|
+
"https://checkip.amazonaws.com",
|
|
9341
|
+
"https://4.ident.me",
|
|
9342
|
+
"https://cloudflare.com/cdn-cgi/trace"
|
|
9343
|
+
];
|
|
9344
|
+
DEFAULT_IPV6_ENDPOINTS = [
|
|
9345
|
+
"https://api6.ipify.org",
|
|
9346
|
+
"https://ipv6.icanhazip.com",
|
|
9347
|
+
"https://6.ident.me",
|
|
9348
|
+
"https://cloudflare.com/cdn-cgi/trace"
|
|
9349
|
+
];
|
|
9350
|
+
}));
|
|
9351
|
+
//#endregion
|
|
9352
|
+
//#region src/services/ddns.ts
|
|
9353
|
+
function emptyState() {
|
|
9354
|
+
return {
|
|
9355
|
+
version: 1,
|
|
9356
|
+
updatedAt: 0,
|
|
9357
|
+
records: {}
|
|
9358
|
+
};
|
|
9359
|
+
}
|
|
9360
|
+
function readState(file) {
|
|
9361
|
+
try {
|
|
9362
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
9363
|
+
const records = {};
|
|
9364
|
+
for (const [key, value] of Object.entries(parsed?.records ?? {})) if (typeof value?.ip === "string") records[key] = value;
|
|
9365
|
+
return {
|
|
9366
|
+
version: 1,
|
|
9367
|
+
updatedAt: typeof parsed?.updatedAt === "number" ? parsed.updatedAt : 0,
|
|
9368
|
+
records
|
|
9369
|
+
};
|
|
9370
|
+
} catch {
|
|
9371
|
+
return emptyState();
|
|
9372
|
+
}
|
|
9373
|
+
}
|
|
9374
|
+
function cacheKey(host, type) {
|
|
9375
|
+
return `${normalizeHost(host)}|${type}`;
|
|
9376
|
+
}
|
|
9377
|
+
var RUN_TIMEOUT_MS, DdnsService;
|
|
9378
|
+
var init_ddns = __esmMin((() => {
|
|
9379
|
+
init_atomic();
|
|
9380
|
+
init_logger();
|
|
9381
|
+
init_ddns$2();
|
|
9382
|
+
init_ip();
|
|
9383
|
+
init_types();
|
|
9384
|
+
RUN_TIMEOUT_MS = 6e4;
|
|
9385
|
+
DdnsService = class {
|
|
9386
|
+
deps;
|
|
9387
|
+
results = /* @__PURE__ */ new Map();
|
|
9388
|
+
persisted;
|
|
9389
|
+
running = false;
|
|
9390
|
+
disposed = false;
|
|
9391
|
+
/** Null until the first pass, so a fresh panel looks immediately. */
|
|
9392
|
+
lastStartedAt = null;
|
|
9393
|
+
lastRunAt = null;
|
|
9394
|
+
lastResult = null;
|
|
9395
|
+
ipv4 = null;
|
|
9396
|
+
ipv6 = null;
|
|
9397
|
+
constructor(deps) {
|
|
9398
|
+
this.deps = deps;
|
|
9399
|
+
this.persisted = readState(deps.statePath);
|
|
9400
|
+
}
|
|
9401
|
+
now() {
|
|
9402
|
+
return this.deps.now?.() ?? Date.now();
|
|
9403
|
+
}
|
|
9404
|
+
/** What the state frame and the settings page read. */
|
|
9405
|
+
get view() {
|
|
9406
|
+
return {
|
|
9407
|
+
enabled: this.deps.getConfig().enabled,
|
|
9408
|
+
running: this.running,
|
|
9409
|
+
lastRunAt: this.lastRunAt,
|
|
9410
|
+
lastResult: this.lastResult,
|
|
9411
|
+
ipv4: this.ipv4,
|
|
9412
|
+
ipv6: this.ipv6,
|
|
9413
|
+
records: this.records()
|
|
9414
|
+
};
|
|
9415
|
+
}
|
|
9416
|
+
/** One entry per configured target, with whatever the last pass learned. */
|
|
9417
|
+
records() {
|
|
9418
|
+
const config = this.deps.getConfig();
|
|
9419
|
+
const views = [];
|
|
9420
|
+
for (const domain of config.domains) {
|
|
9421
|
+
const account = config.accounts.find((entry) => entry.id === domain.account);
|
|
9422
|
+
const provider = account === void 0 ? null : ddnsProvider(account.provider);
|
|
9423
|
+
for (const type of domain.types) {
|
|
9424
|
+
const found = this.results.get(cacheKey(domain.host, type));
|
|
9425
|
+
views.push({
|
|
9426
|
+
host: domain.host,
|
|
9427
|
+
account: domain.account,
|
|
9428
|
+
provider: account?.provider ?? "",
|
|
9429
|
+
type,
|
|
9430
|
+
ip: found?.ip ?? this.persisted.records[cacheKey(domain.host, type)]?.ip ?? null,
|
|
9431
|
+
state: found?.state ?? (provider === null ? "skipped" : "pending"),
|
|
9432
|
+
message: found?.message ?? (account === void 0 ? `unknown account "${domain.account}"` : provider === null ? `unknown provider "${account.provider}"` : null),
|
|
9433
|
+
updatedAt: found?.updatedAt ?? null
|
|
9434
|
+
});
|
|
9435
|
+
}
|
|
9436
|
+
}
|
|
9437
|
+
return views;
|
|
9438
|
+
}
|
|
9439
|
+
/** Makes the next tick run immediately, after a config or credential save. */
|
|
9440
|
+
refresh() {
|
|
9441
|
+
this.lastStartedAt = null;
|
|
9442
|
+
}
|
|
9443
|
+
/**
|
|
9444
|
+
* Cheap enough to call every supervisor tick, and deliberately non-blocking:
|
|
9445
|
+
* a slow provider must never hold up health probes.
|
|
9446
|
+
*/
|
|
9447
|
+
tick(now = this.now()) {
|
|
9448
|
+
if (this.disposed || this.running) return;
|
|
9449
|
+
const config = this.deps.getConfig();
|
|
9450
|
+
if (!config.enabled) return;
|
|
9451
|
+
if (this.lastStartedAt !== null && now - this.lastStartedAt < config.intervalMs) return;
|
|
9452
|
+
this.lastStartedAt = now;
|
|
9453
|
+
this.run().catch((error) => {
|
|
9454
|
+
logger.error("ddns pass failed", error);
|
|
9455
|
+
});
|
|
9456
|
+
}
|
|
9457
|
+
/** Runs one pass now; `force` re-sends addresses the cache already knows. */
|
|
9458
|
+
async run(options = {}) {
|
|
9459
|
+
if (this.running || this.disposed) return this.view;
|
|
9460
|
+
this.running = true;
|
|
9461
|
+
const config = this.deps.getConfig();
|
|
9462
|
+
const signal = AbortSignal.timeout(RUN_TIMEOUT_MS);
|
|
9463
|
+
const fetchImpl = this.deps.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
9464
|
+
let changed = 0;
|
|
9465
|
+
let unchanged = 0;
|
|
9466
|
+
let failed = 0;
|
|
9467
|
+
let skipped = 0;
|
|
9468
|
+
try {
|
|
9469
|
+
const wanted = this.wantedFamilies(config);
|
|
9470
|
+
const detected = {
|
|
9471
|
+
ipv4: wanted.ipv4 ? await detectPublicAddress("A", config.ipv4.url, fetchImpl, signal) : {
|
|
9472
|
+
ip: null,
|
|
9473
|
+
error: null
|
|
9474
|
+
},
|
|
9475
|
+
ipv6: wanted.ipv6 ? await detectPublicAddress("AAAA", config.ipv6.url, fetchImpl, signal) : {
|
|
9476
|
+
ip: null,
|
|
9477
|
+
error: null
|
|
9478
|
+
}
|
|
9479
|
+
};
|
|
9480
|
+
if (detected.ipv4.ip !== null) this.ipv4 = detected.ipv4.ip;
|
|
9481
|
+
if (detected.ipv6.ip !== null) this.ipv6 = detected.ipv6.ip;
|
|
9482
|
+
if (detected.ipv4.error !== null) logger.warn(`ddns: could not detect the public IPv4 address (${detected.ipv4.error})`);
|
|
9483
|
+
if (detected.ipv6.error !== null) logger.warn(`ddns: could not detect the public IPv6 address (${detected.ipv6.error})`);
|
|
9484
|
+
for (const domain of config.domains) {
|
|
9485
|
+
if (!domain.enabled) continue;
|
|
9486
|
+
const account = config.accounts.find((entry) => entry.id === domain.account);
|
|
9487
|
+
const provider = account === void 0 ? null : ddnsProvider(account.provider);
|
|
9488
|
+
const credentials = account === void 0 ? null : this.deps.getCredentials(account.id);
|
|
9489
|
+
for (const type of domain.types) {
|
|
9490
|
+
const key = cacheKey(domain.host, type);
|
|
9491
|
+
const family = type === "A" ? "ipv4" : "ipv6";
|
|
9492
|
+
const familyConfig = type === "A" ? config.ipv4 : config.ipv6;
|
|
9493
|
+
const address = type === "A" ? detected.ipv4.ip : detected.ipv6.ip;
|
|
9494
|
+
if (provider === null || account === void 0) {
|
|
9495
|
+
this.remember(key, "skipped", address, `unknown account or provider for "${domain.account}"`);
|
|
9496
|
+
skipped += 1;
|
|
9497
|
+
continue;
|
|
9498
|
+
}
|
|
9499
|
+
if (!familyConfig.enabled) {
|
|
9500
|
+
this.remember(key, "skipped", address, `${type} records need ${family} detection, which is off`);
|
|
9501
|
+
skipped += 1;
|
|
9502
|
+
continue;
|
|
9503
|
+
}
|
|
9504
|
+
if (address === null) {
|
|
9505
|
+
this.remember(key, "skipped", null, detected[family].error ?? `no public ${family} address was found`);
|
|
9506
|
+
skipped += 1;
|
|
9507
|
+
continue;
|
|
9508
|
+
}
|
|
9509
|
+
if (!provider.families.includes(type)) {
|
|
9510
|
+
this.remember(key, "skipped", address, `${provider.label} cannot manage ${type} records`);
|
|
9511
|
+
skipped += 1;
|
|
9512
|
+
continue;
|
|
9513
|
+
}
|
|
9514
|
+
if (!options.force && this.persisted.records[key]?.ip === address) {
|
|
9515
|
+
this.remember(key, "unchanged", address, null);
|
|
9516
|
+
unchanged += 1;
|
|
9517
|
+
continue;
|
|
9518
|
+
}
|
|
9519
|
+
const cache = this.persisted.records[key];
|
|
9520
|
+
const record = {
|
|
9521
|
+
host: domain.host,
|
|
9522
|
+
type,
|
|
9523
|
+
ip: address,
|
|
9524
|
+
ttl: domain.ttl ?? config.ttl,
|
|
9525
|
+
proxied: domain.proxied ?? config.proxied,
|
|
9526
|
+
...domain.zone === void 0 ? {} : { zone: domain.zone },
|
|
9527
|
+
...cache === void 0 ? {} : { cache: {
|
|
9528
|
+
...cache.zoneId === void 0 ? {} : { zoneId: cache.zoneId },
|
|
9529
|
+
...cache.recordId === void 0 ? {} : { recordId: cache.recordId }
|
|
9530
|
+
} }
|
|
9531
|
+
};
|
|
9532
|
+
const result = await provider.update(record, {
|
|
9533
|
+
credentials: credentials ?? {},
|
|
9534
|
+
fetch: fetchImpl,
|
|
9535
|
+
signal
|
|
9536
|
+
});
|
|
9537
|
+
if (result.ok) {
|
|
9538
|
+
this.persisted.records[key] = {
|
|
9539
|
+
ip: address,
|
|
9540
|
+
updatedAt: this.now(),
|
|
9541
|
+
...result.cache?.zoneId === void 0 ? {} : { zoneId: result.cache.zoneId },
|
|
9542
|
+
...result.cache?.recordId === void 0 ? {} : { recordId: result.cache.recordId }
|
|
9543
|
+
};
|
|
9544
|
+
this.remember(key, result.changed ? "ok" : "unchanged", address, result.message);
|
|
9545
|
+
if (result.changed) changed += 1;
|
|
9546
|
+
else unchanged += 1;
|
|
9547
|
+
} else {
|
|
9548
|
+
this.remember(key, "error", address, result.error ?? result.message);
|
|
9549
|
+
failed += 1;
|
|
9550
|
+
logger.warn(`ddns: ${domain.host} ${type}: ${result.error ?? result.message}`);
|
|
9551
|
+
}
|
|
9552
|
+
}
|
|
9553
|
+
}
|
|
9554
|
+
} finally {
|
|
9555
|
+
this.running = false;
|
|
9556
|
+
this.lastRunAt = this.now();
|
|
9557
|
+
this.persisted.updatedAt = this.lastRunAt;
|
|
9558
|
+
this.writeState();
|
|
9559
|
+
this.lastResult = this.describe(changed, unchanged, failed, skipped);
|
|
9560
|
+
this.announce(config, {
|
|
9561
|
+
changed,
|
|
9562
|
+
failed
|
|
9563
|
+
});
|
|
9564
|
+
}
|
|
9565
|
+
return this.view;
|
|
9566
|
+
}
|
|
9567
|
+
/** Only the families a configured target actually asks for. */
|
|
9568
|
+
wantedFamilies(config) {
|
|
9569
|
+
return {
|
|
9570
|
+
ipv4: config.ipv4.enabled && config.domains.some((domain) => domain.enabled && domain.types.includes("A")),
|
|
9571
|
+
ipv6: config.ipv6.enabled && config.domains.some((domain) => domain.enabled && domain.types.includes("AAAA"))
|
|
9572
|
+
};
|
|
9573
|
+
}
|
|
9574
|
+
remember(key, state, ip, message) {
|
|
9575
|
+
this.results.set(key, {
|
|
9576
|
+
state,
|
|
9577
|
+
ip,
|
|
9578
|
+
message,
|
|
9579
|
+
updatedAt: this.now()
|
|
9580
|
+
});
|
|
9581
|
+
}
|
|
9582
|
+
/** Counts only: the detected addresses are shown beside it, and are the status's own. */
|
|
9583
|
+
describe(changed, unchanged, failed, skipped) {
|
|
9584
|
+
const parts = [
|
|
9585
|
+
changed > 0 ? `${changed} updated` : null,
|
|
9586
|
+
unchanged > 0 ? `${unchanged} already current` : null,
|
|
9587
|
+
failed > 0 ? `${failed} failed` : null,
|
|
9588
|
+
skipped > 0 ? `${skipped} skipped` : null
|
|
9589
|
+
].filter((part) => part !== null);
|
|
9590
|
+
return parts.length === 0 ? "nothing to update" : parts.join(", ");
|
|
9591
|
+
}
|
|
9592
|
+
/** One message per pass, so a flapping provider cannot flood the chat. */
|
|
9593
|
+
announce(config, counts) {
|
|
9594
|
+
if (!config.notify || counts.changed === 0 && counts.failed === 0) return;
|
|
9595
|
+
if (counts.failed > 0) {
|
|
9596
|
+
this.deps.notifications.notify({
|
|
9597
|
+
serverId: "ddns",
|
|
9598
|
+
label: "DDNS",
|
|
9599
|
+
reason: "ddns-error",
|
|
9600
|
+
detail: this.lastResult ?? "a dynamic DNS update failed"
|
|
9601
|
+
});
|
|
9602
|
+
return;
|
|
9603
|
+
}
|
|
9604
|
+
this.deps.notifications.notify({
|
|
9605
|
+
serverId: "ddns",
|
|
9606
|
+
label: "DDNS",
|
|
9607
|
+
reason: "ddns",
|
|
9608
|
+
detail: this.lastResult ?? "a dynamic DNS record changed"
|
|
9609
|
+
});
|
|
9610
|
+
}
|
|
9611
|
+
writeState() {
|
|
9612
|
+
try {
|
|
9613
|
+
writeFileAtomic(this.deps.statePath, `${JSON.stringify(this.persisted, null, 2)}\n`);
|
|
9614
|
+
} catch (error) {
|
|
9615
|
+
logger.warn(`ddns: could not save its state (${error instanceof Error ? error.message : String(error)})`);
|
|
9616
|
+
}
|
|
9617
|
+
}
|
|
9618
|
+
dispose() {
|
|
9619
|
+
this.disposed = true;
|
|
9620
|
+
}
|
|
9621
|
+
};
|
|
9622
|
+
}));
|
|
9623
|
+
//#endregion
|
|
7767
9624
|
//#region src/services/events.ts
|
|
7768
9625
|
var ALL, EventHub;
|
|
7769
9626
|
var init_events = __esmMin((() => {
|
|
@@ -8277,7 +10134,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8277
10134
|
"recovered": "recovered",
|
|
8278
10135
|
"rss": "exceeded its memory limit",
|
|
8279
10136
|
"host": "host thresholds breached",
|
|
8280
|
-
"host-recovered": "host thresholds recovered"
|
|
10137
|
+
"host-recovered": "host thresholds recovered",
|
|
10138
|
+
"ddns": "updated a dynamic DNS record",
|
|
10139
|
+
"ddns-error": "hit a dynamic DNS error"
|
|
8281
10140
|
};
|
|
8282
10141
|
TITLE = {
|
|
8283
10142
|
"crash": "🔴 server down",
|
|
@@ -8286,7 +10145,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8286
10145
|
"recovered": "🟢 server recovered",
|
|
8287
10146
|
"rss": "🔴 server over its memory limit",
|
|
8288
10147
|
"host": "🟠 host warning",
|
|
8289
|
-
"host-recovered": "🟢 host recovered"
|
|
10148
|
+
"host-recovered": "🟢 host recovered",
|
|
10149
|
+
"ddns": "🌐 DNS updated",
|
|
10150
|
+
"ddns-error": "🌐 DNS update failed"
|
|
8290
10151
|
};
|
|
8291
10152
|
NotificationService = class {
|
|
8292
10153
|
secrets;
|
|
@@ -8314,6 +10175,7 @@ var init_notifications = __esmMin((() => {
|
|
|
8314
10175
|
onForcedRestart: telegram.onForcedRestart,
|
|
8315
10176
|
onRecovered: telegram.onRecovered,
|
|
8316
10177
|
onHost: telegram.onHost,
|
|
10178
|
+
onDdns: telegram.onDdns,
|
|
8317
10179
|
cooldownMs: telegram.cooldownMs,
|
|
8318
10180
|
lastResult: this.lastResult,
|
|
8319
10181
|
lastResultAt: this.lastResultAt
|
|
@@ -8330,7 +10192,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8330
10192
|
"recovered": telegram.onRecovered,
|
|
8331
10193
|
"rss": telegram.onCrash,
|
|
8332
10194
|
"host": telegram.onHost,
|
|
8333
|
-
"host-recovered": telegram.onHost
|
|
10195
|
+
"host-recovered": telegram.onHost,
|
|
10196
|
+
"ddns": telegram.onDdns,
|
|
10197
|
+
"ddns-error": telegram.onDdns
|
|
8334
10198
|
}[event.reason]) return false;
|
|
8335
10199
|
const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
|
|
8336
10200
|
return !(telegram.cooldownMs > 0 && until > now);
|
|
@@ -9310,6 +11174,12 @@ async function runControlPlane(options) {
|
|
|
9310
11174
|
dataRoot,
|
|
9311
11175
|
home: os.homedir()
|
|
9312
11176
|
})), notifications);
|
|
11177
|
+
const ddns = new DdnsService({
|
|
11178
|
+
getConfig: () => store.config.ddns,
|
|
11179
|
+
getCredentials: (accountId) => secrets.getDdnsCredentials(accountId),
|
|
11180
|
+
notifications,
|
|
11181
|
+
statePath: defaultDdnsStatePath
|
|
11182
|
+
});
|
|
9313
11183
|
let onConfigRestored;
|
|
9314
11184
|
const backups = new BackupService({
|
|
9315
11185
|
dataRoot,
|
|
@@ -9393,6 +11263,7 @@ async function runControlPlane(options) {
|
|
|
9393
11263
|
notifications,
|
|
9394
11264
|
hostMonitor,
|
|
9395
11265
|
backups,
|
|
11266
|
+
ddns,
|
|
9396
11267
|
logsDir: logFiles.directory,
|
|
9397
11268
|
views
|
|
9398
11269
|
}),
|
|
@@ -9400,6 +11271,7 @@ async function runControlPlane(options) {
|
|
|
9400
11271
|
logFiles,
|
|
9401
11272
|
notifications,
|
|
9402
11273
|
hostMonitor,
|
|
11274
|
+
ddns,
|
|
9403
11275
|
nannyDir: defaultNannyDir
|
|
9404
11276
|
});
|
|
9405
11277
|
/**
|
|
@@ -9447,6 +11319,7 @@ async function runControlPlane(options) {
|
|
|
9447
11319
|
clearRuntime();
|
|
9448
11320
|
configWatch.dispose();
|
|
9449
11321
|
auth.dispose();
|
|
11322
|
+
ddns.dispose();
|
|
9450
11323
|
await supervisor.dispose();
|
|
9451
11324
|
logFiles.dispose();
|
|
9452
11325
|
history.dispose();
|
|
@@ -9464,6 +11337,7 @@ async function runControlPlane(options) {
|
|
|
9464
11337
|
logFiles,
|
|
9465
11338
|
notifications,
|
|
9466
11339
|
backups,
|
|
11340
|
+
ddns,
|
|
9467
11341
|
ui,
|
|
9468
11342
|
runtimeToken: token,
|
|
9469
11343
|
onShutdown: () => shutdown("shutdown requested locally")
|
|
@@ -9543,6 +11417,7 @@ var init_src = __esmMin((() => {
|
|
|
9543
11417
|
init_backups();
|
|
9544
11418
|
init_config_watch();
|
|
9545
11419
|
init_control_server();
|
|
11420
|
+
init_ddns();
|
|
9546
11421
|
init_events();
|
|
9547
11422
|
init_exposure();
|
|
9548
11423
|
init_history();
|