Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/MACOS-INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export TZ="America/Chicago"
Additional environment variables can be added to `start.sh` - see [docker/.env.example](../docker/.env.example) for all available options including:
- `HOST_NAME` - Hostname for canonical URL enforcement
- `REVERSE_PROXIED_HOST_NAME` - Hostname when behind a reverse proxy (enables HTTPS)
- `OPENSPEEDTEST_SAVE_DATA_URL` - Optional absolute HTTP(S) URL or root-relative path for submitting browser speed-test results
- `OPENSPEEDTEST_CLIENT_RESULTS_URL` - Optional absolute HTTP(S) URL or root-relative path for viewing client results
- `OPENSPEEDTEST_HTTPS` - Enable HTTPS for speed tests (required for geolocation)
- `Logging__LogLevel__NetworkOptimizer` / `Logging__LogLevel__Default` - Logging verbosity (see [Enable Debug Logging](#enable-debug-logging))

Expand Down
10 changes: 6 additions & 4 deletions src/NetworkOptimizer.Installer/SpeedTest/config.js.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@

// These will be replaced by the service at startup
var saveData = {{SAVE_DATA}};
var saveDataURL = "{{SAVE_DATA_URL}}";
var saveDataURL = {{SAVE_DATA_URL}};
var apiPath = "{{API_PATH}}";
var clientResultsUrl = {{CLIENT_RESULTS_URL}};

// If __DYNAMIC__, construct URL from browser location (same host, port 8042)
if (saveDataURL === "__DYNAMIC__") {
saveDataURL = window.location.protocol + "//" + window.location.hostname + ":8042" + apiPath;
}

// URL for viewing client speed test results (derived from saveDataURL)
// Extract base URL by splitting on /api
var clientResultsUrl = saveDataURL.split("/api")[0] + "/client-speedtest";
// By default, view results on the same Network Optimizer origin that receives them.
if (clientResultsUrl === "__FROM_SAVE_DATA_URL__") {
clientResultsUrl = saveDataURL.split("/api")[0] + "/client-speedtest";
}

// Fix for missing variable bug in OpenSpeedTest
var OpenSpeedTestdb = "";
49 changes: 46 additions & 3 deletions src/NetworkOptimizer.Web/Services/NginxHostedService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text.Json;
using NetworkOptimizer.Core.Helpers;

namespace NetworkOptimizer.Web.Services;
Expand Down Expand Up @@ -89,13 +90,15 @@ private async Task GenerateConfigJsAsync(string speedTestFolder)
// Construct the save URL based on configuration
const string apiPath = "/api/public/speedtest/results";
var saveDataUrl = ConstructSaveDataUrl(config, apiPath);
var clientResultsUrl = ConstructClientResultsUrl(config);

// Read template and replace placeholders (matches OpenSpeedTest format)
var template = await File.ReadAllTextAsync(templatePath);

var configJs = template
.Replace("{{SAVE_DATA}}", "true")
.Replace("{{SAVE_DATA_URL}}", saveDataUrl)
.Replace("{{SAVE_DATA_URL}}", JsonSerializer.Serialize(saveDataUrl))
.Replace("{{CLIENT_RESULTS_URL}}", JsonSerializer.Serialize(clientResultsUrl))
.Replace("{{API_PATH}}", apiPath);

// Ensure output directory exists
Expand All @@ -106,7 +109,10 @@ private async Task GenerateConfigJsAsync(string speedTestFolder)
}

await File.WriteAllTextAsync(outputPath, configJs);
_logger.LogInformation("Generated config.js with save URL: {SaveUrl}", saveDataUrl);
_logger.LogInformation(
"Generated config.js with save URL {SaveUrl} and client results URL {ClientResultsUrl}",
saveDataUrl,
clientResultsUrl);
}

private Task<Dictionary<string, string>> LoadConfigurationAsync()
Expand All @@ -126,6 +132,8 @@ private Task<Dictionary<string, string>> LoadConfigurationAsync()
LoadRegistryValue(config, key, "REVERSE_PROXIED_HOST_NAME");
LoadRegistryValue(config, key, "REVERSE_PROXIED_PORT");
LoadRegistryValue(config, key, "OPENSPEEDTEST_PORT");
LoadRegistryValue(config, key, "OPENSPEEDTEST_SAVE_DATA_URL");
LoadRegistryValue(config, key, "OPENSPEEDTEST_CLIENT_RESULTS_URL");
}
}
catch (Exception ex)
Expand All @@ -140,6 +148,8 @@ private Task<Dictionary<string, string>> LoadConfigurationAsync()
OverrideFromConfiguration(config, "REVERSE_PROXIED_HOST_NAME");
OverrideFromConfiguration(config, "REVERSE_PROXIED_PORT");
OverrideFromConfiguration(config, "OPENSPEEDTEST_PORT");
OverrideFromConfiguration(config, "OPENSPEEDTEST_SAVE_DATA_URL");
OverrideFromConfiguration(config, "OPENSPEEDTEST_CLIENT_RESULTS_URL");

