@dshn/agent 0.3.3 → 0.3.4

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.
Files changed (2) hide show
  1. package/lib/index.js +126 -21
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -2272,7 +2272,7 @@ var require_websocket = __commonJS({
2272
2272
  var tls = __require("tls");
2273
2273
  var { randomBytes: randomBytes2, createHash: createHash2 } = __require("crypto");
2274
2274
  var { Duplex, Readable } = __require("stream");
2275
- var { URL } = __require("url");
2275
+ var { URL: URL2 } = __require("url");
2276
2276
  var PerMessageDeflate2 = require_permessage_deflate();
2277
2277
  var Receiver2 = require_receiver();
2278
2278
  var Sender2 = require_sender();
@@ -2773,11 +2773,11 @@ var require_websocket = __commonJS({
2773
2773
  );
2774
2774
  }
2775
2775
  let parsedUrl;
2776
- if (address instanceof URL) {
2776
+ if (address instanceof URL2) {
2777
2777
  parsedUrl = address;
2778
2778
  } else {
2779
2779
  try {
2780
- parsedUrl = new URL(address);
2780
+ parsedUrl = new URL2(address);
2781
2781
  } catch {
2782
2782
  throw new SyntaxError(`Invalid URL: ${address}`);
2783
2783
  }
@@ -2914,7 +2914,7 @@ var require_websocket = __commonJS({
2914
2914
  req.abort();
2915
2915
  let addr;
2916
2916
  try {
2917
- addr = new URL(location, address);
2917
+ addr = new URL2(location, address);
2918
2918
  } catch (e) {
2919
2919
  const err = new SyntaxError(`Invalid URL: ${location}`);
2920
2920
  emitErrorAndClose(websocket, err);
@@ -4728,6 +4728,28 @@ var SHIM_BODY = String.raw`
4728
4728
  }
4729
4729
  window.WebSocket = E2EWebSocket
4730
4730
 
4731
+ // Fail closed for the clients this shim does not seal: an XMLHttpRequest or
4732
+ // sendBeacon to /api would leave the page in plaintext (and the agent now
4733
+ // refuses it with 428 anyway). Refuse here, loudly, so the failure is a
4734
+ // clear error in the console rather than a silent plaintext leak.
4735
+ const refuse = (what, url) => {
4736
+ console.error('[dshn] end-to-end encryption is on: ' + what + ' to ' + url + ' is not sealed and was blocked; use fetch()')
4737
+ }
4738
+ if (typeof XMLHttpRequest !== 'undefined') {
4739
+ const realOpen = XMLHttpRequest.prototype.open
4740
+ XMLHttpRequest.prototype.open = function (method, url) {
4741
+ if (isApi(String(url))) { refuse('XMLHttpRequest', String(url)); throw new DOMException('blocked by end-to-end encryption', 'SecurityError') }
4742
+ return realOpen.apply(this, arguments)
4743
+ }
4744
+ }
4745
+ if (navigator && typeof navigator.sendBeacon === 'function') {
4746
+ const realBeacon = navigator.sendBeacon.bind(navigator)
4747
+ navigator.sendBeacon = function (url, data) {
4748
+ if (isApi(String(url))) { refuse('sendBeacon', String(url)); return false }
4749
+ return realBeacon(url, data)
4750
+ }
4751
+ }
4752
+
4731
4753
  // The host injected this script only because E2E is ON, with the public
4732
4754
  // salt and device id inline — so there is nothing to discover and no
4733
4755
  // window in which dsh's own traffic could slip past the gate: fetch and
@@ -4960,6 +4982,9 @@ var ROUTE_FAIL_MAX = 3;
4960
4982
  var ROUTE_FALLBACK_MS = 5 * 6e4;
4961
4983
  var ROUTE_FALLBACK_MAX_MS = 60 * 6e4;
4962
4984
  var ROUTE_PROBE_TIMEOUT_MS = 1e4;
4985
+ var MAX_FRAME_BYTES = 128 * 1024 * 1024;
4986
+ var SEND_HIGH_WATER = 8 * 1024 * 1024;
4987
+ var MAX_BUFFERED = 64 * 1024 * 1024;
4963
4988
  function isValidRouteHost(raw) {
4964
4989
  if (typeof raw !== "string" || raw.length === 0 || raw.length > 253)
4965
4990
  return false;
@@ -5124,8 +5149,10 @@ var AgentTunnel = class {
5124
5149
  this.status.configured = true;
5125
5150
  this.status.subdomain = label;
5126
5151
  this.status.lastError = null;
5127
- this.control?.close();
5152
+ const old = this.control;
5128
5153
  this.control = null;
5154
+ this.dropStreams();
5155
+ old?.close();
5129
5156
  this.backoffMs = 1e3;
5130
5157
  this.connect();
5131
5158
  return null;
@@ -5148,6 +5175,8 @@ var AgentTunnel = class {
5148
5175
  this.creds = { ...this.creds, e2ePassword: next, e2eSalt: changed ? void 0 : this.creds.e2eSalt };
5149
5176
  this.refreshE2E();
5150
5177
  this.saveCreds(this.creds);
5178
+ if (changed)
5179
+ this.dropStreams();
5151
5180
  return null;
5152
5181
  }
5153
5182
  /**
@@ -5171,7 +5200,12 @@ var AgentTunnel = class {
5171
5200
  const c = this.creds?.relayHost;
5172
5201
  return typeof c === "string" && c !== "" ? c : this.config.relayHost;
5173
5202
  }
5174
- /** The origin CA to pin, as PEM bytes: inline PEM from the UI, or an env-configured file path. */
5203
+ /**
5204
+ * The origin CA to pin, as PEM bytes: inline PEM from the UI, or an
5205
+ * env-configured file path. A configured file that cannot be read THROWS —
5206
+ * silently falling back to the system CAs would turn a pinned, self-signed
5207
+ * relay into "any certificate the network offers".
5208
+ */
5175
5209
  effectiveOriginCa() {
5176
5210
  const raw = this.creds?.originCa && this.creds.originCa !== "" ? this.creds.originCa : this.config.originCa;
5177
5211
  if (raw === "" || raw === void 0)
@@ -5180,8 +5214,16 @@ var AgentTunnel = class {
5180
5214
  return Buffer.from(raw);
5181
5215
  try {
5182
5216
  return readFileSync(raw);
5217
+ } catch (err) {
5218
+ throw new Error(`cannot read the relay CA file ${raw}: ${err.message}`);
5219
+ }
5220
+ }
5221
+ /** Whether the CA setting is usable (or absent) — the panel's "direct" flag must not throw. */
5222
+ hasOriginCa() {
5223
+ try {
5224
+ return this.effectiveOriginCa() !== null;
5183
5225
  } catch {
5184
- return null;
5226
+ return true;
5185
5227
  }
5186
5228
  }
5187
5229
  /** The raw self-hosted overrides, for the settings UI to pre-fill (loopback callers only). */
@@ -5191,7 +5233,7 @@ var AgentTunnel = class {
5191
5233
  /** Connection details for the local panel (relay, mode, uptime, latency, throughput). */
5192
5234
  info() {
5193
5235
  const host = this.effectiveRelayHost();
5194
- const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
5236
+ const direct = /^wss?:\/\//.test(host) || this.hasOriginCa();
5195
5237
  const live = this.status.connected && this.dialledHost !== null ? this.dialledHost : host;
5196
5238
  return {
5197
5239
  relayHost: live.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
@@ -5220,8 +5262,10 @@ var AgentTunnel = class {
5220
5262
  clearTimeout(this.reconnectTimer);
5221
5263
  this.reconnectTimer = null;
5222
5264
  }
5223
- this.control?.close();
5265
+ const old = this.control;
5224
5266
  this.control = null;
5267
+ this.dropStreams();
5268
+ old?.close();
5225
5269
  }
5226
5270
  start() {
5227
5271
  if (!this.config.enabled || this.creds === null)
@@ -5410,8 +5454,30 @@ var AgentTunnel = class {
5410
5454
  const relayHost = this.dialHost();
5411
5455
  const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
5412
5456
  this.dialledHost = relayHost;
5413
- const wsOpts = { maxPayload: 512 * 1024 * 1024 };
5414
- const ca = this.effectiveOriginCa();
5457
+ const wsOpts = { maxPayload: MAX_FRAME_BYTES };
5458
+ const dialProblem = (why) => {
5459
+ this.status.lastError = why;
5460
+ this.dialledHost = null;
5461
+ if (this.reconnectTimer !== null)
5462
+ clearTimeout(this.reconnectTimer);
5463
+ this.reconnectTimer = setTimeout(() => this.connect(), this.backoffMs);
5464
+ this.backoffMs = Math.min(this.backoffMs * 2, 3e4);
5465
+ };
5466
+ if (base.startsWith("ws://")) {
5467
+ let hostname2 = "";
5468
+ try {
5469
+ hostname2 = new URL(base).hostname;
5470
+ } catch {
5471
+ }
5472
+ if (!isLoopbackAddress(hostname2) && hostname2 !== "localhost")
5473
+ return dialProblem(`refusing plain ws:// to ${hostname2 || base}: use wss:// (ws:// is allowed to loopback only)`);
5474
+ }
5475
+ let ca;
5476
+ try {
5477
+ ca = this.effectiveOriginCa();
5478
+ } catch (err) {
5479
+ return dialProblem(err.message);
5480
+ }
5415
5481
  if (ca !== null && relayHost === this.effectiveRelayHost())
5416
5482
  wsOpts.ca = ca;
5417
5483
  const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
@@ -5494,9 +5560,18 @@ var AgentTunnel = class {
5494
5560
  if (this.control?.readyState === import_websocket.default.OPEN)
5495
5561
  this.control.send(encodeControl(frame));
5496
5562
  }
5497
- sendData(kind, id, payload) {
5498
- if (this.control?.readyState === import_websocket.default.OPEN)
5499
- this.control.send(encodeData(kind, id, payload));
5563
+ /**
5564
+ * Send a data frame. Returns false when the control socket already holds
5565
+ * more than the high-water mark: the caller should pause its source and
5566
+ * resume it from `flushed`, which fires once this frame has been written.
5567
+ */
5568
+ sendData(kind, id, payload, flushed) {
5569
+ const ws = this.control;
5570
+ if (ws?.readyState !== import_websocket.default.OPEN)
5571
+ return true;
5572
+ const over = ws.bufferedAmount > SEND_HIGH_WATER;
5573
+ ws.send(encodeData(kind, id, payload), over && flushed !== void 0 ? () => flushed() : void 0);
5574
+ return !over;
5500
5575
  }
5501
5576
  onMessage(data, isBinary) {
5502
5577
  try {
@@ -5626,14 +5701,25 @@ var AgentTunnel = class {
5626
5701
  const bare = path.split("?", 1)[0];
5627
5702
  const outHeaders = this.loopbackHeaders(headers);
5628
5703
  if (this.e2eKey !== null && bare.startsWith("/api")) {
5629
- outHeaders["accept-encoding"] = "identity";
5630
5704
  const marked = headers.some(([k]) => k.toLowerCase() === E2E_HEADER);
5705
+ if (!marked) {
5706
+ this.send({ t: "res_head", id, status: 428, headers: [["content-type", "application/json"], ["cache-control", "no-store"]] });
5707
+ this.sendData(DATA_RES_BODY, id, Buffer.from(JSON.stringify({ error: "end-to-end encryption is on: /api requests must be sealed by the page (reload the page)" })));
5708
+ this.send({ t: "res_end", id });
5709
+ return;
5710
+ }
5711
+ outHeaders["accept-encoding"] = "identity";
5712
+ delete outHeaders["if-none-match"];
5713
+ delete outHeaders["if-modified-since"];
5631
5714
  this.reqE2E.set(id, { method, path, headers: outHeaders, marked, chunks: [] });
5632
5715
  return;
5633
5716
  }
5634
5717
  const wantsDocument = method === "GET" && headers.some(([k, v]) => k.toLowerCase() === "accept" && v.includes("text/html"));
5635
- if (wantsDocument)
5718
+ if (wantsDocument) {
5636
5719
  outHeaders["accept-encoding"] = "identity";
5720
+ delete outHeaders["if-none-match"];
5721
+ delete outHeaders["if-modified-since"];
5722
+ }
5637
5723
  const req = http.request({ host: this.config.localHost, port: this.localPort(), method, path, headers: outHeaders }, (res) => {
5638
5724
  const contentType = String(res.headers["content-type"] ?? "");
5639
5725
  if (wantsDocument && res.statusCode === 200 && /^text\/html\b/i.test(contentType)) {
@@ -5644,8 +5730,8 @@ var AgentTunnel = class {
5644
5730
  if (this.e2eKey !== null)
5645
5731
  html = injectE2EBootstrap(html, { salt: this.e2eSalt, device: this.deviceId });
5646
5732
  const body = Buffer.from(html, "utf8");
5647
- const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
5648
- resHeaders.push(["content-length", String(body.length)]);
5733
+ const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding", "etag", "last-modified", "cache-control", "expires"]));
5734
+ resHeaders.push(["content-length", String(body.length)], ["cache-control", "no-store"]);
5649
5735
  this.send({ t: "res_head", id, status: 200, headers: resHeaders });
5650
5736
  this.sendData(DATA_RES_BODY, id, body);
5651
5737
  this.send({ t: "res_end", id });
@@ -5659,7 +5745,10 @@ var AgentTunnel = class {
5659
5745
  status: res.statusCode ?? 502,
5660
5746
  headers: filterHeaders(headerListFromRaw(res.rawHeaders))
5661
5747
  });
5662
- res.on("data", (chunk) => this.sendData(DATA_RES_BODY, id, chunk));
5748
+ res.on("data", (chunk) => {
5749
+ if (!this.sendData(DATA_RES_BODY, id, chunk, () => res.resume()))
5750
+ res.pause();
5751
+ });
5663
5752
  res.on("end", () => this.send({ t: "res_end", id }));
5664
5753
  res.on("error", () => this.send({ t: "abort", id, reason: "response stream error" }));
5665
5754
  });
@@ -5700,8 +5789,8 @@ var AgentTunnel = class {
5700
5789
  res.on("data", (c) => chunks.push(c));
5701
5790
  res.on("end", () => {
5702
5791
  const sealed = seal(this.e2eKey, Buffer.concat(chunks));
5703
- const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
5704
- resHeaders.push([E2E_HEADER, "1"], ["content-length", String(sealed.length)]);
5792
+ const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding", "etag", "last-modified", "cache-control", "expires"]));
5793
+ resHeaders.push([E2E_HEADER, "1"], ["content-length", String(sealed.length)], ["cache-control", "no-store"]);
5705
5794
  this.send({ t: "res_head", id, status: res.statusCode ?? 502, headers: resHeaders });
5706
5795
  this.sendData(DATA_RES_BODY, id, sealed);
5707
5796
  this.send({ t: "res_end", id });
@@ -5731,6 +5820,13 @@ var AgentTunnel = class {
5731
5820
  sock.on("open", () => this.send({ t: "ws_ready", id }));
5732
5821
  sock.on("message", (data, isBinary) => {
5733
5822
  const raw = toBuf(data);
5823
+ if (this.control !== null && this.control.bufferedAmount > MAX_BUFFERED) {
5824
+ if (this.sockets.delete(id)) {
5825
+ sock.close(1013, "uplink congested");
5826
+ this.send({ t: "ws_close", id, code: 1013, reason: "uplink congested" });
5827
+ }
5828
+ return;
5829
+ }
5734
5830
  if (sealMessages && this.e2eKey !== null) {
5735
5831
  const typed = Buffer.concat([Buffer.from([isBinary ? E2E_MSG_BINARY : E2E_MSG_TEXT]), raw]);
5736
5832
  this.sendData(DATA_WS_BINARY, id, seal(this.e2eKey, typed));
@@ -5775,9 +5871,17 @@ function targetsManagementRoute(rawPath) {
5775
5871
  const norm = "/" + segs.join("/");
5776
5872
  return norm === "/dshn" || norm.startsWith("/dshn/");
5777
5873
  }
5874
+ function isLoopbackAddress(addr) {
5875
+ if (addr === void 0 || addr === "")
5876
+ return false;
5877
+ const a = addr.toLowerCase().replace(/^::ffff:/, "");
5878
+ return a === "::1" || a.startsWith("127.");
5879
+ }
5778
5880
  function isLoopbackRequest(req) {
5779
5881
  if (req.headers[TUNNEL_MARKER] !== void 0)
5780
5882
  return false;
5883
+ if (!isLoopbackAddress(req.socket?.remoteAddress))
5884
+ return false;
5781
5885
  const host = String(req.headers.host ?? "");
5782
5886
  const hostname2 = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
5783
5887
  return hostname2 === "localhost" || hostname2 === "::1" || hostname2.startsWith("127.");
@@ -5943,6 +6047,7 @@ export {
5943
6047
  apply,
5944
6048
  fileStore,
5945
6049
  inject,
6050
+ isLoopbackAddress,
5946
6051
  isLoopbackRequest,
5947
6052
  name,
5948
6053
  settingsStore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Forward a local dsh web service to the public internet over ds.hn (bundled).",
5
5
  "keywords": [
6
6
  "dsh",