Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/sdk/src/Dotnet.Watch/HotReloadAgent.Host/Listener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, Cancellation
var payloadType = (RequestType)await request.Stream.ReadByteAsync(cancellationToken).ConfigureAwait(false);
switch (payloadType)
{
case RequestType.SetEnvironmentVariables:
await SetEnvironmentVariablesAsync(request.Stream, cancellationToken).ConfigureAwait(false);
break;

case RequestType.ManagedCodeUpdate:
await ReadAndApplyManagedCodeUpdateAsync(request.Stream, cancellationToken).ConfigureAwait(false);
break;
Expand All @@ -113,6 +117,29 @@ private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, Cancellation
}
}

private async Task SetEnvironmentVariablesAsync(Stream stream, CancellationToken cancellationToken)
{
var environmentVariableCount = await stream.ReadInt32Async(cancellationToken).ConfigureAwait(false);

log($"Setting environment variables ({environmentVariableCount})");

for (var i = 0; i < environmentVariableCount; i++)
{
var name = await stream.ReadStringAsync(cancellationToken).ConfigureAwait(false);
var value = await stream.ReadStringAsync(cancellationToken).ConfigureAwait(false);
try
{
Environment.SetEnvironmentVariable(name, value);
}
catch (Exception e)
{
log($"Unable to set environment variable '{name}': {e.Message}");
}
}

await SendResponseAsync(new SetEnvironmentVariablesResponse(), cancellationToken).ConfigureAwait(false);
}

