home-hosted 0.6.7 → 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
@@ -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, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
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");
@@ -553,8 +553,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
553
553
  zone: "string?",
554
554
  /** Seconds; `1` means "automatic" where the provider supports it. */
555
555
  ttl: "number.integer >= 1?",
556
- /** Cloudflare only. */
557
- proxied: "boolean?",
556
+ /** Proxying is a property of the record, not the panel: Cloudflare only. */
557
+ proxied: "boolean = false",
558
558
  enabled: "boolean = true"
559
559
  }).onUndeclaredKey("reject");
560
560
  ddnsIpv4Schema = type({
@@ -574,10 +574,6 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
574
574
  ipv6: ddnsIpv6Schema.default(() => ({})),
575
575
  /** Default TTL for the records that accept one. */
576
576
  ttl: "number.integer >= 1 = 1",
577
- /** Default proxy flag, where the provider has one (Cloudflare). */
578
- proxied: "boolean = false",
579
- /** Tell Telegram about a change, and about an update that failed. */
580
- notify: "boolean = true",
581
577
  accounts: ddnsAccountSchema.array().default(() => []),
582
578
  domains: ddnsDomainSchema.array().default(() => [])
583
579
  }).onUndeclaredKey("reject");
@@ -623,7 +619,25 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
623
619
  /** Account ids whose credentials are stored. */
624
620
  credentials: type("string[]")
625
621
  });
626
- ddnsCredentialsSchema = type({ credentials: type("Record<string, string>") }).onUndeclaredKey("reject");
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");
627
641
  backupsSchema = type({
628
642
  enabled: "boolean = true",
629
643
  dir: "string = \".backups\"",
@@ -807,7 +821,9 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
807
821
  logs: logsPatchSchema.optional(),
808
822
  notifications: notificationsPatchSchema.optional(),
809
823
  host: hostPatchSchema.optional(),
810
- backups: backupsPatchSchema.optional()
824
+ backups: backupsPatchSchema.optional(),
825
+ /** Replaces the DDNS lists; see `ddnsPatchSchema`. */
826
+ ddns: ddnsPatchSchema.optional()
811
827
  }).onUndeclaredKey("reject");
812
828
  authStatusSchema = type({
813
829
  enabled: "boolean",
@@ -943,6 +959,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
943
959
  });
944
960
  backupPathSchema = type({
945
961
  path: "string",
962
+ /** Which item this path is in a backup's `include`; `data:<path>`. */
963
+ id: "string?",
946
964
  /** Who declared it: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
947
965
  origin: "string",
948
966
  /** false when a parent path already covers it, or it would swallow the archive dir. */
@@ -986,9 +1004,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
986
1004
  reloaded: "boolean",
987
1005
  error: "string?"
988
1006
  });
