259 lines
11 KiB
C#
259 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Runtime.ConstrainedExecution;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Threading.Tasks;
|
|
using System.IO;
|
|
using Duplicati.Server.Database;
|
|
using Microsoft.AspNetCore.Connections;
|
|
|
|
namespace Duplicati.Server;
|
|
|
|
/// <summary>
|
|
/// Helper class for starting the webserver
|
|
/// </summary>
|
|
public static class WebServerLoader
|
|
{
|
|
/// <summary>
|
|
/// The tag used for logging
|
|
/// </summary>
|
|
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(typeof(WebServerLoader));
|
|
|
|
/// <summary>
|
|
/// Option for changing the webroot folder
|
|
/// </summary>
|
|
public const string OPTION_WEBROOT = "webservice-webroot";
|
|
|
|
/// <summary>
|
|
/// Option for changing the webservice listen port
|
|
/// </summary>
|
|
public const string OPTION_PORT = "webservice-port";
|
|
|
|
/// <summary>
|
|
/// Option for changing the webservice listen interface
|
|
/// </summary>
|
|
public const string OPTION_INTERFACE = "webservice-interface";
|
|
|
|
/// <summary>
|
|
/// Option for setting the webservice password
|
|
/// </summary>
|
|
public const string OPTION_WEBSERVICE_PASSWORD = "webservice-password";
|
|
|
|
/// <summary>
|
|
/// Option for resetting the JWT configuration
|
|
/// </summary>
|
|
public const string OPTION_WEBSERVICE_RESET_JWT_CONFIG = "webservice-reset-jwt-config";
|
|
|
|
/// <summary>
|
|
/// Option for disabling the visual captcha
|
|
/// </summary>
|
|
public const string OPTION_WEBSERVICE_DISABLE_VISUAL_CAPTCHA = "webservice-disable-visual-captcha";
|
|
|
|
/// <summary>
|
|
/// Option for setting the webservice allowed hostnames
|
|
/// </summary>
|
|
public const string OPTION_WEBSERVICE_ALLOWEDHOSTNAMES = "webservice-allowed-hostnames";
|
|
/// <summary>
|
|
/// Option for setting the webservice allowed hostnames, alternative name
|
|
/// </summary>
|
|
public const string OPTION_WEBSERVICE_ALLOWEDHOSTNAMES_ALT = "webservice-allowedhostnames";
|
|
|
|
/// <summary>
|
|
/// The default path to the web root
|
|
/// </summary>
|
|
public const string DEFAULT_OPTION_WEBROOT = "webroot";
|
|
|
|
/// <summary>
|
|
/// The default listening port
|
|
/// </summary>
|
|
public const int DEFAULT_OPTION_PORT = 8200;
|
|
|
|
/// <summary>
|
|
/// The default certificate file for https
|
|
/// </summary>
|
|
public const string DEFAULT_OPTION_CERTIFICATEFILE = "server-webui.pfx";
|
|
|
|
/// <summary>
|
|
/// Option for setting if to use HTTPS
|
|
/// </summary>
|
|
public const string OPTION_USEHTTPS = "webservice-usehttps";
|
|
|
|
/// <summary>
|
|
/// Option for setting the webservice SSL certificate
|
|
/// </summary>
|
|
public const string OPTION_SSLCERTIFICATEFILE = "webservice-sslcertificatefile";
|
|
|
|
/// <summary>
|
|
/// Option for setting the webservice SSL certificate key
|
|
/// </summary>
|
|
public const string OPTION_SSLCERTIFICATEFILEPASSWORD = "webservice-sslcertificatepassword";
|
|
|
|
/// <summary>
|
|
/// The default listening interface
|
|
/// </summary>
|
|
public const string DEFAULT_OPTION_INTERFACE = "loopback";
|
|
|
|
/// <summary>
|
|
/// The parsed settings for the webserver
|
|
/// </summary>
|
|
/// <param name="WebRoot">The root folder with static files</param>
|
|
/// <param name="Port">The listining port</param>
|
|
/// <param name="Interface">The listening interface</param>
|
|
/// <param name="HTTPS">If to use HTTPS</param>
|
|
/// <param name="CertificateFile">Path to certificate file, if any</param>
|
|
/// <param name="CertificatePassword">Password to the certificate, if any</param>
|
|
/// <param name="Servername">The servername to report</param>
|
|
/// <param name="AllowedHostnames">The allowed hostnames</param>
|
|
public record ParsedWebserverSettings(
|
|
string WebRoot,
|
|
int Port,
|
|
System.Net.IPAddress Interface,
|
|
bool HTTPS,
|
|
string? CertificateFile,
|
|
string? CertificatePassword,
|
|
string Servername,
|
|
IEnumerable<string> AllowedHostnames
|
|
);
|
|
|
|
|
|
/// <summary>
|
|
/// Sets up the webserver and starts it
|
|
/// </summary>
|
|
/// <param name="options">A set of options</param>
|
|
/// <param name="createServer">The method to start the server</param>
|
|
public static async Task<TServer> TryRunServer<TServer>(IReadOnlyDictionary<string, string> options, Connection connection, Func<ParsedWebserverSettings, Task<TServer>> createServer)
|
|
{
|
|
var ports = Enumerable.Empty<int>();
|
|
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);
|
|
|
|
options.TryGetValue(OPTION_USEHTTPS, out var usehttps);
|
|
options.TryGetValue(OPTION_SSLCERTIFICATEFILE, out var certificateFile);
|
|
options.TryGetValue(OPTION_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))
|
|
{
|
|
var cert = new X509Certificate2(certificateFile, certificateFilePassword, X509KeyStorageFlags.Exportable);
|
|
|
|
connection.ApplicationSettings.ServerSSLCertificate = Convert.ToBase64String(cert.Export(X509ContentType.Pkcs12, connection.ApplicationSettings.ServerSSLCertificatePassword));
|
|
connection.ApplicationSettings.ServerSSLCertificatePassword = Guid.NewGuid().ToString().ToLowerInvariant();
|
|
}
|
|
else if (certificateFile != null && certificateFile.Length == 0)
|
|
{
|
|
connection.ApplicationSettings.ServerSSLCertificate = null;
|
|
connection.ApplicationSettings.ServerSSLCertificatePassword = null;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(connection.ApplicationSettings.ServerSSLCertificate))
|
|
{
|
|
File.WriteAllBytes(Path.Combine(Program.DataFolder, DEFAULT_OPTION_CERTIFICATEFILE), Convert.FromBase64String(connection.ApplicationSettings.ServerSSLCertificate));
|
|
|
|
//backward compatible check for installations before OPTION_USEHTTPS
|
|
if (usehttps == null && string.IsNullOrEmpty(connection.ApplicationSettings.ServerSSLCertificatePassword))
|
|
connection.ApplicationSettings.ServerUseHTTPS = true;
|
|
}
|
|
else
|
|
{
|
|
if (File.Exists(Path.Combine(Program.DataFolder, DEFAULT_OPTION_CERTIFICATEFILE)))
|
|
File.Delete(Path.Combine(Program.DataFolder, DEFAULT_OPTION_CERTIFICATEFILE));
|
|
}
|
|
|
|
if (usehttps != null)
|
|
connection.ApplicationSettings.ServerUseHTTPS = bool.Parse(usehttps);
|
|
|
|
if(connection.ApplicationSettings.ServerUseHTTPS && connection.ApplicationSettings.ServerSSLCertificate == null)
|
|
throw new ArgumentException(Strings.Server.SSLParametersMismatch);
|
|
|
|
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))
|
|
{
|
|
string 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
|
|
}
|
|
|
|
var settings = new ParsedWebserverSettings(
|
|
webroot,
|
|
-1,
|
|
listenInterface,
|
|
connection.ApplicationSettings.ServerUseHTTPS,
|
|
Path.Combine(Program.DataFolder, DEFAULT_OPTION_CERTIFICATEFILE),
|
|
connection.ApplicationSettings.ServerSSLCertificatePassword,
|
|
string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version),
|
|
(connection.ApplicationSettings.AllowedHostnames ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
|
|
);
|
|
|
|
// 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 (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));
|
|
}
|
|
} |