@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.
- package/lib/index.js +137 -24
- 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
|
|
2776
|
+
if (address instanceof URL2) {
|
|
2777
2777
|
parsedUrl = address;
|
|
2778
2778
|
} else {
|
|
2779
2779
|
try {
|
|
2780
|
-
parsedUrl = new
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
5407
|
-
const
|
|
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
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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) =>
|
|
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,
|