@dshn/agent 0.3.2 → 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 +137 -24
  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
@@ -4848,6 +4870,13 @@ function e2eBootstrapTag(info) {
4848
4870
  const json = JSON.stringify({ salt: info.salt, device: info.device }).replace(/</g, "\\u003c");
4849
4871
  return `<script>(function (__dshnInfo) {${SHIM_BODY}})(${json})</script>`;
4850
4872
  }
4873
+ function credentialManifestLinks(html) {
4874
+ return html.replace(/<link\b[^>]*>/gi, (tag) => {
4875
+ if (!/\brel\s*=\s*["']?manifest\b/i.test(tag) || /\bcrossorigin\b/i.test(tag))
4876
+ return tag;
4877
+ return tag.replace(/\s*\/?>$/, (end) => ` crossorigin="use-credentials"${end.trim() === "/>" ? " />" : ">"}`);
4878
+ });
4879
+ }
4851
4880
  function injectE2EBootstrap(html, info) {
4852
4881
  const tag = e2eBootstrapTag(info);
4853
4882
  const head = /<head(\s[^>]*)?>/i.exec(html);
@@ -4953,6 +4982,9 @@ var ROUTE_FAIL_MAX = 3;
4953
4982
  var ROUTE_FALLBACK_MS = 5 * 6e4;
4954
4983
  var ROUTE_FALLBACK_MAX_MS = 60 * 6e4;
4955
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;
4956
4988
  function isValidRouteHost(raw) {
4957
4989
  if (typeof raw !== "string" || raw.length === 0 || raw.length > 253)
4958
4990
  return false;
@@ -5117,8 +5149,10 @@ var AgentTunnel = class {
5117
5149
  this.status.configured = true;
5118
5150
  this.status.subdomain = label;
5119
5151
  this.status.lastError = null;
5120
- this.control?.close();
5152
+ const old = this.control;
5121
5153
  this.control = null;
5154
+ this.dropStreams();
5155
+ old?.close();
5122
5156
  this.backoffMs = 1e3;
5123
5157
  this.connect();
5124
5158
  return null;
@@ -5141,6 +5175,8 @@ var AgentTunnel = class {
5141
5175
  this.creds = { ...this.creds, e2ePassword: next, e2eSalt: changed ? void 0 : this.creds.e2eSalt };
5142
5176
  this.refreshE2E();
5143
5177
  this.saveCreds(this.creds);
5178
+ if (changed)
5179
+ this.dropStreams();
5144
5180
  return null;
5145
5181
  }
5146
5182
  /**
@@ -5164,7 +5200,12 @@ var AgentTunnel = class {
5164
5200
  const c = this.creds?.relayHost;
5165
5201
  return typeof c === "string" && c !== "" ? c : this.config.relayHost;
5166
5202
  }
5167
- /** 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
+ */
5168
5209
  effectiveOriginCa() {
5169
5210
  const raw = this.creds?.originCa && this.creds.originCa !== "" ? this.creds.originCa : this.config.originCa;
5170
5211
  if (raw === "" || raw === void 0)
@@ -5173,8 +5214,16 @@ var AgentTunnel = class {
5173
5214
  return Buffer.from(raw);
5174
5215
  try {
5175
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;
5176
5225
  } catch {
5177
- return null;
5226
+ return true;
5178
5227
  }
5179
5228
  }
5180
5229
  /** The raw self-hosted overrides, for the settings UI to pre-fill (loopback callers only). */
@@ -5184,7 +5233,7 @@ var AgentTunnel = class {
5184
5233
  /** Connection details for the local panel (relay, mode, uptime, latency, throughput). */
5185
5234
  info() {
5186
5235
  const host = this.effectiveRelayHost();
5187
- const direct = /^wss?:\/\//.test(host) || this.effectiveOriginCa() !== null;
5236
+ const direct = /^wss?:\/\//.test(host) || this.hasOriginCa();
5188
5237
  const live = this.status.connected && this.dialledHost !== null ? this.dialledHost : host;
5189
5238
  return {
5190
5239
  relayHost: live.replace(/^wss?:\/\//, "").replace(/\/.*$/, ""),
@@ -5213,8 +5262,10 @@ var AgentTunnel = class {
5213
5262
  clearTimeout(this.reconnectTimer);
5214
5263
  this.reconnectTimer = null;
5215
5264
  }
5216
- this.control?.close();
5265
+ const old = this.control;
5217
5266
  this.control = null;
5267
+ this.dropStreams();
5268
+ old?.close();
5218
5269
  }
5219
5270
  start() {
5220
5271
  if (!this.config.enabled || this.creds === null)
@@ -5403,8 +5454,30 @@ var AgentTunnel = class {
5403
5454
  const relayHost = this.dialHost();
5404
5455
  const base = relayHost.includes("://") ? relayHost : `wss://${relayHost}`;
5405
5456
  this.dialledHost = relayHost;
5406
- const wsOpts = { maxPayload: 512 * 1024 * 1024 };
5407
- 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
+ }
5408
5481
  if (ca !== null && relayHost === this.effectiveRelayHost())
5409
5482
  wsOpts.ca = ca;
5410
5483
  const ws = new import_websocket.default(`${base}${AGENT_WS_PATH}`, wsOpts);
@@ -5487,9 +5560,18 @@ var AgentTunnel = class {
5487
5560
  if (this.control?.readyState === import_websocket.default.OPEN)
5488
5561
  this.control.send(encodeControl(frame));
5489
5562
  }
5490
- sendData(kind, id, payload) {
5491
- if (this.control?.readyState === import_websocket.default.OPEN)
5492
- 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;
5493
5575
  }
5494
5576
  onMessage(data, isBinary) {
5495
5577
  try {
@@ -5619,25 +5701,37 @@ var AgentTunnel = class {
5619
5701
  const bare = path.split("?", 1)[0];
5620
5702
  const outHeaders = this.loopbackHeaders(headers);
5621
5703
  if (this.e2eKey !== null && bare.startsWith("/api")) {
5622
- outHeaders["accept-encoding"] = "identity";
5623
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"];
5624
5714
  this.reqE2E.set(id, { method, path, headers: outHeaders, marked, chunks: [] });
5625
5715
  return;
5626
5716
  }
5627
5717
  const wantsDocument = method === "GET" && headers.some(([k, v]) => k.toLowerCase() === "accept" && v.includes("text/html"));
5628
- const injectBootstrap = this.e2eKey !== null && wantsDocument;
5629
- if (injectBootstrap)
5718
+ if (wantsDocument) {
5630
5719
  outHeaders["accept-encoding"] = "identity";
5720
+ delete outHeaders["if-none-match"];
5721
+ delete outHeaders["if-modified-since"];
5722
+ }
5631
5723
  const req = http.request({ host: this.config.localHost, port: this.localPort(), method, path, headers: outHeaders }, (res) => {
5632
5724
  const contentType = String(res.headers["content-type"] ?? "");
5633
- if (injectBootstrap && this.e2eKey !== null && res.statusCode === 200 && /^text\/html\b/i.test(contentType)) {
5725
+ if (wantsDocument && res.statusCode === 200 && /^text\/html\b/i.test(contentType)) {
5634
5726
  const chunks = [];
5635
5727
  res.on("data", (c) => chunks.push(c));
5636
5728
  res.on("end", () => {
5637
- const html = injectE2EBootstrap(Buffer.concat(chunks).toString("utf8"), { salt: this.e2eSalt, device: this.deviceId });
5729
+ let html = credentialManifestLinks(Buffer.concat(chunks).toString("utf8"));
5730
+ if (this.e2eKey !== null)
5731
+ html = injectE2EBootstrap(html, { salt: this.e2eSalt, device: this.deviceId });
5638
5732
  const body = Buffer.from(html, "utf8");
5639
- const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
5640
- 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"]);
5641
5735
  this.send({ t: "res_head", id, status: 200, headers: resHeaders });
5642
5736
  this.sendData(DATA_RES_BODY, id, body);
5643
5737
  this.send({ t: "res_end", id });
@@ -5651,7 +5745,10 @@ var AgentTunnel = class {
5651
5745
  status: res.statusCode ?? 502,
5652
5746
  headers: filterHeaders(headerListFromRaw(res.rawHeaders))
5653
5747
  });
5654
- 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
+ });
5655
5752
  res.on("end", () => this.send({ t: "res_end", id }));
5656
5753
  res.on("error", () => this.send({ t: "abort", id, reason: "response stream error" }));
5657
5754
  });
@@ -5692,8 +5789,8 @@ var AgentTunnel = class {
5692
5789
  res.on("data", (c) => chunks.push(c));
5693
5790
  res.on("end", () => {
5694
5791
  const sealed = seal(this.e2eKey, Buffer.concat(chunks));
5695
- const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
5696
- 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"]);
5697
5794
  this.send({ t: "res_head", id, status: res.statusCode ?? 502, headers: resHeaders });
5698
5795
  this.sendData(DATA_RES_BODY, id, sealed);
5699
5796
  this.send({ t: "res_end", id });
@@ -5723,6 +5820,13 @@ var AgentTunnel = class {
5723
5820
  sock.on("open", () => this.send({ t: "ws_ready", id }));
5724
5821
  sock.on("message", (data, isBinary) => {
5725
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
+ }
5726
5830
  if (sealMessages && this.e2eKey !== null) {
5727
5831
  const typed = Buffer.concat([Buffer.from([isBinary ? E2E_MSG_BINARY : E2E_MSG_TEXT]), raw]);
5728
5832
  this.sendData(DATA_WS_BINARY, id, seal(this.e2eKey, typed));
@@ -5767,9 +5871,17 @@ function targetsManagementRoute(rawPath) {
5767
5871
  const norm = "/" + segs.join("/");
5768
5872
  return norm === "/dshn" || norm.startsWith("/dshn/");
5769
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
+ }
5770
5880
  function isLoopbackRequest(req) {
5771
5881
  if (req.headers[TUNNEL_MARKER] !== void 0)
5772
5882
  return false;
5883
+ if (!isLoopbackAddress(req.socket?.remoteAddress))
5884
+ return false;
5773
5885
  const host = String(req.headers.host ?? "");
5774
5886
  const hostname2 = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
5775
5887
  return hostname2 === "localhost" || hostname2 === "::1" || hostname2.startsWith("127.");
@@ -5935,6 +6047,7 @@ export {
5935
6047
  apply,
5936
6048
  fileStore,
5937
6049
  inject,
6050
+ isLoopbackAddress,
5938
6051
  isLoopbackRequest,
5939
6052
  name,
5940
6053
  settingsStore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.3.2",
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",