home-hosted 0.6.6 → 0.6.7

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