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.
This commit is contained in:
Florian Rappl
2026-05-09 17:03:32 +02:00
committed by GitHub
parent 1e8b02648a
commit 11f71feeb8
21 changed files with 533 additions and 128 deletions

View File

@@ -3,10 +3,12 @@
namespace ElectronNET.API;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ElectronNET.API.Serialization;
using SocketIO.Serializer.SystemTextJson;
using SocketIO = SocketIOClient.SocketIO;
using SocketIOOptions = SocketIOClient.SocketIOOptions;
internal class SocketIOConnection : ISocketConnection
{
@@ -14,9 +16,16 @@ internal class SocketIOConnection : ISocketConnection
private readonly object _lockObj = new object();
private bool _isDisposed;
public SocketIOConnection(string uri)
public SocketIOConnection(string uri, string authorization)
{
_socket = new SocketIO(uri);
var opts = string.IsNullOrEmpty(authorization) ? new SocketIOOptions() : new SocketIOOptions
{
ExtraHeaders = new Dictionary<string, string>
{
["authorization"] = authorization
},
};
_socket = new SocketIO(uri, opts);
_socket.Serializer = new SystemTextJsonSerializer(ElectronJson.Options);
// Use default System.Text.Json serializer from SocketIOClient.
// Outgoing args are normalized to camelCase via SerializeArg in Emit.

View File

@@ -1,6 +1,7 @@
namespace ElectronNET.Common
{
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
@@ -26,6 +27,8 @@
private readonly StringBuilder stdOut = new StringBuilder(4 * 1024);
private readonly StringBuilder stdErr = new StringBuilder(4 * 1024);
public event EventHandler<string> LineReceived;
private volatile ManualResetEvent stdOutEvent;
private volatile ManualResetEvent stdErrEvent;
private volatile Stopwatch stopwatch;
@@ -109,6 +112,11 @@
public int? LastExitCode { get; private set; }
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory)
{
return this.Run(exeFileName, commandLineArgs, workingDirectory, null);
}
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory, IDictionary<string, string> environmentVariables)
{
this.CommandLine = commandLineArgs;
this.WorkingFolder = workingDirectory;
@@ -126,6 +134,14 @@
WorkingDirectory = workingDirectory
};
if (environmentVariables != null)
{
foreach (var kv in environmentVariables)
{
startInfo.EnvironmentVariables[kv.Key] = kv.Value;
}
}
return this.Run(startInfo);
}
@@ -571,6 +587,7 @@
if (e.Data != null)
{
Console.WriteLine("|| " + e.Data);
LineReceived?.Invoke(this, e.Data);
}
else
{

View File

@@ -16,6 +16,7 @@
internal const int DefaultWebPort = 8001;
internal const string ElectronPortArgumentName = "electronPort";
internal const string ElectronPidArgumentName = "electronPID";
internal const string ElectronAuthTokenArgumentName = "electronAuthToken";
/// <summary>Initializes the <see cref="ElectronNetRuntime"/> class.</summary>
static ElectronNetRuntime()
@@ -26,6 +27,8 @@
public static string ElectronExtraArguments { get; set; }
public static string ElectronAuthToken { get; internal set; }
public static int? ElectronSocketPort { get; internal set; }
public static int? AspNetWebPort { get; internal set; }

View File

@@ -13,7 +13,6 @@
{
private ElectronProcessBase electronProcess;
private SocketBridgeService socketBridge;
private int? port;
public RuntimeControllerDotNetFirst()
{
@@ -41,19 +40,13 @@
var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged();
var electronBinaryName = ElectronNetRuntime.ElectronExecutable;
var args = string.Format("{0} {1}", ElectronNetRuntime.ElectronExtraArguments, Environment.CommandLine).Trim();
this.port = ElectronNetRuntime.ElectronSocketPort;
if (!this.port.HasValue)
{
this.port = PortHelper.GetFreePort(ElectronNetRuntime.DefaultSocketPort);
ElectronNetRuntime.ElectronSocketPort = this.port;
}
var port = ElectronNetRuntime.ElectronSocketPort ?? 0;
Console.Error.WriteLine("[StartCore]: isUnPacked: {0}", isUnPacked);
Console.Error.WriteLine("[StartCore]: electronBinaryName: {0}", electronBinaryName);
Console.Error.WriteLine("[StartCore]: args: {0}", args);
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, this.port.Value);
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, port);
this.electronProcess.Ready += this.ElectronProcess_Ready;
this.electronProcess.Stopped += this.ElectronProcess_Stopped;
@@ -63,8 +56,10 @@
private void ElectronProcess_Ready(object sender, EventArgs e)
{
var port = ElectronNetRuntime.ElectronSocketPort.Value;
var token = ElectronNetRuntime.ElectronAuthToken;
this.TransitionState(LifetimeState.Started);
this.socketBridge = new SocketBridgeService(this.port!.Value);
this.socketBridge = new SocketBridgeService(port, token);
this.socketBridge.Ready += this.SocketBridge_Ready;
this.socketBridge.Stopped += this.SocketBridge_Stopped;
this.socketBridge.Start();

View File

@@ -11,7 +11,6 @@
{
private ElectronProcessBase electronProcess;
private SocketBridgeService socketBridge;
private int? port;
public RuntimeControllerElectronFirst()
{
@@ -36,12 +35,8 @@
protected override Task StartCore()
{
this.port = ElectronNetRuntime.ElectronSocketPort;
if (!this.port.HasValue)
{
throw new Exception("No port has been specified by Electron!");
}
var port = ElectronNetRuntime.ElectronSocketPort.Value;
var token = ElectronNetRuntime.ElectronAuthToken;
if (!ElectronNetRuntime.ElectronProcessId.HasValue)
{
@@ -49,7 +44,7 @@
}
this.TransitionState(LifetimeState.Starting);
this.socketBridge = new SocketBridgeService(this.port!.Value);
this.socketBridge = new SocketBridgeService(port, token);
this.socketBridge.Ready += this.SocketBridge_Ready;
this.socketBridge.Stopped += this.SocketBridge_Stopped;
this.socketBridge.Start();

View File

@@ -1,10 +1,15 @@
namespace ElectronNET.Runtime.Services.ElectronProcess
{
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ElectronNET.Common;
using ElectronNET.Runtime.Data;
@@ -15,6 +20,9 @@
[Localizable(false)]
internal class ElectronProcessActive : ElectronProcessBase
{
private const string AuthTokenEnvVar = "ELECTRONNET_AUTH_TOKEN";
private const string StartupInfoEnvVar = "ELECTRONNET_STARTUP_INFO";
private readonly bool isUnpackaged;
private readonly string electronBinaryName;
private readonly string extraArguments;
@@ -88,8 +96,23 @@
workingDir = dir.FullName;
}
// Generate the auth token on the .NET side (256 bit entropy) and pass it
// to Electron via an environment variable. Electron will report the
// OS-selected port via a temporary handshake file - this avoids any
// dependency on parsing Electron's console output.
var authToken = CreateAuthToken();
var startupInfoPath = Path.Combine(
Path.GetTempPath(),
$"electronnet-startup-{Environment.ProcessId}-{Guid.NewGuid():N}.json");
// We don't await this in order to let the state transition to "Starting"
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir).ConfigureAwait(false));
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir, authToken, startupInfoPath).ConfigureAwait(false));
}
private static string CreateAuthToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private void CheckRuntimeIdentifier()
@@ -101,7 +124,6 @@
}
var osPart = buildInfoRid.Split('-').First();
var mismatch = false;
switch (osPart)
@@ -155,20 +177,56 @@
return Task.CompletedTask;
}
private async Task StartInternal(string startCmd, string args, string directoriy)
private async Task StartInternal(string startCmd, string args, string directoriy, string authToken, string startupInfoPath)
{
var tcs = new TaskCompletionSource();
using var cts = new CancellationTokenSource(2 * 60_000); // cancel after 2 minutes
using var _ = cts.Token.Register(() =>
{
// Time is over - let's kill the process and move on
this.process.Cancel();
// We don't want to raise exceptions here - just pass the barrier
tcs.TrySetResult();
});
void Monitor_SocketIO_Failure(object sender, EventArgs e)
{
// We don't want to raise exceptions here - just pass the barrier
if (tcs.Task.IsCompleted)
{
this.Process_Exited(sender, e);
}
else
{
tcs.TrySetResult();
}
}
try
{
await Task.Delay(10.ms()).ConfigureAwait(false);
Console.Error.WriteLine("[StartInternal]: startCmd: {0}", startCmd);
Console.Error.WriteLine("[StartInternal]: args: {0}", args);
this.process = new ProcessRunner("ElectronRunner");
this.process.ProcessExited += this.Process_Exited;
this.process.Run(startCmd, args, directoriy);
this.process.ProcessExited += Monitor_SocketIO_Failure;
await Task.Delay(500.ms()).ConfigureAwait(false);
var env = new Dictionary<string, string>
{
[AuthTokenEnvVar] = authToken,
[StartupInfoEnvVar] = startupInfoPath,
};
this.process.Run(startCmd, args, directoriy, env);
// Wait for Electron to write the startup-info file (or for the process to die / timeout).
var waitTask = WaitForStartupInfoAsync(startupInfoPath, cts.Token);
var completed = await Task.WhenAny(waitTask, tcs.Task).ConfigureAwait(false);
int port = 0;
if (completed == waitTask && waitTask.Status == TaskStatus.RanToCompletion)
{
port = waitTask.Result;
}
Console.Error.WriteLine("[StartInternal]: after run:");
@@ -178,11 +236,18 @@
Console.Error.WriteLine("[StartInternal]: Process is not running: " + this.process.StandardOutput);
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
throw new Exception("Failed to launch the Electron process.");
}
this.TransitionState(LifetimeState.Ready);
else if (port > 0)
{
ElectronNetRuntime.ElectronAuthToken = authToken;
ElectronNetRuntime.ElectronSocketPort = port;
this.TransitionState(LifetimeState.Ready);
}
else
{
Console.Error.WriteLine("[StartInternal]: Did not receive Electron startup info before process exit/timeout.");
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
}
}
catch (Exception ex)
{
@@ -191,6 +256,63 @@
Console.Error.WriteLine("[StartInternal]: Exception: " + ex);
throw;
}
finally
{
try
{
if (File.Exists(startupInfoPath))
{
File.Delete(startupInfoPath);
}
}
catch
{
// best effort cleanup
}
}
}
private static async Task<int> WaitForStartupInfoAsync(string startupInfoPath, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (File.Exists(startupInfoPath))
{
var json = await File.ReadAllTextAsync(startupInfoPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(json))
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("port", out var portElement) &&
portElement.TryGetInt32(out var port) &&
port > 0)
{
return port;
}
}
}
}
catch (JsonException)
{
// File may be partially written / racing with the writer - retry.
}
catch (IOException)
{
// Same - transient races on file access; retry.
}
try
{
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
break;
}
}
return 0;
}
private void Process_Exited(object sender, EventArgs e)

