From ab82719a6bdb0110bd37f9787f4a2604dbd8acbd Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 20 Feb 2026 10:39:51 +0100 Subject: [PATCH] Improved license key support Added support for reporting the license key details in SystemInfo. Added support for loading a key via the console connection, if used. --- .../RemoteControl/ExchangeDataTypes.cs | 4 + .../Library/RestAPI/Database/Connection.cs | 3 +- .../RestAPI/Database/ServerSettings.cs | 7 + Duplicati/Server/Program.cs | 3 + .../Abstractions/ServerSettings.cs | 28 ++++ Duplicati/WebserverCore/Dto/SystemInfoDto.cs | 72 +++++++++ .../Services/RemoteControllerHandler.cs | 9 ++ .../Services/RemoteControllerService.cs | 2 + .../Services/Settings/SettingsService.cs | 2 + .../Services/SystemInfoProvider.cs | 2 + proprietary/LicenseChecker/LicenseHelper.cs | 148 +++++++++++++++--- 11 files changed, 256 insertions(+), 24 deletions(-) diff --git a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs index 5294dd316..e4cc4f900 100644 --- a/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs +++ b/Duplicati/Library/RemoteControl/ExchangeDataTypes.cs @@ -97,6 +97,10 @@ public sealed record ControlRequestMessage(string Command, Dictionary public const string ReportUrlKey = "reportingurl"; /// + /// The key that contains the client license key + /// + public const string ClientLicenseKeyKey = "clientlicensekey"; + /// /// The key that contains the dashboard URL /// public const string DashboardUrlKey = "dashboardurl"; diff --git a/Duplicati/Library/RestAPI/Database/Connection.cs b/Duplicati/Library/RestAPI/Database/Connection.cs index d58354352..ca12dea77 100644 --- a/Duplicati/Library/RestAPI/Database/Connection.cs +++ b/Duplicati/Library/RestAPI/Database/Connection.cs @@ -79,7 +79,8 @@ namespace Duplicati.Server.Database ServerSettings.CONST.REMOTE_CONTROL_CONFIG, ServerSettings.CONST.SERVER_SSL_CERTIFICATE, ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD, - ServerSettings.CONST.REMOTE_CONTROL_STORAGE_API_KEY + ServerSettings.CONST.REMOTE_CONTROL_STORAGE_API_KEY, + ServerSettings.CONST.CLIENT_LICENSE_KEY ]) .ToHashSet(StringComparer.OrdinalIgnoreCase); diff --git a/Duplicati/Library/RestAPI/Database/ServerSettings.cs b/Duplicati/Library/RestAPI/Database/ServerSettings.cs index 6fb0b2267..969a11331 100644 --- a/Duplicati/Library/RestAPI/Database/ServerSettings.cs +++ b/Duplicati/Library/RestAPI/Database/ServerSettings.cs @@ -82,6 +82,7 @@ namespace Duplicati.Server.Database public const string REMOTE_CONTROL_STORAGE_API_ID = "remote-control-storage-api-id"; public const string REMOTE_CONTROL_STORAGE_API_KEY = "remote-control-storage-api-key"; public const string REMOTE_CONTROL_STORAGE_ENDPOINT_URL = "remote-control-storage-endpoint-url"; + public const string CLIENT_LICENSE_KEY = "client-license-key"; } private readonly Dictionary settings; @@ -708,6 +709,12 @@ namespace Duplicati.Server.Database set => SetAndSaveSetting(CONST.REMOTE_CONTROL_STORAGE_ENDPOINT_URL, value); } + public string? ClientLicenseKey + { + get => settings[CONST.CLIENT_LICENSE_KEY]; + set => SetAndSaveSetting(CONST.CLIENT_LICENSE_KEY, value); + } + public string? BackupListSortOrder { get diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index c577e62b9..2c3850ab3 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -293,6 +293,9 @@ namespace Duplicati.Server if (!string.IsNullOrWhiteSpace(remoteControlUrl)) RegisterForRemoteControl(DuplicatiWebserver.Provider.GetRequiredService(), DuplicatiWebserver.Provider.GetRequiredService(), remoteControlUrl, Library.Utility.Utility.ParseBoolOption(commandlineOptions, REGISTER_REMOTE_CONTROL_REREGISTER_OPTION), logMessageToConsole); + if (!string.IsNullOrWhiteSpace(connection.ApplicationSettings.ClientLicenseKey)) + Proprietary.LicenseChecker.LicenseHelper.SetRemoteClientLicenseKey(connection.ApplicationSettings.ClientLicenseKey); + if (applicationSettings.Origin == "Server" && connection.ApplicationSettings.AutogeneratedPassphrase) { var signinToken = DuplicatiWebserver.Provider.GetRequiredService().CreateSigninToken("server-cli"); diff --git a/Duplicati/WebserverCore/Abstractions/ServerSettings.cs b/Duplicati/WebserverCore/Abstractions/ServerSettings.cs index 677a8dcfb..9ea1bdb6c 100644 --- a/Duplicati/WebserverCore/Abstractions/ServerSettings.cs +++ b/Duplicati/WebserverCore/Abstractions/ServerSettings.cs @@ -125,4 +125,32 @@ public class ServerSettings { get => applicationSettings.ServerSSLCertificate != null; } +} + +public class LicenseStatusDto +{ + /// + /// Whether a license is configured. + /// + public required bool IsConfigured { get; init; } + + /// + /// Whether the license is currently valid. + /// + public required bool IsValid { get; init; } + + /// + /// Whether the license is in its grace period (expired but still functional). + /// + public required bool IsInGracePeriod { get; init; } + + /// + /// The license expiration date (null if no license configured). + /// + public required DateTimeOffset? ExpiresAt { get; init; } + + /// + /// The license expiration date including grace period (null if no license configured). + /// + public required DateTimeOffset? ExpiresWithGraceAt { get; init; } } \ No newline at end of file diff --git a/Duplicati/WebserverCore/Dto/SystemInfoDto.cs b/Duplicati/WebserverCore/Dto/SystemInfoDto.cs index db2f4e053..afbdcdad8 100644 --- a/Duplicati/WebserverCore/Dto/SystemInfoDto.cs +++ b/Duplicati/WebserverCore/Dto/SystemInfoDto.cs @@ -247,6 +247,78 @@ public sealed record SystemInfoDto /// public required IEnumerable PowerModeProviders { get; init; } + /// + /// Gets or sets the local license status (from file or environment only, not server-provided). + /// + public required LicenseStatusDto? LocalLicenseStatus { get; init; } + + /// + /// Gets or sets the remote license status (from server-provided license). + /// + public required LicenseStatusDto? RemoteLicenseStatus { get; init; } + + /// + /// Represents the license status information. + /// + public class LicenseStatusDto + { + /// + /// Whether a local license is configured (from file or environment). + /// + public required bool IsConfigured { get; init; } + + /// + /// Whether the local license is currently valid. + /// + public required bool IsValid { get; init; } + + /// + /// Whether the license is in its grace period (expired but still functional). + /// + public required bool IsInGracePeriod { get; init; } + + /// + /// The license expiration date (null if no license configured). + /// + public required DateTimeOffset? ExpiresAt { get; init; } + + /// + /// The license expiration date including grace period (null if no license configured). + /// + public required DateTimeOffset? ExpiresWithGraceAt { get; init; } + + /// + /// The enabled features from the license. + /// + public required Dictionary? Features { get; init; } + + /// + /// Maps the license data to a DTO. + /// + /// The license data to map. + /// The mapped DTO. + public static LicenseStatusDto Map(Proprietary.LicenseChecker.LicenseData? data) + => data != null + ? new LicenseStatusDto + { + IsConfigured = true, + IsValid = data.IsValidNow, + IsInGracePeriod = data.IsInGracePeriod, + ExpiresAt = data.ValidTo, + ExpiresWithGraceAt = data.ValidToWithGrace, + Features = data.Features + } + : new LicenseStatusDto + { + IsConfigured = false, + IsValid = false, + IsInGracePeriod = false, + ExpiresAt = null, + ExpiresWithGraceAt = null, + Features = null + }; + } + /// /// Represents a timezone. /// diff --git a/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs b/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs index 79137dedc..392698b21 100644 --- a/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs +++ b/Duplicati/WebserverCore/Services/RemoteControllerHandler.cs @@ -98,6 +98,15 @@ public class RemoteControllerHandler(Connection connection, IHttpClientFactory h connection.ApplicationSettings.RemoteControlStorageApiKey = message.ControlRequestMessage.Parameters.GetValueOrDefault(ControlRequestMessage.StorageApiKeyKey); connection.ApplicationSettings.RemoteControlStorageEndpointUrl = message.ControlRequestMessage.Parameters.GetValueOrDefault(ControlRequestMessage.StorageEndpointUrlKey); + var newLicenseKey = message.ControlRequestMessage.Parameters.GetValueOrDefault(ControlRequestMessage.ClientLicenseKeyKey); + var currentLicenseKey = connection.ApplicationSettings.ClientLicenseKey; + + if (newLicenseKey != currentLicenseKey) + { + connection.ApplicationSettings.ClientLicenseKey = newLicenseKey; + Duplicati.Proprietary.LicenseChecker.LicenseHelper.SetRemoteClientLicenseKey(newLicenseKey); + } + var backupConfigs = message.ControlRequestMessage.Parameters .Where(kv => kv.Key.StartsWith(ControlRequestMessage.BackupConfigKeyPrefix, StringComparison.OrdinalIgnoreCase)) .Select(kv => kv.Key) diff --git a/Duplicati/WebserverCore/Services/RemoteControllerService.cs b/Duplicati/WebserverCore/Services/RemoteControllerService.cs index e44ed6e5b..76d497b1d 100644 --- a/Duplicati/WebserverCore/Services/RemoteControllerService.cs +++ b/Duplicati/WebserverCore/Services/RemoteControllerService.cs @@ -109,6 +109,8 @@ public class RemoteControllerService(Connection connection, IRemoteControllerHan connection.ApplicationSettings.RemoteControlStorageApiId = string.Empty; connection.ApplicationSettings.RemoteControlStorageApiKey = string.Empty; connection.ApplicationSettings.RemoteControlStorageEndpointUrl = string.Empty; + connection.ApplicationSettings.ClientLicenseKey = string.Empty; + Duplicati.Proprietary.LicenseChecker.LicenseHelper.SetRemoteClientLicenseKey(null); // Remove remotely configured backups var remoteBackupIds = connection.Backups.Where(x => !string.IsNullOrWhiteSpace(x.ExternalID) && x.ExternalID.StartsWith(ControlRequestMessage.BackupConfigKeyPrefix, StringComparison.OrdinalIgnoreCase)) diff --git a/Duplicati/WebserverCore/Services/Settings/SettingsService.cs b/Duplicati/WebserverCore/Services/Settings/SettingsService.cs index 1022cc549..f61cc163d 100644 --- a/Duplicati/WebserverCore/Services/Settings/SettingsService.cs +++ b/Duplicati/WebserverCore/Services/Settings/SettingsService.cs @@ -36,6 +36,7 @@ public class SettingsService(Connection connection) : ISettingsService Server.Database.ServerSettings.CONST.SERVER_SSL_CERTIFICATE, Server.Database.ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD, Server.Database.ServerSettings.CONST.REMOTE_CONTROL_STORAGE_API_KEY, + Server.Database.ServerSettings.CONST.CLIENT_LICENSE_KEY, // Not used anymore, but not completely removed Server.Database.ServerSettings.CONST.SERVER_PASSPHRASE, Server.Database.ServerSettings.CONST.SERVER_PASSPHRASE_SALT, @@ -60,6 +61,7 @@ public class SettingsService(Connection connection) : ISettingsService Server.Database.ServerSettings.CONST.REMOTE_CONTROL_STORAGE_ENDPOINT_URL, Server.Database.ServerSettings.CONST.REMOTE_CONTROL_STORAGE_API_KEY, Server.Database.ServerSettings.CONST.REMOTE_CONTROL_STORAGE_API_ID, + Server.Database.ServerSettings.CONST.CLIENT_LICENSE_KEY, "ServerSSLCertificate", "server-passphrase-trayicon-hash", "server-passphrase-trayicon-salt" diff --git a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs index af7047d9e..aa556910f 100644 --- a/Duplicati/WebserverCore/Services/SystemInfoProvider.cs +++ b/Duplicati/WebserverCore/Services/SystemInfoProvider.cs @@ -397,6 +397,8 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec DefaultOAuthURL = AuthIdOptionsHelper.DUPLICATI_OAUTH_SERVICE, DefaultOAuthURLv2 = AuthIdOptionsHelper.DUPLICATI_OAUTH_SERVICE_NEW, PowerModeProviders = systeminfo.PowerModeProviders, + LocalLicenseStatus = SystemInfoDto.LicenseStatusDto.Map(Proprietary.LicenseChecker.LicenseHelper.GetLocalLicenseData()), + RemoteLicenseStatus = SystemInfoDto.LicenseStatusDto.Map(Proprietary.LicenseChecker.LicenseHelper.GetRemoteLicenseData()) }; } } diff --git a/proprietary/LicenseChecker/LicenseHelper.cs b/proprietary/LicenseChecker/LicenseHelper.cs index 05e9f381a..2244654a9 100644 --- a/proprietary/LicenseChecker/LicenseHelper.cs +++ b/proprietary/LicenseChecker/LicenseHelper.cs @@ -7,7 +7,131 @@ namespace Duplicati.Proprietary.LicenseChecker; public static class LicenseHelper { - public static LicenseData? LicenseData => licenseData.Value; + private static readonly object _licenseLock = new(); + private static LicenseData? _cachedLicenseData; + private static string? _remoteClientLicenseKey; + private static bool _isInitialized; + + public static void SetRemoteClientLicenseKey(string? key) + { + lock (_licenseLock) + { + if (_remoteClientLicenseKey != key) + { + _remoteClientLicenseKey = key; + _cachedLicenseData = null; // Force reload + _isInitialized = false; + } + } + } + + public static void ReloadLicense() + { + lock (_licenseLock) + { + _cachedLicenseData = null; + _isInitialized = false; + } + } + + public static LicenseData? LicenseData + { + get + { + lock (_licenseLock) + { + if (!_isInitialized) + { + _cachedLicenseData = LoadLicenseData(); + _isInitialized = true; + } + return _cachedLicenseData; + } + } + } + + private static LicenseData? LoadLicenseData() + { + // Priority: File > Environment > ClientLicenseKey (server-provided) + // Local license always takes precedence over server-provided license + string? key = null; + + // Check for a license file in the installation directory (highest priority) + var keyfilepath = Path.Combine(UpdaterManager.INSTALLATIONDIR, "license.key"); + if (File.Exists(keyfilepath)) + key = $"file://{keyfilepath}"; + + // Check for a license key in the environment variables + if (string.IsNullOrWhiteSpace(key)) + key = Environment.GetEnvironmentVariable("DUPLICATI_LICENSE_KEY"); + + // Fall back to server-provided license key (lowest priority) + if (string.IsNullOrWhiteSpace(key)) + key = _remoteClientLicenseKey; + + if (string.IsNullOrWhiteSpace(key)) + return null; + + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + return LicenseChecker.ObtainLicenseAsync(key, ct.Token).Await(); + } + + /// + /// Gets the local license data (from file or environment only, ignoring server-provided license). + /// Used for SystemInfo to report local license status. + /// + public static LicenseData? GetLocalLicenseData() + { + string? key = null; + + // Check for a license file in the installation directory + var keyfilepath = Path.Combine(UpdaterManager.INSTALLATIONDIR, "license.key"); + if (File.Exists(keyfilepath)) + key = $"file://{keyfilepath}"; + + // Check for a license key in the environment variables + if (string.IsNullOrWhiteSpace(key)) + key = Environment.GetEnvironmentVariable("DUPLICATI_LICENSE_KEY"); + + if (string.IsNullOrWhiteSpace(key)) + return null; + + try + { + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + return LicenseChecker.ObtainLicenseAsync(key, ct.Token).Await(); + } + catch + { + return null; + } + } + + /// + /// Gets the remote (server-provided) license data only. + /// Used for OnConnect metadata to report remote license status. + /// + public static LicenseData? GetRemoteLicenseData() + { + string? key; + lock (_licenseLock) + { + key = _remoteClientLicenseKey; + } + + if (string.IsNullOrWhiteSpace(key)) + return null; + + try + { + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + return LicenseChecker.ObtainLicenseAsync(key, ct.Token).Await(); + } + catch + { + return null; + } + } public static int AvailableOffice365UserSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365Users); public static int AvailableOffice365GroupSeats => GetFeatureSeats(DuplicatiLicenseFeatures.Office365Groups); @@ -54,26 +178,4 @@ public static class LicenseHelper return seats; } - - private static Lazy licenseData = new Lazy(() => - { - string? key = null; - - // Check for a license file in the installation directory - var keyfilepath = Path.Combine(UpdaterManager.INSTALLATIONDIR, "license.key"); - if (File.Exists(keyfilepath)) - key = $"file://{keyfilepath}"; - - // Check for a license key in the environment variables - if (string.IsNullOrWhiteSpace(key)) - key = Environment.GetEnvironmentVariable("DUPLICATI_LICENSE_KEY"); - - // No license key found - if (string.IsNullOrWhiteSpace(key)) - return null; - - using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - return LicenseChecker.ObtainLicenseAsync(key, ct.Token).Await(); - }); - }