dsh-mobile 0.5.1 → 0.5.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/lib/index.mjs CHANGED
@@ -6120,13 +6120,51 @@ function tomlString(value) {
6120
6120
  return JSON.stringify(value);
6121
6121
  }
6122
6122
  /**
6123
+ * Legacy proxy name, kept only as the default of {@link createFrpcToml}.
6124
+ *
6125
+ * A shared frps accepts one proxy per name across *all* its clients, so a fixed
6126
+ * name let any other DSH installation — or a not yet reaped session of this one —
6127
+ * own `dsh-mobile` first. frpc then logged `start error: proxy name [dsh-mobile]
6128
+ * is already in use`, kept running without publishing the entry port, and the
6129
+ * start-up self-check ended in `frp_start_timeout` ("the public endpoint did not
6130
+ * become ready"). Product callers therefore pass an installation-derived name
6131
+ * from {@link frpProxyName}; this constant only keeps the exported API and the
6132
+ * byte-for-byte compatibility test working for legacy callers.
6133
+ */
6134
+ const FRP_DEFAULT_PROXY_NAME = "dsh-mobile";
6135
+ /**
6136
+ * Validate a proxy name for the generated frpc configuration.
6137
+ *
6138
+ * frps accepts one proxy per name on the whole server, so the name must be a
6139
+ * single safe token: no whitespace, no quotes, no path or bracket characters.
6140
+ */
6141
+ function validateFrpProxyName(value) {
6142
+ if (typeof value !== "string" || value.length < 1 || value.length > 128 || !/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("frp_proxy_name_invalid");
6143
+ return value;
6144
+ }
6145
+ /**
6146
+ * Proxy name for one DSH installation.
6147
+ *
6148
+ * Derived from the stable installation identity so two computers sharing one
6149
+ * frps never register the same proxy, while a single installation keeps the
6150
+ * same name across restarts (so its own stale registration is the only possible
6151
+ * conflict, and that one expires with the frps heartbeat).
6152
+ */
6153
+ function frpProxyName(instanceId) {
6154
+ if (typeof instanceId !== "string" || !/^[a-f0-9]{64}$/u.test(instanceId)) throw new Error("frp_instance_id_invalid");
6155
+ return `${FRP_DEFAULT_PROXY_NAME}-${instanceId.slice(0, 12)}`;
6156
+ }
6157
+ /**
6123
6158
  * Build the single-purpose frpc configuration for the current loopback gateway.
6124
6159
  *
6125
6160
  * The default (public-CA) entry is an HTTP vhost behind Caddy and is emitted byte
6126
6161
  * for byte as before. The self-signed entry cannot use a vhost at all: frps only
6127
6162
  * forwards raw TCP, and the gateway terminates TLS on that connection.
6163
+ *
6164
+ * `proxyName` defaults to the legacy fixed name only for legacy callers; the
6165
+ * provider and the attach preview always pass {@link frpProxyName}.
6128
6166
  */
6129
- function createFrpcToml(settings, localPort) {
6167
+ function createFrpcToml(settings, localPort, proxyName = FRP_DEFAULT_PROXY_NAME) {
6130
6168
  if (!Number.isSafeInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error("frp_local_port_invalid");
6131
6169
  const header = [
6132
6170
  `serverAddr = ${tomlString(settings.serverAddress)}`,
@@ -6136,7 +6174,7 @@ function createFrpcToml(settings, localPort) {
6136
6174
  "transport.tls.enable = true",
6137
6175
  "",
6138
6176
  "[[proxies]]",
6139
- "name = \"dsh-mobile\""
6177
+ `name = ${tomlString(validateFrpProxyName(proxyName))}`
6140
6178
  ];
6141
6179
  if (isFrpSelfSignedIngress(settings)) return [
6142
6180
  ...header,
@@ -6198,13 +6236,22 @@ var FrpConfigStore = class {
6198
6236
  stateRoot;
6199
6237
  settingsFile;
6200
6238
  runtimeConfigFile;
6239
+ /**
6240
+ * Proxy name every generated frpc.toml registers.
6241
+ *
6242
+ * Injected by the composing plugin ({@link frpProxyName}) so one frps can
6243
+ * serve several DSH installations; the legacy fixed name stays the default
6244
+ * for direct constructions in tests and for the exported API.
6245
+ */
6246
+ proxyName;
6201
6247
  settingsValue;
6202
6248
  errorCode;
6203
- constructor(stateDirectory) {
6249
+ constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME) {
6204
6250
  if (!isAbsolute(stateDirectory)) throw new Error("frp config state directory must be absolute");
6205
6251
  this.stateRoot = resolve(stateDirectory);
6206
6252
  this.settingsFile = join(this.stateRoot, "settings.json");
6207
6253
  this.runtimeConfigFile = join(this.stateRoot, "frpc.toml");
6254
+ this.proxyName = validateFrpProxyName(proxyName);
6208
6255
  }
6209
6256
  /** Load private settings while rejecting links, oversized files, and unknown fields. */
6210
6257
  async initialize() {
@@ -6248,6 +6295,7 @@ var FrpConfigStore = class {
6248
6295
  },
6249
6296
  vhostHttpPort: settings === void 0 ? FRP_VHOST_HTTP_PORT : resolveFrpVhostHttpPort(settings),
6250
6297
  storagePath: this.stateRoot,
6298
+ proxyName: this.proxyName,
6251
6299
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode },
6252
6300
  ...mode === void 0 || mode === "deploy" ? {} : { mode },
6253
6301
  ...entryTls === void 0 || entryTls === "public-ip-cert" ? {} : { entryTls },
@@ -6271,7 +6319,7 @@ var FrpConfigStore = class {
6271
6319
  async writeRuntimeConfig(localPort) {
6272
6320
  const settings = this.settingsValue;
6273
6321
  if (settings === void 0) throw new Error("frp_config_missing");
6274
- await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort));
6322
+ await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort, this.proxyName));
6275
6323
  return this.runtimeConfigFile;
6276
6324
  }
6277
6325
  /** Remove only configuration files owned by the FRP provider. */
@@ -6330,7 +6378,7 @@ function redactFrpcTomlToken(frpcToml) {
6330
6378
  * the provider writes stays byte-for-byte identical to the upstream output.
6331
6379
  */
6332
6380
  function createFrpAttachFrpcToml(settings, options = {}) {
6333
- const frpcToml = createFrpcToml(settings, attachLocalPort(options));
6381
+ const frpcToml = createFrpcToml(settings, attachLocalPort(options), options.proxyName ?? "dsh-mobile");
6334
6382
  return options.revealToken === true ? frpcToml : redactFrpcTomlToken(frpcToml);
6335
6383
  }
6336
6384
  const DEFAULT_ATTACH_CONFIG_FILE = "<DSH Mobile 私有目录>/remote/frp/config/frpc.toml";
@@ -7522,6 +7570,30 @@ const DISCOVERY_RETRY_MS = 1e3;
7522
7570
  const MAX_DISCOVERY_BYTES = 16384;
7523
7571
  const VHOST_PROBE_TIMEOUT_MS = 1500;
7524
7572
  const INGRESS_CERT_CHECK_MS = 432e5;
7573
+ /**
7574
+ * frpc start-failure signatures, most specific first.
7575
+ *
7576
+ * frpc reports a refused proxy registration as a warning and keeps running, so
7577
+ * without this mapping the controller only ever saw a healthy child process and
7578
+ * an entry port nobody published: the start-up self-check burned its full 45s
7579
+ * deadline and reported `frp_start_timeout` ("the public endpoint did not become
7580
+ * ready"), which sent users looking at DNS, Caddy, and firewalls.
7581
+ *
7582
+ * The two real conflicts are a proxy name another client of the same frps holds
7583
+ * and an entry port another proxy already owns. Both are reported directly.
7584
+ */
7585
+ const CLIENT_FAILURE_CODES = Object.freeze([
7586
+ [/proxy name \[[^\]]*\] is already in use/iu, "frp_proxy_name_in_use"],
7587
+ [/(?:remote )?port (?:\[?[0-9]+\]? )?already (?:used|in use)/iu, "frp_remote_port_in_use"],
7588
+ [/start error:/iu, "frp_proxy_start_failed"]
7589
+ ]);
7590
+ /**
7591
+ * Stable error code for a chunk of frpc output, or undefined when it reports no
7592
+ * start failure. Exported for the conflict tests.
7593
+ */
7594
+ function frpcDiagnosisCode(output) {
7595
+ return CLIENT_FAILURE_CODES.find(([pattern]) => pattern.test(output))?.[1];
7596
+ }
7525
7597
  function publicStatus$3(status) {
7526
7598
  return Object.freeze({
7527
7599
  enabled: status.enabled,
@@ -7923,15 +7995,37 @@ var FrpController = class {
7923
7995
  return;
7924
7996
  }
7925
7997
  this.child = child;
7998
+ const pending = {
7999
+ stdout: "",
8000
+ stderr: ""
8001
+ };
8002
+ let diagnosed = false;
8003
+ const inspectLine = (line) => {
8004
+ if (diagnosed) return;
8005
+ const code = frpcDiagnosisCode(line);
8006
+ if (code === void 0) return;
8007
+ diagnosed = true;
8008
+ this.enqueue(() => this.failGeneration(generation, code));
8009
+ };
8010
+ const inspect = (stream, chunk) => {
8011
+ if (diagnosed) return;
8012
+ const lines = (pending[stream] + String(chunk)).split(/\r\n|\r|\n/u);
8013
+ pending[stream] = (lines.pop() ?? "").slice(-8192);
8014
+ for (const line of lines) inspectLine(line.slice(-8192));
8015
+ };
8016
+ child.stdout.on("data", (chunk) => inspect("stdout", chunk));
8017
+ child.stderr.on("data", (chunk) => inspect("stderr", chunk));
7926
8018
  child.stdout.resume();
7927
8019
  child.stderr.resume();
7928
8020
  child.once("error", () => {
7929
8021
  this.enqueue(() => this.failGeneration(generation, "frp_launch_failed"));
7930
8022
  });
7931
8023
  child.once("close", (code) => {
8024
+ inspectLine(pending.stdout);
8025
+ inspectLine(pending.stderr);
7932
8026
  if (generation !== this.generation || this.child !== child) return;
7933
8027
  this.child = void 0;
7934
- if (this.enabled) this.enqueue(() => this.failGeneration(generation, code === 0 ? "frp_stopped" : "frp_exited"));
8028
+ if (this.enabled && !diagnosed) this.enqueue(() => this.failGeneration(generation, code === 0 ? "frp_stopped" : "frp_exited"));
7935
8029
  });
7936
8030
  this.publish({
7937
8031
  enabled: true,
@@ -7947,6 +8041,7 @@ var FrpController = class {
7947
8041
  return;
7948
8042
  }
7949
8043
  if (generation !== this.generation || !this.enabled) return;
8044
+ if (diagnosed) return;
7950
8045
  const controller = new AbortController();
7951
8046
  this.startupAbort = controller;
7952
8047
  this.waitForDiscovery(generation, check, gateway.config.instanceId, settings, gateway, controller.signal);
@@ -9571,6 +9666,9 @@ const REMOTE_ERROR_GUIDANCE = Object.freeze({
9571
9666
  frp_vhost_probe_failed: "确认 VPS 地址可解析后重新连接。",
9572
9667
  frp_launch_failed: "重新安装 FRP 官方组件后重试。",
9573
9668
  frp_start_timeout: "确认 frps、Caddy 和域名解析正常后重新连接。",
9669
+ frp_proxy_name_in_use: "该 frps 上已有同名代理:在另一台 DSH 电脑上关闭自建 FRP,或在 frps 上清掉该代理后重新连接。",
9670
+ frp_remote_port_in_use: "公网入口端口被其他代理占用:改用其他入口端口后重新连接。",
9671
+ frp_proxy_start_failed: "查看 frps 日志与 allowPorts,确认它能接受该代理名和入口端口。",
9574
9672
  frp_discovery_mismatch: "公开域名连接到了另一台电脑,请核对 Caddy 与 frps 配置。",
9575
9673
  frp_discovery_invalid: "公开域名返回了非 DSH Mobile 响应。",
9576
9674
  frp_stopped: "点击“重新连接”。",
@@ -12669,6 +12767,36 @@ async function installMobileFileLogger(ctx, stateDirectory) {
12669
12767
  return file;
12670
12768
  }
12671
12769
  //#endregion
12770
+ //#region src/installation-id.ts
12771
+ const INSTALLATION_ID_PATTERN = /^[a-f0-9]{64}\n$/u;
12772
+ const INSTALLATION_ID_BYTES = 65;
12773
+ async function readInstallationId(file) {
12774
+ const entry = await lstat(file);
12775
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== INSTALLATION_ID_BYTES) throw new Error("installation_id_invalid");
12776
+ const contents = await readFile(file, "utf8");
12777
+ if (!INSTALLATION_ID_PATTERN.test(contents)) throw new Error("installation_id_invalid");
12778
+ await restrictPrivateFile(file);
12779
+ return contents.slice(0, -1);
12780
+ }
12781
+ /** Keep one random plugin identity across setup changes, restarts, and FRP resets. */
12782
+ async function loadOrCreateInstallationId(file) {
12783
+ if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
12784
+ const target = resolve(file);
12785
+ await mkdir(dirname(target), {
12786
+ recursive: true,
12787
+ mode: 448
12788
+ });
12789
+ try {
12790
+ await writeFile(target, `${randomBytes(32).toString("hex")}\n`, {
12791
+ flag: "wx",
12792
+ mode: 384
12793
+ });
12794
+ } catch (error) {
12795
+ if (error.code !== "EEXIST") throw error;
12796
+ }
12797
+ return readInstallationId(target);
12798
+ }
12799
+ //#endregion
12672
12800
  //#region src/lan-setup.ts
12673
12801
  const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
12674
12802
  const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
@@ -13066,7 +13194,8 @@ async function apply(ctx, config) {
13066
13194
  await cloudflaredComponent.initialize();
13067
13195
  const frpComponent = new FrpComponentManager({ stateDirectory });
13068
13196
  await frpComponent.initialize();
13069
- const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"));
13197
+ const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
13198
+ const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity));
13070
13199
  await frpConfig.initialize();
13071
13200
  const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
13072
13201
  await originConfig.initialize();
@@ -13544,7 +13673,10 @@ async function apply(ctx, config) {
13544
13673
  }
13545
13674
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/frp/attach-plan`) {
13546
13675
  const settings = mergeSavedFrpSettings(await readJsonObject(request, 8192), frpConfig.settings());
13547
- const options = { configFile: frpConfig.runtimeConfigFile };
13676
+ const options = {
13677
+ configFile: frpConfig.runtimeConfigFile,
13678
+ proxyName: frpConfig.proxyName
13679
+ };
13548
13680
  logger.info("frp attach plan requested mode=%s entryTls=%s vhostHttpPort=%d", settings.mode ?? "deploy", settings.entryTls ?? "public-ip-cert", resolveFrpVhostHttpPort(settings));
13549
13681
  sendJson(response, 200, {
13550
13682
  ...remotePayload(),
@@ -13814,6 +13946,6 @@ async function apply(ctx, config) {
13814
13946
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
13815
13947
  }
13816
13948
  //#endregion
13817
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
13949
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CSRF_COOKIE, CSRF_HEADER, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
13818
13950
 
13819
13951
  //# sourceMappingURL=index.mjs.map