// 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. #nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Security; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using CoCoL; using Duplicati.Library.Common.IO; using Duplicati.Server.Serialization; using Duplicati.Server.Serialization.Interface; using Duplicati.WebserverCore; using Duplicati.WebserverCore.Abstractions; using Duplicati.WebserverCore.Middlewares; using Microsoft.Extensions.DependencyInjection; namespace Duplicati.GUI.TrayIcon { public class HttpServerConnection : IDisposable { private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); private const string LONGPOLL_TIMEOUT = "5m"; private readonly HttpClient HTTPCLIENT; private record ServerStatusImpl( LiveControlState ProgramState, SuggestedStatusIcon SuggestedStatusIcon, long LastEventID, long LastDataUpdateID, long LastNotificationUpdateID ) : IServerStatus; private record NotificationImpl( long ID, Server.Serialization.NotificationType Type, string Title, string Message, string Exception, string BackupID, string Action, DateTime Timestamp, string LogEntryID, string MessageID, string MessageLogTag ) : INotification; private readonly JsonSerializerOptions serializerOptions = new() { PropertyNamingPolicy = null, Converters = { new JsonStringEnumConverter(), new DayOfWeekStringEnumConverter() } }; private const string STATUS_WINDOW = "index.html"; private const string SIGNIN_WINDOW = "signin.html"; private record BackgroundRequest(string Method, string Endpoint, string? Body, TimeSpan? Timeout = null); private readonly string m_apiUri; private readonly string m_baseUri; private string m_password; private string? m_accesstoken; private bool m_isTryingWithPassword; public Func? OnStatusUpdated; public Action? ConnectionClosed; private int _connectionClosedInvoked; public long m_lastNotificationId = -1; public DateTime m_firstNotificationTime; public delegate void NewNotificationDelegate(INotification notification); public event NewNotificationDelegate? OnNotification; private long m_lastEventId = 0; private long m_lastDataUpdateId = -1; private bool m_disableTrayIconLogin; private volatile IServerStatus m_status = new ServerStatusImpl(LiveControlState.Paused, SuggestedStatusIcon.Disconnected, 0, 0, 0); private Task m_requestHandlerTask; private Task m_pollThread; private readonly CancellationTokenSource m_stopToken = new CancellationTokenSource(); private readonly Dictionary m_options; private readonly Program.PasswordSource m_passwordSource; public IServerStatus Status { get { return m_status; } } private readonly IChannel m_workQueue = Channel.Create(name: "TrayIconRequestQueue"); private readonly CancellationToken _applicationExitEvent; public HttpServerConnection(IApplicationSettings? applicationSettings, System.Uri server, string password, Program.PasswordSource passwordSource, bool disableTrayIconLogin, string acceptedHostCertificate, Dictionary options) { _applicationExitEvent = applicationSettings?.ApplicationExit ?? CancellationToken.None; m_baseUri = Util.AppendDirSeparator(server.ToString(), "/"); m_apiUri = m_baseUri + "api/v1"; m_disableTrayIconLogin = disableTrayIconLogin; m_firstNotificationTime = DateTime.Now; m_password = password; m_options = options; m_passwordSource = passwordSource; var acceptedCertificates = new HashSet(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(acceptedHostCertificate)) acceptedCertificates.UnionWith(acceptedHostCertificate.Split(new char[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); HTTPCLIENT = new(new HttpClientHandler { ServerCertificateCustomValidationCallback = acceptedCertificates switch { { Count: 0 } or null => null, { } when acceptedCertificates.Contains("*") => HttpClientHandler .DangerousAcceptAnyServerCertificateValidator, _ => (sender, cert, chain, sslPolicyErrors) => { if (sslPolicyErrors == SslPolicyErrors.None) return true; if (cert == null) return false; var certHash = cert.GetCertHashString(); return acceptedCertificates.Contains(certHash); } } }) { // Max time a request can be pending, actual requests can set a lower limit Timeout = Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) + TimeSpan.FromSeconds(10), DefaultRequestHeaders = { UserAgent = { new ProductInfoHeaderValue("Duplicati-TrayIcon-Monitor", System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0") } } }; m_requestHandlerTask = ThreadRunner(); m_pollThread = LongPollRunner(); m_requestHandlerTask.ContinueWith(t => { if (t.IsFaulted) Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", t.Exception, "Crashed request handler"); if (Interlocked.CompareExchange(ref _connectionClosedInvoked, 1, 0) == 0) ConnectionClosed?.Invoke(); }); m_pollThread.ContinueWith(t => { if (t.IsFaulted) Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", t.Exception, "Crashed poll thread"); if (Interlocked.CompareExchange(ref _connectionClosedInvoked, 1, 0) == 0) ConnectionClosed?.Invoke(); }); } public Task UpdateStatus() => UpdateStatusAsync(false); private async Task UpdateStatusAsync(bool longpoll) { var query = longpoll ? $"?longpoll=true&lastEventId={m_lastEventId}&duration={LONGPOLL_TIMEOUT}" : ""; m_status = await PerformRequestAsync("GET", $"/serverstate{query}", null, longpoll ? Library.Utility.Timeparser.ParseTimeSpan(LONGPOLL_TIMEOUT) : null); m_lastEventId = m_status.LastEventID; if (OnStatusUpdated != null) await OnStatusUpdated(m_status).ConfigureAwait(false); if (m_lastNotificationId != m_status.LastNotificationUpdateID) { m_lastNotificationId = m_status.LastNotificationUpdateID; await UpdateNotificationsAsync().ConfigureAwait(false); } if (m_lastDataUpdateId != m_status.LastDataUpdateID) { m_lastDataUpdateId = m_status.LastDataUpdateID; await UpdateApplicationSettingsAsync().ConfigureAwait(false); } } private async Task UpdateNotificationsAsync() { var notifications = await PerformRequestAsync("GET", "/notifications", null, null) .ConfigureAwait(false); if (notifications != null) { foreach (var n in notifications.Where(x => x.Timestamp > m_firstNotificationTime)) if (OnNotification != null) OnNotification(n); if (notifications.Any()) m_firstNotificationTime = notifications.Select(x => x.Timestamp).Max(); } } private async Task UpdateApplicationSettingsAsync() { var settings = await PerformRequestAsync>("GET", "/serversettings", null, null) .ConfigureAwait(false); if (settings != null && settings.TryGetValue("disable-tray-icon-login", out var str)) m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false); } private async Task LongPollRunner() { var started = DateTime.Now; var errorCount = 0; var hasConnected = false; while (!m_stopToken.IsCancellationRequested && !_applicationExitEvent.IsCancellationRequested) { try { var waitTime = TimeSpan.FromSeconds(Math.Min(10, errorCount * 2)) - (DateTime.Now - started); if (waitTime.TotalSeconds > 0) { // Wait for the specified time or until stop or exit is requested using var cts = CancellationTokenSource.CreateLinkedTokenSource(m_stopToken.Token, _applicationExitEvent); await Task.Delay(waitTime, cts.Token).ConfigureAwait(false); } started = DateTime.Now; await UpdateStatusAsync(true).ConfigureAwait(false); errorCount = 0; hasConnected = true; } catch (Exception ex) when (ex.IsRetiredException() || m_stopToken.IsCancellationRequested || _applicationExitEvent.IsCancellationRequested) { Library.Logging.Log.WriteVerboseMessage(LOGTAG, "TrayIconPollRequestError", ex, "Failed to get response"); return; } catch (Exception ex) { errorCount++; // More than 1 error, and we are disconnected if (errorCount >= 2 && OnStatusUpdated != null || !hasConnected) { if (OnStatusUpdated != null) await OnStatusUpdated.Invoke(new ServerStatusImpl( LiveControlState.Paused, SuggestedStatusIcon.Disconnected, m_lastEventId, m_lastDataUpdateId, m_lastNotificationId)).ConfigureAwait(false); } System.Diagnostics.Trace.WriteLine("Request error: " + ex.Message); Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconPollRequestError", ex, "Failed to get response"); } } } private async Task ThreadRunner() { while (true) { try { var req = await m_workQueue.ReadAsync().ConfigureAwait(false); await PerformRequestAsync(req.Method, req.Endpoint, req.Body, req.Timeout) .ConfigureAwait(false); } catch (Exception ex) when (ex.IsRetiredException() || m_stopToken.IsCancellationRequested) { Library.Logging.Log.WriteVerboseMessage(LOGTAG, "TrayIconRequestError", ex, "Failed to get response"); return; } catch (Exception ex) { System.Diagnostics.Trace.WriteLine("Request error: " + ex.Message); Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", ex, "Failed to get response"); } } } public void Close() { m_stopToken.Cancel(); m_workQueue.Retire(); if (Interlocked.CompareExchange(ref _connectionClosedInvoked, 1, 0) == 0) ConnectionClosed?.Invoke(); } private sealed record SigninResponse(string AccessToken); private async Task PerformRequestAsync(string method, string urlfragment, string? body, TimeSpan? timeout) { if (string.IsNullOrWhiteSpace(m_accesstoken) && !urlfragment.StartsWith("/auth/")) await ObtainAccessTokenAsync().ConfigureAwait(false); var hasTriedPassword = m_isTryingWithPassword; while (true) { try { return await PerformRequestInternalAsync(method, urlfragment, body, timeout) .ConfigureAwait(false); } catch (Exception ex) when (ex is HttpRequestException hex && (hex.StatusCode == HttpStatusCode.Unauthorized || hex.StatusCode == HttpStatusCode.Forbidden) || ex is AggregateException aex && aex.InnerExceptions.Any(x => x is HttpRequestException hex && (hex.StatusCode == HttpStatusCode.Unauthorized || hex.StatusCode == HttpStatusCode.Forbidden)) ) { if (hasTriedPassword) throw; // TODO: This error handling is error prone and can end up in infinite recursion // Should rewrite the entire class and use websockets instead // Only try once, and clear the token for the next try hasTriedPassword = true; m_accesstoken = null; try { m_isTryingWithPassword = true; await ObtainAccessTokenAsync().ConfigureAwait(false); } finally { m_isTryingWithPassword = false; } } } } private async Task ObtainAccessTokenAsync() { // If we host the server, issue the access token from the service var sp = Server.Program.DuplicatiWebserver?.Provider; if (sp != null && m_passwordSource == Program.PasswordSource.HostedServer) { var provider = sp.GetRequiredService(); m_accesstoken = provider.CreateAccessToken("trayicon", provider.TemporaryFamilyId); return; } // If we know the password, issue a token from the API if (!string.IsNullOrWhiteSpace(m_password)) { m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/login", JsonSerializer.Serialize(new { Password = m_password }), null).ConfigureAwait(false)).AccessToken; return; } // Otherwise, try to get a signin token var token = await ObtainSignInTokenAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) return; // Use the signin token to get an access token m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/signin", JsonSerializer.Serialize(new { SigninToken = token }), null).ConfigureAwait(false)).AccessToken; } private async Task PerformRequestInternalAsync(string method, string endpoint, string? body, TimeSpan? timeout) { var request = new HttpRequestMessage(new HttpMethod(method), new Uri(m_apiUri + endpoint)); if (!string.IsNullOrWhiteSpace(m_accesstoken)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", m_accesstoken); if (!string.IsNullOrWhiteSpace(body)) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); // Set up response timeout, use 100s which is the .NET default using var cts = CancellationTokenSource.CreateLinkedTokenSource(m_stopToken.Token, _applicationExitEvent); cts.CancelAfter(timeout.HasValue ? timeout.Value + TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(100)); var response = await HTTPCLIENT.SendAsync(request, cts.Token).ConfigureAwait(false); response.EnsureSuccessStatusCode(); if (typeof(T) == typeof(string)) return (T)(object)await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(stream, serializerOptions, cts.Token).ConfigureAwait(false) ?? throw new JsonException("Failed to deserialize response."); } private void ExecuteAndNotify(string method, string urifragment, string? body) { m_workQueue.WriteNoWait(new BackgroundRequest(method, urifragment, body, null)); } public void Pause(string? duration = null) { ExecuteAndNotify("POST", $"/serverstate/pause{(string.IsNullOrWhiteSpace(duration) ? "" : $"?duration={duration}")}", null); } public void Resume() { ExecuteAndNotify("POST", "/serverstate/resume", null); } public void Dispose() { Close(); } private sealed record SigninTokenResponse(string Token); private async Task IssueSigninTokenAsync(string password) => (await PerformRequestInternalAsync("POST", "/auth/issuesignintoken", JsonSerializer.Serialize(new { Password = password }), null).ConfigureAwait(false)).Token; private async Task ObtainSignInTokenAsync() { string? signinjwt = null; // If we host the server, issue the token from the service var sp = Server.Program.DuplicatiWebserver?.Provider; if (sp != null && m_passwordSource == Program.PasswordSource.HostedServer) { if (sp.GetRequiredService().ApplicationSettings.DisableSigninTokens) return null; signinjwt = sp.GetRequiredService().CreateSigninToken("trayicon"); } // If we have database access, grab the issuer key from the db and issue a token if (string.IsNullOrWhiteSpace(signinjwt) && m_passwordSource == Program.PasswordSource.Database) { Program.databaseConnection.ApplicationSettings.ReloadSettings(); if (Program.databaseConnection.ApplicationSettings.DisableSigninTokens) return null; var cfg = Program.databaseConnection.ApplicationSettings.JWTConfig; if (!string.IsNullOrWhiteSpace(cfg)) signinjwt = new JWTTokenProvider(JsonSerializer.Deserialize(cfg) ?? throw new JsonException("Failed to deserialize JWT config")).CreateSigninToken("trayicon"); } // If we know the password, issue a token from the API if (string.IsNullOrWhiteSpace(signinjwt) && !string.IsNullOrWhiteSpace(m_password)) signinjwt = await IssueSigninTokenAsync(m_password); return signinjwt; } public async Task GetStatusWindowURLAsync() { string? signinjwt = null; if (!m_disableTrayIconLogin) { try { signinjwt = await ObtainSignInTokenAsync().ConfigureAwait(false); } catch { } } return string.IsNullOrWhiteSpace(signinjwt) ? m_baseUri + STATUS_WINDOW : m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; } } }