Skip to content

Commit 11f71fe

Browse files
authored
Automatic Port Selection and Secured Communication (#1038)
Adds dynamic OS-selected socket port handling and secured Electron/.NET communication. The dotnet-first startup flow now generates the auth token on the .NET side and passes it to Electron through an environment variable. Electron reports the selected socket port through a temporary startup-info file, avoiding any dependency on parsing Electron console output. Also avoids logging backend startup parameters because they include the auth token.
1 parent 1e8b026 commit 11f71fe

21 files changed

Lines changed: 528 additions & 123 deletions

src/ElectronNET.API/Bridge/SocketIOConnection.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,29 @@
33
namespace ElectronNET.API;
44

55
using System;
6+
using System.Collections.Generic;
67
using System.Threading.Tasks;
78
using ElectronNET.API.Serialization;
89
using SocketIO.Serializer.SystemTextJson;
910
using SocketIO = SocketIOClient.SocketIO;
11+
using SocketIOOptions = SocketIOClient.SocketIOOptions;
1012

1113
internal class SocketIOConnection : ISocketConnection
1214
{
1315
private readonly SocketIO _socket;
1416
private readonly object _lockObj = new object();
1517
private bool _isDisposed;
1618

17-
public SocketIOConnection(string uri)
19+
public SocketIOConnection(string uri, string authorization)
1820
{
19-
_socket = new SocketIO(uri);
21+
var opts = string.IsNullOrEmpty(authorization) ? new SocketIOOptions() : new SocketIOOptions
22+
{
23+
ExtraHeaders = new Dictionary<string, string>
24+
{
25+
["authorization"] = authorization
26+
},
27+
};
28+
_socket = new SocketIO(uri, opts);
2029
_socket.Serializer = new SystemTextJsonSerializer(ElectronJson.Options);
2130
// Use default System.Text.Json serializer from SocketIOClient.
2231
// Outgoing args are normalized to camelCase via SerializeArg in Emit.

src/ElectronNET.API/Common/ProcessRunner.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace ElectronNET.Common
22
{
33
using System;
4+
using System.Collections.Generic;
45
using System.Diagnostics;
56
using System.Diagnostics.CodeAnalysis;
67
using System.Text;
@@ -26,6 +27,8 @@ public class ProcessRunner : IDisposable
2627
private readonly StringBuilder stdOut = new StringBuilder(4 * 1024);
2728
private readonly StringBuilder stdErr = new StringBuilder(4 * 1024);
2829

30+
public event EventHandler<string> LineReceived;
31+
2932
private volatile ManualResetEvent stdOutEvent;
3033
private volatile ManualResetEvent stdErrEvent;
3134
private volatile Stopwatch stopwatch;
@@ -109,6 +112,11 @@ public string StandardError
109112
public int? LastExitCode { get; private set; }
110113

111114
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory)
115+
{
116+
return this.Run(exeFileName, commandLineArgs, workingDirectory, null);
117+
}
118+
119+
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory, IDictionary<string, string> environmentVariables)
112120
{
113121
this.CommandLine = commandLineArgs;
114122
this.WorkingFolder = workingDirectory;
@@ -126,6 +134,14 @@ public bool Run(string exeFileName, string commandLineArgs, string workingDirect
126134
WorkingDirectory = workingDirectory
127135
};
128136

137+
if (environmentVariables != null)
138+
{
139+
foreach (var kv in environmentVariables)
140+
{
141+
startInfo.EnvironmentVariables[kv.Key] = kv.Value;
142+
}
143+
}
144+
129145
return this.Run(startInfo);
130146
}
131147

@@ -571,6 +587,7 @@ private void Process_OutputDataReceived(object sender, DataReceivedEventArgs e)
571587
if (e.Data != null)
572588
{
573589
Console.WriteLine("|| " + e.Data);
590+
LineReceived?.Invoke(this, e.Data);
574591
}
575592
else
576593
{

src/ElectronNET.API/ElectronNetRuntime.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static class ElectronNetRuntime
1616
internal const int DefaultWebPort = 8001;
1717
internal const string ElectronPortArgumentName = "electronPort";
1818
internal const string ElectronPidArgumentName = "electronPID";
19+
internal const string ElectronAuthTokenArgumentName = "electronAuthToken";
1920

2021
/// <summary>Initializes the <see cref="ElectronNetRuntime"/> class.</summary>
2122
static ElectronNetRuntime()
@@ -26,6 +27,8 @@ static ElectronNetRuntime()
2627

2728
public static string ElectronExtraArguments { get; set; }
2829

30+
public static string ElectronAuthToken { get; internal set; }
31+
2932
public static int? ElectronSocketPort { get; internal set; }
3033

3134
public static int? AspNetWebPort { get; internal set; }

src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ internal class RuntimeControllerDotNetFirst : RuntimeControllerBase
1313
{
1414
private ElectronProcessBase electronProcess;
1515
private SocketBridgeService socketBridge;
16-
private int? port;
1716

1817
public RuntimeControllerDotNetFirst()
1918
{
@@ -41,19 +40,13 @@ protected override Task StartCore()
4140
var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged();
4241
var electronBinaryName = ElectronNetRuntime.ElectronExecutable;
4342
var args = string.Format("{0} {1}", ElectronNetRuntime.ElectronExtraArguments, Environment.CommandLine).Trim();
44-
this.port = ElectronNetRuntime.ElectronSocketPort;
45-
46-
if (!this.port.HasValue)
47-
{
48-
this.port = PortHelper.GetFreePort(ElectronNetRuntime.DefaultSocketPort);
49-
ElectronNetRuntime.ElectronSocketPort = this.port;
50-
}
43+
var port = ElectronNetRuntime.ElectronSocketPort ?? 0;
5144

5245
Console.Error.WriteLine("[StartCore]: isUnPacked: {0}", isUnPacked);
5346
Console.Error.WriteLine("[StartCore]: electronBinaryName: {0}", electronBinaryName);
5447
Console.Error.WriteLine("[StartCore]: args: {0}", args);
5548

56-
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, this.port.Value);
49+
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, port);
5750
this.electronProcess.Ready += this.ElectronProcess_Ready;
5851
this.electronProcess.Stopped += this.ElectronProcess_Stopped;
5952

@@ -63,8 +56,10 @@ protected override Task StartCore()
6356

6457
private void ElectronProcess_Ready(object sender, EventArgs e)
6558
{
59+
var port = ElectronNetRuntime.ElectronSocketPort.Value;
60+
var token = ElectronNetRuntime.ElectronAuthToken;
6661
this.TransitionState(LifetimeState.Started);
67-
this.socketBridge = new SocketBridgeService(this.port!.Value);
62+
this.socketBridge = new SocketBridgeService(port, token);
6863
this.socketBridge.Ready += this.SocketBridge_Ready;
6964
this.socketBridge.Stopped += this.SocketBridge_Stopped;
7065
this.socketBridge.Start();

src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ internal class RuntimeControllerElectronFirst : RuntimeControllerBase
1111
{
1212
private ElectronProcessBase electronProcess;
1313
private SocketBridgeService socketBridge;
14-
private int? port;
1514

1615
public RuntimeControllerElectronFirst()
1716
{
@@ -36,20 +35,16 @@ internal override ISocketConnection Socket
3635

3736
protected override Task StartCore()
3837
{
39-
this.port = ElectronNetRuntime.ElectronSocketPort;
40-
41-
if (!this.port.HasValue)
42-
{
43-
throw new Exception("No port has been specified by Electron!");
44-
}
38+
var port = ElectronNetRuntime.ElectronSocketPort.Value;
39+
var token = ElectronNetRuntime.ElectronAuthToken;
4540

4641
if (!ElectronNetRuntime.ElectronProcessId.HasValue)
4742
{
4843
throw new Exception("No electronPID has been specified by Electron!");
4944
}
5045

5146
this.TransitionState(LifetimeState.Starting);
52-
this.socketBridge = new SocketBridgeService(this.port!.Value);
47+
this.socketBridge = new SocketBridgeService(port, token);
5348
this.socketBridge.Ready += this.SocketBridge_Ready;
5449
this.socketBridge.Stopped += this.SocketBridge_Stopped;
5550
this.socketBridge.Start();

src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs

Lines changed: 134 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
namespace ElectronNET.Runtime.Services.ElectronProcess
22
{
33
using System;
4+
using System.Collections.Generic;
45
using System.ComponentModel;
6+
using System.Diagnostics;
57
using System.IO;
68
using System.Linq;
79
using System.Runtime.InteropServices;
10+
using System.Security.Cryptography;
11+
using System.Text.Json;
12+
using System.Threading;
813
using System.Threading.Tasks;
914
using ElectronNET.Common;
1015
using ElectronNET.Runtime.Data;
@@ -15,6 +20,9 @@
1520
[Localizable(false)]
1621
internal class ElectronProcessActive : ElectronProcessBase
1722
{
23+
private const string AuthTokenEnvVar = "ELECTRONNET_AUTH_TOKEN";
24+
private const string StartupInfoEnvVar = "ELECTRONNET_STARTUP_INFO";
25+
1826
private readonly bool isUnpackaged;
1927
private readonly string electronBinaryName;
2028
private readonly string extraArguments;
@@ -88,8 +96,23 @@ protected override async Task StartCore()
8896
workingDir = dir.FullName;
8997
}
9098

99+
// Generate the auth token on the .NET side (256 bit entropy) and pass it
100+
// to Electron via an environment variable. Electron will report the
101+
// OS-selected port via a temporary handshake file - this avoids any
102+
// dependency on parsing Electron's console output.
103+
var authToken = CreateAuthToken();
104+
var startupInfoPath = Path.Combine(
105+
Path.GetTempPath(),
106+
$"electronnet-startup-{Environment.ProcessId}-{Guid.NewGuid():N}.json");
107+
91108
// We don't await this in order to let the state transition to "Starting"
92-
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir).ConfigureAwait(false));
109+
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir, authToken, startupInfoPath).ConfigureAwait(false));
110+
}
111+
112+
private static string CreateAuthToken()
113+
{
114+
var bytes = RandomNumberGenerator.GetBytes(32);
115+
return Convert.ToHexString(bytes).ToLowerInvariant();
93116
}
94117

95118
private void CheckRuntimeIdentifier()
@@ -101,7 +124,6 @@ private void CheckRuntimeIdentifier()
101124
}
102125

103126
var osPart = buildInfoRid.Split('-').First();
104-
105127
var mismatch = false;
106128

107129
switch (osPart)
@@ -155,20 +177,56 @@ protected override Task StopCore()
155177
return Task.CompletedTask;
156178
}
157179

158-
private async Task StartInternal(string startCmd, string args, string directoriy)
180+
private async Task StartInternal(string startCmd, string args, string directoriy, string authToken, string startupInfoPath)
159181
{
160-
try
182+
var tcs = new TaskCompletionSource();
183+
using var cts = new CancellationTokenSource(2 * 60_000); // cancel after 2 minutes
184+
using var _ = cts.Token.Register(() =>
185+
{
186+
// Time is over - let's kill the process and move on
187+
this.process.Cancel();
188+
// We don't want to raise exceptions here - just pass the barrier
189+
tcs.TrySetResult();
190+
});
191+
192+
void Monitor_SocketIO_Failure(object sender, EventArgs e)
161193
{
162-
await Task.Delay(10.ms()).ConfigureAwait(false);
194+
// We don't want to raise exceptions here - just pass the barrier
195+
if (tcs.Task.IsCompleted)
196+
{
197+
this.Process_Exited(sender, e);
198+
}
199+
else
200+
{
201+
tcs.TrySetResult();
202+
}
203+
}
163204

205+
try
206+
{
164207
Console.Error.WriteLine("[StartInternal]: startCmd: {0}", startCmd);
165208
Console.Error.WriteLine("[StartInternal]: args: {0}", args);
166209

167210
this.process = new ProcessRunner("ElectronRunner");
168-
this.process.ProcessExited += this.Process_Exited;
169-
this.process.Run(startCmd, args, directoriy);
211+
this.process.ProcessExited += Monitor_SocketIO_Failure;
170212

171-
await Task.Delay(500.ms()).ConfigureAwait(false);
213+
var env = new Dictionary<string, string>
214+
{
215+
[AuthTokenEnvVar] = authToken,
216+
[StartupInfoEnvVar] = startupInfoPath,
217+
};
218+
219+
this.process.Run(startCmd, args, directoriy, env);
220+
221+
// Wait for Electron to write the startup-info file (or for the process to die / timeout).
222+
var waitTask = WaitForStartupInfoAsync(startupInfoPath, cts.Token);
223+
var completed = await Task.WhenAny(waitTask, tcs.Task).ConfigureAwait(false);
224+
225+
int port = 0;
226+
if (completed == waitTask && waitTask.Status == TaskStatus.RanToCompletion)
227+
{
228+
port = waitTask.Result;
229+
}
172230

173231
Console.Error.WriteLine("[StartInternal]: after run:");
174232

@@ -178,11 +236,18 @@ private async Task StartInternal(string startCmd, string args, string directoriy
178236
Console.Error.WriteLine("[StartInternal]: Process is not running: " + this.process.StandardOutput);
179237

180238
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
181-
182-
throw new Exception("Failed to launch the Electron process.");
183239
}
184-
185-
this.TransitionState(LifetimeState.Ready);
240+
else if (port > 0)
241+
{
242+
ElectronNetRuntime.ElectronAuthToken = authToken;
243+
ElectronNetRuntime.ElectronSocketPort = port;
244+
this.TransitionState(LifetimeState.Ready);
245+
}
246+
else
247+
{
248+
Console.Error.WriteLine("[StartInternal]: Did not receive Electron startup info before process exit/timeout.");
249+
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
250+
}
186251
}
187252
catch (Exception ex)
188253
{
@@ -191,6 +256,63 @@ private async Task StartInternal(string startCmd, string args, string directoriy
191256
Console.Error.WriteLine("[StartInternal]: Exception: " + ex);
192257
throw;
193258
}
259+
finally
260+
{
261+
try
262+
{
263+
if (File.Exists(startupInfoPath))
264+
{
265+
File.Delete(startupInfoPath);
266+
}
267+
}
268+
catch
269+
{
270+
// best effort cleanup
271+
}
272+
}
273+
}
274+
275+
private static async Task<int> WaitForStartupInfoAsync(string startupInfoPath, CancellationToken cancellationToken)
276+
{
277+
while (!cancellationToken.IsCancellationRequested)
278+
{
279+
try
280+
{
281+
if (File.Exists(startupInfoPath))
282+
{
283+
var json = await File.ReadAllTextAsync(startupInfoPath, cancellationToken).ConfigureAwait(false);
284+
if (!string.IsNullOrWhiteSpace(json))
285+
{
286+
using var doc = JsonDocument.Parse(json);
287+
if (doc.RootElement.TryGetProperty("port", out var portElement) &&
288+
portElement.TryGetInt32(out var port) &&
289+
port > 0)
290+
{
291+
return port;
292+
}
293+
}
294+
}
295+
}
296+
catch (JsonException)
297+
{
298+
// File may be partially written / racing with the writer - retry.
299+
}
300+
catch (IOException)
301+
{
302+
// Same - transient races on file access; retry.
303+
}
304+
305+
try
306+
{
307+
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
308+
}
309+
catch (TaskCanceledException)
310+
{
311+
break;
312+
}
313+
}
314+
315+
return 0;
194316
}
195317

196318
private void Process_Exited(object sender, EventArgs e)

0 commit comments

Comments
 (0)