home-hosted 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { HTTPException } from "hono/http-exception";
22
22
  import { Scalar } from "@scalar/hono-api-reference";
23
23
  import http from "node:http";
24
24
  import https from "node:https";
25
- import net from "node:net";
25
+ import net, { isIP } from "node:net";
26
26
  import { parseArgs, promisify } from "node:util";
27
27
  import { Readable, Writable } from "node:stream";
28
28
  import { finished } from "node:stream/promises";
@@ -133,6 +133,7 @@ function aliasesOf(def) {
133
133
  function findArg(argsDef, name) {
134
134
  for (const [key, def] of Object.entries(argsDef)) {
135
135
  if (def === void 0) continue;
136
+ if (def.type === "positional") continue;
136
137
  if ((/* @__PURE__ */ new Set([
137
138
  key,
138
139
  kebab(key),
@@ -152,10 +153,15 @@ function findArg(argsDef, name) {
152
153
  */
153
154
  function rejectUnknownFlags(argv, argsDef) {
154
155
  if (argsDef === void 0 || Object.keys(argsDef).length === 0) return null;
156
+ let positionals = Object.values(argsDef).filter((def) => def?.type === "positional").length;
155
157
  for (let index = 0; index < argv.length; index++) {
156
158
  const token = argv[index];
157
159
  if (token === "--") return null;
158
- if (!token.startsWith("-") || token.length === 1) return `Unexpected argument '${token}'`;
160
+ if (!token.startsWith("-") || token.length === 1) {
161
+ if (positionals === 0) return `Unexpected argument '${token}'`;
162
+ positionals -= 1;
163
+ continue;
164
+ }
159
165
  const body = token.startsWith("--") ? token.slice(2) : token.slice(1);
160
166
  const equals = body.indexOf("=");
161
167
  const name = equals === -1 ? body : body.slice(0, equals);
@@ -300,6 +306,50 @@ var init_runtime = __esmMin((() => {
300
306
  NANNY_COMMAND = "__nanny";
301
307
  }));
302
308
  //#endregion
309
+ //#region src/helpers/version.ts
310
+ var version_exports = /* @__PURE__ */ __exportAll({
311
+ appVersion: () => appVersion,
312
+ versionMismatchNote: () => versionMismatchNote
313
+ });
314
+ /**
315
+ * The running release, read from the package manifest.
316
+ *
317
+ * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
318
+ * is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
319
+ * its own read because it may only import node builtins statically.
320
+ */
321
+ function appVersion() {
322
+ if (cached !== null) return cached;
323
+ let dir = path.dirname(fileURLToPath(import.meta.url));
324
+ for (let depth = 0; depth < 4; depth++) {
325
+ try {
326
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
327
+ if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
328
+ cached = manifest.version;
329
+ return cached;
330
+ }
331
+ } catch {}
332
+ const parent = path.dirname(dir);
333
+ if (parent === dir) break;
334
+ dir = parent;
335
+ }
336
+ cached = "0.0.0";
337
+ return cached;
338
+ }
339
+ /**
340
+ * The one line to add when this CLI and the panel it is talking to are not the same
341
+ * release — which is what a linked checkout, a global install and a project pin
342
+ * produce. `null` when they agree.
343
+ */
344
+ function versionMismatchNote(panelVersion) {
345
+ const cli = appVersion();
346
+ return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
347
+ }
348
+ var cached;
349
+ var init_version = __esmMin((() => {
350
+ cached = null;
351
+ }));
352
+ //#endregion
303
353
  //#region src/helpers/cookies.ts
304
354
  function parseCookies(header) {
305
355
  const cookies = {};
@@ -341,7 +391,7 @@ function parseBind(value) {
341
391
  const parsed = bindSchema(value);
342
392
  return parsed instanceof type.errors ? null : parsed;
343
393
  }
344
- var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
394
+ var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
345
395
  var init_contracts = __esmMin((() => {
346
396
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
347
397
  portSchema = type("1 <= number.integer <= 65535 | null");
@@ -460,6 +510,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
460
510
  onRecovered: "boolean = false",
461
511
  /** Host vitals breaches (disk, memory, swap, load, temperature). */
462
512
  onHost: "boolean = true",
513
+ /** Dynamic DNS changes and failures. */
514
+ onDdns: "boolean = true",
463
515
  /** Per server *and* reason, so a flapping server cannot spam the chat. */
464
516
  cooldownMs: "number >= 0 = 120000"
465
517
  }).onUndeclaredKey("reject");
@@ -483,6 +535,95 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
483
535
  loadPerCpu: "number >= 0 = 2",
484
536
  tempCelsius: "number >= 0 = 85"
485
537
  }).onUndeclaredKey("reject");
538
+ ddnsRecordTypeSchema = type.enumerated("A", "AAAA");
539
+ ddnsAccountSchema = type({
540
+ id: "/^[a-z0-9][a-z0-9_-]*$/",
541
+ provider: "string >= 1",
542
+ label: "string = \"\""
543
+ }).onUndeclaredKey("reject");
544
+ ddnsDomainSchema = type({
545
+ host: "/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/",
546
+ account: "string >= 1",
547
+ /** Records to keep current; `A` unless the host needs IPv6 too. */
548
+ types: ddnsRecordTypeSchema.array().default(() => ["A"]),
549
+ /**
550
+ * The registered domain (apex) this host sits in. Only needed where a
551
+ * provider cannot look it up and the guess is wrong (three-label suffixes).
552
+ */
553
+ zone: "string?",
554
+ /** Seconds; `1` means "automatic" where the provider supports it. */
555
+ ttl: "number.integer >= 1?",
556
+ /** Cloudflare only. */
557
+ proxied: "boolean?",
558
+ enabled: "boolean = true"
559
+ }).onUndeclaredKey("reject");
560
+ ddnsIpv4Schema = type({
561
+ enabled: "boolean = true",
562
+ /** Override the public-IP endpoint; empty uses the built-in list. */
563
+ url: "string = \"\""
564
+ }).onUndeclaredKey("reject");
565
+ ddnsIpv6Schema = type({
566
+ enabled: "boolean = false",
567
+ url: "string = \"\""
568
+ }).onUndeclaredKey("reject");
569
+ ddnsConfigSchema = type({
570
+ enabled: "boolean = false",
571
+ /** How often the public address is re-checked. */
572
+ intervalMs: "number >= 60000 = 300000",
573
+ ipv4: ddnsIpv4Schema.default(() => ({})),
574
+ ipv6: ddnsIpv6Schema.default(() => ({})),
575
+ /** Default TTL for the records that accept one. */
576
+ ttl: "number.integer >= 1 = 1",
577
+ /** Default proxy flag, where the provider has one (Cloudflare). */
578
+ proxied: "boolean = false",
579
+ /** Tell Telegram about a change, and about an update that failed. */
580
+ notify: "boolean = true",
581
+ accounts: ddnsAccountSchema.array().default(() => []),
582
+ domains: ddnsDomainSchema.array().default(() => [])
583
+ }).onUndeclaredKey("reject");
584
+ ddnsRecordStateSchema = type("\"pending\" | \"ok\" | \"unchanged\" | \"error\" | \"skipped\"");
585
+ ddnsRecordViewSchema = type({
586
+ host: "string",
587
+ account: "string",
588
+ provider: "string",
589
+ type: ddnsRecordTypeSchema,
590
+ ip: "string | null",
591
+ state: ddnsRecordStateSchema,
592
+ message: "string | null",
593
+ updatedAt: "number | null"
594
+ });
595
+ ddnsStatusSchema = type({
596
+ enabled: "boolean",
597
+ running: "boolean",
598
+ lastRunAt: "number | null",
599
+ lastResult: "string | null",
600
+ ipv4: "string | null",
601
+ ipv6: "string | null",
602
+ records: ddnsRecordViewSchema.array()
603
+ });
604
+ ddnsProviderFieldSchema = type({
605
+ key: "string",
606
+ label: "string",
607
+ hint: "string = \"\"",
608
+ optional: "boolean = false"
609
+ });
610
+ ddnsProviderInfoSchema = type({
611
+ id: "string",
612
+ label: "string",
613
+ docsUrl: "string",
614
+ families: ddnsRecordTypeSchema.array(),
615
+ fields: ddnsProviderFieldSchema.array(),
616
+ ttl: "boolean",
617
+ proxied: "boolean"
618
+ });
619
+ ddnsViewSchema = type({
620
+ config: ddnsConfigSchema,
621
+ status: ddnsStatusSchema,
622
+ providers: ddnsProviderInfoSchema.array(),
623
+ /** Account ids whose credentials are stored. */
624
+ credentials: type("string[]")
625
+ });
626
+ ddnsCredentialsSchema = type({ credentials: type("Record<string, string>") }).onUndeclaredKey("reject");
486
627
  backupsSchema = type({
487
628
  enabled: "boolean = true",
488
629
  dir: "string = \".backups\"",
@@ -558,6 +699,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
558
699
  onForcedRestart: "boolean?",
559
700
  onRecovered: "boolean?",
560
701
  onHost: "boolean?",
702
+ onDdns: "boolean?",
561
703
  cooldownMs: "number >= 0?"
562
704
  }).onUndeclaredKey("reject");
563
705
  notificationsPatchSchema = type({ telegram: telegramPatchSchema.optional() }).onUndeclaredKey("reject");
@@ -712,6 +854,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
712
854
  onRecovered: "boolean",
713
855
  /** Host vitals breaches (disk, memory, swap, load, temperature). */
714
856
  onHost: "boolean",
857
+ /** Dynamic DNS, when this panel knows about it (optional for an older panel). */
858
+ onDdns: "boolean?",
715
859
  cooldownMs: "number",
716
860
  /** Last delivery outcome, for the settings page. */
717
861
  lastResult: "string | null",
@@ -893,20 +1037,27 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
893
1037
  tls: tlsStatusSchema
894
1038
  });
895
1039
  appStateSchema = type({
896
- control: controlViewSchema,
897
- defaults: defaultsSchema,
898
- logs: logsSchema,
899
- notifications: notificationViewSchema,
900
- host: hostViewSchema,
901
- backups: backupsViewSchema,
902
- configPath: "string",
903
- configError: "string | null",
1040
+ "control": controlViewSchema,
1041
+ "defaults": defaultsSchema,
1042
+ "logs": logsSchema,
1043
+ "notifications": notificationViewSchema,
1044
+ "host": hostViewSchema,
1045
+ "backups": backupsViewSchema,
1046
+ "configPath": "string",
1047
+ "configError": "string | null",
904
1048
  /** The directory the panel was started from; relative entry paths use it. */
905
- projectDir: "string",
1049
+ "projectDir": "string",
906
1050
  /** `HHOSTED_HOME`: every file home-hosted owns lives under here. */
907
- dataRoot: "string",
908
- logsDir: "string",
909
- servers: serverViewSchema.array()
1051
+ "dataRoot": "string",
1052
+ "logsDir": "string",
1053
+ /**
1054
+ * Dynamic DNS state. Optional so a newer UI still frames a state a panel from
1055
+ * before this field served: the app would blank on a required key.
1056
+ */
1057
+ "ddns?": ddnsStatusSchema,
1058
+ /** The release this panel is running. Optional: an older panel does not send one. */
1059
+ "version?": "string",
1060
+ "servers": serverViewSchema.array()
910
1061
  });
911
1062
  type({
912
1063
  type: "\"hello\" | \"state\" | \"log\" | \"server\"",
@@ -1140,6 +1291,21 @@ function apiTokenRecord(token, now = Date.now()) {
1140
1291
  updatedAt: now
1141
1292
  };
1142
1293
  }
1294
+ /**
1295
+ * Only account → string map → string survives; a hand-edited file with a number
1296
+ * or a nested object in there reads as "no credentials" instead of throwing.
1297
+ */
1298
+ function readDdnsCredentials(value) {
1299
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
1300
+ const out = {};
1301
+ for (const [accountId, fields] of Object.entries(value)) {
1302
+ if (typeof fields !== "object" || fields === null || Array.isArray(fields)) continue;
1303
+ const record = {};
1304
+ for (const [key, field] of Object.entries(fields)) if (typeof field === "string" && field.length > 0) record[key] = field;
1305
+ if (Object.keys(record).length > 0) out[accountId] = record;
1306
+ }
1307
+ return out;
1308
+ }
1143
1309
  var COST, KEYLEN, SALT_BYTES, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
1144
1310
  var init_secrets = __esmMin((() => {
1145
1311
  init_atomic();
@@ -1250,12 +1416,38 @@ var init_secrets = __esmMin((() => {
1250
1416
  telegram: trimmed.length > 0 ? { botToken: trimmed } : null
1251
1417
  });
1252
1418
  }
1419
+ getDdnsCredentials(accountId) {
1420
+ return this.load().ddns[accountId] ?? null;
1421
+ }
1422
+ get ddnsAccountIds() {
1423
+ return Object.keys(this.load().ddns);
1424
+ }
1425
+ /** An empty field is dropped, so clearing every field forgets the account. */
1426
+ setDdnsCredentials(accountId, credentials) {
1427
+ const entries = Object.entries(credentials).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value.length > 0);
1428
+ const ddns = { ...this.load().ddns };
1429
+ if (entries.length === 0) delete ddns[accountId];
1430
+ else ddns[accountId] = Object.fromEntries(entries);
1431
+ this.save({
1432
+ ...this.load(),
1433
+ ddns
1434
+ });
1435
+ }
1436
+ clearDdnsCredentials(accountId) {
1437
+ const ddns = { ...this.load().ddns };
1438
+ delete ddns[accountId];
1439
+ this.save({
1440
+ ...this.load(),
1441
+ ddns
1442
+ });
1443
+ }
1253
1444
  read() {
1254
1445
  if (!fs.existsSync(this.file)) return {
1255
1446
  version: 3,
1256
1447
  password: null,
1257
1448
  apiToken: null,
1258
- telegram: null
1449
+ telegram: null,
1450
+ ddns: {}
1259
1451
  };
1260
1452
  try {
1261
1453
  const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
@@ -1263,14 +1455,16 @@ var init_secrets = __esmMin((() => {
1263
1455
  version: 3,
1264
1456
  password: parsed?.password ?? null,
1265
1457
  apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
1266
- telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null
1458
+ telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
1459
+ ddns: readDdnsCredentials(parsed?.ddns)
1267
1460
  };
1268
1461
  } catch {
1269
1462
  return {
1270
1463
  version: 3,
1271
1464
  password: null,
1272
1465
  apiToken: null,
1273
- telegram: null
1466
+ telegram: null,
1467
+ ddns: {}
1274
1468
  };
1275
1469
  }
1276
1470
  }
@@ -1814,6 +2008,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
1814
2008
  daemonLogPath: () => daemonLogPath,
1815
2009
  dataRoot: () => dataRoot,
1816
2010
  defaultConfigPath: () => defaultConfigPath,
2011
+ defaultDdnsStatePath: () => defaultDdnsStatePath,
1817
2012
  defaultHistoryPath: () => defaultHistoryPath,
1818
2013
  defaultLogsDir: () => defaultLogsDir,
1819
2014
  defaultNannyDir: () => defaultNannyDir,
@@ -1854,7 +2049,7 @@ function resolveUserPath(target, base = projectDir) {
1854
2049
  else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
1855
2050
  return path.isAbsolute(value) ? value : path.resolve(base, value);
1856
2051
  }
1857
- var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, runtimePath, daemonLogPath;
2052
+ var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, defaultDdnsStatePath, runtimePath, daemonLogPath;
1858
2053
  var init_paths = __esmMin((() => {
1859
2054
  dataRoot = resolveDataRoot();
1860
2055
  projectDir = resolveProjectDir();
@@ -1865,6 +2060,7 @@ var init_paths = __esmMin((() => {
1865
2060
  defaultHistoryPath = path.join(dataRoot, ".logs", "history.json");
1866
2061
  defaultTlsDir = path.join(dataRoot, ".tls");
1867
2062
  defaultNannyDir = path.join(dataRoot, ".state");
2063
+ defaultDdnsStatePath = path.join(dataRoot, ".state", "ddns.json");
1868
2064
  runtimePath = path.join(dataRoot, "run.json");
1869
2065
  daemonLogPath = path.join(dataRoot, ".logs", "home-hosted.log");
1870
2066
  }));
@@ -1929,16 +2125,19 @@ function buildAppState(deps) {
1929
2125
  notifications: { telegram: deps.notifications.status() },
1930
2126
  host: buildHostView(deps.hostMonitor),
1931
2127
  backups: buildBackupsView(deps.store, deps.backups),
2128
+ ddns: deps.ddns.view,
1932
2129
  configPath: deps.store.path,
1933
2130
  configError: deps.store.configError,
1934
2131
  projectDir,
1935
2132
  dataRoot,
1936
2133
  logsDir: deps.logsDir,
2134
+ version: appVersion(),
1937
2135
  servers: deps.views
1938
2136
  };
1939
2137
  }
1940
2138
  var init_state$1 = __esmMin((() => {
1941
2139
  init_paths();
2140
+ init_version();
1942
2141
  init_exposure();
1943
2142
  }));
1944
2143
  //#endregion
@@ -2108,6 +2307,14 @@ var init_backups$1 = __esmMin((() => {
2108
2307
  nameParam = type({ name: "string >= 1" });
2109
2308
  }));
2110
2309
  //#endregion
2310
+ //#region src/helpers/action-result.ts
2311
+ /** Unknown ids are 404; a server that exists but cannot act is a 409. */
2312
+ function statusForAction(result) {
2313
+ if (result.ok) return 200;
2314
+ return result.error?.startsWith("unknown server") ? 404 : 409;
2315
+ }
2316
+ var init_action_result = __esmMin((() => {}));
2317
+ //#endregion
2111
2318
  //#region src/helpers/deferred.ts
2112
2319
  /**
2113
2320
  * Runs a task after the current response has been written.
@@ -2126,754 +2333,315 @@ var init_deferred = __esmMin((() => {}));
2126
2333
  //#endregion
2127
2334
  //#region src/api/control.ts
2128
2335
  /**
2129
- * The local control channel used by `home-hosted down`.
2336
+ * The local control channel used by `home-hosted down` and `home-hosted start|stop`.
2130
2337
  *
2131
2338
  * It is deliberately outside `/api` (so no session is needed) and guarded by a
2132
2339
  * token that only the owner of `run.json` can read, plus a loopback check: a
2133
2340
  * different local user, or anyone on the network, gets nothing.
2134
2341
  */
2135
2342
  function createControlRoute(deps) {
2136
- return appFactory.createApp().post("/shutdown", describeRoute({
2137
- tags: ["panel"],
2138
- summary: "Stop the panel and everything it supervises (local token required)",
2139
- responses: {
2140
- 200: { description: "Stopping" },
2141
- 403: { description: "Bad token, or not a local caller" }
2142
- }
2143
- }), (c) => {
2343
+ /** The one place the local channel's credentials are read. */
2344
+ function assertLocalCall(c, action) {
2144
2345
  const token = c.req.header("x-home-hosted-token");
2145
2346
  if (token === void 0 || token !== deps.runtimeToken) throw new DetailedError("invalid token", {
2146
2347
  statusCode: 403,
2147
2348
  code: "INVALID_TOKEN"
2148
2349
  });
2149
- if (!isLoopbackRequest(c)) throw new DetailedError("only this machine may stop the control panel", {
2350
+ if (!isLoopbackRequest(c)) throw new DetailedError(`only this machine may ${action}`, {
2150
2351
  statusCode: 403,
2151
2352
  code: "NOT_LOOPBACK"
2152
2353
  });
2354
+ }
2355
+ return appFactory.createApp().post("/shutdown", describeRoute({
2356
+ tags: ["panel"],
2357
+ summary: "Stop the panel and everything it supervises (local token required)",
2358
+ responses: {
2359
+ 200: { description: "Stopping" },
2360
+ 403: { description: "Bad token, or not a local caller" }
2361
+ }
2362
+ }), (c) => {
2363
+ assertLocalCall(c, "stop the control panel");
2153
2364
  afterResponse(deps.onShutdown, (error) => logger.error("shutdown failed", error));
2154
2365
  return c.json({ ok: true });
2366
+ }).post("/servers/:id/start", describeRoute({
2367
+ tags: ["panel"],
2368
+ summary: "Start one server, without a session (local token required)",
2369
+ responses: {
2370
+ 200: { description: "Started" },
2371
+ 403: { description: "Bad token, or not a local caller" },
2372
+ 404: { description: "No such server" }
2373
+ }
2374
+ }), async (c) => {
2375
+ assertLocalCall(c, "start or stop a server");
2376
+ const result = await deps.supervisor.start(c.req.param("id"));
2377
+ return c.json(result, statusForAction(result));
2378
+ }).post("/servers/:id/stop", describeRoute({
2379
+ tags: ["panel"],
2380
+ summary: "Stop one server, without a session (local token required)",
2381
+ responses: {
2382
+ 200: { description: "Stopped" },
2383
+ 403: { description: "Bad token, or not a local caller" },
2384
+ 404: { description: "No such server" }
2385
+ }
2386
+ }), async (c) => {
2387
+ assertLocalCall(c, "start or stop a server");
2388
+ const result = await deps.supervisor.stop(c.req.param("id"));
2389
+ return c.json(result, statusForAction(result));
2155
2390
  });
2156
2391
  }
2157
2392
  var init_control = __esmMin((() => {
2393
+ init_action_result();
2158
2394
  init_deferred();
2159
2395
  init_factory();
2160
2396
  init_logger();
2161
2397
  init_loopback();
2162
2398
  }));
2163
2399
  //#endregion
2164
- //#region src/api/events.ts
2165
- /**
2166
- * One SSE stream per subscriber. Pass `?serverId=<id>` to receive only that
2167
- * server's messages; the first frame always carries the full state snapshot.
2168
- */
2169
- function createEventsRoute(deps) {
2170
- return appFactory.createApp().get("/events", describeRoute({
2171
- tags: ["panel"],
2172
- summary: "Panel state and log frames as server-sent events",
2173
- responses: { 200: { description: "text/event-stream" } }
2174
- }), validate("query", eventsQuery), (c) => {
2175
- const query = c.req.valid("query");
2176
- const serverId = query.serverId ?? null;
2177
- const logOnly = query.logs !== "0";
2178
- return streamSSE(c, async (stream) => {
2179
- let pending = 0;
2180
- let queue = Promise.resolve();
2181
- let closed = false;
2182
- const send = (message) => {
2183
- if (closed) return queue;
2184
- if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
2185
- pending += 1;
2186
- queue = queue.then(() => stream.writeSSE({
2187
- event: message.type,
2188
- data: JSON.stringify(message)
2189
- })).catch(() => {
2190
- closed = true;
2191
- }).finally(() => {
2192
- pending -= 1;
2193
- });
2194
- return queue;
2195
- };
2196
- const unsubscribe = deps.hub.subscribe(serverId, (message) => {
2197
- if (!logOnly && message.type === "log") return;
2198
- send(message);
2199
- });
2200
- stream.onAbort(() => {
2201
- closed = true;
2202
- unsubscribe();
2203
- });
2204
- await send({
2205
- type: "hello",
2206
- ts: Date.now(),
2207
- state: deps.supervisor.getState()
2208
- });
2209
- while (true) {
2210
- await stream.sleep(PING_INTERVAL_MS);
2211
- if (closed) break;
2212
- await stream.writeSSE({
2213
- event: "ping",
2214
- data: String(Date.now())
2215
- });
2216
- }
2217
- });
2218
- });
2400
+ //#region src/config/migrations.ts
2401
+ /** What would have to run to bring `from` up to `to`. */
2402
+ function planConfigMigrations(from, options = {}) {
2403
+ const to = options.to ?? 1;
2404
+ return {
2405
+ from,
2406
+ to,
2407
+ steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
2408
+ tooNew: from > to
2409
+ };
2219
2410
  }
2220
- var MAX_PENDING_WRITES$1, PING_INTERVAL_MS, eventsQuery;
2221
- var init_events$1 = __esmMin((() => {
2222
- init_factory();
2223
- init_validator();
2224
- MAX_PENDING_WRITES$1 = 200;
2225
- PING_INTERVAL_MS = 15e3;
2226
- eventsQuery = type({
2227
- /** Only this server's frames. */
2228
- "serverId?": "string",
2229
- /** `logs=0` drops log frames; the first frame is always the full state. */
2230
- "logs?": "string"
2231
- });
2411
+ /** Applies the plan in order; the caller owns writing the result. */
2412
+ function applyConfigMigrations(config, from, options = {}) {
2413
+ const { steps } = planConfigMigrations(from, options);
2414
+ let current = config;
2415
+ for (const step of steps) current = step.apply(current);
2416
+ return {
2417
+ config: current,
2418
+ applied: steps
2419
+ };
2420
+ }
2421
+ var configMigrations;
2422
+ var init_migrations = __esmMin((() => {
2423
+ configMigrations = [];
2232
2424
  }));
2233
2425
  //#endregion
2234
- //#region src/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
+ }
2235
2430
  /**
2236
- * Liveness for external monitors. Mounted outside `/api`, so it answers without a
2237
- * session: it reports whether the panel itself is serving, and 503 when an
2238
- * autostart server has crashed. Server details are only included for an
2239
- * 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.
2240
2435
  */
2241
- function createHealthRoute(deps) {
2242
- return appFactory.createApp().get("/healthz", describeRoute({
2243
- tags: ["panel"],
2244
- summary: "Liveness for monitors — no session required",
2245
- responses: {
2246
- 200: {
2247
- description: "Serving",
2248
- content: jsonBody(type({
2249
- "status": "\"ok\" | \"degraded\"",
2250
- "uptimeMs": "number",
2251
- "servers?": type({
2252
- total: "number",
2253
- running: "number",
2254
- crashed: "number",
2255
- unhealthy: "number"
2256
- }),
2257
- "hostAlerts?": "string[]"
2258
- }))
2259
- },
2260
- 503: { description: "An autostart server has crashed" }
2261
- }
2262
- }), (c) => {
2263
- const state = deps.supervisor.getState();
2264
- const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
2265
- const authenticated = requestIdentity(c, deps.auth).authenticated;
2266
- return c.json({
2267
- status: broken.length > 0 ? "degraded" : "ok",
2268
- uptimeMs: Math.round(process.uptime() * 1e3),
2269
- ...authenticated ? {
2270
- servers: {
2271
- total: state.servers.length,
2272
- running: state.servers.filter((server) => server.status === "running").length,
2273
- crashed: state.servers.filter((server) => server.status === "crashed").length,
2274
- unhealthy: state.servers.filter((server) => server.health === "unhealthy").length
2275
- },
2276
- hostAlerts: state.host.alerts
2277
- } : {}
2278
- }, broken.length > 0 ? 503 : 200);
2279
- });
2436
+ function mergeDefaults(defaults, entry) {
2437
+ const merged = { ...entry };
2438
+ for (const [key, value] of Object.entries(defaults)) {
2439
+ const current = merged[key];
2440
+ if (current === void 0) merged[key] = value;
2441
+ else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
2442
+ }
2443
+ return merged;
2280
2444
  }
2281
- var init_health = __esmMin((() => {
2282
- init_factory();
2283
- init_openapi$1();
2284
- 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
+ ];
2285
2476
  }));
2286
2477
  //#endregion
2287
- //#region src/api/logs.ts
2288
- function unknownServer$1(id) {
2289
- return new DetailedError(`unknown server "${id}"`, {
2290
- statusCode: 404,
2291
- code: "UNKNOWN_SERVER"
2292
- });
2478
+ //#region src/config/parse.ts
2479
+ function isRecord$1(value) {
2480
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2293
2481
  }
2294
- function createLogsRoute(deps) {
2295
- return appFactory.createApp().get("/logs", describeRoute({
2296
- tags: ["logs"],
2297
- summary: "Every server with its on-disk log files",
2298
- responses: { 200: {
2299
- description: "Log sources",
2300
- content: jsonBody(logServersViewSchema)
2301
- } }
2302
- }), (c) => c.json({ servers: deps.supervisor.views().map((server) => ({
2303
- serverId: server.id,
2304
- label: server.config.label ?? server.id,
2305
- status: server.status,
2306
- ...deps.logFiles.info(server.id)
2307
- })) })).get("/logs/:id", describeRoute({
2308
- tags: ["logs"],
2309
- summary: "Persisted log lines, with search and a stream filter",
2310
- responses: {
2311
- 200: { description: "Lines" },
2312
- 404: ERROR_RESPONSES[404]
2313
- }
2314
- }), validate("param", idParam$1), validate("query", logHistoryQuerySchema), (c) => {
2315
- const { id } = c.req.valid("param");
2316
- if (!deps.store.getServer(id)) throw unknownServer$1(id);
2317
- const query = c.req.valid("query");
2318
- const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
2319
- const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
2320
- const info = deps.logFiles.info(id);
2321
- const search = query.search?.trim() ?? "";
2322
- const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
2323
- let lines = deps.logFiles.readTail(id, window);
2324
- if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
2325
- if (search.length > 0) {
2326
- const needle = search.toLowerCase();
2327
- lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
2328
- }
2329
- return c.json({
2330
- serverId: id,
2331
- enabled: info.enabled,
2332
- sizeBytes: info.sizeBytes,
2333
- files: info.files.map((file) => file.name),
2334
- searched: search.length > 0 ? window : null,
2335
- lines: lines.slice(-tail)
2336
- });
2337
- }).get("/logs/:id/download", describeRoute({
2338
- tags: ["logs"],
2339
- summary: "Download one rotated log file",
2340
- responses: {
2341
- 200: { description: "application/x-ndjson" },
2342
- 404: ERROR_RESPONSES[404]
2343
- }
2344
- }), validate("param", idParam$1), validate("query", downloadQuery), (c) => {
2345
- const { id } = c.req.valid("param");
2346
- if (!deps.store.getServer(id)) throw unknownServer$1(id);
2347
- const requested = c.req.valid("query").file ?? `${id}.log`;
2348
- if (!deps.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
2349
- statusCode: 404,
2350
- code: "UNKNOWN_LOG_FILE"
2351
- });
2352
- const file = path.join(deps.logFiles.directory, requested);
2353
- const body = fs.readFileSync(file);
2354
- return c.body(body, 200, {
2355
- "Content-Type": "application/x-ndjson; charset=utf-8",
2356
- "Content-Length": String(body.byteLength),
2357
- "Content-Disposition": `attachment; filename="${requested}"`
2358
- });
2359
- }).delete("/logs/:id", describeRoute({
2360
- tags: ["logs"],
2361
- summary: "Delete the persisted logs of one server",
2362
- responses: {
2363
- 200: { description: "Cleared" },
2364
- 404: ERROR_RESPONSES[404]
2365
- }
2366
- }), validate("param", idParam$1), (c) => {
2367
- const { id } = c.req.valid("param");
2368
- if (!deps.store.getServer(id)) throw unknownServer$1(id);
2369
- deps.logFiles.clear(id);
2370
- return c.json({ ok: true });
2371
- });
2482
+ function deleteAtPath(root, path) {
2483
+ let node = root;
2484
+ for (const key of path.slice(0, -1)) {
2485
+ node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
2486
+ if (node === void 0) return;
2487
+ }
2488
+ const last = path[path.length - 1];
2489
+ if (last === void 0) return;
2490
+ if (isRecord$1(node)) delete node[String(last)];
2491
+ else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
2372
2492
  }
2373
- var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$1, downloadQuery;
2374
- var init_logs = __esmMin((() => {
2375
- init_factory();
2376
- init_openapi$1();
2377
- init_validator();
2378
- init_contracts();
2379
- MIN_TAIL = 50;
2380
- MAX_TAIL = 5e3;
2381
- DEFAULT_TAIL = 500;
2382
- idParam$1 = type({ id: "string >= 1" });
2383
- downloadQuery = type({ "file?": "string" });
2384
- }));
2385
- //#endregion
2386
- //#region src/api/metrics.ts
2387
2493
  /**
2388
- * Prometheus text for whatever wants to scrape the panel (Beszel, Grafana,
2389
- * `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.
2390
2501
  */
2391
- function createMetricsRoute(deps) {
2392
- return appFactory.createApp().get("/metrics", describeRoute({
2393
- tags: ["panel"],
2394
- summary: "Prometheus text for whatever scrapes the panel",
2395
- responses: { 200: { description: "text/plain; version=0.0.4" } }
2396
- }), (c) => {
2397
- const state = deps.supervisor.getState();
2398
- const lines = [];
2399
- const metric = (name, help, samples) => {
2400
- if (samples.length === 0) return;
2401
- lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
2502
+ function parseTolerant(value, schema, prefix, unknownKeys) {
2503
+ const candidate = structuredClone(value);
2504
+ for (let pass = 0; pass < 25; pass++) {
2505
+ const parsed = schema(candidate);
2506
+ if (!(parsed instanceof type.errors)) return {
2507
+ value: parsed,
2508
+ error: null
2402
2509
  };
2403
- metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
2404
- metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
2405
- metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
2406
- metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
2407
- 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}`));
2408
- 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)}`));
2409
- 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}`));
2410
- 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}`));
2411
- metric("hh_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
2412
- metric("hh_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
2413
- metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
2414
- metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
2415
- metric("hh_host_load1_per_cpu", "1 minute load average per cpu", [`hh_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
2416
- return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
2417
- });
2418
- }
2419
- var init_metrics = __esmMin((() => {
2420
- init_factory();
2421
- }));
2422
- //#endregion
2423
- //#region src/providers/telegram.ts
2424
- function getBot(token) {
2425
- const cached = bots.get(token);
2426
- if (cached) return cached;
2427
- const bot = new Bot(token, { client: { timeoutSeconds: 10 } });
2428
- bot.api.config.use(autoRetry({
2429
- maxRetryAttempts: 3,
2430
- maxDelaySeconds: 20
2431
- }));
2432
- bots.set(token, bot);
2433
- return bot;
2434
- }
2435
- /** Drops cached clients; used when the token changes or the service shuts down. */
2436
- function forgetBots() {
2437
- bots.clear();
2438
- }
2439
- function escapeHtml(value) {
2440
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2441
- }
2442
- function formatTelegramMessage(title, lines) {
2443
- const body = lines.filter((line) => line.length > 0).map((line) => `• ${escapeHtml(line)}`).join("\n");
2444
- return `<b>${escapeHtml(title)}</b>${body.length > 0 ? `\n${body}` : ""}`;
2445
- }
2446
- /** Turns a grammY error into something worth showing in the settings page. */
2447
- function describeTelegramError(error) {
2448
- if (typeof error === "object" && error !== null) {
2449
- const candidate = error;
2450
- const description = candidate.description ?? candidate.message;
2451
- if (description) return `${description}${candidate.error_code === void 0 ? "" : ` (${candidate.error_code})`}${candidate.parameters?.retry_after === void 0 ? "" : `, retry in ${candidate.parameters.retry_after}s`}`;
2452
- }
2453
- return error instanceof Error ? error.message : String(error);
2454
- }
2455
- async function sendTelegramMessage(token, chatId, html) {
2456
- try {
2457
- await getBot(token).api.sendMessage(chatId, html, {
2458
- parse_mode: "HTML",
2459
- link_preview_options: { is_disabled: true }
2460
- });
2461
- return { ok: true };
2462
- } catch (error) {
2463
- return {
2464
- ok: false,
2465
- error: describeTelegramError(error)
2466
- };
2467
- }
2468
- }
2469
- async function verifyTelegramToken(token) {
2470
- try {
2471
- return {
2472
- ok: true,
2473
- username: (await getBot(token).api.getMe()).username
2474
- };
2475
- } catch (error) {
2476
- return {
2477
- ok: false,
2478
- error: describeTelegramError(error)
2510
+ const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
2511
+ if (removable.length === 0) return {
2512
+ value: null,
2513
+ error: parsed.summary
2514
+ };
2515
+ if (!isRecord$1(candidate)) return {
2516
+ value: null,
2517
+ error: parsed.summary
2479
2518
  };
2519
+ for (const problem of removable) {
2520
+ const at = `${prefix}.${problem.path.join(".")}`;
2521
+ if (!unknownKeys.includes(at)) unknownKeys.push(at);
2522
+ deleteAtPath(candidate, problem.path);
2523
+ }
2480
2524
  }
2525
+ return {
2526
+ value: null,
2527
+ error: "too many unrecognized keys to ignore"
2528
+ };
2481
2529
  }
2482
2530
  /**
2483
- * Recent chats that talked to the bot, so a chat id can be picked instead of
2484
- * hunted down by hand. Telegram only reports chats with pending updates, so the
2485
- * caller is told to message the bot first.
2531
+ * Reads a `servers.config.json` from any release. Unrecognized keys are reported
2532
+ * and left on disk; blocking problems — including a file this release cannot
2533
+ * understand — land in `errors` and leave `config` null.
2486
2534
  */
2487
- async function listTelegramChats(token) {
2488
- try {
2489
- const updates = await getBot(token).api.getUpdates({
2490
- limit: 100,
2491
- allowed_updates: [
2492
- "message",
2493
- "channel_post",
2494
- "edited_message"
2495
- ]
2496
- });
2497
- const chats = /* @__PURE__ */ new Map();
2498
- for (const update of updates) {
2499
- const chat = update.message?.chat ?? update.channel_post?.chat ?? update.edited_message?.chat;
2500
- if (!chat) continue;
2501
- 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";
2502
- chats.set(String(chat.id), {
2503
- id: chat.id,
2504
- title
2505
- });
2506
- }
2507
- return {
2508
- ok: true,
2509
- chats: [...chats.values()]
2510
- };
2511
- } catch (error) {
2512
- return {
2513
- ok: false,
2514
- chats: [],
2515
- error: describeTelegramError(error)
2516
- };
2535
+ function parseConfig(raw, options = {}) {
2536
+ const unknownKeys = [];
2537
+ const errors = [];
2538
+ const warnings = [];
2539
+ const result = {
2540
+ config: null,
2541
+ errors,
2542
+ unknownKeys,
2543
+ warnings,
2544
+ schemaVersion: 1,
2545
+ writtenBy: null
2546
+ };
2547
+ if (!isRecord$1(raw)) {
2548
+ errors.push("the config must contain a JSON object");
2549
+ return result;
2517
2550
  }
2518
- }
2519
- var bots;
2520
- var init_telegram = __esmMin((() => {
2521
- bots = /* @__PURE__ */ new Map();
2522
- }));
2523
- //#endregion
2524
- //#region src/api/notifications.ts
2525
- /**
2526
- * The bot token is written straight to the secrets file and never into
2527
- * `servers.config.json`, so notification *policy* and the *credential* stay
2528
- * separate.
2529
- */
2530
- function createNotificationsRoute(deps) {
2531
- return appFactory.createApp().put("/notifications/token", describeRoute({
2532
- tags: ["notifications"],
2533
- summary: "Store the Telegram bot token (verified first)",
2534
- responses: {
2535
- 200: { description: "Stored" },
2536
- 400: ERROR_RESPONSES[400]
2537
- }
2538
- }), validate("json", telegramTokenSchema), async (c) => {
2539
- const { botToken } = c.req.valid("json");
2540
- const verified = await deps.notifications.verifyToken(botToken);
2541
- if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
2542
- statusCode: 400,
2543
- code: "TELEGRAM_TOKEN_REJECTED"
2544
- });
2545
- deps.secrets.setTelegramToken(botToken);
2546
- forgetBots();
2547
- return c.json({
2548
- ok: true,
2549
- username: verified.username ?? null
2550
- });
2551
- }).delete("/notifications/token", describeRoute({
2552
- tags: ["notifications"],
2553
- summary: "Forget the Telegram bot token",
2554
- responses: { 200: { description: "Removed" } }
2555
- }), (c) => {
2556
- deps.secrets.setTelegramToken(null);
2557
- forgetBots();
2558
- return c.json({ ok: true });
2559
- }).post("/notifications/test", describeRoute({
2560
- tags: ["notifications"],
2561
- summary: "Send a test message",
2562
- responses: {
2563
- 200: { description: "Sent or refused" },
2564
- 400: ERROR_RESPONSES[400]
2551
+ const meta = isRecord$1(raw.meta) ? raw.meta : null;
2552
+ const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
2553
+ const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
2554
+ result.schemaVersion = schemaVersion;
2555
+ result.writtenBy = writtenBy;
2556
+ if (schemaVersion > 1) {
2557
+ errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
2558
+ return result;
2559
+ }
2560
+ const { steps } = planConfigMigrations(schemaVersion, options);
2561
+ if (steps.length > 0) {
2562
+ errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
2563
+ return result;
2564
+ }
2565
+ for (const key of Object.keys(raw)) if (!CONFIG_KEYS.includes(key)) unknownKeys.push(key);
2566
+ const groups = {};
2567
+ for (const [name, schema] of GROUPS) {
2568
+ const parsed = parseTolerant(raw[name] ?? {}, schema, name, unknownKeys);
2569
+ if (parsed.error !== null) errors.push(`${name}: ${parsed.error}`);
2570
+ groups[name] = parsed.value ?? schema({});
2571
+ }
2572
+ const defaults = groups.defaults;
2573
+ const servers = [];
2574
+ const seen = /* @__PURE__ */ new Set();
2575
+ (Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
2576
+ const label = `servers[${index}]`;
2577
+ const parsed = parseTolerant(isRecord$1(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
2578
+ if (parsed.error !== null) {
2579
+ const id = isRecord$1(entry) ? entry.id : void 0;
2580
+ errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
2581
+ return;
2565
2582
  }
2566
- }), validate("json", notificationActionSchema), async (c) => {
2567
- const result = await deps.notifications.sendTest(c.req.valid("json"));
2568
- if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
2569
- statusCode: 400,
2570
- code: "TELEGRAM_SEND_FAILED"
2571
- });
2572
- return c.json(result);
2573
- }).post("/notifications/detect-chats", describeRoute({
2574
- tags: ["notifications"],
2575
- summary: "List the chats the bot can see",
2576
- responses: {
2577
- 200: {
2578
- description: "Chats",
2579
- content: jsonBody(type({ chats: type({
2580
- id: "string | number",
2581
- title: "string"
2582
- }).array() }))
2583
- },
2584
- 400: ERROR_RESPONSES[400]
2583
+ const server = parsed.value;
2584
+ if (seen.has(server.id)) {
2585
+ errors.push(`${label}: duplicate id "${server.id}"`);
2586
+ return;
2585
2587
  }
2586
- }), validate("json", notificationActionSchema), async (c) => {
2587
- const result = await deps.notifications.detectChats(c.req.valid("json"));
2588
- if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
2589
- statusCode: 400,
2590
- code: "TELEGRAM_LIST_FAILED"
2588
+ seen.add(server.id);
2589
+ servers.push({
2590
+ ...server,
2591
+ port: server.port ?? null
2591
2592
  });
2592
- return c.json({ chats: result.chats });
2593
2593
  });
2594
+ warnings.push(...validateDependencies(servers));
2595
+ if (errors.length > 0) return result;
2596
+ result.config = {
2597
+ meta: {
2598
+ writtenBy: writtenBy ?? "",
2599
+ schema: schemaVersion
2600
+ },
2601
+ control: groups.control,
2602
+ defaults,
2603
+ logs: groups.logs,
2604
+ notifications: groups.notifications,
2605
+ host: groups.host,
2606
+ backups: groups.backups,
2607
+ ddns: groups.ddns,
2608
+ servers
2609
+ };
2610
+ return result;
2594
2611
  }
2595
- var init_notifications$1 = __esmMin((() => {
2596
- init_factory();
2597
- init_openapi$1();
2598
- init_validator();
2599
- init_telegram();
2600
- init_contracts();
2601
- }));
2602
- //#endregion
2603
- //#region src/config/migrations.ts
2604
- /** What would have to run to bring `from` up to `to`. */
2605
- function planConfigMigrations(from, options = {}) {
2606
- const to = options.to ?? 1;
2607
- return {
2608
- from,
2609
- to,
2610
- steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
2611
- tooNew: from > to
2612
+ /** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
2613
+ function validateDependencies(servers) {
2614
+ const ids = new Set(servers.map((server) => server.id));
2615
+ const errors = [];
2616
+ for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
2617
+ else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
2618
+ const visiting = /* @__PURE__ */ new Set();
2619
+ const settled = /* @__PURE__ */ new Set();
2620
+ const byId = new Map(servers.map((server) => [server.id, server]));
2621
+ const walk = (id) => {
2622
+ if (settled.has(id)) return;
2623
+ if (visiting.has(id)) {
2624
+ errors.push(`dependency cycle through "${id}"`);
2625
+ return;
2626
+ }
2627
+ visiting.add(id);
2628
+ for (const dependency of byId.get(id)?.dependsOn ?? []) walk(dependency);
2629
+ visiting.delete(id);
2630
+ settled.add(id);
2612
2631
  };
2632
+ for (const server of servers) walk(server.id);
2633
+ return [...new Set(errors)];
2613
2634
  }
2614
- /** Applies the plan in order; the caller owns writing the result. */
2615
- function applyConfigMigrations(config, from, options = {}) {
2616
- const { steps } = planConfigMigrations(from, options);
2617
- let current = config;
2618
- for (const step of steps) current = step.apply(current);
2635
+ /** Adds the stamp every write carries, so the next release can tell what wrote the file. */
2636
+ function stampConfig(draft) {
2637
+ const { $schema, meta: _meta, ...rest } = draft;
2619
2638
  return {
2620
- config: current,
2621
- applied: steps
2622
- };
2623
- }
2624
- var configMigrations;
2625
- var init_migrations = __esmMin((() => {
2626
- configMigrations = [];
2627
- }));
2628
- //#endregion
2629
- //#region src/config/schema.ts
2630
- function isRecord$2(value) {
2631
- return typeof value === "object" && value !== null && !Array.isArray(value);
2632
- }
2633
- /**
2634
- * Resolves one entry against the panel's server defaults. A group (`restart`,
2635
- * `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
2636
- * member does not silently fall back to the *schema* default for the others — which
2637
- * is the whole point of the panel having defaults at all.
2638
- */
2639
- function mergeDefaults(defaults, entry) {
2640
- const merged = { ...entry };
2641
- for (const [key, value] of Object.entries(defaults)) {
2642
- const current = merged[key];
2643
- if (current === void 0) merged[key] = value;
2644
- else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
2645
- }
2646
- return merged;
2647
- }
2648
- var metaSchema, configSchema, CONFIG_KEYS;
2649
- var init_schema = __esmMin((() => {
2650
- init_contracts();
2651
- metaSchema = type({
2652
- writtenBy: "string = \"\"",
2653
- schema: "number.integer >= 1 = 1"
2654
- }).onUndeclaredKey("reject");
2655
- configSchema = type({
2656
- $schema: "string?",
2657
- meta: metaSchema.optional(),
2658
- control: controlSchema.default(() => ({})),
2659
- defaults: defaultsSchema.default(() => ({})),
2660
- logs: logsSchema.default(() => ({})),
2661
- notifications: notificationsSchema.default(() => ({})),
2662
- host: hostSchema.default(() => ({})),
2663
- backups: backupsSchema.default(() => ({})),
2664
- servers: serverSchema.array().default(() => [])
2665
- }).onUndeclaredKey("reject");
2666
- CONFIG_KEYS = [
2667
- "$schema",
2668
- "meta",
2669
- "control",
2670
- "defaults",
2671
- "logs",
2672
- "notifications",
2673
- "host",
2674
- "backups",
2675
- "servers"
2676
- ];
2677
- }));
2678
- //#endregion
2679
- //#region src/helpers/version.ts
2680
- var version_exports = /* @__PURE__ */ __exportAll({ appVersion: () => appVersion });
2681
- /**
2682
- * The running release, read from the package manifest.
2683
- *
2684
- * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
2685
- * is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
2686
- * its own read because it may only import node builtins statically.
2687
- */
2688
- function appVersion() {
2689
- if (cached !== null) return cached;
2690
- let dir = path.dirname(fileURLToPath(import.meta.url));
2691
- for (let depth = 0; depth < 4; depth++) {
2692
- try {
2693
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
2694
- if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
2695
- cached = manifest.version;
2696
- return cached;
2697
- }
2698
- } catch {}
2699
- const parent = path.dirname(dir);
2700
- if (parent === dir) break;
2701
- dir = parent;
2702
- }
2703
- cached = "0.0.0";
2704
- return cached;
2705
- }
2706
- var cached;
2707
- var init_version = __esmMin((() => {
2708
- cached = null;
2709
- }));
2710
- //#endregion
2711
- //#region src/config/parse.ts
2712
- function isRecord$1(value) {
2713
- return typeof value === "object" && value !== null && !Array.isArray(value);
2714
- }
2715
- function deleteAtPath(root, path) {
2716
- let node = root;
2717
- for (const key of path.slice(0, -1)) {
2718
- node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
2719
- if (node === void 0) return;
2720
- }
2721
- const last = path[path.length - 1];
2722
- if (last === void 0) return;
2723
- if (isRecord$1(node)) delete node[String(last)];
2724
- else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
2725
- }
2726
- /**
2727
- * Validates one object against one schema, tolerating keys the schema does not
2728
- * know: a config written by a newer release has to keep working here, and losing
2729
- * the *whole group* to schema defaults would silently reset the listener port,
2730
- * the bind and the auth policy over a single unrecognized key.
2731
- *
2732
- * Only unrecognized keys are dropped, and each one is reported. Anything else is
2733
- * a real problem, returned for the caller to refuse on.
2734
- */
2735
- function parseTolerant(value, schema, prefix, unknownKeys) {
2736
- const candidate = structuredClone(value);
2737
- for (let pass = 0; pass < 25; pass++) {
2738
- const parsed = schema(candidate);
2739
- if (!(parsed instanceof type.errors)) return {
2740
- value: parsed,
2741
- error: null
2742
- };
2743
- const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
2744
- if (removable.length === 0) return {
2745
- value: null,
2746
- error: parsed.summary
2747
- };
2748
- if (!isRecord$1(candidate)) return {
2749
- value: null,
2750
- error: parsed.summary
2751
- };
2752
- for (const problem of removable) {
2753
- const at = `${prefix}.${problem.path.join(".")}`;
2754
- if (!unknownKeys.includes(at)) unknownKeys.push(at);
2755
- deleteAtPath(candidate, problem.path);
2756
- }
2757
- }
2758
- return {
2759
- value: null,
2760
- error: "too many unrecognized keys to ignore"
2761
- };
2762
- }
2763
- /**
2764
- * Reads a `servers.config.json` from any release. Unrecognized keys are reported
2765
- * and left on disk; blocking problems — including a file this release cannot
2766
- * understand — land in `errors` and leave `config` null.
2767
- */
2768
- function parseConfig(raw, options = {}) {
2769
- const unknownKeys = [];
2770
- const errors = [];
2771
- const warnings = [];
2772
- const result = {
2773
- config: null,
2774
- errors,
2775
- unknownKeys,
2776
- warnings,
2777
- schemaVersion: 1,
2778
- writtenBy: null
2779
- };
2780
- if (!isRecord$1(raw)) {
2781
- errors.push("the config must contain a JSON object");
2782
- return result;
2783
- }
2784
- const meta = isRecord$1(raw.meta) ? raw.meta : null;
2785
- const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
2786
- const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
2787
- result.schemaVersion = schemaVersion;
2788
- result.writtenBy = writtenBy;
2789
- if (schemaVersion > 1) {
2790
- errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
2791
- return result;
2792
- }
2793
- const { steps } = planConfigMigrations(schemaVersion, options);
2794
- if (steps.length > 0) {
2795
- errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
2796
- return result;
2797
- }
2798
- for (const key of Object.keys(raw)) if (!CONFIG_KEYS.includes(key)) unknownKeys.push(key);
2799
- const groups = {};
2800
- for (const [name, schema] of GROUPS) {
2801
- const parsed = parseTolerant(raw[name] ?? {}, schema, name, unknownKeys);
2802
- if (parsed.error !== null) errors.push(`${name}: ${parsed.error}`);
2803
- groups[name] = parsed.value ?? schema({});
2804
- }
2805
- const defaults = groups.defaults;
2806
- const servers = [];
2807
- const seen = /* @__PURE__ */ new Set();
2808
- (Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
2809
- const label = `servers[${index}]`;
2810
- const parsed = parseTolerant(isRecord$1(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
2811
- if (parsed.error !== null) {
2812
- const id = isRecord$1(entry) ? entry.id : void 0;
2813
- errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
2814
- return;
2815
- }
2816
- const server = parsed.value;
2817
- if (seen.has(server.id)) {
2818
- errors.push(`${label}: duplicate id "${server.id}"`);
2819
- return;
2820
- }
2821
- seen.add(server.id);
2822
- servers.push({
2823
- ...server,
2824
- port: server.port ?? null
2825
- });
2826
- });
2827
- warnings.push(...validateDependencies(servers));
2828
- if (errors.length > 0) return result;
2829
- result.config = {
2830
- meta: {
2831
- writtenBy: writtenBy ?? "",
2832
- schema: schemaVersion
2833
- },
2834
- control: groups.control,
2835
- defaults,
2836
- logs: groups.logs,
2837
- notifications: groups.notifications,
2838
- host: groups.host,
2839
- backups: groups.backups,
2840
- servers
2841
- };
2842
- return result;
2843
- }
2844
- /** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
2845
- function validateDependencies(servers) {
2846
- const ids = new Set(servers.map((server) => server.id));
2847
- const errors = [];
2848
- for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
2849
- else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
2850
- const visiting = /* @__PURE__ */ new Set();
2851
- const settled = /* @__PURE__ */ new Set();
2852
- const byId = new Map(servers.map((server) => [server.id, server]));
2853
- const walk = (id) => {
2854
- if (settled.has(id)) return;
2855
- if (visiting.has(id)) {
2856
- errors.push(`dependency cycle through "${id}"`);
2857
- return;
2858
- }
2859
- visiting.add(id);
2860
- for (const dependency of byId.get(id)?.dependsOn ?? []) walk(dependency);
2861
- visiting.delete(id);
2862
- settled.add(id);
2863
- };
2864
- for (const server of servers) walk(server.id);
2865
- return [...new Set(errors)];
2866
- }
2867
- /** Adds the stamp every write carries, so the next release can tell what wrote the file. */
2868
- function stampConfig(draft) {
2869
- const { $schema, meta: _meta, ...rest } = draft;
2870
- return {
2871
- ...$schema === void 0 ? {} : { $schema },
2872
- meta: {
2873
- writtenBy: appVersion(),
2874
- schema: 1
2875
- },
2876
- ...rest
2639
+ ...$schema === void 0 ? {} : { $schema },
2640
+ meta: {
2641
+ writtenBy: appVersion(),
2642
+ schema: 1
2643
+ },
2644
+ ...rest
2877
2645
  };
2878
2646
  }
2879
2647
  var GROUPS, UNDECLARED;
@@ -2887,7 +2655,8 @@ var init_parse = __esmMin((() => {
2887
2655
  ["logs", logsSchema],
2888
2656
  ["notifications", notificationsSchema],
2889
2657
  ["host", hostSchema],
2890
- ["backups", backupsSchema]
2658
+ ["backups", backupsSchema],
2659
+ ["ddns", ddnsConfigSchema]
2891
2660
  ];
2892
2661
  UNDECLARED = "must be removed";
2893
2662
  }));
@@ -3153,6 +2922,18 @@ var init_store = __esmMin((() => {
3153
2922
  this.commit(draft);
3154
2923
  return backups;
3155
2924
  }
2925
+ /**
2926
+ * The DDNS block is replaced as a whole: its accounts and domains are lists,
2927
+ * and a key-by-key merge would leave a removed account or hostname behind.
2928
+ */
2929
+ updateDdns(config) {
2930
+ const parsed = ddnsConfigSchema(config);
2931
+ if (parsed instanceof type.errors) throw new ConfigError(`ddns: ${formatErrors(parsed)}`);
2932
+ const draft = structuredClone(this.raw);
2933
+ draft.ddns = parsed;
2934
+ this.commit(draft);
2935
+ return parsed;
2936
+ }
3156
2937
  updateDefaults(patch) {
3157
2938
  const draft = structuredClone(this.raw);
3158
2939
  draft.defaults = { ...draft.defaults ?? {} };
@@ -3201,43 +2982,1754 @@ var init_store = __esmMin((() => {
3201
2982
  this.apply(stamped);
3202
2983
  this.notify();
3203
2984
  }
3204
- resolveFallback() {
3205
- const control = controlSchema({});
3206
- const defaults = defaultsSchema({});
3207
- if (control instanceof type.errors || defaults instanceof type.errors) throw new ConfigError("internal: default config failed validation");
3208
- const logs = logsSchema({});
3209
- const notifications = notificationsSchema({});
3210
- const host = hostSchema({});
3211
- const backups = backupsSchema({});
3212
- 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");
3213
- return {
3214
- control,
3215
- defaults,
3216
- logs,
3217
- notifications,
3218
- host,
3219
- backups,
3220
- servers: []
3221
- };
2985
+ resolveFallback() {
2986
+ const control = controlSchema({});
2987
+ const defaults = defaultsSchema({});
2988
+ if (control instanceof type.errors || defaults instanceof type.errors) throw new ConfigError("internal: default config failed validation");
2989
+ const logs = logsSchema({});
2990
+ const notifications = notificationsSchema({});
2991
+ const host = hostSchema({});
2992
+ const backups = backupsSchema({});
2993
+ const ddns = ddnsConfigSchema({});
2994
+ if (logs instanceof type.errors || notifications instanceof type.errors || host instanceof type.errors || backups instanceof type.errors || ddns instanceof type.errors) throw new ConfigError("internal: default settings failed validation");
2995
+ return {
2996
+ control,
2997
+ defaults,
2998
+ logs,
2999
+ notifications,
3000
+ host,
3001
+ backups,
3002
+ ddns,
3003
+ servers: []
3004
+ };
3005
+ }
3006
+ apply(raw) {
3007
+ this.raw = raw;
3008
+ const parsed = parseConfig(raw);
3009
+ this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
3010
+ this.schemaVersion = parsed.schemaVersion;
3011
+ this.warnings = [...parsed.warnings, ...parsed.unknownKeys.length === 0 ? [] : [`${path.basename(this.file)} carries ${parsed.unknownKeys.length} unrecognized key(s) this release ignores: ${parsed.unknownKeys.join(", ")}`]];
3012
+ if (parsed.config !== null) this.resolvedConfig = parsed.config;
3013
+ else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
3014
+ }
3015
+ };
3016
+ }));
3017
+ //#endregion
3018
+ //#region src/providers/ddns/types.ts
3019
+ function succeeded(message, changed) {
3020
+ return {
3021
+ ok: true,
3022
+ changed,
3023
+ message
3024
+ };
3025
+ }
3026
+ function failed(error) {
3027
+ return {
3028
+ ok: false,
3029
+ changed: false,
3030
+ message: error,
3031
+ error
3032
+ };
3033
+ }
3034
+ /** Missing required credential keys, as a sentence for the settings page. */
3035
+ function missingFields(credentials, keys) {
3036
+ const missing = keys.filter((key) => (credentials?.[key] ?? "").trim().length === 0);
3037
+ return missing.length === 0 ? null : `set ${missing.join(", ")}`;
3038
+ }
3039
+ function basicAuth(user, password) {
3040
+ return `Basic ${Buffer$1.from(`${user}:${password}`, "utf8").toString("base64")}`;
3041
+ }
3042
+ /** A provider error body is useful, but never worth throwing over. */
3043
+ async function errorText(response, limit = 300) {
3044
+ try {
3045
+ const text = (await response.text()).replace(/\s+/g, " ").trim();
3046
+ return text.length === 0 ? `HTTP ${response.status}` : `HTTP ${response.status}: ${text.slice(0, limit)}`;
3047
+ } catch {
3048
+ return `HTTP ${response.status}`;
3049
+ }
3050
+ }
3051
+ /** `Sub.Domain.COM.` -> `sub.domain.com`; DuckDNS and friends are case-insensitive. */
3052
+ function normalizeHost(host) {
3053
+ return host.trim().toLowerCase().replace(NORMALIZE_DOTS, "");
3054
+ }
3055
+ var NORMALIZE_DOTS;
3056
+ var init_types = __esmMin((() => {
3057
+ NORMALIZE_DOTS = /\.$/;
3058
+ }));
3059
+ //#endregion
3060
+ //#region src/providers/ddns/cloudflare.ts
3061
+ function authHeaders(credentials) {
3062
+ const token = (credentials.apiToken ?? "").trim();
3063
+ if (token.length > 0) return { Authorization: `Bearer ${token}` };
3064
+ const email = (credentials.email ?? "").trim();
3065
+ const key = (credentials.apiKey ?? "").trim();
3066
+ if (email.length > 0 && key.length > 0) return {
3067
+ "X-Auth-Email": email,
3068
+ "X-Auth-Key": key
3069
+ };
3070
+ return null;
3071
+ }
3072
+ async function call(path, headers, context, init = {}) {
3073
+ try {
3074
+ const response = await context.fetch(`${API$5}${path}`, {
3075
+ ...init,
3076
+ headers: {
3077
+ ...headers,
3078
+ "Content-Type": "application/json",
3079
+ ...init.headers ?? {}
3080
+ },
3081
+ signal: context.signal
3082
+ });
3083
+ const text = await response.text();
3084
+ let parsed;
3085
+ try {
3086
+ parsed = JSON.parse(text);
3087
+ } catch {
3088
+ return {
3089
+ data: null,
3090
+ status: response.status,
3091
+ error: `cloudflare answered ${response.status}: ${text.slice(0, 200)}`
3092
+ };
3093
+ }
3094
+ if (!response.ok || parsed.success !== true) {
3095
+ const detail = (parsed.errors ?? []).map((entry) => `${entry.code} ${entry.message}`).join("; ");
3096
+ return {
3097
+ data: parsed,
3098
+ status: response.status,
3099
+ error: detail.length > 0 ? detail : `HTTP ${response.status}`
3100
+ };
3101
+ }
3102
+ return {
3103
+ data: parsed,
3104
+ status: response.status,
3105
+ error: null
3106
+ };
3107
+ } catch (error) {
3108
+ return {
3109
+ data: null,
3110
+ status: 0,
3111
+ error: error instanceof Error ? error.message : String(error)
3112
+ };
3113
+ }
3114
+ }
3115
+ /**
3116
+ * Walks the host's parents through `GET /zones?name=`, which is how the
3117
+ * reference image resolves a zone without a pasted zone id; the id is cached in
3118
+ * the panel's state file, so it usually costs nothing.
3119
+ */
3120
+ async function resolveZone(host, headers, context, cached) {
3121
+ if (cached !== void 0) return {
3122
+ id: cached,
3123
+ name: host
3124
+ };
3125
+ const labels = host.split(".");
3126
+ for (let index = 0; index <= labels.length - 2; index++) {
3127
+ const candidate = labels.slice(index).join(".");
3128
+ const { data, error } = await call(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
3129
+ if (error !== null || data === null) throw new Error(error ?? "could not look up the zone");
3130
+ const zone = data.result[0];
3131
+ if (zone !== void 0) return zone;
3132
+ }
3133
+ return null;
3134
+ }
3135
+ async function findRecord(zoneId, record, headers, context, cached) {
3136
+ if (cached !== void 0) {
3137
+ const fetched = await call(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
3138
+ if (fetched.error === null && fetched.data !== null) return {
3139
+ record: fetched.data.result,
3140
+ error: null
3141
+ };
3142
+ if (fetched.status !== 404 && fetched.status !== 400) return {
3143
+ record: null,
3144
+ error: fetched.error
3145
+ };
3146
+ }
3147
+ const listed = await call(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
3148
+ if (listed.error !== null) return {
3149
+ record: null,
3150
+ error: listed.error
3151
+ };
3152
+ return {
3153
+ record: (listed.data?.result ?? []).find((entry) => entry.id !== void 0) ?? null,
3154
+ error: null
3155
+ };
3156
+ }
3157
+ var API$5, cloudflareProvider;
3158
+ var init_cloudflare = __esmMin((() => {
3159
+ init_types();
3160
+ API$5 = "https://api.cloudflare.com/client/v4";
3161
+ cloudflareProvider = {
3162
+ id: "cloudflare",
3163
+ label: "Cloudflare",
3164
+ docsUrl: "https://developers.cloudflare.com/api/resources/dns/subresources/records/",
3165
+ families: ["A", "AAAA"],
3166
+ ttl: true,
3167
+ proxied: true,
3168
+ fields: [
3169
+ {
3170
+ key: "apiToken",
3171
+ label: "API token",
3172
+ hint: "Zone → DNS → Edit (plus Zone → Zone → Read) for your zones. Preferred.",
3173
+ optional: true
3174
+ },
3175
+ {
3176
+ key: "email",
3177
+ label: "Account email",
3178
+ hint: "Only for the legacy Global API Key.",
3179
+ optional: true
3180
+ },
3181
+ {
3182
+ key: "apiKey",
3183
+ label: "Global API key",
3184
+ hint: "Legacy; prefer an API token.",
3185
+ optional: true
3186
+ }
3187
+ ],
3188
+ validate(credentials) {
3189
+ if (authHeaders(credentials ?? {}) !== null) return null;
3190
+ return missingFields(credentials, ["apiToken"]);
3191
+ },
3192
+ async update(record, context) {
3193
+ const headers = authHeaders(context.credentials);
3194
+ if (headers === null) return failed("cloudflare needs an API token (or the legacy email + global key)");
3195
+ try {
3196
+ const zone = await resolveZone(record.host, headers, context, record.cache?.zoneId);
3197
+ if (zone === null) return failed(`cloudflare does not have a zone for ${record.host}`);
3198
+ const ttl = record.proxied ? 1 : record.ttl;
3199
+ const found = await findRecord(zone.id, record, headers, context, record.cache?.recordId);
3200
+ if (found.error !== null) return failed(found.error);
3201
+ const existing = found.record;
3202
+ if (existing !== null && existing.content === record.ip && existing.ttl === ttl && existing.proxied === record.proxied) return {
3203
+ ...succeeded(`already ${record.ip}`, false),
3204
+ cache: {
3205
+ zoneId: zone.id,
3206
+ recordId: existing.id
3207
+ }
3208
+ };
3209
+ const cache = {
3210
+ zoneId: zone.id,
3211
+ recordId: existing?.id
3212
+ };
3213
+ const body = JSON.stringify({
3214
+ type: record.type,
3215
+ name: record.host,
3216
+ content: record.ip,
3217
+ ttl,
3218
+ proxied: record.proxied
3219
+ });
3220
+ const written = existing === null ? await call(`/zones/${zone.id}/dns_records`, headers, context, {
3221
+ method: "POST",
3222
+ body
3223
+ }) : await call(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
3224
+ method: "PATCH",
3225
+ body
3226
+ });
3227
+ if (written.error !== null) return failed(written.error);
3228
+ return {
3229
+ ...succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true),
3230
+ cache: {
3231
+ ...cache,
3232
+ recordId: written.data?.result?.id ?? existing?.id
3233
+ }
3234
+ };
3235
+ } catch (error) {
3236
+ return failed(error instanceof Error ? error.message : String(error));
3237
+ }
3238
+ }
3239
+ };
3240
+ }));
3241
+ //#endregion
3242
+ //#region src/providers/ddns/dyndns2.ts
3243
+ /** The first word of the body is the code; the rest is provider detail. */
3244
+ function parseDyndnsReply(body) {
3245
+ const trimmed = body.trim();
3246
+ const separator = trimmed.search(/\s/);
3247
+ if (separator < 0) return {
3248
+ code: trimmed.toLowerCase(),
3249
+ detail: ""
3250
+ };
3251
+ return {
3252
+ code: trimmed.slice(0, separator).toLowerCase(),
3253
+ detail: trimmed.slice(separator).trim()
3254
+ };
3255
+ }
3256
+ /** Maps a dyndns2 body onto the panel's result shape. */
3257
+ function describeDyndnsReply(body, provider) {
3258
+ const { code, detail } = parseDyndnsReply(body);
3259
+ const known = CODES[code];
3260
+ if (known === void 0) return failed(`${provider} answered "${(body.trim() || "an empty body").slice(0, 120)}"`);
3261
+ if (known.ok) return succeeded(`${known.message}${detail.length === 0 ? "" : ` (${detail})`}`, known.changed);
3262
+ return failed(`${known.message}${detail.length === 0 ? "" : `: ${detail}`}`);
3263
+ }
3264
+ /**
3265
+ * A 401/400 with a non-dyndns2 body is still an answer worth quoting; a known
3266
+ * code wins, because several of these endpoints report errors with HTTP 200.
3267
+ */
3268
+ function describeDyndnsResponse(response, body, provider) {
3269
+ const { code } = parseDyndnsReply(body);
3270
+ if (!response.ok && CODES[code] === void 0) {
3271
+ const text = body.replace(/\s+/g, " ").trim().slice(0, 160);
3272
+ return failed(`${provider} answered HTTP ${response.status}${text.length === 0 ? "" : `: ${text}`}`);
3273
+ }
3274
+ return describeDyndnsReply(body, provider);
3275
+ }
3276
+ var CODES;
3277
+ var init_dyndns2 = __esmMin((() => {
3278
+ init_types();
3279
+ CODES = {
3280
+ "good": {
3281
+ ok: true,
3282
+ changed: true,
3283
+ message: "updated",
3284
+ permanent: false
3285
+ },
3286
+ "nochg": {
3287
+ ok: true,
3288
+ changed: false,
3289
+ message: "already current",
3290
+ permanent: false
3291
+ },
3292
+ "nohost": {
3293
+ ok: false,
3294
+ changed: false,
3295
+ message: "the provider does not know this hostname",
3296
+ permanent: true
3297
+ },
3298
+ "notfqdn": {
3299
+ ok: false,
3300
+ changed: false,
3301
+ message: "the provider wants a fully qualified hostname",
3302
+ permanent: true
3303
+ },
3304
+ "numhost": {
3305
+ ok: false,
3306
+ changed: false,
3307
+ message: "too many hostnames for this provider",
3308
+ permanent: true
3309
+ },
3310
+ "badauth": {
3311
+ ok: false,
3312
+ changed: false,
3313
+ message: "the provider rejected the credentials",
3314
+ permanent: true
3315
+ },
3316
+ "badagent": {
3317
+ ok: false,
3318
+ changed: false,
3319
+ message: "the provider rejected this client (User-Agent)",
3320
+ permanent: true
3321
+ },
3322
+ "!donator": {
3323
+ ok: false,
3324
+ changed: false,
3325
+ message: "this feature needs a paid provider plan",
3326
+ permanent: true
3327
+ },
3328
+ "abuse": {
3329
+ ok: false,
3330
+ changed: false,
3331
+ message: "the provider blocked this account for abuse",
3332
+ permanent: true
3333
+ },
3334
+ "dnserr": {
3335
+ ok: false,
3336
+ changed: false,
3337
+ message: "the provider had a DNS error",
3338
+ permanent: false
3339
+ },
3340
+ "servererror": {
3341
+ ok: false,
3342
+ changed: false,
3343
+ message: "the provider had a server error",
3344
+ permanent: false
3345
+ },
3346
+ "911": {
3347
+ ok: false,
3348
+ changed: false,
3349
+ message: "the provider is down; it asks you to wait about 30 minutes",
3350
+ permanent: false
3351
+ },
3352
+ "unknown": {
3353
+ ok: false,
3354
+ changed: false,
3355
+ message: "the provider answered with an unknown error",
3356
+ permanent: false
3357
+ }
3358
+ };
3359
+ }));
3360
+ //#endregion
3361
+ //#region src/providers/ddns/desec.ts
3362
+ var ENDPOINT$6, desecProvider;
3363
+ var init_desec = __esmMin((() => {
3364
+ init_dyndns2();
3365
+ init_types();
3366
+ ENDPOINT$6 = "https://update.dedyn.io/";
3367
+ desecProvider = {
3368
+ id: "desec",
3369
+ label: "deSEC",
3370
+ docsUrl: "https://desec.readthedocs.io/en/latest/dyndns/update-api.html",
3371
+ families: ["A", "AAAA"],
3372
+ ttl: false,
3373
+ proxied: false,
3374
+ fields: [{
3375
+ key: "token",
3376
+ label: "API token",
3377
+ hint: "desec.io → Token management; the token must allow the RRset update.",
3378
+ optional: false
3379
+ }],
3380
+ validate(credentials) {
3381
+ return missingFields(credentials, ["token"]);
3382
+ },
3383
+ async update(record, context) {
3384
+ const token = (context.credentials.token ?? "").trim();
3385
+ if (token.length === 0) return failed("desec needs an API token");
3386
+ const family = record.type === "A" ? `myipv4=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
3387
+ const url = `${ENDPOINT$6}?hostname=${encodeURIComponent(record.host)}&${family}`;
3388
+ try {
3389
+ const response = await context.fetch(url, {
3390
+ headers: { Authorization: basicAuth(record.host, token) },
3391
+ signal: context.signal
3392
+ });
3393
+ return describeDyndnsResponse(response, await response.text(), "desec");
3394
+ } catch (error) {
3395
+ return failed(error instanceof Error ? error.message : String(error));
3396
+ }
3397
+ }
3398
+ };
3399
+ }));
3400
+ //#endregion
3401
+ //#region src/providers/ddns/zone.ts
3402
+ /**
3403
+ * Splits a hostname into the zone and the record name, which is what every
3404
+ * provider that has no zone lookup needs. `zone` short-circuits the heuristic.
3405
+ */
3406
+ function splitHost(host, zone) {
3407
+ const normalized = normalizeHost(host);
3408
+ const override = normalizeHost(zone ?? "");
3409
+ const resolved = override.length > 0 ? override : guessZone(normalized);
3410
+ if (normalized === resolved) return {
3411
+ zone: resolved,
3412
+ name: "@"
3413
+ };
3414
+ return {
3415
+ zone: resolved,
3416
+ name: normalized.endsWith(`.${resolved}`) ? normalized.slice(0, -(resolved.length + 1)) : normalized
3417
+ };
3418
+ }
3419
+ function guessZone(host) {
3420
+ const labels = host.split(".");
3421
+ if (labels.length <= 2) return host;
3422
+ const lastTwo = labels.slice(-2).join(".");
3423
+ const lastThree = labels.slice(-3).join(".");
3424
+ return MULTI_LABEL_SUFFIXES.has(lastTwo) ? lastThree : lastTwo;
3425
+ }
3426
+ var MULTI_LABEL_SUFFIXES;
3427
+ var init_zone = __esmMin((() => {
3428
+ init_types();
3429
+ MULTI_LABEL_SUFFIXES = /* @__PURE__ */ new Set([
3430
+ "ac.uk",
3431
+ "co.in",
3432
+ "co.jp",
3433
+ "co.ke",
3434
+ "co.kr",
3435
+ "co.nz",
3436
+ "co.uk",
3437
+ "co.za",
3438
+ "com.au",
3439
+ "com.br",
3440
+ "com.cn",
3441
+ "com.hk",
3442
+ "com.mx",
3443
+ "com.sg",
3444
+ "com.tr",
3445
+ "com.tw",
3446
+ "edu.au",
3447
+ "gov.uk",
3448
+ "net.au",
3449
+ "net.uk",
3450
+ "org.au",
3451
+ "org.uk"
3452
+ ]);
3453
+ }));
3454
+ //#endregion
3455
+ //#region src/providers/ddns/digitalocean.ts
3456
+ function oceanTtl(ttl) {
3457
+ return Math.max(30, ttl);
3458
+ }
3459
+ var API$4, digitaloceanProvider;
3460
+ var init_digitalocean = __esmMin((() => {
3461
+ init_types();
3462
+ init_zone();
3463
+ API$4 = "https://api.digitalocean.com/v2/domains";
3464
+ digitaloceanProvider = {
3465
+ id: "digitalocean",
3466
+ label: "DigitalOcean",
3467
+ docsUrl: "https://docs.digitalocean.com/reference/api/api-reference/#tag/Domain-Records",
3468
+ families: ["A", "AAAA"],
3469
+ ttl: true,
3470
+ proxied: false,
3471
+ fields: [{
3472
+ key: "token",
3473
+ label: "API token",
3474
+ hint: "A personal access token with the `domain` scope.",
3475
+ optional: false
3476
+ }],
3477
+ validate(credentials) {
3478
+ return missingFields(credentials, ["token"]);
3479
+ },
3480
+ async update(record, context) {
3481
+ const { zone, name } = splitHost(record.host, record.zone);
3482
+ const ttl = oceanTtl(record.ttl);
3483
+ const headers = {
3484
+ "Authorization": `Bearer ${(context.credentials.token ?? "").trim()}`,
3485
+ "Content-Type": "application/json"
3486
+ };
3487
+ const collection = `${API$4}/${encodeURIComponent(zone)}/records`;
3488
+ try {
3489
+ const listed = await context.fetch(`${collection}?per_page=200`, {
3490
+ headers,
3491
+ signal: context.signal
3492
+ });
3493
+ if (!listed.ok) return failed(await errorText(listed));
3494
+ const existing = ((await listed.json()).domain_records ?? []).find((entry) => entry.name === name && entry.type === record.type);
3495
+ if (existing !== void 0 && existing.data === record.ip) return succeeded(`already ${record.ip}`, false);
3496
+ const written = existing === void 0 ? await context.fetch(collection, {
3497
+ method: "POST",
3498
+ headers,
3499
+ body: JSON.stringify({
3500
+ type: record.type,
3501
+ name,
3502
+ data: record.ip,
3503
+ ttl
3504
+ }),
3505
+ signal: context.signal
3506
+ }) : await context.fetch(`${collection}/${existing.id}`, {
3507
+ method: "PUT",
3508
+ headers,
3509
+ body: JSON.stringify({
3510
+ type: record.type,
3511
+ name,
3512
+ data: record.ip,
3513
+ ttl
3514
+ }),
3515
+ signal: context.signal
3516
+ });
3517
+ if (!written.ok) return failed(await errorText(written));
3518
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
3519
+ } catch (error) {
3520
+ return failed(error instanceof Error ? error.message : String(error));
3521
+ }
3522
+ }
3523
+ };
3524
+ }));
3525
+ //#endregion
3526
+ //#region src/providers/ddns/duckdns.ts
3527
+ /** DuckDNS addresses one subdomain at a time, so the label is the domain. */
3528
+ function subdomain(host) {
3529
+ const normalized = host.trim().toLowerCase();
3530
+ return normalized.endsWith(".duckdns.org") ? normalized.slice(0, -12) : normalized;
3531
+ }
3532
+ var ENDPOINT$5, duckdnsProvider;
3533
+ var init_duckdns = __esmMin((() => {
3534
+ init_types();
3535
+ ENDPOINT$5 = "https://www.duckdns.org/update";
3536
+ duckdnsProvider = {
3537
+ id: "duckdns",
3538
+ label: "DuckDNS",
3539
+ docsUrl: "https://www.duckdns.org/spec.jsp",
3540
+ families: ["A", "AAAA"],
3541
+ ttl: false,
3542
+ proxied: false,
3543
+ fields: [{
3544
+ key: "token",
3545
+ label: "Account token",
3546
+ hint: "On duckdns.org after signing in. One token covers every subdomain.",
3547
+ optional: false
3548
+ }],
3549
+ validate(credentials) {
3550
+ return missingFields(credentials, ["token"]);
3551
+ },
3552
+ async update(record, context) {
3553
+ const token = (context.credentials.token ?? "").trim();
3554
+ if (token.length === 0) return failed("duckdns needs the account token");
3555
+ const family = record.type === "A" ? `ip=${encodeURIComponent(record.ip)}` : `ipv6=${encodeURIComponent(record.ip)}`;
3556
+ const url = `${ENDPOINT$5}?domains=${encodeURIComponent(subdomain(record.host))}&token=${encodeURIComponent(token)}&${family}`;
3557
+ try {
3558
+ const response = await context.fetch(url, { signal: context.signal });
3559
+ const body = (await response.text()).trim();
3560
+ if (body.toUpperCase() === "OK") return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
3561
+ return failed(`duckdns answered "${body.slice(0, 120) || `HTTP ${response.status}`}"`);
3562
+ } catch (error) {
3563
+ return failed(error instanceof Error ? error.message : String(error));
3564
+ }
3565
+ }
3566
+ };
3567
+ }));
3568
+ //#endregion
3569
+ //#region src/providers/ddns/dynu.ts
3570
+ var ENDPOINT$4, dynuProvider;
3571
+ var init_dynu = __esmMin((() => {
3572
+ init_dyndns2();
3573
+ init_types();
3574
+ ENDPOINT$4 = "https://api.dynu.com/nic/update";
3575
+ dynuProvider = {
3576
+ id: "dynu",
3577
+ label: "Dynu",
3578
+ docsUrl: "https://www.dynu.com/DynamicDNS/IP-Update-Protocol",
3579
+ families: ["A", "AAAA"],
3580
+ ttl: false,
3581
+ proxied: false,
3582
+ fields: [{
3583
+ key: "username",
3584
+ label: "Username",
3585
+ hint: "Dynu username; used for HTTP Basic auth.",
3586
+ optional: false
3587
+ }, {
3588
+ key: "password",
3589
+ label: "Password",
3590
+ hint: "Account password, or its SHA-256 hash.",
3591
+ optional: false
3592
+ }],
3593
+ validate(credentials) {
3594
+ return missingFields(credentials, ["username", "password"]);
3595
+ },
3596
+ async update(record, context) {
3597
+ const username = (context.credentials.username ?? "").trim();
3598
+ const password = (context.credentials.password ?? "").trim();
3599
+ if (username.length === 0 || password.length === 0) return failed("dynu needs the username and password");
3600
+ const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
3601
+ const url = `${ENDPOINT$4}?hostname=${encodeURIComponent(record.host)}&${address}`;
3602
+ try {
3603
+ const response = await context.fetch(url, {
3604
+ headers: { Authorization: basicAuth(username, password) },
3605
+ signal: context.signal
3606
+ });
3607
+ return describeDyndnsResponse(response, await response.text(), "dynu");
3608
+ } catch (error) {
3609
+ return failed(error instanceof Error ? error.message : String(error));
3610
+ }
3611
+ }
3612
+ };
3613
+ }));
3614
+ //#endregion
3615
+ //#region src/providers/ddns/freedns.ts
3616
+ /**
3617
+ * FreeDNS credentials *are* a URL: the per-record "Direct URL" hash. Both the
3618
+ * bare hash and the whole URL are accepted, because that is what people copy.
3619
+ */
3620
+ function updateToken(credentials) {
3621
+ const raw = (credentials.token ?? "").trim();
3622
+ if (raw.length === 0) return "";
3623
+ return (raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : raw).replace(/^https?:\/\/[^?]*\?/, "");
3624
+ }
3625
+ var ENDPOINT$3, freednsProvider;
3626
+ var init_freedns = __esmMin((() => {
3627
+ init_types();
3628
+ ENDPOINT$3 = "https://freedns.afraid.org/dynamic/update.php";
3629
+ freednsProvider = {
3630
+ id: "freedns",
3631
+ label: "FreeDNS (afraid.org)",
3632
+ docsUrl: "https://freedns.afraid.org/faq/",
3633
+ families: ["A", "AAAA"],
3634
+ ttl: false,
3635
+ proxied: false,
3636
+ fields: [{
3637
+ key: "token",
3638
+ label: "Direct URL",
3639
+ hint: "FreeDNS → Dynamic DNS → the per-record \"Direct URL\". Paste the whole URL or just its hash.",
3640
+ optional: false
3641
+ }],
3642
+ validate(credentials) {
3643
+ return missingFields(credentials, ["token"]);
3644
+ },
3645
+ async update(record, context) {
3646
+ const token = updateToken(context.credentials);
3647
+ if (token.length === 0) return failed("freedns needs the per-record Direct URL");
3648
+ try {
3649
+ const response = await context.fetch(`${ENDPOINT$3}?${token}&address=${encodeURIComponent(record.ip)}`, { signal: context.signal });
3650
+ const body = (await response.text()).trim();
3651
+ if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
3652
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
3653
+ } catch (error) {
3654
+ return failed(error instanceof Error ? error.message : String(error));
3655
+ }
3656
+ }
3657
+ };
3658
+ }));
3659
+ //#endregion
3660
+ //#region src/providers/ddns/gandi.ts
3661
+ /** Gandi's documented minimum; `1` (automatic) has no meaning here. */
3662
+ function gandiTtl(ttl) {
3663
+ return Math.min(2592e3, Math.max(300, ttl));
3664
+ }
3665
+ /** A Personal Access Token (`pat_…`) is preferred; the legacy API key is still accepted. */
3666
+ function authHeader(token) {
3667
+ return token.startsWith("pat_") ? `Bearer ${token}` : `Apikey ${token}`;
3668
+ }
3669
+ var API$3, gandiProvider;
3670
+ var init_gandi = __esmMin((() => {
3671
+ init_types();
3672
+ init_zone();
3673
+ API$3 = "https://api.gandi.net/v5/livedns/domains";
3674
+ gandiProvider = {
3675
+ id: "gandi",
3676
+ label: "Gandi",
3677
+ docsUrl: "https://api.gandi.net/docs/livedns/",
3678
+ families: ["A", "AAAA"],
3679
+ ttl: true,
3680
+ proxied: false,
3681
+ fields: [{
3682
+ key: "token",
3683
+ label: "Personal access token",
3684
+ hint: "A `pat_…` token from admin.gandi.net → Organizations. A legacy API key also works.",
3685
+ optional: false
3686
+ }],
3687
+ validate(credentials) {
3688
+ return missingFields(credentials, ["token"]);
3689
+ },
3690
+ async update(record, context) {
3691
+ const token = (context.credentials.token ?? "").trim();
3692
+ if (token.length === 0) return failed("gandi needs an API token");
3693
+ const { zone, name } = splitHost(record.host, record.zone);
3694
+ const ttl = gandiTtl(record.ttl);
3695
+ const url = `${API$3}/${encodeURIComponent(zone)}/records/${encodeURIComponent(name)}/${record.type}`;
3696
+ const headers = {
3697
+ "Authorization": authHeader(token),
3698
+ "Content-Type": "application/json"
3699
+ };
3700
+ try {
3701
+ const current = await context.fetch(url, {
3702
+ headers,
3703
+ signal: context.signal
3704
+ });
3705
+ if (current.ok) {
3706
+ const body = await current.json();
3707
+ if (body.rrset_values?.length === 1 && body.rrset_values[0] === record.ip) return succeeded(`already ${record.ip}`, false);
3708
+ }
3709
+ const written = await context.fetch(url, {
3710
+ method: "PUT",
3711
+ headers,
3712
+ body: JSON.stringify({
3713
+ rrset_values: [record.ip],
3714
+ rrset_ttl: ttl
3715
+ }),
3716
+ signal: context.signal
3717
+ });
3718
+ if (!written.ok) return failed(await errorText(written));
3719
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
3720
+ } catch (error) {
3721
+ return failed(error instanceof Error ? error.message : String(error));
3722
+ }
3723
+ }
3724
+ };
3725
+ }));
3726
+ //#endregion
3727
+ //#region src/providers/ddns/godaddy.ts
3728
+ /** GoDaddy's floor; `1` (automatic) has no meaning here. */
3729
+ function godaddyTtl(ttl) {
3730
+ return Math.min(86400, Math.max(600, ttl));
3731
+ }
3732
+ var API$2, godaddyProvider;
3733
+ var init_godaddy = __esmMin((() => {
3734
+ init_types();
3735
+ init_zone();
3736
+ API$2 = "https://api.godaddy.com/v1/domains";
3737
+ godaddyProvider = {
3738
+ id: "godaddy",
3739
+ label: "GoDaddy",
3740
+ docsUrl: "https://developer.godaddy.com/doc/endpoint/dns",
3741
+ families: ["A", "AAAA"],
3742
+ ttl: true,
3743
+ proxied: false,
3744
+ fields: [
3745
+ {
3746
+ key: "key",
3747
+ label: "API key",
3748
+ hint: "developer.godaddy.com/keys.",
3749
+ optional: false
3750
+ },
3751
+ {
3752
+ key: "secret",
3753
+ label: "API secret",
3754
+ hint: "Shown once with the key.",
3755
+ optional: false
3756
+ },
3757
+ {
3758
+ key: "baseUrl",
3759
+ label: "API base URL",
3760
+ hint: "Optional; use https://api.ote-godaddy.com to rehearse against the test environment.",
3761
+ optional: true
3762
+ }
3763
+ ],
3764
+ validate(credentials) {
3765
+ return missingFields(credentials, ["key", "secret"]);
3766
+ },
3767
+ async update(record, context) {
3768
+ const { zone, name } = splitHost(record.host, record.zone);
3769
+ const ttl = godaddyTtl(record.ttl);
3770
+ const url = `${(context.credentials.baseUrl ?? "").trim() || API$2}/${encodeURIComponent(zone)}/records/${record.type}/${encodeURIComponent(name)}`;
3771
+ const headers = {
3772
+ "Authorization": `sso-key ${context.credentials.key ?? ""}:${context.credentials.secret ?? ""}`,
3773
+ "Content-Type": "application/json"
3774
+ };
3775
+ try {
3776
+ const current = await context.fetch(url, {
3777
+ headers,
3778
+ signal: context.signal
3779
+ });
3780
+ if (current.ok) {
3781
+ const body = await current.json();
3782
+ if (body.length === 1 && body[0]?.data === record.ip) return succeeded(`already ${record.ip}`, false);
3783
+ }
3784
+ const written = await context.fetch(url, {
3785
+ method: "PUT",
3786
+ headers,
3787
+ body: JSON.stringify([{
3788
+ data: record.ip,
3789
+ ttl
3790
+ }]),
3791
+ signal: context.signal
3792
+ });
3793
+ if (!written.ok) return failed(await errorText(written));
3794
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
3795
+ } catch (error) {
3796
+ return failed(error instanceof Error ? error.message : String(error));
3797
+ }
3798
+ }
3799
+ };
3800
+ }));
3801
+ //#endregion
3802
+ //#region src/providers/ddns/henet.ts
3803
+ var ENDPOINT$2, henetProvider;
3804
+ var init_henet = __esmMin((() => {
3805
+ init_dyndns2();
3806
+ init_types();
3807
+ ENDPOINT$2 = "https://dyn.dns.he.net/nic/update";
3808
+ henetProvider = {
3809
+ id: "henet",
3810
+ label: "Hurricane Electric",
3811
+ docsUrl: "https://dns.he.net/docs.html",
3812
+ families: ["A", "AAAA"],
3813
+ ttl: false,
3814
+ proxied: false,
3815
+ fields: [{
3816
+ key: "key",
3817
+ label: "DDNS key",
3818
+ hint: "dns.he.net → the key shown next to the host when DDNS is enabled.",
3819
+ optional: false
3820
+ }],
3821
+ validate(credentials) {
3822
+ return missingFields(credentials, ["key"]);
3823
+ },
3824
+ async update(record, context) {
3825
+ const key = (context.credentials.key ?? "").trim();
3826
+ if (key.length === 0) return failed("hurricane electric needs the per-host DDNS key");
3827
+ const url = `${ENDPOINT$2}?hostname=${encodeURIComponent(record.host)}&password=${encodeURIComponent(key)}&myip=${encodeURIComponent(record.ip)}`;
3828
+ try {
3829
+ const response = await context.fetch(url, { signal: context.signal });
3830
+ return describeDyndnsResponse(response, await response.text(), "hurricane electric");
3831
+ } catch (error) {
3832
+ return failed(error instanceof Error ? error.message : String(error));
3833
+ }
3834
+ }
3835
+ };
3836
+ }));
3837
+ //#endregion
3838
+ //#region src/providers/ddns/namecheap.ts
3839
+ function tag(body, name) {
3840
+ return new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "i").exec(body)?.[1]?.trim() ?? null;
3841
+ }
3842
+ var ENDPOINT$1, namecheapProvider;
3843
+ var init_namecheap = __esmMin((() => {
3844
+ init_types();
3845
+ init_zone();
3846
+ ENDPOINT$1 = "https://dynamicdns.park-your-domain.com/update";
3847
+ namecheapProvider = {
3848
+ id: "namecheap",
3849
+ label: "Namecheap",
3850
+ docsUrl: "https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-https-request/",
3851
+ families: ["A"],
3852
+ ttl: false,
3853
+ proxied: false,
3854
+ fields: [{
3855
+ key: "password",
3856
+ label: "Dynamic DNS password",
3857
+ hint: "Dashboard → Manage domain → Advanced DNS → Dynamic DNS. Not your account password or API key.",
3858
+ optional: false
3859
+ }],
3860
+ validate(credentials) {
3861
+ return missingFields(credentials, ["password"]);
3862
+ },
3863
+ async update(record, context) {
3864
+ const password = (context.credentials.password ?? "").trim();
3865
+ if (password.length === 0) return failed("namecheap needs the Dynamic DNS password");
3866
+ const { zone, name } = splitHost(record.host, record.zone);
3867
+ const host = name === "@" ? "@" : name;
3868
+ const url = `${ENDPOINT$1}?host=${encodeURIComponent(host)}&domain=${encodeURIComponent(zone)}&password=${encodeURIComponent(password)}&ip=${encodeURIComponent(record.ip)}`;
3869
+ try {
3870
+ const body = await (await context.fetch(url, { signal: context.signal })).text();
3871
+ const errCount = Number.parseInt(tag(body, "ErrCount") ?? "0", 10);
3872
+ const ip = tag(body, "IP");
3873
+ if (errCount > 0) {
3874
+ const first = tag(body, "Err1");
3875
+ return failed(`namecheap rejected the update${first === null ? "" : `: ${first}`}`);
3876
+ }
3877
+ if (ip === null) return failed(`namecheap answered without an IP: ${body.replace(/\s+/g, " ").slice(0, 160)}`);
3878
+ return succeeded(`${host === "@" ? zone : `${host}.${zone}`} now ${ip}`, ip !== record.ip);
3879
+ } catch (error) {
3880
+ return failed(error instanceof Error ? error.message : String(error));
3881
+ }
3882
+ }
3883
+ };
3884
+ }));
3885
+ //#endregion
3886
+ //#region src/providers/ddns/noip.ts
3887
+ var ENDPOINT, DEFAULT_AGENT, noipProvider;
3888
+ var init_noip = __esmMin((() => {
3889
+ init_dyndns2();
3890
+ init_types();
3891
+ ENDPOINT = "https://dynupdate.no-ip.com/nic/update";
3892
+ DEFAULT_AGENT = "home-hosted/1.0 (+https://github.com/NamesMT/home-hosted)";
3893
+ noipProvider = {
3894
+ id: "noip",
3895
+ label: "No-IP",
3896
+ docsUrl: "https://www.noip.com/integrate/request",
3897
+ families: ["A", "AAAA"],
3898
+ ttl: false,
3899
+ proxied: false,
3900
+ fields: [
3901
+ {
3902
+ key: "username",
3903
+ label: "Email",
3904
+ hint: "Your No-IP account email.",
3905
+ optional: false
3906
+ },
3907
+ {
3908
+ key: "password",
3909
+ label: "Password",
3910
+ hint: "Your No-IP account password.",
3911
+ optional: false
3912
+ },
3913
+ {
3914
+ key: "userAgent",
3915
+ label: "User-Agent override",
3916
+ hint: "No-IP only guarantees updates for recognised clients; set an approved agent here if yours is registered.",
3917
+ optional: true
3918
+ }
3919
+ ],
3920
+ validate(credentials) {
3921
+ return missingFields(credentials, ["username", "password"]);
3922
+ },
3923
+ async update(record, context) {
3924
+ const username = (context.credentials.username ?? "").trim();
3925
+ const password = (context.credentials.password ?? "").trim();
3926
+ if (username.length === 0 || password.length === 0) return failed("no-ip needs the account email and password");
3927
+ const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
3928
+ const url = `${ENDPOINT}?hostname=${encodeURIComponent(record.host)}&${address}`;
3929
+ try {
3930
+ const response = await context.fetch(url, {
3931
+ headers: {
3932
+ "Authorization": basicAuth(username, password),
3933
+ "User-Agent": (context.credentials.userAgent ?? "").trim() || DEFAULT_AGENT
3934
+ },
3935
+ signal: context.signal
3936
+ });
3937
+ return describeDyndnsResponse(response, await response.text(), "no-ip");
3938
+ } catch (error) {
3939
+ return failed(error instanceof Error ? error.message : String(error));
3940
+ }
3941
+ }
3942
+ };
3943
+ }));
3944
+ //#endregion
3945
+ //#region src/providers/ddns/porkbun.ts
3946
+ /** Porkbun's documented floor; `1` (automatic) maps onto it. */
3947
+ function porkbunTtl(ttl) {
3948
+ return Math.max(600, ttl);
3949
+ }
3950
+ async function post(path, context, body) {
3951
+ try {
3952
+ const response = await context.fetch(`${API$1}${path}`, {
3953
+ method: "POST",
3954
+ headers: {
3955
+ "Content-Type": "application/json",
3956
+ "X-API-Key": context.credentials.apikey ?? "",
3957
+ "X-Secret-API-Key": context.credentials.secretapikey ?? ""
3958
+ },
3959
+ body: JSON.stringify(body),
3960
+ signal: context.signal
3961
+ });
3962
+ const parsed = JSON.parse(await response.text());
3963
+ if (parsed.status !== "SUCCESS") return {
3964
+ data: parsed,
3965
+ error: parsed.message ?? `HTTP ${response.status}`
3966
+ };
3967
+ return {
3968
+ data: parsed,
3969
+ error: null
3970
+ };
3971
+ } catch (error) {
3972
+ return {
3973
+ data: null,
3974
+ error: error instanceof Error ? error.message : String(error)
3975
+ };
3976
+ }
3977
+ }
3978
+ var API$1, porkbunProvider;
3979
+ var init_porkbun = __esmMin((() => {
3980
+ init_types();
3981
+ init_zone();
3982
+ API$1 = "https://api.porkbun.com/api/json/v3";
3983
+ porkbunProvider = {
3984
+ id: "porkbun",
3985
+ label: "Porkbun",
3986
+ docsUrl: "https://porkbun.com/api/json/v3/documentation",
3987
+ families: ["A", "AAAA"],
3988
+ ttl: true,
3989
+ proxied: false,
3990
+ fields: [{
3991
+ key: "apikey",
3992
+ label: "API key",
3993
+ hint: "porkbun.com/account/api — starts with pk1_.",
3994
+ optional: false
3995
+ }, {
3996
+ key: "secretapikey",
3997
+ label: "Secret API key",
3998
+ hint: "Shown once when the key is created.",
3999
+ optional: false
4000
+ }],
4001
+ validate(credentials) {
4002
+ return missingFields(credentials, ["apikey", "secretapikey"]);
4003
+ },
4004
+ async update(record, context) {
4005
+ const { zone, name: rawName } = splitHost(record.host, record.zone);
4006
+ const name = rawName === "@" ? "" : rawName;
4007
+ const ttl = porkbunTtl(record.ttl);
4008
+ const existing = (await post(`/dns/retrieveByNameType/${encodeURIComponent(zone)}/${record.type}/${encodeURIComponent(name)}`, context, {})).data?.records?.[0];
4009
+ if (existing !== void 0 && existing.content === record.ip && Number.parseInt(existing.ttl, 10) === ttl) return succeeded(`already ${record.ip}`, false);
4010
+ const written = await post(`/dns/editByNameType/${encodeURIComponent(zone)}/${record.type}/${encodeURIComponent(name)}`, context, {
4011
+ content: record.ip,
4012
+ ttl
4013
+ });
4014
+ if (written.error !== null) return failed(written.error);
4015
+ const warnings = written.data?.warnings ?? [];
4016
+ const note = warnings.length > 0 ? ` — porkbun warns: ${warnings.join("; ")}` : "";
4017
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}${note}`, true);
4018
+ }
4019
+ };
4020
+ }));
4021
+ //#endregion
4022
+ //#region src/providers/ddns/spaceship.ts
4023
+ function spaceshipTtl(ttl) {
4024
+ return Math.min(3600, Math.max(60, ttl));
4025
+ }
4026
+ var API, PAGE_SIZE, spaceshipProvider;
4027
+ var init_spaceship = __esmMin((() => {
4028
+ init_types();
4029
+ init_zone();
4030
+ API = "https://spaceship.dev/api/v1/dns/records";
4031
+ PAGE_SIZE = 500;
4032
+ spaceshipProvider = {
4033
+ id: "spaceship",
4034
+ label: "Spaceship",
4035
+ docsUrl: "https://docs.spaceship.dev/",
4036
+ families: ["A", "AAAA"],
4037
+ ttl: true,
4038
+ proxied: false,
4039
+ fields: [{
4040
+ key: "apiKey",
4041
+ label: "API key",
4042
+ hint: "spaceship.com → API Manager; needs the `dnsrecords:read`/`write` scopes.",
4043
+ optional: false
4044
+ }, {
4045
+ key: "apiSecret",
4046
+ label: "API secret",
4047
+ hint: "Shown once with the key.",
4048
+ optional: false
4049
+ }],
4050
+ validate(credentials) {
4051
+ return missingFields(credentials, ["apiKey", "apiSecret"]);
4052
+ },
4053
+ async update(record, context) {
4054
+ const { zone, name } = splitHost(record.host, record.zone);
4055
+ const headers = {
4056
+ "X-API-Key": (context.credentials.apiKey ?? "").trim(),
4057
+ "X-API-Secret": (context.credentials.apiSecret ?? "").trim(),
4058
+ "Content-Type": "application/json"
4059
+ };
4060
+ try {
4061
+ let found;
4062
+ for (let skip = 0; skip < PAGE_SIZE * 5; skip += PAGE_SIZE) {
4063
+ const listed = await context.fetch(`${API}/${encodeURIComponent(zone)}?take=${PAGE_SIZE}&skip=${skip}`, {
4064
+ headers,
4065
+ signal: context.signal
4066
+ });
4067
+ if (!listed.ok) return failed(await errorText(listed));
4068
+ const body = await listed.json();
4069
+ const items = Array.isArray(body) ? body : body.items ?? [];
4070
+ found = items.find((entry) => entry.name === name && entry.type === record.type);
4071
+ if (found !== void 0 || items.length < PAGE_SIZE) break;
4072
+ }
4073
+ if (found?.address === record.ip) return succeeded(`already ${record.ip}`, false);
4074
+ const written = await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
4075
+ method: "PUT",
4076
+ headers,
4077
+ body: JSON.stringify({
4078
+ force: true,
4079
+ items: [{
4080
+ type: record.type,
4081
+ name,
4082
+ ttl: spaceshipTtl(record.ttl),
4083
+ address: record.ip
4084
+ }]
4085
+ }),
4086
+ signal: context.signal
4087
+ });
4088
+ if (!written.ok) return failed(await errorText(written));
4089
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4090
+ } catch (error) {
4091
+ return failed(error instanceof Error ? error.message : String(error));
4092
+ }
4093
+ }
4094
+ };
4095
+ }));
4096
+ //#endregion
4097
+ //#region src/providers/ddns/index.ts
4098
+ function ddnsProvider(id) {
4099
+ return BY_ID.get(id) ?? null;
4100
+ }
4101
+ function ddnsProviderInfos() {
4102
+ return DDNS_PROVIDERS.map((provider) => ({
4103
+ id: provider.id,
4104
+ label: provider.label,
4105
+ docsUrl: provider.docsUrl,
4106
+ families: provider.families,
4107
+ fields: provider.fields,
4108
+ ttl: provider.ttl,
4109
+ proxied: provider.proxied
4110
+ }));
4111
+ }
4112
+ /**
4113
+ * Cross-field problems ArkType cannot see. Credentials are deliberately not
4114
+ * required here: an account is normally added before its secret is typed in,
4115
+ * and a missing secret is reported per record instead of blocking the save.
4116
+ */
4117
+ function validateDdnsConfig(config) {
4118
+ const errors = [];
4119
+ const seenAccounts = /* @__PURE__ */ new Set();
4120
+ for (const account of config.accounts) {
4121
+ if (seenAccounts.has(account.id)) {
4122
+ errors.push(`account "${account.id}" is declared twice`);
4123
+ continue;
4124
+ }
4125
+ seenAccounts.add(account.id);
4126
+ if (ddnsProvider(account.provider) === null) errors.push(`account "${account.id}" uses an unknown provider "${account.provider}"`);
4127
+ }
4128
+ const seenRecords = /* @__PURE__ */ new Set();
4129
+ for (const domain of config.domains) {
4130
+ const account = config.accounts.find((entry) => entry.id === domain.account);
4131
+ if (account === void 0) {
4132
+ errors.push(`"${domain.host}" points at unknown account "${domain.account}"`);
4133
+ continue;
4134
+ }
4135
+ const provider = ddnsProvider(account.provider);
4136
+ if (provider !== null) for (const recordType of domain.types) {
4137
+ if (!provider.families.includes(recordType)) errors.push(`${account.label || provider.label} cannot manage ${recordType} records ("${domain.host}")`);
4138
+ const key = `${domain.host.toLowerCase()}|${recordType}`;
4139
+ if (seenRecords.has(key)) errors.push(`"${domain.host}" asks for ${recordType} twice`);
4140
+ seenRecords.add(key);
4141
+ }
4142
+ }
4143
+ return errors;
4144
+ }
4145
+ var DDNS_PROVIDERS, BY_ID;
4146
+ var init_ddns$2 = __esmMin((() => {
4147
+ init_cloudflare();
4148
+ init_desec();
4149
+ init_digitalocean();
4150
+ init_duckdns();
4151
+ init_dynu();
4152
+ init_freedns();
4153
+ init_gandi();
4154
+ init_godaddy();
4155
+ init_henet();
4156
+ init_namecheap();
4157
+ init_noip();
4158
+ init_porkbun();
4159
+ init_spaceship();
4160
+ DDNS_PROVIDERS = [
4161
+ cloudflareProvider,
4162
+ namecheapProvider,
4163
+ spaceshipProvider,
4164
+ porkbunProvider,
4165
+ godaddyProvider,
4166
+ gandiProvider,
4167
+ digitaloceanProvider,
4168
+ desecProvider,
4169
+ duckdnsProvider,
4170
+ noipProvider,
4171
+ dynuProvider,
4172
+ henetProvider,
4173
+ freednsProvider
4174
+ ];
4175
+ BY_ID = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
4176
+ }));
4177
+ //#endregion
4178
+ //#region src/api/ddns.ts
4179
+ /**
4180
+ * Dynamic DNS is configured as one whole block, not as settings patches: its
4181
+ * accounts and hostnames are lists, and a key-by-key merge would leave a removed
4182
+ * account or hostname behind. Provider credentials never come back over the API —
4183
+ * the view only says which accounts have them.
4184
+ */
4185
+ function createDdnsRoute(deps) {
4186
+ const view = () => ({
4187
+ config: deps.store.config.ddns,
4188
+ status: deps.ddns.view,
4189
+ providers: ddnsProviderInfos(),
4190
+ credentials: deps.secrets.ddnsAccountIds
4191
+ });
4192
+ return appFactory.createApp().get("/ddns", describeRoute({
4193
+ tags: ["ddns"],
4194
+ summary: "Dynamic DNS policy, live state and the providers this build knows",
4195
+ responses: { 200: {
4196
+ description: "The DDNS view",
4197
+ content: jsonBody(ddnsViewSchema)
4198
+ } }
4199
+ }), (c) => c.json(view())).put("/ddns", describeRoute({
4200
+ tags: ["ddns"],
4201
+ summary: "Replace the dynamic DNS block",
4202
+ responses: {
4203
+ 200: {
4204
+ description: "Saved",
4205
+ content: jsonBody(ddnsViewSchema)
4206
+ },
4207
+ 400: ERROR_RESPONSES[400]
4208
+ }
4209
+ }), validate("json", ddnsConfigSchema), (c) => {
4210
+ const config = c.req.valid("json");
4211
+ const problems = validateDdnsConfig(config);
4212
+ if (problems.length > 0) throw new DetailedError(`invalid DDNS configuration: ${problems.join("; ")}`, {
4213
+ statusCode: 400,
4214
+ code: "INVALID_DDNS",
4215
+ detail: problems
4216
+ });
4217
+ try {
4218
+ deps.store.updateDdns(config);
4219
+ } catch (error) {
4220
+ if (error instanceof ConfigError) throw new DetailedError(error.message, {
4221
+ statusCode: 400,
4222
+ code: "INVALID_DDNS"
4223
+ });
4224
+ throw error;
4225
+ }
4226
+ deps.ddns.refresh();
4227
+ return c.json(view());
4228
+ }).put("/ddns/credentials/:id", describeRoute({
4229
+ tags: ["ddns"],
4230
+ summary: "Store the credentials of one DDNS account",
4231
+ responses: {
4232
+ 200: {
4233
+ description: "Stored",
4234
+ content: jsonBody(ddnsViewSchema)
4235
+ },
4236
+ 400: ERROR_RESPONSES[400]
4237
+ }
4238
+ }), validate("param", accountParam), validate("json", ddnsCredentialsSchema), (c) => {
4239
+ const { id } = c.req.valid("param");
4240
+ const account = deps.store.config.ddns.accounts.find((entry) => entry.id === id);
4241
+ if (account === void 0) throw new DetailedError(`unknown DDNS account "${id}"`, {
4242
+ statusCode: 400,
4243
+ code: "UNKNOWN_DDNS_ACCOUNT"
4244
+ });
4245
+ const provider = ddnsProvider(account.provider);
4246
+ if (provider === null) throw new DetailedError(`unknown DDNS provider "${account.provider}"`, {
4247
+ statusCode: 400,
4248
+ code: "UNKNOWN_DDNS_PROVIDER"
4249
+ });
4250
+ const { credentials } = c.req.valid("json");
4251
+ const problem = provider.validate(credentials);
4252
+ if (problem !== null) throw new DetailedError(`${provider.label}: ${problem}`, {
4253
+ statusCode: 400,
4254
+ code: "INVALID_DDNS_CREDENTIALS"
4255
+ });
4256
+ deps.secrets.setDdnsCredentials(id, credentials);
4257
+ deps.ddns.refresh();
4258
+ return c.json(view());
4259
+ }).delete("/ddns/credentials/:id", describeRoute({
4260
+ tags: ["ddns"],
4261
+ summary: "Forget the credentials of one DDNS account",
4262
+ responses: { 200: {
4263
+ description: "Removed",
4264
+ content: jsonBody(ddnsViewSchema)
4265
+ } }
4266
+ }), validate("param", accountParam), (c) => {
4267
+ deps.secrets.clearDdnsCredentials(c.req.valid("param").id);
4268
+ deps.ddns.refresh();
4269
+ return c.json(view());
4270
+ }).post("/ddns/check", describeRoute({
4271
+ tags: ["ddns"],
4272
+ summary: "Run a dynamic DNS pass now",
4273
+ responses: { 200: {
4274
+ description: "The pass, with its result",
4275
+ content: jsonBody(ddnsViewSchema)
4276
+ } }
4277
+ }), async (c) => {
4278
+ await deps.ddns.run({ force: true });
4279
+ return c.json(view());
4280
+ });
4281
+ }
4282
+ var accountParam;
4283
+ var init_ddns$1 = __esmMin((() => {
4284
+ init_store();
4285
+ init_factory();
4286
+ init_openapi$1();
4287
+ init_validator();
4288
+ init_ddns$2();
4289
+ init_contracts();
4290
+ accountParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
4291
+ }));
4292
+ //#endregion
4293
+ //#region src/api/events.ts
4294
+ /**
4295
+ * One SSE stream per subscriber. Pass `?serverId=<id>` to receive only that
4296
+ * server's messages; the first frame always carries the full state snapshot.
4297
+ */
4298
+ function createEventsRoute(deps) {
4299
+ return appFactory.createApp().get("/events", describeRoute({
4300
+ tags: ["panel"],
4301
+ summary: "Panel state and log frames as server-sent events",
4302
+ responses: { 200: { description: "text/event-stream" } }
4303
+ }), validate("query", eventsQuery), (c) => {
4304
+ const query = c.req.valid("query");
4305
+ const serverId = query.serverId ?? null;
4306
+ const logOnly = query.logs !== "0";
4307
+ return streamSSE(c, async (stream) => {
4308
+ let pending = 0;
4309
+ let queue = Promise.resolve();
4310
+ let closed = false;
4311
+ const send = (message) => {
4312
+ if (closed) return queue;
4313
+ if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
4314
+ pending += 1;
4315
+ queue = queue.then(() => stream.writeSSE({
4316
+ event: message.type,
4317
+ data: JSON.stringify(message)
4318
+ })).catch(() => {
4319
+ closed = true;
4320
+ }).finally(() => {
4321
+ pending -= 1;
4322
+ });
4323
+ return queue;
4324
+ };
4325
+ const unsubscribe = deps.hub.subscribe(serverId, (message) => {
4326
+ if (!logOnly && message.type === "log") return;
4327
+ send(message);
4328
+ });
4329
+ stream.onAbort(() => {
4330
+ closed = true;
4331
+ unsubscribe();
4332
+ });
4333
+ await send({
4334
+ type: "hello",
4335
+ ts: Date.now(),
4336
+ state: deps.supervisor.getState()
4337
+ });
4338
+ while (true) {
4339
+ await stream.sleep(PING_INTERVAL_MS);
4340
+ if (closed) break;
4341
+ await stream.writeSSE({
4342
+ event: "ping",
4343
+ data: String(Date.now())
4344
+ });
4345
+ }
4346
+ });
4347
+ });
4348
+ }
4349
+ var MAX_PENDING_WRITES$1, PING_INTERVAL_MS, eventsQuery;
4350
+ var init_events$1 = __esmMin((() => {
4351
+ init_factory();
4352
+ init_validator();
4353
+ MAX_PENDING_WRITES$1 = 200;
4354
+ PING_INTERVAL_MS = 15e3;
4355
+ eventsQuery = type({
4356
+ /** Only this server's frames. */
4357
+ "serverId?": "string",
4358
+ /** `logs=0` drops log frames; the first frame is always the full state. */
4359
+ "logs?": "string"
4360
+ });
4361
+ }));
4362
+ //#endregion
4363
+ //#region src/api/health.ts
4364
+ /**
4365
+ * Liveness for external monitors. Mounted outside `/api`, so it answers without a
4366
+ * session: it reports whether the panel itself is serving, and 503 when an
4367
+ * autostart server has crashed. Server details are only included for an
4368
+ * authenticated caller.
4369
+ */
4370
+ function createHealthRoute(deps) {
4371
+ return appFactory.createApp().get("/healthz", describeRoute({
4372
+ tags: ["panel"],
4373
+ summary: "Liveness for monitors — no session required",
4374
+ responses: {
4375
+ 200: {
4376
+ description: "Serving",
4377
+ content: jsonBody(type({
4378
+ "status": "\"ok\" | \"degraded\"",
4379
+ "uptimeMs": "number",
4380
+ "servers?": type({
4381
+ total: "number",
4382
+ running: "number",
4383
+ crashed: "number",
4384
+ unhealthy: "number"
4385
+ }),
4386
+ "hostAlerts?": "string[]"
4387
+ }))
4388
+ },
4389
+ 503: { description: "An autostart server has crashed" }
4390
+ }
4391
+ }), (c) => {
4392
+ const state = deps.supervisor.getState();
4393
+ const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
4394
+ const authenticated = requestIdentity(c, deps.auth).authenticated;
4395
+ return c.json({
4396
+ status: broken.length > 0 ? "degraded" : "ok",
4397
+ uptimeMs: Math.round(process.uptime() * 1e3),
4398
+ ...authenticated ? {
4399
+ servers: {
4400
+ total: state.servers.length,
4401
+ running: state.servers.filter((server) => server.status === "running").length,
4402
+ crashed: state.servers.filter((server) => server.status === "crashed").length,
4403
+ unhealthy: state.servers.filter((server) => server.health === "unhealthy").length
4404
+ },
4405
+ hostAlerts: state.host.alerts
4406
+ } : {}
4407
+ }, broken.length > 0 ? 503 : 200);
4408
+ });
4409
+ }
4410
+ var init_health = __esmMin((() => {
4411
+ init_factory();
4412
+ init_openapi$1();
4413
+ init_auth();
4414
+ }));
4415
+ //#endregion
4416
+ //#region src/api/logs.ts
4417
+ function unknownServer$1(id) {
4418
+ return new DetailedError(`unknown server "${id}"`, {
4419
+ statusCode: 404,
4420
+ code: "UNKNOWN_SERVER"
4421
+ });
4422
+ }
4423
+ function createLogsRoute(deps) {
4424
+ return appFactory.createApp().get("/logs", describeRoute({
4425
+ tags: ["logs"],
4426
+ summary: "Every server with its on-disk log files",
4427
+ responses: { 200: {
4428
+ description: "Log sources",
4429
+ content: jsonBody(logServersViewSchema)
4430
+ } }
4431
+ }), (c) => c.json({ servers: deps.supervisor.views().map((server) => ({
4432
+ serverId: server.id,
4433
+ label: server.config.label ?? server.id,
4434
+ status: server.status,
4435
+ ...deps.logFiles.info(server.id)
4436
+ })) })).get("/logs/:id", describeRoute({
4437
+ tags: ["logs"],
4438
+ summary: "Persisted log lines, with search and a stream filter",
4439
+ responses: {
4440
+ 200: { description: "Lines" },
4441
+ 404: ERROR_RESPONSES[404]
4442
+ }
4443
+ }), validate("param", idParam$1), validate("query", logHistoryQuerySchema), (c) => {
4444
+ const { id } = c.req.valid("param");
4445
+ if (!deps.store.getServer(id)) throw unknownServer$1(id);
4446
+ const query = c.req.valid("query");
4447
+ const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
4448
+ const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
4449
+ const info = deps.logFiles.info(id);
4450
+ const search = query.search?.trim() ?? "";
4451
+ const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
4452
+ let lines = deps.logFiles.readTail(id, window);
4453
+ if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
4454
+ if (search.length > 0) {
4455
+ const needle = search.toLowerCase();
4456
+ lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
4457
+ }
4458
+ return c.json({
4459
+ serverId: id,
4460
+ enabled: info.enabled,
4461
+ sizeBytes: info.sizeBytes,
4462
+ files: info.files.map((file) => file.name),
4463
+ searched: search.length > 0 ? window : null,
4464
+ lines: lines.slice(-tail)
4465
+ });
4466
+ }).get("/logs/:id/download", describeRoute({
4467
+ tags: ["logs"],
4468
+ summary: "Download one rotated log file",
4469
+ responses: {
4470
+ 200: { description: "application/x-ndjson" },
4471
+ 404: ERROR_RESPONSES[404]
4472
+ }
4473
+ }), validate("param", idParam$1), validate("query", downloadQuery), (c) => {
4474
+ const { id } = c.req.valid("param");
4475
+ if (!deps.store.getServer(id)) throw unknownServer$1(id);
4476
+ const requested = c.req.valid("query").file ?? `${id}.log`;
4477
+ if (!deps.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
4478
+ statusCode: 404,
4479
+ code: "UNKNOWN_LOG_FILE"
4480
+ });
4481
+ const file = path.join(deps.logFiles.directory, requested);
4482
+ const body = fs.readFileSync(file);
4483
+ return c.body(body, 200, {
4484
+ "Content-Type": "application/x-ndjson; charset=utf-8",
4485
+ "Content-Length": String(body.byteLength),
4486
+ "Content-Disposition": `attachment; filename="${requested}"`
4487
+ });
4488
+ }).delete("/logs/:id", describeRoute({
4489
+ tags: ["logs"],
4490
+ summary: "Delete the persisted logs of one server",
4491
+ responses: {
4492
+ 200: { description: "Cleared" },
4493
+ 404: ERROR_RESPONSES[404]
4494
+ }
4495
+ }), validate("param", idParam$1), (c) => {
4496
+ const { id } = c.req.valid("param");
4497
+ if (!deps.store.getServer(id)) throw unknownServer$1(id);
4498
+ deps.logFiles.clear(id);
4499
+ return c.json({ ok: true });
4500
+ });
4501
+ }
4502
+ var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$1, downloadQuery;
4503
+ var init_logs = __esmMin((() => {
4504
+ init_factory();
4505
+ init_openapi$1();
4506
+ init_validator();
4507
+ init_contracts();
4508
+ MIN_TAIL = 50;
4509
+ MAX_TAIL = 5e3;
4510
+ DEFAULT_TAIL = 500;
4511
+ idParam$1 = type({ id: "string >= 1" });
4512
+ downloadQuery = type({ "file?": "string" });
4513
+ }));
4514
+ //#endregion
4515
+ //#region src/api/metrics.ts
4516
+ /**
4517
+ * Prometheus text for whatever wants to scrape the panel (Beszel, Grafana,
4518
+ * `curl`). Lives under `/api`, so it needs a session like every other route.
4519
+ */
4520
+ function createMetricsRoute(deps) {
4521
+ return appFactory.createApp().get("/metrics", describeRoute({
4522
+ tags: ["panel"],
4523
+ summary: "Prometheus text for whatever scrapes the panel",
4524
+ responses: { 200: { description: "text/plain; version=0.0.4" } }
4525
+ }), (c) => {
4526
+ const state = deps.supervisor.getState();
4527
+ const lines = [];
4528
+ const metric = (name, help, samples) => {
4529
+ if (samples.length === 0) return;
4530
+ lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
4531
+ };
4532
+ metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
4533
+ metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
4534
+ metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
4535
+ metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
4536
+ metric("hh_server_crashes_24h", "Crashes in the last 24 hours", state.servers.map((server) => `hh_server_crashes_24h{server="${server.id}"} ${server.history.crashes}`));
4537
+ metric("hh_server_uptime_ratio_24h", "Share of the last 24 hours the server was up", state.servers.filter((server) => server.history.uptimeRatio !== null).map((server) => `hh_server_uptime_ratio_24h{server="${server.id}"} ${server.history.uptimeRatio.toFixed(4)}`));
4538
+ metric("hh_server_response_ms", "Last health probe latency in milliseconds", state.servers.filter((server) => server.responseMs !== null).map((server) => `hh_server_response_ms{server="${server.id}"} ${server.responseMs}`));
4539
+ metric("hh_server_rss_bytes", "RSS of the server process tree", state.servers.filter((server) => server.resources?.rssBytes != null).map((server) => `hh_server_rss_bytes{server="${server.id}"} ${server.resources.rssBytes}`));
4540
+ metric("hh_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
4541
+ metric("hh_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
4542
+ metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
4543
+ metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
4544
+ metric("hh_host_load1_per_cpu", "1 minute load average per cpu", [`hh_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
4545
+ return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
4546
+ });
4547
+ }
4548
+ var init_metrics = __esmMin((() => {
4549
+ init_factory();
4550
+ }));
4551
+ //#endregion
4552
+ //#region src/providers/telegram.ts
4553
+ function getBot(token) {
4554
+ const cached = bots.get(token);
4555
+ if (cached) return cached;
4556
+ const bot = new Bot(token, { client: { timeoutSeconds: 10 } });
4557
+ bot.api.config.use(autoRetry({
4558
+ maxRetryAttempts: 3,
4559
+ maxDelaySeconds: 20
4560
+ }));
4561
+ bots.set(token, bot);
4562
+ return bot;
4563
+ }
4564
+ /** Drops cached clients; used when the token changes or the service shuts down. */
4565
+ function forgetBots() {
4566
+ bots.clear();
4567
+ }
4568
+ function escapeHtml(value) {
4569
+ return value.replace(/&/g, "&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]
3222
4666
  }
3223
- apply(raw) {
3224
- this.raw = raw;
3225
- const parsed = parseConfig(raw);
3226
- this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
3227
- this.schemaVersion = parsed.schemaVersion;
3228
- 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(", ")}`]];
3229
- if (parsed.config !== null) this.resolvedConfig = parsed.config;
3230
- else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
4667
+ }), validate("json", telegramTokenSchema), async (c) => {
4668
+ const { botToken } = c.req.valid("json");
4669
+ const verified = await deps.notifications.verifyToken(botToken);
4670
+ if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
4671
+ statusCode: 400,
4672
+ code: "TELEGRAM_TOKEN_REJECTED"
4673
+ });
4674
+ deps.secrets.setTelegramToken(botToken);
4675
+ forgetBots();
4676
+ return c.json({
4677
+ ok: true,
4678
+ username: verified.username ?? null
4679
+ });
4680
+ }).delete("/notifications/token", describeRoute({
4681
+ tags: ["notifications"],
4682
+ summary: "Forget the Telegram bot token",
4683
+ responses: { 200: { description: "Removed" } }
4684
+ }), (c) => {
4685
+ deps.secrets.setTelegramToken(null);
4686
+ forgetBots();
4687
+ return c.json({ ok: true });
4688
+ }).post("/notifications/test", describeRoute({
4689
+ tags: ["notifications"],
4690
+ summary: "Send a test message",
4691
+ responses: {
4692
+ 200: { description: "Sent or refused" },
4693
+ 400: ERROR_RESPONSES[400]
3231
4694
  }
3232
- };
4695
+ }), validate("json", notificationActionSchema), async (c) => {
4696
+ const result = await deps.notifications.sendTest(c.req.valid("json"));
4697
+ if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
4698
+ statusCode: 400,
4699
+ code: "TELEGRAM_SEND_FAILED"
4700
+ });
4701
+ return c.json(result);
4702
+ }).post("/notifications/detect-chats", describeRoute({
4703
+ tags: ["notifications"],
4704
+ summary: "List the chats the bot can see",
4705
+ responses: {
4706
+ 200: {
4707
+ description: "Chats",
4708
+ content: jsonBody(type({ chats: type({
4709
+ id: "string | number",
4710
+ title: "string"
4711
+ }).array() }))
4712
+ },
4713
+ 400: ERROR_RESPONSES[400]
4714
+ }
4715
+ }), validate("json", notificationActionSchema), async (c) => {
4716
+ const result = await deps.notifications.detectChats(c.req.valid("json"));
4717
+ if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
4718
+ statusCode: 400,
4719
+ code: "TELEGRAM_LIST_FAILED"
4720
+ });
4721
+ return c.json({ chats: result.chats });
4722
+ });
4723
+ }
4724
+ var init_notifications$1 = __esmMin((() => {
4725
+ init_factory();
4726
+ init_openapi$1();
4727
+ init_validator();
4728
+ init_telegram();
4729
+ init_contracts();
3233
4730
  }));
3234
4731
  //#endregion
3235
4732
  //#region src/api/servers/$.routes.ts
3236
- /** Unknown ids are 404; a server that exists but cannot start is a 409. */
3237
- function statusFor(result) {
3238
- if (result.ok) return 200;
3239
- return result.error?.startsWith("unknown server") ? 404 : 409;
3240
- }
3241
4733
  function unknownServer(id) {
3242
4734
  return new DetailedError(`unknown server "${id}"`, {
3243
4735
  statusCode: 404,
@@ -3387,7 +4879,7 @@ function createServersRoute(deps) {
3387
4879
  }
3388
4880
  }), validate("param", idParam), async (c) => {
3389
4881
  const result = await deps.supervisor.start(c.req.valid("param").id);
3390
- return c.json(result, statusFor(result));
4882
+ return c.json(result, statusForAction(result));
3391
4883
  }).post("/:id/stop", describeRoute({
3392
4884
  tags: ["servers"],
3393
4885
  summary: "Stop a server",
@@ -3407,7 +4899,7 @@ function createServersRoute(deps) {
3407
4899
  }
3408
4900
  }), validate("param", idParam), async (c) => {
3409
4901
  const result = await deps.supervisor.restart(c.req.valid("param").id);
3410
- return c.json(result, statusFor(result));
4902
+ return c.json(result, statusForAction(result));
3411
4903
  }).post("/:id/clear-logs", describeRoute({
3412
4904
  tags: ["servers"],
3413
4905
  summary: "Forget the buffered log lines",
@@ -3433,7 +4925,7 @@ function createServersRoute(deps) {
3433
4925
  const { id } = c.req.valid("param");
3434
4926
  const result = await deps.supervisor.freePort(id);
3435
4927
  if (!result.ok) throw new DetailedError(result.error ?? `could not free the port for "${id}"`, {
3436
- statusCode: statusFor(result),
4928
+ statusCode: statusForAction(result),
3437
4929
  code: "FREE_PORT_FAILED"
3438
4930
  });
3439
4931
  return c.json(result);
@@ -3486,6 +4978,7 @@ function createServersRoute(deps) {
3486
4978
  var idParam, MAX_PENDING_WRITES, serverResponse, serversResponse, okResponse;
3487
4979
  var init_$_routes = __esmMin((() => {
3488
4980
  init_store();
4981
+ init_action_result();
3489
4982
  init_factory();
3490
4983
  init_openapi$1();
3491
4984
  init_validator();
@@ -3937,13 +5430,14 @@ function createRootApp(deps) {
3937
5430
  const started = Date.now();
3938
5431
  await next();
3939
5432
  logger.debug(`${c.req.method} ${new URL(c.req.url).pathname} ${c.res.status} ${Date.now() - started}ms`);
3940
- }).onError(errorHandler).route("/_hh", createControlRoute(deps)).use("/api/*", createAuthGuard({ auth: deps.auth })).route("/api", createAuthRoute(deps)).route("/api", createStateRoute(deps)).route("/api", createEventsRoute(deps)).route("/api", createSettingsRoute(deps)).route("/api", createTlsRoute(deps)).route("/api", createLogsRoute(deps)).route("/api", createNotificationsRoute(deps)).route("/api", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
5433
+ }).onError(errorHandler).route("/_hh", createControlRoute(deps)).use("/api/*", createAuthGuard({ auth: deps.auth })).route("/api", createAuthRoute(deps)).route("/api", createStateRoute(deps)).route("/api", createEventsRoute(deps)).route("/api", createSettingsRoute(deps)).route("/api", createTlsRoute(deps)).route("/api", createLogsRoute(deps)).route("/api", createNotificationsRoute(deps)).route("/api", createDdnsRoute(deps)).route("/api", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
3941
5434
  return app.route("/", setupOpenAPI(app)).route("/", createStaticRoute({ dir: () => deps.ui.resolveDir() }));
3942
5435
  }
3943
5436
  var init_app = __esmMin((() => {
3944
5437
  init_$_routes$1();
3945
5438
  init_backups$1();
3946
5439
  init_control();
5440
+ init_ddns$1();
3947
5441
  init_events$1();
3948
5442
  init_health();
3949
5443
  init_logs();
@@ -3968,6 +5462,7 @@ var daemon_exports = /* @__PURE__ */ __exportAll({
3968
5462
  newToken: () => newToken,
3969
5463
  probeRuntime: () => probeRuntime,
3970
5464
  readRuntime: () => readRuntime,
5465
+ requestControl: () => requestControl,
3971
5466
  requestShutdown: () => requestShutdown,
3972
5467
  runtimeSchema: () => runtimeSchema,
3973
5468
  writeRuntime: () => writeRuntime
@@ -4000,10 +5495,10 @@ function isProcessAlive$1(pid) {
4000
5495
  }
4001
5496
  /** A degraded panel is still answering: a 503 must not read as "not running". */
4002
5497
  async function probeRuntime(runtime, timeoutMs = 2500) {
4003
- const status = await localRequest(runtime, "/healthz", "GET", void 0, timeoutMs);
5498
+ const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
4004
5499
  return {
4005
- reachable: status !== null,
4006
- degraded: status === 503
5500
+ reachable: answer !== null,
5501
+ degraded: answer?.status === 503
4007
5502
  };
4008
5503
  }
4009
5504
  /**
@@ -4011,15 +5506,36 @@ async function probeRuntime(runtime, timeoutMs = 2500) {
4011
5506
  * shut down cleanly on every platform (a bare signal is not graceful on Windows).
4012
5507
  */
4013
5508
  async function requestShutdown(runtime, timeoutMs = 4e3) {
4014
- const status = await localRequest(runtime, "/_hh/shutdown", "POST", runtime.token, timeoutMs);
4015
- return status !== null && status >= 200 && status < 300;
5509
+ const answer = await localCall(runtime, "/_hh/shutdown", "POST", runtime.token, timeoutMs);
5510
+ return answer !== null && answer.status >= 200 && answer.status < 300;
5511
+ }
5512
+ /**
5513
+ * Drives a supervised server from this machine (`home-hosted start`/`stop`). The
5514
+ * token comes from run.json, so it needs no session, no password and no API token —
5515
+ * only the owner of that 0600 file, talking over loopback.
5516
+ *
5517
+ * The wait is generous on purpose: a start may have to run a bootstrap, which has a
5518
+ * 120s default timeout of its own. Past this it is a wedged panel, not a slow one.
5519
+ */
5520
+ async function requestControl(runtime, path, timeoutMs = 15e4) {
5521
+ const answer = await localCall(runtime, path, "POST", runtime.token, timeoutMs);
5522
+ if (answer === null) return null;
5523
+ let body = null;
5524
+ try {
5525
+ body = JSON.parse(answer.body);
5526
+ } catch {}
5527
+ return {
5528
+ status: answer.status,
5529
+ body
5530
+ };
4016
5531
  }
4017
5532
  /**
4018
5533
  * Talks to the panel over loopback. Node's `fetch` cannot be told to accept the
4019
5534
  * self-signed certificate an uploaded TLS pair usually is, which would break
4020
- * `status` and the graceful `down` — so this speaks http/https directly.
5535
+ * `status`, the graceful `down` and the local start/stop — so this speaks
5536
+ * http/https directly.
4021
5537
  */
4022
- function localRequest(runtime, path, method, token, timeoutMs) {
5538
+ function localCall(runtime, path, method, token, timeoutMs) {
4023
5539
  return new Promise((resolve) => {
4024
5540
  const url = new URL(`${runtime.probeUrl}${path}`);
4025
5541
  const secure = url.protocol === "https:";
@@ -4032,8 +5548,15 @@ function localRequest(runtime, path, method, token, timeoutMs) {
4032
5548
  headers: token === void 0 ? {} : { "x-home-hosted-token": token },
4033
5549
  timeout: timeoutMs
4034
5550
  }, (response) => {
4035
- response.resume();
4036
- response.once("end", () => resolve(response.statusCode ?? null));
5551
+ let body = "";
5552
+ response.setEncoding("utf8");
5553
+ response.on("data", (chunk) => {
5554
+ body += chunk;
5555
+ });
5556
+ response.once("end", () => resolve({
5557
+ status: response.statusCode ?? 0,
5558
+ body
5559
+ }));
4037
5560
  });
4038
5561
  request.once("error", () => resolve(null));
4039
5562
  request.once("timeout", () => {
@@ -5587,9 +7110,20 @@ var init_log_relay = __esmMin((() => {
5587
7110
  this.start();
5588
7111
  return history;
5589
7112
  }
7113
+ /**
7114
+ * Stops following and hands back whatever the file still held unread.
7115
+ *
7116
+ * A nanny writes its last lines and exits in the same breath, so the tailer can be
7117
+ * dropped between two polls with its final lines — the child's crash output — still
7118
+ * on disk. Losing them here is what made a persistent entry's crash silent in the
7119
+ * panel, so the caller gets them to ingest rather than an empty hand.
7120
+ */
5590
7121
  unfollow(serverId) {
7122
+ const current = this.followed.get(serverId);
7123
+ const rest = current === void 0 ? [] : current.tailer.read();
5591
7124
  this.followed.delete(serverId);
5592
7125
  if (this.followed.size === 0) this.stopTimer();
7126
+ return rest;
5593
7127
  }
5594
7128
  followedIds() {
5595
7129
  return [...this.followed.keys()];
@@ -5624,6 +7158,18 @@ var init_log_relay = __esmMin((() => {
5624
7158
  }));
5625
7159
  //#endregion
5626
7160
  //#region src/services/supervisor.ts
7161
+ /** One line's exact identity: a timestamp this panel minted, its stream and its text. */
7162
+ function echoedKey(line) {
7163
+ return `${line.ts}\u0000${line.stream}\u0000${line.text}`;
7164
+ }
7165
+ function rememberEchoedLine(set, line) {
7166
+ set.add(echoedKey(line));
7167
+ while (set.size > ECHOED_LINE_LIMIT) set.delete(set.values().next().value);
7168
+ }
7169
+ /** True when this line is this panel's own echo, so it is only ever reported once. */
7170
+ function forgetEchoedLine(set, line) {
7171
+ return set.delete(echoedKey(line));
7172
+ }
5627
7173
  function delay(ms) {
5628
7174
  return new Promise((resolve) => setTimeout(resolve, ms));
5629
7175
  }
@@ -5643,7 +7189,7 @@ function serverTemplateVars(config) {
5643
7189
  home: os.homedir()
5644
7190
  };
5645
7191
  }
5646
- var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, Supervisor;
7192
+ var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, ECHOED_LINE_LIMIT, Supervisor;
5647
7193
  var init_supervisor = __esmMin((() => {
5648
7194
  init_backoff();
5649
7195
  init_bind();
@@ -5668,6 +7214,7 @@ var init_supervisor = __esmMin((() => {
5668
7214
  PORT_RELEASE_RECHECK_MS = 300;
5669
7215
  RESOURCE_SAMPLE_INTERVAL_MS = 5e3;
5670
7216
  PERSISTENT_BACKFILL_LINES = 200;
7217
+ ECHOED_LINE_LIMIT = 100;
5671
7218
  Supervisor = class {
5672
7219
  store;
5673
7220
  hub;
@@ -6017,7 +7564,7 @@ var init_supervisor = __esmMin((() => {
6017
7564
  clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
6018
7565
  clearNannySpec(nannySpecPath(this.options.nannyDir, entry.config.id));
6019
7566
  } else this.log(entry, "system", `pid ${survivor} is still alive after the stop — its state file is kept`);
6020
- this.relay.unfollow(entry.config.id);
7567
+ this.drainRelay(entry);
6021
7568
  }
6022
7569
  entry.stopping = false;
6023
7570
  entry.child = null;
@@ -6059,6 +7606,7 @@ var init_supervisor = __esmMin((() => {
6059
7606
  stopping: false,
6060
7607
  bootstrapDone: !config.bootstrap,
6061
7608
  logs: new LogBuffer(config.logBufferLines),
7609
+ echoed: /* @__PURE__ */ new Set(),
6062
7610
  responseMs: null,
6063
7611
  resources: null,
6064
7612
  resourcesSampledAt: 0,
@@ -6078,8 +7626,8 @@ var init_supervisor = __esmMin((() => {
6078
7626
  continue;
6079
7627
  }
6080
7628
  const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
7629
+ if (entry.config.persistent && !config.persistent) this.drainRelay(entry);
6081
7630
  entry.config = config;
6082
- if (!config.persistent) this.relay.unfollow(id);
6083
7631
  if (bufferChanged) {
6084
7632
  const kept = entry.logs.list(config.logBufferLines);
6085
7633
  entry.logs = new LogBuffer(config.logBufferLines);
@@ -6239,8 +7787,8 @@ var init_supervisor = __esmMin((() => {
6239
7787
  entry.health = entry.config.health.enabled ? "unknown" : "disabled";
6240
7788
  entry.startedAt = Date.now();
6241
7789
  entry.lastError = null;
7790
+ if (entry.config.persistent) this.followPersistent(entry, true);
6242
7791
  this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
6243
- if (entry.config.persistent) this.followPersistent(entry);
6244
7792
  this.publishServer(entry);
6245
7793
  return { ok: true };
6246
7794
  }
@@ -6475,25 +8023,41 @@ var init_supervisor = __esmMin((() => {
6475
8023
  ingestExternalLines(serverId, lines) {
6476
8024
  const entry = this.entries.get(serverId);
6477
8025
  if (entry === void 0 || !entry.config.persistent || lines.length === 0) return;
6478
- 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);
6479
8029
  this.hub.publish({
6480
8030
  type: "log",
6481
- ts: lines[lines.length - 1].ts,
8031
+ ts: kept[kept.length - 1].ts,
6482
8032
  serverId,
6483
- lines
8033
+ lines: kept
6484
8034
  });
6485
8035
  }
6486
8036
  /**
8037
+ * Whatever the relay had not read yet, ingested before its tailer goes: a nanny's
8038
+ * exit and a stop both end with lines the panel has never seen.
8039
+ */
8040
+ drainRelay(entry) {
8041
+ const rest = this.relay.unfollow(entry.config.id);
8042
+ if (rest.length > 0) this.ingestExternalLines(entry.config.id, rest);
8043
+ }
8044
+ /**
6487
8045
  * Follows a persistent entry's log file, filling the ring buffer from what is on disk.
6488
8046
  *
6489
8047
  * The backfill is read *through* the tailer, which is the only thing that may own the
6490
8048
  * offset: reading it from `LogFiles` first and seeking to EOF second would either
6491
8049
  * replay a line as news or skip one as history, depending on which order the two
6492
8050
  * syscalls happen to land in.
8051
+ *
8052
+ * `attach` is the caller's word for "this entry is already running, and its history is
8053
+ * new to this panel": only then is the file's tail history. The buffer itself cannot
8054
+ * answer that — spawn, adopt and reattach all log a line of their own — which is why a
8055
+ * `logs.size === 0` test left this backfill dead. An empty buffer is still required, so
8056
+ * a second attach to an entry we already hold lines for cannot replay older ones on top.
6493
8057
  */
6494
- followPersistent(entry) {
8058
+ followPersistent(entry, attach = false) {
6495
8059
  const id = entry.config.id;
6496
- const backfill = entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
8060
+ const backfill = attach && entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
6497
8061
  const history = this.relay.follow(id, nannyLogFile(this.options.logFiles.directory, id), { backfill });
6498
8062
  if (history.length > 0) entry.logs.extend(history);
6499
8063
  }
@@ -6516,8 +8080,8 @@ var init_supervisor = __esmMin((() => {
6516
8080
  entry.lastError = null;
6517
8081
  entry.nannyExit = null;
6518
8082
  const child = state.childPid === null ? "" : `, server pid ${state.childPid}`;
8083
+ this.followPersistent(entry, true);
6519
8084
  this.log(entry, "system", `persistent: still running (nanny pid ${state.nannyPid}${child}) — reattached`);
6520
- this.followPersistent(entry);
6521
8085
  this.publishServer(entry);
6522
8086
  return true;
6523
8087
  }
@@ -6556,7 +8120,7 @@ var init_supervisor = __esmMin((() => {
6556
8120
  const state = readNannyState(statePath);
6557
8121
  if (state?.lastExit !== void 0) entry.nannyExit = state.lastExit;
6558
8122
  clearNannyState(statePath);
6559
- this.relay.unfollow(entry.config.id);
8123
+ this.drainRelay(entry);
6560
8124
  }
6561
8125
  /** One probe using the configured mode (TCP or HTTP), with timing. */
6562
8126
  async probeEntryHealth(entry) {
@@ -6675,6 +8239,7 @@ var init_supervisor = __esmMin((() => {
6675
8239
  if (this.disposed) return;
6676
8240
  const now = Date.now();
6677
8241
  await this.options.hostMonitor.tick(now);
8242
+ this.options.ddns.tick(now);
6678
8243
  await this.sampleResources(now);
6679
8244
  await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
6680
8245
  for (const entry of this.entries.values()) {
@@ -6820,6 +8385,7 @@ var init_supervisor = __esmMin((() => {
6820
8385
  };
6821
8386
  entry.logs.push(line);
6822
8387
  this.options.logFiles.append(entry.config.id, line);
8388
+ if (entry.config.persistent && this.options.logFiles.config.persist) rememberEchoedLine(entry.echoed, line);
6823
8389
  this.hub.publish({
6824
8390
  type: "log",
6825
8391
  ts: line.ts,
@@ -6839,19 +8405,30 @@ var init_supervisor = __esmMin((() => {
6839
8405
  }
6840
8406
  publishState() {
6841
8407
  const state = this.getState();
6842
- const signature = [state.configError ?? "", ...state.servers.map((server) => [
6843
- server.id,
6844
- server.status,
6845
- server.health,
6846
- server.portState,
6847
- server.pid,
6848
- server.restarts,
6849
- server.nextRetryAt,
6850
- server.lastError,
6851
- server.bufferedLines,
6852
- server.responseMs,
6853
- server.resources?.sampledAt
6854
- ].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("|");
6855
8432
  if (signature === this.lastStateSignature) return;
6856
8433
  this.lastStateSignature = signature;
6857
8434
  this.hub.publish({
@@ -7048,7 +8625,7 @@ function safeSlug(slug) {
7048
8625
  return slug;
7049
8626
  }
7050
8627
  /** A flag is valid for one exact file revision, not for the name alone. */
7051
- function cacheKey(file) {
8628
+ function cacheKey$1(file) {
7052
8629
  return `${file.sizeBytes}:${file.createdAt}`;
7053
8630
  }
7054
8631
  /** Stable, filesystem-safe name for a data path inside the archive. */
@@ -7113,13 +8690,13 @@ var init_backups = __esmMin((() => {
7113
8690
  const files = this.scan();
7114
8691
  for (const file of files) {
7115
8692
  const cached = this.flags.get(file.name);
7116
- if (cached === void 0 || cached.key !== cacheKey(file)) this.scheduleRefresh();
8693
+ if (cached === void 0 || cached.key !== cacheKey$1(file)) this.scheduleRefresh();
7117
8694
  }
7118
8695
  return files.map((file) => {
7119
8696
  const cached = this.flags.get(file.name);
7120
8697
  return {
7121
8698
  ...file,
7122
- encrypted: cached !== void 0 && cached.key === cacheKey(file) ? cached.encrypted : false
8699
+ encrypted: cached !== void 0 && cached.key === cacheKey$1(file) ? cached.encrypted : false
7123
8700
  };
7124
8701
  });
7125
8702
  }
@@ -7444,7 +9021,7 @@ var init_backups = __esmMin((() => {
7444
9021
  const dir = this.resolveDir();
7445
9022
  const listed = this.scan();
7446
9023
  for (const file of listed) {
7447
- const key = cacheKey(file);
9024
+ const key = cacheKey$1(file);
7448
9025
  if (this.flags.get(file.name)?.key === key) continue;
7449
9026
  try {
7450
9027
  const entries = await listZip(path.join(dir, file.name));
@@ -7680,6 +9257,370 @@ var init_control_server = __esmMin((() => {
7680
9257
  };
7681
9258
  }));
7682
9259
  //#endregion
9260
+ //#region src/providers/ddns/ip.ts
9261
+ /** RFC1918, loopback, link-local and CGNAT are never a public DNS target. */
9262
+ function isPublicAddress(ip, family) {
9263
+ if (family === "A") {
9264
+ const parts = ip.split(".").map((part) => Number.parseInt(part, 10));
9265
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
9266
+ const [a, b] = parts;
9267
+ return !(a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127);
9268
+ }
9269
+ const lower = ip.toLowerCase();
9270
+ if (lower === "::1" || lower === "::") return false;
9271
+ return !(lower.startsWith("fc") || lower.startsWith("fd") || /^fe[89ab]/.test(lower));
9272
+ }
9273
+ /**
9274
+ * Reads the address out of whatever the endpoint answered: a bare address, a
9275
+ * Cloudflare `cdn-cgi/trace` body (`ip=…`), or a small JSON object.
9276
+ */
9277
+ function parseAddressResponse(text, family) {
9278
+ const trimmed = text.trim();
9279
+ if (trimmed.length === 0) return null;
9280
+ const candidates = [];
9281
+ const trace = /^ip=(.+)$/m.exec(trimmed)?.[1];
9282
+ if (trace !== void 0) candidates.push(trace.trim());
9283
+ if (trimmed.startsWith("{")) try {
9284
+ const parsed = JSON.parse(trimmed);
9285
+ for (const key of [
9286
+ "ip",
9287
+ "address",
9288
+ "ip_addr",
9289
+ "query"
9290
+ ]) {
9291
+ const value = parsed[key];
9292
+ if (typeof value === "string") candidates.push(value.trim());
9293
+ }
9294
+ } catch {}
9295
+ candidates.push(trimmed);
9296
+ const wanted = family === "A" ? 4 : 6;
9297
+ for (const candidate of candidates) {
9298
+ const value = candidate.split(/[\s,]/)[0] ?? "";
9299
+ if (isIP(value) === wanted && isPublicAddress(value, family)) return value;
9300
+ }
9301
+ return null;
9302
+ }
9303
+ /**
9304
+ * Tries the configured endpoint, or the built-in list in order, and returns the
9305
+ * first address that is really public. A transient failure never deletes a
9306
+ * record; it just leaves the previous one in place.
9307
+ */
9308
+ async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
9309
+ const configured = overrideUrl.trim();
9310
+ const endpoints = configured.length > 0 ? [configured] : family === "A" ? DEFAULT_IPV4_ENDPOINTS : DEFAULT_IPV6_ENDPOINTS;
9311
+ let lastError = null;
9312
+ for (const endpoint of endpoints) try {
9313
+ const response = await fetchImpl(endpoint, {
9314
+ signal,
9315
+ headers: { Accept: "text/plain, application/json" }
9316
+ });
9317
+ if (!response.ok) {
9318
+ lastError = `${endpoint} answered ${response.status}`;
9319
+ continue;
9320
+ }
9321
+ const parsed = parseAddressResponse(await response.text(), family);
9322
+ if (parsed !== null) return {
9323
+ ip: parsed,
9324
+ error: null
9325
+ };
9326
+ lastError = `${endpoint} did not answer with a public ${family === "A" ? "IPv4" : "IPv6"} address`;
9327
+ } catch (error) {
9328
+ lastError = `${endpoint}: ${error instanceof Error ? error.message : String(error)}`;
9329
+ }
9330
+ return {
9331
+ ip: null,
9332
+ error: lastError ?? `no endpoint for ${family}`
9333
+ };
9334
+ }
9335
+ var DEFAULT_IPV4_ENDPOINTS, DEFAULT_IPV6_ENDPOINTS;
9336
+ var init_ip = __esmMin((() => {
9337
+ DEFAULT_IPV4_ENDPOINTS = [
9338
+ "https://api4.ipify.org",
9339
+ "https://ipv4.icanhazip.com",
9340
+ "https://checkip.amazonaws.com",
9341
+ "https://4.ident.me",
9342
+ "https://cloudflare.com/cdn-cgi/trace"
9343
+ ];
9344
+ DEFAULT_IPV6_ENDPOINTS = [
9345
+ "https://api6.ipify.org",
9346
+ "https://ipv6.icanhazip.com",
9347
+ "https://6.ident.me",
9348
+ "https://cloudflare.com/cdn-cgi/trace"
9349
+ ];
9350
+ }));
9351
+ //#endregion
9352
+ //#region src/services/ddns.ts
9353
+ function emptyState() {
9354
+ return {
9355
+ version: 1,
9356
+ updatedAt: 0,
9357
+ records: {}
9358
+ };
9359
+ }
9360
+ function readState(file) {
9361
+ try {
9362
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
9363
+ const records = {};
9364
+ for (const [key, value] of Object.entries(parsed?.records ?? {})) if (typeof value?.ip === "string") records[key] = value;
9365
+ return {
9366
+ version: 1,
9367
+ updatedAt: typeof parsed?.updatedAt === "number" ? parsed.updatedAt : 0,
9368
+ records
9369
+ };
9370
+ } catch {
9371
+ return emptyState();
9372
+ }
9373
+ }
9374
+ function cacheKey(host, type) {
9375
+ return `${normalizeHost(host)}|${type}`;
9376
+ }
9377
+ var RUN_TIMEOUT_MS, DdnsService;
9378
+ var init_ddns = __esmMin((() => {
9379
+ init_atomic();
9380
+ init_logger();
9381
+ init_ddns$2();
9382
+ init_ip();
9383
+ init_types();
9384
+ RUN_TIMEOUT_MS = 6e4;
9385
+ DdnsService = class {
9386
+ deps;
9387
+ results = /* @__PURE__ */ new Map();
9388
+ persisted;
9389
+ running = false;
9390
+ disposed = false;
9391
+ /** Null until the first pass, so a fresh panel looks immediately. */
9392
+ lastStartedAt = null;
9393
+ lastRunAt = null;
9394
+ lastResult = null;
9395
+ ipv4 = null;
9396
+ ipv6 = null;
9397
+ constructor(deps) {
9398
+ this.deps = deps;
9399
+ this.persisted = readState(deps.statePath);
9400
+ }
9401
+ now() {
9402
+ return this.deps.now?.() ?? Date.now();
9403
+ }
9404
+ /** What the state frame and the settings page read. */
9405
+ get view() {
9406
+ return {
9407
+ enabled: this.deps.getConfig().enabled,
9408
+ running: this.running,
9409
+ lastRunAt: this.lastRunAt,
9410
+ lastResult: this.lastResult,
9411
+ ipv4: this.ipv4,
9412
+ ipv6: this.ipv6,
9413
+ records: this.records()
9414
+ };
9415
+ }
9416
+ /** One entry per configured target, with whatever the last pass learned. */
9417
+ records() {
9418
+ const config = this.deps.getConfig();
9419
+ const views = [];
9420
+ for (const domain of config.domains) {
9421
+ const account = config.accounts.find((entry) => entry.id === domain.account);
9422
+ const provider = account === void 0 ? null : ddnsProvider(account.provider);
9423
+ for (const type of domain.types) {
9424
+ const found = this.results.get(cacheKey(domain.host, type));
9425
+ views.push({
9426
+ host: domain.host,
9427
+ account: domain.account,
9428
+ provider: account?.provider ?? "",
9429
+ type,
9430
+ ip: found?.ip ?? this.persisted.records[cacheKey(domain.host, type)]?.ip ?? null,
9431
+ state: found?.state ?? (provider === null ? "skipped" : "pending"),
9432
+ message: found?.message ?? (account === void 0 ? `unknown account "${domain.account}"` : provider === null ? `unknown provider "${account.provider}"` : null),
9433
+ updatedAt: found?.updatedAt ?? null
9434
+ });
9435
+ }
9436
+ }
9437
+ return views;
9438
+ }
9439
+ /** Makes the next tick run immediately, after a config or credential save. */
9440
+ refresh() {
9441
+ this.lastStartedAt = null;
9442
+ }
9443
+ /**
9444
+ * Cheap enough to call every supervisor tick, and deliberately non-blocking:
9445
+ * a slow provider must never hold up health probes.
9446
+ */
9447
+ tick(now = this.now()) {
9448
+ if (this.disposed || this.running) return;
9449
+ const config = this.deps.getConfig();
9450
+ if (!config.enabled) return;
9451
+ if (this.lastStartedAt !== null && now - this.lastStartedAt < config.intervalMs) return;
9452
+ this.lastStartedAt = now;
9453
+ this.run().catch((error) => {
9454
+ logger.error("ddns pass failed", error);
9455
+ });
9456
+ }
9457
+ /** Runs one pass now; `force` re-sends addresses the cache already knows. */
9458
+ async run(options = {}) {
9459
+ if (this.running || this.disposed) return this.view;
9460
+ this.running = true;
9461
+ const config = this.deps.getConfig();
9462
+ const signal = AbortSignal.timeout(RUN_TIMEOUT_MS);
9463
+ const fetchImpl = this.deps.fetchImpl ?? ((input, init) => fetch(input, init));
9464
+ let changed = 0;
9465
+ let unchanged = 0;
9466
+ let failed = 0;
9467
+ let skipped = 0;
9468
+ try {
9469
+ const wanted = this.wantedFamilies(config);
9470
+ const detected = {
9471
+ ipv4: wanted.ipv4 ? await detectPublicAddress("A", config.ipv4.url, fetchImpl, signal) : {
9472
+ ip: null,
9473
+ error: null
9474
+ },
9475
+ ipv6: wanted.ipv6 ? await detectPublicAddress("AAAA", config.ipv6.url, fetchImpl, signal) : {
9476
+ ip: null,
9477
+ error: null
9478
+ }
9479
+ };
9480
+ if (detected.ipv4.ip !== null) this.ipv4 = detected.ipv4.ip;
9481
+ if (detected.ipv6.ip !== null) this.ipv6 = detected.ipv6.ip;
9482
+ if (detected.ipv4.error !== null) logger.warn(`ddns: could not detect the public IPv4 address (${detected.ipv4.error})`);
9483
+ if (detected.ipv6.error !== null) logger.warn(`ddns: could not detect the public IPv6 address (${detected.ipv6.error})`);
9484
+ for (const domain of config.domains) {
9485
+ if (!domain.enabled) continue;
9486
+ const account = config.accounts.find((entry) => entry.id === domain.account);
9487
+ const provider = account === void 0 ? null : ddnsProvider(account.provider);
9488
+ const credentials = account === void 0 ? null : this.deps.getCredentials(account.id);
9489
+ for (const type of domain.types) {
9490
+ const key = cacheKey(domain.host, type);
9491
+ const family = type === "A" ? "ipv4" : "ipv6";
9492
+ const familyConfig = type === "A" ? config.ipv4 : config.ipv6;
9493
+ const address = type === "A" ? detected.ipv4.ip : detected.ipv6.ip;
9494
+ if (provider === null || account === void 0) {
9495
+ this.remember(key, "skipped", address, `unknown account or provider for "${domain.account}"`);
9496
+ skipped += 1;
9497
+ continue;
9498
+ }
9499
+ if (!familyConfig.enabled) {
9500
+ this.remember(key, "skipped", address, `${type} records need ${family} detection, which is off`);
9501
+ skipped += 1;
9502
+ continue;
9503
+ }
9504
+ if (address === null) {
9505
+ this.remember(key, "skipped", null, detected[family].error ?? `no public ${family} address was found`);
9506
+ skipped += 1;
9507
+ continue;
9508
+ }
9509
+ if (!provider.families.includes(type)) {
9510
+ this.remember(key, "skipped", address, `${provider.label} cannot manage ${type} records`);
9511
+ skipped += 1;
9512
+ continue;
9513
+ }
9514
+ if (!options.force && this.persisted.records[key]?.ip === address) {
9515
+ this.remember(key, "unchanged", address, null);
9516
+ unchanged += 1;
9517
+ continue;
9518
+ }
9519
+ const cache = this.persisted.records[key];
9520
+ const record = {
9521
+ host: domain.host,
9522
+ type,
9523
+ ip: address,
9524
+ ttl: domain.ttl ?? config.ttl,
9525
+ proxied: domain.proxied ?? config.proxied,
9526
+ ...domain.zone === void 0 ? {} : { zone: domain.zone },
9527
+ ...cache === void 0 ? {} : { cache: {
9528
+ ...cache.zoneId === void 0 ? {} : { zoneId: cache.zoneId },
9529
+ ...cache.recordId === void 0 ? {} : { recordId: cache.recordId }
9530
+ } }
9531
+ };
9532
+ const result = await provider.update(record, {
9533
+ credentials: credentials ?? {},
9534
+ fetch: fetchImpl,
9535
+ signal
9536
+ });
9537
+ if (result.ok) {
9538
+ this.persisted.records[key] = {
9539
+ ip: address,
9540
+ updatedAt: this.now(),
9541
+ ...result.cache?.zoneId === void 0 ? {} : { zoneId: result.cache.zoneId },
9542
+ ...result.cache?.recordId === void 0 ? {} : { recordId: result.cache.recordId }
9543
+ };
9544
+ this.remember(key, result.changed ? "ok" : "unchanged", address, result.message);
9545
+ if (result.changed) changed += 1;
9546
+ else unchanged += 1;
9547
+ } else {
9548
+ this.remember(key, "error", address, result.error ?? result.message);
9549
+ failed += 1;
9550
+ logger.warn(`ddns: ${domain.host} ${type}: ${result.error ?? result.message}`);
9551
+ }
9552
+ }
9553
+ }
9554
+ } finally {
9555
+ this.running = false;
9556
+ this.lastRunAt = this.now();
9557
+ this.persisted.updatedAt = this.lastRunAt;
9558
+ this.writeState();
9559
+ this.lastResult = this.describe(changed, unchanged, failed, skipped);
9560
+ this.announce(config, {
9561
+ changed,
9562
+ failed
9563
+ });
9564
+ }
9565
+ return this.view;
9566
+ }
9567
+ /** Only the families a configured target actually asks for. */
9568
+ wantedFamilies(config) {
9569
+ return {
9570
+ ipv4: config.ipv4.enabled && config.domains.some((domain) => domain.enabled && domain.types.includes("A")),
9571
+ ipv6: config.ipv6.enabled && config.domains.some((domain) => domain.enabled && domain.types.includes("AAAA"))
9572
+ };
9573
+ }
9574
+ remember(key, state, ip, message) {
9575
+ this.results.set(key, {
9576
+ state,
9577
+ ip,
9578
+ message,
9579
+ updatedAt: this.now()
9580
+ });
9581
+ }
9582
+ /** Counts only: the detected addresses are shown beside it, and are the status's own. */
9583
+ describe(changed, unchanged, failed, skipped) {
9584
+ const parts = [
9585
+ changed > 0 ? `${changed} updated` : null,
9586
+ unchanged > 0 ? `${unchanged} already current` : null,
9587
+ failed > 0 ? `${failed} failed` : null,
9588
+ skipped > 0 ? `${skipped} skipped` : null
9589
+ ].filter((part) => part !== null);
9590
+ return parts.length === 0 ? "nothing to update" : parts.join(", ");
9591
+ }
9592
+ /** One message per pass, so a flapping provider cannot flood the chat. */
9593
+ announce(config, counts) {
9594
+ if (!config.notify || counts.changed === 0 && counts.failed === 0) return;
9595
+ if (counts.failed > 0) {
9596
+ this.deps.notifications.notify({
9597
+ serverId: "ddns",
9598
+ label: "DDNS",
9599
+ reason: "ddns-error",
9600
+ detail: this.lastResult ?? "a dynamic DNS update failed"
9601
+ });
9602
+ return;
9603
+ }
9604
+ this.deps.notifications.notify({
9605
+ serverId: "ddns",
9606
+ label: "DDNS",
9607
+ reason: "ddns",
9608
+ detail: this.lastResult ?? "a dynamic DNS record changed"
9609
+ });
9610
+ }
9611
+ writeState() {
9612
+ try {
9613
+ writeFileAtomic(this.deps.statePath, `${JSON.stringify(this.persisted, null, 2)}\n`);
9614
+ } catch (error) {
9615
+ logger.warn(`ddns: could not save its state (${error instanceof Error ? error.message : String(error)})`);
9616
+ }
9617
+ }
9618
+ dispose() {
9619
+ this.disposed = true;
9620
+ }
9621
+ };
9622
+ }));
9623
+ //#endregion
7683
9624
  //#region src/services/events.ts
7684
9625
  var ALL, EventHub;
7685
9626
  var init_events = __esmMin((() => {
@@ -8193,7 +10134,9 @@ var init_notifications = __esmMin((() => {
8193
10134
  "recovered": "recovered",
8194
10135
  "rss": "exceeded its memory limit",
8195
10136
  "host": "host thresholds breached",
8196
- "host-recovered": "host thresholds recovered"
10137
+ "host-recovered": "host thresholds recovered",
10138
+ "ddns": "updated a dynamic DNS record",
10139
+ "ddns-error": "hit a dynamic DNS error"
8197
10140
  };
8198
10141
  TITLE = {
8199
10142
  "crash": "🔴 server down",
@@ -8202,7 +10145,9 @@ var init_notifications = __esmMin((() => {
8202
10145
  "recovered": "🟢 server recovered",
8203
10146
  "rss": "🔴 server over its memory limit",
8204
10147
  "host": "🟠 host warning",
8205
- "host-recovered": "🟢 host recovered"
10148
+ "host-recovered": "🟢 host recovered",
10149
+ "ddns": "🌐 DNS updated",
10150
+ "ddns-error": "🌐 DNS update failed"
8206
10151
  };
8207
10152
  NotificationService = class {
8208
10153
  secrets;
@@ -8230,6 +10175,7 @@ var init_notifications = __esmMin((() => {
8230
10175
  onForcedRestart: telegram.onForcedRestart,
8231
10176
  onRecovered: telegram.onRecovered,
8232
10177
  onHost: telegram.onHost,
10178
+ onDdns: telegram.onDdns,
8233
10179
  cooldownMs: telegram.cooldownMs,
8234
10180
  lastResult: this.lastResult,
8235
10181
  lastResultAt: this.lastResultAt
@@ -8246,7 +10192,9 @@ var init_notifications = __esmMin((() => {
8246
10192
  "recovered": telegram.onRecovered,
8247
10193
  "rss": telegram.onCrash,
8248
10194
  "host": telegram.onHost,
8249
- "host-recovered": telegram.onHost
10195
+ "host-recovered": telegram.onHost,
10196
+ "ddns": telegram.onDdns,
10197
+ "ddns-error": telegram.onDdns
8250
10198
  }[event.reason]) return false;
8251
10199
  const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
8252
10200
  return !(telegram.cooldownMs > 0 && until > now);
@@ -9226,6 +11174,12 @@ async function runControlPlane(options) {
9226
11174
  dataRoot,
9227
11175
  home: os.homedir()
9228
11176
  })), notifications);
11177
+ const ddns = new DdnsService({
11178
+ getConfig: () => store.config.ddns,
11179
+ getCredentials: (accountId) => secrets.getDdnsCredentials(accountId),
11180
+ notifications,
11181
+ statePath: defaultDdnsStatePath
11182
+ });
9229
11183
  let onConfigRestored;
9230
11184
  const backups = new BackupService({
9231
11185
  dataRoot,
@@ -9309,6 +11263,7 @@ async function runControlPlane(options) {
9309
11263
  notifications,
9310
11264
  hostMonitor,
9311
11265
  backups,
11266
+ ddns,
9312
11267
  logsDir: logFiles.directory,
9313
11268
  views
9314
11269
  }),
@@ -9316,6 +11271,7 @@ async function runControlPlane(options) {
9316
11271
  logFiles,
9317
11272
  notifications,
9318
11273
  hostMonitor,
11274
+ ddns,
9319
11275
  nannyDir: defaultNannyDir
9320
11276
  });
9321
11277
  /**
@@ -9363,6 +11319,7 @@ async function runControlPlane(options) {
9363
11319
  clearRuntime();
9364
11320
  configWatch.dispose();
9365
11321
  auth.dispose();
11322
+ ddns.dispose();
9366
11323
  await supervisor.dispose();
9367
11324
  logFiles.dispose();
9368
11325
  history.dispose();
@@ -9380,6 +11337,7 @@ async function runControlPlane(options) {
9380
11337
  logFiles,
9381
11338
  notifications,
9382
11339
  backups,
11340
+ ddns,
9383
11341
  ui,
9384
11342
  runtimeToken: token,
9385
11343
  onShutdown: () => shutdown("shutdown requested locally")
@@ -9459,6 +11417,7 @@ var init_src = __esmMin((() => {
9459
11417
  init_backups();
9460
11418
  init_config_watch();
9461
11419
  init_control_server();
11420
+ init_ddns();
9462
11421
  init_events();
9463
11422
  init_exposure();
9464
11423
  init_history();
@@ -9530,7 +11489,9 @@ async function runUp(flags, entry) {
9530
11489
  const { daemonLogPath, dataRoot, projectDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9531
11490
  const existing = readRuntime();
9532
11491
  if (existing !== null && isProcessAlive(existing.pid)) {
9533
- process.stdout.write(`${green("already running")} (pid ${existing.pid}) at ${existing.url}\n`);
11492
+ process.stdout.write(`${green("already running")} home-hosted ${existing.version} (pid ${existing.pid}) at ${existing.url}\n`);
11493
+ const note = versionMismatchNote(existing.version);
11494
+ if (note !== null) process.stdout.write(`${dim(note)}\n`);
9534
11495
  process.stdout.write(`${dim("stop it with `home-hosted down`")}\n`);
9535
11496
  return;
9536
11497
  }
@@ -9566,7 +11527,7 @@ async function runUp(flags, entry) {
9566
11527
  if (output.length > 0) process.stderr.write(`${dim(`${daemonLogPath}:`)}\n${output}\n`);
9567
11528
  process.exit(1);
9568
11529
  }
9569
- process.stdout.write(`${green("home-hosted is up")} (pid ${runtime.pid})\n`);
11530
+ process.stdout.write(`${green(`home-hosted ${runtime.version} is up`)} (pid ${runtime.pid})\n`);
9570
11531
  process.stdout.write(` ${bold(runtime.url)}\n`);
9571
11532
  process.stdout.write(` ${dim(`project ${runtime.projectDir}`)}\n`);
9572
11533
  process.stdout.write(` ${dim(`state ${runtime.dataRoot}`)}\n`);
@@ -9600,6 +11561,7 @@ var init_up = __esmMin((() => {
9600
11561
  init_args();
9601
11562
  init_io();
9602
11563
  init_runtime();
11564
+ init_version();
9603
11565
  DEFAULT_PORT$1 = 3999;
9604
11566
  LOG_ROTATE_BYTES = 5242880;
9605
11567
  upArgs = {
@@ -9652,14 +11614,20 @@ async function runDown() {
9652
11614
  }
9653
11615
  if (!isProcessAlive(runtime.pid)) {
9654
11616
  clearRuntime();
9655
- process.stdout.write("home-hosted is not running (removed a stale run.json)\n");
11617
+ process.stdout.write(`home-hosted is not running (removed a stale run.json from home-hosted ${runtime.version})\n`);
9656
11618
  return;
9657
11619
  }
11620
+ /** What was stopped, and whether this CLI is a different release than it was. */
11621
+ const reportStopped = (forced) => {
11622
+ process.stdout.write(`${green("stopped")}${forced ? " (forced)" : ""} ${dim(`— was home-hosted ${runtime.version}`)}\n`);
11623
+ const note = versionMismatchNote(runtime.version);
11624
+ if (note !== null) process.stdout.write(`${dim(note)}\n`);
11625
+ };
9658
11626
  process.stdout.write(`stopping pid ${runtime.pid}…\n`);
9659
11627
  if (!await requestShutdown(runtime)) signal(runtime.pid, "SIGTERM");
9660
11628
  if (await waitForExit(runtime.pid, 2e4)) {
9661
11629
  clearRuntime();
9662
- process.stdout.write(`${green("stopped")}\n`);
11630
+ reportStopped(false);
9663
11631
  await reportPersistent();
9664
11632
  return;
9665
11633
  }
@@ -9667,7 +11635,7 @@ async function runDown() {
9667
11635
  forceStop(runtime.pid);
9668
11636
  await waitForExit(runtime.pid, 5e3);
9669
11637
  clearRuntime();
9670
- process.stdout.write(`${green("stopped")} (forced)\n`);
11638
+ reportStopped(true);
9671
11639
  await reportPersistent();
9672
11640
  }
9673
11641
  /**
@@ -9725,6 +11693,7 @@ function forceStop(pid) {
9725
11693
  var downCommand;
9726
11694
  var init_down = __esmMin((() => {
9727
11695
  init_io();
11696
+ init_version();
9728
11697
  downCommand = defineCommand({
9729
11698
  meta: {
9730
11699
  name: "down",
@@ -9836,6 +11805,55 @@ var init_status = __esmMin((() => {
9836
11805
  });
9837
11806
  }));
9838
11807
  //#endregion
11808
+ //#region src/cli/server.ts
11809
+ var server_exports = /* @__PURE__ */ __exportAll({
11810
+ runServerAction: () => runServerAction,
11811
+ startCommand: () => startCommand,
11812
+ stopCommand: () => stopCommand
11813
+ });
11814
+ async function runServerAction(action, id) {
11815
+ const { clearRuntime, isProcessAlive, readRuntime, requestControl } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
11816
+ const runtime = readRuntime();
11817
+ if (runtime === null || !isProcessAlive(runtime.pid)) {
11818
+ if (runtime !== null) clearRuntime();
11819
+ fail("home-hosted is not running — start it with `home-hosted up`");
11820
+ }
11821
+ const answer = await requestControl(runtime, `/_hh/servers/${encodeURIComponent(id)}/${action}`);
11822
+ if (answer === null) fail(`the control panel is not answering on ${runtime.probeUrl}`);
11823
+ const body = answer.body ?? {};
11824
+ if (body.ok !== true) {
11825
+ const reason = body.error ?? body.message;
11826
+ if (reason !== void 0) fail(reason);
11827
+ if (answer.status === 404) fail(`the panel on ${runtime.url} is home-hosted ${runtime.version}, which does not know this command — restart it on this release`);
11828
+ fail(`the panel answered ${answer.status}`);
11829
+ }
11830
+ process.stdout.write(`${green(action === "start" ? "started" : "stopped")} ${id}\n`);
11831
+ }
11832
+ function serverCommand(action) {
11833
+ return defineCommand({
11834
+ meta: {
11835
+ name: action,
11836
+ description: action === "start" ? "start one server (the panel keeps running)" : "stop one server (the panel keeps running)"
11837
+ },
11838
+ args: { id: {
11839
+ type: "positional",
11840
+ required: false,
11841
+ description: "the server id from servers.config.json"
11842
+ } },
11843
+ run: async ({ args }) => {
11844
+ const id = typeof args.id === "string" ? args.id.trim() : "";
11845
+ if (id.length === 0) fail(`${action} needs a server id — see \`home-hosted status\` or servers.config.json`);
11846
+ await runServerAction(action, id);
11847
+ }
11848
+ });
11849
+ }
11850
+ var startCommand, stopCommand;
11851
+ var init_server = __esmMin((() => {
11852
+ init_io();
11853
+ startCommand = serverCommand("start");
11854
+ stopCommand = serverCommand("stop");
11855
+ }));
11856
+ //#endregion
9839
11857
  //#region src/cli/set-password.ts
9840
11858
  var set_password_exports = /* @__PURE__ */ __exportAll({
9841
11859
  runSetPassword: () => runSetPassword,
@@ -10894,6 +12912,8 @@ var SYNOPSIS = {
10894
12912
  "down": "home-hosted down",
10895
12913
  "restart": "home-hosted restart [options]",
10896
12914
  "status": "home-hosted status [--json]",
12915
+ "start": "home-hosted start <id>",
12916
+ "stop": "home-hosted stop <id>",
10897
12917
  "set-password": "home-hosted set-password",
10898
12918
  "set-token": "home-hosted set-token",
10899
12919
  "migrate": "home-hosted migrate",
@@ -10907,6 +12927,8 @@ var SUMMARIES = {
10907
12927
  "down": "stop it, and everything it supervises",
10908
12928
  "restart": "down, then up",
10909
12929
  "status": "is it running, where, and how to reach it",
12930
+ "start": "start one server, leaving the panel up",
12931
+ "stop": "stop one server, leaving the panel up",
10910
12932
  "set-password": "set the panel password without the API",
10911
12933
  "set-token": "set the API token that scripts and agents use",
10912
12934
  "migrate": "bring the config up to this release's schema",
@@ -10933,6 +12955,10 @@ var STATUS_SECTION = {
10933
12955
  heading: "Options for status",
10934
12956
  lines: [["--json", "print machine-readable JSON"]]
10935
12957
  };
12958
+ var SERVER_SECTION = {
12959
+ heading: "Arguments for start/stop",
12960
+ lines: [["<id>", "the server id from servers.config.json"]]
12961
+ };
10936
12962
  var SET_PASSWORD_SECTION = {
10937
12963
  heading: "Options for set-password",
10938
12964
  lines: [["--clear", "remove the password, which disables authentication"]]
@@ -11044,11 +13070,15 @@ var USAGE = [
11044
13070
  "",
11045
13071
  renderSection(STATUS_SECTION),
11046
13072
  "",
13073
+ renderSection(SERVER_SECTION),
13074
+ "",
11047
13075
  SHARED_TRAILER
11048
13076
  ].join("\n");
11049
13077
  var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
11050
13078
  var SECTIONS = {
11051
13079
  "status": STATUS_SECTION,
13080
+ "start": SERVER_SECTION,
13081
+ "stop": SERVER_SECTION,
11052
13082
  "set-password": SET_PASSWORD_SECTION,
11053
13083
  "set-token": SET_TOKEN_SECTION,
11054
13084
  "migrate": MIGRATE_SECTION,
@@ -11095,6 +13125,8 @@ var COMMANDS = {
11095
13125
  "down": () => Promise.resolve().then(() => (init_down(), down_exports)).then((module) => module.downCommand),
11096
13126
  "restart": () => Promise.resolve().then(() => (init_restart(), restart_exports)).then((module) => module.restartCommand(CLI_ENTRY)),
11097
13127
  "status": () => Promise.resolve().then(() => (init_status(), status_exports)).then((module) => module.statusCommand),
13128
+ "start": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.startCommand),
13129
+ "stop": () => Promise.resolve().then(() => (init_server(), server_exports)).then((module) => module.stopCommand),
11098
13130
  "set-password": () => Promise.resolve().then(() => (init_set_password(), set_password_exports)).then((module) => module.setPasswordCommand),
11099
13131
  "set-token": () => Promise.resolve().then(() => (init_set_token(), set_token_exports)).then((module) => module.setTokenCommand),
11100
13132
  "migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),