330 lines
12 KiB
C#
330 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using EmbedIO.Utilities;
|
|
using Swan;
|
|
using Swan.Logging;
|
|
|
|
namespace EmbedIO
|
|
{
|
|
/// <summary>
|
|
/// Contains options for configuring an instance of <see cref="WebServer"/>.
|
|
/// </summary>
|
|
public sealed class WebServerOptions : WebServerOptionsBase
|
|
{
|
|
private const string NetShLogSource = "NetSh";
|
|
|
|
private readonly List<string> _urlPrefixes = new List<string>();
|
|
|
|
private HttpListenerMode _mode = HttpListenerMode.EmbedIO;
|
|
|
|
private X509Certificate2? _certificate;
|
|
|
|
private string? _certificateThumbprint;
|
|
|
|
private bool _autoLoadCertificate;
|
|
|
|
private bool _autoRegisterCertificate;
|
|
|
|
private StoreName _storeName = StoreName.My;
|
|
|
|
private StoreLocation _storeLocation = StoreLocation.LocalMachine;
|
|
|
|
/// <summary>
|
|
/// Gets the URL prefixes.
|
|
/// </summary>
|
|
public IReadOnlyList<string> UrlPrefixes => _urlPrefixes;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the type of HTTP listener.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
/// <seealso cref="HttpListenerMode"/>
|
|
public HttpListenerMode Mode
|
|
{
|
|
get => _mode;
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
_mode = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the X.509 certificate to use for SSL connections.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
public X509Certificate2? Certificate
|
|
{
|
|
get
|
|
{
|
|
if (AutoRegisterCertificate)
|
|
return TryRegisterCertificate() ? _certificate : null;
|
|
|
|
return _certificate ?? (AutoLoadCertificate ? LoadCertificate() : null);
|
|
}
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
_certificate = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the thumbprint of the X.509 certificate to use for SSL connections.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
public string? CertificateThumbprint
|
|
{
|
|
get => _certificateThumbprint;
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
|
|
// strip any non-hexadecimal values and make uppercase
|
|
_certificateThumbprint = value == null
|
|
? null
|
|
: Regex.Replace(value, @"[^\da-fA-F]", string.Empty).ToUpper(CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether to automatically load the X.509 certificate.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
/// <exception cref="PlatformNotSupportedException">This property is being set to <see langword="true"/>
|
|
/// and the underlying operating system is not Windows.</exception>
|
|
public bool AutoLoadCertificate
|
|
{
|
|
get => _autoLoadCertificate;
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
if (value && SwanRuntime.OS != Swan.OperatingSystem.Windows)
|
|
throw new PlatformNotSupportedException("AutoLoadCertificate functionality is only available under Windows.");
|
|
|
|
_autoLoadCertificate = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether to automatically bind the X.509 certificate
|
|
/// to the port used for HTTPS.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
/// <exception cref="PlatformNotSupportedException">This property is being set to <see langword="true"/>
|
|
/// and the underlying operating system is not Windows.</exception>
|
|
public bool AutoRegisterCertificate
|
|
{
|
|
get => _autoRegisterCertificate;
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
if (value && SwanRuntime.OS != Swan.OperatingSystem.Windows)
|
|
throw new PlatformNotSupportedException("AutoRegisterCertificate functionality is only available under Windows.");
|
|
|
|
_autoRegisterCertificate = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating the X.509 certificate store where to load the certificate from.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
/// <seealso cref="System.Security.Cryptography.X509Certificates.StoreName"/>
|
|
public StoreName StoreName
|
|
{
|
|
get => _storeName;
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
_storeName = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating the location of the X.509 certificate store where to load the certificate from.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">This property is being set,
|
|
/// and this instance's configuration is locked.</exception>
|
|
/// <seealso cref="System.Security.Cryptography.X509Certificates.StoreLocation"/>
|
|
public StoreLocation StoreLocation
|
|
{
|
|
get => _storeLocation;
|
|
set
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
_storeLocation = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a URL prefix.
|
|
/// </summary>
|
|
/// <param name="urlPrefix">The URL prefix.</param>
|
|
/// <exception cref="InvalidOperationException">This instance's configuration is locked.</exception>
|
|
/// <exception cref="ArgumentNullException"><paramref name="urlPrefix"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="ArgumentException">
|
|
/// <para><paramref name="urlPrefix"/> is the empty string.</para>
|
|
/// <para>- or -</para>
|
|
/// <para><paramref name="urlPrefix"/> is already registered.</para>
|
|
/// </exception>
|
|
public void AddUrlPrefix(string urlPrefix)
|
|
{
|
|
EnsureConfigurationNotLocked();
|
|
|
|
urlPrefix = Validate.NotNullOrEmpty(nameof(urlPrefix), urlPrefix);
|
|
if (_urlPrefixes.Contains(urlPrefix))
|
|
throw new ArgumentException("URL prefix is already registered.", nameof(urlPrefix));
|
|
|
|
_urlPrefixes.Add(urlPrefix);
|
|
}
|
|
|
|
private X509Certificate2? LoadCertificate()
|
|
{
|
|
if (SwanRuntime.OS != Swan.OperatingSystem.Windows)
|
|
return null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(_certificateThumbprint)) return GetCertificate(_certificateThumbprint);
|
|
|
|
using var netsh = GetNetsh("show");
|
|
|
|
string? thumbprint = null;
|
|
|
|
netsh.ErrorDataReceived += (s, e) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(e.Data)) return;
|
|
|
|
e.Data.Error(NetShLogSource);
|
|
};
|
|
|
|
netsh.OutputDataReceived += (s, e) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(e.Data)) return;
|
|
|
|
e.Data.Debug(NetShLogSource);
|
|
|
|
var line = e.Data.Trim();
|
|
|
|
if (line.StartsWith("Certificate Hash") && line.IndexOf(":", StringComparison.Ordinal) > -1)
|
|
thumbprint = line.Split(':')[1].Trim();
|
|
};
|
|
|
|
if (!netsh.Start())
|
|
return null;
|
|
|
|
netsh.BeginOutputReadLine();
|
|
netsh.BeginErrorReadLine();
|
|
netsh.WaitForExit();
|
|
|
|
return netsh.ExitCode == 0 && !string.IsNullOrEmpty(thumbprint)
|
|
? GetCertificate(thumbprint)
|
|
: null;
|
|
}
|
|
|
|
private X509Certificate2? GetCertificate(string? thumbprint = null)
|
|
{
|
|
using var store = new X509Store(StoreName, StoreLocation);
|
|
store.Open(OpenFlags.ReadOnly);
|
|
var signingCert = store.Certificates.Find(
|
|
X509FindType.FindByThumbprint,
|
|
thumbprint ?? _certificateThumbprint,
|
|
false);
|
|
return signingCert.Count == 0 ? null : signingCert[0];
|
|
}
|
|
|
|
private bool AddCertificateToStore()
|
|
{
|
|
using var store = new X509Store(StoreName, StoreLocation);
|
|
try
|
|
{
|
|
store.Open(OpenFlags.ReadWrite);
|
|
store.Add(_certificate);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private bool TryRegisterCertificate()
|
|
{
|
|
if (SwanRuntime.OS != Swan.OperatingSystem.Windows)
|
|
return false;
|
|
|
|
if (_certificate == null)
|
|
throw new InvalidOperationException("A certificate is required to AutoRegister");
|
|
|
|
if (GetCertificate(_certificate.Thumbprint) == null && !AddCertificateToStore())
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The provided certificate cannot be added to the default store, add it manually");
|
|
}
|
|
|
|
using var netsh = GetNetsh("add", $"certhash={_certificate.Thumbprint} appid={{adaa04bb-8b63-4073-a12f-d6f8c0b4383f}}");
|
|
|
|
var sb = new StringBuilder();
|
|
|
|
void PushLine(object sender, DataReceivedEventArgs e)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(e.Data)) return;
|
|
|
|
sb.AppendLine(e.Data);
|
|
e.Data.Error(NetShLogSource);
|
|
}
|
|
|
|
netsh.OutputDataReceived += PushLine;
|
|
|
|
netsh.ErrorDataReceived += PushLine;
|
|
|
|
if (!netsh.Start()) return false;
|
|
|
|
netsh.BeginOutputReadLine();
|
|
netsh.BeginErrorReadLine();
|
|
netsh.WaitForExit();
|
|
|
|
return netsh.ExitCode == 0 ? true : throw new InvalidOperationException($"NetSh error: {sb}");
|
|
}
|
|
|
|
private int GetSslPort()
|
|
{
|
|
var port = 443;
|
|
|
|
foreach (var url in UrlPrefixes.Where(x =>
|
|
x.StartsWith("https:", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
var match = Regex.Match(url, @":(\d+)");
|
|
|
|
if (match.Success && int.TryParse(match.Groups[1].Value, out port))
|
|
break;
|
|
}
|
|
|
|
return port;
|
|
}
|
|
|
|
private Process GetNetsh(string verb, string options = "") => new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "netsh",
|
|
CreateNoWindow = true,
|
|
RedirectStandardError = true,
|
|
RedirectStandardOutput = true,
|
|
UseShellExecute = false,
|
|
Arguments = $"http {verb} sslcert ipport=0.0.0.0:{GetSslPort()} {options}",
|
|
},
|
|
};
|
|
}
|
|
} |