dsh-mobile 0.6.11 → 0.6.13

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
@@ -9,7 +9,7 @@ import { performance as performance$1 } from "node:perf_hooks";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import { createSocket } from "node:dgram";
11
11
  import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
12
- import { createServer as createServer$1, request } from "node:http";
12
+ import { STATUS_CODES, createServer as createServer$1, request } from "node:http";
13
13
  import { createServer as createServer$2, request as request$1 } from "node:https";
14
14
  import { Readable, Transform, finished } from "node:stream";
15
15
  import { finished as finished$1, pipeline } from "node:stream/promises";
@@ -64,6 +64,10 @@ var BoundedRateLimiter = class {
64
64
  current.count += 1;
65
65
  return true;
66
66
  }
67
+ /** Discard entries that no longer identify an eligible key in a trusted, finite key set. */
68
+ retainKeys(eligible) {
69
+ for (const key of this.buckets.keys()) if (!eligible.has(key)) this.buckets.delete(key);
70
+ }
67
71
  /** Current table size, exposed for bounded-state assertions. */
68
72
  get size() {
69
73
  return this.buckets.size;
@@ -104,6 +108,7 @@ var AccessController = class {
104
108
  pairLimiter;
105
109
  devices = [];
106
110
  pairingWindow;
111
+ pairPending = false;
107
112
  sessions = /* @__PURE__ */ new Map();
108
113
  sessionEndedListeners = /* @__PURE__ */ new Set();
109
114
  mutation = Promise.resolve();
@@ -212,40 +217,52 @@ var AccessController = class {
212
217
  });
213
218
  });
214
219
  }
220
+ /** Limit invalid guesses by peer without letting them consume a valid one-time secret's admission. */
221
+ rejectPairingAttempt(sourceKey, now) {
222
+ if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
223
+ throw new AccessError(401, "authentication_failed");
224
+ }
215
225
  /** Consume the pairing window exactly once and persist only the device-token digest. */
