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
19 changes: 14 additions & 5 deletions docs/docs/distributed/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,16 +436,16 @@ invokes again and never misses a broadcast.
```csharp
builder.AddSignalRDistributedCoordinator(options => options with
{
ListenUrl = new Uri("http://0.0.0.0:5099"),
ListenUrl = new Uri("https://0.0.0.0:5099"),
AdvertisedUrl = new Uri("https://pipeline-master.example.com"),
AccessToken = Environment.GetEnvironmentVariable("PIPELINE_HUB_TOKEN"),
});
```

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `ListenUrl` | `Uri` | `http://localhost:5099` | URL the master binds. Port `0` lets the operating system choose a port. |
| `AdvertisedUrl` | `Uri?` | `null` | URL workers connect to. The master advertises it through discovery; workers without discovery connect to it, or to `ListenUrl` when it is unset. |
| `ListenUrl` | `Uri` | `http://localhost:5099` | HTTPS URL the master binds (HTTP only on loopback). Port `0` lets the operating system choose a port. |
| `AdvertisedUrl` | `Uri?` | `null` | HTTPS URL workers connect to (HTTP only on loopback). The master advertises it through discovery; workers without discovery connect to it, or to `ListenUrl` when it is unset. |
| `HubPath` | `string` | `/pipeline-hub` | Hub path, starting with `/`. |
| `AccessToken` | `string?` | `null` | Token workers present to the master. When unset, the master generates one whenever it is reachable beyond the machine (a tunnel or a non-loopback `ListenUrl`) and shares it through master discovery; without discovery such a master fails at startup until every process configures a token. The token is never logged. |
| `ConnectionTimeout` | `TimeSpan` | `00:02:00` | How long a worker keeps trying to connect. |
Expand All @@ -457,6 +457,13 @@ builder.AddSignalRDistributedCoordinator(options => options with
| `Tunnel.CloudflaredPath` | `string` | `cloudflared` | Path to the cloudflared binary. |
| `Tunnel.StartupTimeout` | `TimeSpan` | `00:00:30` | How long to wait for the tunnel URL. |

SignalR requires HTTPS for every non-loopback endpoint, whether configured directly or returned by
master discovery, even when no access token is configured. Configure Kestrel's server certificate on
the master, for example with `Kestrel__Certificates__Default__Path` and
`Kestrel__Certificates__Default__Password`, and ensure workers trust its certificate. For a reverse
proxy or cloudflared tunnel that terminates HTTPS, the local listener can use
`http://127.0.0.1:5099` while workers connect to the public HTTPS URL.

The hub rejects registrations for another `RunId`, a second live worker with the same `WorkerId`, and every
call from a connection that has not registered; results and heartbeats must come from the registered worker.
Detailed hub errors are disabled, and the tunnel URL is logged only at Debug.
Expand Down Expand Up @@ -531,12 +538,14 @@ endpoint strings in `new Uri("https://host.example")`. Endpoints must be absolut
HTTP or HTTPS URLs; relative URLs and other schemes fail options validation.
Redis REST endpoints require HTTPS except on loopback because requests include a bearer token.
They may include a path prefix, but must not contain a query string or fragment.
SignalR listener, advertised and discovered endpoints also require HTTPS except on loopback, to
protect access tokens and module payloads. Non-loopback HTTP URLs now fail validation or worker startup.
Empty or whitespace optional S3/Redis endpoints are treated as omitted.

`ListenUrl` represents one endpoint, not a semicolon-separated list of Kestrel
addresses. Kestrel wildcard hosts such as `http://+:5099` and `http://*:5099` are
addresses. Kestrel wildcard hosts such as `https://+:5099` and `https://*:5099` are
not valid `Uri` values. To listen on all IPv4 interfaces, use
`new Uri("http://0.0.0.0:5099")` and set `AdvertisedUrl` to an address workers can reach.
`new Uri("https://0.0.0.0:5099")` and set `AdvertisedUrl` to an HTTPS address workers can reach.

Configuration files and environment variables still contain URL strings; normal
configuration binding converts them to `Uri`. SignalR retains its
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ public async Task<IDistributedWorkerCoordinator> CreateWorkerAsync(CancellationT
Url = _options.AdvertisedUrl ?? _options.ListenUrl,
AccessToken = _options.AccessToken,
};
if (!SignalRDistributedOptionsValidator.IsSupportedEndpoint(endpoint.Url))
{
throw new InvalidOperationException(
"The SignalR master endpoint must be an absolute HTTPS URL, or an HTTP URL on loopback.");
}

var accessToken = endpoint.AccessToken ?? _options.AccessToken;
var hubUrl = GetHubUrl(endpoint.Url, _options.HubPath);
var logger = loggerFactory.CreateLogger<SignalRWorkerCoordinator>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ namespace ModularPipelines.Distributed.SignalR;
public sealed record SignalRDistributedOptions
{
/// <summary>
/// Gets the absolute HTTP or HTTPS URL the master binds its SignalR server to. Use port 0 to let the operating
/// system choose a port. Default: <c>http://localhost:5099</c>.
/// Gets the absolute HTTPS URL the master binds its SignalR server to. HTTP is allowed only on loopback.
/// Use port 0 to let the operating system choose a port. Default: <c>http://localhost:5099</c>.
/// </summary>
public Uri ListenUrl { get; init; } = new("http://localhost:5099");

/// <summary>
/// Gets the URL workers use to reach the master. The master advertises it through
/// Gets the absolute HTTPS URL workers use to reach the master. HTTP is allowed only on loopback.
/// The master advertises it through
/// <see cref="IMasterDiscovery"/>, and workers without discovery connect to it. When unset, the
/// master advertises the tunnel URL or the bound listen URL, and workers without discovery
/// connect to <see cref="ListenUrl"/>. Workers preserve any path prefix in the endpoint URL when
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,15 @@ internal sealed class SignalRDistributedOptionsValidator : IValidateOptions<Sign
public ValidateOptionsResult Validate(string? name, SignalRDistributedOptions options)
{
var failures = new List<string>();
if (options.ListenUrl is not { IsAbsoluteUri: true } listenUrl
|| listenUrl.Scheme is not ("http" or "https"))
if (!IsSupportedEndpoint(options.ListenUrl))
{
failures.Add($"{nameof(SignalRDistributedOptions.ListenUrl)} must be an absolute http or https URL.");
failures.Add($"{nameof(SignalRDistributedOptions.ListenUrl)} must be an absolute HTTPS URL, or an HTTP URL on loopback.");
}

if (options.AdvertisedUrl is { } advertisedUrl
&& (!advertisedUrl.IsAbsoluteUri || advertisedUrl.Scheme is not ("http" or "https")))
&& !IsSupportedEndpoint(advertisedUrl))
{
failures.Add($"{nameof(SignalRDistributedOptions.AdvertisedUrl)} must be an absolute http or https URL.");
failures.Add($"{nameof(SignalRDistributedOptions.AdvertisedUrl)} must be an absolute HTTPS URL, or an HTTP URL on loopback.");
}

if (string.IsNullOrWhiteSpace(options.HubPath) || !options.HubPath.StartsWith('/'))
Expand Down Expand Up @@ -69,4 +68,8 @@ public ValidateOptionsResult Validate(string? name, SignalRDistributedOptions op

return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
}

internal static bool IsSupportedEndpoint(Uri? endpoint) =>
endpoint is { IsAbsoluteUri: true }
&& (endpoint.Scheme == "https" || (endpoint.Scheme == "http" && endpoint.IsLoopback));
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public async Task ConfigurationSectionBindsOptions()
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["SignalR:ListenUrl"] = "http://0.0.0.0:6000",
["SignalR:ListenUrl"] = "https://0.0.0.0:6000",
["SignalR:AdvertisedUrl"] = "https://master.example",
["SignalR:HubPath"] = "/distributed",
["SignalR:AccessToken"] = "token",
Expand All @@ -55,7 +55,7 @@ public async Task ConfigurationSectionBindsOptions()

using (Assert.Multiple())
{
await Assert.That(options.ListenUrl).IsEqualTo(new Uri("http://0.0.0.0:6000"));
await Assert.That(options.ListenUrl).IsEqualTo(new Uri("https://0.0.0.0:6000"));
await Assert.That(options.AdvertisedUrl).IsEqualTo(new Uri("https://master.example"));
await Assert.That(options.HubPath).IsEqualTo("/distributed");
await Assert.That(options.AccessToken).IsEqualTo("token");
Expand Down Expand Up @@ -113,7 +113,7 @@ public async Task Loopback_Master_Needs_No_Token()
public async Task Reachable_Master_Generates_Token_For_Discovery()
{
var first = SignalRDistributedCoordinatorFactory.ResolveMasterAccessToken(
new SignalRDistributedOptions { ListenUrl = new Uri("http://0.0.0.0:5099") },
new SignalRDistributedOptions { ListenUrl = new Uri("https://0.0.0.0:5099") },
hasDiscovery: true);
var second = SignalRDistributedCoordinatorFactory.ResolveMasterAccessToken(
new SignalRDistributedOptions { ListenUrl = new Uri("http://localhost:5099"), Tunnel = new SignalRTunnelOptions { Enabled = true } },
Expand All @@ -127,7 +127,7 @@ public async Task Reachable_Master_Generates_Token_For_Discovery()
public async Task Reachable_Master_Without_Token_Or_Discovery_Fails()
{
await Assert.That(() => SignalRDistributedCoordinatorFactory.ResolveMasterAccessToken(
new SignalRDistributedOptions { ListenUrl = new Uri("http://0.0.0.0:5099") },
new SignalRDistributedOptions { ListenUrl = new Uri("https://0.0.0.0:5099") },
hasDiscovery: false))
.Throws<InvalidOperationException>();
}
Expand All @@ -149,7 +149,7 @@ await Assert.That(() => SignalRDistributedCoordinatorFactory.ResolveMasterAccess
public async Task Configured_Token_Is_Used()
{
var token = SignalRDistributedCoordinatorFactory.ResolveMasterAccessToken(
new SignalRDistributedOptions { ListenUrl = new Uri("http://0.0.0.0:5099"), AccessToken = "configured" },
new SignalRDistributedOptions { ListenUrl = new Uri("https://0.0.0.0:5099"), AccessToken = "configured" },
hasDiscovery: false);

await Assert.That(token).IsEqualTo("configured");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,98 @@ await Assert.That(() => services.GetRequiredService<IOptions<SignalRDistributedO
[Test]
[Arguments("http://127.0.0.1:0")]
[Arguments("https://master.example:6000")]
public async Task Listener_Accepts_Http_And_Https_Uris(string endpoint)
[Arguments("http://localhost:5099")]
[Arguments("http://127.0.0.2:5099")]
[Arguments("http://[::1]:5099")]
[Arguments("https://0.0.0.0:5099")]
public async Task Endpoints_Accept_Https_Or_Loopback_Http(string endpoint)
{
var builder = Pipeline.CreateBuilder();
builder.AddSignalRDistributedCoordinator(options => options with { ListenUrl = new Uri(endpoint) });
builder.AddSignalRDistributedCoordinator(options => options with
{
ListenUrl = new Uri(endpoint),
AdvertisedUrl = new Uri(endpoint),
});
using var services = builder.Services.BuildServiceProvider();

await Assert.That(services.GetRequiredService<IOptions<SignalRDistributedOptions>>().Value.ListenUrl)
.IsEqualTo(new Uri(endpoint));
var options = services.GetRequiredService<IOptions<SignalRDistributedOptions>>().Value;
await Assert.That(options.ListenUrl).IsEqualTo(new Uri(endpoint));
await Assert.That(options.AdvertisedUrl).IsEqualTo(new Uri(endpoint));
}

[Test]
[Arguments(false, false)]
[Arguments(false, true)]
[Arguments(true, false)]
[Arguments(true, true)]
public async Task Non_Loopback_Http_Endpoints_Fail_Validation(bool advertised, bool withToken)
{
foreach (var endpoint in new[] { "http://master.example:5099", "http://192.0.2.1:5099", "http://0.0.0.0:5099", "http://[::]:5099", "http://[2001:db8::1]:5099" })
{
var builder = Pipeline.CreateBuilder();
builder.AddSignalRDistributedCoordinator(options => options with
{
ListenUrl = advertised ? options.ListenUrl : new Uri(endpoint),
AdvertisedUrl = advertised ? new Uri(endpoint) : null,
AccessToken = withToken ? "private-token" : null,
});
using var services = builder.Services.BuildServiceProvider();

var exception = await Assert.That(() => services.GetRequiredService<IOptions<SignalRDistributedOptions>>().Value)
.Throws<OptionsValidationException>()
.WithMessageContaining(advertised ? nameof(SignalRDistributedOptions.AdvertisedUrl) : nameof(SignalRDistributedOptions.ListenUrl));

await Assert.That(exception!.Message).Contains("HTTPS");
}
}

[Test]
[Arguments("relative/path")]
[Arguments("ftp://master.example")]
[Arguments("file:///master")]
public async Task Advertised_Endpoint_Requires_An_Absolute_Http_Uri(string endpoint)
{
var builder = Pipeline.CreateBuilder();
builder.AddSignalRDistributedCoordinator(options => options with { AdvertisedUrl = new Uri(endpoint, UriKind.RelativeOrAbsolute) });
using var services = builder.Services.BuildServiceProvider();

await Assert.That(() => services.GetRequiredService<IOptions<SignalRDistributedOptions>>().Value)
.Throws<OptionsValidationException>().WithMessageContaining(nameof(SignalRDistributedOptions.AdvertisedUrl));
}

[Test]
[Arguments("http://private-user:private-password@master.example:5099/?secret=private-query", false, true)]
[Arguments("http://private-user:private-password@master.example:5099/?secret=private-query", true, true)]
[Arguments("http://master.example:5099", false, false)]
[Arguments("http://master.example:5099", true, false)]
[Arguments("http://192.0.2.1:5099", true, true)]
[Arguments("http://0.0.0.0:5099", true, true)]
[Arguments("http://[2001:db8::1]:5099", true, true)]
[Arguments("relative/path", true, true)]
[Arguments("ftp://master.example", true, true)]
[Arguments((string?) null, true, true)]
public async Task Worker_Rejects_Unsafe_Endpoints_Before_Connecting(string? endpoint, bool useDiscovery, bool withToken)
{
var url = endpoint is null ? null! : new Uri(endpoint, UriKind.RelativeOrAbsolute);
var discovery = new SignalRTestMaster.CapturingDiscovery
{
Override = new MasterEndpoint { Url = url, AccessToken = withToken ? "private-discovered-token" : null },
};
var factory = new SignalRDistributedCoordinatorFactory(
MsOptions.Create(new SignalRDistributedOptions
{
AdvertisedUrl = useDiscovery ? null : url,
AccessToken = withToken ? "private-configured-token" : null,
}),
MsOptions.Create(new DistributedOptions()),
NullLoggerFactory.Instance,
useDiscovery ? discovery : null);

var exception = await Assert.That(async () =>
await factory.CreateWorkerAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)))
.Throws<InvalidOperationException>().WithMessageContaining("HTTPS");

await Assert.That(exception!.Message).DoesNotContain("private");
}

[Test]
Expand Down
Loading