return Task.FromResult(config);
}
Expand All @@ -165,8 +175,14 @@ private void OverrideFromConfiguration(Dictionary<string, string> config, string
}
}

private string ConstructSaveDataUrl(Dictionary<string, string> config, string apiPath)
internal static string ConstructSaveDataUrl(IReadOnlyDictionary<string, string> config, string apiPath)
{
if (config.TryGetValue("OPENSPEEDTEST_SAVE_DATA_URL", out var configuredUrl)
&& !string.IsNullOrWhiteSpace(configuredUrl))
{
return ValidateConfiguredUrl(configuredUrl, "OPENSPEEDTEST_SAVE_DATA_URL");
}

// Priority: REVERSE_PROXIED_HOST_NAME (https) > HOST_NAME (http) > HOST_IP (http) > __DYNAMIC__
// IMPORTANT: Keep this logic in sync with docker/openspeedtest/entrypoint.sh (Docker deployment)
config.TryGetValue("REVERSE_PROXIED_HOST_NAME", out var reverseProxy);
Expand Down Expand Up @@ -196,6 +212,33 @@ private string ConstructSaveDataUrl(Dictionary<string, string> config, string ap
}
}

internal static string ConstructClientResultsUrl(IReadOnlyDictionary<string, string> config)
{
if (config.TryGetValue("OPENSPEEDTEST_CLIENT_RESULTS_URL", out var configuredUrl)
&& !string.IsNullOrWhiteSpace(configuredUrl))
{
return ValidateConfiguredUrl(configuredUrl, "OPENSPEEDTEST_CLIENT_RESULTS_URL");
}

return "__FROM_SAVE_DATA_URL__";
}

private static string ValidateConfiguredUrl(string value, string key)
{
var url = value.Trim();
if (url.StartsWith('/') && !url.StartsWith("//", StringComparison.Ordinal))
return url;

if (Uri.TryCreate(url, UriKind.Absolute, out var parsed)
&& (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps))
{
return url;
}

throw new InvalidOperationException(
$"{key} must be an absolute HTTP(S) URL or a root-relative path");
}

/// <summary>
/// Gets the path to the nginx executable.
/// On Windows, looks for bundled nginx.exe in SpeedTest folder.
Expand Down
53 changes: 53 additions & 0 deletions tests/NetworkOptimizer.Web.Tests/NginxHostedServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using FluentAssertions;
using NetworkOptimizer.Web.Services;
using Xunit;

namespace NetworkOptimizer.Web.Tests;

public class NginxHostedServiceTests
{
[Fact]
public void ConstructUrls_UsesIndependentConfiguredRoutes()
{
var config = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["OPENSPEEDTEST_SAVE_DATA_URL"] = "/api/public/speedtest/results",
["OPENSPEEDTEST_CLIENT_RESULTS_URL"] = "https://optimizer.example/client-speedtest"
};

NginxHostedService.ConstructSaveDataUrl(config, "/ignored")
.Should().Be("/api/public/speedtest/results");
NginxHostedService.ConstructClientResultsUrl(config)
.Should().Be("https://optimizer.example/client-speedtest");
}

[Fact]
public void ConstructUrls_PreservesExistingDefaults()
{
var config = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["REVERSE_PROXIED_HOST_NAME"] = "optimizer.example"
};

NginxHostedService.ConstructSaveDataUrl(config, "/api/public/speedtest/results")
.Should().Be("https://optimizer.example/api/public/speedtest/results");
NginxHostedService.ConstructClientResultsUrl(config)
.Should().Be("__FROM_SAVE_DATA_URL__");
}

[Theory]
[InlineData("javascript:alert(1)")]
[InlineData("//unexpected.example/results")]
[InlineData("relative/results")]
public void ConstructSaveDataUrl_RejectsUnsafeConfiguredValues(string value)
{
var config = new Dictionary<string, string>
{
["OPENSPEEDTEST_SAVE_DATA_URL"] = value
};

var act = () => NginxHostedService.ConstructSaveDataUrl(config, "/api/results");

act.Should().Throw<InvalidOperationException>();
}
}
Loading