989
- backupCreateSchema = type({
990
- /** Optional: encrypts the archive. Never stored. */
991
- 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");
992
1013
  restoreRequestSchema = type({
993
1014
  name: "string?",
994
1015
  password: passwordValueSchema.optional(),
@@ -1243,6 +1264,12 @@ function writeFileAtomic(file, content, options = {}) {
1243
1264
  }
1244
1265
  var init_atomic = __esmMin((() => {}));
1245
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
1246
1273
  //#region src/config/secrets.ts
1247
1274
  function deriveKey(password, salt, cost, keylen) {
1248
1275
  return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
@@ -1291,24 +1318,104 @@ function apiTokenRecord(token, now = Date.now()) {
1291
1318
  updatedAt: now
1292
1319
  };
1293
1320
  }
1294
- /**
1295
- * Only account → string map → string survives; a hand-edited file with a number
1296
- * or a nested object in there reads as "no credentials" instead of throwing.
1297
- */
1298
- function readDdnsCredentials(value) {
1299
- if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
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 {};
1300
1340
  const out = {};
1301
- for (const [accountId, fields] of Object.entries(value)) {
1302
- if (typeof fields !== "object" || fields === null || Array.isArray(fields)) continue;
1303
- const record = {};
1304
- for (const [key, field] of Object.entries(fields)) if (typeof field === "string" && field.length > 0) record[key] = field;
1305
- if (Object.keys(record).length > 0) out[accountId] = record;
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
+ };
1306
1354
  }
1307
1355
  return out;
1308
1356
  }
1309
- var COST, KEYLEN, SALT_BYTES, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
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;
1310
1416
  var init_secrets = __esmMin((() => {
1311
1417
  init_atomic();
1418
+ init_logger();
1312
1419
  COST = {
1313
1420
  N: 16384,
1314
1421
  r: 8,
@@ -1316,14 +1423,23 @@ var init_secrets = __esmMin((() => {
1316
1423
  };
1317
1424
  KEYLEN = 64;
1318
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";
1319
1430
  API_TOKEN_BYTES = 32;
1320
1431
  API_TOKEN_HINT_CHARS = 8;
1321
1432
  SecretsStore = class {
1322
1433
  file;
1323
1434
  cache = null;
1324
1435
  cacheKey = "";
1325
- constructor(file) {
1436
+ ddnsKeyCache = null;
1437
+ warnedAccounts = /* @__PURE__ */ new Set();
1438
+ ddnsSecret;
1439
+ constructor(file, ddnsSecret) {
1326
1440
  this.file = file;
1441
+ const candidate = (ddnsSecret ?? process.env.HHOSTED_DDNS_SECRET ?? "").trim();
1442
+ this.ddnsSecret = candidate.length > 0 ? candidate : "hh";
1327
1443
  }
1328
1444
  get path() {
1329
1445
  return this.file;
@@ -1416,38 +1532,98 @@ var init_secrets = __esmMin((() => {
1416
1532
  telegram: trimmed.length > 0 ? { botToken: trimmed } : null
1417
1533
  });
1418
1534
  }
1419
- getDdnsCredentials(accountId) {
1420
- return this.load().ddns[accountId] ?? null;
1421
- }
1535
+ /** Ids that have something stored, whether or not a config still declares them. */
1422
1536
  get ddnsAccountIds() {
1423
1537
  return Object.keys(this.load().ddns);
1424
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
+ }
1425
1550
  /** An empty field is dropped, so clearing every field forgets the account. */
1426
- setDdnsCredentials(accountId, credentials) {
1427
- const entries = Object.entries(credentials).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value.length > 0);
1428
- const ddns = { ...this.load().ddns };
1429
- if (entries.length === 0) delete ddns[accountId];
1430
- else ddns[accountId] = Object.fromEntries(entries);
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
+ };
1431
1561
  this.save({
1432
- ...this.load(),
1562
+ ...current,
1433
1563
  ddns
1434
1564
  });
1435
1565
  }
1436
1566
  clearDdnsCredentials(accountId) {
1437
- const ddns = { ...this.load().ddns };
1567
+ const current = this.load();
1568
+ if (current.ddns[accountId] === void 0) return;
1569
+ const ddns = { ...current.ddns };
1438
1570
  delete ddns[accountId];
1439
1571
  this.save({
1440
- ...this.load(),
1572
+ ...current,
1441
1573
  ddns
1442
1574
  });
1443
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
+ }
1444
1619
  read() {
1445
1620
  if (!fs.existsSync(this.file)) return {
1446
1621
  version: 3,
1447
1622
  password: null,
1448
1623
  apiToken: null,
1449
1624
  telegram: null,
1450
- ddns: {}
1625
+ ddns: {},
1626
+ ddnsKdf: null
1451
1627
  };
1452
1628
  try {
1453
1629
  const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
@@ -1456,7 +1632,8 @@ var init_secrets = __esmMin((() => {
1456
1632
  password: parsed?.password ?? null,
1457
1633
  apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
1458
1634
  telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
1459
- ddns: readDdnsCredentials(parsed?.ddns)
1635
+ ddns: readStoredDdns(parsed?.ddns),
1636
+ ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
1460
1637
  };
1461
1638
  } catch {
1462
1639
  return {
@@ -1464,13 +1641,32 @@ var init_secrets = __esmMin((() => {
1464
1641
  password: null,
1465
1642
  apiToken: null,
1466
1643
  telegram: null,
1467
- ddns: {}
1644
+ ddns: {},
1645
+ ddnsKdf: null
1468
1646
  };
1469
1647
  }
1470
1648
  }
1471
1649
  save(contents) {
1472
- writeFileAtomic(this.file, `${JSON.stringify(contents, null, 2)}\n`, { mode: 384 });
1473
- 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
+ };
1474
1670
  }
1475
1671
  };
1476
1672
  }));
@@ -1975,12 +2171,6 @@ var init_$_routes$1 = __esmMin((() => {
1975
2171
  init_contracts();
1976
2172
  }));
1977
2173
  //#endregion
1978
- //#region src/helpers/logger.ts
1979
- var logger;
1980
- var init_logger = __esmMin((() => {
1981
- logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
1982
- }));
1983
- //#endregion
1984
2174
  //#region src/helpers/validate.ts
1985
2175
  /**
1986
2176
  * Validates a value that is not a request target (a query string, a URL
@@ -2110,7 +2300,10 @@ function buildBackupsView(store, backups) {
2110
2300
  dir: backups.directory,
2111
2301
  keep: store.config.backups.keep,
2112
2302
  includePaths: store.config.backups.includePaths,
2113
- paths: backups.paths,
2303
+ paths: backups.paths.map((entry) => ({
2304
+ ...entry,
2305
+ id: `data:${entry.path}`
2306
+ })),
2114
2307
  files: backups.list()
2115
2308
  };
2116
2309
  }
@@ -2166,7 +2359,10 @@ function createBackupsRoute(deps) {
2166
2359
  }
2167
2360
  }), validate("json", backupCreateSchema), async (c) => {
2168
2361
  const body = c.req.valid("json");
2169
- 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
+ });
2170
2366
  if (!result.ok) throw backupFailed(result.error);
2171
2367
  return c.json({
2172
2368
  file: result.file,
@@ -2681,473 +2877,139 @@ var init_seed = __esmMin((() => {
2681
2877
  };
2682
2878
  }));
2683
2879
  //#endregion
2684
- //#region src/config/store.ts
2685
- function formatErrors(errors) {
2686
- 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
+ };
2687
2887
  }
2688
- function isRecord(value) {
2689
- 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
+ };
2690
2895
  }
2691
- function applyPatch(target, patch, mergeKeys) {
2692
- for (const [key, value] of Object.entries(patch)) {
2693
- if (value === void 0) continue;
2694
- if (mergeKeys.has(key) && isRecord(value) && isRecord(target[key])) {
2695
- target[key] = mergeGroup(target[key], value);
2696
- 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
+ };
2697
2955
  }
2698
- 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
+ };
2699
2975
  }
2700
2976
  }
2701
2977
  /**
2702
- * Merges one nested group recursively — `health.http` is a group of its own, and
2703
- * replacing it wholesale would silently reset the siblings a partial patch never
2704
- * mentioned. An explicit `null` removes a key, which is how a schema-optional
2705
- * 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.
2706
2981
  */
2707
- function mergeGroup(target, patch) {
2708
- const merged = { ...target };
2709
- for (const [key, value] of Object.entries(patch)) {
2710
- if (value === void 0) continue;
2711
- if (value === null) {
2712
- delete merged[key];
2713
- continue;
2714
- }
2715
- if (isRecord(value) && isRecord(merged[key])) {
2716
- merged[key] = mergeGroup(merged[key], value);
2717
- continue;
2718
- }
2719
- 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;
2720
2994
  }
2721
- return merged;
2995
+ return null;
2722
2996
  }
2723
- var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, EMPTY_MERGE_KEYS, ConfigError, ConfigStore;
2724
- var init_store = __esmMin((() => {
2725
- init_migrations();
2726
- init_parse();
2727
- init_schema();
2728
- init_seed();
2729
- init_atomic();
2730
- init_paths();
2731
- SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
2732
- "restart",
2733
- "health",
2734
- "stop"
2735
- ]);
2736
- CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
2737
- NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["telegram"]);
2738
- EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
2739
- ConfigError = class extends Error {
2740
- name = "ConfigError";
2741
- };
2742
- ConfigStore = class {
2743
- file;
2744
- seed;
2745
- raw = {};
2746
- /** The bytes behind the live config, so a watcher can tell a real edit from our own write. */
2747
- lastText = null;
2748
- resolvedConfig;
2749
- error = null;
2750
- warnings = [];
2751
- schemaVersion = 1;
2752
- listeners = /* @__PURE__ */ new Set();
2753
- constructor(file, seed = SEED_CONFIG) {
2754
- this.file = file;
2755
- this.seed = seed;
2756
- }
2757
- get path() {
2758
- return this.file;
2759
- }
2760
- get config() {
2761
- return this.resolvedConfig;
2762
- }
2763
- get configError() {
2764
- return this.error;
2765
- }
2766
- /** Keys a newer release wrote that this one ignores; nothing to refuse over. */
2767
- get configWarnings() {
2768
- return [...this.warnings];
2769
- }
2770
- /** The shape the file declares, as last read. */
2771
- get configSchemaVersion() {
2772
- return this.schemaVersion;
2773
- }
2774
- /** Steps that would have to run before this release could use the file. */
2775
- get pendingMigrations() {
2776
- return planConfigMigrations(this.schemaVersion).steps;
2777
- }
2778
- get servers() {
2779
- return this.resolvedConfig.servers;
2780
- }
2781
- get defaults() {
2782
- return this.resolvedConfig.defaults;
2783
- }
2784
- get rawConfig() {
2785
- return structuredClone(this.raw);
2786
- }
2787
- onChange(listener) {
2788
- this.listeners.add(listener);
2789
- return () => this.listeners.delete(listener);
2790
- }
2791
- getServer(id) {
2792
- return this.servers.find((server) => server.id === id);
2793
- }
2794
- /**
2795
- * Reads the file and tells the listeners, so whatever wrote it — the settings
2796
- * page, a restored backup, or the file watcher — becomes the live config.
2797
- */
2798
- load() {
2799
- this.read();
2800
- this.notify();
2801
- }
2802
- /**
2803
- * The watcher's entry point: re-reads the file only when its bytes changed.
2804
- *
2805
- * `changed` means the file on disk is different from what the live config was
2806
- * read from, `applied` means that difference was accepted — a file this release
2807
- * cannot read is reported and the config already running is left alone, which is
2808
- * what keeps an editor's typo from stopping every server.
2809
- */
2810
- reloadFromDisk() {
2811
- let text;
2812
- try {
2813
- text = fs.readFileSync(this.file, "utf8");
2814
- } catch {
2815
- this.error = `${path.basename(this.file)} is gone`;
2816
- return {
2817
- changed: false,
2818
- applied: false,
2819
- error: this.error
2820
- };
2821
- }
2822
- if (text === this.lastText) {
2823
- if (this.error !== null) {
2824
- this.error = null;
2825
- this.load();
2826
- }
2827
- return {
2828
- changed: false,
2829
- applied: false,
2830
- error: null
2831
- };
2832
- }
2833
- const before = this.resolvedConfig;
2834
- this.load();
2835
- return {
2836
- changed: true,
2837
- applied: this.resolvedConfig !== before,
2838
- error: this.error
2839
- };
2840
- }
2841
- read() {
2842
- if (!fs.existsSync(this.file)) {
2843
- const seed = stampConfig(structuredClone(this.seed));
2844
- const text = `${JSON.stringify(seed, null, 2)}\n`;
2845
- writeFileAtomic(this.file, text);
2846
- this.lastText = text;
2847
- this.raw = seed;
2848
- this.apply(seed);
2849
- return;
2850
- }
2851
- let text;
2852
- let parsed;
2853
- try {
2854
- text = fs.readFileSync(this.file, "utf8");
2855
- parsed = JSON.parse(text);
2856
- } catch (error) {
2857
- this.error = `cannot parse ${path.basename(this.file)}: ${error.message}`;
2858
- if (this.resolvedConfig === void 0) {
2859
- this.raw = {};
2860
- this.resolvedConfig = this.resolveFallback();
2861
- }
2862
- return;
2863
- }
2864
- this.lastText = text;
2865
- this.apply(parsed);
2866
- }
2867
- notify() {
2868
- for (const listener of this.listeners) listener();
2869
- }
2870
- updateServer(id, patch) {
2871
- const index = this.raw.servers?.findIndex((entry) => entry.id === id) ?? -1;
2872
- if (index < 0) throw new ConfigError(`unknown server "${id}"`);
2873
- const draft = structuredClone(this.raw);
2874
- const entry = draft.servers[index];
2875
- applyPatch(entry, patch, SERVER_MERGE_KEYS);
2876
- const validated = this.validateServer(entry, `servers[${index}]`);
2877
- this.commit(draft);
2878
- return validated;
2879
- }
2880
- updateControl(patch) {
2881
- const draft = structuredClone(this.raw);
2882
- draft.control = { ...draft.control ?? {} };
2883
- applyPatch(draft.control, patch, CONTROL_MERGE_KEYS);
2884
- const control = controlSchema(draft.control);
2885
- if (control instanceof type.errors) throw new ConfigError(`control: ${formatErrors(control)}`);
2886
- this.commit(draft);
2887
- return control;
2888
- }
2889
- updateLogs(patch) {
2890
- const draft = structuredClone(this.raw);
2891
- draft.logs = { ...draft.logs ?? {} };
2892
- applyPatch(draft.logs, patch, EMPTY_MERGE_KEYS);
2893
- const logs = logsSchema(draft.logs);
2894
- if (logs instanceof type.errors) throw new ConfigError(`logs: ${formatErrors(logs)}`);
2895
- this.commit(draft);
2896
- return logs;
2897
- }
2898
- updateNotifications(patch) {
2899
- const draft = structuredClone(this.raw);
2900
- draft.notifications = { ...draft.notifications ?? {} };
2901
- applyPatch(draft.notifications, patch, NOTIFICATION_MERGE_KEYS);
2902
- const notifications = notificationsSchema(draft.notifications);
2903
- if (notifications instanceof type.errors) throw new ConfigError(`notifications: ${formatErrors(notifications)}`);
2904
- this.commit(draft);
2905
- return notifications;
2906
- }
2907
- updateHost(patch) {
2908
- const draft = structuredClone(this.raw);
2909
- draft.host = { ...draft.host ?? {} };
2910
- applyPatch(draft.host, patch, EMPTY_MERGE_KEYS);
2911
- const host = hostSchema(draft.host);
2912
- if (host instanceof type.errors) throw new ConfigError(`host: ${formatErrors(host)}`);
2913
- this.commit(draft);
2914
- return host;
2915
- }
2916
- updateBackups(patch) {
2917
- const draft = structuredClone(this.raw);
2918
- draft.backups = { ...draft.backups ?? {} };
2919
- applyPatch(draft.backups, patch, EMPTY_MERGE_KEYS);
2920
- const backups = backupsSchema(draft.backups);
2921
- if (backups instanceof type.errors) throw new ConfigError(`backups: ${formatErrors(backups)}`);
2922
- this.commit(draft);
2923
- return backups;
2924
- }
2925
- /**
2926
- * The DDNS block is replaced as a whole: its accounts and domains are lists,
2927
- * and a key-by-key merge would leave a removed account or hostname behind.
2928
- */
2929
- updateDdns(config) {
2930
- const parsed = ddnsConfigSchema(config);
2931
- if (parsed instanceof type.errors) throw new ConfigError(`ddns: ${formatErrors(parsed)}`);
2932
- const draft = structuredClone(this.raw);
2933
- draft.ddns = parsed;
2934
- this.commit(draft);
2935
- return parsed;
2936
- }
2937
- updateDefaults(patch) {
2938
- const draft = structuredClone(this.raw);
2939
- draft.defaults = { ...draft.defaults ?? {} };
2940
- applyPatch(draft.defaults, patch, SERVER_MERGE_KEYS);
2941
- const defaults = defaultsSchema(draft.defaults);
2942
- if (defaults instanceof type.errors) throw new ConfigError(`defaults: ${formatErrors(defaults)}`);
2943
- this.commit(draft);
2944
- return defaults;
2945
- }
2946
- addServer(input) {
2947
- const draft = structuredClone(this.raw);
2948
- draft.servers ??= [];
2949
- if (draft.servers.some((entry) => entry.id === input.id)) throw new ConfigError(`server "${String(input.id)}" already exists`);
2950
- const index = draft.servers.length;
2951
- draft.servers.push(structuredClone(input));
2952
- const validated = this.validateServer(draft.servers[index], `servers[${index}]`);
2953
- this.commit(draft);
2954
- return validated;
2955
- }
2956
- removeServer(id) {
2957
- const draft = structuredClone(this.raw);
2958
- const before = draft.servers?.length ?? 0;
2959
- draft.servers = (draft.servers ?? []).filter((entry) => entry.id !== id);
2960
- if (draft.servers.length === before) throw new ConfigError(`unknown server "${id}"`);
2961
- this.commit(draft);
2962
- }
2963
- /** Regenerates `servers.config.schema.json` for editor autocomplete. */
2964
- writeJsonSchema() {
2965
- const schema = JSON.stringify(configSchema.toJsonSchema(), null, 2);
2966
- if ((fs.existsSync(configSchemaPath) ? fs.readFileSync(configSchemaPath, "utf8") : null) !== schema) writeFileAtomic(configSchemaPath, schema);
2967
- }
2968
- validateServer(entry, label) {
2969
- const parsed = serverSchema(mergeDefaults(this.defaults, entry));
2970
- if (parsed instanceof type.errors) throw new ConfigError(`${label}: ${formatErrors(parsed)}`);
2971
- return {
2972
- ...parsed,
2973
- port: parsed.port ?? null
2974
- };
2975
- }
2976
- commit(draft) {
2977
- const stamped = stampConfig(draft);
2978
- const text = `${JSON.stringify(stamped, null, 2)}\n`;
2979
- writeFileAtomic(this.file, text);
2980
- this.lastText = text;
2981
- this.raw = stamped;
2982
- this.apply(stamped);
2983
- this.notify();
2984
- }
2985
- resolveFallback() {
2986
- const control = controlSchema({});
2987
- const defaults = defaultsSchema({});
2988
- if (control instanceof type.errors || defaults instanceof type.errors) throw new ConfigError("internal: default config failed validation");
2989
- const logs = logsSchema({});
2990
- const notifications = notificationsSchema({});
2991
- const host = hostSchema({});
2992
- const backups = backupsSchema({});
2993
- const ddns = ddnsConfigSchema({});
2994
- if (logs instanceof type.errors || notifications instanceof type.errors || host instanceof type.errors || backups instanceof type.errors || ddns instanceof type.errors) throw new ConfigError("internal: default settings failed validation");
2995
- return {
2996
- control,
2997
- defaults,
2998
- logs,
2999
- notifications,
3000
- host,
3001
- backups,
3002
- ddns,
3003
- servers: []
3004
- };
3005
- }
3006
- apply(raw) {
3007
- this.raw = raw;
3008
- const parsed = parseConfig(raw);
3009
- this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
3010
- this.schemaVersion = parsed.schemaVersion;
3011
- this.warnings = [...parsed.warnings, ...parsed.unknownKeys.length === 0 ? [] : [`${path.basename(this.file)} carries ${parsed.unknownKeys.length} unrecognized key(s) this release ignores: ${parsed.unknownKeys.join(", ")}`]];
3012
- if (parsed.config !== null) this.resolvedConfig = parsed.config;
3013
- else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
3014
- }
3015
- };
3016
- }));
3017
- //#endregion
3018
- //#region src/providers/ddns/types.ts
3019
- function succeeded(message, changed) {
3020
- return {
3021
- ok: true,
3022
- changed,
3023
- message
3024
- };
3025
- }
3026
- function failed(error) {
3027
- return {
3028
- ok: false,
3029
- changed: false,
3030
- message: error,
3031
- error
3032
- };
3033
- }
3034
- /** Missing required credential keys, as a sentence for the settings page. */
3035
- function missingFields(credentials, keys) {
3036
- const missing = keys.filter((key) => (credentials?.[key] ?? "").trim().length === 0);
3037
- return missing.length === 0 ? null : `set ${missing.join(", ")}`;
3038
- }
3039
- function basicAuth(user, password) {
3040
- return `Basic ${Buffer$1.from(`${user}:${password}`, "utf8").toString("base64")}`;
3041
- }
3042
- /** A provider error body is useful, but never worth throwing over. */
3043
- async function errorText(response, limit = 300) {
3044
- try {
3045
- const text = (await response.text()).replace(/\s+/g, " ").trim();
3046
- return text.length === 0 ? `HTTP ${response.status}` : `HTTP ${response.status}: ${text.slice(0, limit)}`;
3047
- } catch {
3048
- return `HTTP ${response.status}`;
3049
- }
3050
- }
3051
- /** `Sub.Domain.COM.` -> `sub.domain.com`; DuckDNS and friends are case-insensitive. */
3052
- function normalizeHost(host) {
3053
- return host.trim().toLowerCase().replace(NORMALIZE_DOTS, "");
3054
- }
3055
- var NORMALIZE_DOTS;
3056
- var init_types = __esmMin((() => {
3057
- NORMALIZE_DOTS = /\.$/;
3058
- }));
3059
- //#endregion
3060
- //#region src/providers/ddns/cloudflare.ts
3061
- function authHeaders(credentials) {
3062
- const token = (credentials.apiToken ?? "").trim();
3063
- if (token.length > 0) return { Authorization: `Bearer ${token}` };
3064
- const email = (credentials.email ?? "").trim();
3065
- const key = (credentials.apiKey ?? "").trim();
3066
- if (email.length > 0 && key.length > 0) return {
3067
- "X-Auth-Email": email,
3068
- "X-Auth-Key": key
3069
- };
3070
- return null;
3071
- }
3072
- async function call(path, headers, context, init = {}) {
3073
- try {
3074
- const response = await context.fetch(`${API$5}${path}`, {
3075
- ...init,
3076
- headers: {
3077
- ...headers,
3078
- "Content-Type": "application/json",
3079
- ...init.headers ?? {}
3080
- },
3081
- signal: context.signal
3082
- });
3083
- const text = await response.text();
3084
- let parsed;
3085
- try {
3086
- parsed = JSON.parse(text);
3087
- } catch {
3088
- return {
3089
- data: null,
3090
- status: response.status,
3091
- error: `cloudflare answered ${response.status}: ${text.slice(0, 200)}`
3092
- };
3093
- }
3094
- if (!response.ok || parsed.success !== true) {
3095
- const detail = (parsed.errors ?? []).map((entry) => `${entry.code} ${entry.message}`).join("; ");
3096
- return {
3097
- data: parsed,
3098
- status: response.status,
3099
- error: detail.length > 0 ? detail : `HTTP ${response.status}`
3100
- };
3101
- }
3102
- return {
3103
- data: parsed,
3104
- status: response.status,
3105
- error: null
3106
- };
3107
- } catch (error) {
3108
- return {
3109
- data: null,
3110
- status: 0,
3111
- error: error instanceof Error ? error.message : String(error)
3112
- };
3113
- }
3114
- }
3115
- /**
3116
- * Walks the host's parents through `GET /zones?name=`, which is how the
3117
- * reference image resolves a zone without a pasted zone id; the id is cached in
3118
- * the panel's state file, so it usually costs nothing.
3119
- */
3120
- async function resolveZone(host, headers, context, cached) {
3121
- if (cached !== void 0) return {
3122
- id: cached,
3123
- name: host
3124
- };
3125
- const labels = host.split(".");
3126
- for (let index = 0; index <= labels.length - 2; index++) {
3127
- const candidate = labels.slice(index).join(".");
3128
- const { data, error } = await call(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
3129
- if (error !== null || data === null) throw new Error(error ?? "could not look up the zone");
3130
- const zone = data.result[0];
3131
- if (zone !== void 0) return zone;
3132
- }
3133
- return null;
3134
- }
3135
- async function findRecord(zoneId, record, headers, context, cached) {
3136
- if (cached !== void 0) {
3137
- const fetched = await call(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
3138
- if (fetched.error === null && fetched.data !== null) return {
3139
- record: fetched.data.result,
3140
- error: null
3141
- };
3142
- if (fetched.status !== 404 && fetched.status !== 400) return {
3143
- record: null,
3144
- error: fetched.error
3145
- };
3146
- }
3147
- const listed = await call(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
3148
- if (listed.error !== null) return {
3149
- record: null,
3150
- error: listed.error
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
3151
3013
  };
3152
3014
  return {
3153
3015
  record: (listed.data?.result ?? []).find((entry) => entry.id !== void 0) ?? null,
@@ -4088,91 +3950,434 @@ var init_spaceship = __esmMin((() => {
4088
3950
  if (!written.ok) return failed(await errorText(written));
4089
3951
  return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4090
3952
  } catch (error) {
4091
- return failed(error instanceof Error ? error.message : String(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;
4092
4221
  }
4222
+ this.lastText = text;
4223
+ this.apply(parsed);
4093
4224
  }
4094
- };
4095
- }));
4096
- //#endregion
4097
- //#region src/providers/ddns/index.ts
4098
- function ddnsProvider(id) {
4099
- return BY_ID.get(id) ?? null;
4100
- }
4101
- function ddnsProviderInfos() {
4102
- return DDNS_PROVIDERS.map((provider) => ({
4103
- id: provider.id,
4104
- label: provider.label,
4105
- docsUrl: provider.docsUrl,
4106
- families: provider.families,
4107
- fields: provider.fields,
4108
- ttl: provider.ttl,
4109
- proxied: provider.proxied
4110
- }));
4111
- }
4112
- /**
4113
- * Cross-field problems ArkType cannot see. Credentials are deliberately not
4114
- * required here: an account is normally added before its secret is typed in,
4115
- * and a missing secret is reported per record instead of blocking the save.
4116
- */
4117
- function validateDdnsConfig(config) {
4118
- const errors = [];
4119
- const seenAccounts = /* @__PURE__ */ new Set();
4120
- for (const account of config.accounts) {
4121
- if (seenAccounts.has(account.id)) {
4122
- errors.push(`account "${account.id}" is declared twice`);
4123
- continue;
4225
+ notify() {
4226
+ for (const listener of this.listeners) listener();
4124
4227
  }
4125
- seenAccounts.add(account.id);
4126
- if (ddnsProvider(account.provider) === null) errors.push(`account "${account.id}" uses an unknown provider "${account.provider}"`);
4127
- }
4128
- const seenRecords = /* @__PURE__ */ new Set();
4129
- for (const domain of config.domains) {
4130
- const account = config.accounts.find((entry) => entry.id === domain.account);
4131
- if (account === void 0) {
4132
- errors.push(`"${domain.host}" points at unknown account "${domain.account}"`);
4133
- continue;
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;
4134
4237
  }
4135
- const provider = ddnsProvider(account.provider);
4136
- if (provider !== null) for (const recordType of domain.types) {
4137
- if (!provider.families.includes(recordType)) errors.push(`${account.label || provider.label} cannot manage ${recordType} records ("${domain.host}")`);
4138
- const key = `${domain.host.toLowerCase()}|${recordType}`;
4139
- if (seenRecords.has(key)) errors.push(`"${domain.host}" asks for ${recordType} twice`);
4140
- seenRecords.add(key);
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;
4141
4245
  }
4142
- }
4143
- return errors;
4144
- }
4145
- var DDNS_PROVIDERS, BY_ID;
4146
- var init_ddns$2 = __esmMin((() => {
4147
- init_cloudflare();
4148
- init_desec();
4149
- init_digitalocean();
4150
- init_duckdns();
4151
- init_dynu();
4152
- init_freedns();
4153
- init_gandi();
4154
- init_godaddy();
4155
- init_henet();
4156
- init_namecheap();
4157
- init_noip();
4158
- init_porkbun();
4159
- init_spaceship();
4160
- DDNS_PROVIDERS = [
4161
- cloudflareProvider,
4162
- namecheapProvider,
4163
- spaceshipProvider,
4164
- porkbunProvider,
4165
- godaddyProvider,
4166
- gandiProvider,
4167
- digitaloceanProvider,
4168
- desecProvider,
4169
- duckdnsProvider,
4170
- noipProvider,
4171
- dynuProvider,
4172
- henetProvider,
4173
- freednsProvider
4174
- ];
4175
- BY_ID = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
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
+ };
4176
4381
  }));
4177
4382
  //#endregion
4178
4383
  //#region src/api/ddns.ts
@@ -4183,12 +4388,18 @@ var init_ddns$2 = __esmMin((() => {
4183
4388
  * the view only says which accounts have them.
4184
4389
  */
4185
4390
  function createDdnsRoute(deps) {
4186
- const view = () => ({
4187
- config: deps.store.config.ddns,
4188
- status: deps.ddns.view,
4189
- providers: ddnsProviderInfos(),
4190
- credentials: deps.secrets.ddnsAccountIds
4191
- });
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
+ };
4192
4403
  return appFactory.createApp().get("/ddns", describeRoute({
4193
4404
  tags: ["ddns"],
4194
4405
  summary: "Dynamic DNS policy, live state and the providers this build knows",
@@ -4223,6 +4434,8 @@ function createDdnsRoute(deps) {
4223
4434
  });
4224
4435
  throw error;
4225
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`);
4226
4439
  deps.ddns.refresh();
4227
4440
  return c.json(view());
4228
4441
  }).put("/ddns/credentials/:id", describeRoute({
@@ -4237,23 +4450,18 @@ function createDdnsRoute(deps) {
4237
4450
  }
4238
4451
  }), validate("param", accountParam), validate("json", ddnsCredentialsSchema), (c) => {
4239
4452
  const { id } = c.req.valid("param");
4240
- const account = deps.store.config.ddns.accounts.find((entry) => entry.id === id);
4241
- if (account === void 0) throw new DetailedError(`unknown DDNS account "${id}"`, {
4242
- statusCode: 400,
4243
- code: "UNKNOWN_DDNS_ACCOUNT"
4244
- });
4245
- const provider = ddnsProvider(account.provider);
4246
- if (provider === null) throw new DetailedError(`unknown DDNS provider "${account.provider}"`, {
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}"`, {
4247
4456
  statusCode: 400,
4248
4457
  code: "UNKNOWN_DDNS_PROVIDER"
4249
4458
  });
4250
- const { credentials } = c.req.valid("json");
4251
4459
  const problem = provider.validate(credentials);
4252
4460
  if (problem !== null) throw new DetailedError(`${provider.label}: ${problem}`, {
4253
4461
  statusCode: 400,
4254
4462
  code: "INVALID_DDNS_CREDENTIALS"
4255
4463
  });
4256
- deps.secrets.setDdnsCredentials(id, credentials);
4464
+ deps.secrets.setDdnsCredentials(id, providerId, credentials);
4257
4465
  deps.ddns.refresh();
4258
4466
  return c.json(view());
4259
4467
  }).delete("/ddns/credentials/:id", describeRoute({
@@ -4283,6 +4491,7 @@ var accountParam;
4283
4491
  var init_ddns$1 = __esmMin((() => {
4284
4492
  init_store();
4285
4493
  init_factory();
4494
+ init_logger();
4286
4495
  init_openapi$1();
4287
4496
  init_validator();
4288
4497
  init_ddns$2();
@@ -5047,6 +5256,7 @@ function createSettingsRoute(deps) {
5047
5256
  if (patch.notifications !== void 0) deps.store.updateNotifications(patch.notifications);
5048
5257
  if (patch.host !== void 0) deps.store.updateHost(patch.host);
5049
5258
  if (patch.backups !== void 0) deps.store.updateBackups(patch.backups);
5259
+ if (patch.ddns !== void 0) deps.store.updateDdns(patch.ddns);
5050
5260
  if (patch.control !== void 0) deps.store.updateControl(patch.control);
5051
5261
  } catch (error) {
5052
5262
  if (error instanceof ConfigError) throw new DetailedError(error.message, {
@@ -5055,6 +5265,7 @@ function createSettingsRoute(deps) {
5055
5265
  });
5056
5266
  throw error;
5057
5267
  }
5268
+ if (patch.ddns !== void 0) deps.ddns.refresh();
5058
5269
  const next = deps.store.config.control;
5059
5270
  const endpointChanged = next.host !== deps.controlServer.endpoint.host || next.port !== deps.controlServer.endpoint.port;
5060
5271
  const proxyChanged = next.auth.trustProxy !== previous.trustProxy;
@@ -8713,6 +8924,8 @@ var init_backups = __esmMin((() => {
8713
8924
  error: "backups are disabled"
8714
8925
  };
8715
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);
8716
8929
  const dir = this.resolveDir();
8717
8930
  const sources = this.options.getSources();
8718
8931
  const staging = path.join(dir, `.staging-${Date.now()}`);
@@ -8721,12 +8934,12 @@ var init_backups = __esmMin((() => {
8721
8934
  const destination = path.join(dir, name);
8722
8935
  try {
8723
8936
  fs.mkdirSync(staging, { recursive: true });
8724
- this.copyInto(staging, "config/servers.config.json", sources.configPath);
8725
- this.copyInto(staging, "secrets/control-secrets.json", sources.secretsPath);
8726
- 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);
8727
8940
  const data = [];
8728
8941
  for (const declared of this.paths) {
8729
- if (!declared.included || !fs.existsSync(declared.path)) continue;
8942
+ if (!captures(`data:${declared.path}`) || !declared.included || !fs.existsSync(declared.path)) continue;
8730
8943
  const slug = slugifyPath(declared.path);
8731
8944
  if (data.some((entry) => entry.slug === slug)) continue;
8732
8945
  this.copyInto(staging, path.join("data", slug), declared.path, declared.ignoreGenerated === true);
@@ -8736,6 +8949,16 @@ var init_backups = __esmMin((() => {
8736
8949
  origin: declared.origin
8737
8950
  });
8738
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
+ }
8739
8962
  const manifest = {
8740
8963
  version: 1,
8741
8964
  createdAt,
@@ -8913,12 +9136,26 @@ var init_backups = __esmMin((() => {
8913
9136
  });
8914
9137
  }
8915
9138
  const fromArchive = archiveTargets(restoredConfig);
8916
- 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) => ({
8917
9141
  path: entry.path,
8918
9142
  origin: entry.origin
8919
- }))];
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
+ };
8920
9157
  for (const entry of manifest.data ?? []) {
8921
- 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);
8922
9159
  const from = path.join(staging, "data", safeSlug(entry.slug) ?? slugifyPath(entry.path));
8923
9160
  const common = {
8924
9161
  id: `data:${entry.path}`,
@@ -9485,7 +9722,7 @@ var init_ddns = __esmMin((() => {
9485
9722
  if (!domain.enabled) continue;
9486
9723
  const account = config.accounts.find((entry) => entry.id === domain.account);
9487
9724
  const provider = account === void 0 ? null : ddnsProvider(account.provider);
9488
- const credentials = account === void 0 ? null : this.deps.getCredentials(account.id);
9725
+ const credentials = account === void 0 ? null : this.deps.getCredentials(account.id, account.provider);
9489
9726
  for (const type of domain.types) {
9490
9727
  const key = cacheKey(domain.host, type);
9491
9728
  const family = type === "A" ? "ipv4" : "ipv6";
@@ -9522,7 +9759,7 @@ var init_ddns = __esmMin((() => {
9522
9759
  type,
9523
9760
  ip: address,
9524
9761
  ttl: domain.ttl ?? config.ttl,
9525
- proxied: domain.proxied ?? config.proxied,
9762
+ proxied: domain.proxied,
9526
9763
  ...domain.zone === void 0 ? {} : { zone: domain.zone },
9527
9764
  ...cache === void 0 ? {} : { cache: {
9528
9765
  ...cache.zoneId === void 0 ? {} : { zoneId: cache.zoneId },
@@ -9557,7 +9794,7 @@ var init_ddns = __esmMin((() => {
9557
9794
  this.persisted.updatedAt = this.lastRunAt;
9558
9795
  this.writeState();
9559
9796
  this.lastResult = this.describe(changed, unchanged, failed, skipped);
9560
- this.announce(config, {
9797
+ this.announce({
9561
9798
  changed,
9562
9799
  failed
9563
9800
  });
@@ -9590,8 +9827,8 @@ var init_ddns = __esmMin((() => {
9590
9827
  return parts.length === 0 ? "nothing to update" : parts.join(", ");
9591
9828
  }
9592
9829
  /** One message per pass, so a flapping provider cannot flood the chat. */
9593
- announce(config, counts) {
9594
- if (!config.notify || counts.changed === 0 && counts.failed === 0) return;
9830
+ announce(counts) {
9831
+ if (counts.changed === 0 && counts.failed === 0) return;
9595
9832
  if (counts.failed > 0) {
9596
9833
  this.deps.notifications.notify({
9597
9834
  serverId: "ddns",
@@ -11176,7 +11413,7 @@ async function runControlPlane(options) {
11176
11413
  })), notifications);
11177
11414
  const ddns = new DdnsService({
11178
11415
  getConfig: () => store.config.ddns,
11179
- getCredentials: (accountId) => secrets.getDdnsCredentials(accountId),
11416
+ getCredentials: (accountId, provider) => secrets.getDdnsCredentials(accountId, provider)?.values ?? null,
11180
11417
  notifications,
11181
11418
  statePath: defaultDdnsStatePath
11182
11419
  });