216
226
  async pair(sourceKey, token, label) {
217
227
  this.requireInitialized();
218
- const now = this.now();
219
- if (!this.pairLimiter.take(sourceKey, now)) throw new AccessError(429, "rate_limited");
220
- if (token.length > 512) throw new AccessError(401, "authentication_failed");
221
- return this.exclusive(async () => {
222
- const window = this.pairingWindow;
223
- if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
224
- if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
225
- throw new AccessError(401, "authentication_failed");
226
- }
227
- this.pairingWindow = void 0;
228
- if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
229
- const deviceToken = opaqueToken();
230
- const device = Object.freeze({
231
- id: randomBytes(16).toString("hex"),
232
- label: normalizeLabel(label),
233
- tokenDigest: digestHex(deviceToken),
234
- createdAt: now,
235
- expiresAt: now + this.options.deviceTtlMs,
236
- lastSeenAt: now,
237
- ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
238
- });
239
- const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
240
- await this.store.save(this.snapshot(next));
241
- this.devices = next;
242
- const session = this.createSession(device, now);
243
- return Object.freeze({
244
- ...session,
245
- deviceToken,
246
- deviceExpiresAt: device.expiresAt
228
+ const initialNow = this.now();
229
+ const initialWindow = this.pairingWindow;
230
+ if (token.length > 512 || initialWindow === void 0 || initialWindow.expiresAt <= initialNow || !matchesDigest(token, initialWindow.digest)) this.rejectPairingAttempt(sourceKey, initialNow);
231
+ if (this.pairPending) throw new AccessError(429, "rate_limited");
232
+ this.pairPending = true;
233
+ try {
234
+ return await this.exclusive(async () => {
235
+ const now = this.now();
236
+ const window = this.pairingWindow;
237
+ if (window === void 0 || window.expiresAt <= now || !matchesDigest(token, window.digest)) {
238
+ if (window !== void 0 && window.expiresAt <= now) this.pairingWindow = void 0;
239
+ this.rejectPairingAttempt(sourceKey, now);
240
+ }
241
+ this.pairingWindow = void 0;
242
+ if (this.devices.filter((device) => device.revokedAt === void 0 && device.expiresAt > now).length >= this.options.maxDevices) throw new AccessError(409, "device_limit");
243
+ const deviceToken = opaqueToken();
244
+ const device = Object.freeze({
245
+ id: randomBytes(16).toString("hex"),
246
+ label: normalizeLabel(label),
247
+ tokenDigest: digestHex(deviceToken),
248
+ createdAt: now,
249
+ expiresAt: now + this.options.deviceTtlMs,
250
+ lastSeenAt: now,
251
+ ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
252
+ });
253
+ const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
254
+ await this.store.save(this.snapshot(next));
255
+ this.devices = next;
256
+ const session = this.createSession(device, now);
257
+ return Object.freeze({
258
+ ...session,
259
+ deviceToken,
260
+ deviceExpiresAt: device.expiresAt
261
+ });
247
262
  });
248
- });
263
+ } finally {
264
+ this.pairPending = false;
265
+ }
249
266
  }
250
267
  /** Exchange a valid persistent device credential for a new short Session. */
251
268
  async renew(deviceToken) {
@@ -1951,7 +1968,7 @@ function authenticatedCookie(values, authenticate, accepts = () => true) {
1951
1968
  }
1952
1969
  //#endregion
1953
1970
  //#region src/auth-failure-log.ts
1954
- const LOG_INTERVAL_MS = 3e4;
1971
+ const LOG_INTERVAL_MS$1 = 3e4;
1955
1972
  const MAX_LOGGED_ORIGIN_LENGTH = 512;
1956
1973
  function requestPathCategory(path) {
1957
1974
  switch (path) {
@@ -2048,7 +2065,7 @@ var AuthFailureRecorder = class {
2048
2065
  return;
2049
2066
  }
2050
2067
  const suppressed = window.suppressed;
2051
- window.nextLogAt = now + LOG_INTERVAL_MS;
2068
+ window.nextLogAt = now + LOG_INTERVAL_MS$1;
2052
2069
  window.suppressed = 0;
2053
2070
  try {
2054
2071
  this.observe({
@@ -2065,6 +2082,129 @@ var AuthFailureRecorder = class {
2065
2082
  }
2066
2083
  };
2067
2084
  //#endregion
2085
+ //#region src/http-failure-log.ts
2086
+ function safeParserCode(code) {
2087
+ switch (code) {
2088
+ case "ERR_HTTP_REQUEST_TIMEOUT":
2089
+ case "HPE_HEADER_OVERFLOW":
2090
+ case "HPE_INVALID_CHUNK_SIZE":
2091
+ case "HPE_INVALID_CONTENT_LENGTH":
2092
+ case "HPE_INVALID_EOF_STATE":
2093
+ case "HPE_INVALID_HEADER_TOKEN":
2094
+ case "HPE_INVALID_TRANSFER_ENCODING":
2095
+ case "HPE_UNEXPECTED_CONTENT_LENGTH": return code;
2096
+ default: return "other";
2097
+ }
2098
+ }
2099
+ /**
2100
+ * Classify Node's HTTP rejection without reflecting an unrecognized error code.
2101
+ * @param nodeCode - The parser error's machine code, not its message or raw packet.
2102
+ * @returns The fixed public HTTP status/category and allowlisted diagnostic code.
2103
+ */
2104
+ function classifyHttpParserFailure(nodeCode) {
2105
+ const parserCode = safeParserCode(nodeCode);
2106
+ if (parserCode === "ERR_HTTP_REQUEST_TIMEOUT") return {
2107
+ status: 408,
2108
+ code: "request_timeout",
2109
+ parserCode
2110
+ };
2111
+ if (parserCode === "HPE_HEADER_OVERFLOW") return {
2112
+ status: 431,
2113
+ code: "headers_too_large",
2114
+ parserCode
2115
+ };
2116
+ return {
2117
+ status: 400,
2118
+ code: "bad_request",
2119
+ parserCode
2120
+ };
2121
+ }
2122
+ const LOG_INTERVAL_MS = 3e4;
2123
+ /**
2124
+ * Per-Gateway diagnostics with three fixed sampling slots and no timers or request-selected keys.
2125
+ * Each kind records once immediately, then at most once per 30 seconds with suppressed counts.
2126
+ * A sink failure cannot change a response, retry a write or expose the diagnostic sink's error.
2127
+ */
2128
+ var HttpFailureRecorder = class {
2129
+ observe;
2130
+ now;
2131
+ reportObserverFailure;
2132
+ windows = {
2133
+ parser: {
2134
+ nextLogAt: 0,
2135
+ suppressed: 0
2136
+ },
2137
+ target: {
2138
+ nextLogAt: 0,
2139
+ suppressed: 0
2140
+ },
2141
+ upstream: {
2142
+ nextLogAt: 0,
2143
+ suppressed: 0
2144
+ }
2145
+ };
2146
+ observerFailureReported = false;
2147
+ constructor(observe, now = () => performance.now(), reportObserverFailure = () => {
2148
+ process.emitWarning("Mobile access HTTP diagnostics could not be recorded", { code: "DSH_MOBILE_HTTP_LOG_FAILED" });
2149
+ }) {
2150
+ this.observe = observe;
2151
+ this.now = now;
2152
+ this.reportObserverFailure = reportObserverFailure;
2153
+ }
2154
+ /** Classify an inbound parser rejection and sample only its fixed status and safe code. */
2155
+ recordParser(nodeCode) {
2156
+ const failure = classifyHttpParserFailure(nodeCode);
2157
+ this.record({
2158
+ kind: "parser",
2159
+ status: failure.status,
2160
+ code: failure.parserCode
2161
+ });
2162
+ return failure;
2163
+ }
2164
+ /** Sample an invalid request target without retaining its raw or decoded URL. */
2165
+ recordTarget() {
2166
+ this.record({
2167
+ kind: "target",
2168
+ status: 400,
2169
+ code: "bad_request"
2170
+ });
2171
+ }
2172
+ /** Sample only authenticated prompt rejections; the caller must authorize before forwarding. */
2173
+ recordUpstream(method, pathname, status) {
2174
+ if (method !== "POST" || pathname !== "/api/session/prompt" || !Number.isInteger(status) || status < 400 || status > 599) return;
2175
+ this.record({
2176
+ kind: "upstream",
2177
+ status,
2178
+ method: "POST",
2179
+ path: "/api/session/prompt"
2180
+ });
2181
+ }
2182
+ record(entry) {
2183
+ if (this.observe === void 0) return;
2184
+ const now = this.now();
2185
+ const window = this.windows[entry.kind];
2186
+ if (now < window.nextLogAt) {
2187
+ window.suppressed = Math.min(Number.MAX_SAFE_INTEGER, window.suppressed + 1);
2188
+ return;
2189
+ }
2190
+ const suppressed = window.suppressed;
2191
+ window.nextLogAt = now + LOG_INTERVAL_MS;
2192
+ window.suppressed = 0;
2193
+ try {
2194
+ this.observe({
2195
+ ...entry,
2196
+ ...suppressed === 0 ? {} : { suppressed }
2197
+ });
2198
+ } catch (observerError) {
2199
+ if (this.observerFailureReported) return;
2200
+ this.observerFailureReported = true;
2201
+ try {
2202
+ this.reportObserverFailure();
2203
+ } catch (reporterError) {}
2204
+ }
2205
+ }
2206
+ };
2207
+ //#endregion
2068
2208
  //#region src/client-module-recovery.ts
2069
2209
  const CLIENT_MODULE_RECOVERY_COPY = {
2070
2210
  zh: {
@@ -5035,7 +5175,7 @@ function rejectUpgrade(socket, status, code) {
5035
5175
  if (socket.destroyed) return;
5036
5176
  const body = `${JSON.stringify({ error: code })}\n`;
5037
5177
  socket.end([
5038
- `HTTP/1.1 ${String(status)} ${status === 401 ? "Unauthorized" : status === 403 ? "Forbidden" : "Bad Request"}`,
5178
+ `HTTP/1.1 ${String(status)} ${STATUS_CODES[status] ?? "Bad Request"}`,
5039
5179
  "Connection: close",
5040
5180
  "Cache-Control: no-store",
5041
5181
  "Content-Type: application/json; charset=utf-8",
@@ -5395,14 +5535,19 @@ var MobileAccessGateway = class {
5395
5535
  closeTask;
5396
5536
  removeSessionListener;
5397
5537
  removeExtensionContentListener;
5398
- renewLimiter;
5399
- probeLimiter;
5538
+ renewFailureLimiter;
5539
+ renewDeviceLimiter;
5540
+ renewInFlight = /* @__PURE__ */ new Set();
5541
+ probeFailureLimiter;
5542
+ probeDeviceLimiter;
5543
+ probeInFlight = /* @__PURE__ */ new Set();
5400
5544
  clientModulePreferences;
5401
5545
  clientModuleOperations = /* @__PURE__ */ new Set();
5402
5546
  clientModuleTasks = /* @__PURE__ */ new Set();
5403
5547
  clientModuleRevocations = /* @__PURE__ */ new Set();
5404
5548
  authFailures;
5405
- constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog) {
5549
+ httpFailures;
5550
+ constructor(config, store, extensions, upstreamAuthenticatedUrl, extraWebSocketPaths, blockedUpgradeLog, onDiscoveryDegraded, clientModulePreferences, authFailureLog, httpFailureLog) {
5406
5551
  this.config = config;
5407
5552
  this.extensions = extensions;
5408
5553
  this.upstreamAuthenticatedUrl = upstreamAuthenticatedUrl;
@@ -5410,6 +5555,7 @@ var MobileAccessGateway = class {
5410
5555
  this.blockedUpgradeLog = blockedUpgradeLog;
5411
5556
  this.onDiscoveryDegraded = onDiscoveryDegraded;
5412
5557
  this.authFailures = new AuthFailureRecorder(authFailureLog);
5558
+ this.httpFailures = new HttpFailureRecorder(httpFailureLog);
5413
5559
  this.clientModulePreferences = clientModulePreferences ?? new ClientModulePreferenceStore(join(dirname(config.stateFile), `${basename(config.stateFile)}.client-modules.json`), config.excludedClientModules);
5414
5560
  this.listenerTlsEnabled = config.tls.mode === "provided";
5415
5561
  this.tlsEnabled = config.publicTls;
@@ -5425,8 +5571,12 @@ var MobileAccessGateway = class {
5425
5571
  maxPairingAttempts: config.maxPairingAttempts,
5426
5572
  maxRateLimitKeys: config.maxRateLimitKeys
5427
5573
  });
5428
- this.renewLimiter = new BoundedRateLimiter(Math.min(100, config.maxPairingAttempts * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
5429
- this.probeLimiter = new BoundedRateLimiter(Math.min(1e3, config.maxDevices * 4), config.rateLimitWindowMs, config.maxRateLimitKeys);
5574
+ const renewLimit = Math.min(100, config.maxPairingAttempts * 4);
5575
+ const probeLimit = Math.min(1e3, config.maxDevices * 4);
5576
+ this.renewFailureLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5577
+ this.renewDeviceLimiter = new BoundedRateLimiter(renewLimit, config.rateLimitWindowMs, config.maxDevices);
5578
+ this.probeFailureLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxRateLimitKeys);
5579
+ this.probeDeviceLimiter = new BoundedRateLimiter(probeLimit, config.rateLimitWindowMs, config.maxDevices);
5430
5580
  this.removeSessionListener = this.access.onSessionEnded((authorization, reason) => {
5431
5581
  if (reason === "revoked") {
5432
5582
  const cleanup = this.clientModulePreferences.removeDevice(authorization.deviceId).catch(() => {
@@ -5502,8 +5652,10 @@ var MobileAccessGateway = class {
5502
5652
  rejectUpgrade(client, mapped.status, mapped.code);
5503
5653
  });
5504
5654
  });
5505
- server.on("clientError", (_error, socket) => {
5506
- rejectUpgrade(socket, 400, "bad_request");
5655
+ server.on("clientError", (error, socket) => {
5656
+ const nodeCode = "code" in error && typeof error.code === "string" ? error.code : void 0;
5657
+ const failure = this.httpFailures.recordParser(nodeCode);
5658
+ rejectUpgrade(socket, failure.status, failure.code);
5507
5659
  });
5508
5660
  await new Promise((resolve, reject) => {
5509
5661
  const failed = (error) => {
@@ -5754,6 +5906,27 @@ var MobileAccessGateway = class {
5754
5906
  maxAgeSeconds: (deviceExpiresAt - Date.now()) / 1e3
5755
5907
  })]);
5756
5908
  }
5909
+ /** Separate malformed or invalid requests from device-authenticated work without trusting forwarded addresses. */
5910
+ async withDeviceRequest(request, failureLimiter, deviceLimiter, inFlight, validate, operation) {
5911
+ let device;
5912
+ try {
5913
+ device = await validate();
5914
+ } catch (error) {
5915
+ if ((error instanceof HttpError || error instanceof AccessError) && error.status >= 400 && error.status < 500 && !failureLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5916
+ throw error;
5917
+ }
5918
+ if (inFlight.has(device.deviceId)) throw new HttpError(429, "rate_limited");
5919
+ const now = Date.now();
5920
+ const activeIds = new Set(this.access.listDevices().filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now).map((candidate) => candidate.id));
5921
+ deviceLimiter.retainKeys(activeIds);
5922
+ if (!deviceLimiter.take(device.deviceId, now)) throw new HttpError(429, "rate_limited");
5923
+ inFlight.add(device.deviceId);
5924
+ try {
5925
+ return await operation(device);
5926
+ } finally {
5927
+ inFlight.delete(device.deviceId);
5928
+ }
5929
+ }
5757
5930
  async handlePair(request, response) {
5758
5931
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5759
5932
  if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
@@ -5776,21 +5949,28 @@ var MobileAccessGateway = class {
5776
5949
  }, this.tlsEnabled);
5777
5950
  }
5778
5951
  async handleRenew(request, response) {
5779
- if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5780
- await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5781
- const cookies = browserAuthCookies(request.headers.cookie);
5782
- const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
5783
- if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
5784
- const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
5785
- const result = await this.access.renew(device.value);
5786
- this.setSessionCookies(response, result, Date.now());
5787
- this.setBrowserDeviceBinding(response, result.deviceId, device.result.deviceExpiresAt);
5788
- sendJson(response, 200, {
5789
- renewed: true,
5790
- deviceId: result.deviceId,
5791
- csrfToken: result.csrfToken,
5792
- sessionExpiresAt: result.sessionExpiresAt
5793
- }, this.tlsEnabled);
5952
+ await this.withDeviceRequest(request, this.renewFailureLimiter, this.renewDeviceLimiter, this.renewInFlight, async () => {
5953
+ await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5954
+ const cookies = browserAuthCookies(request.headers.cookie);
5955
+ const bindings = this.tlsEnabled ? cookies.get(HOST_DEVICE_COOKIE) : void 0;
5956
+ if (bindings !== void 0 && bindings.length !== 1) throw new HttpError(401, "authentication_failed");
5957
+ const device = authenticatedCookie(cookies.get("dsh_ma_device") ?? [], (token) => this.access.authorizeDevice(token), (candidate) => bindings === void 0 || candidate.deviceId === bindings[0]);
5958
+ return {
5959
+ deviceId: device.result.deviceId,
5960
+ token: device.value,
5961
+ credential: device.result
5962
+ };
5963
+ }, async (selected) => {
5964
+ const result = await this.access.renew(selected.token);
5965
+ this.setSessionCookies(response, result, Date.now());
5966
+ this.setBrowserDeviceBinding(response, result.deviceId, selected.credential.deviceExpiresAt);
5967
+ sendJson(response, 200, {
5968
+ renewed: true,
5969
+ deviceId: result.deviceId,
5970
+ csrfToken: result.csrfToken,
5971
+ sessionExpiresAt: result.sessionExpiresAt
5972
+ }, this.tlsEnabled);
5973
+ });
5794
5974
  }
5795
5975
  /** CA-backed entries retain their pin; public entries project the authenticated credential's identity. */
5796
5976
  nativeResponseInstanceId(result) {
@@ -5811,28 +5991,40 @@ var MobileAccessGateway = class {
5811
5991
  }, this.tlsEnabled);
5812
5992
  }
5813
5993
  async handleNativeRenew(request, response) {
5814
- if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5815
- const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5816
- if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5817
- const result = await this.access.renew(body.deviceToken);
5818
- sendJson(response, 200, {
5819
- instanceId: this.nativeResponseInstanceId(result),
5820
- deviceId: result.deviceId,
5821
- sessionToken: result.sessionToken,
5822
- csrfToken: result.csrfToken,
5823
- sessionExpiresAt: result.sessionExpiresAt
5824
- }, this.tlsEnabled);
5994
+ await this.withDeviceRequest(request, this.renewFailureLimiter, this.renewDeviceLimiter, this.renewInFlight, async () => {
5995
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5996
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5997
+ return {
5998
+ deviceId: this.access.authorizeDevice(body.deviceToken).deviceId,
5999
+ token: body.deviceToken
6000
+ };
6001
+ }, async (selected) => {
6002
+ const result = await this.access.renew(selected.token);
6003
+ sendJson(response, 200, {
6004
+ instanceId: this.nativeResponseInstanceId(result),
6005
+ deviceId: result.deviceId,
6006
+ sessionToken: result.sessionToken,
6007
+ csrfToken: result.csrfToken,
6008
+ sessionExpiresAt: result.sessionExpiresAt
6009
+ }, this.tlsEnabled);
6010
+ });
5825
6011
  }
5826
6012
  async handleNativeProbe(request, response) {
5827
- if (!this.probeLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
5828
- const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5829
- if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5830
- const result = await this.access.probe(body.deviceToken);
5831
- sendJson(response, 200, {
5832
- instanceId: this.nativeResponseInstanceId(result),
5833
- deviceId: result.deviceId,
5834
- deviceExpiresAt: result.deviceExpiresAt
5835
- }, this.tlsEnabled);
6013
+ await this.withDeviceRequest(request, this.probeFailureLimiter, this.probeDeviceLimiter, this.probeInFlight, async () => {
6014
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
6015
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
6016
+ return {
6017
+ deviceId: this.access.authorizeDevice(body.deviceToken).deviceId,
6018
+ token: body.deviceToken
6019
+ };
6020
+ }, async (selected) => {
6021
+ const result = await this.access.probe(selected.token);
6022
+ sendJson(response, 200, {
6023
+ instanceId: this.nativeResponseInstanceId(result),
6024
+ deviceId: result.deviceId,
6025
+ deviceExpiresAt: result.deviceExpiresAt
6026
+ }, this.tlsEnabled);
6027
+ });
5836
6028
  }
5837
6029
  async handleLogout(request, response) {
5838
6030
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
@@ -5867,7 +6059,13 @@ var MobileAccessGateway = class {
5867
6059
  sendJson(response, 200, { loggedOut: true }, this.tlsEnabled);
5868
6060
  }
5869
6061
  async handleExternalRequest(request, response) {
5870
- const target = parseRequestTarget(request.url);
6062
+ let target;
6063
+ try {
6064
+ target = parseRequestTarget(request.url);
6065
+ } catch (error) {
6066
+ if (error instanceof HttpError && error.status === 400) this.httpFailures.recordTarget();
6067
+ throw error;
6068
+ }
5871
6069
  const policy = this.requirePolicy();
5872
6070
  const isMutation = request.method !== "GET" && request.method !== "HEAD";
5873
6071
  assertExternalTrust(request, policy, isMutation);
@@ -6390,7 +6588,8 @@ var MobileAccessGateway = class {
6390
6588
  headers: {
6391
6589
  host: this.config.upstreamOrigin.host,
6392
6590
  accept: "text/html",
6393
- "accept-encoding": "identity"
6591
+ "accept-encoding": "identity",
6592
+ "sec-fetch-mode": "same-origin"
6394
6593
  },
6395
6594
  agent: false
6396
6595
  });
@@ -7027,6 +7226,7 @@ var MobileAccessGateway = class {
7027
7226
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
7028
7227
  const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
7029
7228
  const statusCode = proxied.statusCode ?? 502;
7229
+ this.httpFailures.recordUpstream(request$2.method, request$2.url?.split("?", 1)[0] ?? "", statusCode);
7030
7230
  const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
7031
7231
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
7032
7232
  const compressible = canCompressResponse(request$2, proxied);
@@ -17535,6 +17735,8 @@ async function apply(ctx, config) {
17535
17735
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
17536
17736
  }, clientModulePreferences, (entry) => {
17537
17737
  logger.warn("auth failure %s", JSON.stringify(entry));
17738
+ }, (entry) => {
17739
+ logger.warn("http failure %s", JSON.stringify(entry));
17538
17740
  });
17539
17741
  await candidate.start();
17540
17742
  lanGateway = candidate;
@@ -17593,6 +17795,8 @@ async function apply(ctx, config) {
17593
17795
  const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, remoteInstanceId, listenPort, instanceId);
17594
17796
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17595
17797
  logger.warn("auth failure %s", JSON.stringify(entry));
17798
+ }, (entry) => {
17799
+ logger.warn("http failure %s", JSON.stringify(entry));
17596
17800
  });
17597
17801
  await candidate.start();
17598
17802
  return candidate;
@@ -17609,7 +17813,11 @@ async function apply(ctx, config) {
17609
17813
  if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
17610
17814
  const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), void 0, stateDirectory);
17611
17815
  const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
17612
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17816
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17817
+ logger.warn("auth failure %s", JSON.stringify(entry));
17818
+ }, (entry) => {
17819
+ logger.warn("http failure %s", JSON.stringify(entry));
17820
+ });
17613
17821
  try {
17614
17822
  await candidate.start();
17615
17823
  } catch (error) {
@@ -17620,7 +17828,11 @@ async function apply(ctx, config) {
17620
17828
  };
17621
17829
  const createOriginGateway = async (settings) => {
17622
17830
  const resolved = originGatewayConfig(template, settings, remoteDeviceFile, remoteInstanceId, settings.listenPort, instanceId);
17623
- const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17831
+ const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences, (entry) => {
17832
+ logger.warn("auth failure %s", JSON.stringify(entry));
17833
+ }, (entry) => {
17834
+ logger.warn("http failure %s", JSON.stringify(entry));
17835
+ });
17624
17836
  try {
17625
17837
  await candidate.start();
17626
17838
  } catch (error) {