Merge pull request #6777 from duplicati/feature/assign-license-from-console

Improved license key support
This commit is contained in:
Kenneth Skovhede
2026-02-20 13:09:18 +01:00
committed by GitHub
11 changed files with 256 additions and 24 deletions
@@ -97,6 +97,10 @@ public sealed record ControlRequestMessage(string Command, Dictionary<string, st
/// </summary>
public const string ReportUrlKey = "reportingurl";
/// <summary>
/// The key that contains the client license key
/// </summary>
public const string ClientLicenseKeyKey = "clientlicensekey";
/// <summary>
/// The key that contains the dashboard URL
/// </summary>
public const string DashboardUrlKey = "dashboardurl";
@@ -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);
@@ -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";
public const string ENABLE_FOLDER_STATUS_SERVICE = "enable-folder-status-service";
}
@@ -709,6 +710,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 bool EnableFolderStatusService
{
get => Utility.ParseBool(settings[CONST.ENABLE_FOLDER_STATUS_SERVICE], false);
+3
View File
@@ -293,6 +293,9 @@ namespace Duplicati.Server
if (!string.IsNullOrWhiteSpace(remoteControlUrl))
RegisterForRemoteControl(DuplicatiWebserver.Provider.GetRequiredService<IRemoteControllerRegistration>(), DuplicatiWebserver.Provider.GetRequiredService<IRemoteController>(), 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<IJWTTokenProvider>().CreateSigninToken("server-cli");
@@ -125,4 +125,32 @@ public class ServerSettings
{
get => applicationSettings.ServerSSLCertificate != null;
}
}
public class LicenseStatusDto
{
/// <summary>
/// Whether a license is configured.
/// </summary>
public required bool IsConfigured { get; init; }
/// <summary>
/// Whether the license is currently valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Whether the license is in its grace period (expired but still functional).
/// </summary>
public required bool IsInGracePeriod { get; init; }
/// <summary>
/// The license expiration date (null if no license configured).
/// </summary>
public required DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// The license expiration date including grace period (null if no license configured).
/// </summary>
public required DateTimeOffset? ExpiresWithGraceAt { get; init; }
}
@@ -247,6 +247,78 @@ public sealed record SystemInfoDto
/// </summary>
public required IEnumerable<string> PowerModeProviders { get; init; }
/// <summary>
/// Gets or sets the local license status (from file or environment only, not server-provided).
/// </summary>
public required LicenseStatusDto? LocalLicenseStatus { get; init; }
/// <summary>
/// Gets or sets the remote license status (from server-provided license).
/// </summary>
public required LicenseStatusDto? RemoteLicenseStatus { get; init; }
/// <summary>
/// Represents the license status information.
/// </summary>
public class LicenseStatusDto
{
/// <summary>
/// Whether a local license is configured (from file or environment).
/// </summary>
public required bool IsConfigured { get; init; }
/// <summary>
/// Whether the local license is currently valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Whether the license is in its grace period (expired but still functional).
/// </summary>
public required bool IsInGracePeriod { get; init; }
/// <summary>
/// The license expiration date (null if no license configured).
/// </summary>
public required DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// The license expiration date including grace period (null if no license configured).
/// </summary>
public required DateTimeOffset? ExpiresWithGraceAt { get; init; }
/// <summary>
/// The enabled features from the license.
/// </summary>
public required Dictionary<string, string>? Features { get; init; }
/// <summary>
/// Maps the license data to a DTO.
/// </summary>
/// <param name="data">The license data to map.</param>
/// <returns>The mapped DTO.</returns>
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
};
}
/// <summary>
/// Represents a timezone.
/// </summary>
@@ -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)
@@ -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))
@@ -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"
@@ -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())
};
}
}
+125 -23
View File
@@ -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();
}
/// <summary>
/// Gets the local license data (from file or environment only, ignoring server-provided license).
/// Used for SystemInfo to report local license status.
/// </summary>
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;
}
}
/// <summary>
/// Gets the remote (server-provided) license data only.
/// Used for OnConnect metadata to report remote license status.
/// </summary>
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?> licenseData = new Lazy<LicenseData?>(() =>
{
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();
});
}