private async ValueTask ReadAndApplyManagedCodeUpdateAsync(Stream stream, CancellationToken cancellationToken)
{
var request = await ManagedCodeUpdateRequest.ReadAsync(stream, cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public override async ValueTask SendAsync(IResponse response, CancellationToken
_webSocket.Options.AddSubProtocol(encryptedSecret);
}

_webSocket.Options.SetRequestHeader("Origin", (serverPublicKey != null ? "wss" : "ws") + "://localhost");

Log($"Connecting to {serverUrl}...");
await _webSocket.ConnectAsync(new Uri(serverUrl), connectCts.Token).ConfigureAwait(false);
Log("Connected.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,26 @@ internal interface IUpdateRequest : IRequest

internal enum RequestType : byte
{
ManagedCodeUpdate = 1,
StaticAssetUpdate = 2,
InitialUpdatesCompleted = 3,
SetEnvironmentVariables = 1,
ManagedCodeUpdate = 2,
StaticAssetUpdate = 3,
InitialUpdatesCompleted = 4,
}

internal enum ResponseType : byte
{
InitializationResponse = 1,
UpdateResponse = 2,
HotReloadExceptionNotification = 3,
EnvironmentVariablesSet = 2,
UpdateResponse = 3,
HotReloadExceptionNotification = 4,
}

internal readonly struct SetEnvironmentVariablesResponse() : IResponse
{
public ResponseType Type => ResponseType.EnvironmentVariablesSet;

public ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken)
=> default;
}

internal readonly struct ManagedCodeUpdateRequest(IReadOnlyList<RuntimeManagedCodeUpdate> updates, ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public override void Dispose()
/// </summary>
internal ClientTransport Transport => transport;

public override void InitiateConnection(CancellationToken cancellationToken)
public override void InitiateConnection(IReadOnlyCollection<(string name, string value)> environmentVariables, CancellationToken cancellationToken)
{
// It is important to establish the connection (WaitForConnectionAsync) before we return,
// otherwise the client wouldn't be able to connect.
Expand Down Expand Up @@ -73,6 +73,9 @@ async Task<ImmutableArray<string>> ConnectAsync()

Logger.Log(LogEvents.Capabilities, string.Join(" ", result));

// Initialize process:
await SetEnvironmentVariablesAsync(environmentVariables, cancellationToken);

// fire and forget:
_ = ListenForResponsesAsync(cancellationToken);

Expand Down Expand Up @@ -164,6 +167,33 @@ public override Task<ImmutableArray<string>> GetUpdateCapabilitiesAsync(Cancella
private ResponseLoggingLevel ResponseLoggingLevel
=> Logger.IsEnabled(LogLevel.Debug) ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors;

public async ValueTask SetEnvironmentVariablesAsync(IReadOnlyCollection<(string name, string value)> environmentVariables, CancellationToken cancellationToken)
{
if (environmentVariables.Count == 0)
{
return;
}

// Send a request to set environment variables:
await transport.WriteAsync((byte)RequestType.SetEnvironmentVariables, async (stream, cancellationToken) =>
{
await stream.WriteAsync(environmentVariables.Count, cancellationToken);

foreach (var (name, value) in environmentVariables)
{
await stream.WriteAsync(name, cancellationToken);
await stream.WriteAsync(value, cancellationToken);
}
}, cancellationToken);

// Wait until the environment variables are set in the target process:
using var response = await transport.ReadAsync(cancellationToken);
if (response != null && response.Value.Type != ResponseType.EnvironmentVariablesSet)
{
throw new InvalidOperationException($"Unexpected response received from the agent: {response.Value.Type}");
}
}

public async override Task<Task<bool>> ApplyManagedCodeUpdatesAsync(ImmutableArray<HotReloadManagedCodeUpdate> updates, CancellationToken applyOperationCancellationToken, CancellationToken cancellationToken)
{
RequireReadyForUpdates();
Expand Down
3 changes: 2 additions & 1 deletion src/sdk/src/Dotnet.Watch/HotReloadClient/HotReloadClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ protected static ImmutableArray<string> AddImplicitCapabilities(IEnumerable<stri
/// <summary>
/// Initiates connection with the agent in the target process.
/// </summary>
/// <param name="environmentVariables">Environment variable to be set by the agent in the target process.</param>
/// <param name="cancellationToken">Cancellation token. The cancellation should trigger on process terminatation.</param>
public abstract void InitiateConnection(CancellationToken cancellationToken);
public abstract void InitiateConnection(IReadOnlyCollection<(string name, string value)> environmentVariables, CancellationToken cancellationToken);

/// <summary>
/// Waits until the connection with the agent is established.
Expand Down
5 changes: 3 additions & 2 deletions src/sdk/src/Dotnet.Watch/HotReloadClient/HotReloadClients.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,13 @@ internal void ConfigureLaunchEnvironment(IDictionary<string, string> environment
browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder, enableHotReload: true);
}

/// <param name="environmentVariables">Environment variable to be set by the agent in the target process.</param>
/// <param name="cancellationToken">Cancellation token. The cancellation should trigger on process terminatation.</param>
internal void InitiateConnection(CancellationToken cancellationToken)
internal void InitiateConnection(IReadOnlyCollection<(string name, string value)> environmentVariables, CancellationToken cancellationToken)
{
foreach (var client in clients)
{
client.InitiateConnection(cancellationToken);
client.InitiateConnection(environmentVariables, cancellationToken);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;

namespace Microsoft.DotNet.HotReload;

Expand Down Expand Up @@ -52,7 +53,15 @@ private async Task WebSocketRequestAsync(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}

// check the domain of the Origin header:
if (!Uri.TryCreate(context.Request.Headers.Origin.FirstOrDefault(), UriKind.Absolute, out var originUri) ||
!webSocketConfig.GetAllowedOriginDomains().Contains(originUri.Host, StringComparer.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public override void ConfigureLaunchEnvironment(IDictionary<string, string> envi
// the environment is configued via browser refesh server
}

public override void InitiateConnection(CancellationToken cancellationToken)
public override void InitiateConnection(IReadOnlyCollection<(string name, string value)> environmentVariables, CancellationToken cancellationToken)
{
}

Expand Down
22 changes: 20 additions & 2 deletions src/sdk/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
#nullable enable

using System.Collections.Generic;
using System.Collections.Immutable;

namespace Microsoft.DotNet.HotReload;

internal readonly struct WebSocketConfig(int port, int? securePort, string? hostName)
internal readonly struct WebSocketConfig(int port, int? securePort, string? hostName, ImmutableArray<string> additionalAllowedOrigins)
{
/// <summary>
/// 0 to auto-assign.
Expand All @@ -34,6 +35,23 @@ public IEnumerable<string> GetHttpUrls()
}
}

public IEnumerable<string> GetAllowedOriginDomains()
{
yield return "localhost";
yield return "127.0.0.1";
yield return "[::1]";

foreach (var origin in additionalAllowedOrigins)
{
yield return origin;
}

if (hostName != null)
{
yield return hostName;
}
}

public WebSocketConfig WithSecurePort(int? value)
=> new(port, value, hostName);
=> new(port, value, hostName, additionalAllowedOrigins);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public override bool IsExpectedConnectionTermination(Exception exception, Cancel
/// </summary>
public static async Task<WebSocketClientTransport> CreateAsync(WebSocketConfig config, ILogger logger, CancellationToken cancellationToken)
{
var handler = new RequestHandler(logger);
var handler = new RequestHandler(config, logger);
var server = await KestrelWebSocketServer.StartServerAsync(config, handler.HandleRequestAsync, cancellationToken);
var transport = new WebSocketClientTransport(server, handler);

Expand Down Expand Up @@ -81,15 +81,15 @@ public override ValueTask WriteAsync(byte type, Func<Stream, CancellationToken,
public override ValueTask<ClientTransportResponse?> ReadAsync(CancellationToken cancellationToken)
=> _handler.ReadAsync(cancellationToken);

private sealed class RequestHandler(ILogger logger) : IDisposable
private sealed class RequestHandler(WebSocketConfig webSocketConfig, ILogger logger) : IDisposable
{
public SharedSecretProvider SharedSecretProvider { get; } = new();
public TaskCompletionSource<WebSocket?> ClientConnectedSource { get; } = new();

private WebSocket? _clientSocket;

public bool IsClientSocketAborted
=> _clientSocket?.State is System.Net.WebSockets.WebSocketState.Aborted;
=> _clientSocket?.State is WebSocketState.Aborted;

// Reused across WriteAsync calls to avoid allocations.
// WriteAsync is invoked under a semaphore in DefaultHotReloadClient.
Expand Down Expand Up @@ -118,7 +118,15 @@ public async Task HandleRequestAsync(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}

// check the domain of the Origin header:
if (!Uri.TryCreate(context.Request.Headers.Origin.FirstOrDefault(), UriKind.Absolute, out var originUri) ||
!webSocketConfig.GetAllowedOriginDomains().Contains(originUri.Host, StringComparer.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}

Expand All @@ -128,7 +136,7 @@ public async Task HandleRequestAsync(HttpContext context)
if (subProtocol == null)
{
logger.LogWarning("WebSocket connection rejected: missing subprotocol (shared secret)");
context.Response.StatusCode = 401;
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}

Expand All @@ -140,7 +148,7 @@ public async Task HandleRequestAsync(HttpContext context)
catch (Exception ex)
{
logger.LogWarning("WebSocket connection rejected: invalid shared secret - {Message}", ex.Message);
context.Response.StatusCode = 401;
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}

Expand Down
12 changes: 10 additions & 2 deletions src/sdk/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,16 @@ internal sealed record EnvironmentOptions(
RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit,
CliLogLevel: EnvironmentVariables.CliLogLevel,
BrowserPath: EnvironmentVariables.BrowserPath,
BrowserWebSocketConfig: new(EnvironmentVariables.BrowserWebSocketPort, EnvironmentVariables.BrowserWebSocketSecurePort, EnvironmentVariables.BrowserWebSocketHostName),
AgentWebSocketConfig: new(EnvironmentVariables.AgentWebSocketPort, EnvironmentVariables.AgentWebSocketSecurePort, hostName: null),
BrowserWebSocketConfig: new(
port: EnvironmentVariables.BrowserWebSocketPort,
securePort: EnvironmentVariables.BrowserWebSocketSecurePort,
hostName: EnvironmentVariables.BrowserWebSocketHostName,
additionalAllowedOrigins: EnvironmentVariables.DotNetWatchWebSocketAllowedOrigins),
AgentWebSocketConfig: new(
port: EnvironmentVariables.AgentWebSocketPort,
securePort: EnvironmentVariables.AgentWebSocketSecurePort,
hostName: null,
additionalAllowedOrigins: []),
TestFlags: EnvironmentVariables.TestFlags,
TestOutput: EnvironmentVariables.TestOutputDir
);
Expand Down
13 changes: 13 additions & 0 deletions src/sdk/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;
Expand Down Expand Up @@ -61,6 +62,13 @@ public static LogLevel? CliLogLevel

public static string? BrowserWebSocketHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME");

/// <summary>
/// A list of additional domains, other than localhost and <see cref="BrowserWebSocketHostName"/>,
/// allowed as origins of connections to the broser refresh web socket.
/// Use when the browser opens the app on a custom domain.
/// </summary>
public static ImmutableArray<string> DotNetWatchWebSocketAllowedOrigins => ReadList("DOTNET_WATCH_AUTO_RELOAD_WS_ORIGINS", separators: [';', ',']);

/// <summary>
/// Port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified.
/// </summary>
Expand Down Expand Up @@ -98,6 +106,11 @@ private static bool ReadBool(string variableName)
private static int? ReadInt(string variableName)
=> Environment.GetEnvironmentVariable(variableName) is var value && int.TryParse(value, out var intValue) ? intValue : null;

private static ImmutableArray<string> ReadList(string variableName, char[] separators)
=> Environment.GetEnvironmentVariable(variableName) is { } value
? [.. value.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]
: [];

private static bool ParseBool(string? value)
=> value == "1" || bool.TryParse(value, out var boolValue) && boolValue;
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ public ImmutableDictionary<string, ImmutableArray<RunningProject>> CurrentRunnin

// It is important to first create the named pipe connection (Hot Reload client is the named pipe server)
// and then start the process (named pipe client). Otherwise, the connection would fail.
clients.InitiateConnection(processCommunicationCancellationToken);
//
// Send project defined environment variables through RPC to avoid leaking potentially sensitive data.
// On Linux the command line of a process can be read by other users via `/proc/<pid>/cmdline`.
// Only environment variables that are necessary to establish communication with the agent are passed through command line (`-e` switch).
// The agent variables can't be set directly on `dotnet run` process as Hot Reload should not be enabled for that process.
// Other variables are not set directly either to avoid leakage to `dotnet build` or other processes that `dotnet run` might execute.
clients.InitiateConnection(projectOptions.LaunchEnvironmentVariables, processCommunicationCancellationToken);

RunningProject? publishedRunningProject = null;

Expand Down
7 changes: 0 additions & 7 deletions src/sdk/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,6 @@ public RunningProjectsManager RunningProjectsManager

var environmentBuilder = new Dictionary<string, string>();

// initialize with project settings:
foreach (var (name, value) in projectOptions.LaunchEnvironmentVariables)
{
environmentBuilder[name] = value;
}

// override any project settings:
environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1";
environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public Test(TestContext testContext, TestHotReloadAgent agent)

_cancellationSource = new CancellationTokenSource();

Client.InitiateConnection(CancellationToken.None);
Client.InitiateConnection(environmentVariables: [], CancellationToken.None);
var agentTransport = new NamedPipeTransport(clientTransport.NamedPipeName, log: _ => { }, timeoutMS: Timeout.Infinite);
var listener = new Listener(agentTransport, agent, log: _ => { });
_listenerTaskFactory = Task.Run<Task>(() => listener.Listen(_cancellationSource.Token), testContext.CancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public override void ConfigureLaunchEnvironment(IDictionary<string, string> envi
{
}

public override void InitiateConnection(CancellationToken cancellationToken)
public override void InitiateConnection(IReadOnlyCollection<(string name, string value)> environmentVariables, CancellationToken cancellationToken)
{
}

Expand Down
Loading
Loading