surf-cli 2.18.0 → 2.20.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.
@@ -35,6 +35,14 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
35
35
  })();
36
36
  }
37
37
 
38
+ function semanticRequestContext(context, timeoutMs, identity) {
39
+ return {
40
+ ...context,
41
+ timeoutMs,
42
+ ...(identity ? { tabId: identity.tabId, windowId: undefined, session: undefined } : {}),
43
+ };
44
+ }
45
+
38
46
  function summarizeArgs(step) {
39
47
  return Object.entries(step.args || {})
40
48
  .map(([key, value]) => typeof value === "string" && value.length > 40 ? `${key}="${value.slice(0, 37)}..."` : `${key}=${JSON.stringify(value)}`)
@@ -63,14 +71,32 @@ function printProgress(event) {
63
71
 
64
72
  async function executeDoSteps(steps, options = {}) {
65
73
  const context = options.context || {};
66
- return runtime.executeWorkflow(steps, {
67
- ...options,
68
- executeTool: options.executeTool || ((tool, args) => sendDoRequest(tool, args, context)),
69
- onProgress: options.quiet ? options.onProgress : (event) => {
70
- printProgress(event);
71
- options.onProgress?.(event);
72
- },
73
- });
74
+ let semanticExecutor;
75
+ const executeSemanticStep = steps.some((step) => step.cmd === "semantic.step")
76
+ ? async (step, semanticContext, executionOptions) => {
77
+ if (!semanticExecutor) {
78
+ semanticExecutor = options.executeSemanticStep;
79
+ if (!semanticExecutor && typeof options.createSemanticExecutor === "function") {
80
+ semanticExecutor = await options.createSemanticExecutor({ context });
81
+ }
82
+ if (typeof semanticExecutor !== "function") throw new Error("semantic.step requires an injected semantic executor");
83
+ }
84
+ return semanticExecutor(step, semanticContext, executionOptions);
85
+ }
86
+ : undefined;
87
+ try {
88
+ return await runtime.executeWorkflow(steps, {
89
+ ...options,
90
+ executeTool: options.executeTool || ((tool, args) => sendDoRequest(tool, args, context)),
91
+ ...(executeSemanticStep ? { executeSemanticStep } : {}),
92
+ onProgress: options.quiet ? options.onProgress : (event) => {
93
+ printProgress(event);
94
+ options.onProgress?.(event);
95
+ },
96
+ });
97
+ } finally {
98
+ await semanticExecutor?.close?.();
99
+ }
74
100
  }
75
101
 
76
102
  module.exports = {
@@ -83,6 +109,7 @@ module.exports = {
83
109
  extractStepOutput: runtime.extractStepOutput,
84
110
  getAutoWaitCommand: runtime.getAutoWaitCommand,
85
111
  resolveVar: runtime.resolveVar,
112
+ semanticRequestContext,
86
113
  sendDoRequest,
87
114
  shouldAutoWait: runtime.shouldAutoWait,
88
115
  substituteVars: runtime.substituteVars,
package/native/doctor.cjs CHANGED
@@ -4,6 +4,17 @@ const os = require("os");
4
4
  const path = require("path");
5
5
  const { execFileSync } = require("child_process");
6
6
  const { connectEndpoint, selectEndpoint } = require("./endpoint.cjs");
7
+ const {
8
+ convertWindowsPath,
9
+ getWindowsEnv,
10
+ nativeMessagingRegistryPath,
11
+ runWindowsExecutable,
12
+ } = require("../scripts/windows-interop.cjs");
13
+ const {
14
+ renderWslWrapper,
15
+ probeWindowsWrapper,
16
+ } = require("./native-host-launch-probe.cjs");
17
+ const { findNode, getHostPath } = require("../scripts/install-native-host.cjs");
7
18
 
8
19
  const HOST_NAME = "surf.browser.host";
9
20
 
@@ -115,32 +126,12 @@ function resolveBrowsers(browserArg) {
115
126
  return browsers;
116
127
  }
117
128
 
118
- function getWindowsEnv(name, { env = process.env, execFileSync: execFile = execFileSync } = {}) {
119
- if (env[name]) return env[name];
120
- try {
121
- return execFile("cmd.exe", ["/c", "echo", `%${name}%`], { encoding: "utf8" })
122
- .trim()
123
- .replace(/\r/g, "");
124
- } catch {
125
- return null;
126
- }
127
- }
128
-
129
- function windowsPathToWslPath(winPath) {
130
- const normalized = winPath.replace(/\\/g, "/");
131
- const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
132
- if (!match) return normalized;
133
- return `/mnt/${match[1].toLowerCase()}/${match[2]}`;
134
- }
135
-
136
129
  function manifestPathForBrowser(browserKey, context) {
137
130
  const browser = BROWSERS[browserKey];
138
131
  if (!browser) return null;
139
132
 
140
133
  if (context.effectiveTarget === "wsl-windows") {
141
- const localAppData = getWindowsEnv("LOCALAPPDATA", context);
142
- if (!localAppData || !browser.wsl) return null;
143
- return path.join(windowsPathToWslPath(localAppData), browser.wsl, `${HOST_NAME}.json`);
134
+ return null;
144
135
  }
145
136
 
146
137
  if (context.platform === "win32") {
@@ -156,7 +147,7 @@ function manifestPathForBrowser(browserKey, context) {
156
147
 
157
148
  function fsPathFromManifestPath(manifestPath, context) {
158
149
  if (context.platform === "linux" && /^[A-Za-z]:[\\/]/.test(manifestPath)) {
159
- return windowsPathToWslPath(manifestPath);
150
+ return convertWindowsPath(manifestPath, context);
160
151
  }
161
152
  return manifestPath;
162
153
  }
@@ -164,17 +155,21 @@ function fsPathFromManifestPath(manifestPath, context) {
164
155
  function windowsRegistryPathForBrowser(browserKey) {
165
156
  const browser = BROWSERS[browserKey];
166
157
  if (!browser?.win32) return null;
167
- return `HKCU\\Software\\${browser.win32}\\NativeMessagingHosts\\${HOST_NAME}`;
158
+ return nativeMessagingRegistryPath(browser.win32, HOST_NAME);
168
159
  }
169
160
 
170
161
  function readWindowsRegistryManifestPath(registryPath, context) {
171
162
  try {
172
- const output = context.execFileSync("reg", ["query", registryPath, "/ve"], { encoding: "utf8" });
163
+ const output = runWindowsExecutable("reg.exe", ["query", registryPath, "/ve"], {
164
+ execFileSync: context.execFileSync,
165
+ allowWslFallback: context.effectiveTarget === "wsl-windows",
166
+ execOptions: { encoding: "utf8" },
167
+ });
173
168
  const line = output.split(/\r?\n/).find((item) => item.includes("REG_SZ"));
174
- if (!line) return null;
175
- return line.replace(/^.*REG_SZ\s+/, "").trim() || null;
176
- } catch {
177
- return null;
169
+ if (!line) return { manifestPath: null, error: "registry output contained no REG_SZ default value" };
170
+ return { manifestPath: line.replace(/^.*REG_SZ\s+/, "").trim() || null, error: null };
171
+ } catch (error) {
172
+ return { manifestPath: null, error: error.message };
178
173
  }
179
174
  }
180
175
 
@@ -192,15 +187,16 @@ function checkWindowsRegistry(browserKey, context) {
192
187
  };
193
188
  }
194
189
 
195
- const manifestPath = readWindowsRegistryManifestPath(registryPath, context);
190
+ const registry = readWindowsRegistryManifestPath(registryPath, context);
191
+ const manifestPath = registry.manifestPath;
196
192
  return {
197
193
  check: {
198
194
  id: "windows-registry",
199
195
  status: manifestPath ? "pass" : "fail",
200
196
  browser: browserKey,
201
- message: manifestPath
202
- ? `Windows native messaging registry points to ${manifestPath}`
203
- : `Windows native messaging registry entry not found: ${registryPath}`,
197
+ message: !manifestPath
198
+ ? `Windows native messaging registry entry not found: ${registryPath}${registry.error ? ` (${registry.error})` : ""}`
199
+ : `Windows native messaging registry points to ${manifestPath}`,
204
200
  registryPath,
205
201
  path: manifestPath,
206
202
  },
@@ -210,19 +206,32 @@ function checkWindowsRegistry(browserKey, context) {
210
206
 
211
207
  function checkManifest(manifestPath, context) {
212
208
  const checks = [];
213
- const exists = manifestPath ? context.fs.existsSync(manifestPath) : false;
209
+ let manifestFsPath = manifestPath;
210
+ try {
211
+ manifestFsPath = manifestPath ? fsPathFromManifestPath(manifestPath, context) : manifestPath;
212
+ } catch (error) {
213
+ checks.push({
214
+ id: "manifest-file",
215
+ status: "fail",
216
+ message: `Could not resolve manifest path ${manifestPath}: ${error.message}`,
217
+ path: manifestPath,
218
+ });
219
+ return { checks, manifest: null };
220
+ }
221
+ const exists = manifestFsPath ? context.fs.existsSync(manifestFsPath) : false;
214
222
  checks.push({
215
223
  id: "manifest-file",
216
224
  status: exists ? "pass" : "fail",
217
- message: exists ? `Manifest found: ${manifestPath}` : "Native messaging manifest not found",
225
+ message: exists ? `Manifest found: ${manifestPath}` : `Native messaging manifest not found: ${manifestPath}`,
218
226
  path: manifestPath,
227
+ fsPath: manifestFsPath,
219
228
  });
220
229
 
221
230
  if (!exists) return { checks, manifest: null };
222
231
 
223
232
  let manifest;
224
233
  try {
225
- manifest = JSON.parse(context.fs.readFileSync(manifestPath, "utf8"));
234
+ manifest = JSON.parse(context.fs.readFileSync(manifestFsPath, "utf8"));
226
235
  checks.push({ id: "manifest-json", status: "pass", message: "Manifest JSON is valid" });
227
236
  } catch (error) {
228
237
  checks.push({ id: "manifest-json", status: "fail", message: `Manifest JSON is invalid: ${error.message}` });
@@ -288,6 +297,126 @@ function checkManifest(manifestPath, context) {
288
297
  return { checks, manifest };
289
298
  }
290
299
 
300
+ function normalizeWindowsPath(filePath) {
301
+ return path.win32.normalize(filePath).replace(/[\\/]+$/, "").toLowerCase();
302
+ }
303
+
304
+ function checkWslWrapperLaunch(manifest, context) {
305
+ if (!manifest || typeof manifest.path !== "string" || manifest.path.length === 0) {
306
+ return null;
307
+ }
308
+
309
+ let canonicalWindowsPath;
310
+ try {
311
+ const localAppData = getWindowsEnv("LOCALAPPDATA", { execFileSync: context.execFileSync });
312
+ canonicalWindowsPath = path.win32.join(
313
+ localAppData,
314
+ "surf-cli",
315
+ "host-wrapper-wsl.cmd",
316
+ );
317
+ } catch (error) {
318
+ return {
319
+ id: "wrapper-launch",
320
+ status: "fail",
321
+ message: `Could not resolve Surf's managed Windows wrapper path: ${error.message}`,
322
+ };
323
+ }
324
+
325
+ if (normalizeWindowsPath(manifest.path) !== normalizeWindowsPath(canonicalWindowsPath)) {
326
+ return {
327
+ id: "wrapper-launch",
328
+ status: "warn",
329
+ message:
330
+ "Skipped launch probe because the manifest does not point to Surf's managed WSL wrapper. Run `surf install <extension-id>` to restore the managed wrapper.",
331
+ path: manifest.path,
332
+ };
333
+ }
334
+
335
+ let wrapperFsPath;
336
+ try {
337
+ wrapperFsPath = fsPathFromManifestPath(canonicalWindowsPath, context);
338
+ } catch (error) {
339
+ return {
340
+ id: "wrapper-launch",
341
+ status: "fail",
342
+ message: `Could not resolve Surf's managed WSL wrapper: ${error.message}`,
343
+ path: canonicalWindowsPath,
344
+ };
345
+ }
346
+
347
+ if (!context.fs.existsSync(wrapperFsPath)) {
348
+ return {
349
+ id: "wrapper-launch",
350
+ status: "fail",
351
+ message: `Surf's managed WSL wrapper does not exist: ${canonicalWindowsPath}`,
352
+ path: canonicalWindowsPath,
353
+ fsPath: wrapperFsPath,
354
+ };
355
+ }
356
+
357
+ let wrapperContent;
358
+ try {
359
+ wrapperContent = context.fs.readFileSync(wrapperFsPath, "utf8");
360
+ } catch (error) {
361
+ return {
362
+ id: "wrapper-launch",
363
+ status: "fail",
364
+ message: `Could not read Surf's managed WSL wrapper: ${error.message}`,
365
+ path: canonicalWindowsPath,
366
+ fsPath: wrapperFsPath,
367
+ };
368
+ }
369
+
370
+ const nodePath = context.nodePath || findNode();
371
+ const hostPath = context.hostPath || getHostPath();
372
+ const distro = context.env.WSL_DISTRO_NAME;
373
+ let explicitWrapper = null;
374
+ let defaultWrapper = null;
375
+ if (nodePath && hostPath && distro) {
376
+ try {
377
+ explicitWrapper = renderWslWrapper(nodePath, hostPath, distro);
378
+ defaultWrapper = renderWslWrapper(nodePath, hostPath, null);
379
+ } catch {
380
+ // Do not execute a wrapper whose installed paths cannot be rendered safely.
381
+ }
382
+ }
383
+ if (wrapperContent !== explicitWrapper && wrapperContent !== defaultWrapper) {
384
+ return {
385
+ id: "wrapper-launch",
386
+ status: "warn",
387
+ message:
388
+ "Surf's managed WSL wrapper does not match this installation, so it was not executed. Run `surf install <extension-id>` to replace it.",
389
+ path: canonicalWindowsPath,
390
+ fsPath: wrapperFsPath,
391
+ };
392
+ }
393
+
394
+ try {
395
+ const observedDistro = context.probeWindowsWrapper(canonicalWindowsPath, {
396
+ execFileSync: context.execFileSync,
397
+ verifyDistro: wrapperContent === defaultWrapper,
398
+ });
399
+ if (wrapperContent === defaultWrapper && observedDistro !== distro) {
400
+ throw new Error("Windows default WSL distro does not match this installation");
401
+ }
402
+ return {
403
+ id: "wrapper-launch",
404
+ status: "pass",
405
+ message: "Surf's managed WSL wrapper completed its launch probe",
406
+ path: canonicalWindowsPath,
407
+ fsPath: wrapperFsPath,
408
+ };
409
+ } catch (error) {
410
+ return {
411
+ id: "wrapper-launch",
412
+ status: "fail",
413
+ message: `Surf's managed WSL wrapper failed validation: ${error.message}`,
414
+ path: canonicalWindowsPath,
415
+ fsPath: wrapperFsPath,
416
+ };
417
+ }
418
+ }
419
+
291
420
  async function checkSocket(socketPath, context) {
292
421
  const checks = [];
293
422
  if (context.platform !== "win32") {
@@ -371,6 +500,20 @@ function summarize(checks) {
371
500
  function buildRecommendations(report) {
372
501
  const recommendations = [];
373
502
  const failedIds = new Set(report.checks.filter((check) => check.status === "fail").map((check) => check.id));
503
+ const debugRelevantIds = new Set([
504
+ "windows-registry",
505
+ "manifest-file",
506
+ "manifest-json",
507
+ "manifest-shape",
508
+ "manifest-name",
509
+ "manifest-type",
510
+ "manifest-origins",
511
+ "manifest-path",
512
+ "manifest-path-executable",
513
+ "wrapper-launch",
514
+ "socket-file",
515
+ "socket-connect",
516
+ ]);
374
517
 
375
518
  if (failedIds.has("windows-registry")) {
376
519
  recommendations.push("Run `surf install <extension-id> --browser <browser>` so Windows registers the native messaging host, then restart the browser.");
@@ -384,12 +527,18 @@ function buildRecommendations(report) {
384
527
  if (failedIds.has("manifest-path") || failedIds.has("manifest-path-executable")) {
385
528
  recommendations.push("Reinstall the native host so the manifest path points at the current Surf wrapper.");
386
529
  }
530
+ if (failedIds.has("wrapper-launch")) {
531
+ recommendations.push("Rerun `surf install <extension-id>` from the same WSL distro so Surf can replace and validate the Windows wrapper.");
532
+ }
387
533
  if (failedIds.has("manifest-supported")) {
388
534
  recommendations.push("Choose a browser supported for this target, or rerun with `--browser all` to inspect every supported setup.");
389
535
  }
390
536
  if (failedIds.has("socket-file") || failedIds.has("socket-connect")) {
391
537
  recommendations.push("Make sure the browser is running with the Surf extension enabled, then restart the browser after install changes.");
392
538
  }
539
+ if ([...failedIds].some((id) => debugRelevantIds.has(id))) {
540
+ recommendations.push("Open chrome://extensions and inspect Surf's service worker console. In Surf's Details > Extension options, enable Debug Mode, reproduce the failure, then disable Debug Mode when finished.");
541
+ }
393
542
  if (report.environment.surfSocketSet) {
394
543
  recommendations.push("SURF_SOCKET is set; make sure Chrome launches the native host with the same socket value.");
395
544
  }
@@ -405,6 +554,14 @@ function buildRecommendations(report) {
405
554
 
406
555
  function remoteRecommendations(endpoint, code) {
407
556
  const base = [`Confirm Surf is listening on ${endpoint.display}; the remote host listener must allow this Tailnet connection.`];
557
+ const tlsCodes = new Set([
558
+ "ERR_TLS_CERT_ALTNAME_INVALID", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "SELF_SIGNED_CERT_IN_CHAIN",
559
+ "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_GET_ISSUER_CERT", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
560
+ "CERT_HAS_EXPIRED",
561
+ ]);
562
+ if (endpoint.tls?.enabled && (code === "ETIMEDOUT" || tlsCodes.has(code))) {
563
+ return [...base, "Check the reverse-proxy certificate chain and expected server name (or --remote-tls-server-name); a custom CA replaces system roots."];
564
+ }
408
565
  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
566
  if (code === "ETIMEDOUT") return [...base, `Run \`tailscale ping ${endpoint.host}\`; check restrictive Tailnet ACLs/grants and host firewall rules.`];
410
567
  if (code === "ECONNREFUSED") return [...base, "Verify the host process is running and bound to the requested TCP port; check restrictive Tailnet ACLs/grants."];
@@ -445,9 +602,10 @@ async function runDoctor(rawOptions = {}, deps = {}) {
445
602
  socket = connectEndpoint(target, () => finish({ ok: true, message: "authenticated" }));
446
603
  socket.once("error", (error) => finish({ ok: false, code: error.code, message: error.message || String(error) }));
447
604
  })))(endpoint, options.connectTimeoutMs);
448
- const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}`, endpoint: endpoint.display }, {
605
+ const tlsMarker = endpoint.tls?.enabled ? " (TLS)" : "";
606
+ const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}${tlsMarker}`, endpoint: endpoint.display }, {
449
607
  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}`,
608
+ message: connection.ok ? `Connected to remote endpoint ${endpoint.display}${tlsMarker}` : `Could not connect to remote endpoint ${endpoint.display}${tlsMarker}: ${connection.message}`,
451
609
  code: connection.code,
452
610
  }, {
453
611
  id: "remote-auth", status: connection.ok ? "pass" : connection.code === "EAUTH" ? "fail" : "info",
@@ -469,6 +627,9 @@ async function runDoctor(rawOptions = {}, deps = {}) {
469
627
  effectiveTarget,
470
628
  fs: deps.fs || fs,
471
629
  execFileSync: deps.execFileSync || execFileSync,
630
+ probeWindowsWrapper: deps.probeWindowsWrapper || probeWindowsWrapper,
631
+ nodePath: deps.nodePath,
632
+ hostPath: deps.hostPath,
472
633
  connectSocket: deps.connectSocket || connectSocket,
473
634
  connectTimeoutMs: options.connectTimeoutMs,
474
635
  };
@@ -484,15 +645,20 @@ async function runDoctor(rawOptions = {}, deps = {}) {
484
645
  for (const browserKey of browsers) {
485
646
  const browser = BROWSERS[browserKey];
486
647
  const browserChecks = [];
487
- let manifestPath = manifestPathForBrowser(browserKey, context);
648
+ const usesWindowsRegistry =
649
+ (context.platform === "win32" || context.effectiveTarget === "wsl-windows") &&
650
+ Boolean(browser.win32);
651
+ let manifestPath = null;
488
652
 
489
- if (context.platform === "win32" && browser.win32) {
653
+ if (usesWindowsRegistry) {
490
654
  const registry = checkWindowsRegistry(browserKey, context);
491
655
  browserChecks.push(registry.check);
492
- if (registry.manifestPath) manifestPath = registry.manifestPath;
656
+ manifestPath = registry.manifestPath;
657
+ } else {
658
+ manifestPath = manifestPathForBrowser(browserKey, context);
493
659
  }
494
660
 
495
- if (!manifestPath) {
661
+ if (!manifestPath && !usesWindowsRegistry) {
496
662
  const check = {
497
663
  id: "manifest-supported",
498
664
  status: options.browser === "all" ? "warn" : "fail",
@@ -507,6 +673,10 @@ async function runDoctor(rawOptions = {}, deps = {}) {
507
673
 
508
674
  const result = checkManifest(manifestPath, context);
509
675
  browserChecks.push(...result.checks.map((check) => ({ ...check, browser: browserKey })));
676
+ if (context.effectiveTarget === "wsl-windows" && result.manifest) {
677
+ const wrapperLaunch = checkWslWrapperLaunch(result.manifest, context);
678
+ if (wrapperLaunch) browserChecks.push({ ...wrapperLaunch, browser: browserKey });
679
+ }
510
680
  checks.push(...browserChecks);
511
681
  manifests.push({
512
682
  browser: browserKey,
@@ -629,5 +799,4 @@ module.exports = {
629
799
  parseDoctorArgs,
630
800
  runDoctor,
631
801
  runDoctorCli,
632
- windowsPathToWslPath,
633
802
  };
@@ -1,7 +1,12 @@
1
1
  const net = require("net");
2
+ const fs = require("fs");
3
+ const tls = require("tls");
2
4
  const { DEFAULT_SOCKET_PATH } = require("./socket-path.cjs");
3
5
  const { authenticateClient } = require("./remote-transport.cjs");
4
6
 
7
+ const TLS_HANDSHAKE_TIMEOUT_MS = 5000;
8
+ const HOSTNAME_PATTERN = /^(?=.{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])?$/;
9
+
5
10
  function parseRemoteEndpoint(value) {
6
11
  if (typeof value !== "string" || !value) throw new Error("--remote requires host:port");
7
12
  let host;
@@ -16,7 +21,7 @@ function parseRemoteEndpoint(value) {
16
21
  if (!match) throw new Error("remote endpoint must be host:port (IPv6 must be bracketed)");
17
22
  [, host, portText] = match;
18
23
  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))) {
24
+ if ((/^\d+(?:\.\d+){3}$/.test(host) && net.isIP(host) !== 4) || (net.isIP(host) !== 4 && !HOSTNAME_PATTERN.test(host))) {
20
25
  throw new Error("remote endpoint host is invalid");
21
26
  }
22
27
  host = host.toLowerCase();
@@ -30,45 +35,107 @@ function parseRemoteEndpoint(value) {
30
35
  return { kind: "remote", host, port, display, key: `tcp:${display}`, connectionOptions: { host, port } };
31
36
  }
32
37
 
38
+ function extractRemoteOptions(args) {
39
+ const definitions = {
40
+ "--remote": { name: "remote", missing: "--remote requires host:port" },
41
+ "--remote-credential": { name: "credential", missing: "--remote-credential requires a file path" },
42
+ "--remote-tls": { name: "tls", boolean: true },
43
+ "--remote-tls-ca": { name: "tlsCa", missing: "--remote-tls-ca requires a file path" },
44
+ "--remote-tls-server-name": { name: "tlsServerName", missing: "--remote-tls-server-name requires a DNS hostname" },
45
+ };
46
+ const values = {};
47
+ const strippedArgs = [];
48
+ for (let index = 0; index < args.length; index++) {
49
+ const option = definitions[args[index]];
50
+ if (!option) {
51
+ strippedArgs.push(args[index]);
52
+ continue;
53
+ }
54
+ if (Object.hasOwn(values, option.name)) throw new Error(`${args[index]} may only be specified once`);
55
+ if (option.boolean) {
56
+ values[option.name] = true;
57
+ continue;
58
+ }
59
+ const value = args[index + 1];
60
+ if (!value || value.startsWith("--")) throw new Error(option.missing);
61
+ values[option.name] = value;
62
+ index++;
63
+ }
64
+ return { values, strippedArgs };
65
+ }
66
+
67
+ function validateServerName(value, source) {
68
+ if (!value || net.isIP(value) !== 0 || !HOSTNAME_PATTERN.test(value)) {
69
+ throw new Error(`${source} must be a valid DNS hostname`);
70
+ }
71
+ return value.toLowerCase();
72
+ }
73
+
74
+ function loadTlsCa(caPath, source) {
75
+ let ca;
76
+ try {
77
+ ca = fs.readFileSync(caPath);
78
+ } catch (error) {
79
+ throw new Error(`${source} could not read CA file ${caPath}: ${error.message}`);
80
+ }
81
+ if (ca.length === 0 || !ca.includes(Buffer.from("-----BEGIN CERTIFICATE-----"))) {
82
+ throw new Error(`${source} CA file ${caPath} must contain a PEM certificate`);
83
+ }
84
+ return ca;
85
+ }
86
+
33
87
  function selectEndpoint(args, env) {
34
88
  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);
89
+ const { values, strippedArgs } = extractRemoteOptions(args);
90
+ const envTls = selectedEnv.SURF_REMOTE_TLS;
91
+ if (envTls && envTls !== "1") {
92
+ throw new Error('SURF_REMOTE_TLS must be "1" to enable TLS; unset it to disable');
51
93
  }
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);
94
+ const remoteValue = values.remote || selectedEnv.SURF_REMOTE;
95
+ const credentialPath = values.credential || selectedEnv.SURF_REMOTE_CREDENTIAL;
96
+ const tlsEnabled = values.tls === true || envTls === "1";
97
+ const caPath = values.tlsCa || selectedEnv.SURF_REMOTE_TLS_CA;
98
+ const serverNameValue = values.tlsServerName || selectedEnv.SURF_REMOTE_TLS_SERVER_NAME;
99
+ if (!remoteValue) {
100
+ if (values.credential) throw new Error("--remote-credential requires a remote endpoint");
101
+ if (values.tls) throw new Error("--remote-tls requires a remote endpoint");
102
+ if (selectedEnv.SURF_REMOTE_TLS === "1") throw new Error("SURF_REMOTE_TLS requires a remote endpoint");
103
+ if (values.tlsCa) throw new Error("--remote-tls-ca requires a remote endpoint");
104
+ if (selectedEnv.SURF_REMOTE_TLS_CA) throw new Error("SURF_REMOTE_TLS_CA requires a remote endpoint");
105
+ if (values.tlsServerName) throw new Error("--remote-tls-server-name requires a remote endpoint");
106
+ if (selectedEnv.SURF_REMOTE_TLS_SERVER_NAME) throw new Error("SURF_REMOTE_TLS_SERVER_NAME requires a remote endpoint");
107
+ const socketPath = selectedEnv.SURF_SOCKET || DEFAULT_SOCKET_PATH;
108
+ return { args: strippedArgs, endpoint: { kind: "local", path: socketPath, display: socketPath, key: `unix:${socketPath}`, connectionOptions: socketPath } };
58
109
  }
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 } };
110
+ if (!credentialPath) throw new Error("remote endpoint requires --remote-credential <path> or SURF_REMOTE_CREDENTIAL");
111
+ if (caPath && !tlsEnabled) throw new Error(`${values.tlsCa ? "--remote-tls-ca" : "SURF_REMOTE_TLS_CA"} requires TLS to be enabled`);
112
+ if (serverNameValue && !tlsEnabled) throw new Error(`${values.tlsServerName ? "--remote-tls-server-name" : "SURF_REMOTE_TLS_SERVER_NAME"} requires TLS to be enabled`);
113
+ const endpoint = { ...parseRemoteEndpoint(remoteValue), credentialPath };
114
+ if (tlsEnabled) {
115
+ const tlsOptions = { enabled: true };
116
+ if (caPath) {
117
+ tlsOptions.ca = loadTlsCa(caPath, values.tlsCa ? "--remote-tls-ca" : "SURF_REMOTE_TLS_CA");
118
+ tlsOptions.caPath = caPath;
119
+ }
120
+ const serverName = serverNameValue
121
+ ? validateServerName(serverNameValue, values.tlsServerName ? "--remote-tls-server-name" : "SURF_REMOTE_TLS_SERVER_NAME")
122
+ : net.isIP(endpoint.host) === 0 ? endpoint.host : undefined;
123
+ if (serverName) tlsOptions.serverName = serverName;
124
+ endpoint.tls = tlsOptions;
64
125
  }
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 } };
126
+ return { args: strippedArgs, endpoint };
68
127
  }
69
128
 
70
129
  function createRemoteSocket(endpoint) {
71
- const rawSocket = net.createConnection(endpoint.connectionOptions, () => {});
130
+ const usingTls = endpoint.tls?.enabled === true;
131
+ const rawSocket = usingTls
132
+ ? tls.connect({
133
+ ...endpoint.connectionOptions,
134
+ rejectUnauthorized: true,
135
+ ...(endpoint.tls.ca ? { ca: endpoint.tls.ca } : {}),
136
+ ...(endpoint.tls.serverName ? { servername: endpoint.tls.serverName } : {}),
137
+ })
138
+ : net.createConnection(endpoint.connectionOptions, () => {});
72
139
  let ready = false;
73
140
  let connected = false;
74
141
  let destroyed = false;
@@ -106,7 +173,25 @@ function createRemoteSocket(endpoint) {
106
173
  get authenticated() { return ready; },
107
174
  get connected() { return connected; },
108
175
  };
109
- rawSocket.once("connect", () => { connected = true; });
176
+ const transportReadyEvent = usingTls ? "secureConnect" : "connect";
177
+ rawSocket.once(transportReadyEvent, () => { connected = true; });
178
+ let handshakeTimer;
179
+ if (usingTls) {
180
+ const timeoutMs = endpoint.tls.handshakeTimeoutMs ?? TLS_HANDSHAKE_TIMEOUT_MS;
181
+ const clearHandshakeTimer = () => {
182
+ if (handshakeTimer) clearTimeout(handshakeTimer);
183
+ handshakeTimer = undefined;
184
+ };
185
+ handshakeTimer = setTimeout(() => {
186
+ if (connected || rawSocket.destroyed) return;
187
+ const error = new Error(`TLS handshake timed out after ${timeoutMs}ms`);
188
+ error.code = "ETIMEDOUT";
189
+ rawSocket.destroy(error);
190
+ }, timeoutMs);
191
+ rawSocket.once("secureConnect", clearHandshakeTimer);
192
+ rawSocket.once("error", clearHandshakeTimer);
193
+ rawSocket.once("close", clearHandshakeTimer);
194
+ }
110
195
  rawSocket.on("error", (error) => {
111
196
  if (ready || destroyed) return;
112
197
  ready = true;
@@ -133,7 +218,7 @@ function connectEndpoint(endpoint, onConnect) {
133
218
  return net.createConnection(endpoint.connectionOptions, onConnect || (() => {}));
134
219
  }
135
220
  const { rawSocket, proxy, flush } = createRemoteSocket(endpoint);
136
- rawSocket.once("connect", () => {
221
+ rawSocket.once(endpoint.tls?.enabled ? "secureConnect" : "connect", () => {
137
222
  authenticateClient(rawSocket, endpoint.credentialPath)
138
223
  .then(() => {
139
224
  flush();
@@ -168,7 +253,7 @@ function connectEndpoint(endpoint, onConnect) {
168
253
  function formatEndpointError(error, endpoint, formatSocketError) {
169
254
  if (endpoint.kind === "local") return formatSocketError(error);
170
255
  const message = error?.message || String(error);
171
- return `Remote endpoint connection failed (${endpoint.display}): ${message}`;
256
+ return `Remote endpoint connection failed (${endpoint.display}${endpoint.tls?.enabled ? ", TLS" : ""}): ${message}`;
172
257
  }
173
258
 
174
- module.exports = { parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
259
+ module.exports = { TLS_HANDSHAKE_TIMEOUT_MS, parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };