surf-cli 2.8.0 → 2.9.0
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/README.md +98 -4
- package/dist/content/index.js +116 -0
- package/dist/content/index.js.map +1 -0
- package/dist/manifest.json +2 -11
- package/dist/options/options.js +3 -3
- package/dist/options/options.js.map +1 -1
- package/dist/service-worker/index.js +261 -61
- package/dist/service-worker/index.js.map +1 -1
- package/native/abort.cjs +65 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +47 -31
- package/native/cli.cjs +300 -204
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +25 -44
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +37 -12
- package/native/host-sessions.cjs +283 -0
- package/native/host.cjs +800 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +113 -0
- package/native/perplexity-client.cjs +46 -17
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/package.json +8 -6
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +31 -4
- package/dist/content/accessibility-tree.js +0 -11
- package/dist/content/accessibility-tree.js.map +0 -1
- package/dist/content/visual-indicator.js +0 -111
- package/dist/content/visual-indicator.js.map +0 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const { connectEndpoint } = require("./endpoint.cjs");
|
|
2
|
+
const { createFrameParser, createSocketWriter } = require("./remote-transport.cjs");
|
|
3
|
+
const { createClientTransferController } = require("./file-transfer.cjs");
|
|
4
|
+
|
|
5
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
6
|
+
|
|
7
|
+
async function openClientTransport(endpoint, { requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
|
|
8
|
+
let socket;
|
|
9
|
+
let ready;
|
|
10
|
+
let readyReject;
|
|
11
|
+
let readyErrorListener;
|
|
12
|
+
ready = new Promise((resolve, reject) => {
|
|
13
|
+
readyReject = reject;
|
|
14
|
+
readyErrorListener = (error) => reject(error);
|
|
15
|
+
socket = connectEndpoint(endpoint, () => {
|
|
16
|
+
socket.removeListener("error", readyErrorListener);
|
|
17
|
+
resolve();
|
|
18
|
+
});
|
|
19
|
+
socket.once("error", readyErrorListener);
|
|
20
|
+
});
|
|
21
|
+
await ready;
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
const writer = createSocketWriter(socket, { onOverflow: ({ error }) => socket.destroy(error) });
|
|
24
|
+
const transfers = endpoint.kind === "remote"
|
|
25
|
+
? createClientTransferController({ writer, onActivity: () => {} })
|
|
26
|
+
: null;
|
|
27
|
+
let transferChain = Promise.resolve();
|
|
28
|
+
let transferCleanupPromise = null;
|
|
29
|
+
const cleanupTransfers = (error) => {
|
|
30
|
+
if (!transfers) return Promise.resolve();
|
|
31
|
+
transferCleanupPromise ||= transfers.cleanup(error);
|
|
32
|
+
return transferCleanupPromise;
|
|
33
|
+
};
|
|
34
|
+
const rejectPending = (error) => {
|
|
35
|
+
for (const entry of pending.values()) {
|
|
36
|
+
clearTimeout(entry.timer);
|
|
37
|
+
entry.reject(error);
|
|
38
|
+
}
|
|
39
|
+
pending.clear();
|
|
40
|
+
};
|
|
41
|
+
const parser = createFrameParser({
|
|
42
|
+
onFrame(message) {
|
|
43
|
+
if (transfers && message.type && message.type.startsWith("transfer_")) {
|
|
44
|
+
transferChain = transferChain.then(() => transfers.handle(message)).catch((error) => {
|
|
45
|
+
rejectPending(error);
|
|
46
|
+
return writer.send({ type: "transfer_error", version: 1, transferId: message.transferId, error: error.message }).finally(() => socket.destroy(error)).then(() => { throw error; });
|
|
47
|
+
});
|
|
48
|
+
transferChain.catch(() => undefined);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (message.type === "extension_disconnected") {
|
|
52
|
+
transferChain = transferChain.then(() => {
|
|
53
|
+
const error = new Error(message.message || "Surf extension disconnected");
|
|
54
|
+
rejectPending(error);
|
|
55
|
+
socket.end();
|
|
56
|
+
});
|
|
57
|
+
transferChain.catch(() => undefined);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (message.id === undefined || !pending.has(message.id)) return;
|
|
61
|
+
transferChain = transferChain.then(() => {
|
|
62
|
+
const entry = pending.get(message.id);
|
|
63
|
+
if (!entry) return;
|
|
64
|
+
pending.delete(message.id);
|
|
65
|
+
clearTimeout(entry.timer);
|
|
66
|
+
entry.resolve(message);
|
|
67
|
+
}).catch((error) => {
|
|
68
|
+
rejectPending(error);
|
|
69
|
+
socket.destroy(error);
|
|
70
|
+
});
|
|
71
|
+
transferChain.catch(() => undefined);
|
|
72
|
+
},
|
|
73
|
+
onError(error) {
|
|
74
|
+
rejectPending(error);
|
|
75
|
+
readyReject?.(error);
|
|
76
|
+
socket.destroy();
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
socket.on("data", (chunk) => parser.push(chunk));
|
|
80
|
+
let closed = false;
|
|
81
|
+
socket.on("close", () => {
|
|
82
|
+
parser.close();
|
|
83
|
+
const error = new Error("connection closed");
|
|
84
|
+
rejectPending(error);
|
|
85
|
+
writer.close(error);
|
|
86
|
+
cleanupTransfers(error);
|
|
87
|
+
closed = true;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
socket,
|
|
91
|
+
transfers,
|
|
92
|
+
async request(message, timeoutMs = requestTimeoutMs, transferPlan = {}) {
|
|
93
|
+
if (closed) throw new Error("client transport is closed");
|
|
94
|
+
if (message.id === undefined || message.id === null) throw new Error("request id is required");
|
|
95
|
+
if (pending.has(message.id)) throw new Error("duplicate request id");
|
|
96
|
+
const downloadIds = (transferPlan.downloads || []).map((download) => download.transferId);
|
|
97
|
+
const timeoutError = new Error("request timed out");
|
|
98
|
+
let rejectTimeout;
|
|
99
|
+
const timeout = new Promise((_, reject) => { rejectTimeout = reject; });
|
|
100
|
+
timeout.catch(() => undefined);
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
closed = true;
|
|
103
|
+
rejectPending(timeoutError);
|
|
104
|
+
cleanupTransfers(timeoutError).catch(() => {});
|
|
105
|
+
writer.close(timeoutError);
|
|
106
|
+
socket.destroy();
|
|
107
|
+
rejectTimeout(timeoutError);
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
const execute = async () => {
|
|
110
|
+
try {
|
|
111
|
+
if (transfers) {
|
|
112
|
+
for (const download of transferPlan.downloads || []) await transfers.expectDownload(download);
|
|
113
|
+
const uploadDescriptors = [];
|
|
114
|
+
for (const upload of transferPlan.uploads || []) {
|
|
115
|
+
const result = await transfers.upload(upload.path, upload);
|
|
116
|
+
uploadDescriptors.push({ transferId: result.transferId, field: upload.field, original: upload.original, kind: "upload" });
|
|
117
|
+
}
|
|
118
|
+
message = { ...message, _surfTransfers: { uploads: uploadDescriptors, downloads: (transferPlan.downloads || []).map(({ transferId, field, original }) => ({ transferId, field, original, kind: "download" })) }, _surfPaths: transferPlan.pathRefs || [] };
|
|
119
|
+
}
|
|
120
|
+
const response = new Promise((resolve, reject) => {
|
|
121
|
+
pending.set(message.id, { resolve, reject, timer });
|
|
122
|
+
});
|
|
123
|
+
response.catch(() => undefined);
|
|
124
|
+
try {
|
|
125
|
+
await writer.send(message);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const entry = pending.get(message.id);
|
|
128
|
+
if (entry) { pending.delete(message.id); clearTimeout(entry.timer); entry.reject(error); }
|
|
129
|
+
await transfers?.cancelDownloads(downloadIds);
|
|
130
|
+
await response.catch(() => undefined);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
const result = await response;
|
|
134
|
+
if (result.error || (transfers && downloadIds.some((id) => transfers.hasDownload(id)))) {
|
|
135
|
+
await transfers?.cancelDownloads(downloadIds);
|
|
136
|
+
if (!result.error) throw new Error("request completed without completing output download");
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await transfers?.cancelDownloads(downloadIds);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
return await Promise.race([execute(), timeout]);
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
async closeAsync() {
|
|
151
|
+
if (closed) return transferCleanupPromise;
|
|
152
|
+
closed = true;
|
|
153
|
+
const error = new Error("client transport is closed");
|
|
154
|
+
parser.close();
|
|
155
|
+
rejectPending(error);
|
|
156
|
+
writer.close(error);
|
|
157
|
+
const cleanup = cleanupTransfers(error);
|
|
158
|
+
socket.end();
|
|
159
|
+
await cleanup;
|
|
160
|
+
return cleanup;
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
return this.closeAsync();
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { DEFAULT_REQUEST_TIMEOUT_MS, openClientTransport };
|
package/native/do-executor.cjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* Follows the same socket communication pattern as --script mode in cli.cjs.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
const {
|
|
13
|
+
const { selectEndpoint } = require("./endpoint.cjs");
|
|
14
|
+
const { openClientTransport } = require("./client-transport.cjs");
|
|
15
|
+
const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
|
|
16
|
+
const { prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
|
|
15
17
|
|
|
16
18
|
// Maximum iterations for loops (safety cap)
|
|
17
19
|
const MAX_LOOP_ITERATIONS = 100;
|
|
@@ -71,48 +73,27 @@ function getAutoWaitCommand(cmd) {
|
|
|
71
73
|
* @returns {Promise<object>} - Response from host
|
|
72
74
|
*/
|
|
73
75
|
function sendDoRequest(toolName, toolArgs, context = {}) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const resp = JSON.parse(line);
|
|
96
|
-
sock.end();
|
|
97
|
-
resolve(resp);
|
|
98
|
-
} catch {
|
|
99
|
-
sock.end();
|
|
100
|
-
reject(new Error("Invalid JSON response"));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
sock.on("error", (e) => {
|
|
106
|
-
reject(new Error(formatSocketError(e)));
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const timeoutId = setTimeout(() => {
|
|
110
|
-
sock.destroy();
|
|
111
|
-
reject(new Error("Request timeout"));
|
|
112
|
-
}, 30000);
|
|
113
|
-
|
|
114
|
-
sock.on("close", () => clearTimeout(timeoutId));
|
|
115
|
-
});
|
|
76
|
+
const request = {
|
|
77
|
+
type: "tool_request",
|
|
78
|
+
method: "execute_tool",
|
|
79
|
+
params: { tool: toolName, args: toolArgs },
|
|
80
|
+
id: "do-" + Date.now() + "-" + Math.random(),
|
|
81
|
+
};
|
|
82
|
+
if (context.tabId) request.tabId = context.tabId;
|
|
83
|
+
if (context.windowId) request.windowId = context.windowId;
|
|
84
|
+
const requestTimeoutMs = context.timeoutMs || resolveRequestDeadlineMs(toolName, toolArgs);
|
|
85
|
+
const endpoint = context.endpoint || selectEndpoint([]).endpoint;
|
|
86
|
+
const prepared = endpoint.kind === "remote" ? prepareRemoteTool(toolName, toolArgs) : (() => { const args = validateLocalToolPaths(toolName, toolArgs); return { args, uploads: [], downloads: [] }; })();
|
|
87
|
+
request.params.args = prepared.args;
|
|
88
|
+
if (context.transport) return context.transport.request(request, requestTimeoutMs, prepared);
|
|
89
|
+
return (async () => {
|
|
90
|
+
const transport = await openClientTransport(endpoint);
|
|
91
|
+
try {
|
|
92
|
+
return await transport.request(request, requestTimeoutMs, prepared);
|
|
93
|
+
} finally {
|
|
94
|
+
await transport.close();
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
116
97
|
}
|
|
117
98
|
|
|
118
99
|
/**
|
package/native/doctor.cjs
CHANGED
|
@@ -3,6 +3,7 @@ const net = require("net");
|
|
|
3
3
|
const os = require("os");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { execFileSync } = require("child_process");
|
|
6
|
+
const { connectEndpoint, selectEndpoint } = require("./endpoint.cjs");
|
|
6
7
|
|
|
7
8
|
const HOST_NAME = "surf.browser.host";
|
|
8
9
|
|
|
@@ -402,6 +403,16 @@ function buildRecommendations(report) {
|
|
|
402
403
|
return Array.from(new Set(recommendations));
|
|
403
404
|
}
|
|
404
405
|
|
|
406
|
+
function remoteRecommendations(endpoint, code) {
|
|
407
|
+
const base = [`Confirm Surf is listening on ${endpoint.display}; the remote host listener must allow this Tailnet connection.`];
|
|
408
|
+
if (code === "ENOTFOUND") return [...base, "Check the Tailnet DNS name, then run `tailscale status` and `tailscale ping <host>`."].map((item) => item.replace("<host>", endpoint.host));
|
|
409
|
+
if (code === "ETIMEDOUT") return [...base, `Run \`tailscale ping ${endpoint.host}\`; check restrictive Tailnet ACLs/grants and host firewall rules.`];
|
|
410
|
+
if (code === "ECONNREFUSED") return [...base, "Verify the host process is running and bound to the requested TCP port; check restrictive Tailnet ACLs/grants."];
|
|
411
|
+
if (code === "ENETUNREACH" || code === "EHOSTUNREACH") return [...base, `Run \`tailscale status\` and \`tailscale ping ${endpoint.host}\`; check Tailnet routing plus restrictive ACLs/grants.`];
|
|
412
|
+
if (code === "EAUTH") return [...base, "Verify the client credential path, pinned host identity, and that the labeled client has not been revoked."].map((item) => item.replace("<host>", endpoint.host));
|
|
413
|
+
return base;
|
|
414
|
+
}
|
|
415
|
+
|
|
405
416
|
async function runDoctor(rawOptions = {}, deps = {}) {
|
|
406
417
|
const env = deps.env || process.env;
|
|
407
418
|
const platform = deps.platform || process.platform;
|
|
@@ -413,6 +424,41 @@ async function runDoctor(rawOptions = {}, deps = {}) {
|
|
|
413
424
|
socket: rawOptions.socket || env.SURF_SOCKET || defaultSocketPath(platform),
|
|
414
425
|
connectTimeoutMs: rawOptions.connectTimeoutMs ?? 750,
|
|
415
426
|
};
|
|
427
|
+
const selectedEndpoint = rawOptions.endpoint || selectEndpoint([], env).endpoint;
|
|
428
|
+
if (selectedEndpoint.kind === "remote") {
|
|
429
|
+
const endpoint = selectedEndpoint;
|
|
430
|
+
const connection = await (deps.connectEndpoint || ((target, timeoutMs) => new Promise((resolve) => {
|
|
431
|
+
let settled = false;
|
|
432
|
+
let socket;
|
|
433
|
+
const finish = (result) => {
|
|
434
|
+
if (settled) return;
|
|
435
|
+
settled = true;
|
|
436
|
+
clearTimeout(timeout);
|
|
437
|
+
socket?.destroy();
|
|
438
|
+
resolve(result);
|
|
439
|
+
};
|
|
440
|
+
const timeout = setTimeout(() => finish({
|
|
441
|
+
ok: false,
|
|
442
|
+
code: socket?.connected ? "EAUTH" : "ETIMEDOUT",
|
|
443
|
+
message: socket?.connected ? "remote authentication timed out" : `timed out after ${timeoutMs}ms`,
|
|
444
|
+
}), timeoutMs);
|
|
445
|
+
socket = connectEndpoint(target, () => finish({ ok: true, message: "authenticated" }));
|
|
446
|
+
socket.once("error", (error) => finish({ ok: false, code: error.code, message: error.message || String(error) }));
|
|
447
|
+
})))(endpoint, options.connectTimeoutMs);
|
|
448
|
+
const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}`, endpoint: endpoint.display }, {
|
|
449
|
+
id: "remote-connect", status: connection.ok ? "pass" : "fail",
|
|
450
|
+
message: connection.ok ? `Connected to remote endpoint ${endpoint.display}` : `Could not connect to remote endpoint ${endpoint.display}: ${connection.message}`,
|
|
451
|
+
code: connection.code,
|
|
452
|
+
}, {
|
|
453
|
+
id: "remote-auth", status: connection.ok ? "pass" : connection.code === "EAUTH" ? "fail" : "info",
|
|
454
|
+
message: connection.ok ? "Remote credential authenticated and server identity verified" : connection.code === "EAUTH" ? "Remote credential rejected, revoked, or server identity mismatch" : "Remote authentication was not reached",
|
|
455
|
+
code: connection.code,
|
|
456
|
+
}];
|
|
457
|
+
const summary = summarize(checks);
|
|
458
|
+
const report = { ok: connection.ok, summary, environment: { platform, remoteEndpoint: endpoint.display, endpointKind: "remote", browsers: [] }, manifests: [], checks };
|
|
459
|
+
report.recommendations = connection.ok ? [] : remoteRecommendations(endpoint, connection.code);
|
|
460
|
+
return report;
|
|
461
|
+
}
|
|
416
462
|
const effectiveTarget = resolveEffectiveTarget(options, { platform, runningInWsl });
|
|
417
463
|
const browsers = resolveBrowsers(options.browser);
|
|
418
464
|
const context = {
|
|
@@ -502,9 +548,13 @@ function formatCheck(check) {
|
|
|
502
548
|
function formatDoctorReport(report) {
|
|
503
549
|
const lines = ["Surf doctor", ""];
|
|
504
550
|
lines.push(`Platform: ${report.environment.platform}${report.environment.runningInWsl ? " (WSL2 detected)" : ""}`);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
551
|
+
if (report.environment.endpointKind === "remote") {
|
|
552
|
+
lines.push(`Remote endpoint: ${report.environment.remoteEndpoint}`);
|
|
553
|
+
} else {
|
|
554
|
+
lines.push(`Target: ${report.environment.effectiveTarget === "wsl-windows" ? "Windows browser from WSL2" : report.environment.effectiveTarget}`);
|
|
555
|
+
}
|
|
556
|
+
if (report.environment.socketPath) lines.push(`Socket: ${report.environment.socketPath}`);
|
|
557
|
+
if (report.environment.browsers.length) lines.push(`Browsers: ${report.environment.browsers.join(", ")}`);
|
|
508
558
|
lines.push("");
|
|
509
559
|
|
|
510
560
|
for (const check of report.checks.filter((item) => item.status !== "info")) {
|
|
@@ -542,7 +592,7 @@ Examples:
|
|
|
542
592
|
`;
|
|
543
593
|
}
|
|
544
594
|
|
|
545
|
-
async function runDoctorCli(rawArgs) {
|
|
595
|
+
async function runDoctorCli(rawArgs, endpoint) {
|
|
546
596
|
let options;
|
|
547
597
|
try {
|
|
548
598
|
options = parseDoctorArgs(rawArgs);
|
|
@@ -558,7 +608,7 @@ async function runDoctorCli(rawArgs) {
|
|
|
558
608
|
}
|
|
559
609
|
|
|
560
610
|
try {
|
|
561
|
-
const report = await runDoctor(options);
|
|
611
|
+
const report = await runDoctor({ ...options, endpoint });
|
|
562
612
|
if (options.json) {
|
|
563
613
|
console.log(JSON.stringify(report, null, 2));
|
|
564
614
|
} else {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const net = require("net");
|
|
2
|
+
const { DEFAULT_SOCKET_PATH } = require("./socket-path.cjs");
|
|
3
|
+
const { authenticateClient } = require("./remote-transport.cjs");
|
|
4
|
+
|
|
5
|
+
function parseRemoteEndpoint(value) {
|
|
6
|
+
if (typeof value !== "string" || !value) throw new Error("--remote requires host:port");
|
|
7
|
+
let host;
|
|
8
|
+
let portText;
|
|
9
|
+
if (value.startsWith("[")) {
|
|
10
|
+
const match = value.match(/^\[([^\]]+)\]:(\d+)$/);
|
|
11
|
+
if (!match || net.isIP(match?.[1]) !== 6) throw new Error("remote endpoint must use a bracketed IPv6 address and port");
|
|
12
|
+
[, host, portText] = match;
|
|
13
|
+
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
|
|
14
|
+
} else {
|
|
15
|
+
const match = value.match(/^([^:]+):(\d+)$/);
|
|
16
|
+
if (!match) throw new Error("remote endpoint must be host:port (IPv6 must be bracketed)");
|
|
17
|
+
[, host, portText] = match;
|
|
18
|
+
if (host.includes("/") || host.includes("@") || host.includes(":") || host === "*" || host.includes("*")) throw new Error("remote endpoint host is invalid");
|
|
19
|
+
if ((/^\d+(?:\.\d+){3}$/.test(host) && net.isIP(host) !== 4) || (net.isIP(host) !== 4 && !/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(host))) {
|
|
20
|
+
throw new Error("remote endpoint host is invalid");
|
|
21
|
+
}
|
|
22
|
+
host = host.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
const port = Number(portText);
|
|
25
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("remote endpoint port must be between 1 and 65535");
|
|
26
|
+
if ((net.isIP(host) === 4 && host === "0.0.0.0") || (net.isIP(host) === 6 && /^0*:?0*$/.test(host.replace(/:/g, "")))) {
|
|
27
|
+
throw new Error("remote endpoint host must not be unspecified");
|
|
28
|
+
}
|
|
29
|
+
const display = net.isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`;
|
|
30
|
+
return { kind: "remote", host, port, display, key: `tcp:${display}`, connectionOptions: { host, port } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function selectEndpoint(args, env) {
|
|
34
|
+
const selectedEnv = env === undefined ? process.env : env;
|
|
35
|
+
const remoteIndexes = [];
|
|
36
|
+
const credentialIndexes = [];
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
if (args[i] === "--remote") remoteIndexes.push(i);
|
|
39
|
+
if (args[i] === "--remote-credential") credentialIndexes.push(i);
|
|
40
|
+
}
|
|
41
|
+
if (remoteIndexes.length > 1) throw new Error("--remote may only be specified once");
|
|
42
|
+
if (credentialIndexes.length > 1) throw new Error("--remote-credential may only be specified once");
|
|
43
|
+
let cliRemote;
|
|
44
|
+
let cliCredential;
|
|
45
|
+
const strippedArgs = [...args];
|
|
46
|
+
if (remoteIndexes.length) {
|
|
47
|
+
const index = remoteIndexes[0];
|
|
48
|
+
cliRemote = args[index + 1];
|
|
49
|
+
if (!cliRemote || cliRemote.startsWith("--")) throw new Error("--remote requires host:port");
|
|
50
|
+
strippedArgs.splice(index, 2);
|
|
51
|
+
}
|
|
52
|
+
if (credentialIndexes.length) {
|
|
53
|
+
const index = credentialIndexes[0];
|
|
54
|
+
cliCredential = args[index + 1];
|
|
55
|
+
if (!cliCredential || cliCredential.startsWith("--")) throw new Error("--remote-credential requires a file path");
|
|
56
|
+
const adjustedIndex = index - (remoteIndexes.length && index > remoteIndexes[0] ? 2 : 0);
|
|
57
|
+
strippedArgs.splice(adjustedIndex, 2);
|
|
58
|
+
}
|
|
59
|
+
const remoteValue = cliRemote || selectedEnv.SURF_REMOTE;
|
|
60
|
+
if (remoteValue) {
|
|
61
|
+
const credentialPath = cliCredential || selectedEnv.SURF_REMOTE_CREDENTIAL;
|
|
62
|
+
if (!credentialPath) throw new Error("remote endpoint requires --remote-credential <path> or SURF_REMOTE_CREDENTIAL");
|
|
63
|
+
return { args: strippedArgs, endpoint: { ...parseRemoteEndpoint(remoteValue), credentialPath } };
|
|
64
|
+
}
|
|
65
|
+
if (cliCredential) throw new Error("--remote-credential requires a remote endpoint");
|
|
66
|
+
const socketPath = selectedEnv.SURF_SOCKET || DEFAULT_SOCKET_PATH;
|
|
67
|
+
return { args: strippedArgs, endpoint: { kind: "local", path: socketPath, display: socketPath, key: `unix:${socketPath}`, connectionOptions: socketPath } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createRemoteSocket(endpoint) {
|
|
71
|
+
const rawSocket = net.createConnection(endpoint.connectionOptions, () => {});
|
|
72
|
+
let ready = false;
|
|
73
|
+
let connected = false;
|
|
74
|
+
let destroyed = false;
|
|
75
|
+
const pending = new Map();
|
|
76
|
+
const queue = (event, listener, once) => {
|
|
77
|
+
if (ready) {
|
|
78
|
+
once ? rawSocket.once(event, listener) : rawSocket.on(event, listener);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const listeners = pending.get(event) || [];
|
|
82
|
+
listeners.push({ listener, once });
|
|
83
|
+
pending.set(event, listeners);
|
|
84
|
+
};
|
|
85
|
+
const flush = () => {
|
|
86
|
+
ready = true;
|
|
87
|
+
for (const [event, listeners] of pending) {
|
|
88
|
+
for (const { listener, once } of listeners) {
|
|
89
|
+
once ? rawSocket.once(event, listener) : rawSocket.on(event, listener);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pending.clear();
|
|
93
|
+
};
|
|
94
|
+
const proxy = {
|
|
95
|
+
on(event, listener) { queue(event, listener, false); return proxy; },
|
|
96
|
+
once(event, listener) { queue(event, listener, true); return proxy; },
|
|
97
|
+
removeListener(event, listener) {
|
|
98
|
+
if (ready) rawSocket.removeListener(event, listener);
|
|
99
|
+
else pending.set(event, (pending.get(event) || []).filter((entry) => entry.listener !== listener));
|
|
100
|
+
return proxy;
|
|
101
|
+
},
|
|
102
|
+
write(...args) { return rawSocket.write(...args); },
|
|
103
|
+
end(...args) { return rawSocket.end(...args); },
|
|
104
|
+
destroy(...args) { destroyed = true; return rawSocket.destroy(...args); },
|
|
105
|
+
setTimeout(...args) { rawSocket.setTimeout(...args); return proxy; },
|
|
106
|
+
get authenticated() { return ready; },
|
|
107
|
+
get connected() { return connected; },
|
|
108
|
+
};
|
|
109
|
+
rawSocket.once("connect", () => { connected = true; });
|
|
110
|
+
rawSocket.on("error", (error) => {
|
|
111
|
+
if (ready || destroyed) return;
|
|
112
|
+
ready = true;
|
|
113
|
+
const listeners = pending.get("error") || [];
|
|
114
|
+
pending.delete("error");
|
|
115
|
+
for (const { listener } of listeners) listener(error);
|
|
116
|
+
if (proxy.__pendingErrors) proxy.__pendingErrors.length = 0;
|
|
117
|
+
flush();
|
|
118
|
+
});
|
|
119
|
+
rawSocket.on("close", () => {
|
|
120
|
+
if (ready) return;
|
|
121
|
+
ready = true;
|
|
122
|
+
const error = new Error("remote authentication connection closed");
|
|
123
|
+
for (const { listener } of pending.get("error") || []) listener(error);
|
|
124
|
+
for (const { listener } of pending.get("close") || []) listener();
|
|
125
|
+
pending.clear();
|
|
126
|
+
if (proxy.__pendingErrors) proxy.__pendingErrors.length = 0;
|
|
127
|
+
});
|
|
128
|
+
return { rawSocket, proxy, flush };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function connectEndpoint(endpoint, onConnect) {
|
|
132
|
+
if (endpoint.kind === "local") {
|
|
133
|
+
return net.createConnection(endpoint.connectionOptions, onConnect || (() => {}));
|
|
134
|
+
}
|
|
135
|
+
const { rawSocket, proxy, flush } = createRemoteSocket(endpoint);
|
|
136
|
+
rawSocket.once("connect", () => {
|
|
137
|
+
authenticateClient(rawSocket, endpoint.credentialPath)
|
|
138
|
+
.then(() => {
|
|
139
|
+
flush();
|
|
140
|
+
if (onConnect) onConnect(proxy);
|
|
141
|
+
})
|
|
142
|
+
.catch((error) => {
|
|
143
|
+
error.code = error.code || "EAUTH";
|
|
144
|
+
const listeners = proxy.__pendingErrors || [];
|
|
145
|
+
for (const listener of listeners) {
|
|
146
|
+
proxy.removeListener("error", listener);
|
|
147
|
+
listener(error);
|
|
148
|
+
}
|
|
149
|
+
proxy.__pendingErrors.length = 0;
|
|
150
|
+
flush();
|
|
151
|
+
proxy.destroy();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
proxy.__pendingErrors = [];
|
|
155
|
+
const originalOn = proxy.on;
|
|
156
|
+
const originalOnce = proxy.once;
|
|
157
|
+
proxy.on = (event, listener) => {
|
|
158
|
+
if (event === "error" && !proxy.authenticated) proxy.__pendingErrors.push(listener);
|
|
159
|
+
return originalOn(event, listener);
|
|
160
|
+
};
|
|
161
|
+
proxy.once = (event, listener) => {
|
|
162
|
+
if (event === "error" && !proxy.authenticated) proxy.__pendingErrors.push(listener);
|
|
163
|
+
return originalOnce(event, listener);
|
|
164
|
+
};
|
|
165
|
+
return proxy;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatEndpointError(error, endpoint, formatSocketError) {
|
|
169
|
+
if (endpoint.kind === "local") return formatSocketError(error);
|
|
170
|
+
const message = error?.message || String(error);
|
|
171
|
+
return `Remote endpoint connection failed (${endpoint.display}): ${message}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
|