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/AGENTS.md +20 -7
- package/README.md +12 -6
- package/dist/cli.js +852 -615
- package/dist/cli.js.map +1 -1
- package/docs/DDNS.md +14 -10
- package/docs/UI_CREATION.md +1 -1
- package/package.json +1 -1
- package/uis/stock/dist/assets/index-B_4sGjNj.css +2 -0
- package/uis/stock/dist/assets/{index-C6tbSbCK.js → index-BuB3VX_S.js} +4 -4
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/assets/index-Do5DvzS4.css +0 -2
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({
|
|
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
|
-
|
|
991
|
-
password: passwordValueSchema.optional()
|
|
1007
|
+
backupCreateSchema = type({
|
|
1008
|
+
/** Optional: encrypts the archive. Never stored. */
|
|
1009
|
+
password: passwordValueSchema.optional(),
|
|
1010
|
+
/** Item ids to capture (`config`, `secrets`, `tls`, `data:<path>`); omitted means everything. */
|
|
1011
|
+
include: type("string[] >= 1").optional()
|
|
1012
|
+
}).onUndeclaredKey("reject");
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
*/
|
|
1298
|
-
function
|
|
1299
|
-
if (
|
|
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,
|
|
1302
|
-
if (
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1428
|
-
const
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
...
|
|
1562
|
+
...current,
|
|
1433
1563
|
ddns
|
|
1434
1564
|
});
|
|
1435
1565
|
}
|
|
1436
1566
|
clearDdnsCredentials(accountId) {
|
|
1437
|
-
const
|
|
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
|
-
...
|
|
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:
|
|
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
|
-
|
|
1473
|
-
this.
|
|
1650
|
+
const sealed = this.sealStored(contents);
|
|
1651
|
+
writeFileAtomic(this.file, `${JSON.stringify(sealed, null, 2)}\n`, { mode: 384 });
|
|
1652
|
+
this.cache = sealed;
|
|
1653
|
+
}
|
|
1654
|
+
/** Any write seals the entries still sitting in the clear, so the file never keeps both. */
|
|
1655
|
+
sealStored(contents) {
|
|
1656
|
+
const plain = Object.entries(contents.ddns).filter((entry) => isPlainDdns(entry[1]));
|
|
1657
|
+
if (plain.length === 0) return contents;
|
|
1658
|
+
const kdf = contents.ddnsKdf ?? newDdnsKdf();
|
|
1659
|
+
const key = this.ddnsKey(kdf);
|
|
1660
|
+
const ddns = { ...contents.ddns };
|
|
1661
|
+
for (const [accountId, record] of plain) ddns[accountId] = sealDdns(accountId, {
|
|
1662
|
+
provider: record.provider,
|
|
1663
|
+
values: record.values
|
|
1664
|
+
}, key);
|
|
1665
|
+
return {
|
|
1666
|
+
...contents,
|
|
1667
|
+
ddns,
|
|
1668
|
+
ddnsKdf: kdf
|
|
1669
|
+
};
|
|
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({
|
|
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/
|
|
2685
|
-
function
|
|
2686
|
-
return
|
|
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
|
|
2689
|
-
return
|
|
2888
|
+
function failed(error) {
|
|
2889
|
+
return {
|
|
2890
|
+
ok: false,
|
|
2891
|
+
changed: false,
|
|
2892
|
+
message: error,
|
|
2893
|
+
error
|
|
2894
|
+
};
|
|
2690
2895
|
}
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2703
|
-
*
|
|
2704
|
-
*
|
|
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
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
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
|
|
2995
|
+
return null;
|
|
2722
2996
|
}
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
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
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
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
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
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
|
-
|
|
4144
|
-
}
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
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
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
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
|
|
4241
|
-
|
|
4242
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
9594
|
-
if (
|
|
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
|
});
|