// 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;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Duplicati.Server.Database;
using Microsoft.AspNetCore.Connections;
using Duplicati.Library.Utility;
#nullable enable
namespace Duplicati.Server;
///
/// Helper class for starting the webserver
///
public static class WebServerLoader
{
///
/// The tag used for logging
///
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(typeof(WebServerLoader));
///
/// Option for changing the webroot folder
///
public const string OPTION_WEBROOT = "webservice-webroot";
///
/// Option for changing the webservice listen port
///
public const string OPTION_PORT = "webservice-port";
///
/// Option for changing the webservice listen interface
///
public const string OPTION_INTERFACE = "webservice-interface";
///
/// Option for setting the webservice password
///
public const string OPTION_WEBSERVICE_PASSWORD = "webservice-password";
///
/// Option for resetting the JWT configuration
///
public const string OPTION_WEBSERVICE_RESET_JWT_CONFIG = "webservice-reset-jwt-config";
///
/// Option for enabling the forever token
///
public const string OPTION_WEBSERVICE_ENABLE_FOREVER_TOKEN = "webservice-enable-forever-token";
///
/// Option for disabling the visual captcha
///
public const string OPTION_WEBSERVICE_DISABLE_VISUAL_CAPTCHA = "webservice-disable-visual-captcha";
///
/// Option for setting the webservice allowed hostnames
///
public const string OPTION_WEBSERVICE_ALLOWEDHOSTNAMES = "webservice-allowed-hostnames";
///
/// Option for setting the webservice allowed hostnames, alternative name
///
public const string OPTION_WEBSERVICE_ALLOWEDHOSTNAMES_ALT = "webservice-allowedhostnames";
///
/// Option for removing the hosted static files from the server
///
public const string OPTION_WEBSERVICE_API_ONLY = "webservice-api-only";
///
/// Option for disabling the use of signin tokens
///
public const string OPTION_WEBSERVICE_DISABLE_SIGNIN_TOKENS = "webservice-disable-signin-tokens";
///
/// Option for setting the webservice SPA paths
///
public const string OPTION_WEBSERVICE_SPAPATHS = "webservice-spa-paths";
///
/// The CORS origins to allow
///
public const string OPTION_WEBSERVICE_CORS_ORIGINS = "webservice-cors-origins";
///
/// Option for setting the webservice timezone
///
public const string OPTION_WEBSERVICE_TIMEZONE = "webservice-timezone";
///
/// The default path to the web root
///
public const string DEFAULT_OPTION_WEBROOT = "webroot";
///
/// The default paths to serve as SPAs
///
public const string DEFAULT_OPTION_SPAPATHS = "/ngclient";
///
/// The default listening port
///
public const int DEFAULT_OPTION_PORT = 8200;
///
/// Option for setting if to use HTTPS
///
public const string OPTION_WEBSERVICE_DISABLEHTTPS = "webservice-disable-https";
///
/// Option for removing the SSL certificate from the datbase
///
public const string OPTION_WEBSERVICE_REMOVESSLCERTIFICATE = "webservice-remove-sslcertificate";
///
/// Option for setting the webservice SSL certificate
///
public const string OPTION_WEBSERVICE_SSLCERTIFICATEFILE = "webservice-sslcertificatefile";
///
/// Option for setting the webservice SSL certificate key
///
public const string OPTION_WEBSERVICE_SSLCERTIFICATEFILEPASSWORD = "webservice-sslcertificatepassword";
///
/// The default listening interface
///
public const string DEFAULT_OPTION_INTERFACE = "loopback";
///
/// The parsed settings for the webserver
///
/// The root folder with static files
/// The listining port
/// The listening interface
/// SSL certificate, if any
/// The servername to report
/// The allowed hostnames
/// If static files should be disabled
/// The paths to serve as SPAs
/// The origins to allow for CORS
public record ParsedWebserverSettings(
string WebRoot,
int Port,
System.Net.IPAddress Interface,
X509Certificate2Collection? Certificate,
string Servername,
IEnumerable AllowedHostnames,
bool DisableStaticFiles,
IEnumerable SPAPaths,
IEnumerable CorsOrigins
);
///
/// Sets up the webserver and starts it
///
/// A set of options
/// The method to start the server
public static async Task TryRunServer(IReadOnlyDictionary options, Connection connection, Func> createServer)
{
var ports = Enumerable.Empty();
options.TryGetValue(OPTION_PORT, out var portstring);
if (!string.IsNullOrEmpty(portstring))
ports =
from n in portstring.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
where int.TryParse(n, out _)
select int.Parse(n);
if (ports == null || !ports.Any())
ports = [DEFAULT_OPTION_PORT];
options.TryGetValue(OPTION_INTERFACE, out var interfacestring);
if (string.IsNullOrWhiteSpace(interfacestring))
interfacestring = connection.ApplicationSettings.ServerListenInterface;
if (string.IsNullOrWhiteSpace(interfacestring))
interfacestring = DEFAULT_OPTION_INTERFACE;
var listenInterface = System.Net.IPAddress.Loopback;
interfacestring = interfacestring.Trim();
if (new[] { "*", "all", "any" }.Any(x => x.Equals(interfacestring, StringComparison.OrdinalIgnoreCase)))
listenInterface = System.Net.IPAddress.Any;
else if (interfacestring != "loopback")
listenInterface = System.Net.IPAddress.Parse(interfacestring);
var removeCertificate = Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_REMOVESSLCERTIFICATE);
connection.ApplicationSettings.DisableHTTPS = removeCertificate || Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_DISABLEHTTPS);
options.TryGetValue(OPTION_WEBSERVICE_SSLCERTIFICATEFILE, out var certificateFile);
options.TryGetValue(OPTION_WEBSERVICE_SSLCERTIFICATEFILEPASSWORD, out var certificateFilePassword);
certificateFilePassword = certificateFilePassword?.Trim();
if (string.IsNullOrEmpty(certificateFile) && !string.IsNullOrEmpty(certificateFilePassword))
Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerCertificate", Strings.Server.SSLCertificateFileMissingOption);
if (!string.IsNullOrEmpty(certificateFile) && !string.IsNullOrEmpty(certificateFilePassword))
connection.ApplicationSettings.ServerSSLCertificate = Utility.LoadPfxCertificate(certificateFile, certificateFilePassword);
else if (removeCertificate)
connection.ApplicationSettings.ServerSSLCertificate = null;
var webroot = Library.AutoUpdater.UpdaterManager.INSTALLATIONDIR;
#if DEBUG
//For debug we go "../../../../../.." to get out of "Executables/net8/Duplicati.GUI.TrayIcon/bin/debug/net8.0"
string tmpwebroot = System.IO.Path.GetFullPath(System.IO.Path.Combine(webroot, "..", "..", "..", "..", "..", ".."));
tmpwebroot = System.IO.Path.Combine(tmpwebroot, "Duplicati", "Server");
if (System.IO.Directory.Exists(System.IO.Path.Combine(tmpwebroot, "webroot")))
webroot = tmpwebroot;
#endif
webroot = System.IO.Path.Combine(webroot, "webroot");
if (options.ContainsKey(OPTION_WEBROOT))
{
var userroot = options[OPTION_WEBROOT];
#if DEBUG
//In debug mode we do not care where the path points
#else
//In release mode we check that the user supplied path is located
// in the same folders as the running application, to avoid users
// that inadvertently expose top level folders
if (!string.IsNullOrWhiteSpace(userroot) && userroot.StartsWith(Duplicati.Library.Common.IO.Util.AppendDirSeparator(Duplicati.Library.Utility.Utility.getEntryAssembly().Location), Library.Utility.Utility.ClientFilenameStringComparison))
webroot = userroot;
#endif
}
options.TryGetValue(OPTION_WEBSERVICE_SPAPATHS, out var spaPathsString);
if (string.IsNullOrWhiteSpace(spaPathsString))
spaPathsString = DEFAULT_OPTION_SPAPATHS;
var settings = new ParsedWebserverSettings(
webroot,
-1,
listenInterface,
connection.ApplicationSettings.UseHTTPS ? connection.ApplicationSettings.ServerSSLCertificate : null,
string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, Library.AutoUpdater.UpdaterManager.SelfVersion.Version),
(connection.ApplicationSettings.AllowedHostnames ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries),
Duplicati.Library.Utility.Utility.ParseBoolOption(options, OPTION_WEBSERVICE_API_ONLY),
spaPathsString.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries),
options.GetValueOrDefault(OPTION_WEBSERVICE_CORS_ORIGINS)?.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty()
);
// Materialize the list of ports, and move the last-used port to the front, so we try the last-known port first
ports = ports.ToList();
if (ports.Contains(connection.ApplicationSettings.LastWebserverPort))
ports = ports.Where(x => x != connection.ApplicationSettings.LastWebserverPort).Prepend(connection.ApplicationSettings.LastWebserverPort).ToList();
// If we are in hosted mode with no specified port,
// then try different ports
foreach (var p in ports)
try
{
settings = settings with { Port = p };
var server = await createServer(settings);
// If we get here, the server started successfully, so store the new interface setting
if (interfacestring != connection.ApplicationSettings.ServerListenInterface)
connection.ApplicationSettings.ServerListenInterface = interfacestring;
Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerListening", Strings.Server.StartedServer(listenInterface.ToString(), p));
return server;
}
catch (Exception ex) when
(ex is System.Net.Sockets.SocketException { SocketErrorCode: System.Net.Sockets.SocketError.AddressAlreadyInUse }
|| ex is System.IO.IOException { InnerException: AddressInUseException })
{ }
throw new Exception(Strings.Server.ServerStartFailure(ports));
}
}