View File

@@ -8,12 +8,14 @@
internal class SocketBridgeService : LifetimeServiceBase
{
private readonly int socketPort;
private readonly string authorization;
private readonly string socketUrl;
private SocketIOConnection socket;
public SocketBridgeService(int socketPort)
public SocketBridgeService(int socketPort, string authorization)
{
this.socketPort = socketPort;
this.authorization = authorization;
this.socketUrl = $"http://localhost:{this.socketPort}";
}
@@ -23,7 +25,7 @@
protected override Task StartCore()
{
this.socket = new SocketIOConnection(this.socketUrl);
this.socket = new SocketIOConnection(this.socketUrl, this.authorization);
this.socket.BridgeConnected += this.Socket_BridgeConnected;
this.socket.BridgeDisconnected += this.Socket_BridgeDisconnected;
Task.Run(this.Connect);

View File

@@ -106,6 +106,20 @@
Console.WriteLine("Electron Process ID: " + result);
}
}
var authTokenArg = argsList.FirstOrDefault(e => e.Contains(ElectronNetRuntime.ElectronAuthTokenArgumentName, StringComparison.OrdinalIgnoreCase));
if (authTokenArg != null)
{
var parts = authTokenArg.Split('=', StringSplitOptions.TrimEntries);
if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]))
{
var result = parts[1];
ElectronNetRuntime.ElectronAuthToken = result;
Console.WriteLine("Use Auth Token: " + result);
}
}
}
private void SetElectronExecutable()