1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-18 21:35:27 -04:00

New: Support PEM format for SSL certificates

Closes #8087
This commit is contained in:
Mark McDowall
2025-09-27 15:42:38 -07:00
parent 0407564784
commit a4a18d6121
10 changed files with 102 additions and 31 deletions
@@ -156,6 +156,7 @@ function GeneralSettings() {
enableSsl={settings.enableSsl}
sslPort={settings.sslPort}
sslCertPath={settings.sslCertPath}
sslKeyPath={settings.sslKeyPath}
sslCertPassword={settings.sslCertPassword}
launchBrowser={settings.launchBrowser}
onInputChange={handleInputChange}
+39 -24
View File
@@ -19,6 +19,7 @@ interface HostSettingsProps {
applicationUrl: PendingSection<General>['applicationUrl'];
enableSsl: PendingSection<General>['enableSsl'];
sslPort: PendingSection<General>['sslPort'];
sslKeyPath: PendingSection<General>['sslKeyPath'];
sslCertPath: PendingSection<General>['sslCertPath'];
sslCertPassword: PendingSection<General>['sslCertPassword'];
launchBrowser: PendingSection<General>['launchBrowser'];
@@ -34,6 +35,7 @@ function HostSettings({
enableSsl,
sslPort,
sslCertPath,
sslKeyPath,
sslCertPassword,
launchBrowser,
onInputChange,
@@ -142,33 +144,46 @@ function HostSettings({
) : null}
{enableSsl.value ? (
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPath')}</FormLabel>
<>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPath')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="sslCertPath"
helpText={translate('SslCertPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPath}
/>
</FormGroup>
) : null}
<FormInputGroup
type={inputTypes.TEXT}
name="sslCertPath"
helpText={translate('SslCertPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPath}
/>
</FormGroup>
{enableSsl.value ? (
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPassword')}</FormLabel>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslKeyPath')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="sslCertPassword"
helpText={translate('SslCertPasswordHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPassword}
/>
</FormGroup>
<FormInputGroup
type={inputTypes.TEXT}
name="sslKeyPath"
helpText={translate('SslKeyPathHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslKeyPath}
/>
</FormGroup>
<FormGroup advancedSettings={showAdvancedSettings} isAdvanced={true}>
<FormLabel>{translate('SslCertPassword')}</FormLabel>
<FormInputGroup
type={inputTypes.PASSWORD}
name="sslCertPassword"
helpText={translate('SslCertPasswordHelpText')}
helpTextWarning={translate('RestartRequiredHelpTextWarning')}
onChange={onInputChange}
{...sslCertPassword}
/>
</FormGroup>
</>
) : null}
{isWindowsService ? null : (
+1
View File
@@ -23,6 +23,7 @@ export default interface General {
branch: string;
apiKey: string;
sslCertPath: string;
sslKeyPath: string;
sslCertPassword: string;
urlBase: string;
instanceName: string;
@@ -8,5 +8,6 @@ public class ServerOptions
public bool? EnableSsl { get; set; }
public int? SslPort { get; set; }
public string SslCertPath { get; set; }
public string SslKeyPath { get; set; }
public string SslCertPassword { get; set; }
}
@@ -47,6 +47,7 @@ namespace NzbDrone.Core.Configuration
string Branch { get; }
string ApiKey { get; }
string SslCertPath { get; }
string SslKeyPath { get; }
string SslCertPassword { get; }
string UrlBase { get; }
string UiFolder { get; }
@@ -257,6 +258,7 @@ namespace NzbDrone.Core.Configuration
public int LogSizeLimit => Math.Min(Math.Max(_logOptions.SizeLimit ?? GetValueInt("LogSizeLimit", 1, persist: false), 0), 10);
public bool FilterSentryEvents => _logOptions.FilterSentryEvents ?? GetValueBoolean("FilterSentryEvents", true, persist: false);
public string SslCertPath => _serverOptions.SslCertPath ?? GetValue("SslCertPath", "");
public string SslKeyPath => _serverOptions.SslKeyPath ?? GetValue("SslKeyPath", "");
public string SslCertPassword => _serverOptions.SslCertPassword ?? GetValue("SslCertPassword", "");
public string UrlBase
+3 -1
View File
@@ -2002,7 +2002,9 @@
"SslCertPassword": "SSL Cert Password",
"SslCertPasswordHelpText": "Password for pfx file",
"SslCertPath": "SSL Cert Path",
"SslCertPathHelpText": "Path to pfx file",
"SslCertPathHelpText": "Path to pfx or pem file",
"SslKeyPath": "SSL Key Path",
"SslKeyPathHelpText": "Path to key file used with pem file",
"SslPort": "SSL Port",
"Standard": "Standard",
"StandardEpisodeFormat": "Standard Episode Format",
+21 -3
View File
@@ -139,6 +139,7 @@ namespace NzbDrone.Host
var sslPort = config.GetValue<int?>($"Sonarr:Server:{nameof(ServerOptions.SslPort)}") ?? config.GetValue(nameof(ConfigFileProvider.SslPort), 9898);
var enableSsl = config.GetValue<bool?>($"Sonarr:Server:{nameof(ServerOptions.EnableSsl)}") ?? config.GetValue(nameof(ConfigFileProvider.EnableSsl), false);
var sslCertPath = config.GetValue<string>($"Sonarr:Server:{nameof(ServerOptions.SslCertPath)}") ?? config.GetValue<string>(nameof(ConfigFileProvider.SslCertPath));
var sslKeyPath = config.GetValue<string>($"Sonarr:Server:{nameof(ServerOptions.SslKeyPath)}") ?? config.GetValue<string>(nameof(ConfigFileProvider.SslKeyPath));
var sslCertPassword = config.GetValue<string>($"Sonarr:Server:{nameof(ServerOptions.SslCertPassword)}") ?? config.GetValue<string>(nameof(ConfigFileProvider.SslCertPassword));
var logDbEnabled = config.GetValue<bool?>($"Sonarr:Log:{nameof(LogOptions.DbEnabled)}") ?? config.GetValue(nameof(ConfigFileProvider.LogDbEnabled), true);
@@ -191,7 +192,7 @@ namespace NzbDrone.Host
{
options.ConfigureHttpsDefaults(configureOptions =>
{
configureOptions.ServerCertificate = ValidateSslCertificate(sslCertPath, sslCertPassword);
configureOptions.ServerCertificate = ValidateSslCertificate(sslCertPath, sslKeyPath, sslCertPassword);
});
}
});
@@ -271,13 +272,26 @@ namespace NzbDrone.Host
return $"{scheme}://{bindAddress}:{port}";
}
private static X509Certificate2 ValidateSslCertificate(string cert, string password)
private static X509Certificate2 ValidateSslCertificate(string cert, string key, string password)
{
X509Certificate2 certificate;
try
{
certificate = new X509Certificate2(cert, password, X509KeyStorageFlags.DefaultKeySet);
var type = X509Certificate2.GetCertContentType(cert);
if (type == X509ContentType.Cert)
{
certificate = X509Certificate2.CreateFromPemFile(cert, key.IsNullOrWhiteSpace() ? null : key);
}
else if (type == X509ContentType.Pkcs12)
{
certificate = new X509Certificate2(cert, password, X509KeyStorageFlags.DefaultKeySet);
}
else
{
throw new SonarrStartupException($"Invalid certificate type: {type}");
}
}
catch (CryptographicException ex)
{
@@ -289,6 +303,10 @@ namespace NzbDrone.Host
throw new SonarrStartupException(ex);
}
catch (Exception ex)
{
throw new SonarrStartupException(ex);
}
return certificate;
}
@@ -3,6 +3,7 @@ using System.Security.Cryptography.X509Certificates;
using FluentValidation;
using FluentValidation.Validators;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation;
namespace Sonarr.Api.V3.Config
@@ -17,7 +18,7 @@ namespace Sonarr.Api.V3.Config
public class CertificateValidator : PropertyValidator
{
protected override string GetDefaultMessageTemplate() => "Invalid SSL certificate file or password. {message}";
protected override string GetDefaultMessageTemplate() => "Invalid SSL certificate file or {passwordOrKey}. {message}";
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(CertificateValidator));
@@ -33,16 +34,38 @@ namespace Sonarr.Api.V3.Config
return true;
}
var certPath = resource.SslCertPath;
var keyPath = resource.SslKeyPath;
var certPassword = resource.SslCertPassword;
var type = X509Certificate2.GetCertContentType(certPath);
try
{
new X509Certificate2(resource.SslCertPath, resource.SslCertPassword, X509KeyStorageFlags.DefaultKeySet);
if (type == X509ContentType.Cert)
{
X509Certificate2.CreateFromPemFile(certPath, keyPath.IsNullOrWhiteSpace() ? null : keyPath);
}
else if (type == X509ContentType.Pkcs12)
{
new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.DefaultKeySet);
}
else
{
Logger.Debug("Invalid SSL certificate file. Unexpected certificate type: {0}", type);
context.MessageFormatter.AppendArgument("passwordOrKey", "password");
return false;
}
return true;
}
catch (CryptographicException ex)
{
Logger.Debug(ex, "Invalid SSL certificate file or password. {0}", ex.Message);
var passwordOrKey = type == X509ContentType.Cert ? "key" : "password";
Logger.Debug(ex, "Invalid SSL certificate file or {0}. {1}", passwordOrKey, ex.Message);
context.MessageFormatter.AppendArgument("passwordOrKey", passwordOrKey);
context.MessageFormatter.AppendArgument("message", ex.Message);
return false;
@@ -63,6 +63,12 @@ namespace Sonarr.Api.V3.Config
.IsValidCertificate()
.When(c => c.EnableSsl);
SharedValidator.RuleFor(c => c.SslKeyPath)
.NotEmpty()
.IsValidPath()
.SetValidator(fileExistsValidator)
.When(c => c.SslKeyPath.IsNotNullOrWhiteSpace());
SharedValidator.RuleFor(c => c.LogSizeLimit).InclusiveBetween(1, 10);
SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'main' is the default");
@@ -26,6 +26,7 @@ namespace Sonarr.Api.V3.Config
public string Branch { get; set; }
public string ApiKey { get; set; }
public string SslCertPath { get; set; }
public string SslKeyPath { get; set; }
public string SslCertPassword { get; set; }
public string UrlBase { get; set; }
public string InstanceName { get; set; }
@@ -72,6 +73,7 @@ namespace Sonarr.Api.V3.Config
Branch = model.Branch,
ApiKey = model.ApiKey,
SslCertPath = model.SslCertPath,
SslKeyPath = model.SslKeyPath,
SslCertPassword = model.SslCertPassword,
UrlBase = model.UrlBase,
InstanceName = model.InstanceName,