@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.
- package/lib/index.js +126 -21
- 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
|
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
5414
|
-
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
|
+
}
|
|
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
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
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) =>
|
|
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,
|