home-hosted 0.6.5 → 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 +25 -18
- package/README.md +19 -6
- package/dist/cli.js +2829 -797
- 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-Dqs9Vd8M.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";
|
|
@@ -133,6 +133,7 @@ function aliasesOf(def) {
|
|
|
133
133
|
function findArg(argsDef, name) {
|
|
134
134
|
for (const [key, def] of Object.entries(argsDef)) {
|
|
135
135
|
if (def === void 0) continue;
|
|
136
|
+
if (def.type === "positional") continue;
|
|
136
137
|
if ((/* @__PURE__ */ new Set([
|
|
137
138
|
key,
|
|
138
139
|
kebab(key),
|
|
@@ -152,10 +153,15 @@ function findArg(argsDef, name) {
|
|
|
152
153
|
*/
|
|
153
154
|
function rejectUnknownFlags(argv, argsDef) {
|
|
154
155
|
if (argsDef === void 0 || Object.keys(argsDef).length === 0) return null;
|
|
156
|
+
let positionals = Object.values(argsDef).filter((def) => def?.type === "positional").length;
|
|
155
157
|
for (let index = 0; index < argv.length; index++) {
|
|
156
158
|
const token = argv[index];
|
|
157
159
|
if (token === "--") return null;
|
|
158
|
-
if (!token.startsWith("-") || token.length === 1)
|
|
160
|
+
if (!token.startsWith("-") || token.length === 1) {
|
|
161
|
+
if (positionals === 0) return `Unexpected argument '${token}'`;
|
|
162
|
+
positionals -= 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
159
165
|
const body = token.startsWith("--") ? token.slice(2) : token.slice(1);
|
|
160
166
|
const equals = body.indexOf("=");
|
|
161
167
|
const name = equals === -1 ? body : body.slice(0, equals);
|
|
@@ -300,6 +306,50 @@ var init_runtime = __esmMin((() => {
|
|
|
300
306
|
NANNY_COMMAND = "__nanny";
|
|
301
307
|
}));
|
|
302
308
|
//#endregion
|
|
309
|
+
//#region src/helpers/version.ts
|
|
310
|
+
var version_exports = /* @__PURE__ */ __exportAll({
|
|
311
|
+
appVersion: () => appVersion,
|
|
312
|
+
versionMismatchNote: () => versionMismatchNote
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* The running release, read from the package manifest.
|
|
316
|
+
*
|
|
317
|
+
* The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
|
|
318
|
+
* is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
|
|
319
|
+
* its own read because it may only import node builtins statically.
|
|
320
|
+
*/
|
|
321
|
+
function appVersion() {
|
|
322
|
+
if (cached !== null) return cached;
|
|
323
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
324
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
325
|
+
try {
|
|
326
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
327
|
+
if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
|
|
328
|
+
cached = manifest.version;
|
|
329
|
+
return cached;
|
|
330
|
+
}
|
|
331
|
+
} catch {}
|
|
332
|
+
const parent = path.dirname(dir);
|
|
333
|
+
if (parent === dir) break;
|
|
334
|
+
dir = parent;
|
|
335
|
+
}
|
|
336
|
+
cached = "0.0.0";
|
|
337
|
+
return cached;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The one line to add when this CLI and the panel it is talking to are not the same
|
|
341
|
+
* release — which is what a linked checkout, a global install and a project pin
|
|
342
|
+
* produce. `null` when they agree.
|
|
343
|
+
*/
|
|
344
|
+
function versionMismatchNote(panelVersion) {
|
|
345
|
+
const cli = appVersion();
|
|
346
|
+
return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
|
|
347
|
+
}
|
|
348
|
+
var cached;
|
|
349
|
+
var init_version = __esmMin((() => {
|
|
350
|
+
cached = null;
|
|
351
|
+
}));
|
|
352
|
+
//#endregion
|
|
303
353
|
//#region src/helpers/cookies.ts
|
|
304
354
|
function parseCookies(header) {
|
|
305
355
|
const cookies = {};
|
|
@@ -341,7 +391,7 @@ function parseBind(value) {
|
|
|
341
391
|
const parsed = bindSchema(value);
|
|
342
392
|
return parsed instanceof type.errors ? null : parsed;
|
|
343
393
|
}
|
|
344
|
-
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;
|
|
345
395
|
var init_contracts = __esmMin((() => {
|
|
346
396
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
347
397
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
@@ -460,6 +510,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
460
510
|
onRecovered: "boolean = false",
|
|
461
511
|
/** Host vitals breaches (disk, memory, swap, load, temperature). */
|
|
462
512
|
onHost: "boolean = true",
|
|
513
|
+
/** Dynamic DNS changes and failures. */
|
|
514
|
+
onDdns: "boolean = true",
|
|
463
515
|
/** Per server *and* reason, so a flapping server cannot spam the chat. */
|
|
464
516
|
cooldownMs: "number >= 0 = 120000"
|
|
465
517
|
}).onUndeclaredKey("reject");
|
|
@@ -483,6 +535,95 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
483
535
|
loadPerCpu: "number >= 0 = 2",
|
|
484
536
|
tempCelsius: "number >= 0 = 85"
|
|
485
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");
|
|
486
627
|
backupsSchema = type({
|
|
487
628
|
enabled: "boolean = true",
|
|
488
629
|
dir: "string = \".backups\"",
|
|
@@ -558,6 +699,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
558
699
|
onForcedRestart: "boolean?",
|
|
559
700
|
onRecovered: "boolean?",
|
|
560
701
|
onHost: "boolean?",
|
|
702
|
+
onDdns: "boolean?",
|
|
561
703
|
cooldownMs: "number >= 0?"
|
|
562
704
|
}).onUndeclaredKey("reject");
|
|
563
705
|
notificationsPatchSchema = type({ telegram: telegramPatchSchema.optional() }).onUndeclaredKey("reject");
|
|
@@ -712,6 +854,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
712
854
|
onRecovered: "boolean",
|
|
713
855
|
/** Host vitals breaches (disk, memory, swap, load, temperature). */
|
|
714
856
|
onHost: "boolean",
|
|
857
|
+
/** Dynamic DNS, when this panel knows about it (optional for an older panel). */
|
|
858
|
+
onDdns: "boolean?",
|
|
715
859
|
cooldownMs: "number",
|
|
716
860
|
/** Last delivery outcome, for the settings page. */
|
|
717
861
|
lastResult: "string | null",
|
|
@@ -893,20 +1037,27 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
893
1037
|
tls: tlsStatusSchema
|
|
894
1038
|
});
|
|
895
1039
|
appStateSchema = type({
|
|
896
|
-
control: controlViewSchema,
|
|
897
|
-
defaults: defaultsSchema,
|
|
898
|
-
logs: logsSchema,
|
|
899
|
-
notifications: notificationViewSchema,
|
|
900
|
-
host: hostViewSchema,
|
|
901
|
-
backups: backupsViewSchema,
|
|
902
|
-
configPath: "string",
|
|
903
|
-
configError: "string | null",
|
|
1040
|
+
"control": controlViewSchema,
|
|
1041
|
+
"defaults": defaultsSchema,
|
|
1042
|
+
"logs": logsSchema,
|
|
1043
|
+
"notifications": notificationViewSchema,
|
|
1044
|
+
"host": hostViewSchema,
|
|
1045
|
+
"backups": backupsViewSchema,
|
|
1046
|
+
"configPath": "string",
|
|
1047
|
+
"configError": "string | null",
|
|
904
1048
|
/** The directory the panel was started from; relative entry paths use it. */
|
|
905
|
-
projectDir: "string",
|
|
1049
|
+
"projectDir": "string",
|
|
906
1050
|
/** `HHOSTED_HOME`: every file home-hosted owns lives under here. */
|
|
907
|
-
dataRoot: "string",
|
|
908
|
-
logsDir: "string",
|
|
909
|
-
|
|
1051
|
+
"dataRoot": "string",
|
|
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,
|
|
1058
|
+
/** The release this panel is running. Optional: an older panel does not send one. */
|
|
1059
|
+
"version?": "string",
|
|
1060
|
+
"servers": serverViewSchema.array()
|
|
910
1061
|
});
|
|
911
1062
|
type({
|
|
912
1063
|
type: "\"hello\" | \"state\" | \"log\" | \"server\"",
|
|
@@ -1140,6 +1291,21 @@ function apiTokenRecord(token, now = Date.now()) {
|
|
|
1140
1291
|
updatedAt: now
|
|
1141
1292
|
};
|
|
1142
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
|
+
}
|
|
1143
1309
|
var COST, KEYLEN, SALT_BYTES, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
|
|
1144
1310
|
var init_secrets = __esmMin((() => {
|
|
1145
1311
|
init_atomic();
|
|
@@ -1250,12 +1416,38 @@ var init_secrets = __esmMin((() => {
|
|
|
1250
1416
|
telegram: trimmed.length > 0 ? { botToken: trimmed } : null
|
|
1251
1417
|
});
|
|
1252
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
|
+
}
|
|
1253
1444
|
read() {
|
|
1254
1445
|
if (!fs.existsSync(this.file)) return {
|
|
1255
1446
|
version: 3,
|
|
1256
1447
|
password: null,
|
|
1257
1448
|
apiToken: null,
|
|
1258
|
-
telegram: null
|
|
1449
|
+
telegram: null,
|
|
1450
|
+
ddns: {}
|
|
1259
1451
|
};
|
|
1260
1452
|
try {
|
|
1261
1453
|
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
@@ -1263,14 +1455,16 @@ var init_secrets = __esmMin((() => {
|
|
|
1263
1455
|
version: 3,
|
|
1264
1456
|
password: parsed?.password ?? null,
|
|
1265
1457
|
apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
|
|
1266
|
-
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null
|
|
1458
|
+
telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
|
|
1459
|
+
ddns: readDdnsCredentials(parsed?.ddns)
|
|
1267
1460
|
};
|
|
1268
1461
|
} catch {
|
|
1269
1462
|
return {
|
|
1270
1463
|
version: 3,
|
|
1271
1464
|
password: null,
|
|
1272
1465
|
apiToken: null,
|
|
1273
|
-
telegram: null
|
|
1466
|
+
telegram: null,
|
|
1467
|
+
ddns: {}
|
|
1274
1468
|
};
|
|
1275
1469
|
}
|
|
1276
1470
|
}
|
|
@@ -1814,6 +2008,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
1814
2008
|
daemonLogPath: () => daemonLogPath,
|
|
1815
2009
|
dataRoot: () => dataRoot,
|
|
1816
2010
|
defaultConfigPath: () => defaultConfigPath,
|
|
2011
|
+
defaultDdnsStatePath: () => defaultDdnsStatePath,
|
|
1817
2012
|
defaultHistoryPath: () => defaultHistoryPath,
|
|
1818
2013
|
defaultLogsDir: () => defaultLogsDir,
|
|
1819
2014
|
defaultNannyDir: () => defaultNannyDir,
|
|
@@ -1854,7 +2049,7 @@ function resolveUserPath(target, base = projectDir) {
|
|
|
1854
2049
|
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
1855
2050
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
1856
2051
|
}
|
|
1857
|
-
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;
|
|
1858
2053
|
var init_paths = __esmMin((() => {
|
|
1859
2054
|
dataRoot = resolveDataRoot();
|
|
1860
2055
|
projectDir = resolveProjectDir();
|
|
@@ -1865,6 +2060,7 @@ var init_paths = __esmMin((() => {
|
|
|
1865
2060
|
defaultHistoryPath = path.join(dataRoot, ".logs", "history.json");
|
|
1866
2061
|
defaultTlsDir = path.join(dataRoot, ".tls");
|
|
1867
2062
|
defaultNannyDir = path.join(dataRoot, ".state");
|
|
2063
|
+
defaultDdnsStatePath = path.join(dataRoot, ".state", "ddns.json");
|
|
1868
2064
|
runtimePath = path.join(dataRoot, "run.json");
|
|
1869
2065
|
daemonLogPath = path.join(dataRoot, ".logs", "home-hosted.log");
|
|
1870
2066
|
}));
|
|
@@ -1929,16 +2125,19 @@ function buildAppState(deps) {
|
|
|
1929
2125
|
notifications: { telegram: deps.notifications.status() },
|
|
1930
2126
|
host: buildHostView(deps.hostMonitor),
|
|
1931
2127
|
backups: buildBackupsView(deps.store, deps.backups),
|
|
2128
|
+
ddns: deps.ddns.view,
|
|
1932
2129
|
configPath: deps.store.path,
|
|
1933
2130
|
configError: deps.store.configError,
|
|
1934
2131
|
projectDir,
|
|
1935
2132
|
dataRoot,
|
|
1936
2133
|
logsDir: deps.logsDir,
|
|
2134
|
+
version: appVersion(),
|
|
1937
2135
|
servers: deps.views
|
|
1938
2136
|
};
|
|
1939
2137
|
}
|
|
1940
2138
|
var init_state$1 = __esmMin((() => {
|
|
1941
2139
|
init_paths();
|
|
2140
|
+
init_version();
|
|
1942
2141
|
init_exposure();
|
|
1943
2142
|
}));
|
|
1944
2143
|
//#endregion
|
|
@@ -2108,6 +2307,14 @@ var init_backups$1 = __esmMin((() => {
|
|
|
2108
2307
|
nameParam = type({ name: "string >= 1" });
|
|
2109
2308
|
}));
|
|
2110
2309
|
//#endregion
|
|
2310
|
+
//#region src/helpers/action-result.ts
|
|
2311
|
+
/** Unknown ids are 404; a server that exists but cannot act is a 409. */
|
|
2312
|
+
function statusForAction(result) {
|
|
2313
|
+
if (result.ok) return 200;
|
|
2314
|
+
return result.error?.startsWith("unknown server") ? 404 : 409;
|
|
2315
|
+
}
|
|
2316
|
+
var init_action_result = __esmMin((() => {}));
|
|
2317
|
+
//#endregion
|
|
2111
2318
|
//#region src/helpers/deferred.ts
|
|
2112
2319
|
/**
|
|
2113
2320
|
* Runs a task after the current response has been written.
|
|
@@ -2126,754 +2333,315 @@ var init_deferred = __esmMin((() => {}));
|
|
|
2126
2333
|
//#endregion
|
|
2127
2334
|
//#region src/api/control.ts
|
|
2128
2335
|
/**
|
|
2129
|
-
* The local control channel used by `home-hosted down`.
|
|
2336
|
+
* The local control channel used by `home-hosted down` and `home-hosted start|stop`.
|
|
2130
2337
|
*
|
|
2131
2338
|
* It is deliberately outside `/api` (so no session is needed) and guarded by a
|
|
2132
2339
|
* token that only the owner of `run.json` can read, plus a loopback check: a
|
|
2133
2340
|
* different local user, or anyone on the network, gets nothing.
|
|
2134
2341
|
*/
|
|
2135
2342
|
function createControlRoute(deps) {
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
summary: "Stop the panel and everything it supervises (local token required)",
|
|
2139
|
-
responses: {
|
|
2140
|
-
200: { description: "Stopping" },
|
|
2141
|
-
403: { description: "Bad token, or not a local caller" }
|
|
2142
|
-
}
|
|
2143
|
-
}), (c) => {
|
|
2343
|
+
/** The one place the local channel's credentials are read. */
|
|
2344
|
+
function assertLocalCall(c, action) {
|
|
2144
2345
|
const token = c.req.header("x-home-hosted-token");
|
|
2145
2346
|
if (token === void 0 || token !== deps.runtimeToken) throw new DetailedError("invalid token", {
|
|
2146
2347
|
statusCode: 403,
|
|
2147
2348
|
code: "INVALID_TOKEN"
|
|
2148
2349
|
});
|
|
2149
|
-
if (!isLoopbackRequest(c)) throw new DetailedError(
|
|
2350
|
+
if (!isLoopbackRequest(c)) throw new DetailedError(`only this machine may ${action}`, {
|
|
2150
2351
|
statusCode: 403,
|
|
2151
2352
|
code: "NOT_LOOPBACK"
|
|
2152
2353
|
});
|
|
2354
|
+
}
|
|
2355
|
+
return appFactory.createApp().post("/shutdown", describeRoute({
|
|
2356
|
+
tags: ["panel"],
|
|
2357
|
+
summary: "Stop the panel and everything it supervises (local token required)",
|
|
2358
|
+
responses: {
|
|
2359
|
+
200: { description: "Stopping" },
|
|
2360
|
+
403: { description: "Bad token, or not a local caller" }
|
|
2361
|
+
}
|
|
2362
|
+
}), (c) => {
|
|
2363
|
+
assertLocalCall(c, "stop the control panel");
|
|
2153
2364
|
afterResponse(deps.onShutdown, (error) => logger.error("shutdown failed", error));
|
|
2154
2365
|
return c.json({ ok: true });
|
|
2366
|
+
}).post("/servers/:id/start", describeRoute({
|
|
2367
|
+
tags: ["panel"],
|
|
2368
|
+
summary: "Start one server, without a session (local token required)",
|
|
2369
|
+
responses: {
|
|
2370
|
+
200: { description: "Started" },
|
|
2371
|
+
403: { description: "Bad token, or not a local caller" },
|
|
2372
|
+
404: { description: "No such server" }
|
|
2373
|
+
}
|
|
2374
|
+
}), async (c) => {
|
|
2375
|
+
assertLocalCall(c, "start or stop a server");
|
|
2376
|
+
const result = await deps.supervisor.start(c.req.param("id"));
|
|
2377
|
+
return c.json(result, statusForAction(result));
|
|
2378
|
+
}).post("/servers/:id/stop", describeRoute({
|
|
2379
|
+
tags: ["panel"],
|
|
2380
|
+
summary: "Stop one server, without a session (local token required)",
|
|
2381
|
+
responses: {
|
|
2382
|
+
200: { description: "Stopped" },
|
|
2383
|
+
403: { description: "Bad token, or not a local caller" },
|
|
2384
|
+
404: { description: "No such server" }
|
|
2385
|
+
}
|
|
2386
|
+
}), async (c) => {
|
|
2387
|
+
assertLocalCall(c, "start or stop a server");
|
|
2388
|
+
const result = await deps.supervisor.stop(c.req.param("id"));
|
|
2389
|
+
return c.json(result, statusForAction(result));
|
|
2155
2390
|
});
|
|
2156
2391
|
}
|
|
2157
2392
|
var init_control = __esmMin((() => {
|
|
2393
|
+
init_action_result();
|
|
2158
2394
|
init_deferred();
|
|
2159
2395
|
init_factory();
|
|
2160
2396
|
init_logger();
|
|
2161
2397
|
init_loopback();
|
|
2162
2398
|
}));
|
|
2163
2399
|
//#endregion
|
|
2164
|
-
//#region src/
|
|
2165
|
-
/**
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
}), validate("query", eventsQuery), (c) => {
|
|
2175
|
-
const query = c.req.valid("query");
|
|
2176
|
-
const serverId = query.serverId ?? null;
|
|
2177
|
-
const logOnly = query.logs !== "0";
|
|
2178
|
-
return streamSSE(c, async (stream) => {
|
|
2179
|
-
let pending = 0;
|
|
2180
|
-
let queue = Promise.resolve();
|
|
2181
|
-
let closed = false;
|
|
2182
|
-
const send = (message) => {
|
|
2183
|
-
if (closed) return queue;
|
|
2184
|
-
if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
|
|
2185
|
-
pending += 1;
|
|
2186
|
-
queue = queue.then(() => stream.writeSSE({
|
|
2187
|
-
event: message.type,
|
|
2188
|
-
data: JSON.stringify(message)
|
|
2189
|
-
})).catch(() => {
|
|
2190
|
-
closed = true;
|
|
2191
|
-
}).finally(() => {
|
|
2192
|
-
pending -= 1;
|
|
2193
|
-
});
|
|
2194
|
-
return queue;
|
|
2195
|
-
};
|
|
2196
|
-
const unsubscribe = deps.hub.subscribe(serverId, (message) => {
|
|
2197
|
-
if (!logOnly && message.type === "log") return;
|
|
2198
|
-
send(message);
|
|
2199
|
-
});
|
|
2200
|
-
stream.onAbort(() => {
|
|
2201
|
-
closed = true;
|
|
2202
|
-
unsubscribe();
|
|
2203
|
-
});
|
|
2204
|
-
await send({
|
|
2205
|
-
type: "hello",
|
|
2206
|
-
ts: Date.now(),
|
|
2207
|
-
state: deps.supervisor.getState()
|
|
2208
|
-
});
|
|
2209
|
-
while (true) {
|
|
2210
|
-
await stream.sleep(PING_INTERVAL_MS);
|
|
2211
|
-
if (closed) break;
|
|
2212
|
-
await stream.writeSSE({
|
|
2213
|
-
event: "ping",
|
|
2214
|
-
data: String(Date.now())
|
|
2215
|
-
});
|
|
2216
|
-
}
|
|
2217
|
-
});
|
|
2218
|
-
});
|
|
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
|
+
};
|
|
2219
2410
|
}
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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 = [];
|
|
2232
2424
|
}));
|
|
2233
2425
|
//#endregion
|
|
2234
|
-
//#region src/
|
|
2426
|
+
//#region src/config/schema.ts
|
|
2427
|
+
function isRecord$2(value) {
|
|
2428
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2429
|
+
}
|
|
2235
2430
|
/**
|
|
2236
|
-
*
|
|
2237
|
-
*
|
|
2238
|
-
*
|
|
2239
|
-
*
|
|
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.
|
|
2240
2435
|
*/
|
|
2241
|
-
function
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
"status": "\"ok\" | \"degraded\"",
|
|
2250
|
-
"uptimeMs": "number",
|
|
2251
|
-
"servers?": type({
|
|
2252
|
-
total: "number",
|
|
2253
|
-
running: "number",
|
|
2254
|
-
crashed: "number",
|
|
2255
|
-
unhealthy: "number"
|
|
2256
|
-
}),
|
|
2257
|
-
"hostAlerts?": "string[]"
|
|
2258
|
-
}))
|
|
2259
|
-
},
|
|
2260
|
-
503: { description: "An autostart server has crashed" }
|
|
2261
|
-
}
|
|
2262
|
-
}), (c) => {
|
|
2263
|
-
const state = deps.supervisor.getState();
|
|
2264
|
-
const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
|
|
2265
|
-
const authenticated = requestIdentity(c, deps.auth).authenticated;
|
|
2266
|
-
return c.json({
|
|
2267
|
-
status: broken.length > 0 ? "degraded" : "ok",
|
|
2268
|
-
uptimeMs: Math.round(process.uptime() * 1e3),
|
|
2269
|
-
...authenticated ? {
|
|
2270
|
-
servers: {
|
|
2271
|
-
total: state.servers.length,
|
|
2272
|
-
running: state.servers.filter((server) => server.status === "running").length,
|
|
2273
|
-
crashed: state.servers.filter((server) => server.status === "crashed").length,
|
|
2274
|
-
unhealthy: state.servers.filter((server) => server.health === "unhealthy").length
|
|
2275
|
-
},
|
|
2276
|
-
hostAlerts: state.host.alerts
|
|
2277
|
-
} : {}
|
|
2278
|
-
}, broken.length > 0 ? 503 : 200);
|
|
2279
|
-
});
|
|
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;
|
|
2280
2444
|
}
|
|
2281
|
-
var
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
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
|
+
];
|
|
2285
2476
|
}));
|
|
2286
2477
|
//#endregion
|
|
2287
|
-
//#region src/
|
|
2288
|
-
function
|
|
2289
|
-
return
|
|
2290
|
-
statusCode: 404,
|
|
2291
|
-
code: "UNKNOWN_SERVER"
|
|
2292
|
-
});
|
|
2478
|
+
//#region src/config/parse.ts
|
|
2479
|
+
function isRecord$1(value) {
|
|
2480
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2293
2481
|
}
|
|
2294
|
-
function
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
label: server.config.label ?? server.id,
|
|
2305
|
-
status: server.status,
|
|
2306
|
-
...deps.logFiles.info(server.id)
|
|
2307
|
-
})) })).get("/logs/:id", describeRoute({
|
|
2308
|
-
tags: ["logs"],
|
|
2309
|
-
summary: "Persisted log lines, with search and a stream filter",
|
|
2310
|
-
responses: {
|
|
2311
|
-
200: { description: "Lines" },
|
|
2312
|
-
404: ERROR_RESPONSES[404]
|
|
2313
|
-
}
|
|
2314
|
-
}), validate("param", idParam$1), validate("query", logHistoryQuerySchema), (c) => {
|
|
2315
|
-
const { id } = c.req.valid("param");
|
|
2316
|
-
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
2317
|
-
const query = c.req.valid("query");
|
|
2318
|
-
const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
|
|
2319
|
-
const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
|
|
2320
|
-
const info = deps.logFiles.info(id);
|
|
2321
|
-
const search = query.search?.trim() ?? "";
|
|
2322
|
-
const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
|
|
2323
|
-
let lines = deps.logFiles.readTail(id, window);
|
|
2324
|
-
if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
|
|
2325
|
-
if (search.length > 0) {
|
|
2326
|
-
const needle = search.toLowerCase();
|
|
2327
|
-
lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
|
|
2328
|
-
}
|
|
2329
|
-
return c.json({
|
|
2330
|
-
serverId: id,
|
|
2331
|
-
enabled: info.enabled,
|
|
2332
|
-
sizeBytes: info.sizeBytes,
|
|
2333
|
-
files: info.files.map((file) => file.name),
|
|
2334
|
-
searched: search.length > 0 ? window : null,
|
|
2335
|
-
lines: lines.slice(-tail)
|
|
2336
|
-
});
|
|
2337
|
-
}).get("/logs/:id/download", describeRoute({
|
|
2338
|
-
tags: ["logs"],
|
|
2339
|
-
summary: "Download one rotated log file",
|
|
2340
|
-
responses: {
|
|
2341
|
-
200: { description: "application/x-ndjson" },
|
|
2342
|
-
404: ERROR_RESPONSES[404]
|
|
2343
|
-
}
|
|
2344
|
-
}), validate("param", idParam$1), validate("query", downloadQuery), (c) => {
|
|
2345
|
-
const { id } = c.req.valid("param");
|
|
2346
|
-
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
2347
|
-
const requested = c.req.valid("query").file ?? `${id}.log`;
|
|
2348
|
-
if (!deps.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
|
|
2349
|
-
statusCode: 404,
|
|
2350
|
-
code: "UNKNOWN_LOG_FILE"
|
|
2351
|
-
});
|
|
2352
|
-
const file = path.join(deps.logFiles.directory, requested);
|
|
2353
|
-
const body = fs.readFileSync(file);
|
|
2354
|
-
return c.body(body, 200, {
|
|
2355
|
-
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
2356
|
-
"Content-Length": String(body.byteLength),
|
|
2357
|
-
"Content-Disposition": `attachment; filename="${requested}"`
|
|
2358
|
-
});
|
|
2359
|
-
}).delete("/logs/:id", describeRoute({
|
|
2360
|
-
tags: ["logs"],
|
|
2361
|
-
summary: "Delete the persisted logs of one server",
|
|
2362
|
-
responses: {
|
|
2363
|
-
200: { description: "Cleared" },
|
|
2364
|
-
404: ERROR_RESPONSES[404]
|
|
2365
|
-
}
|
|
2366
|
-
}), validate("param", idParam$1), (c) => {
|
|
2367
|
-
const { id } = c.req.valid("param");
|
|
2368
|
-
if (!deps.store.getServer(id)) throw unknownServer$1(id);
|
|
2369
|
-
deps.logFiles.clear(id);
|
|
2370
|
-
return c.json({ ok: true });
|
|
2371
|
-
});
|
|
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);
|
|
2372
2492
|
}
|
|
2373
|
-
var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$1, downloadQuery;
|
|
2374
|
-
var init_logs = __esmMin((() => {
|
|
2375
|
-
init_factory();
|
|
2376
|
-
init_openapi$1();
|
|
2377
|
-
init_validator();
|
|
2378
|
-
init_contracts();
|
|
2379
|
-
MIN_TAIL = 50;
|
|
2380
|
-
MAX_TAIL = 5e3;
|
|
2381
|
-
DEFAULT_TAIL = 500;
|
|
2382
|
-
idParam$1 = type({ id: "string >= 1" });
|
|
2383
|
-
downloadQuery = type({ "file?": "string" });
|
|
2384
|
-
}));
|
|
2385
|
-
//#endregion
|
|
2386
|
-
//#region src/api/metrics.ts
|
|
2387
2493
|
/**
|
|
2388
|
-
*
|
|
2389
|
-
*
|
|
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.
|
|
2390
2501
|
*/
|
|
2391
|
-
function
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const lines = [];
|
|
2399
|
-
const metric = (name, help, samples) => {
|
|
2400
|
-
if (samples.length === 0) return;
|
|
2401
|
-
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
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
|
|
2402
2509
|
};
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
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}`));
|
|
2412
|
-
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)}`));
|
|
2413
|
-
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
2414
|
-
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
2415
|
-
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)}`]);
|
|
2416
|
-
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
2417
|
-
});
|
|
2418
|
-
}
|
|
2419
|
-
var init_metrics = __esmMin((() => {
|
|
2420
|
-
init_factory();
|
|
2421
|
-
}));
|
|
2422
|
-
//#endregion
|
|
2423
|
-
//#region src/providers/telegram.ts
|
|
2424
|
-
function getBot(token) {
|
|
2425
|
-
const cached = bots.get(token);
|
|
2426
|
-
if (cached) return cached;
|
|
2427
|
-
const bot = new Bot(token, { client: { timeoutSeconds: 10 } });
|
|
2428
|
-
bot.api.config.use(autoRetry({
|
|
2429
|
-
maxRetryAttempts: 3,
|
|
2430
|
-
maxDelaySeconds: 20
|
|
2431
|
-
}));
|
|
2432
|
-
bots.set(token, bot);
|
|
2433
|
-
return bot;
|
|
2434
|
-
}
|
|
2435
|
-
/** Drops cached clients; used when the token changes or the service shuts down. */
|
|
2436
|
-
function forgetBots() {
|
|
2437
|
-
bots.clear();
|
|
2438
|
-
}
|
|
2439
|
-
function escapeHtml(value) {
|
|
2440
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2441
|
-
}
|
|
2442
|
-
function formatTelegramMessage(title, lines) {
|
|
2443
|
-
const body = lines.filter((line) => line.length > 0).map((line) => `• ${escapeHtml(line)}`).join("\n");
|
|
2444
|
-
return `<b>${escapeHtml(title)}</b>${body.length > 0 ? `\n${body}` : ""}`;
|
|
2445
|
-
}
|
|
2446
|
-
/** Turns a grammY error into something worth showing in the settings page. */
|
|
2447
|
-
function describeTelegramError(error) {
|
|
2448
|
-
if (typeof error === "object" && error !== null) {
|
|
2449
|
-
const candidate = error;
|
|
2450
|
-
const description = candidate.description ?? candidate.message;
|
|
2451
|
-
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`}`;
|
|
2452
|
-
}
|
|
2453
|
-
return error instanceof Error ? error.message : String(error);
|
|
2454
|
-
}
|
|
2455
|
-
async function sendTelegramMessage(token, chatId, html) {
|
|
2456
|
-
try {
|
|
2457
|
-
await getBot(token).api.sendMessage(chatId, html, {
|
|
2458
|
-
parse_mode: "HTML",
|
|
2459
|
-
link_preview_options: { is_disabled: true }
|
|
2460
|
-
});
|
|
2461
|
-
return { ok: true };
|
|
2462
|
-
} catch (error) {
|
|
2463
|
-
return {
|
|
2464
|
-
ok: false,
|
|
2465
|
-
error: describeTelegramError(error)
|
|
2466
|
-
};
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
async function verifyTelegramToken(token) {
|
|
2470
|
-
try {
|
|
2471
|
-
return {
|
|
2472
|
-
ok: true,
|
|
2473
|
-
username: (await getBot(token).api.getMe()).username
|
|
2474
|
-
};
|
|
2475
|
-
} catch (error) {
|
|
2476
|
-
return {
|
|
2477
|
-
ok: false,
|
|
2478
|
-
error: describeTelegramError(error)
|
|
2510
|
+
const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
|
|
2511
|
+
if (removable.length === 0) return {
|
|
2512
|
+
value: null,
|
|
2513
|
+
error: parsed.summary
|
|
2514
|
+
};
|
|
2515
|
+
if (!isRecord$1(candidate)) return {
|
|
2516
|
+
value: null,
|
|
2517
|
+
error: parsed.summary
|
|
2479
2518
|
};
|
|
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);
|
|
2523
|
+
}
|
|
2480
2524
|
}
|
|
2525
|
+
return {
|
|
2526
|
+
value: null,
|
|
2527
|
+
error: "too many unrecognized keys to ignore"
|
|
2528
|
+
};
|
|
2481
2529
|
}
|
|
2482
2530
|
/**
|
|
2483
|
-
*
|
|
2484
|
-
*
|
|
2485
|
-
*
|
|
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.
|
|
2486
2534
|
*/
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
chats.set(String(chat.id), {
|
|
2503
|
-
id: chat.id,
|
|
2504
|
-
title
|
|
2505
|
-
});
|
|
2506
|
-
}
|
|
2507
|
-
return {
|
|
2508
|
-
ok: true,
|
|
2509
|
-
chats: [...chats.values()]
|
|
2510
|
-
};
|
|
2511
|
-
} catch (error) {
|
|
2512
|
-
return {
|
|
2513
|
-
ok: false,
|
|
2514
|
-
chats: [],
|
|
2515
|
-
error: describeTelegramError(error)
|
|
2516
|
-
};
|
|
2535
|
+
function parseConfig(raw, options = {}) {
|
|
2536
|
+
const unknownKeys = [];
|
|
2537
|
+
const errors = [];
|
|
2538
|
+
const warnings = [];
|
|
2539
|
+
const result = {
|
|
2540
|
+
config: null,
|
|
2541
|
+
errors,
|
|
2542
|
+
unknownKeys,
|
|
2543
|
+
warnings,
|
|
2544
|
+
schemaVersion: 1,
|
|
2545
|
+
writtenBy: null
|
|
2546
|
+
};
|
|
2547
|
+
if (!isRecord$1(raw)) {
|
|
2548
|
+
errors.push("the config must contain a JSON object");
|
|
2549
|
+
return result;
|
|
2517
2550
|
}
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
username: verified.username ?? null
|
|
2550
|
-
});
|
|
2551
|
-
}).delete("/notifications/token", describeRoute({
|
|
2552
|
-
tags: ["notifications"],
|
|
2553
|
-
summary: "Forget the Telegram bot token",
|
|
2554
|
-
responses: { 200: { description: "Removed" } }
|
|
2555
|
-
}), (c) => {
|
|
2556
|
-
deps.secrets.setTelegramToken(null);
|
|
2557
|
-
forgetBots();
|
|
2558
|
-
return c.json({ ok: true });
|
|
2559
|
-
}).post("/notifications/test", describeRoute({
|
|
2560
|
-
tags: ["notifications"],
|
|
2561
|
-
summary: "Send a test message",
|
|
2562
|
-
responses: {
|
|
2563
|
-
200: { description: "Sent or refused" },
|
|
2564
|
-
400: ERROR_RESPONSES[400]
|
|
2551
|
+
const meta = isRecord$1(raw.meta) ? raw.meta : null;
|
|
2552
|
+
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
2553
|
+
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
2554
|
+
result.schemaVersion = schemaVersion;
|
|
2555
|
+
result.writtenBy = writtenBy;
|
|
2556
|
+
if (schemaVersion > 1) {
|
|
2557
|
+
errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
|
|
2558
|
+
return result;
|
|
2559
|
+
}
|
|
2560
|
+
const { steps } = planConfigMigrations(schemaVersion, options);
|
|
2561
|
+
if (steps.length > 0) {
|
|
2562
|
+
errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
|
|
2563
|
+
return result;
|
|
2564
|
+
}
|
|
2565
|
+
for (const key of Object.keys(raw)) if (!CONFIG_KEYS.includes(key)) unknownKeys.push(key);
|
|
2566
|
+
const groups = {};
|
|
2567
|
+
for (const [name, schema] of GROUPS) {
|
|
2568
|
+
const parsed = parseTolerant(raw[name] ?? {}, schema, name, unknownKeys);
|
|
2569
|
+
if (parsed.error !== null) errors.push(`${name}: ${parsed.error}`);
|
|
2570
|
+
groups[name] = parsed.value ?? schema({});
|
|
2571
|
+
}
|
|
2572
|
+
const defaults = groups.defaults;
|
|
2573
|
+
const servers = [];
|
|
2574
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2575
|
+
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
2576
|
+
const label = `servers[${index}]`;
|
|
2577
|
+
const parsed = parseTolerant(isRecord$1(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
|
|
2578
|
+
if (parsed.error !== null) {
|
|
2579
|
+
const id = isRecord$1(entry) ? entry.id : void 0;
|
|
2580
|
+
errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
|
|
2581
|
+
return;
|
|
2565
2582
|
}
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
code: "TELEGRAM_SEND_FAILED"
|
|
2571
|
-
});
|
|
2572
|
-
return c.json(result);
|
|
2573
|
-
}).post("/notifications/detect-chats", describeRoute({
|
|
2574
|
-
tags: ["notifications"],
|
|
2575
|
-
summary: "List the chats the bot can see",
|
|
2576
|
-
responses: {
|
|
2577
|
-
200: {
|
|
2578
|
-
description: "Chats",
|
|
2579
|
-
content: jsonBody(type({ chats: type({
|
|
2580
|
-
id: "string | number",
|
|
2581
|
-
title: "string"
|
|
2582
|
-
}).array() }))
|
|
2583
|
-
},
|
|
2584
|
-
400: ERROR_RESPONSES[400]
|
|
2583
|
+
const server = parsed.value;
|
|
2584
|
+
if (seen.has(server.id)) {
|
|
2585
|
+
errors.push(`${label}: duplicate id "${server.id}"`);
|
|
2586
|
+
return;
|
|
2585
2587
|
}
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
code: "TELEGRAM_LIST_FAILED"
|
|
2588
|
+
seen.add(server.id);
|
|
2589
|
+
servers.push({
|
|
2590
|
+
...server,
|
|
2591
|
+
port: server.port ?? null
|
|
2591
2592
|
});
|
|
2592
|
-
return c.json({ chats: result.chats });
|
|
2593
2593
|
});
|
|
2594
|
+
warnings.push(...validateDependencies(servers));
|
|
2595
|
+
if (errors.length > 0) return result;
|
|
2596
|
+
result.config = {
|
|
2597
|
+
meta: {
|
|
2598
|
+
writtenBy: writtenBy ?? "",
|
|
2599
|
+
schema: schemaVersion
|
|
2600
|
+
},
|
|
2601
|
+
control: groups.control,
|
|
2602
|
+
defaults,
|
|
2603
|
+
logs: groups.logs,
|
|
2604
|
+
notifications: groups.notifications,
|
|
2605
|
+
host: groups.host,
|
|
2606
|
+
backups: groups.backups,
|
|
2607
|
+
ddns: groups.ddns,
|
|
2608
|
+
servers
|
|
2609
|
+
};
|
|
2610
|
+
return result;
|
|
2594
2611
|
}
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
+
/** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
|
|
2613
|
+
function validateDependencies(servers) {
|
|
2614
|
+
const ids = new Set(servers.map((server) => server.id));
|
|
2615
|
+
const errors = [];
|
|
2616
|
+
for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
2617
|
+
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
2618
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
2619
|
+
const settled = /* @__PURE__ */ new Set();
|
|
2620
|
+
const byId = new Map(servers.map((server) => [server.id, server]));
|
|
2621
|
+
const walk = (id) => {
|
|
2622
|
+
if (settled.has(id)) return;
|
|
2623
|
+
if (visiting.has(id)) {
|
|
2624
|
+
errors.push(`dependency cycle through "${id}"`);
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
visiting.add(id);
|
|
2628
|
+
for (const dependency of byId.get(id)?.dependsOn ?? []) walk(dependency);
|
|
2629
|
+
visiting.delete(id);
|
|
2630
|
+
settled.add(id);
|
|
2612
2631
|
};
|
|
2632
|
+
for (const server of servers) walk(server.id);
|
|
2633
|
+
return [...new Set(errors)];
|
|
2613
2634
|
}
|
|
2614
|
-
/**
|
|
2615
|
-
function
|
|
2616
|
-
const {
|
|
2617
|
-
let current = config;
|
|
2618
|
-
for (const step of steps) current = step.apply(current);
|
|
2635
|
+
/** Adds the stamp every write carries, so the next release can tell what wrote the file. */
|
|
2636
|
+
function stampConfig(draft) {
|
|
2637
|
+
const { $schema, meta: _meta, ...rest } = draft;
|
|
2619
2638
|
return {
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
configMigrations = [];
|
|
2627
|
-
}));
|
|
2628
|
-
//#endregion
|
|
2629
|
-
//#region src/config/schema.ts
|
|
2630
|
-
function isRecord$2(value) {
|
|
2631
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2632
|
-
}
|
|
2633
|
-
/**
|
|
2634
|
-
* Resolves one entry against the panel's server defaults. A group (`restart`,
|
|
2635
|
-
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
2636
|
-
* member does not silently fall back to the *schema* default for the others — which
|
|
2637
|
-
* is the whole point of the panel having defaults at all.
|
|
2638
|
-
*/
|
|
2639
|
-
function mergeDefaults(defaults, entry) {
|
|
2640
|
-
const merged = { ...entry };
|
|
2641
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
2642
|
-
const current = merged[key];
|
|
2643
|
-
if (current === void 0) merged[key] = value;
|
|
2644
|
-
else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
|
|
2645
|
-
}
|
|
2646
|
-
return merged;
|
|
2647
|
-
}
|
|
2648
|
-
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2649
|
-
var init_schema = __esmMin((() => {
|
|
2650
|
-
init_contracts();
|
|
2651
|
-
metaSchema = type({
|
|
2652
|
-
writtenBy: "string = \"\"",
|
|
2653
|
-
schema: "number.integer >= 1 = 1"
|
|
2654
|
-
}).onUndeclaredKey("reject");
|
|
2655
|
-
configSchema = type({
|
|
2656
|
-
$schema: "string?",
|
|
2657
|
-
meta: metaSchema.optional(),
|
|
2658
|
-
control: controlSchema.default(() => ({})),
|
|
2659
|
-
defaults: defaultsSchema.default(() => ({})),
|
|
2660
|
-
logs: logsSchema.default(() => ({})),
|
|
2661
|
-
notifications: notificationsSchema.default(() => ({})),
|
|
2662
|
-
host: hostSchema.default(() => ({})),
|
|
2663
|
-
backups: backupsSchema.default(() => ({})),
|
|
2664
|
-
servers: serverSchema.array().default(() => [])
|
|
2665
|
-
}).onUndeclaredKey("reject");
|
|
2666
|
-
CONFIG_KEYS = [
|
|
2667
|
-
"$schema",
|
|
2668
|
-
"meta",
|
|
2669
|
-
"control",
|
|
2670
|
-
"defaults",
|
|
2671
|
-
"logs",
|
|
2672
|
-
"notifications",
|
|
2673
|
-
"host",
|
|
2674
|
-
"backups",
|
|
2675
|
-
"servers"
|
|
2676
|
-
];
|
|
2677
|
-
}));
|
|
2678
|
-
//#endregion
|
|
2679
|
-
//#region src/helpers/version.ts
|
|
2680
|
-
var version_exports = /* @__PURE__ */ __exportAll({ appVersion: () => appVersion });
|
|
2681
|
-
/**
|
|
2682
|
-
* The running release, read from the package manifest.
|
|
2683
|
-
*
|
|
2684
|
-
* The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
|
|
2685
|
-
* is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
|
|
2686
|
-
* its own read because it may only import node builtins statically.
|
|
2687
|
-
*/
|
|
2688
|
-
function appVersion() {
|
|
2689
|
-
if (cached !== null) return cached;
|
|
2690
|
-
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
2691
|
-
for (let depth = 0; depth < 4; depth++) {
|
|
2692
|
-
try {
|
|
2693
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
2694
|
-
if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
|
|
2695
|
-
cached = manifest.version;
|
|
2696
|
-
return cached;
|
|
2697
|
-
}
|
|
2698
|
-
} catch {}
|
|
2699
|
-
const parent = path.dirname(dir);
|
|
2700
|
-
if (parent === dir) break;
|
|
2701
|
-
dir = parent;
|
|
2702
|
-
}
|
|
2703
|
-
cached = "0.0.0";
|
|
2704
|
-
return cached;
|
|
2705
|
-
}
|
|
2706
|
-
var cached;
|
|
2707
|
-
var init_version = __esmMin((() => {
|
|
2708
|
-
cached = null;
|
|
2709
|
-
}));
|
|
2710
|
-
//#endregion
|
|
2711
|
-
//#region src/config/parse.ts
|
|
2712
|
-
function isRecord$1(value) {
|
|
2713
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2714
|
-
}
|
|
2715
|
-
function deleteAtPath(root, path) {
|
|
2716
|
-
let node = root;
|
|
2717
|
-
for (const key of path.slice(0, -1)) {
|
|
2718
|
-
node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
|
|
2719
|
-
if (node === void 0) return;
|
|
2720
|
-
}
|
|
2721
|
-
const last = path[path.length - 1];
|
|
2722
|
-
if (last === void 0) return;
|
|
2723
|
-
if (isRecord$1(node)) delete node[String(last)];
|
|
2724
|
-
else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
|
|
2725
|
-
}
|
|
2726
|
-
/**
|
|
2727
|
-
* Validates one object against one schema, tolerating keys the schema does not
|
|
2728
|
-
* know: a config written by a newer release has to keep working here, and losing
|
|
2729
|
-
* the *whole group* to schema defaults would silently reset the listener port,
|
|
2730
|
-
* the bind and the auth policy over a single unrecognized key.
|
|
2731
|
-
*
|
|
2732
|
-
* Only unrecognized keys are dropped, and each one is reported. Anything else is
|
|
2733
|
-
* a real problem, returned for the caller to refuse on.
|
|
2734
|
-
*/
|
|
2735
|
-
function parseTolerant(value, schema, prefix, unknownKeys) {
|
|
2736
|
-
const candidate = structuredClone(value);
|
|
2737
|
-
for (let pass = 0; pass < 25; pass++) {
|
|
2738
|
-
const parsed = schema(candidate);
|
|
2739
|
-
if (!(parsed instanceof type.errors)) return {
|
|
2740
|
-
value: parsed,
|
|
2741
|
-
error: null
|
|
2742
|
-
};
|
|
2743
|
-
const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
|
|
2744
|
-
if (removable.length === 0) return {
|
|
2745
|
-
value: null,
|
|
2746
|
-
error: parsed.summary
|
|
2747
|
-
};
|
|
2748
|
-
if (!isRecord$1(candidate)) return {
|
|
2749
|
-
value: null,
|
|
2750
|
-
error: parsed.summary
|
|
2751
|
-
};
|
|
2752
|
-
for (const problem of removable) {
|
|
2753
|
-
const at = `${prefix}.${problem.path.join(".")}`;
|
|
2754
|
-
if (!unknownKeys.includes(at)) unknownKeys.push(at);
|
|
2755
|
-
deleteAtPath(candidate, problem.path);
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2758
|
-
return {
|
|
2759
|
-
value: null,
|
|
2760
|
-
error: "too many unrecognized keys to ignore"
|
|
2761
|
-
};
|
|
2762
|
-
}
|
|
2763
|
-
/**
|
|
2764
|
-
* Reads a `servers.config.json` from any release. Unrecognized keys are reported
|
|
2765
|
-
* and left on disk; blocking problems — including a file this release cannot
|
|
2766
|
-
* understand — land in `errors` and leave `config` null.
|
|
2767
|
-
*/
|
|
2768
|
-
function parseConfig(raw, options = {}) {
|
|
2769
|
-
const unknownKeys = [];
|
|
2770
|
-
const errors = [];
|
|
2771
|
-
const warnings = [];
|
|
2772
|
-
const result = {
|
|
2773
|
-
config: null,
|
|
2774
|
-
errors,
|
|
2775
|
-
unknownKeys,
|
|
2776
|
-
warnings,
|
|
2777
|
-
schemaVersion: 1,
|
|
2778
|
-
writtenBy: null
|
|
2779
|
-
};
|
|
2780
|
-
if (!isRecord$1(raw)) {
|
|
2781
|
-
errors.push("the config must contain a JSON object");
|
|
2782
|
-
return result;
|
|
2783
|
-
}
|
|
2784
|
-
const meta = isRecord$1(raw.meta) ? raw.meta : null;
|
|
2785
|
-
const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
|
|
2786
|
-
const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
|
|
2787
|
-
result.schemaVersion = schemaVersion;
|
|
2788
|
-
result.writtenBy = writtenBy;
|
|
2789
|
-
if (schemaVersion > 1) {
|
|
2790
|
-
errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
|
|
2791
|
-
return result;
|
|
2792
|
-
}
|
|
2793
|
-
const { steps } = planConfigMigrations(schemaVersion, options);
|
|
2794
|
-
if (steps.length > 0) {
|
|
2795
|
-
errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
|
|
2796
|
-
return result;
|
|
2797
|
-
}
|
|
2798
|
-
for (const key of Object.keys(raw)) if (!CONFIG_KEYS.includes(key)) unknownKeys.push(key);
|
|
2799
|
-
const groups = {};
|
|
2800
|
-
for (const [name, schema] of GROUPS) {
|
|
2801
|
-
const parsed = parseTolerant(raw[name] ?? {}, schema, name, unknownKeys);
|
|
2802
|
-
if (parsed.error !== null) errors.push(`${name}: ${parsed.error}`);
|
|
2803
|
-
groups[name] = parsed.value ?? schema({});
|
|
2804
|
-
}
|
|
2805
|
-
const defaults = groups.defaults;
|
|
2806
|
-
const servers = [];
|
|
2807
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2808
|
-
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
2809
|
-
const label = `servers[${index}]`;
|
|
2810
|
-
const parsed = parseTolerant(isRecord$1(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
|
|
2811
|
-
if (parsed.error !== null) {
|
|
2812
|
-
const id = isRecord$1(entry) ? entry.id : void 0;
|
|
2813
|
-
errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
|
|
2814
|
-
return;
|
|
2815
|
-
}
|
|
2816
|
-
const server = parsed.value;
|
|
2817
|
-
if (seen.has(server.id)) {
|
|
2818
|
-
errors.push(`${label}: duplicate id "${server.id}"`);
|
|
2819
|
-
return;
|
|
2820
|
-
}
|
|
2821
|
-
seen.add(server.id);
|
|
2822
|
-
servers.push({
|
|
2823
|
-
...server,
|
|
2824
|
-
port: server.port ?? null
|
|
2825
|
-
});
|
|
2826
|
-
});
|
|
2827
|
-
warnings.push(...validateDependencies(servers));
|
|
2828
|
-
if (errors.length > 0) return result;
|
|
2829
|
-
result.config = {
|
|
2830
|
-
meta: {
|
|
2831
|
-
writtenBy: writtenBy ?? "",
|
|
2832
|
-
schema: schemaVersion
|
|
2833
|
-
},
|
|
2834
|
-
control: groups.control,
|
|
2835
|
-
defaults,
|
|
2836
|
-
logs: groups.logs,
|
|
2837
|
-
notifications: groups.notifications,
|
|
2838
|
-
host: groups.host,
|
|
2839
|
-
backups: groups.backups,
|
|
2840
|
-
servers
|
|
2841
|
-
};
|
|
2842
|
-
return result;
|
|
2843
|
-
}
|
|
2844
|
-
/** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
|
|
2845
|
-
function validateDependencies(servers) {
|
|
2846
|
-
const ids = new Set(servers.map((server) => server.id));
|
|
2847
|
-
const errors = [];
|
|
2848
|
-
for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
|
|
2849
|
-
else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
|
|
2850
|
-
const visiting = /* @__PURE__ */ new Set();
|
|
2851
|
-
const settled = /* @__PURE__ */ new Set();
|
|
2852
|
-
const byId = new Map(servers.map((server) => [server.id, server]));
|
|
2853
|
-
const walk = (id) => {
|
|
2854
|
-
if (settled.has(id)) return;
|
|
2855
|
-
if (visiting.has(id)) {
|
|
2856
|
-
errors.push(`dependency cycle through "${id}"`);
|
|
2857
|
-
return;
|
|
2858
|
-
}
|
|
2859
|
-
visiting.add(id);
|
|
2860
|
-
for (const dependency of byId.get(id)?.dependsOn ?? []) walk(dependency);
|
|
2861
|
-
visiting.delete(id);
|
|
2862
|
-
settled.add(id);
|
|
2863
|
-
};
|
|
2864
|
-
for (const server of servers) walk(server.id);
|
|
2865
|
-
return [...new Set(errors)];
|
|
2866
|
-
}
|
|
2867
|
-
/** Adds the stamp every write carries, so the next release can tell what wrote the file. */
|
|
2868
|
-
function stampConfig(draft) {
|
|
2869
|
-
const { $schema, meta: _meta, ...rest } = draft;
|
|
2870
|
-
return {
|
|
2871
|
-
...$schema === void 0 ? {} : { $schema },
|
|
2872
|
-
meta: {
|
|
2873
|
-
writtenBy: appVersion(),
|
|
2874
|
-
schema: 1
|
|
2875
|
-
},
|
|
2876
|
-
...rest
|
|
2639
|
+
...$schema === void 0 ? {} : { $schema },
|
|
2640
|
+
meta: {
|
|
2641
|
+
writtenBy: appVersion(),
|
|
2642
|
+
schema: 1
|
|
2643
|
+
},
|
|
2644
|
+
...rest
|
|
2877
2645
|
};
|
|
2878
2646
|
}
|
|
2879
2647
|
var GROUPS, UNDECLARED;
|
|
@@ -2887,7 +2655,8 @@ var init_parse = __esmMin((() => {
|
|
|
2887
2655
|
["logs", logsSchema],
|
|
2888
2656
|
["notifications", notificationsSchema],
|
|
2889
2657
|
["host", hostSchema],
|
|
2890
|
-
["backups", backupsSchema]
|
|
2658
|
+
["backups", backupsSchema],
|
|
2659
|
+
["ddns", ddnsConfigSchema]
|
|
2891
2660
|
];
|
|
2892
2661
|
UNDECLARED = "must be removed";
|
|
2893
2662
|
}));
|
|
@@ -3153,6 +2922,18 @@ var init_store = __esmMin((() => {
|
|
|
3153
2922
|
this.commit(draft);
|
|
3154
2923
|
return backups;
|
|
3155
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
|
+
}
|
|
3156
2937
|
updateDefaults(patch) {
|
|
3157
2938
|
const draft = structuredClone(this.raw);
|
|
3158
2939
|
draft.defaults = { ...draft.defaults ?? {} };
|
|
@@ -3201,43 +2982,1754 @@ var init_store = __esmMin((() => {
|
|
|
3201
2982
|
this.apply(stamped);
|
|
3202
2983
|
this.notify();
|
|
3203
2984
|
}
|
|
3204
|
-
resolveFallback() {
|
|
3205
|
-
const control = controlSchema({});
|
|
3206
|
-
const defaults = defaultsSchema({});
|
|
3207
|
-
if (control instanceof type.errors || defaults instanceof type.errors) throw new ConfigError("internal: default config failed validation");
|
|
3208
|
-
const logs = logsSchema({});
|
|
3209
|
-
const notifications = notificationsSchema({});
|
|
3210
|
-
const host = hostSchema({});
|
|
3211
|
-
const backups = backupsSchema({});
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
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]
|
|
3222
4666
|
}
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
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]
|
|
3231
4694
|
}
|
|
3232
|
-
}
|
|
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]
|
|
4714
|
+
}
|
|
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();
|
|
3233
4730
|
}));
|
|
3234
4731
|
//#endregion
|
|
3235
4732
|
//#region src/api/servers/$.routes.ts
|
|
3236
|
-
/** Unknown ids are 404; a server that exists but cannot start is a 409. */
|
|
3237
|
-
function statusFor(result) {
|
|
3238
|
-
if (result.ok) return 200;
|
|
3239
|
-
return result.error?.startsWith("unknown server") ? 404 : 409;
|
|
3240
|
-
}
|
|
3241
4733
|
function unknownServer(id) {
|
|
3242
4734
|
return new DetailedError(`unknown server "${id}"`, {
|
|
3243
4735
|
statusCode: 404,
|
|
@@ -3387,7 +4879,7 @@ function createServersRoute(deps) {
|
|
|
3387
4879
|
}
|
|
3388
4880
|
}), validate("param", idParam), async (c) => {
|
|
3389
4881
|
const result = await deps.supervisor.start(c.req.valid("param").id);
|
|
3390
|
-
return c.json(result,
|
|
4882
|
+
return c.json(result, statusForAction(result));
|
|
3391
4883
|
}).post("/:id/stop", describeRoute({
|
|
3392
4884
|
tags: ["servers"],
|
|
3393
4885
|
summary: "Stop a server",
|
|
@@ -3407,7 +4899,7 @@ function createServersRoute(deps) {
|
|
|
3407
4899
|
}
|
|
3408
4900
|
}), validate("param", idParam), async (c) => {
|
|
3409
4901
|
const result = await deps.supervisor.restart(c.req.valid("param").id);
|
|
3410
|
-
return c.json(result,
|
|
4902
|
+
return c.json(result, statusForAction(result));
|
|
3411
4903
|
}).post("/:id/clear-logs", describeRoute({
|
|
3412
4904
|
tags: ["servers"],
|
|
3413
4905
|
summary: "Forget the buffered log lines",
|
|
@@ -3433,7 +4925,7 @@ function createServersRoute(deps) {
|
|
|
3433
4925
|
const { id } = c.req.valid("param");
|
|
3434
4926
|
const result = await deps.supervisor.freePort(id);
|
|
3435
4927
|
if (!result.ok) throw new DetailedError(result.error ?? `could not free the port for "${id}"`, {
|
|
3436
|
-
statusCode:
|
|
4928
|
+
statusCode: statusForAction(result),
|
|
3437
4929
|
code: "FREE_PORT_FAILED"
|
|
3438
4930
|
});
|
|
3439
4931
|
return c.json(result);
|
|
@@ -3486,6 +4978,7 @@ function createServersRoute(deps) {
|
|
|
3486
4978
|
var idParam, MAX_PENDING_WRITES, serverResponse, serversResponse, okResponse;
|
|
3487
4979
|
var init_$_routes = __esmMin((() => {
|
|
3488
4980
|
init_store();
|
|
4981
|
+
init_action_result();
|
|
3489
4982
|
init_factory();
|
|
3490
4983
|
init_openapi$1();
|
|
3491
4984
|
init_validator();
|
|
@@ -3937,13 +5430,14 @@ function createRootApp(deps) {
|
|
|
3937
5430
|
const started = Date.now();
|
|
3938
5431
|
await next();
|
|
3939
5432
|
logger.debug(`${c.req.method} ${new URL(c.req.url).pathname} ${c.res.status} ${Date.now() - started}ms`);
|
|
3940
|
-
}).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));
|
|
3941
5434
|
return app.route("/", setupOpenAPI(app)).route("/", createStaticRoute({ dir: () => deps.ui.resolveDir() }));
|
|
3942
5435
|
}
|
|
3943
5436
|
var init_app = __esmMin((() => {
|
|
3944
5437
|
init_$_routes$1();
|
|
3945
5438
|
init_backups$1();
|
|
3946
5439
|
init_control();
|
|
5440
|
+
init_ddns$1();
|
|
3947
5441
|
init_events$1();
|
|
3948
5442
|
init_health();
|
|
3949
5443
|
init_logs();
|
|
@@ -3968,6 +5462,7 @@ var daemon_exports = /* @__PURE__ */ __exportAll({
|
|
|
3968
5462
|
newToken: () => newToken,
|
|
3969
5463
|
probeRuntime: () => probeRuntime,
|
|
3970
5464
|
readRuntime: () => readRuntime,
|
|
5465
|
+
requestControl: () => requestControl,
|
|
3971
5466
|
requestShutdown: () => requestShutdown,
|
|
3972
5467
|
runtimeSchema: () => runtimeSchema,
|
|
3973
5468
|
writeRuntime: () => writeRuntime
|
|
@@ -4000,10 +5495,10 @@ function isProcessAlive$1(pid) {
|
|
|
4000
5495
|
}
|
|
4001
5496
|
/** A degraded panel is still answering: a 503 must not read as "not running". */
|
|
4002
5497
|
async function probeRuntime(runtime, timeoutMs = 2500) {
|
|
4003
|
-
const
|
|
5498
|
+
const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
|
|
4004
5499
|
return {
|
|
4005
|
-
reachable:
|
|
4006
|
-
degraded: status === 503
|
|
5500
|
+
reachable: answer !== null,
|
|
5501
|
+
degraded: answer?.status === 503
|
|
4007
5502
|
};
|
|
4008
5503
|
}
|
|
4009
5504
|
/**
|
|
@@ -4011,15 +5506,36 @@ async function probeRuntime(runtime, timeoutMs = 2500) {
|
|
|
4011
5506
|
* shut down cleanly on every platform (a bare signal is not graceful on Windows).
|
|
4012
5507
|
*/
|
|
4013
5508
|
async function requestShutdown(runtime, timeoutMs = 4e3) {
|
|
4014
|
-
const
|
|
4015
|
-
return
|
|
5509
|
+
const answer = await localCall(runtime, "/_hh/shutdown", "POST", runtime.token, timeoutMs);
|
|
5510
|
+
return answer !== null && answer.status >= 200 && answer.status < 300;
|
|
5511
|
+
}
|
|
5512
|
+
/**
|
|
5513
|
+
* Drives a supervised server from this machine (`home-hosted start`/`stop`). The
|
|
5514
|
+
* token comes from run.json, so it needs no session, no password and no API token —
|
|
5515
|
+
* only the owner of that 0600 file, talking over loopback.
|
|
5516
|
+
*
|
|
5517
|
+
* The wait is generous on purpose: a start may have to run a bootstrap, which has a
|
|
5518
|
+
* 120s default timeout of its own. Past this it is a wedged panel, not a slow one.
|
|
5519
|
+
*/
|
|
5520
|
+
async function requestControl(runtime, path, timeoutMs = 15e4) {
|
|
5521
|
+
const answer = await localCall(runtime, path, "POST", runtime.token, timeoutMs);
|
|
5522
|
+
if (answer === null) return null;
|
|
5523
|
+
let body = null;
|
|
5524
|
+
try {
|
|
5525
|
+
body = JSON.parse(answer.body);
|
|
5526
|
+
} catch {}
|
|
5527
|
+
return {
|
|
5528
|
+
status: answer.status,
|
|
5529
|
+
body
|
|
5530
|
+
};
|
|
4016
5531
|
}
|
|
4017
5532
|
/**
|
|
4018
5533
|
* Talks to the panel over loopback. Node's `fetch` cannot be told to accept the
|
|
4019
5534
|
* self-signed certificate an uploaded TLS pair usually is, which would break
|
|
4020
|
-
* `status
|
|
5535
|
+
* `status`, the graceful `down` and the local start/stop — so this speaks
|
|
5536
|
+
* http/https directly.
|
|
4021
5537
|
*/
|
|
4022
|
-
function
|
|
5538
|
+
function localCall(runtime, path, method, token, timeoutMs) {
|
|
4023
5539
|
return new Promise((resolve) => {
|
|
4024
5540
|
const url = new URL(`${runtime.probeUrl}${path}`);
|
|
4025
5541
|
const secure = url.protocol === "https:";
|
|
@@ -4032,8 +5548,15 @@ function localRequest(runtime, path, method, token, timeoutMs) {
|
|
|
4032
5548
|
headers: token === void 0 ? {} : { "x-home-hosted-token": token },
|
|
4033
5549
|
timeout: timeoutMs
|
|
4034
5550
|
}, (response) => {
|
|
4035
|
-
|
|
4036
|
-
response.
|
|
5551
|
+
let body = "";
|
|
5552
|
+
response.setEncoding("utf8");
|
|
5553
|
+
response.on("data", (chunk) => {
|
|
5554
|
+
body += chunk;
|
|
5555
|
+
});
|
|
5556
|
+
response.once("end", () => resolve({
|
|
5557
|
+
status: response.statusCode ?? 0,
|
|
5558
|
+
body
|
|
5559
|
+
}));
|
|
4037
5560
|
});
|
|
4038
5561
|
request.once("error", () => resolve(null));
|
|
4039
5562
|
request.once("timeout", () => {
|
|
@@ -5587,9 +7110,20 @@ var init_log_relay = __esmMin((() => {
|
|
|
5587
7110
|
this.start();
|
|
5588
7111
|
return history;
|
|
5589
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
|
+
*/
|
|
5590
7121
|
unfollow(serverId) {
|
|
7122
|
+
const current = this.followed.get(serverId);
|
|
7123
|
+
const rest = current === void 0 ? [] : current.tailer.read();
|
|
5591
7124
|
this.followed.delete(serverId);
|
|
5592
7125
|
if (this.followed.size === 0) this.stopTimer();
|
|
7126
|
+
return rest;
|
|
5593
7127
|
}
|
|
5594
7128
|
followedIds() {
|
|
5595
7129
|
return [...this.followed.keys()];
|
|
@@ -5624,6 +7158,18 @@ var init_log_relay = __esmMin((() => {
|
|
|
5624
7158
|
}));
|
|
5625
7159
|
//#endregion
|
|
5626
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
|
+
}
|
|
5627
7173
|
function delay(ms) {
|
|
5628
7174
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5629
7175
|
}
|
|
@@ -5643,7 +7189,7 @@ function serverTemplateVars(config) {
|
|
|
5643
7189
|
home: os.homedir()
|
|
5644
7190
|
};
|
|
5645
7191
|
}
|
|
5646
|
-
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;
|
|
5647
7193
|
var init_supervisor = __esmMin((() => {
|
|
5648
7194
|
init_backoff();
|
|
5649
7195
|
init_bind();
|
|
@@ -5668,6 +7214,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5668
7214
|
PORT_RELEASE_RECHECK_MS = 300;
|
|
5669
7215
|
RESOURCE_SAMPLE_INTERVAL_MS = 5e3;
|
|
5670
7216
|
PERSISTENT_BACKFILL_LINES = 200;
|
|
7217
|
+
ECHOED_LINE_LIMIT = 100;
|
|
5671
7218
|
Supervisor = class {
|
|
5672
7219
|
store;
|
|
5673
7220
|
hub;
|
|
@@ -6017,7 +7564,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6017
7564
|
clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
|
|
6018
7565
|
clearNannySpec(nannySpecPath(this.options.nannyDir, entry.config.id));
|
|
6019
7566
|
} else this.log(entry, "system", `pid ${survivor} is still alive after the stop — its state file is kept`);
|
|
6020
|
-
this.
|
|
7567
|
+
this.drainRelay(entry);
|
|
6021
7568
|
}
|
|
6022
7569
|
entry.stopping = false;
|
|
6023
7570
|
entry.child = null;
|
|
@@ -6059,6 +7606,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6059
7606
|
stopping: false,
|
|
6060
7607
|
bootstrapDone: !config.bootstrap,
|
|
6061
7608
|
logs: new LogBuffer(config.logBufferLines),
|
|
7609
|
+
echoed: /* @__PURE__ */ new Set(),
|
|
6062
7610
|
responseMs: null,
|
|
6063
7611
|
resources: null,
|
|
6064
7612
|
resourcesSampledAt: 0,
|
|
@@ -6078,8 +7626,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6078
7626
|
continue;
|
|
6079
7627
|
}
|
|
6080
7628
|
const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
|
|
7629
|
+
if (entry.config.persistent && !config.persistent) this.drainRelay(entry);
|
|
6081
7630
|
entry.config = config;
|
|
6082
|
-
if (!config.persistent) this.relay.unfollow(id);
|
|
6083
7631
|
if (bufferChanged) {
|
|
6084
7632
|
const kept = entry.logs.list(config.logBufferLines);
|
|
6085
7633
|
entry.logs = new LogBuffer(config.logBufferLines);
|
|
@@ -6239,8 +7787,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6239
7787
|
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
6240
7788
|
entry.startedAt = Date.now();
|
|
6241
7789
|
entry.lastError = null;
|
|
7790
|
+
if (entry.config.persistent) this.followPersistent(entry, true);
|
|
6242
7791
|
this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
|
|
6243
|
-
if (entry.config.persistent) this.followPersistent(entry);
|
|
6244
7792
|
this.publishServer(entry);
|
|
6245
7793
|
return { ok: true };
|
|
6246
7794
|
}
|
|
@@ -6475,25 +8023,41 @@ var init_supervisor = __esmMin((() => {
|
|
|
6475
8023
|
ingestExternalLines(serverId, lines) {
|
|
6476
8024
|
const entry = this.entries.get(serverId);
|
|
6477
8025
|
if (entry === void 0 || !entry.config.persistent || lines.length === 0) return;
|
|
6478
|
-
|
|
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);
|
|
6479
8029
|
this.hub.publish({
|
|
6480
8030
|
type: "log",
|
|
6481
|
-
ts:
|
|
8031
|
+
ts: kept[kept.length - 1].ts,
|
|
6482
8032
|
serverId,
|
|
6483
|
-
lines
|
|
8033
|
+
lines: kept
|
|
6484
8034
|
});
|
|
6485
8035
|
}
|
|
6486
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
|
+
/**
|
|
6487
8045
|
* Follows a persistent entry's log file, filling the ring buffer from what is on disk.
|
|
6488
8046
|
*
|
|
6489
8047
|
* The backfill is read *through* the tailer, which is the only thing that may own the
|
|
6490
8048
|
* offset: reading it from `LogFiles` first and seeking to EOF second would either
|
|
6491
8049
|
* replay a line as news or skip one as history, depending on which order the two
|
|
6492
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.
|
|
6493
8057
|
*/
|
|
6494
|
-
followPersistent(entry) {
|
|
8058
|
+
followPersistent(entry, attach = false) {
|
|
6495
8059
|
const id = entry.config.id;
|
|
6496
|
-
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;
|
|
6497
8061
|
const history = this.relay.follow(id, nannyLogFile(this.options.logFiles.directory, id), { backfill });
|
|
6498
8062
|
if (history.length > 0) entry.logs.extend(history);
|
|
6499
8063
|
}
|
|
@@ -6516,8 +8080,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
6516
8080
|
entry.lastError = null;
|
|
6517
8081
|
entry.nannyExit = null;
|
|
6518
8082
|
const child = state.childPid === null ? "" : `, server pid ${state.childPid}`;
|
|
8083
|
+
this.followPersistent(entry, true);
|
|
6519
8084
|
this.log(entry, "system", `persistent: still running (nanny pid ${state.nannyPid}${child}) — reattached`);
|
|
6520
|
-
this.followPersistent(entry);
|
|
6521
8085
|
this.publishServer(entry);
|
|
6522
8086
|
return true;
|
|
6523
8087
|
}
|
|
@@ -6556,7 +8120,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6556
8120
|
const state = readNannyState(statePath);
|
|
6557
8121
|
if (state?.lastExit !== void 0) entry.nannyExit = state.lastExit;
|
|
6558
8122
|
clearNannyState(statePath);
|
|
6559
|
-
this.
|
|
8123
|
+
this.drainRelay(entry);
|
|
6560
8124
|
}
|
|
6561
8125
|
/** One probe using the configured mode (TCP or HTTP), with timing. */
|
|
6562
8126
|
async probeEntryHealth(entry) {
|
|
@@ -6675,6 +8239,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6675
8239
|
if (this.disposed) return;
|
|
6676
8240
|
const now = Date.now();
|
|
6677
8241
|
await this.options.hostMonitor.tick(now);
|
|
8242
|
+
this.options.ddns.tick(now);
|
|
6678
8243
|
await this.sampleResources(now);
|
|
6679
8244
|
await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
|
|
6680
8245
|
for (const entry of this.entries.values()) {
|
|
@@ -6820,6 +8385,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
6820
8385
|
};
|
|
6821
8386
|
entry.logs.push(line);
|
|
6822
8387
|
this.options.logFiles.append(entry.config.id, line);
|
|
8388
|
+
if (entry.config.persistent && this.options.logFiles.config.persist) rememberEchoedLine(entry.echoed, line);
|
|
6823
8389
|
this.hub.publish({
|
|
6824
8390
|
type: "log",
|
|
6825
8391
|
ts: line.ts,
|
|
@@ -6839,19 +8405,30 @@ var init_supervisor = __esmMin((() => {
|
|
|
6839
8405
|
}
|
|
6840
8406
|
publishState() {
|
|
6841
8407
|
const state = this.getState();
|
|
6842
|
-
const
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
server
|
|
6853
|
-
|
|
6854
|
-
|
|
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("|");
|
|
6855
8432
|
if (signature === this.lastStateSignature) return;
|
|
6856
8433
|
this.lastStateSignature = signature;
|
|
6857
8434
|
this.hub.publish({
|
|
@@ -7048,7 +8625,7 @@ function safeSlug(slug) {
|
|
|
7048
8625
|
return slug;
|
|
7049
8626
|
}
|
|
7050
8627
|
/** A flag is valid for one exact file revision, not for the name alone. */
|
|
7051
|
-
function cacheKey(file) {
|
|
8628
|
+
function cacheKey$1(file) {
|
|
7052
8629
|
return `${file.sizeBytes}:${file.createdAt}`;
|
|
7053
8630
|
}
|
|
7054
8631
|
/** Stable, filesystem-safe name for a data path inside the archive. */
|
|
@@ -7113,13 +8690,13 @@ var init_backups = __esmMin((() => {
|
|
|
7113
8690
|
const files = this.scan();
|
|
7114
8691
|
for (const file of files) {
|
|
7115
8692
|
const cached = this.flags.get(file.name);
|
|
7116
|
-
if (cached === void 0 || cached.key !== cacheKey(file)) this.scheduleRefresh();
|
|
8693
|
+
if (cached === void 0 || cached.key !== cacheKey$1(file)) this.scheduleRefresh();
|
|
7117
8694
|
}
|
|
7118
8695
|
return files.map((file) => {
|
|
7119
8696
|
const cached = this.flags.get(file.name);
|
|
7120
8697
|
return {
|
|
7121
8698
|
...file,
|
|
7122
|
-
encrypted: cached !== void 0 && cached.key === cacheKey(file) ? cached.encrypted : false
|
|
8699
|
+
encrypted: cached !== void 0 && cached.key === cacheKey$1(file) ? cached.encrypted : false
|
|
7123
8700
|
};
|
|
7124
8701
|
});
|
|
7125
8702
|
}
|
|
@@ -7444,7 +9021,7 @@ var init_backups = __esmMin((() => {
|
|
|
7444
9021
|
const dir = this.resolveDir();
|
|
7445
9022
|
const listed = this.scan();
|
|
7446
9023
|
for (const file of listed) {
|
|
7447
|
-
const key = cacheKey(file);
|
|
9024
|
+
const key = cacheKey$1(file);
|
|
7448
9025
|
if (this.flags.get(file.name)?.key === key) continue;
|
|
7449
9026
|
try {
|
|
7450
9027
|
const entries = await listZip(path.join(dir, file.name));
|
|
@@ -7680,6 +9257,370 @@ var init_control_server = __esmMin((() => {
|
|
|
7680
9257
|
};
|
|
7681
9258
|
}));
|
|
7682
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
|
|
7683
9624
|
//#region src/services/events.ts
|
|
7684
9625
|
var ALL, EventHub;
|
|
7685
9626
|
var init_events = __esmMin((() => {
|
|
@@ -8193,7 +10134,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8193
10134
|
"recovered": "recovered",
|
|
8194
10135
|
"rss": "exceeded its memory limit",
|
|
8195
10136
|
"host": "host thresholds breached",
|
|
8196
|
-
"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"
|
|
8197
10140
|
};
|
|
8198
10141
|
TITLE = {
|
|
8199
10142
|
"crash": "🔴 server down",
|
|
@@ -8202,7 +10145,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8202
10145
|
"recovered": "🟢 server recovered",
|
|
8203
10146
|
"rss": "🔴 server over its memory limit",
|
|
8204
10147
|
"host": "🟠 host warning",
|
|
8205
|
-
"host-recovered": "🟢 host recovered"
|
|
10148
|
+
"host-recovered": "🟢 host recovered",
|
|
10149
|
+
"ddns": "🌐 DNS updated",
|
|
10150
|
+
"ddns-error": "🌐 DNS update failed"
|
|
8206
10151
|
};
|
|
8207
10152
|
NotificationService = class {
|
|
8208
10153
|
secrets;
|
|
@@ -8230,6 +10175,7 @@ var init_notifications = __esmMin((() => {
|
|
|
8230
10175
|
onForcedRestart: telegram.onForcedRestart,
|
|
8231
10176
|
onRecovered: telegram.onRecovered,
|
|
8232
10177
|
onHost: telegram.onHost,
|
|
10178
|
+
onDdns: telegram.onDdns,
|
|
8233
10179
|
cooldownMs: telegram.cooldownMs,
|
|
8234
10180
|
lastResult: this.lastResult,
|
|
8235
10181
|
lastResultAt: this.lastResultAt
|
|
@@ -8246,7 +10192,9 @@ var init_notifications = __esmMin((() => {
|
|
|
8246
10192
|
"recovered": telegram.onRecovered,
|
|
8247
10193
|
"rss": telegram.onCrash,
|
|
8248
10194
|
"host": telegram.onHost,
|
|
8249
|
-
"host-recovered": telegram.onHost
|
|
10195
|
+
"host-recovered": telegram.onHost,
|
|
10196
|
+
"ddns": telegram.onDdns,
|
|
10197
|
+
"ddns-error": telegram.onDdns
|
|
8250
10198
|
}[event.reason]) return false;
|
|
8251
10199
|
const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
|
|
8252
10200
|
return !(telegram.cooldownMs > 0 && until > now);
|
|
@@ -9226,6 +11174,12 @@ async function runControlPlane(options) {
|
|
|
9226
11174
|
dataRoot,
|
|
9227
11175
|
home: os.homedir()
|
|
9228
11176
|
})), notifications);
|
|
11177
|
+
const ddns = new DdnsService({
|
|
11178
|
+
getConfig: () => store.config.ddns,
|
|
11179
|
+
getCredentials: (accountId) => secrets.getDdnsCredentials(accountId),
|
|
11180
|
+
notifications,
|
|
11181
|
+
statePath: defaultDdnsStatePath
|
|
11182
|
+
});
|
|
9229
11183
|
let onConfigRestored;
|
|
9230
11184
|
const backups = new BackupService({
|
|
9231
11185
|
dataRoot,
|
|
@@ -9309,6 +11263,7 @@ async function runControlPlane(options) {
|
|
|
9309
11263
|
notifications,
|
|
9310
11264
|
hostMonitor,
|
|
9311
11265
|
backups,
|
|
11266
|
+
ddns,
|
|
9312
11267
|
logsDir: logFiles.directory,
|
|
9313
11268
|
views
|
|
9314
11269
|
}),
|
|
@@ -9316,6 +11271,7 @@ async function runControlPlane(options) {
|
|
|
9316
11271
|
logFiles,
|
|
9317
11272
|
notifications,
|
|
9318
11273
|
hostMonitor,
|
|
11274
|
+
ddns,
|
|
9319
11275
|
nannyDir: defaultNannyDir
|
|
9320
11276
|
});
|
|
9321
11277
|
/**
|
|
@@ -9363,6 +11319,7 @@ async function runControlPlane(options) {
|
|
|
9363
11319
|
clearRuntime();
|
|
9364
11320
|
configWatch.dispose();
|
|
9365
11321
|
auth.dispose();
|
|
11322
|
+
ddns.dispose();
|
|
9366
11323
|
await supervisor.dispose();
|
|
9367
11324
|
logFiles.dispose();
|
|
9368
11325
|
history.dispose();
|
|
@@ -9380,6 +11337,7 @@ async function runControlPlane(options) {
|
|
|
9380
11337
|
logFiles,
|
|
9381
11338
|
notifications,
|
|
9382
11339
|
backups,
|
|
11340
|
+
ddns,
|
|
9383
11341
|
ui,
|
|
9384
11342
|
runtimeToken: token,
|
|
9385
11343
|
onShutdown: () => shutdown("shutdown requested locally")
|
|
@@ -9459,6 +11417,7 @@ var init_src = __esmMin((() => {
|
|
|
9459
11417
|
init_backups();
|
|
9460
11418
|
init_config_watch();
|
|
9461
11419
|
init_control_server();
|
|
11420
|
+
init_ddns();
|
|
9462
11421
|
init_events();
|
|
9463
11422
|
init_exposure();
|
|
9464
11423
|
init_history();
|
|
@@ -9530,7 +11489,9 @@ async function runUp(flags, entry) {
|
|
|
9530
11489
|
const { daemonLogPath, dataRoot, projectDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
9531
11490
|
const existing = readRuntime();
|
|
9532
11491
|
if (existing !== null && isProcessAlive(existing.pid)) {
|
|
9533
|
-
process.stdout.write(`${green("already running")} (pid ${existing.pid}) at ${existing.url}\n`);
|
|
11492
|
+
process.stdout.write(`${green("already running")} home-hosted ${existing.version} (pid ${existing.pid}) at ${existing.url}\n`);
|
|
11493
|
+
const note = versionMismatchNote(existing.version);
|
|
11494
|
+
if (note !== null) process.stdout.write(`${dim(note)}\n`);
|
|
9534
11495
|
process.stdout.write(`${dim("stop it with `home-hosted down`")}\n`);
|
|
9535
11496
|
return;
|
|
9536
11497
|
}
|
|
@@ -9566,7 +11527,7 @@ async function runUp(flags, entry) {
|
|
|
9566
11527
|
if (output.length > 0) process.stderr.write(`${dim(`${daemonLogPath}:`)}\n${output}\n`);
|
|
9567
11528
|
process.exit(1);
|
|
9568
11529
|
}
|
|
9569
|
-
process.stdout.write(`${green(
|
|
11530
|
+
process.stdout.write(`${green(`home-hosted ${runtime.version} is up`)} (pid ${runtime.pid})\n`);
|
|
9570
11531
|
process.stdout.write(` ${bold(runtime.url)}\n`);
|
|
9571
11532
|
process.stdout.write(` ${dim(`project ${runtime.projectDir}`)}\n`);
|
|
9572
11533
|
process.stdout.write(` ${dim(`state ${runtime.dataRoot}`)}\n`);
|
|
@@ -9600,6 +11561,7 @@ var init_up = __esmMin((() => {
|
|
|
9600
11561
|
init_args();
|
|
9601
11562
|
init_io();
|
|
9602
11563
|
init_runtime();
|
|
11564
|
+
init_version();
|
|
9603
11565
|
DEFAULT_PORT$1 = 3999;
|
|
9604
11566
|
LOG_ROTATE_BYTES = 5242880;
|
|
9605
11567
|
upArgs = {
|
|
@@ -9652,14 +11614,20 @@ async function runDown() {
|
|
|
9652
11614
|
}
|
|
9653
11615
|
if (!isProcessAlive(runtime.pid)) {
|
|
9654
11616
|
clearRuntime();
|
|
9655
|
-
process.stdout.write(
|
|
11617
|
+
process.stdout.write(`home-hosted is not running (removed a stale run.json from home-hosted ${runtime.version})\n`);
|
|
9656
11618
|
return;
|
|
9657
11619
|
}
|
|
11620
|
+
/** What was stopped, and whether this CLI is a different release than it was. */
|
|
11621
|
+
const reportStopped = (forced) => {
|
|
11622
|
+
process.stdout.write(`${green("stopped")}${forced ? " (forced)" : ""} ${dim(`— was home-hosted ${runtime.version}`)}\n`);
|
|
11623
|
+
const note = versionMismatchNote(runtime.version);
|
|
11624
|
+
if (note !== null) process.stdout.write(`${dim(note)}\n`);
|
|
11625
|
+
};
|
|
9658
11626
|
process.stdout.write(`stopping pid ${runtime.pid}…\n`);
|
|
9659
11627
|
if (!await requestShutdown(runtime)) signal(runtime.pid, "SIGTERM");
|
|
9660
11628
|
if (await waitForExit(runtime.pid, 2e4)) {
|
|
9661
11629
|
clearRuntime();
|
|
9662
|
-
|
|
11630
|
+
reportStopped(false);
|
|
9663
11631
|
await reportPersistent();
|
|
9664
11632
|
return;
|
|
9665
11633
|
}
|
|
@@ -9667,7 +11635,7 @@ async function runDown() {
|
|
|
9667
11635
|
forceStop(runtime.pid);
|
|
9668
11636
|
await waitForExit(runtime.pid, 5e3);
|
|
9669
11637
|
clearRuntime();
|
|
9670
|
-
|
|
11638
|
+
reportStopped(true);
|
|
9671
11639
|
await reportPersistent();
|
|
9672
11640
|
}
|
|
9673
11641
|
/**
|
|
@@ -9725,6 +11693,7 @@ function forceStop(pid) {
|
|
|
9725
11693
|
var downCommand;
|
|
9726
11694
|
var init_down = __esmMin((() => {
|
|
9727
11695
|
init_io();
|
|
11696
|
+
init_version();
|
|
9728
11697
|
downCommand = defineCommand({
|
|
9729
11698
|
meta: {
|
|
9730
11699
|
name: "down",
|
|
@@ -9836,6 +11805,55 @@ var init_status = __esmMin((() => {
|
|
|
9836
11805
|
});
|
|
9837
11806
|
}));
|
|
9838
11807
|
//#endregion
|
|
11808
|
+
//#region src/cli/server.ts
|
|
11809
|
+
var server_exports = /* @__PURE__ */ __exportAll({
|
|
11810
|
+
runServerAction: () => runServerAction,
|
|
11811
|
+
startCommand: () => startCommand,
|
|
11812
|
+
stopCommand: () => stopCommand
|
|
11813
|
+
});
|
|
11814
|
+
async function runServerAction(action, id) {
|
|
11815
|
+
const { clearRuntime, isProcessAlive, readRuntime, requestControl } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
11816
|
+
const runtime = readRuntime();
|
|
11817
|
+
if (runtime === null || !isProcessAlive(runtime.pid)) {
|
|
11818
|
+
if (runtime !== null) clearRuntime();
|
|
11819
|
+
fail("home-hosted is not running — start it with `home-hosted up`");
|
|
11820
|
+
}
|
|
11821
|
+
const answer = await requestControl(runtime, `/_hh/servers/${encodeURIComponent(id)}/${action}`);
|
|
11822
|
+
if (answer === null) fail(`the control panel is not answering on ${runtime.probeUrl}`);
|
|
11823
|
+
const body = answer.body ?? {};
|
|
11824
|
+
if (body.ok !== true) {
|
|
11825
|
+
const reason = body.error ?? body.message;
|
|
11826
|
+
if (reason !== void 0) fail(reason);
|
|
11827
|
+
if (answer.status === 404) fail(`the panel on ${runtime.url} is home-hosted ${runtime.version}, which does not know this command — restart it on this release`);
|
|
11828
|
+
fail(`the panel answered ${answer.status}`);
|
|
11829
|
+
}
|
|
11830
|
+
process.stdout.write(`${green(action === "start" ? "started" : "stopped")} ${id}\n`);
|
|
11831
|
+
}
|
|
11832
|
+
function serverCommand(action) {
|
|
11833
|
+
return defineCommand({
|
|
11834
|
+
meta: {
|
|
11835
|
+
name: action,
|
|
11836
|
+
description: action === "start" ? "start one server (the panel keeps running)" : "stop one server (the panel keeps running)"
|
|
11837
|
+
},
|
|
11838
|
+
args: { id: {
|
|
11839
|
+
type: "positional",
|
|
11840
|
+
required: false,
|
|
11841
|
+
description: "the server id from servers.config.json"
|
|
11842
|
+
} },
|
|
11843
|
+
run: async ({ args }) => {
|
|
11844
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
11845
|
+
if (id.length === 0) fail(`${action} needs a server id — see \`home-hosted status\` or servers.config.json`);
|
|
11846
|
+
await runServerAction(action, id);
|
|
11847
|
+
}
|
|
11848
|
+
});
|
|
11849
|
+
}
|
|
11850
|
+
var startCommand, stopCommand;
|
|
11851
|
+
var init_server = __esmMin((() => {
|
|
11852
|
+
init_io();
|
|
11853
|
+
startCommand = serverCommand("start");
|
|
11854
|
+
stopCommand = serverCommand("stop");
|
|
11855
|
+
}));
|
|
11856
|
+
//#endregion
|
|
9839
11857
|
//#region src/cli/set-password.ts
|
|
9840
11858
|
var set_password_exports = /* @__PURE__ */ __exportAll({
|
|
9841
11859
|
runSetPassword: () => runSetPassword,
|
|
@@ -10894,6 +12912,8 @@ var SYNOPSIS = {
|
|
|
10894
12912
|
"down": "home-hosted down",
|
|
10895
12913
|
"restart": "home-hosted restart [options]",
|
|
10896
12914
|
"status": "home-hosted status [--json]",
|
|
12915
|
+
"start": "home-hosted start <id>",
|
|
12916
|
+
"stop": "home-hosted stop <id>",
|
|
10897
12917
|
"set-password": "home-hosted set-password",
|
|
10898
12918
|
"set-token": "home-hosted set-token",
|
|
10899
12919
|
"migrate": "home-hosted migrate",
|
|
@@ -10907,6 +12927,8 @@ var SUMMARIES = {
|
|
|
10907
12927
|
"down": "stop it, and everything it supervises",
|
|
10908
12928
|
"restart": "down, then up",
|
|
10909
12929
|
"status": "is it running, where, and how to reach it",
|
|
12930
|
+
"start": "start one server, leaving the panel up",
|
|
12931
|
+
"stop": "stop one server, leaving the panel up",
|
|
10910
12932
|
"set-password": "set the panel password without the API",
|
|
10911
12933
|
"set-token": "set the API token that scripts and agents use",
|
|
10912
12934
|
"migrate": "bring the config up to this release's schema",
|
|
@@ -10933,6 +12955,10 @@ var STATUS_SECTION = {
|
|
|
10933
12955
|
heading: "Options for status",
|
|
10934
12956
|
lines: [["--json", "print machine-readable JSON"]]
|
|
10935
12957
|
};
|
|
12958
|
+
var SERVER_SECTION = {
|
|
12959
|
+
heading: "Arguments for start/stop",
|
|
12960
|
+
lines: [["<id>", "the server id from servers.config.json"]]
|
|
12961
|
+
};
|
|
10936
12962
|
var SET_PASSWORD_SECTION = {
|
|
10937
12963
|
heading: "Options for set-password",
|
|
10938
12964
|
lines: [["--clear", "remove the password, which disables authentication"]]
|
|
@@ -11044,11 +13070,15 @@ var USAGE = [
|
|
|
11044
13070
|
"",
|
|
11045
13071
|
renderSection(STATUS_SECTION),
|
|
11046
13072
|
"",
|
|
13073
|
+
renderSection(SERVER_SECTION),
|
|
13074
|
+
"",
|
|
11047
13075
|
SHARED_TRAILER
|
|
11048
13076
|
].join("\n");
|
|
11049
13077
|
var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
|
|
11050
13078
|
var SECTIONS = {
|
|
11051
13079
|
"status": STATUS_SECTION,
|
|
13080
|
+
"start": SERVER_SECTION,
|
|
13081
|
+
"stop": SERVER_SECTION,
|
|
11052
13082
|
"set-password": SET_PASSWORD_SECTION,
|
|
11053
13083
|
"set-token": SET_TOKEN_SECTION,
|
|
11054
13084
|
"migrate": MIGRATE_SECTION,
|
|
@@ -11095,6 +13125,8 @@ var COMMANDS = {
|
|
|
11095
13125
|
"down": () => Promise.resolve().then(() => (init_down(), down_exports)).then((module) => module.downCommand),
|
|
11096
13126
|
"restart": () => Promise.resolve().then(() => (init_restart(), restart_exports)).then((module) => module.restartCommand(CLI_ENTRY)),
|
|
11097
13127
|
"status": () => Promise.resolve().then(() => (init_status(), status_exports)).then((module) => module.statusCommand),
|
|
13128
|
+
"start": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.startCommand),
|
|
13129
|
+
"stop": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.stopCommand),
|
|
11098
13130
|
"set-password": () => Promise.resolve().then(() => (init_set_password(), set_password_exports)).then((module) => module.setPasswordCommand),
|
|
11099
13131
|
"set-token": () => Promise.resolve().then(() => (init_set_token(), set_token_exports)).then((module) => module.setTokenCommand),
|
|
11100
13132
|
"migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),
|