home-hosted 0.7.2 → 0.7.3

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
@@ -6,14 +6,14 @@ import path from "node:path";
6
6
  import readline from "node:readline";
7
7
  import { exec, execFile, spawn, spawnSync } from "node:child_process";
8
8
  import os from "node:os";
9
- import { DetailedError } from "@namesmt/utils";
10
9
  import { describeRoute, openAPIRouteHandler, resolver, validator } from "hono-openapi";
11
10
  import { createFactory } from "hono/factory";
12
- import { type } from "arktype";
13
- import crypto, { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
14
- import { Buffer as Buffer$1 } from "node:buffer";
15
11
  import { LogLevels, createConsola } from "consola";
16
12
  import { isDevelopment } from "std-env";
13
+ import { DetailedError } from "@namesmt/utils";
14
+ import { type } from "arktype";
15
+ import crypto, { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
16
+ import { Buffer as Buffer$1 } from "node:buffer";
17
17
  import { streamSSE } from "hono/streaming";
18
18
  import { autoRetry } from "@grammyjs/auto-retry";
19
19
  import { Bot } from "grammy";
@@ -350,6 +350,65 @@ var init_version = __esmMin((() => {
350
350
  cached = null;
351
351
  }));
352
352
  //#endregion
353
+ //#region src/helpers/factory.ts
354
+ var appFactory;
355
+ var init_factory = __esmMin((() => {
356
+ appFactory = createFactory();
357
+ }));
358
+ //#endregion
359
+ //#region src/helpers/logger.ts
360
+ var logger;
361
+ var init_logger = __esmMin((() => {
362
+ logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
363
+ }));
364
+ //#endregion
365
+ //#region src/middleware/loopback.ts
366
+ /** Client address helpers shared by the auth guard and the setup routes. */
367
+ function requestIp(c) {
368
+ return c.req.raw?.ip ?? null;
369
+ }
370
+ function isLoopback(address) {
371
+ if (!address) return false;
372
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
373
+ }
374
+ function isLoopbackRequest(c) {
375
+ return isLoopback(requestIp(c));
376
+ }
377
+ var init_loopback = __esmMin((() => {}));
378
+ //#endregion
379
+ //#region src/api/acme.ts
380
+ /**
381
+ * The ACMEProxy endpoint the reverse-proxy engine calls for a DNS-01 challenge.
382
+ *
383
+ * Deliberately outside `/api` — the engine is not a session — and outside `/_hh`,
384
+ * which carries the panel's own local token. Two things guard it: only a loopback
385
+ * peer is answered, and the engine must present the Basic credentials the panel
386
+ * generated for it. The engine never holds DNS credentials; this endpoint writes
387
+ * the record through the workspace account a route names.
388
+ */
389
+ function createAcmeChallengeRoute(deps) {
390
+ return appFactory.createApp().post("/:action", describeRoute({
391
+ tags: ["proxy"],
392
+ summary: "Answer one ACME DNS-01 challenge (local engine only)",
393
+ responses: {
394
+ 200: { description: "Written or removed" },
395
+ 401: { description: "Bad credentials" },
396
+ 403: { description: "Not a local caller" }
397
+ }
398
+ }), async (c) => {
399
+ if (!isLoopbackRequest(c)) {
400
+ logger.warn("proxy: refused a DNS-01 challenge from a non-loopback caller");
401
+ return c.json({ error: "only this machine may answer a challenge" }, 403);
402
+ }
403
+ return await deps.challenge.handle(c.req.raw, c.req.param("action") ?? "");
404
+ });
405
+ }
406
+ var init_acme = __esmMin((() => {
407
+ init_factory();
408
+ init_logger();
409
+ init_loopback();
410
+ }));
411
+ //#endregion
353
412
  //#region src/helpers/cookies.ts
354
413
  function parseCookies(header) {
355
414
  const cookies = {};
@@ -379,19 +438,13 @@ function serializeCookie(name, value, options = {}) {
379
438
  }
380
439
  var init_cookies = __esmMin((() => {}));
381
440
  //#endregion
382
- //#region src/helpers/factory.ts
383
- var appFactory;
384
- var init_factory = __esmMin((() => {
385
- appFactory = createFactory();
386
- }));
387
- //#endregion
388
441
  //#region src/shared/contracts.ts
389
442
  /** Parses a bind value (`local` | `lan` | ipv4); null when it is not one. */
390
443
  function parseBind(value) {
391
444
  const parsed = bindSchema(value);
392
445
  return parsed instanceof type.errors ? null : parsed;
393
446
  }
394
- var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, 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, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
447
+ var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, 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, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
395
448
  var init_contracts = __esmMin((() => {
396
449
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
397
450
  workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
@@ -620,7 +673,9 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
620
673
  families: ddnsRecordTypeSchema.array(),
621
674
  fields: ddnsProviderFieldSchema.array(),
622
675
  ttl: "boolean",
623
- proxied: "boolean"
676
+ proxied: "boolean",
677
+ /** These credentials can also answer an ACME DNS-01 challenge (a TXT record). */
678
+ txt: "boolean"
624
679
  });
625
680
  ddnsViewSchema = type({
626
681
  config: ddnsConfigSchema,
@@ -886,13 +941,26 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
886
941
  url: "string = \"\"",
887
942
  /** Optional path prefix; empty serves the whole host. */
888
943
  path: "string = \"\"",
889
- tls: proxyTlsModeSchema.default(() => "auto")
944
+ tls: proxyTlsModeSchema.default(() => "auto"),
945
+ /**
946
+ * Which DNS account answers a DNS-01 challenge for this hostname, as
947
+ * `<workspace>/<account>` — the account belongs to a workspace, so the proxy
948
+ * references it rather than owning it. Empty picks the workspace's only
949
+ * account that can write TXT records.
950
+ */
951
+ dnsAccount: "string = \"\""
890
952
  }).onUndeclaredKey("reject");
891
953
  proxyCertificateSchema = type({
892
954
  id: "/^[a-z0-9][a-z0-9_-]*$/",
893
955
  label: "string = \"\""
894
956
  }).onUndeclaredKey("reject");
895
- proxyCertificateStateSchema = type.enumerated("off", "local", "uploaded", "issued", "pending", "failed");
957
+ proxyCertificateStateSchema = type.enumerated("off", "local", "uploaded", "issued", "pending", "failed", "fallback");
958
+ proxyDns01Schema = type({
959
+ /** Off means the engine uses HTTP-01/TLS-ALPN-01, exactly as before. */
960
+ enabled: "boolean = false",
961
+ /** Nameservers Caddy checks the TXT record against; empty uses its own defaults. */
962
+ resolvers: type("string[]").default(() => [])
963
+ }).onUndeclaredKey("reject");
896
964
  proxyConfigSchema = type({
897
965
  enabled: "boolean = false",
898
966
  engine: proxyEngineSchema.default(() => "caddy"),
@@ -903,6 +971,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
903
971
  email: "string = \"\"",
904
972
  /** The ACME staging endpoint: untrusted certificates, no rate-limit burn. */
905
973
  staging: "boolean = false",
974
+ dns01: proxyDns01Schema.default(() => ({})),
906
975
  /** PEM pairs a route with `tls: "manual"` may serve; the engine picks by SNI. */
907
976
  certificates: proxyCertificateSchema.array().default(() => []),
908
977
  routes: proxyRouteSchema.array().default(() => [])
@@ -916,8 +985,6 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
916
985
  source: proxyEngineSourceSchema.or(type("null")),
917
986
  path: "string | null",
918
987
  bytes: "number | null",
919
- /** Recorded at download time, so a swapped binary is visible. */
920
- sha256: "string | null",
921
988
  error: "string | null"
922
989
  });
923
990
  proxyEngineInfoSchema = type({
@@ -938,8 +1005,13 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
938
1005
  "message": "string | null",
939
1006
  /** Where this hostname's certificate stands. Optional: an older panel omits it. */
940
1007
  "certificate?": type({
941
- state: proxyCertificateStateSchema,
942
- message: "string | null"
1008
+ "state": proxyCertificateStateSchema,
1009
+ "message": "string | null",
1010
+ /**
1011
+ * For `fallback`: minutes until the engine tries the CA again by itself, read
1012
+ * from the certificate it fell back to. Optional, so an older client ignores it.
1013
+ */
1014
+ "retryInMinutes?": "number"
943
1015
  })
944
1016
  });
945
1017
  proxyRunStateSchema = type.enumerated("off", "stopped", "starting", "running", "error", "unsupported");
@@ -968,14 +1040,26 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
968
1040
  hosts: type("string[]"),
969
1041
  error: "string | null"
970
1042
  });
1043
+ proxyDnsAccountViewSchema = type({
1044
+ workspace: "string",
1045
+ account: "string",
1046
+ provider: "string",
1047
+ label: "string",
1048
+ /** The credentials can write a TXT record, so this account can answer a challenge. */
1049
+ writesTxt: "boolean",
1050
+ /** Credentials are stored; an account without them cannot answer yet. */
1051
+ hasCredentials: "boolean"
1052
+ });
971
1053
  proxyViewSchema = type({
972
- config: proxyConfigSchema,
973
- engine: proxyEngineStatusSchema,
974
- engines: proxyEngineInfoSchema.array(),
975
- status: proxyStatusSchema,
976
- routes: proxyRouteViewSchema.array(),
1054
+ "config": proxyConfigSchema,
1055
+ "engine": proxyEngineStatusSchema,
1056
+ "engines": proxyEngineInfoSchema.array(),
1057
+ "status": proxyStatusSchema,
1058
+ "routes": proxyRouteViewSchema.array(),
977
1059
  /** Every uploaded pair, with what it covers and when it expires. */
978
- certificates: proxyCertificateViewSchema.array()
1060
+ "certificates": proxyCertificateViewSchema.array(),
1061
+ /** Every DNS account a route may name. Optional: an older panel omits it. */
1062
+ "dnsAccounts?": proxyDnsAccountViewSchema.array()
979
1063
  });
980
1064
  proxyRoutePatchSchema = type({
981
1065
  id: "string",
@@ -986,7 +1070,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
986
1070
  server: "string?",
987
1071
  url: "string?",
988
1072
  path: "string?",
989
- tls: proxyTlsModeSchema.optional()
1073
+ tls: proxyTlsModeSchema.optional(),
1074
+ dnsAccount: "string?"
990
1075
  }).onUndeclaredKey("reject");
991
1076
  proxyPatchSchema = type({
992
1077
  enabled: "boolean?",
@@ -995,6 +1080,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
995
1080
  httpsPort: "1 <= number.integer <= 65535?",
996
1081
  email: "string?",
997
1082
  staging: "boolean?",
1083
+ /**
1084
+ * A patch shape, not the config's: both keys are optional and merge, so a client
1085
+ * may send one without resetting the other, and an explicit `null` clears a key.
1086
+ */
1087
+ dns01: type({
1088
+ enabled: "boolean | null?",
1089
+ resolvers: "string[] | null?"
1090
+ }).onUndeclaredKey("reject").optional(),
998
1091
  certificates: proxyCertificateSchema.array().optional(),
999
1092
  routes: proxyRoutePatchSchema.array().optional()
1000
1093
  }).onUndeclaredKey("reject");
@@ -1413,20 +1506,6 @@ function normalizeIssues(error) {
1413
1506
  }
1414
1507
  var init_validator = __esmMin((() => {}));
1415
1508
  //#endregion
1416
- //#region src/middleware/loopback.ts
1417
- /** Client address helpers shared by the auth guard and the setup routes. */
1418
- function requestIp(c) {
1419
- return c.req.raw?.ip ?? null;
1420
- }
1421
- function isLoopback(address) {
1422
- if (!address) return false;
1423
- return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
1424
- }
1425
- function isLoopbackRequest(c) {
1426
- return isLoopback(requestIp(c));
1427
- }
1428
- var init_loopback = __esmMin((() => {}));
1429
- //#endregion
1430
1509
  //#region src/helpers/atomic.ts
1431
1510
  /**
1432
1511
  * Write via a temp file in the same directory, then rename: readers never see a
@@ -1434,19 +1513,18 @@ var init_loopback = __esmMin((() => {}));
1434
1513
  */
1435
1514
  function writeFileAtomic(file, content, options = {}) {
1436
1515
  fs.mkdirSync(path.dirname(file), { recursive: true });
1437
- const tmp = `${file}.${process.pid}.tmp`;
1438
- fs.writeFileSync(tmp, content, options.mode === void 0 ? void 0 : { mode: options.mode });
1439
- if (options.mode !== void 0) fs.chmodSync(tmp, options.mode);
1440
- fs.renameSync(tmp, file);
1516
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
1517
+ try {
1518
+ fs.writeFileSync(tmp, content, options.mode === void 0 ? void 0 : { mode: options.mode });
1519
+ if (options.mode !== void 0) fs.chmodSync(tmp, options.mode);
1520
+ fs.renameSync(tmp, file);
1521
+ } catch (error) {
1522
+ fs.rmSync(tmp, { force: true });
1523
+ throw error;
1524
+ }
1441
1525
  }
1442
1526
  var init_atomic = __esmMin((() => {}));
1443
1527
  //#endregion
1444
- //#region src/helpers/logger.ts
1445
- var logger;
1446
- var init_logger = __esmMin((() => {
1447
- logger = createConsola({ level: isDevelopment ? LogLevels.debug : void 0 });
1448
- }));
1449
- //#endregion
1450
1528
  //#region src/config/secrets.ts
1451
1529
  function deriveKey(password, salt, cost, keylen) {
1452
1530
  return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
@@ -2411,6 +2489,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
2411
2489
  projectDir: () => projectDir,
2412
2490
  proxyAdminPath: () => proxyAdminPath,
2413
2491
  proxyBinDir: () => proxyBinDir,
2492
+ proxyChallengeAuthPath: () => proxyChallengeAuthPath,
2414
2493
  proxyConfigPath: () => proxyConfigPath,
2415
2494
  proxyDir: () => proxyDir,
2416
2495
  proxyEngineDir: () => proxyEngineDir,
@@ -2520,7 +2599,7 @@ function resolveUserPath(target, base = projectDir) {
2520
2599
  else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
2521
2600
  return path.isAbsolute(value) ? value : path.resolve(base, value);
2522
2601
  }
2523
- var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSettingsSchemaPath, globalSecretsPath, workspacesPath, workspacesSchemaPath, tlsDir, defaultBackupsDirName, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, legacy;
2602
+ var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSettingsSchemaPath, globalSecretsPath, workspacesPath, workspacesSchemaPath, tlsDir, defaultBackupsDirName, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, proxyChallengeAuthPath, legacy;
2524
2603
  var init_paths = __esmMin((() => {
2525
2604
  dataRoot = resolveDataRoot();
2526
2605
  projectDir = resolveProjectDir();
@@ -2544,6 +2623,7 @@ var init_paths = __esmMin((() => {
2544
2623
  proxyStateDir = path.join(proxyDir, "state");
2545
2624
  proxyAdminPath = path.join(proxyStateDir, "admin.json");
2546
2625
  proxyTlsDir = path.join(proxyDir, "tls");
2626
+ proxyChallengeAuthPath = path.join(proxyStateDir, "challenge.json");
2547
2627
  legacy = {
2548
2628
  configPath: path.join(dataRoot, "servers.config.json"),
2549
2629
  configSchemaPath: path.join(dataRoot, "servers.config.schema.json"),
@@ -3420,7 +3500,7 @@ function mergeGroup(target, patch) {
3420
3500
  }
3421
3501
  return merged;
3422
3502
  }
3423
- var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, EMPTY_MERGE_KEYS;
3503
+ var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, EMPTY_MERGE_KEYS;
3424
3504
  var init_patch = __esmMin((() => {
3425
3505
  SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
3426
3506
  "restart",
@@ -3430,6 +3510,7 @@ var init_patch = __esmMin((() => {
3430
3510
  CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
3431
3511
  NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["telegram"]);
3432
3512
  DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
3513
+ PROXY_MERGE_KEYS = /* @__PURE__ */ new Set(["dns01"]);
3433
3514
  EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
3434
3515
  }));
3435
3516
  //#endregion
@@ -3614,11 +3695,11 @@ var init_settings$1 = __esmMin((() => {
3614
3695
  this.commit(draft);
3615
3696
  return backups;
3616
3697
  }
3617
- /** The reverse proxy. `routes` is a list a patch replaces, so it merges nothing. */
3698
+ /** The reverse proxy. `routes` is a list a patch replaces; `dns01` merges key by key. */
3618
3699
  updateProxy(patch) {
3619
3700
  const draft = structuredClone(this.raw);
3620
3701
  draft.proxy = { ...draft.proxy ?? {} };
3621
- applyPatch(draft.proxy, patch, EMPTY_MERGE_KEYS);
3702
+ applyPatch(draft.proxy, patch, PROXY_MERGE_KEYS);
3622
3703
  const proxy = this.parseGroup(proxyConfigSchema, draft.proxy, "proxy");
3623
3704
  this.commit(draft);
3624
3705
  return proxy;
@@ -3722,7 +3803,7 @@ function authHeaders(credentials) {
3722
3803
  };
3723
3804
  return null;
3724
3805
  }
3725
- async function call(path, headers, context, init = {}) {
3806
+ async function call$1(path, headers, context, init = {}) {
3726
3807
  try {
3727
3808
  const response = await context.fetch(`${API$5}${path}`, {
3728
3809
  ...init,
@@ -3778,7 +3859,7 @@ async function resolveZone(host, headers, context, cached) {
3778
3859
  const labels = host.split(".");
3779
3860
  for (let index = 0; index <= labels.length - 2; index++) {
3780
3861
  const candidate = labels.slice(index).join(".");
3781
- const { data, error } = await call(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
3862
+ const { data, error } = await call$1(`/zones?name=${encodeURIComponent(candidate)}&per_page=1`, headers, context);
3782
3863
  if (error !== null || data === null) throw new Error(error ?? "could not look up the zone");
3783
3864
  const zone = data.result[0];
3784
3865
  if (zone !== void 0) return zone;
@@ -3787,7 +3868,7 @@ async function resolveZone(host, headers, context, cached) {
3787
3868
  }
3788
3869
  async function findRecord(zoneId, record, headers, context, cached) {
3789
3870
  if (cached !== void 0) {
3790
- const fetched = await call(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
3871
+ const fetched = await call$1(`/zones/${zoneId}/dns_records/${cached}`, headers, context);
3791
3872
  if (fetched.error === null && fetched.data !== null) return {
3792
3873
  record: fetched.data.result,
3793
3874
  error: null
@@ -3797,7 +3878,7 @@ async function findRecord(zoneId, record, headers, context, cached) {
3797
3878
  error: fetched.error
3798
3879
  };
3799
3880
  }
3800
- const listed = await call(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
3881
+ const listed = await call$1(`/zones/${zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.host)}&per_page=100`, headers, context);
3801
3882
  if (listed.error !== null) return {
3802
3883
  record: null,
3803
3884
  error: listed.error
@@ -3870,10 +3951,10 @@ var init_cloudflare = __esmMin((() => {
3870
3951
  ttl,
3871
3952
  proxied: record.proxied
3872
3953
  });
3873
- const written = existing === null ? await call(`/zones/${zone.id}/dns_records`, headers, context, {
3954
+ const written = existing === null ? await call$1(`/zones/${zone.id}/dns_records`, headers, context, {
3874
3955
  method: "POST",
3875
3956
  body
3876
- }) : await call(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
3957
+ }) : await call$1(`/zones/${zone.id}/dns_records/${existing.id}`, headers, context, {
3877
3958
  method: "PATCH",
3878
3959
  body
3879
3960
  });
@@ -3888,6 +3969,74 @@ var init_cloudflare = __esmMin((() => {
3888
3969
  } catch (error) {
3889
3970
  return failed(error instanceof Error ? error.message : String(error));
3890
3971
  }
3972
+ },
3973
+ /**
3974
+ * One `_acme-challenge` TXT record, written and removed around a validation.
3975
+ * Cloudflare has real per-record endpoints, so nothing else in the zone is read
3976
+ * or rewritten — the failure mode that makes a whole-record-set API risky.
3977
+ */
3978
+ async challenge(record, context) {
3979
+ const headers = authHeaders(context.credentials);
3980
+ if (headers === null) return {
3981
+ ok: false,
3982
+ message: "cloudflare needs an API token (or the legacy email + global key)"
3983
+ };
3984
+ try {
3985
+ const zone = await resolveZone(record.fqdn, headers, context, void 0);
3986
+ if (zone === null) return {
3987
+ ok: false,
3988
+ message: `cloudflare does not have a zone for ${record.fqdn}`
3989
+ };
3990
+ const listed = await call$1(`/zones/${zone.id}/dns_records?type=TXT&name=${encodeURIComponent(record.fqdn)}&per_page=100`, headers, context);
3991
+ if (listed.error !== null) return {
3992
+ ok: false,
3993
+ message: listed.error
3994
+ };
3995
+ const ours = (listed.data?.result ?? []).filter((entry) => entry.id !== void 0).filter((entry) => entry.content === record.value);
3996
+ if (record.action === "present") {
3997
+ if (ours.length > 0) return {
3998
+ ok: true,
3999
+ message: `${record.fqdn} already carries this value`
4000
+ };
4001
+ const written = await call$1(`/zones/${zone.id}/dns_records`, headers, context, {
4002
+ method: "POST",
4003
+ body: JSON.stringify({
4004
+ type: "TXT",
4005
+ name: record.fqdn,
4006
+ content: record.value,
4007
+ ttl: 1
4008
+ })
4009
+ });
4010
+ if (written.error !== null) return {
4011
+ ok: false,
4012
+ message: written.error
4013
+ };
4014
+ return {
4015
+ ok: true,
4016
+ message: `wrote the TXT record for ${record.fqdn}`
4017
+ };
4018
+ }
4019
+ for (const entry of ours) {
4020
+ const removed = await call$1(`/zones/${zone.id}/dns_records/${entry.id}`, headers, context, { method: "DELETE" });
4021
+ if (removed.error !== null && removed.status !== 404) return {
4022
+ ok: false,
4023
+ message: removed.error
4024
+ };
4025
+ }
4026
+ if (ours.length === 0) return {
4027
+ ok: true,
4028
+ message: `${record.fqdn} had no challenge record to remove`
4029
+ };
4030
+ return {
4031
+ ok: true,
4032
+ message: `removed the TXT record for ${record.fqdn}`
4033
+ };
4034
+ } catch (error) {
4035
+ return {
4036
+ ok: false,
4037
+ message: error instanceof Error ? error.message : String(error)
4038
+ };
4039
+ }
3891
4040
  }
3892
4041
  };
3893
4042
  }));
@@ -4012,11 +4161,11 @@ var init_dyndns2 = __esmMin((() => {
4012
4161
  }));
4013
4162
  //#endregion
4014
4163
  //#region src/providers/ddns/desec.ts
4015
- var ENDPOINT$6, desecProvider;
4164
+ var ENDPOINT$7, desecProvider;
4016
4165
  var init_desec = __esmMin((() => {
4017
4166
  init_dyndns2();
4018
4167
  init_types();
4019
- ENDPOINT$6 = "https://update.dedyn.io/";
4168
+ ENDPOINT$7 = "https://update.dedyn.io/";
4020
4169
  desecProvider = {
4021
4170
  id: "desec",
4022
4171
  label: "deSEC",
@@ -4037,7 +4186,7 @@ var init_desec = __esmMin((() => {
4037
4186
  const token = (context.credentials.token ?? "").trim();
4038
4187
  if (token.length === 0) return failed("desec needs an API token");
4039
4188
  const family = record.type === "A" ? `myipv4=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
4040
- const url = `${ENDPOINT$6}?hostname=${encodeURIComponent(record.host)}&${family}`;
4189
+ const url = `${ENDPOINT$7}?hostname=${encodeURIComponent(record.host)}&${family}`;
4041
4190
  try {
4042
4191
  const response = await context.fetch(url, {
4043
4192
  headers: { Authorization: basicAuth(record.host, token) },
@@ -4182,10 +4331,10 @@ function subdomain(host) {
4182
4331
  const normalized = host.trim().toLowerCase();
4183
4332
  return normalized.endsWith(".duckdns.org") ? normalized.slice(0, -12) : normalized;
4184
4333
  }
4185
- var ENDPOINT$5, duckdnsProvider;
4334
+ var ENDPOINT$6, duckdnsProvider;
4186
4335
  var init_duckdns = __esmMin((() => {
4187
4336
  init_types();
4188
- ENDPOINT$5 = "https://www.duckdns.org/update";
4337
+ ENDPOINT$6 = "https://www.duckdns.org/update";
4189
4338
  duckdnsProvider = {
4190
4339
  id: "duckdns",
4191
4340
  label: "DuckDNS",
@@ -4206,7 +4355,7 @@ var init_duckdns = __esmMin((() => {
4206
4355
  const token = (context.credentials.token ?? "").trim();
4207
4356
  if (token.length === 0) return failed("duckdns needs the account token");
4208
4357
  const family = record.type === "A" ? `ip=${encodeURIComponent(record.ip)}` : `ipv6=${encodeURIComponent(record.ip)}`;
4209
- const url = `${ENDPOINT$5}?domains=${encodeURIComponent(subdomain(record.host))}&token=${encodeURIComponent(token)}&${family}`;
4358
+ const url = `${ENDPOINT$6}?domains=${encodeURIComponent(subdomain(record.host))}&token=${encodeURIComponent(token)}&${family}`;
4210
4359
  try {
4211
4360
  const response = await context.fetch(url, { signal: context.signal });
4212
4361
  const body = (await response.text()).trim();
@@ -4220,11 +4369,11 @@ var init_duckdns = __esmMin((() => {
4220
4369
  }));
4221
4370
  //#endregion
4222
4371
  //#region src/providers/ddns/dynu.ts
4223
- var ENDPOINT$4, dynuProvider;
4372
+ var ENDPOINT$5, dynuProvider;
4224
4373
  var init_dynu = __esmMin((() => {
4225
4374
  init_dyndns2();
4226
4375
  init_types();
4227
- ENDPOINT$4 = "https://api.dynu.com/nic/update";
4376
+ ENDPOINT$5 = "https://api.dynu.com/nic/update";
4228
4377
  dynuProvider = {
4229
4378
  id: "dynu",
4230
4379
  label: "Dynu",
@@ -4251,7 +4400,7 @@ var init_dynu = __esmMin((() => {
4251
4400
  const password = (context.credentials.password ?? "").trim();
4252
4401
  if (username.length === 0 || password.length === 0) return failed("dynu needs the username and password");
4253
4402
  const address = record.type === "A" ? `myip=${encodeURIComponent(record.ip)}` : `myipv6=${encodeURIComponent(record.ip)}`;
4254
- const url = `${ENDPOINT$4}?hostname=${encodeURIComponent(record.host)}&${address}`;
4403
+ const url = `${ENDPOINT$5}?hostname=${encodeURIComponent(record.host)}&${address}`;
4255
4404
  try {
4256
4405
  const response = await context.fetch(url, {
4257
4406
  headers: { Authorization: basicAuth(username, password) },
@@ -4275,10 +4424,10 @@ function updateToken(credentials) {
4275
4424
  if (raw.length === 0) return "";
4276
4425
  return (raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : raw).replace(/^https?:\/\/[^?]*\?/, "");
4277
4426
  }
4278
- var ENDPOINT$3, freednsProvider;
4427
+ var ENDPOINT$4, freednsProvider;
4279
4428
  var init_freedns = __esmMin((() => {
4280
4429
  init_types();
4281
- ENDPOINT$3 = "https://freedns.afraid.org/dynamic/update.php";
4430
+ ENDPOINT$4 = "https://freedns.afraid.org/dynamic/update.php";
4282
4431
  freednsProvider = {
4283
4432
  id: "freedns",
4284
4433
  label: "FreeDNS (afraid.org)",
@@ -4299,7 +4448,7 @@ var init_freedns = __esmMin((() => {
4299
4448
  const token = updateToken(context.credentials);
4300
4449
  if (token.length === 0) return failed("freedns needs the per-record Direct URL");
4301
4450
  try {
4302
- const response = await context.fetch(`${ENDPOINT$3}?${token}&address=${encodeURIComponent(record.ip)}`, { signal: context.signal });
4451
+ const response = await context.fetch(`${ENDPOINT$4}?${token}&address=${encodeURIComponent(record.ip)}`, { signal: context.signal });
4303
4452
  const body = (await response.text()).trim();
4304
4453
  if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
4305
4454
  return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
@@ -4453,11 +4602,11 @@ var init_godaddy = __esmMin((() => {
4453
4602
  }));
4454
4603
  //#endregion
4455
4604
  //#region src/providers/ddns/henet.ts
4456
- var ENDPOINT$2, henetProvider;
4605
+ var ENDPOINT$3, henetProvider;
4457
4606
  var init_henet = __esmMin((() => {
4458
4607
  init_dyndns2();
4459
4608
  init_types();
4460
- ENDPOINT$2 = "https://dyn.dns.he.net/nic/update";
4609
+ ENDPOINT$3 = "https://dyn.dns.he.net/nic/update";
4461
4610
  henetProvider = {
4462
4611
  id: "henet",
4463
4612
  label: "Hurricane Electric",
@@ -4477,7 +4626,7 @@ var init_henet = __esmMin((() => {
4477
4626
  async update(record, context) {
4478
4627
  const key = (context.credentials.key ?? "").trim();
4479
4628
  if (key.length === 0) return failed("hurricane electric needs the per-host DDNS key");
4480
- const url = `${ENDPOINT$2}?hostname=${encodeURIComponent(record.host)}&password=${encodeURIComponent(key)}&myip=${encodeURIComponent(record.ip)}`;
4629
+ const url = `${ENDPOINT$3}?hostname=${encodeURIComponent(record.host)}&password=${encodeURIComponent(key)}&myip=${encodeURIComponent(record.ip)}`;
4481
4630
  try {
4482
4631
  const response = await context.fetch(url, { signal: context.signal });
4483
4632
  return describeDyndnsResponse(response, await response.text(), "hurricane electric");
@@ -4492,11 +4641,11 @@ var init_henet = __esmMin((() => {
4492
4641
  function tag(body, name) {
4493
4642
  return new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, "i").exec(body)?.[1]?.trim() ?? null;
4494
4643
  }
4495
- var ENDPOINT$1, namecheapProvider;
4644
+ var ENDPOINT$2, namecheapProvider;
4496
4645
  var init_namecheap = __esmMin((() => {
4497
4646
  init_types();
4498
4647
  init_zone();
4499
- ENDPOINT$1 = "https://dynamicdns.park-your-domain.com/update";
4648
+ ENDPOINT$2 = "https://dynamicdns.park-your-domain.com/update";
4500
4649
  namecheapProvider = {
4501
4650
  id: "namecheap",
4502
4651
  label: "Namecheap",
@@ -4518,7 +4667,7 @@ var init_namecheap = __esmMin((() => {
4518
4667
  if (password.length === 0) return failed("namecheap needs the Dynamic DNS password");
4519
4668
  const { zone, name } = splitHost(record.host, record.zone);
4520
4669
  const host = name === "@" ? "@" : name;
4521
- const url = `${ENDPOINT$1}?host=${encodeURIComponent(host)}&domain=${encodeURIComponent(zone)}&password=${encodeURIComponent(password)}&ip=${encodeURIComponent(record.ip)}`;
4670
+ const url = `${ENDPOINT$2}?host=${encodeURIComponent(host)}&domain=${encodeURIComponent(zone)}&password=${encodeURIComponent(password)}&ip=${encodeURIComponent(record.ip)}`;
4522
4671
  try {
4523
4672
  const body = await (await context.fetch(url, { signal: context.signal })).text();
4524
4673
  const errCount = Number.parseInt(tag(body, "ErrCount") ?? "0", 10);
@@ -4536,6 +4685,248 @@ var init_namecheap = __esmMin((() => {
4536
4685
  };
4537
4686
  }));
4538
4687
  //#endregion
4688
+ //#region src/providers/ddns/namecheap-api.ts
4689
+ /**
4690
+ * Namecheap escapes attribute values, so a value read back has to be unescaped
4691
+ * before it is written out again: the whole-zone rewrite would otherwise turn
4692
+ * `a&b` into `a&amp;b` on every challenge, corrupting a record we did not create.
4693
+ */
4694
+ function decodeXml(value) {
4695
+ return value.replace(/&(#X?[0-9a-f]+|[a-z]+);/gi, (whole, body) => {
4696
+ if (body.startsWith("#")) {
4697
+ const hex = body[1] === "x" || body[1] === "X";
4698
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
4699
+ return Number.isInteger(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
4700
+ }
4701
+ return XML_ENTITIES[body.toLowerCase()] ?? whole;
4702
+ });
4703
+ }
4704
+ function attr(tagText, name) {
4705
+ return decodeXml(new RegExp(`${name}="([^"]*)"`, "i").exec(tagText)?.[1] ?? "").trim();
4706
+ }
4707
+ /** Every `<Host ... />` in a `getHosts` reply, in the order the API listed them. */
4708
+ function parseHosts(body) {
4709
+ const records = [];
4710
+ const pattern = /<Host\b([^>]*?)(?:\/>|>)/gi;
4711
+ let match = pattern.exec(body);
4712
+ while (match !== null) {
4713
+ const attributes = match[1] ?? "";
4714
+ records.push({
4715
+ name: attr(attributes, "Name"),
4716
+ type: attr(attributes, "Type"),
4717
+ address: attr(attributes, "Address"),
4718
+ mxPref: attr(attributes, "MXPref") || "10",
4719
+ ttl: attr(attributes, "TTL") || "1800"
4720
+ });
4721
+ match = pattern.exec(body);
4722
+ }
4723
+ return records;
4724
+ }
4725
+ /** The first `<Err1>`…`<ErrN>` a failed reply carries, as one sentence. */
4726
+ function describeErrors(body) {
4727
+ const errors = [];
4728
+ const pattern = /<Err\d+>([\s\S]*?)<\/Err\d+>/gi;
4729
+ let match = pattern.exec(body);
4730
+ while (match !== null) {
4731
+ const text = (match[1] ?? "").trim();
4732
+ if (text.length > 0) errors.push(text);
4733
+ match = pattern.exec(body);
4734
+ }
4735
+ return errors.length > 0 ? errors.join("; ") : "the API refused the request";
4736
+ }
4737
+ function credentials(store) {
4738
+ const apiUser = (store.apiUser ?? "").trim();
4739
+ const apiKey = (store.apiKey ?? "").trim();
4740
+ const userName = (store.userName ?? "").trim() || apiUser;
4741
+ const clientIp = (store.clientIp ?? "").trim();
4742
+ if (apiUser.length === 0 || apiKey.length === 0 || clientIp.length === 0) return null;
4743
+ return {
4744
+ apiUser,
4745
+ apiKey,
4746
+ userName,
4747
+ clientIp
4748
+ };
4749
+ }
4750
+ /** One XML call. Namecheap answers HTTP 200 whatever happened, so the body decides. */
4751
+ async function call(command, params, context) {
4752
+ const auth = credentials(context.credentials);
4753
+ if (auth === null) return "namecheap needs the API user, the API key and the whitelisted client IP";
4754
+ const query = new URLSearchParams({
4755
+ ApiUser: auth.apiUser,
4756
+ ApiKey: auth.apiKey,
4757
+ UserName: auth.userName,
4758
+ ClientIp: auth.clientIp,
4759
+ Command: command,
4760
+ ...params
4761
+ });
4762
+ try {
4763
+ const response = await context.fetch(`${ENDPOINT$1}?${query.toString()}`, { signal: context.signal });
4764
+ const body = await response.text();
4765
+ if (!response.ok && !body.includes("<ApiResponse")) return `namecheap answered HTTP ${response.status}`;
4766
+ const status = /<ApiResponse[^>]*Status="([^"]*)"/i.exec(body)?.[1] ?? "";
4767
+ if (status.toUpperCase() !== "OK") return describeErrors(body);
4768
+ return {
4769
+ status,
4770
+ errors: [],
4771
+ body
4772
+ };
4773
+ } catch (error) {
4774
+ return error instanceof Error ? error.message : String(error);
4775
+ }
4776
+ }
4777
+ /** The registered domain split into the `SLD`/`TLD` pair the API asks for. */
4778
+ function apexParts(zone) {
4779
+ const apex = normalizeHost(zone);
4780
+ const dot = apex.indexOf(".");
4781
+ if (dot < 0) return {
4782
+ SLD: apex,
4783
+ TLD: ""
4784
+ };
4785
+ return {
4786
+ SLD: apex.slice(0, dot),
4787
+ TLD: apex.slice(dot + 1)
4788
+ };
4789
+ }
4790
+ /** The zone's records, or a sentence saying why they could not be read. */
4791
+ async function readHosts(zone, context) {
4792
+ const reply = await call("namecheap.domains.dns.getHosts", { ...apexParts(zone) }, context);
4793
+ if (typeof reply === "string") return reply;
4794
+ return parseHosts(reply.body);
4795
+ }
4796
+ /** Writes the whole record set back. */
4797
+ async function writeHosts(zone, records, context) {
4798
+ const params = { ...apexParts(zone) };
4799
+ records.forEach((record, index) => {
4800
+ const at = index + 1;
4801
+ params[`HostName${at}`] = record.name;
4802
+ params[`RecordType${at}`] = record.type;
4803
+ params[`Address${at}`] = record.address;
4804
+ params[`TTL${at}`] = record.ttl;
4805
+ params[`MXPref${at}`] = record.mxPref;
4806
+ });
4807
+ const reply = await call("namecheap.domains.dns.setHosts", params, context);
4808
+ return typeof reply === "string" ? reply : null;
4809
+ }
4810
+ /** The record name Namecheap expects for a challenge, relative to the zone. */
4811
+ function challengeName(fqdn, zone) {
4812
+ const normalized = normalizeHost(fqdn);
4813
+ const apex = normalizeHost(zone);
4814
+ if (normalized === apex) return "@";
4815
+ return normalized.endsWith(`.${apex}`) ? normalized.slice(0, -(apex.length + 1)) : normalized;
4816
+ }
4817
+ var ENDPOINT$1, RECORD_LIMIT, CHALLENGE_TTL, XML_ENTITIES, namecheapApiProvider;
4818
+ var init_namecheap_api = __esmMin((() => {
4819
+ init_types();
4820
+ init_zone();
4821
+ ENDPOINT$1 = "https://api.namecheap.com/xml.response";
4822
+ RECORD_LIMIT = 150;
4823
+ CHALLENGE_TTL = 60;
4824
+ XML_ENTITIES = {
4825
+ amp: "&",
4826
+ lt: "<",
4827
+ gt: ">",
4828
+ quot: "\"",
4829
+ apos: "'"
4830
+ };
4831
+ namecheapApiProvider = {
4832
+ id: "namecheap-api",
4833
+ label: "Namecheap (API)",
4834
+ docsUrl: "https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/",
4835
+ families: ["A"],
4836
+ ttl: true,
4837
+ proxied: false,
4838
+ fields: [
4839
+ {
4840
+ key: "apiUser",
4841
+ label: "API user",
4842
+ hint: "Dashboard → Profile → Tools → Namecheap API Access. Usually your account name.",
4843
+ optional: false
4844
+ },
4845
+ {
4846
+ key: "apiKey",
4847
+ label: "API key",
4848
+ hint: "Shown once when API access is enabled.",
4849
+ optional: false
4850
+ },
4851
+ {
4852
+ key: "userName",
4853
+ label: "User name",
4854
+ hint: "Leave empty unless the API user differs from the account.",
4855
+ optional: true
4856
+ },
4857
+ {
4858
+ key: "clientIp",
4859
+ label: "Whitelisted IP",
4860
+ hint: "The public IPv4 this panel calls from. It must be whitelisted under API Access, and error 1017105 means it is not.",
4861
+ optional: false
4862
+ }
4863
+ ],
4864
+ validate(store) {
4865
+ if (credentials(store ?? {}) !== null) return null;
4866
+ const missing = [
4867
+ "apiUser",
4868
+ "apiKey",
4869
+ "clientIp"
4870
+ ].filter((key) => (store?.[key] ?? "").trim().length === 0);
4871
+ return missing.length === 0 ? null : `set ${missing.join(", ")}`;
4872
+ },
4873
+ /**
4874
+ * There is no per-record update here: `setHosts` replaces everything, so this is
4875
+ * a read-modify-write of the whole zone.
4876
+ */
4877
+ async challenge(record, context) {
4878
+ const { zone } = splitHost(record.fqdn);
4879
+ const hosts = await readHosts(zone, context);
4880
+ if (typeof hosts === "string") return {
4881
+ ok: false,
4882
+ message: hosts
4883
+ };
4884
+ const name = challengeName(record.fqdn, zone);
4885
+ const isChallenge = (host) => host.type.toUpperCase() === "TXT" && host.name.toLowerCase() === name.toLowerCase();
4886
+ const isOurs = (host) => isChallenge(host) && host.address === record.value;
4887
+ if (record.action === "present") {
4888
+ if (hosts.some(isOurs)) return {
4889
+ ok: true,
4890
+ message: `${record.fqdn} already carries this value`
4891
+ };
4892
+ if (hosts.length >= RECORD_LIMIT) return {
4893
+ ok: false,
4894
+ message: `namecheap's setHosts takes at most ${RECORD_LIMIT} host records per call and ${zone} has ${hosts.length}, so the panel cannot rewrite this zone to add a challenge record`
4895
+ };
4896
+ const failure = await writeHosts(zone, [...hosts, {
4897
+ name,
4898
+ type: "TXT",
4899
+ address: record.value,
4900
+ mxPref: "10",
4901
+ ttl: String(CHALLENGE_TTL)
4902
+ }], context);
4903
+ if (failure !== null) return {
4904
+ ok: false,
4905
+ message: failure
4906
+ };
4907
+ return {
4908
+ ok: true,
4909
+ message: `added a TXT record for ${record.fqdn} (rewrote ${hosts.length} others)`
4910
+ };
4911
+ }
4912
+ const kept = hosts.filter((host) => !isOurs(host));
4913
+ if (kept.length === hosts.length) return {
4914
+ ok: true,
4915
+ message: `${record.fqdn} had no challenge record to remove`
4916
+ };
4917
+ const failure = await writeHosts(zone, kept, context);
4918
+ if (failure !== null) return {
4919
+ ok: false,
4920
+ message: failure
4921
+ };
4922
+ return {
4923
+ ok: true,
4924
+ message: `removed the TXT record for ${record.fqdn} (rewrote ${kept.length} others)`
4925
+ };
4926
+ }
4927
+ };
4928
+ }));
4929
+ //#endregion
4539
4930
  //#region src/providers/ddns/noip.ts
4540
4931
  var ENDPOINT, DEFAULT_AGENT, noipProvider;
4541
4932
  var init_noip = __esmMin((() => {
@@ -4759,7 +5150,8 @@ function ddnsProviderInfos() {
4759
5150
  families: provider.families,
4760
5151
  fields: provider.fields,
4761
5152
  ttl: provider.ttl,
4762
- proxied: provider.proxied
5153
+ proxied: provider.proxied,
5154
+ txt: provider.challenge !== void 0
4763
5155
  }));
4764
5156
  }
4765
5157
  /**
@@ -4807,12 +5199,14 @@ var init_ddns$2 = __esmMin((() => {
4807
5199
  init_godaddy();
4808
5200
  init_henet();
4809
5201
  init_namecheap();
5202
+ init_namecheap_api();
4810
5203
  init_noip();
4811
5204
  init_porkbun();
4812
5205
  init_spaceship();
4813
5206
  DDNS_PROVIDERS = [
4814
5207
  cloudflareProvider,
4815
5208
  namecheapProvider,
5209
+ namecheapApiProvider,
4816
5210
  spaceshipProvider,
4817
5211
  porkbunProvider,
4818
5212
  godaddyProvider,
@@ -6626,10 +7020,10 @@ function goArch(arch) {
6626
7020
  default: return null;
6627
7021
  }
6628
7022
  }
6629
- var BUILD_URL, PINNED, caddyEngine;
7023
+ var BUILD_URL, PLUGINS, caddyEngine;
6630
7024
  var init_caddy = __esmMin((() => {
6631
7025
  BUILD_URL = "https://caddyserver.com/api/download";
6632
- PINNED = "2.11.4";
7026
+ PLUGINS = ["github.com/caddy-dns/acmeproxy"];
6633
7027
  caddyEngine = {
6634
7028
  info: {
6635
7029
  id: "caddy",
@@ -6638,10 +7032,9 @@ var init_caddy = __esmMin((() => {
6638
7032
  releaseUrl: "https://github.com/caddyserver/caddy/releases",
6639
7033
  acme: true,
6640
7034
  internalCa: true,
6641
- dns01: false,
7035
+ dns01: true,
6642
7036
  tcp: false
6643
7037
  },
6644
- pinnedVersion: PINNED,
6645
7038
  binaryName(platform) {
6646
7039
  return platform === "win32" ? "hh-caddy.exe" : "hh-caddy";
6647
7040
  },
@@ -6649,10 +7042,11 @@ var init_caddy = __esmMin((() => {
6649
7042
  const arch = goArch(target.arch);
6650
7043
  if (arch === null) return null;
6651
7044
  if (target.platform !== "linux" && target.platform !== "darwin" && target.platform !== "win32") return null;
6652
- const version = target.version.length > 0 ? target.version : PINNED;
7045
+ const wanted = target.version.length > 0 ? `&version=${encodeURIComponent(target.version)}` : "";
7046
+ const plugins = PLUGINS.map((plugin) => `&p=${encodeURIComponent(plugin)}`).join("");
6653
7047
  return {
6654
- url: `${BUILD_URL}?os=${target.platform}&arch=${arch}&version=${encodeURIComponent(version)}`,
6655
- version
7048
+ url: `${BUILD_URL}?os=${target.platform}&arch=${arch}${wanted}${plugins}`,
7049
+ version: target.version
6656
7050
  };
6657
7051
  },
6658
7052
  runArgs(run) {
@@ -6709,13 +7103,67 @@ var init_proxy$2 = __esmMin((() => {
6709
7103
  * ZeroSSL is deliberately absent: it needs an EAB key the panel does not collect, so
6710
7104
  * listing it only added a failing issuer and a second retry on every name.
6711
7105
  */
6712
- function acmeIssuers(acme) {
7106
+ /**
7107
+ * The DNS-01 challenge block, which names the panel as the one that answers it. The
7108
+ * engine holds no DNS credentials: it asks the panel over the ACMEProxy protocol, and
7109
+ * the panel writes the record through the workspace account the route names.
7110
+ */
7111
+ function challengeBlock(dns01) {
7112
+ return { challenges: { dns: {
7113
+ provider: {
7114
+ name: "acmeproxy",
7115
+ endpoint: dns01.endpoint,
7116
+ username: dns01.username,
7117
+ password: dns01.password
7118
+ },
7119
+ ...dns01.resolvers.length > 0 ? { resolvers: dns01.resolvers } : {}
7120
+ } } };
7121
+ }
7122
+ function acmeIssuers(acme, challenge = {}) {
6713
7123
  return [{
6714
7124
  module: "acme",
6715
7125
  ...acme.email.length > 0 ? { email: acme.email } : {},
6716
- ...acme.staging ? { ca: ACME_STAGING } : {}
7126
+ ...acme.staging ? { ca: ACME_STAGING } : {},
7127
+ ...challenge
6717
7128
  }, { module: "internal" }];
6718
7129
  }
7130
+ /**
7131
+ * The page a visitor gets when the hostname they reached is not routed at all. It is
7132
+ * the answer on both ports, so a request never lands on a redirect into a handshake
7133
+ * that cannot finish.
7134
+ */
7135
+ function notConfiguredPage() {
7136
+ return `<!doctype html>
7137
+ <html lang="en">
7138
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7139
+ <title>No route for this hostname</title>
7140
+ <style>
7141
+ :root { color-scheme: light dark }
7142
+ body { margin: 0; min-height: 100dvh; display: grid; place-items: center; font: 15px/1.6 system-ui, sans-serif }
7143
+ main { max-width: 34rem; padding: 2rem }
7144
+ h1 { font-size: 1.15rem; margin: 0 0 .75rem }
7145
+ p { margin: 0 0 .75rem }
7146
+ code { font-family: ui-monospace, monospace; font-size: .9em }
7147
+ footer { color: #888; font-size: .85em }
7148
+ </style></head>
7149
+ <body><main>
7150
+ <h1>This route is not configured for any target yet</h1>
7151
+ <p>No route in <strong>home-hosted</strong>'s reverse proxy claims the hostname
7152
+ <code>{http.request.host}</code>, so there is nothing here to serve.</p>
7153
+ <p>Add a route for it under <strong>Others → Reverse Proxy</strong>, or check that you
7154
+ reached the hostname you meant to.</p>
7155
+ <footer>home-hosted reverse proxy</footer>
7156
+ </main></body></html>`;
7157
+ }
7158
+ /** The catch-all: it answers last, so every configured route wins over it. */
7159
+ function notConfiguredRoute() {
7160
+ return { handle: [{
7161
+ handler: "static_response",
7162
+ status_code: 404,
7163
+ headers: { "content-type": ["text/html; charset=utf-8"] },
7164
+ body: notConfiguredPage()
7165
+ }] };
7166
+ }
6719
7167
  /** The page a visitor gets when a name has no certificate yet and HTTPS cannot work. */
6720
7168
  function noticePage(host, httpPort, httpsPort) {
6721
7169
  return `<!doctype html>
@@ -6802,11 +7250,20 @@ function renderCaddyConfig(input) {
6802
7250
  const servers = {};
6803
7251
  if (managed.length > 0) servers.https = {
6804
7252
  listen: [`:${config.httpsPort}`],
6805
- routes: managed.map((route) => ({
6806
- match: [matchFor(route)],
6807
- handle: [proxyHandler(route)],
6808
- terminal: true
6809
- }))
7253
+ tls_connection_policies: [{ default_sni: FALLBACK_NAME }],
7254
+ routes: [
7255
+ ...managed.map((route) => ({
7256
+ match: [matchFor(route)],
7257
+ handle: [proxyHandler(route)],
7258
+ terminal: true
7259
+ })),
7260
+ {
7261
+ match: [{ host: [FALLBACK_NAME] }],
7262
+ ...notConfiguredRoute(),
7263
+ terminal: true
7264
+ },
7265
+ notConfiguredRoute()
7266
+ ]
6810
7267
  };
6811
7268
  if (routes.length > 0) servers.http = {
6812
7269
  listen: [`:${config.httpPort}`],
@@ -6836,7 +7293,8 @@ function renderCaddyConfig(input) {
6836
7293
  headers: { "content-type": ["text/html; charset=utf-8"] },
6837
7294
  body: noticePage(host, config.httpPort, config.httpsPort)
6838
7295
  }]
6839
- }))
7296
+ })),
7297
+ notConfiguredRoute()
6840
7298
  ]
6841
7299
  };
6842
7300
  const adminBlock = { config: { persist: false } };
@@ -6848,10 +7306,25 @@ function renderCaddyConfig(input) {
6848
7306
  }
6849
7307
  const tlsApp = {};
6850
7308
  if (input.manual.length > 0) tlsApp.certificates = { load_files: input.manual };
6851
- if (input.acme.subjects.length > 0) tlsApp.automation = { policies: [{
6852
- subjects: input.acme.subjects,
6853
- issuers: acmeIssuers(input.acme)
6854
- }] };
7309
+ const policies = [];
7310
+ if (input.acme.subjects.length > 0) {
7311
+ const challenge = input.dns01 === void 0 ? {} : challengeBlock(input.dns01);
7312
+ const dnsSubjects = new Set(input.dns01?.policies.flatMap((policy) => policy.subjects) ?? []);
7313
+ for (const policy of input.dns01?.policies ?? []) policies.push({
7314
+ subjects: policy.subjects,
7315
+ issuers: acmeIssuers(input.acme, challenge)
7316
+ });
7317
+ const rest = input.acme.subjects.filter((subject) => !dnsSubjects.has(subject));
7318
+ if (rest.length > 0) policies.push({
7319
+ subjects: rest,
7320
+ issuers: acmeIssuers(input.acme)
7321
+ });
7322
+ }
7323
+ if (managed.length > 0) policies.push({
7324
+ subjects: [FALLBACK_NAME],
7325
+ issuers: [{ module: "internal" }]
7326
+ });
7327
+ if (policies.length > 0) tlsApp.automation = { policies };
6855
7328
  return {
6856
7329
  admin: adminBlock,
6857
7330
  storage: {
@@ -6869,9 +7342,10 @@ function renderCaddyConfig(input) {
6869
7342
  }
6870
7343
  };
6871
7344
  }
6872
- var ACME_STAGING;
7345
+ var ACME_STAGING, FALLBACK_NAME;
6873
7346
  var init_proxy_config = __esmMin((() => {
6874
7347
  ACME_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory";
7348
+ FALLBACK_NAME = "hh-fallback.invalid";
6875
7349
  }));
6876
7350
  //#endregion
6877
7351
  //#region src/services/tls.ts
@@ -7054,6 +7528,17 @@ function coversHost(hosts, host) {
7054
7528
  return name.endsWith(suffix) && !name.slice(0, -suffix.length).includes(".");
7055
7529
  });
7056
7530
  }
7531
+ /**
7532
+ * When the engine tries the CA again, in minutes.
7533
+ *
7534
+ * certmagic renews once a third of a certificate's lifetime is left, so two thirds
7535
+ * of it is the wait; the engine's own CA issues 12 hours by default, which is where
7536
+ * the "about 8 hours" comes from. Read from the certificate, so shortening that
7537
+ * lifetime shortens the wait too.
7538
+ */
7539
+ function retryWindowMinutes(lifetimeMs) {
7540
+ return Math.max(1, Math.round(lifetimeMs / 6e4 * (2 / 3)));
7541
+ }
7057
7542
  /** `host:port`, `http://host:port` or `https://host:port`; `null` when unusable. */
7058
7543
  function parseUpstream(value) {
7059
7544
  const trimmed = value.trim();
@@ -7070,8 +7555,36 @@ function parseUpstream(value) {
7070
7555
  return null;
7071
7556
  }
7072
7557
  }
7558
+ /**
7559
+ * The engine policies DNS-01 needs: one per account, because a policy carries one
7560
+ * challenge provider. Names that no account answers are left out, and the caller
7561
+ * renders them on the port challenges as before.
7562
+ *
7563
+ * Pure so the grouping can be checked without an engine: getting it wrong means a
7564
+ * hostname is asked for through the wrong zone, which fails with nothing to read.
7565
+ */
7566
+ function groupDns01Policies(routes, accountFor) {
7567
+ const byAccount = /* @__PURE__ */ new Map();
7568
+ for (const route of routes) {
7569
+ if (route.tls !== "auto" || !isPublicHost(route.host)) continue;
7570
+ const account = accountFor(route.host);
7571
+ if (account === null) continue;
7572
+ const key = `${account.workspaceId}/${account.accountId}`;
7573
+ const existing = byAccount.get(key);
7574
+ if (existing === void 0) byAccount.set(key, {
7575
+ provider: account.provider,
7576
+ subjects: [route.host]
7577
+ });
7578
+ else if (!existing.subjects.includes(route.host)) existing.subjects.push(route.host);
7579
+ }
7580
+ return [...byAccount.entries()].map(([account, group]) => ({
7581
+ account,
7582
+ provider: group.provider,
7583
+ subjects: group.subjects
7584
+ }));
7585
+ }
7073
7586
  /** Cross-field problems ArkType cannot see, reported by the API before a save. */
7074
- function validateProxyConfig(config) {
7587
+ function validateProxyConfig(config, dnsAccount) {
7075
7588
  const errors = [];
7076
7589
  if (config.httpPort === config.httpsPort) errors.push("the http and https ports must differ");
7077
7590
  const publicNames = [...new Set(config.routes.filter((route) => route.tls !== "off").map((route) => route.host.toLowerCase()))].filter((host) => isPublicHost(host));
@@ -7086,6 +7599,13 @@ function validateProxyConfig(config) {
7086
7599
  seen.add(key);
7087
7600
  if (route.target === "server" && (route.workspace.length === 0 || route.server.length === 0)) errors.push(`route "${route.id}" needs a workspace and a server`);
7088
7601
  if (route.target === "external" && parseUpstream(route.url) === null) errors.push(`route "${route.id}" needs an upstream like http://10.0.0.5:8080`);
7602
+ const named = route.dnsAccount.trim();
7603
+ if (named.length === 0 || dnsAccount === void 0) continue;
7604
+ if (!config.dns01.enabled) errors.push(`route "${route.id}" names DNS account "${named}", but DNS-01 is switched off`);
7605
+ const found = dnsAccount(named, route.workspace);
7606
+ if (found === null) errors.push(`route "${route.id}" names unknown DNS account "${named}"`);
7607
+ else if (!found.writesTxt) errors.push(`route "${route.id}": the "${found.provider}" account cannot write TXT records, so it cannot answer a DNS-01 challenge`);
7608
+ else if (!found.hasCredentials) errors.push(`route "${route.id}": the "${found.provider}" account has no credentials stored yet`);
7089
7609
  }
7090
7610
  return errors;
7091
7611
  }
@@ -7104,12 +7624,14 @@ function engineMessage(body) {
7104
7624
  return body.trim().length > 0 ? body.trim().slice(0, 400) : null;
7105
7625
  }
7106
7626
  }
7107
- var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, engineRecordSchema, appliedRecordSchema, adminRecordSchema, ProxyService, LOCAL_TLDS;
7627
+ var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, ENGINE_COMMAND_TIMEOUT_MS, DNS01_MODULE, engineRecordSchema, challengeAuthSchema, appliedRecordSchema, adminRecordSchema, ProxyService, LOCAL_TLDS;
7108
7628
  var init_proxy$1 = __esmMin((() => {
7109
7629
  init_atomic();
7110
7630
  init_logger();
7111
7631
  init_paths();
7112
7632
  init_runtime();
7633
+ init_ddns$2();
7634
+ init_types();
7113
7635
  init_nanny$2();
7114
7636
  init_port();
7115
7637
  init_proxy$2();
@@ -7120,15 +7642,20 @@ var init_proxy$1 = __esmMin((() => {
7120
7642
  READY_TIMEOUT_MS = 2e4;
7121
7643
  READY_POLL_MS = 250;
7122
7644
  DOWNLOAD_TIMEOUT_MS$1 = 9e5;
7645
+ ENGINE_COMMAND_TIMEOUT_MS = 1e4;
7646
+ DNS01_MODULE = "dns.providers.acmeproxy";
7123
7647
  engineRecordSchema = type({
7124
7648
  engine: "string",
7125
7649
  version: "string",
7126
7650
  source: "\"downloaded\" | \"custom\"",
7127
7651
  url: "string",
7128
- sha256: "string",
7129
7652
  bytes: "number",
7130
7653
  installedAt: "number"
7131
7654
  }).onUndeclaredKey("reject");
7655
+ challengeAuthSchema = type({
7656
+ username: "string >= 1",
7657
+ password: "string >= 1"
7658
+ }).onUndeclaredKey("reject");
7132
7659
  appliedRecordSchema = type({
7133
7660
  httpPort: "1 <= number.integer <= 65535",
7134
7661
  httpsPort: "1 <= number.integer <= 65535",
@@ -7146,6 +7673,10 @@ var init_proxy$1 = __esmMin((() => {
7146
7673
  nanny = null;
7147
7674
  admin = null;
7148
7675
  lastError = null;
7676
+ /** The installed binary's modules, read once per process; an install clears it. */
7677
+ moduleCache = null;
7678
+ /** A legacy `engine.json` is reported once, not on every state frame. */
7679
+ legacyChecksumWarned = false;
7149
7680
  certCache = null;
7150
7681
  /** The certificate state the last apply was built from, so a change re-applies once. */
7151
7682
  certificateSignature = null;
@@ -7166,6 +7697,146 @@ var init_proxy$1 = __esmMin((() => {
7166
7697
  get enginePath() {
7167
7698
  return path.join(this.options.binDir, this.engine.binaryName(process.platform));
7168
7699
  }
7700
+ /**
7701
+ * The Basic credentials the engine uses to reach the panel's DNS-01 endpoint.
7702
+ * Generated on first use and kept 0600: the endpoint is loopback-only, and this
7703
+ * is the second lock on a door that writes into somebody's DNS.
7704
+ */
7705
+ challengeAuth() {
7706
+ const file = this.options.challengeAuthPath ?? proxyChallengeAuthPath;
7707
+ try {
7708
+ const parsed = challengeAuthSchema(JSON.parse(fs.readFileSync(file, "utf8")));
7709
+ if (!(parsed instanceof type.errors)) return parsed;
7710
+ } catch {}
7711
+ const auth = {
7712
+ username: "hh",
7713
+ password: randomBytes(24).toString("base64url")
7714
+ };
7715
+ fs.mkdirSync(path.dirname(file), {
7716
+ recursive: true,
7717
+ mode: 448
7718
+ });
7719
+ writeFileAtomic(file, `${JSON.stringify(auth, null, 2)}\n`, { mode: 384 });
7720
+ return auth;
7721
+ }
7722
+ /**
7723
+ * The DNS-01 half of the renderer's input, or undefined when it is switched off.
7724
+ *
7725
+ * One entry per account, because an engine policy carries one challenge provider:
7726
+ * a name whose zone is at Cloudflare and one at deSEC need a policy each.
7727
+ */
7728
+ dns01Input(routes) {
7729
+ if (!this.config.dns01.enabled) return void 0;
7730
+ const endpoint = this.options.control();
7731
+ const auth = this.challengeAuth();
7732
+ const policies = groupDns01Policies(routes, (host) => this.resolveAccount(host));
7733
+ return {
7734
+ resolvers: this.config.dns01.resolvers,
7735
+ endpoint: `${endpoint.protocol}://127.0.0.1:${endpoint.port}/_acme`,
7736
+ username: auth.username,
7737
+ password: auth.password,
7738
+ policies
7739
+ };
7740
+ }
7741
+ /** The workspace a route's DNS account is looked up in. */
7742
+ workspaceOf(route) {
7743
+ if (route.workspace.length > 0) return route.workspace;
7744
+ return this.options.defaultWorkspaceId?.() ?? "";
7745
+ }
7746
+ /**
7747
+ * The account a route names, or null when it names none.
7748
+ *
7749
+ * A bare id belongs to the route's own workspace; `<workspace>/<id>` crosses into
7750
+ * another one, which is what lets one route table cover zones owned by different
7751
+ * workspaces.
7752
+ */
7753
+ dnsAccountRef(route) {
7754
+ if (route === void 0) return null;
7755
+ const named = route.dnsAccount.trim();
7756
+ if (named.length === 0) return null;
7757
+ const workspaceId = this.workspaceOf(route);
7758
+ if (workspaceId.length === 0) return null;
7759
+ const [owner, accountId] = named.includes("/") ? named.split("/", 2) : [workspaceId, named];
7760
+ const resolved = this.options.resolveDnsAccount?.(owner ?? "", accountId ?? "");
7761
+ if (resolved === void 0 || resolved === null) return null;
7762
+ return {
7763
+ owner: owner ?? "",
7764
+ accountId: accountId ?? "",
7765
+ provider: resolved.provider
7766
+ };
7767
+ }
7768
+ /** The route that answers for a hostname, challenge prefix or not. */
7769
+ routeForHost(host) {
7770
+ return this.config.routes.find((route) => route.enabled && route.host.toLowerCase() === host);
7771
+ }
7772
+ /**
7773
+ * The account that answers a DNS-01 challenge for one hostname.
7774
+ *
7775
+ * The route is the authority. An explicit `dnsAccount` wins; an empty one falls back
7776
+ * to the only account any route names, so a single-account setup needs no per-route
7777
+ * choice — and never to a guess among several, because writing the wrong zone fails
7778
+ * silently.
7779
+ */
7780
+ challengeAccount(fqdn) {
7781
+ const host = fqdn.startsWith("_acme-challenge.") ? fqdn.slice(16) : fqdn;
7782
+ return this.resolveAccount(normalizeHost(host));
7783
+ }
7784
+ resolveAccount(host) {
7785
+ if (!this.config.dns01.enabled) return null;
7786
+ if (!isPublicHost(host)) return null;
7787
+ if (this.options.resolveDnsAccount === void 0) return null;
7788
+ const route = this.routeForHost(host);
7789
+ if (route === void 0) return null;
7790
+ const named = this.dnsAccountRef(route);
7791
+ if (named !== null) return this.accountOf(named.owner, named.accountId);
7792
+ const declared = /* @__PURE__ */ new Map();
7793
+ for (const entry of this.config.routes) {
7794
+ if (!entry.enabled) continue;
7795
+ const ref = this.dnsAccountRef(entry);
7796
+ if (ref !== null) declared.set(`${ref.owner}/${ref.accountId}`, {
7797
+ owner: ref.owner,
7798
+ accountId: ref.accountId
7799
+ });
7800
+ }
7801
+ if (declared.size === 1) {
7802
+ const only = [...declared.values()][0];
7803
+ return this.accountOf(only.owner, only.accountId);
7804
+ }
7805
+ if (declared.size > 1) return null;
7806
+ const usable = (this.options.listDnsAccounts?.() ?? []).filter((entry) => entry.writesTxt && entry.hasCredentials);
7807
+ if (usable.length !== 1) return null;
7808
+ const only = usable[0];
7809
+ return this.accountOf(only.workspace, only.account);
7810
+ }
7811
+ accountOf(workspaceId, accountId) {
7812
+ const found = this.options.resolveDnsAccount?.(workspaceId, accountId);
7813
+ if (found === void 0 || found === null || found.credentials === null) return null;
7814
+ return {
7815
+ workspaceId,
7816
+ accountId,
7817
+ provider: found.provider,
7818
+ credentials: found.credentials
7819
+ };
7820
+ }
7821
+ /**
7822
+ * What a route's `dnsAccount` refers to, for the save-time check.
7823
+ *
7824
+ * A bare id belongs to the default workspace, which is what a person typing one
7825
+ * account means; `<workspace>/<id>` crosses into another.
7826
+ */
7827
+ checkDnsAccount(reference, workspaceId = "") {
7828
+ const named = reference.trim();
7829
+ if (named.length === 0) return null;
7830
+ const owner = named.includes("/") ? named.split("/", 2)[0] ?? "" : workspaceId.length > 0 ? workspaceId : this.options.defaultWorkspaceId?.() ?? "";
7831
+ const accountId = named.includes("/") ? named.split("/", 2)[1] ?? "" : named;
7832
+ const found = this.options.resolveDnsAccount?.(owner, accountId);
7833
+ if (found === void 0 || found === null) return null;
7834
+ return {
7835
+ provider: found.provider,
7836
+ writesTxt: ddnsProvider(found.provider)?.challenge !== void 0,
7837
+ hasCredentials: found.credentials !== null
7838
+ };
7839
+ }
7169
7840
  /** One uploaded pair, by the id that names it in the config. */
7170
7841
  pairStore(id) {
7171
7842
  return new TlsStore(this.options.tlsDir, id);
@@ -7238,6 +7909,7 @@ var init_proxy$1 = __esmMin((() => {
7238
7909
  if (this.certCache !== null && this.certCache.key === key && Date.now() - this.certCache.at < 15e3) return this.certCache;
7239
7910
  const views = this.readCertificateViews();
7240
7911
  const issued = this.readIssuedCertificates();
7912
+ const local = this.readLocalCertificates();
7241
7913
  const values = [...issued.values()];
7242
7914
  const soonest = values.length === 0 ? null : Math.min(...values.map((entry) => entry.notAfter));
7243
7915
  this.certCache = {
@@ -7245,6 +7917,7 @@ var init_proxy$1 = __esmMin((() => {
7245
7917
  key,
7246
7918
  days: soonest === null ? null : Math.floor((soonest - Date.now()) / 864e5),
7247
7919
  issued,
7920
+ local,
7248
7921
  views
7249
7922
  };
7250
7923
  return this.certCache;
@@ -7337,6 +8010,12 @@ var init_proxy$1 = __esmMin((() => {
7337
8010
  message: `expires in ${Math.floor((issued.notAfter - Date.now()) / 864e5)} days`
7338
8011
  };
7339
8012
  const failure = this.obtainFailures().get(route.host.toLowerCase());
8013
+ const local = this.localCertificates().get(route.host.toLowerCase());
8014
+ if (local !== void 0) return {
8015
+ state: "fallback",
8016
+ message: failure ?? "the CA has not issued for this name",
8017
+ retryInMinutes: retryWindowMinutes(local.lifetimeMs)
8018
+ };
7340
8019
  return failure === void 0 ? {
7341
8020
  state: "pending",
7342
8021
  message: "the engine is waiting for a certificate"
@@ -7349,6 +8028,40 @@ var init_proxy$1 = __esmMin((() => {
7349
8028
  issuedCertificates() {
7350
8029
  return this.certSnapshot().issued;
7351
8030
  }
8031
+ /** Certificates the engine's own CA issued, by hostname. */
8032
+ localCertificates() {
8033
+ return this.certSnapshot().local;
8034
+ }
8035
+ /**
8036
+ * The engine's own certificates, which `readIssuedCertificates` skips on purpose.
8037
+ *
8038
+ * A public name in here means the CA would not issue and the engine fell back to
8039
+ * its own CA — a state worth its own name, because the name is served but not by a
8040
+ * trusted issuer, and it clears itself when the engine next tries the CA.
8041
+ */
8042
+ readLocalCertificates() {
8043
+ const found = /* @__PURE__ */ new Map();
8044
+ const root = path.join(this.options.engineDir, "data", "certificates", "local");
8045
+ let entries;
8046
+ try {
8047
+ entries = fs.readdirSync(root, { withFileTypes: true });
8048
+ } catch {
8049
+ return found;
8050
+ }
8051
+ for (const entry of entries) {
8052
+ if (!entry.isDirectory()) continue;
8053
+ try {
8054
+ const x509 = new X509Certificate(fs.readFileSync(path.join(root, entry.name, `${entry.name}.crt`)));
8055
+ const notAfter = new Date(x509.validTo).getTime();
8056
+ const notBefore = new Date(x509.validFrom).getTime();
8057
+ for (const host of namesOf(x509)) found.set(host, {
8058
+ notAfter,
8059
+ lifetimeMs: Math.max(0, notAfter - notBefore)
8060
+ });
8061
+ } catch {}
8062
+ }
8063
+ return found;
8064
+ }
7352
8065
  readIssuedCertificates() {
7353
8066
  const found = /* @__PURE__ */ new Map();
7354
8067
  const root = path.join(this.options.engineDir, "data", "certificates");
@@ -7406,7 +8119,7 @@ var init_proxy$1 = __esmMin((() => {
7406
8119
  const identifier = typeof inner.identifier === "string" ? inner.identifier.toLowerCase() : null;
7407
8120
  if (identifier === null) continue;
7408
8121
  if (inner.logger === "tls.obtain" && typeof inner.error === "string") failures.set(identifier, inner.error);
7409
- if (inner.msg === "certificate obtained successfully") failures.delete(identifier);
8122
+ if (inner.msg === "certificate obtained successfully" && inner.issuer !== "local") failures.delete(identifier);
7410
8123
  }
7411
8124
  return failures;
7412
8125
  }
@@ -7448,6 +8161,16 @@ var init_proxy$1 = __esmMin((() => {
7448
8161
  }
7449
8162
  }
7450
8163
  /** True when a nanny this panel would recognise still owns the engine. */
8164
+ /**
8165
+ * True while a process from the installed binary is alive, however the panel's view
8166
+ * reads. `live()` additionally requires a fresh heartbeat, so a nanny that died and
8167
+ * left its child behind would look gone while the binary is still in use.
8168
+ */
8169
+ engineRunning() {
8170
+ const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
8171
+ if (state === null || state.serverId !== PROXY_ID) return false;
8172
+ return [state.nannyPid, state.childPid].some((pid) => pid !== null && pid > 0 && isProcessAlive$1(pid));
8173
+ }
7451
8174
  live() {
7452
8175
  const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
7453
8176
  if (state === null || state.serverId !== PROXY_ID) return false;
@@ -7467,7 +8190,7 @@ var init_proxy$1 = __esmMin((() => {
7467
8190
  const applied = this.readApplied();
7468
8191
  const httpPort = applied?.httpPort ?? config.httpPort;
7469
8192
  const httpsPort = applied?.httpsPort ?? config.httpsPort;
7470
- const urls = [`http://localhost:${httpPort}`, `https://localhost:${httpsPort}`];
8193
+ const urls = [`http://127.0.0.1:${httpPort}`, `https://127.0.0.1:${httpsPort}`];
7471
8194
  const certExpiryDays = this.certificateExpiryDays();
7472
8195
  if (!this.installed()) return {
7473
8196
  ...base,
@@ -7536,8 +8259,63 @@ var init_proxy$1 = __esmMin((() => {
7536
8259
  return config;
7537
8260
  }
7538
8261
  /** Renders the current route table and applies it; the engine keeps serving on failure. */
7539
- async apply() {
8262
+ /**
8263
+ * Makes the engine ask the CA again for one name, without restarting it.
8264
+ *
8265
+ * Caddy keeps the certificate it holds — in memory and on disk — until the name
8266
+ * leaves its configuration, and the engine's own CA is the last issuer in the
8267
+ * policy, so a name the CA refused is served that untrusted pair and never asks
8268
+ * again until renewal. Reloading the configuration without the route is what makes
8269
+ * the engine let go; the stored pair is dropped while it is out, so putting the
8270
+ * route back leaves nothing to reuse and the engine asks the CA.
8271
+ *
8272
+ * Only this name is out of the configuration, and only for the two reloads: every
8273
+ * other route keeps serving.
8274
+ */
8275
+ async retryCertificate(routeId) {
8276
+ const route = this.config.routes.find((entry) => entry.id === routeId);
8277
+ if (route === void 0) throw new DetailedError(`unknown route "${routeId}"`, {
8278
+ statusCode: 404,
8279
+ code: "PROXY_ROUTE_UNKNOWN"
8280
+ });
8281
+ if (route.tls !== "auto" || !isPublicHost(route.host)) throw new DetailedError(`"${route.host}" is not a name a CA issues for, so there is nothing to retry`, {
8282
+ statusCode: 400,
8283
+ code: "PROXY_CERT_NOT_MANAGED"
8284
+ });
8285
+ if (!this.installed()) throw new DetailedError("the proxy engine is not installed yet", {
8286
+ statusCode: 400,
8287
+ code: "ENGINE_MISSING"
8288
+ });
8289
+ if (this.status().state !== "running") {
8290
+ this.forgetCertificates(route.host);
8291
+ await this.apply({ force: true });
8292
+ return;
8293
+ }
8294
+ await this.loadIntoEngine(this.render(/* @__PURE__ */ new Set([route.id])));
8295
+ this.forgetCertificates(route.host);
8296
+ await this.apply({ force: true });
8297
+ }
8298
+ /** Removes the engine's stored certificates for one name, and nothing else. */
8299
+ forgetCertificates(host) {
8300
+ const root = path.join(this.options.engineDir, "data", "certificates");
8301
+ let issuers;
8302
+ try {
8303
+ issuers = fs.readdirSync(root);
8304
+ } catch {
8305
+ return;
8306
+ }
8307
+ for (const issuer of issuers) {
8308
+ const dir = path.join(root, issuer, host);
8309
+ if (!dir.startsWith(`${root}${path.sep}`)) continue;
8310
+ fs.rmSync(dir, {
8311
+ recursive: true,
8312
+ force: true
8313
+ });
8314
+ }
8315
+ }
8316
+ async apply(options = {}) {
7540
8317
  if (!this.config.enabled || !this.installed()) return;
8318
+ await this.assertDns01Module();
7541
8319
  if (this.admin === null) {
7542
8320
  const stillThere = this.readAdmin();
7543
8321
  if (stillThere === null) return;
@@ -7545,7 +8323,7 @@ var init_proxy$1 = __esmMin((() => {
7545
8323
  }
7546
8324
  const rendered = this.render();
7547
8325
  const text = `${JSON.stringify(rendered, null, 2)}\n`;
7548
- if (fs.existsSync(this.options.configPath) && fs.readFileSync(this.options.configPath, "utf8") === text) {
8326
+ if (options.force !== true && fs.existsSync(this.options.configPath) && fs.readFileSync(this.options.configPath, "utf8") === text) {
7549
8327
  this.lastError = null;
7550
8328
  return;
7551
8329
  }
@@ -7566,13 +8344,69 @@ var init_proxy$1 = __esmMin((() => {
7566
8344
  }
7567
8345
  this.rememberConfig(text);
7568
8346
  this.rememberApplied(this.config.httpPort, this.config.httpsPort);
8347
+ const swept = this.sweepUnroutedCertificates();
8348
+ if (swept > 0) logger.info(`proxy: dropped ${swept} certificate(s) for names no longer routed`);
7569
8349
  this.lastError = null;
7570
8350
  this.options.onStateChange();
7571
8351
  }
8352
+ /**
8353
+ * Certificates the engine holds for names it no longer serves.
8354
+ *
8355
+ * Caddy keeps a certificate until the name leaves its configuration, so a removed
8356
+ * route would leave its pair behind and re-adding the route would serve that stale
8357
+ * certificate instead of asking the CA. A *disabled* route keeps its pair: it is
8358
+ * still configured, and a temporary switch-off should not cost a new issuance.
8359
+ */
8360
+ sweepUnroutedCertificates() {
8361
+ const managed = new Set(this.config.routes.filter((route) => route.tls !== "off").map((route) => route.host.toLowerCase()));
8362
+ managed.add(FALLBACK_NAME.toLowerCase());
8363
+ const root = path.join(this.options.engineDir, "data", "certificates");
8364
+ let issuers;
8365
+ try {
8366
+ issuers = fs.readdirSync(root);
8367
+ } catch {
8368
+ return 0;
8369
+ }
8370
+ let removed = 0;
8371
+ for (const issuer of issuers) {
8372
+ const dir = path.join(root, issuer);
8373
+ let hosts;
8374
+ try {
8375
+ hosts = fs.readdirSync(dir, { withFileTypes: true });
8376
+ } catch {
8377
+ continue;
8378
+ }
8379
+ for (const host of hosts) {
8380
+ if (!host.isDirectory() || managed.has(host.name.toLowerCase())) continue;
8381
+ fs.rmSync(path.join(dir, host.name), {
8382
+ recursive: true,
8383
+ force: true
8384
+ });
8385
+ removed += 1;
8386
+ }
8387
+ }
8388
+ return removed;
8389
+ }
8390
+ /** Hands a configuration to the running engine without touching the file on disk. */
8391
+ async loadIntoEngine(rendered) {
8392
+ const result = await this.request("POST", "/load", rendered).catch((error) => {
8393
+ throw new DetailedError(`the engine did not accept the configuration: ${error instanceof Error ? error.message : String(error)}`, {
8394
+ statusCode: 502,
8395
+ code: "ENGINE_UNREACHABLE"
8396
+ });
8397
+ });
8398
+ if (result.status >= 400) {
8399
+ const message = engineMessage(result.body) ?? `the engine rejected the configuration (HTTP ${result.status})`;
8400
+ throw new DetailedError(message, {
8401
+ statusCode: 400,
8402
+ code: "PROXY_CONFIG_REJECTED"
8403
+ });
8404
+ }
8405
+ }
7572
8406
  /** Advances `previous.json` only for a configuration the engine accepted. */
7573
8407
  rememberConfig(text) {
7574
- if (fs.existsSync(this.options.configPath)) writeFileAtomic(this.options.previousConfigPath, fs.readFileSync(this.options.configPath, "utf8"));
7575
- writeFileAtomic(this.options.configPath, text);
8408
+ if (fs.existsSync(this.options.configPath)) writeFileAtomic(this.options.previousConfigPath, fs.readFileSync(this.options.configPath, "utf8"), { mode: 384 });
8409
+ writeFileAtomic(this.options.configPath, text, { mode: 384 });
7576
8410
  }
7577
8411
  /** The ports the engine last bound. */
7578
8412
  readApplied() {
@@ -7641,9 +8475,9 @@ var init_proxy$1 = __esmMin((() => {
7641
8475
  code: "PROXY_CONFIG_REJECTED"
7642
8476
  });
7643
8477
  }
7644
- writeFileAtomic(this.options.configPath, previous);
8478
+ writeFileAtomic(this.options.configPath, previous, { mode: 384 });
7645
8479
  if (current === null) fs.rmSync(this.options.previousConfigPath, { force: true });
7646
- else writeFileAtomic(this.options.previousConfigPath, current);
8480
+ else writeFileAtomic(this.options.previousConfigPath, current, { mode: 384 });
7647
8481
  this.lastError = null;
7648
8482
  this.options.onStateChange();
7649
8483
  }
@@ -7651,19 +8485,20 @@ var init_proxy$1 = __esmMin((() => {
7651
8485
  * The engine configuration for the current route table. Throws when a route cannot
7652
8486
  * be built, because a half-built configuration is worse than a refused one.
7653
8487
  */
7654
- render() {
7655
- const blocked = this.resolveRoutes().find((view) => view.status === "error");
8488
+ render(exclude) {
8489
+ const blocked = this.resolveRoutes(exclude).find((view) => view.status === "error");
7656
8490
  if (blocked !== void 0) throw new DetailedError(blocked.message ?? `${blocked.route.host} cannot be routed`, {
7657
8491
  statusCode: 400,
7658
8492
  code: "PROXY_ROUTE_INVALID"
7659
8493
  });
7660
- const routes = this.engineRoutes();
8494
+ const routes = this.engineRoutes(exclude);
7661
8495
  return renderCaddyConfig({
7662
8496
  config: this.config,
7663
8497
  admin: this.admin ?? this.unixAdmin(),
7664
8498
  engineDir: this.options.engineDir,
7665
8499
  manual: this.manualPairs(),
7666
8500
  acme: this.acmeAccount(routes),
8501
+ dns01: this.dns01Input(routes),
7667
8502
  routes
7668
8503
  });
7669
8504
  }
@@ -7684,9 +8519,9 @@ var init_proxy$1 = __esmMin((() => {
7684
8519
  };
7685
8520
  }
7686
8521
  /** Every route, resolved far enough to render. */
7687
- resolveRoutes() {
8522
+ resolveRoutes(exclude) {
7688
8523
  const seen = /* @__PURE__ */ new Map();
7689
- return this.config.routes.map((route) => {
8524
+ return this.config.routes.filter((route) => exclude?.has(route.id) !== true).map((route) => {
7690
8525
  const key = `${route.host.toLowerCase()}${route.path}`;
7691
8526
  const clash = seen.get(key);
7692
8527
  if (clash !== void 0) return {
@@ -7780,8 +8615,8 @@ var init_proxy$1 = __esmMin((() => {
7780
8615
  });
7781
8616
  }
7782
8617
  /** Only the routes the engine can serve, in the shape the renderer wants. */
7783
- engineRoutes() {
7784
- return this.resolveRoutes().filter((resolved) => resolved.status === "ok" && resolved.upstream !== null).map((resolved) => {
8618
+ engineRoutes(exclude) {
8619
+ return this.resolveRoutes(exclude).filter((resolved) => resolved.status === "ok" && resolved.upstream !== null).map((resolved) => {
7785
8620
  const certificate = this.certificateFor(resolved.route);
7786
8621
  return {
7787
8622
  host: resolved.route.host,
@@ -7813,7 +8648,8 @@ var init_proxy$1 = __esmMin((() => {
7813
8648
  engines: proxyEngineInfos(),
7814
8649
  status: this.status(),
7815
8650
  routes: this.routeViews(),
7816
- certificates: this.certificateViews()
8651
+ certificates: this.certificateViews(),
8652
+ dnsAccounts: this.options.listDnsAccounts?.() ?? []
7817
8653
  };
7818
8654
  }
7819
8655
  installed() {
@@ -7825,8 +8661,18 @@ var init_proxy$1 = __esmMin((() => {
7825
8661
  }
7826
8662
  readEngineRecord() {
7827
8663
  try {
7828
- const parsed = engineRecordSchema(JSON.parse(fs.readFileSync(path.join(this.options.binDir, "engine.json"), "utf8")));
7829
- return parsed instanceof type.errors ? null : parsed;
8664
+ const parsed = JSON.parse(fs.readFileSync(path.join(this.options.binDir, "engine.json"), "utf8"));
8665
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
8666
+ const record = parsed;
8667
+ if ("sha256" in record) {
8668
+ delete record.sha256;
8669
+ if (!this.legacyChecksumWarned) {
8670
+ this.legacyChecksumWarned = true;
8671
+ logger.warn("proxy: dropped the recorded engine checksum; nothing verifies it");
8672
+ }
8673
+ }
8674
+ const parsedRecord = engineRecordSchema(record);
8675
+ return parsedRecord instanceof type.errors ? null : parsedRecord;
7830
8676
  } catch {
7831
8677
  return null;
7832
8678
  }
@@ -7840,7 +8686,6 @@ var init_proxy$1 = __esmMin((() => {
7840
8686
  source: null,
7841
8687
  path: null,
7842
8688
  bytes: null,
7843
- sha256: null,
7844
8689
  error: null
7845
8690
  };
7846
8691
  let stats;
@@ -7856,14 +8701,17 @@ var init_proxy$1 = __esmMin((() => {
7856
8701
  version: record?.version ?? null,
7857
8702
  source: record?.source ?? (stats.isFile() ? "custom" : null),
7858
8703
  path: path_,
7859
- bytes: record?.bytes ?? stats.size,
7860
- sha256: record?.sha256 ?? null
8704
+ bytes: record?.bytes ?? stats.size
7861
8705
  };
7862
8706
  }
7863
8707
  /**
7864
- * Downloads a pinned release from the engine's own build service. Plugins are
7865
- * compiled in server-side, so no toolchain is involved and the version is
7866
- * whatever was asked for — never "latest" by accident.
8708
+ * Downloads a release from the engine's own build service. Plugins are compiled
8709
+ * in server-side, so no toolchain is involved. An empty `version` asks for the
8710
+ * current release, which is how a security fix reaches the user by pressing Update.
8711
+ *
8712
+ * Refused while the engine is up: the binary is replaced on disk, and Windows
8713
+ * will not replace a running executable at all. Stopping first is the caller's
8714
+ * move, so the panel never drops a socket that faces the internet on its own.
7867
8715
  */
7868
8716
  async install(version = "") {
7869
8717
  const engine = this.engine;
@@ -7871,6 +8719,10 @@ var init_proxy$1 = __esmMin((() => {
7871
8719
  statusCode: 400,
7872
8720
  code: "INVALID_ENGINE_VERSION"
7873
8721
  });
8722
+ if (this.engineRunning()) throw new DetailedError(`the ${engine.info.label} engine is still running — stop it before installing another build`, {
8723
+ statusCode: 409,
8724
+ code: "ENGINE_BUSY"
8725
+ });
7874
8726
  const download = engine.download({
7875
8727
  platform: process.platform,
7876
8728
  arch: process.arch,
@@ -7894,13 +8746,11 @@ var init_proxy$1 = __esmMin((() => {
7894
8746
  statusCode: 502,
7895
8747
  code: "ENGINE_DOWNLOAD_FAILED"
7896
8748
  });
7897
- const hash = createHash("sha256");
7898
8749
  const handle = fs.openSync(target, "w", 493);
7899
8750
  let bytes = 0;
7900
8751
  try {
7901
8752
  for await (const chunk of response.body) {
7902
8753
  const buffer = Buffer$1.isBuffer(chunk) ? chunk : Buffer$1.from(chunk);
7903
- hash.update(buffer);
7904
8754
  bytes += buffer.length;
7905
8755
  fs.writeSync(handle, buffer);
7906
8756
  }
@@ -7914,30 +8764,33 @@ var init_proxy$1 = __esmMin((() => {
7914
8764
  code: "ENGINE_DOWNLOAD_FAILED"
7915
8765
  });
7916
8766
  }
7917
- const sha256 = hash.digest("hex");
7918
8767
  if (process.platform !== "win32") fs.chmodSync(target, 493);
7919
8768
  fs.renameSync(target, this.enginePath);
7920
8769
  if (process.platform !== "win32") fs.chmodSync(this.enginePath, 493);
7921
8770
  const probed = await this.probeEngineVersion();
7922
8771
  const record = {
7923
8772
  engine: engine.info.id,
7924
- version: probed ?? download.version,
8773
+ version: probed ?? (download.version.length > 0 ? download.version : "unknown"),
7925
8774
  source: "downloaded",
7926
8775
  url: download.url,
7927
- sha256,
7928
8776
  bytes,
7929
8777
  installedAt: Date.now()
7930
8778
  };
7931
8779
  writeFileAtomic(path.join(this.options.binDir, "engine.json"), `${JSON.stringify(record, null, 2)}\n`, { mode: 384 });
8780
+ this.moduleCache = null;
7932
8781
  logger.info(`proxy: installed ${engine.info.label} ${record.version} (${Math.round(bytes / 1024 / 1024)} MB)`);
7933
8782
  this.options.onStateChange();
7934
8783
  return this.engineStatus();
7935
8784
  }
7936
- /** Runs `version` once and reports what the binary actually is. */
7937
- async probeEngineVersion() {
7938
- if (!this.installed()) return null;
7939
- return await new Promise((resolve) => {
7940
- const child = spawn(this.enginePath, ["version"], {
8785
+ /**
8786
+ * Runs the binary for its stdout, with a deadline. The engine is operator-supplied
8787
+ * — a replaced or wrapped binary that never exits must not hang `start()` or the
8788
+ * request that triggered it, so a timeout kills it and reads as "no answer".
8789
+ */
8790
+ runEngine(args, timeoutMs) {
8791
+ if (!this.installed()) return Promise.resolve(null);
8792
+ return new Promise((resolve) => {
8793
+ const child = spawn(this.enginePath, args, {
7941
8794
  stdio: [
7942
8795
  "ignore",
7943
8796
  "pipe",
@@ -7946,11 +8799,59 @@ var init_proxy$1 = __esmMin((() => {
7946
8799
  windowsHide: true
7947
8800
  });
7948
8801
  let output = "";
8802
+ let settled = false;
8803
+ let timer;
8804
+ let killTimer;
8805
+ const finish = (value) => {
8806
+ if (settled) return;
8807
+ settled = true;
8808
+ if (timer !== void 0) clearTimeout(timer);
8809
+ if (killTimer !== void 0) clearTimeout(killTimer);
8810
+ resolve(value);
8811
+ };
8812
+ timer = setTimeout(() => {
8813
+ child.kill();
8814
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 500);
8815
+ killTimer.unref();
8816
+ finish(null);
8817
+ }, timeoutMs);
7949
8818
  child.stdout?.on("data", (chunk) => {
7950
8819
  output += chunk.toString("utf8");
7951
8820
  });
7952
- child.on("error", () => resolve(null));
7953
- child.on("exit", () => resolve(this.engine.parseVersion(output)));
8821
+ child.on("error", () => finish(null));
8822
+ child.on("exit", () => finish(output));
8823
+ });
8824
+ }
8825
+ /** Runs `version` once and reports what the binary actually is. */
8826
+ async probeEngineVersion() {
8827
+ const output = await this.runEngine(["version"], this.options.engineCommandTimeoutMs ?? ENGINE_COMMAND_TIMEOUT_MS);
8828
+ return output === null ? null : this.engine.parseVersion(output);
8829
+ }
8830
+ /**
8831
+ * The modules the installed binary carries, by module name.
8832
+ *
8833
+ * A build from an earlier release has no ACMEProxy module, and a configuration
8834
+ * that names it fails inside the engine with something a person cannot act on.
8835
+ * `list-modules` answers in a few milliseconds, so asking is cheaper than the
8836
+ * support question it prevents.
8837
+ */
8838
+ async installedModules() {
8839
+ if (!this.installed()) return null;
8840
+ if (this.moduleCache !== null) return this.moduleCache;
8841
+ const output = await this.runEngine(["list-modules"], this.options.engineCommandTimeoutMs ?? ENGINE_COMMAND_TIMEOUT_MS);
8842
+ if (output === null) return null;
8843
+ const modules = new Set(output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0));
8844
+ this.moduleCache = modules;
8845
+ return modules;
8846
+ }
8847
+ /** The module the panel's DNS-01 configuration needs the engine to carry. */
8848
+ async assertDns01Module() {
8849
+ if (!this.config.dns01.enabled) return;
8850
+ const modules = await this.installedModules();
8851
+ if (modules === null || modules.has(DNS01_MODULE)) return;
8852
+ throw new DetailedError(`the installed ${this.engine.info.label} build has no ${DNS01_MODULE} module — update the engine to enable DNS-01`, {
8853
+ statusCode: 400,
8854
+ code: "ENGINE_MODULE_NOT_INSTALLED"
7954
8855
  });
7955
8856
  }
7956
8857
  async start() {
@@ -7968,6 +8869,7 @@ var init_proxy$1 = __esmMin((() => {
7968
8869
  statusCode: 400,
7969
8870
  code: "ENGINE_MISSING"
7970
8871
  });
8872
+ await this.assertDns01Module();
7971
8873
  if (this.live()) {
7972
8874
  const admin = this.readAdmin();
7973
8875
  if (admin === null) throw new DetailedError("the engine is already running but its admin endpoint is unknown — stop it first", {
@@ -7993,6 +8895,7 @@ var init_proxy$1 = __esmMin((() => {
7993
8895
  engineDir: this.options.engineDir,
7994
8896
  manual: this.manualPairs(),
7995
8897
  acme: this.acmeAccount(routes),
8898
+ dns01: this.dns01Input(routes),
7996
8899
  routes
7997
8900
  });
7998
8901
  fs.mkdirSync(this.options.engineDir, {
@@ -8099,6 +9002,7 @@ var init_proxy$1 = __esmMin((() => {
8099
9002
  this.admin = null;
8100
9003
  this.certCache = null;
8101
9004
  this.certificateSignature = null;
9005
+ this.lastError = null;
8102
9006
  fs.rmSync(nannySpecPath(this.options.stateDir, PROXY_ID), { force: true });
8103
9007
  fs.rmSync(this.options.adminPath, { force: true });
8104
9008
  fs.rmSync(path.join(this.options.stateDir, "applied.json"), { force: true });
@@ -8249,7 +9153,7 @@ var init_proxy$1 = __esmMin((() => {
8249
9153
  /** The patch, applied to the live config the way the store would apply it. */
8250
9154
  function candidateConfig(current, patch) {
8251
9155
  const merged = { ...current };
8252
- for (const [key, value] of Object.entries(patch)) if (value !== void 0) merged[key] = value;
9156
+ applyPatch(merged, patch, PROXY_MERGE_KEYS);
8253
9157
  const parsed = proxyConfigSchema(merged);
8254
9158
  if (parsed instanceof type.errors) throw new DetailedError(parsed.summary, {
8255
9159
  statusCode: 400,
@@ -8267,7 +9171,7 @@ function createProxyRoute(deps) {
8267
9171
  const view = () => proxy().view();
8268
9172
  /** Everything that has to hold before a change is written to disk. */
8269
9173
  const guard = (candidate) => {
8270
- const problems = validateProxyConfig(candidate);
9174
+ const problems = validateProxyConfig(candidate, (ref, workspaceId) => proxy().checkDnsAccount(ref, workspaceId));
8271
9175
  if (problems.length > 0) throw new DetailedError(`invalid reverse proxy configuration: ${problems.join("; ")}`, {
8272
9176
  statusCode: 400,
8273
9177
  code: "INVALID_PROXY",
@@ -8363,6 +9267,20 @@ function createProxyRoute(deps) {
8363
9267
  }), async (c) => {
8364
9268
  await proxy().stop();
8365
9269
  return c.json(view());
9270
+ }).post("/proxy/routes/:id/retry-certificate", describeRoute({
9271
+ tags: ["proxy"],
9272
+ summary: "Make the engine ask the CA again for one route",
9273
+ responses: {
9274
+ 200: {
9275
+ description: "Retried",
9276
+ content: jsonBody(proxyViewSchema)
9277
+ },
9278
+ 400: ERROR_RESPONSES[400],
9279
+ 404: ERROR_RESPONSES[404]
9280
+ }
9281
+ }), async (c) => {
9282
+ await proxy().retryCertificate(c.req.param("id"));
9283
+ return c.json(view());
8366
9284
  }).post("/proxy/apply", describeRoute({
8367
9285
  tags: ["proxy"],
8368
9286
  summary: "Regenerate and reload the engine configuration",
@@ -8427,6 +9345,7 @@ function createProxyRoute(deps) {
8427
9345
  }
8428
9346
  var certificateParam;
8429
9347
  var init_proxy = __esmMin((() => {
9348
+ init_patch();
8430
9349
  init_settings$1();
8431
9350
  init_factory();
8432
9351
  init_openapi$1();
@@ -9295,10 +10214,11 @@ function createRootApp(deps) {
9295
10214
  const started = Date.now();
9296
10215
  await next();
9297
10216
  logger.debug(`${c.req.method} ${new URL(c.req.url).pathname} ${c.res.status} ${Date.now() - started}ms`);
9298
- }).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", createProxyRoute(deps)).route("/api", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/workspaces", createWorkspacesRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
10217
+ }).onError(errorHandler).route("/_hh", createControlRoute(deps)).route("/_acme", createAcmeChallengeRoute(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", createProxyRoute(deps)).route("/api", createMetricsRoute(deps)).route("/api", createBackupsRoute(deps)).route("/api/workspaces", createWorkspacesRoute(deps)).route("/api/servers", createServersRoute(deps)).route("/", createHealthRoute(deps));
9299
10218
  return app.route("/", setupOpenAPI(app)).route("/", createStaticRoute({ dir: () => deps.ui.resolveDir() }));
9300
10219
  }
9301
10220
  var init_app = __esmMin((() => {
10221
+ init_acme();
9302
10222
  init_$_routes$2();
9303
10223
  init_backups$1();
9304
10224
  init_control();
@@ -9695,6 +10615,100 @@ function resolveRecord(record, vars) {
9695
10615
  }
9696
10616
  var init_template = __esmMin((() => {}));
9697
10617
  //#endregion
10618
+ //#region src/services/acme-challenge.ts
10619
+ function reply(message, status = 200) {
10620
+ return new Response(JSON.stringify(message), {
10621
+ status,
10622
+ headers: { "content-type": "application/json" }
10623
+ });
10624
+ }
10625
+ function failure(status, message) {
10626
+ return new Response(JSON.stringify({ error: message }), {
10627
+ status,
10628
+ headers: { "content-type": "application/json" }
10629
+ });
10630
+ }
10631
+ /** Constant-time, and length-safe: `timingSafeEqual` throws on differing lengths. */
10632
+ function matches(actual, expected) {
10633
+ const left = Buffer$1.from(actual, "utf8");
10634
+ const right = Buffer$1.from(expected, "utf8");
10635
+ if (left.length !== right.length) return false;
10636
+ return crypto.timingSafeEqual(left, right);
10637
+ }
10638
+ var proxyMessageSchema, AcmeChallengeService;
10639
+ var init_acme_challenge = __esmMin((() => {
10640
+ init_ddns$2();
10641
+ init_types();
10642
+ proxyMessageSchema = type({
10643
+ fqdn: "string >= 1",
10644
+ value: "string >= 1"
10645
+ }).onUndeclaredKey("reject");
10646
+ AcmeChallengeService = class {
10647
+ options;
10648
+ constructor(options) {
10649
+ this.options = options;
10650
+ }
10651
+ async handle(request, action) {
10652
+ if (action !== "present" && action !== "cleanup") return failure(404, `unknown action "${action}"`);
10653
+ const auth = this.options.auth();
10654
+ if (auth === null) return failure(403, "DNS-01 is not switched on for this panel");
10655
+ if (!this.authorized(request, auth)) return failure(401, "authentication required");
10656
+ let parsed;
10657
+ try {
10658
+ parsed = await request.json();
10659
+ } catch {
10660
+ return failure(400, "the body must be JSON");
10661
+ }
10662
+ const message = proxyMessageSchema(parsed);
10663
+ if (message instanceof type.errors) return failure(400, "the body must carry fqdn and value");
10664
+ const fqdn = normalizeHost(message.fqdn);
10665
+ const account = this.options.accountFor(fqdn);
10666
+ if (account === null) return failure(404, `no DNS account answers for ${fqdn}`);
10667
+ const provider = ddnsProvider(account.provider);
10668
+ if (provider?.challenge === void 0) return failure(400, `the "${account.provider}" account cannot write TXT records`);
10669
+ const record = {
10670
+ fqdn,
10671
+ value: message.value,
10672
+ action
10673
+ };
10674
+ const result = await provider.challenge(record, {
10675
+ credentials: account.credentials,
10676
+ fetch: this.options.fetchImpl ?? fetch
10677
+ });
10678
+ this.options.onResult?.({
10679
+ fqdn,
10680
+ action,
10681
+ ok: result.ok,
10682
+ message: result.message,
10683
+ account
10684
+ });
10685
+ if (!result.ok && action === "cleanup") return reply({
10686
+ fqdn: message.fqdn,
10687
+ value: message.value
10688
+ });
10689
+ if (!result.ok) return failure(502, result.message);
10690
+ return reply({
10691
+ fqdn: message.fqdn,
10692
+ value: message.value
10693
+ });
10694
+ }
10695
+ authorized(request, auth) {
10696
+ const header = request.headers.get("authorization") ?? "";
10697
+ if (!header.toLowerCase().startsWith("basic ")) return false;
10698
+ try {
10699
+ const decoded = Buffer$1.from(header.slice(6).trim(), "base64").toString("utf8");
10700
+ const separator = decoded.indexOf(":");
10701
+ if (separator < 0) return false;
10702
+ const user = decoded.slice(0, separator);
10703
+ const password = decoded.slice(separator + 1);
10704
+ return matches(user, auth.username) && matches(password, auth.password);
10705
+ } catch {
10706
+ return false;
10707
+ }
10708
+ }
10709
+ };
10710
+ }));
10711
+ //#endregion
9698
10712
  //#region src/helpers/env-file.ts
9699
10713
  /** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
9700
10714
  function parseEnvFile(text) {
@@ -13287,7 +14301,12 @@ var init_ddns = __esmMin((() => {
13287
14301
  ...cache.recordId === void 0 ? {} : { recordId: cache.recordId }
13288
14302
  } }
13289
14303
  };
13290
- const result = await provider.update(record, {
14304
+ const update = provider.update;
14305
+ if (update === void 0) {
14306
+ this.remember(key, "skipped", address, `the "${account.provider}" provider does not update addresses`);
14307
+ continue;
14308
+ }
14309
+ const result = await update(record, {
13291
14310
  credentials: credentials ?? {},
13292
14311
  fetch: fetchImpl,
13293
14312
  signal
@@ -13845,6 +14864,7 @@ var init_panel = __esmMin((() => {
13845
14864
  init_logger();
13846
14865
  init_paths();
13847
14866
  init_version();
14867
+ init_ddns$2();
13848
14868
  init_config_watch();
13849
14869
  init_ddns();
13850
14870
  init_history();
@@ -13904,6 +14924,44 @@ var init_panel = __esmMin((() => {
13904
14924
  config
13905
14925
  };
13906
14926
  }
14927
+ /**
14928
+ * One workspace's DDNS account, credentials included, for the panel-wide proxy.
14929
+ *
14930
+ * The account stays where it lives — a workspace owns its secrets and its
14931
+ * notification routing — so the proxy references it instead of holding a copy.
14932
+ */
14933
+ findDnsAccount(workspaceId, accountId) {
14934
+ const workspace = this.runtimes.get(workspaceId);
14935
+ if (workspace === void 0) return null;
14936
+ const account = workspace.store.ddns.accounts.find((entry) => entry.id === accountId);
14937
+ if (account === void 0) return null;
14938
+ return {
14939
+ workspace,
14940
+ account,
14941
+ credentials: workspace.secrets.getDdnsCredentials(accountId, account.provider)?.values ?? null
14942
+ };
14943
+ }
14944
+ /**
14945
+ * Every DNS account a proxy route may name, across every workspace.
14946
+ *
14947
+ * A route references `<workspace>/<account>`, so the picker needs all of them; the
14948
+ * account itself never leaves the workspace that owns it.
14949
+ */
14950
+ listDnsAccounts() {
14951
+ const accounts = [];
14952
+ for (const workspace of this.runtimes.values()) for (const account of workspace.store.ddns.accounts) {
14953
+ const provider = ddnsProvider(account.provider);
14954
+ accounts.push({
14955
+ workspace: workspace.id,
14956
+ account: account.id,
14957
+ provider: account.provider,
14958
+ label: account.label,
14959
+ writesTxt: provider?.challenge !== void 0,
14960
+ hasCredentials: workspace.secrets.getDdnsCredentials(account.id, account.provider) !== null
14961
+ });
14962
+ }
14963
+ return accounts;
14964
+ }
13907
14965
  serverViews(workspaceId) {
13908
14966
  if (workspaceId !== void 0) return this.runtimes.get(workspaceId)?.supervisor.views() ?? [];
13909
14967
  return this.workspaces().flatMap((runtime) => runtime.supervisor.views());
@@ -15000,6 +16058,16 @@ async function runControlPlane(options) {
15000
16058
  message: null
15001
16059
  };
15002
16060
  },
16061
+ resolveDnsAccount: (workspaceId, accountId) => {
16062
+ const found = panel?.findDnsAccount(workspaceId, accountId);
16063
+ if (found === void 0 || found === null) return null;
16064
+ return {
16065
+ provider: found.account.provider,
16066
+ credentials: found.credentials
16067
+ };
16068
+ },
16069
+ listDnsAccounts: () => panel?.listDnsAccounts() ?? [],
16070
+ defaultWorkspaceId: () => registry.defaultId,
15003
16071
  exposureBlocked: () => checkProxyExposure(settings.proxy, settings.control.auth.enabled, auth.passwordSet, auth.usingDefaultPassword),
15004
16072
  onStateChange: () => panel?.notifyStateChange()
15005
16073
  });
@@ -15060,6 +16128,13 @@ async function runControlPlane(options) {
15060
16128
  backups,
15061
16129
  ui,
15062
16130
  proxy,
16131
+ challenge: new AcmeChallengeService({
16132
+ auth: () => proxy.config.dns01.enabled ? proxy.challengeAuth() : null,
16133
+ accountFor: (fqdn) => proxy.challengeAccount(fqdn),
16134
+ onResult: (result) => {
16135
+ if (!result.ok) logger.warn(`proxy: the DNS-01 challenge for ${result.fqdn} failed (${result.action}): ${result.message}`);
16136
+ }
16137
+ }),
15063
16138
  runtimeToken: token,
15064
16139
  onShutdown: () => shutdown("shutdown requested locally")
15065
16140
  });
@@ -15157,6 +16232,7 @@ var init_src = __esmMin((() => {
15157
16232
  init_template();
15158
16233
  init_version();
15159
16234
  init_port();
16235
+ init_acme_challenge();
15160
16236
  init_auth$1();
15161
16237
  init_backups();
15162
16238
  init_config_watch();