home-hosted 0.6.6 → 0.6.8

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