// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.CommandLine; using System.CommandLine.NamingConventionBinder; using System.Security.Cryptography.X509Certificates; using Duplicati.Library.AutoUpdater; using Duplicati.Library.Certificates; using Duplicati.Library.Certificates.Platform; using Duplicati.Library.Interface; using Duplicati.Library.Logging; using Duplicati.Server.Database; using Duplicati.WebserverCore.Abstractions; using ServerSettings = Duplicati.Server.Database.ServerSettings; namespace Duplicati.CommandLine.ConfigureTool.Commands; /// /// Commands for managing HTTPS certificates. /// public static class HttpsCommand { /// /// Adds platform-specific CA options to the command. /// /// The command to add options to. /// The command with added options. private static Command AddPlatformSpecificCAOptions(Command cmd) { // Add platform-specific options if (OperatingSystem.IsWindows()) cmd.AddOption(new Option("--store", getDefaultValue: () => OperatingSystem.IsWindows() ? CATrustInstallerFactory.GetDefaultWindowsStoreLocation() == StoreLocation.LocalMachine ? "local" : "user" : "", description: "Certificate store location (local|user). Defaults to 'local' if admin, otherwise 'user')")); else if (OperatingSystem.IsLinux()) cmd.AddOption(new Option("--cert-dir", getDefaultValue: () => OperatingSystem.IsLinux() ? LinuxCATrustInstaller.DEFAULT_CERT_DIR : "", description: "Custom certificate directory for installing CA certificate")); else if (OperatingSystem.IsMacOS()) cmd.AddOption(new Option("--keychain", getDefaultValue: () => OperatingSystem.IsMacOS() ? MacOSCATrustInstaller.DEFAULT_KEYCHAIN_PATH : "", description: "Custom keychain path for installing CA certificate")); return cmd; } /// /// Adds common database options to the command. /// /// The command to add options to. /// The command private static Command AddDatabaseOptions(Command cmd) { cmd.AddOption(new Option("--data-folder", "Path to the Duplicati data folder (defaults to standard location)")); cmd.AddOption(new Option("--settings-encryption-key", "Settings encryption key for the database (if settings are encrypted)")); return cmd; } /// /// Creates the 'generate' command. /// public static Command CreateGenerateCommand() { var cmd = new Command("generate", "Generate a new CA and server certificate for HTTPS") { new Option("--hostnames", "Comma-separated list of hostnames to include in the certificate (defaults to auto-detected hostnames)"), new Option("--no-trust", "Skip installing the CA certificate in the system trust store"), new Option("--auto-create-database", "Create the database if it does not exist"), }; AddDatabaseOptions(cmd); AddPlatformSpecificCAOptions(cmd); cmd.Handler = CommandHandler.Create(HandleGenerate); return cmd; } /// /// Creates the 'renew' command. /// public static Command CreateRenewCommand() { var cmd = new Command("renew", "Renew the server certificate using the existing CA"); AddDatabaseOptions(cmd); cmd.Handler = CommandHandler.Create(HandleRenew); return cmd; } /// /// Creates the 'regenerate-ca' command. /// public static Command CreateRegenerateCaCommand() { var cmd = new Command("regenerate-ca", "Regenerate the CA and server certificate (removes old CA from trust store)") { new Option("--hostnames", "Comma-separated list of hostnames to include in the certificate (defaults to auto-detected hostnames)"), new Option("--no-trust", "Skip installing the CA certificate in the system trust store"), }; AddDatabaseOptions(cmd); AddPlatformSpecificCAOptions(cmd); cmd.Handler = CommandHandler.Create(HandleRegenerateCa); return cmd; } /// /// Creates the 'remove' command. /// public static Command CreateRemoveCommand() { var cmd = new Command("remove", "Remove the CA from trust store and delete certificates from database"); AddDatabaseOptions(cmd); AddPlatformSpecificCAOptions(cmd); cmd.Handler = CommandHandler.Create(HandleRemove); return cmd; } /// /// Creates the 'show' command. /// public static Command CreateShowCommand() { var cmd = new Command("show", "Display current certificate status"); AddDatabaseOptions(cmd); AddPlatformSpecificCAOptions(cmd); cmd.Handler = CommandHandler.Create(HandleShow); return cmd; } /// /// Creates the 'export' command. /// public static Command CreateExportCommand() { var cmd = new Command("export", "Export the server certificate (public key only) to a file") { new Option("--file", "Output file path (defaults to duplicati-server.crt in current directory)"), }; AddDatabaseOptions(cmd); cmd.Handler = CommandHandler.Create(HandleExport); return cmd; } /// /// Creates the 'export-ca' command. /// public static Command CreateExportCaCommand() { var cmd = new Command("export-ca", "Export the CA certificate (public key only) to a file") { new Option("--file", "Output file path (defaults to duplicati-ca.crt in current directory)"), }; AddDatabaseOptions(cmd); cmd.Handler = CommandHandler.Create(HandleExportCa); return cmd; } /// /// Gets the data folder path, either from the option or using the default. /// private static string GetDataFolder(string? dataFolderOption) { if (!string.IsNullOrWhiteSpace(dataFolderOption)) return Path.GetFullPath(dataFolderOption); return DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly); } /// /// Gets the database path for the given data folder. /// private static string GetDatabasePath(string dataFolder) => Path.Combine(dataFolder, DataFolderManager.SERVER_DATABASE_FILENAME); /// /// Opens a connection to the server database. /// private static Connection OpenDatabase(string dataFolder, string? settingsEncryptionKey, bool autoCreateDatabase) { var databasePath = GetDatabasePath(dataFolder); if (!File.Exists(databasePath) && !autoCreateDatabase) throw new UserInformationException($"Database not found: {databasePath}", "DatabaseNotFound"); var opts = new Dictionary(); // Add settings encryption key if provided if (!string.IsNullOrWhiteSpace(settingsEncryptionKey)) opts["settings-encryption-key"] = settingsEncryptionKey; // Create application settings with the specified data folder var appSettings = new DataFolderApplicationSettings(dataFolder); return Server.Program.GetDatabaseConnection(appSettings, opts, true, false); } /// /// Application settings implementation that uses a specific data folder. /// private class DataFolderApplicationSettings : IApplicationSettings { private readonly CancellationTokenSource _applicationExitEvent = new(); public DataFolderApplicationSettings(string dataFolder) { DataFolder = dataFolder; } public bool SettingsEncryptionKeyProvidedExternally { get; set; } public Action? StartOrStopUsageReporter { get; set; } public string DataFolder { get; } public string Origin { get; set; } = "ConfigureTool"; public CancellationToken ApplicationExit => _applicationExitEvent.Token; public ISecretProvider? SecretProvider { get; set; } public void SignalApplicationExit() => _applicationExitEvent.Cancel(); } /// /// Parses the store location option and returns the appropriate StoreLocation value. /// /// The store option string ("local", "user", or null). /// The StoreLocation value, or null to use auto-detection. private static StoreLocation? ParseStoreLocation(string? storeOption) { if (string.IsNullOrWhiteSpace(storeOption)) return null; // Auto-detect return storeOption.ToLowerInvariant() switch { "local" or "machine" or "localmachine" => StoreLocation.LocalMachine, "user" or "currentuser" => StoreLocation.CurrentUser, _ => null // Invalid value, will use auto-detection }; } /// /// Reads CA certificate data from the database connection. /// /// The database connection. /// The CA certificate data, or null if not available. private static CACertificateData? ReadCaData(Connection connection) { var caCert = connection.ApplicationSettings.ServerCACertificate; var caKey = connection.ApplicationSettings.ServerCACertificateKey; var caPassword = connection.ApplicationSettings.ServerCACertificatePassword; if (string.IsNullOrWhiteSpace(caCert) || string.IsNullOrWhiteSpace(caKey) || string.IsNullOrWhiteSpace(caPassword)) return null; return new CACertificateData { CACertificate = caCert, CAKey = caKey, CAPassword = caPassword }; } /// /// Stores generated CA and server certificates in the database. /// /// The database connection. /// The certificate generation result. private static void StoreGeneratedCertificates(Connection connection, CertificateGenerationResult result) { var settings = new Dictionary { [ServerSettings.CONST.SERVER_CA_CERTIFICATE] = result.CACertificate!.CACertificate, [ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = result.CACertificate.CAKey, [ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = result.CACertificate.CAPassword, [ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = result.ServerCertificate!.ServerCertificate, [ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = result.ServerCertificate.Password, [ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true" }; connection.ApplicationSettings.UpdateSettings(settings, false); } /// /// Stores a renewed server certificate in the database. /// /// The database connection. /// The certificate renewal result. private static void StoreRenewedCertificate(Connection connection, CertificateRenewalResult result) { var settings = new Dictionary { [ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = result.RenewedCertificate!.ServerCertificate, [ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = result.RenewedCertificate.Password, [ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = "true" }; connection.ApplicationSettings.UpdateSettings(settings, false); } /// /// Prints the trust installation status to the console. /// /// The trust installation status. /// True if the status indicates a fatal error that should abort the operation. private static bool PrintTrustInstallationStatus(CATrustInstallationStatus? status) { switch (status) { case CATrustInstallationStatus.Success: Console.WriteLine("CA certificate installed successfully."); return false; case CATrustInstallationStatus.AlreadyInstalled: Console.WriteLine("CA certificate was already installed."); return false; case CATrustInstallationStatus.NotSupported: Console.WriteLine("Warning: No trust installer available for this platform."); return false; case CATrustInstallationStatus.RequiresElevation: Console.WriteLine("Error: Administrator/root privileges required to install CA certificate."); Console.WriteLine("Run with elevated permissions or use --no-trust to skip CA installation."); return true; case CATrustInstallationStatus.Failed: Console.WriteLine("Error: Failed to install CA certificate."); return true; default: return false; } } /// /// Prints a database encryption warning if encryption is not enabled. /// /// The database connection. private static void PrintEncryptionWarning(Connection connection) { if (!connection.IsEncryptingFields) { Console.WriteLine(); Console.WriteLine("WARNING: Database field encryption is not enabled."); Console.WriteLine("Since a generated CA is stored in the database this can enable an attacker to issues certificates and enable a man-in-the-middle attack on all HTTPS connections."); } } /// /// Captures log messages generated in the library and forwards them to the console /// /// A disposable log scope private static IDisposable StartConsoleLogScope() => Log.StartScope(entry => { if (entry.Level == LogMessageType.Information) Console.WriteLine(entry.FormattedMessage); else Console.WriteLine($"{entry.Level}: {entry.FormattedMessage}"); }, entry => entry.Level >= LogMessageType.Information); /// /// Handles the 'generate' command. /// Delegates certificate generation to . /// private static int HandleGenerate(string? hostnames, bool noTrust, string? dataFolder, string? settingsEncryptionKey, bool autoCreateDatabase, string? store, string? certDir, string? keychain) { var storeLocation = ParseStoreLocation(store); var dataFolderPath = GetDataFolder(dataFolder); using var _ = StartConsoleLogScope(); Console.WriteLine($"Using data folder: {dataFolderPath}"); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, autoCreateDatabase); var result = CertificateConfigurationHelper.GenerateCertificates( connection.ApplicationSettings.ServerSSLCertificateAutogenerated, connection.ApplicationSettings.ServerSSLCertificate, ReadCaData(connection), hostnames, noTrust, storeLocation, certDir, keychain); if (!result.Success) { Console.WriteLine($"Error: Failed to generate HTTPS certificates."); if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) Console.WriteLine($"Reason: {result.ErrorMessage}"); return 1; } // Check if certificates were already valid (no new certs generated) if (result.CACertificate == null || result.ServerCertificate == null) { Console.WriteLine("Valid certificates already exist."); Console.WriteLine("Use 'regenerate-ca' to force regeneration or 'renew' to renew the server certificate."); return 0; } // Check trust installation status if (PrintTrustInstallationStatus(result.TrustInstallationStatus)) return 1; // Store in database Console.WriteLine("Storing certificates in database..."); StoreGeneratedCertificates(connection, result); PrintEncryptionWarning(connection); Console.WriteLine(); Console.WriteLine("HTTPS certificates generated and stored successfully."); return 0; } /// /// Handles the 'renew' command. /// Delegates certificate renewal to . /// private static int HandleRenew(string? dataFolder, string? settingsEncryptionKey) { var dataFolderPath = GetDataFolder(dataFolder); Console.WriteLine($"Using data folder: {dataFolderPath}"); using var _ = StartConsoleLogScope(); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, false); // Read existing CA data var caData = ReadCaData(connection); if (caData == null) throw new UserInformationException("No existing CA certificate found in database. Use 'generate' to create new certificates.", "CANotFound"); Console.WriteLine("Renewing server certificate..."); var result = CertificateConfigurationHelper.RenewServerCertificate(caData); if (!result.Renewed) { Console.WriteLine($"Error: Failed to renew server certificate."); if (!string.IsNullOrWhiteSpace(result.RenewalFailedReason)) Console.WriteLine($"Reason: {result.RenewalFailedReason}"); return 1; } // Store new server certificate in database Console.WriteLine("Storing new server certificate in database..."); StoreRenewedCertificate(connection, result); Console.WriteLine(); Console.WriteLine("Server certificate renewed successfully."); return 0; } /// /// Handles the 'regenerate-ca' command. /// Delegates to . /// private static int HandleRegenerateCa(string? hostnames, bool noTrust, string? dataFolder, string? settingsEncryptionKey, string? store, string? certDir, string? keychain) { var storeLocation = ParseStoreLocation(store); var dataFolderPath = GetDataFolder(dataFolder); Console.WriteLine($"Using data folder: {dataFolderPath}"); using var _ = StartConsoleLogScope(); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, false); var existingCaCertBase64 = connection.ApplicationSettings.ServerCACertificate; var result = CertificateConfigurationHelper.RegenerateCACertificates( existingCaCertBase64, hostnames, noTrust, storeLocation, certDir, keychain); if (!result.Success) { Console.WriteLine($"Error: Failed to regenerate HTTPS certificates."); if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) Console.WriteLine($"Reason: {result.ErrorMessage}"); return 1; } // Check trust installation status if (PrintTrustInstallationStatus(result.TrustInstallationStatus)) return 1; // Store in database Console.WriteLine("Storing certificates in database..."); StoreGeneratedCertificates(connection, result); Console.WriteLine(); Console.WriteLine("CA and server certificates regenerated successfully."); return 0; } /// /// Handles the 'remove' command. /// private static int HandleRemove(string? dataFolder, string? settingsEncryptionKey, string? store, string? certDir, string? keychain) { var storeLocation = ParseStoreLocation(store); var dataFolderPath = GetDataFolder(dataFolder); Console.WriteLine($"Using data folder: {dataFolderPath}"); using var _ = StartConsoleLogScope(); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, false); // Get existing CA certificate and remove from trust store var caCertBase64 = connection.ApplicationSettings.ServerCACertificate; if (!string.IsNullOrWhiteSpace(caCertBase64)) { try { var caCert = CertificateStorageHelper.DeserializeCertificate(caCertBase64); Console.WriteLine("Removing CA certificate from system trust store..."); if (CertificateConfigurationHelper.IsCATrustInstalled(caCert, storeLocation, certDir, keychain)) { if (CertificateConfigurationHelper.RemoveCATrust(caCert, storeLocation, certDir, keychain)) Console.WriteLine("CA certificate removed from trust store."); else Console.WriteLine("Warning: Failed to remove CA certificate from trust store."); } else { Console.WriteLine("CA certificate was not found in trust store."); } } catch (Exception ex) { Console.WriteLine($"Warning: Could not remove CA from trust store: {ex.Message}"); } } // Remove all certificate data from database Console.WriteLine("Removing certificate data from database..."); var settings = new Dictionary { [ServerSettings.CONST.SERVER_CA_CERTIFICATE] = null, [ServerSettings.CONST.SERVER_CA_CERTIFICATE_KEY] = null, [ServerSettings.CONST.SERVER_CA_CERTIFICATE_PASSWORD] = null, [ServerSettings.CONST.SERVER_SSL_CERTIFICATE] = null, [ServerSettings.CONST.SERVER_SSL_CERTIFICATEPASSWORD] = null, [ServerSettings.CONST.SERVER_SSL_CERTIFICATE_AUTOGENERATED] = null }; connection.ApplicationSettings.UpdateSettings(settings, false); Console.WriteLine(); Console.WriteLine("HTTPS certificates removed successfully."); return 0; } /// /// Handles the 'show' command. /// Delegates status retrieval to . /// private static int HandleShow(string? dataFolder, string? settingsEncryptionKey, string? store, string? certDir, string? keychain) { var storeLocation = ParseStoreLocation(store); var dataFolderPath = GetDataFolder(dataFolder); Console.WriteLine($"Using data folder: {dataFolderPath}"); Console.WriteLine(); using var _ = StartConsoleLogScope(); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, false); var caCertBase64 = connection.ApplicationSettings.ServerCACertificate; var serverCertCollection = connection.ApplicationSettings.ServerSSLCertificate; var isAutogenerated = connection.ApplicationSettings.ServerSSLCertificateAutogenerated; // Check if certificates exist if (string.IsNullOrWhiteSpace(caCertBase64) && serverCertCollection == null) { Console.WriteLine("No HTTPS certificates configured."); Console.WriteLine("Use 'generate' command to create certificates."); return 0; } var status = CertificateConfigurationHelper.GetCertificateStatus( caCertBase64, serverCertCollection, isAutogenerated, storeLocation, certDir, keychain); // Display CA certificate info Console.WriteLine("=== CA Certificate ==="); if (status.CACert == null) { Console.WriteLine("No CA certificate stored."); } else { Console.WriteLine($"Subject: {status.CACert.Subject}"); Console.WriteLine($"Issuer: {status.CACert.Issuer}"); Console.WriteLine($"Valid from: {status.CACert.NotBefore:yyyy-MM-dd}"); Console.WriteLine($"Valid until: {status.CACert.NotAfter:yyyy-MM-dd}"); var caStatus = status.CaDaysUntilExpiry <= 0 ? "EXPIRED" : status.CaDaysUntilExpiry <= CertificateRenewalChecker.RENEWAL_THRESHOLD_DAYS ? "EXPIRING SOON" : "Valid"; Console.WriteLine($"Status: {caStatus}"); Console.WriteLine($"Trust store: {(status.IsCATrusted ? "Installed" : "Not installed")}"); } Console.WriteLine(); // Display server certificate info Console.WriteLine("=== Server Certificate ==="); if (status.ServerCert == null) { if (serverCertCollection != null) Console.WriteLine("Server certificate collection exists but no certificate with private key found."); else Console.WriteLine("No server certificate stored."); } else { Console.WriteLine($"Subject: {status.ServerCert.Subject}"); Console.WriteLine($"Issuer: {status.ServerCert.Issuer}"); Console.WriteLine($"Valid from: {status.ServerCert.NotBefore:yyyy-MM-dd}"); Console.WriteLine($"Valid until: {status.ServerCert.NotAfter:yyyy-MM-dd}"); var serverStatus = status.ServerDaysUntilExpiry <= 0 ? "EXPIRED" : status.ServerDaysUntilExpiry <= CertificateRenewalChecker.RENEWAL_THRESHOLD_DAYS ? "EXPIRING SOON" : "Valid"; Console.WriteLine($"Status: {serverStatus}"); if (status.DnsNames.Any()) Console.WriteLine($"DNS names: {string.Join(", ", status.DnsNames)}"); if (status.IpAddresses.Any()) Console.WriteLine($"IP addresses: {string.Join(", ", status.IpAddresses)}"); Console.WriteLine($"Autogenerated: {status.IsAutogenerated}"); } Console.WriteLine(); // Display database encryption status Console.WriteLine("=== Security ==="); Console.WriteLine($"Database field encryption: {(connection.IsEncryptingFields ? "Enabled" : "Disabled")}"); return 0; } /// /// Handles the 'export' command. /// private static int HandleExport(string? file, string? dataFolder, string? settingsEncryptionKey) { var dataFolderPath = GetDataFolder(dataFolder); var outputFile = string.IsNullOrWhiteSpace(file) ? "duplicati-server.crt" : file; Console.WriteLine($"Using data folder: {dataFolderPath}"); Console.WriteLine($"Exporting server certificate to: {Path.GetFullPath(outputFile)}"); using var _ = StartConsoleLogScope(); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, false); var serverCertCollection = connection.ApplicationSettings.ServerSSLCertificate; if (serverCertCollection == null || serverCertCollection.Count == 0) { Console.WriteLine("Error: No server certificate found in database."); return 1; } try { var serverCert = serverCertCollection.Cast().FirstOrDefault(c => c.HasPrivateKey) ?? serverCertCollection[0]; var pem = CertificateStorageHelper.ExportToPem(serverCert); File.WriteAllText(outputFile, pem); Console.WriteLine($"Server certificate exported successfully."); Console.WriteLine($"Subject: {serverCert.Subject}"); Console.WriteLine($"Valid until: {serverCert.NotAfter:yyyy-MM-dd}"); return 0; } catch (Exception ex) { Console.WriteLine($"Error exporting certificate: {ex.Message}"); return 1; } } /// /// Handles the 'export-ca' command. /// private static int HandleExportCa(string? file, string? dataFolder, string? settingsEncryptionKey) { var dataFolderPath = GetDataFolder(dataFolder); var outputFile = string.IsNullOrWhiteSpace(file) ? "duplicati-ca.crt" : file; Console.WriteLine($"Using data folder: {dataFolderPath}"); Console.WriteLine($"Exporting CA certificate to: {Path.GetFullPath(outputFile)}"); using var _ = StartConsoleLogScope(); using var connection = OpenDatabase(dataFolderPath, settingsEncryptionKey, false); var caCertBase64 = connection.ApplicationSettings.ServerCACertificate; if (string.IsNullOrWhiteSpace(caCertBase64)) { Console.WriteLine("Error: No CA certificate found in database."); return 1; } try { var caCert = CertificateStorageHelper.DeserializeCertificate(caCertBase64); var pem = CertificateStorageHelper.ExportToPem(caCert); File.WriteAllText(outputFile, pem); Console.WriteLine($"CA certificate exported successfully."); Console.WriteLine($"Subject: {caCert.Subject}"); Console.WriteLine($"Valid until: {caCert.NotAfter:yyyy-MM-dd}"); return 0; } catch (Exception ex) { Console.WriteLine($"Error exporting certificate: {ex.Message}"); return 1; } } }