// Copyright (C) 2024, 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.Net; using System.Text; using Duplicati.Library.Common.IO; using Duplicati.Server.Serialization; using Duplicati.Server.Serialization.Interface; using Newtonsoft.Json; namespace Duplicati.GUI.TrayIcon { public class HttpServerConnection : IDisposable { private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); private const string LOGIN_SCRIPT = "login.cgi"; private const string STATUS_WINDOW = "index.html"; private const string XSRF_COOKIE = "xsrf-token"; private const string XSRF_HEADER = "X-XSRF-Token"; private const string AUTH_COOKIE = "session-auth"; private const string TRAYICONPASSWORDSOURCE_HEADER = "X-TrayIcon-PasswordSource"; private class BackgroundRequest { public readonly string Method; public readonly string Endpoint; public readonly Dictionary Query; public BackgroundRequest(string method, string endpoint, Dictionary query) { this.Method = method; this.Endpoint = endpoint; this.Query = query; } } private readonly string m_apiUri; private readonly string m_baseUri; private string m_password; private readonly bool m_saltedpassword; private string m_authtoken; private string m_xsrftoken; private static readonly System.Text.Encoding ENCODING = System.Text.Encoding.GetEncoding("utf-8"); public delegate void StatusUpdateDelegate(IServerStatus status); public event StatusUpdateDelegate OnStatusUpdated; public long m_lastNotificationId = -1; public DateTime m_firstNotificationTime; public delegate void NewNotificationDelegate(INotification notification); public event NewNotificationDelegate OnNotification; private long m_lastDataUpdateId = -1; private bool m_disableTrayIconLogin; private volatile IServerStatus m_status; private volatile bool m_shutdown = false; private volatile System.Threading.Thread m_requestThread; private volatile System.Threading.Thread m_pollThread; private readonly System.Threading.AutoResetEvent m_waitLock; private readonly Dictionary m_updateRequest; private readonly Dictionary m_options; private readonly Program.PasswordSource m_passwordSource; private string m_TrayIconHeaderValue => (m_passwordSource == Program.PasswordSource.Database) ? "database" : "user"; public IServerStatus Status { get { return m_status; } } private readonly object m_lock = new object(); private readonly Queue m_workQueue = new Queue(); public HttpServerConnection(Uri server, string password, bool saltedpassword, Program.PasswordSource passwordSource, bool disableTrayIconLogin, Dictionary options) { m_baseUri = Util.AppendDirSeparator(server.ToString(), "/"); m_apiUri = m_baseUri + "api/v1"; m_disableTrayIconLogin = disableTrayIconLogin; m_firstNotificationTime = DateTime.Now; m_password = password; m_saltedpassword = saltedpassword; m_options = options; m_passwordSource = passwordSource; m_updateRequest = new Dictionary(); m_updateRequest["longpoll"] = "false"; m_updateRequest["lasteventid"] = "0"; UpdateStatus(); //We do the first request without long poll, // and all the rest with longpoll m_updateRequest["longpoll"] = "true"; m_updateRequest["duration"] = "5m"; m_waitLock = new System.Threading.AutoResetEvent(false); m_requestThread = new System.Threading.Thread(ThreadRunner); m_pollThread = new System.Threading.Thread(LongPollRunner); m_requestThread.Name = "TrayIcon Request Thread"; m_pollThread.Name = "TrayIcon Longpoll Thread"; m_requestThread.Start(); m_pollThread.Start(); } private void UpdateStatus() { m_status = PerformRequest("GET", "/serverstate", m_updateRequest); m_updateRequest["lasteventid"] = m_status.LastEventID.ToString(); if (OnStatusUpdated != null) OnStatusUpdated(m_status); if (m_lastNotificationId != m_status.LastNotificationUpdateID) { m_lastNotificationId = m_status.LastNotificationUpdateID; UpdateNotifications(); } if (m_lastDataUpdateId != m_status.LastDataUpdateID) { m_lastDataUpdateId = m_status.LastDataUpdateID; UpdateApplicationSettings(); } } private void UpdateNotifications() { var req = new Dictionary(); var notifications = PerformRequest("GET", "/notifications", req); 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 void UpdateApplicationSettings() { var req = new Dictionary(); var settings = PerformRequest>("GET", "/serversettings", req); if (settings != null && settings.TryGetValue("disable-tray-icon-login", out var str)) m_disableTrayIconLogin = Library.Utility.Utility.ParseBool(str, false); } private void LongPollRunner() { while (!m_shutdown) { try { UpdateStatus(); } catch (Exception ex) { System.Diagnostics.Trace.WriteLine("Request error: " + ex.Message); Library.Logging.Log.WriteWarningMessage(LOGTAG, "TrayIconRequestError", ex, "Failed to get response"); } } } private void ThreadRunner() { while (!m_shutdown) { try { BackgroundRequest req; bool any = false; do { req = null; lock (m_lock) if (m_workQueue.Count > 0) req = m_workQueue.Dequeue(); if (m_shutdown) break; if (req != null) { any = true; PerformRequest(req.Method, req.Endpoint, req.Query); } } while (req != null); if (!(any || m_shutdown)) m_waitLock.WaitOne(TimeSpan.FromMinutes(1), true); } 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_shutdown = true; m_waitLock.Set(); m_pollThread.Interrupt(); m_pollThread.Join(TimeSpan.FromSeconds(10)); if (!m_requestThread.Join(TimeSpan.FromSeconds(10))) { m_requestThread.Interrupt(); m_requestThread.Join(TimeSpan.FromSeconds(10)); } } private static string EncodeQueryString(Dictionary dict) { return string.Join("&", Array.ConvertAll(dict.Keys.ToArray(), key => string.Format("{0}={1}", Uri.EscapeUriString(key), Uri.EscapeUriString(dict[key])))); } private class SaltAndNonce { // The JsonProperty attribute allows the Serializer.Deserialize method to // set these readonly fields. [JsonProperty] public readonly string Salt = null; [JsonProperty] public readonly string Nonce = null; } private SaltAndNonce GetSaltAndNonce() { var httpOptions = new Duplicati.Library.Modules.Builtin.HttpOptions(); httpOptions.Configure(m_options); using (httpOptions) { var req = (System.Net.HttpWebRequest) System.Net.WebRequest.Create(m_baseUri + LOGIN_SCRIPT); req.Method = "POST"; req.UserAgent = "Duplicati TrayIcon Monitor, v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; req.Headers.Add(TRAYICONPASSWORDSOURCE_HEADER, m_TrayIconHeaderValue); req.ContentType = "application/x-www-form-urlencoded"; Duplicati.Library.Utility.AsyncHttpRequest areq = new Library.Utility.AsyncHttpRequest(req); var body = System.Text.Encoding.ASCII.GetBytes("get-nonce=1"); using (var f = areq.GetRequestStream(body.Length)) f.Write(body, 0, body.Length); using (var r = (System.Net.HttpWebResponse) areq.GetResponse()) using (var s = areq.GetResponseStream()) using (var sr = new System.IO.StreamReader(s, ENCODING, true)) return Serializer.Deserialize(sr); } } private string PerformLogin(string password, string nonce) { var httpOptions = new Duplicati.Library.Modules.Builtin.HttpOptions(); httpOptions.Configure(m_options); using (httpOptions) { System.Net.HttpWebRequest req = (System.Net.HttpWebRequest) System.Net.WebRequest.Create(m_baseUri + LOGIN_SCRIPT); req.Method = "POST"; req.UserAgent = "Duplicati TrayIcon Monitor, v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; req.Headers.Add(TRAYICONPASSWORDSOURCE_HEADER, m_TrayIconHeaderValue); req.ContentType = "application/x-www-form-urlencoded"; if (req.CookieContainer == null) req.CookieContainer = new System.Net.CookieContainer(); req.CookieContainer.Add(new System.Net.Cookie("session-nonce", nonce, "/", req.RequestUri.Host)); //Wrap it all in async stuff Duplicati.Library.Utility.AsyncHttpRequest areq = new Library.Utility.AsyncHttpRequest(req); var body = System.Text.Encoding.ASCII.GetBytes("password=" + Duplicati.Library.Utility.Uri.UrlEncode(password)); using (var f = areq.GetRequestStream(body.Length)) f.Write(body, 0, body.Length); using (var r = (System.Net.HttpWebResponse) areq.GetResponse()) if (r.StatusCode == System.Net.HttpStatusCode.OK) return (r.Cookies[AUTH_COOKIE] ?? r.Cookies[Library.Utility.Uri.UrlEncode(AUTH_COOKIE)]).Value; return null; } } private string GetAuthToken() { var salt_nonce = GetSaltAndNonce(); var sha256 = System.Security.Cryptography.SHA256.Create(); var password = m_password; if (string.IsNullOrWhiteSpace(m_password)) return ""; if (!m_saltedpassword) { var str = System.Text.Encoding.UTF8.GetBytes(m_password); var buf = Convert.FromBase64String(salt_nonce.Salt); sha256.TransformBlock(str, 0, str.Length, str, 0); sha256.TransformFinalBlock(buf, 0, buf.Length); password = Convert.ToBase64String(sha256.Hash); sha256.Initialize(); } var nonce = Convert.FromBase64String(salt_nonce.Nonce); sha256.TransformBlock(nonce, 0, nonce.Length, nonce, 0); var pwdbuf = Convert.FromBase64String(password); sha256.TransformFinalBlock(pwdbuf, 0, pwdbuf.Length); var pwd = Convert.ToBase64String(sha256.Hash); return PerformLogin(pwd, salt_nonce.Nonce); } private string GetXSRFToken() { var httpOptions = new Duplicati.Library.Modules.Builtin.HttpOptions(); httpOptions.Configure(m_options); using (httpOptions) { System.Net.HttpWebRequest req = (System.Net.HttpWebRequest) System.Net.WebRequest.Create(m_baseUri + STATUS_WINDOW); req.Method = "GET"; req.UserAgent = "Duplicati TrayIcon Monitor, v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; req.Headers.Add(TRAYICONPASSWORDSOURCE_HEADER, m_TrayIconHeaderValue); if (req.CookieContainer == null) req.CookieContainer = new System.Net.CookieContainer(); //Wrap it all in async stuff Duplicati.Library.Utility.AsyncHttpRequest areq = new Library.Utility.AsyncHttpRequest(req); using (var r = (System.Net.HttpWebResponse) areq.GetResponse()) if (r.StatusCode == System.Net.HttpStatusCode.OK) return (r.Cookies[XSRF_COOKIE] ?? r.Cookies[Library.Utility.Uri.UrlEncode(XSRF_COOKIE)]).Value; return null; } } private T PerformRequest(string method, string urlfragment, Dictionary queryparams) { var hasTriedXSRF = false; var hasTriedPassword = false; while (true) { try { return PerformRequestInternal(method, urlfragment, queryparams); } catch (System.Net.WebException wex) { var httpex = wex.Response as HttpWebResponse; if (httpex == null) throw; if ( !hasTriedXSRF && wex.Status == System.Net.WebExceptionStatus.ProtocolError && httpex.StatusCode == System.Net.HttpStatusCode.BadRequest && httpex.StatusDescription.IndexOf("XSRF", StringComparison.OrdinalIgnoreCase) >= 0) { hasTriedXSRF = true; var t = httpex.Cookies[XSRF_COOKIE]?.Value; if (string.IsNullOrWhiteSpace(t)) t = GetXSRFToken(); m_xsrftoken = Duplicati.Library.Utility.Uri.UrlDecode(t); } else if ( !hasTriedPassword && wex.Status == System.Net.WebExceptionStatus.ProtocolError && httpex.StatusCode == System.Net.HttpStatusCode.Unauthorized) { //Can survive if server password is changed via web ui switch (m_passwordSource) { case Program.PasswordSource.Database: if (Program.databaseConnection != null) Program.databaseConnection.ApplicationSettings.ReloadSettings(); if (Program.databaseConnection != null && Program.databaseConnection.ApplicationSettings.WebserverPasswordTrayIcon != m_password) m_password = Program.databaseConnection.ApplicationSettings.WebserverPasswordTrayIcon; else hasTriedPassword = true; break; case Program.PasswordSource.HostedServer: if (Server.Program.DataConnection != null && Server.Program.DataConnection.ApplicationSettings.WebserverPassword != m_password) m_password = Server.Program.DataConnection.ApplicationSettings.WebserverPassword; else hasTriedPassword = true; break; default: throw new ArgumentOutOfRangeException(); } m_authtoken = GetAuthToken(); } else throw; } } } private T PerformRequestInternal(string method, string endpoint, Dictionary queryparams) { queryparams["format"] = "json"; string query = EncodeQueryString(queryparams); // TODO: This can interfere with running backups, // as the System.Net.ServicePointManager is shared with // all connections doing ftp/http requests using (var httpOptions = new Duplicati.Library.Modules.Builtin.HttpOptions()) { httpOptions.Configure(m_options); var req = (System.Net.HttpWebRequest) System.Net.WebRequest.Create( new Uri(m_apiUri + endpoint + '?' + query)); req.Method = method; req.Headers.Add("Accept-Charset", ENCODING.BodyName); if (m_xsrftoken != null) req.Headers.Add(XSRF_HEADER, m_xsrftoken); req.UserAgent = "Duplicati TrayIcon Monitor, v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; req.Headers.Add(TRAYICONPASSWORDSOURCE_HEADER, m_TrayIconHeaderValue); if (req.CookieContainer == null) req.CookieContainer = new System.Net.CookieContainer(); if (m_authtoken != null) req.CookieContainer.Add(new System.Net.Cookie(AUTH_COOKIE, m_authtoken, "/", req.RequestUri.Host)); if (m_xsrftoken != null) req.CookieContainer.Add(new System.Net.Cookie(XSRF_COOKIE, m_xsrftoken, "/", req.RequestUri.Host)); //Wrap it all in async stuff var areq = new Library.Utility.AsyncHttpRequest(req); req.AllowWriteStreamBuffering = true; //Assign the timeout, and add a little processing time as well if (endpoint.Equals("/serverstate", StringComparison.OrdinalIgnoreCase) && queryparams.ContainsKey("duration")) areq.Timeout = (int) (Duplicati.Library.Utility.Timeparser.ParseTimeSpan(queryparams["duration"]) + TimeSpan.FromSeconds(5)).TotalMilliseconds; using (var r = (System.Net.HttpWebResponse) areq.GetResponse()) using (var s = areq.GetResponseStream()) if (typeof(T) == typeof(string)) { using (System.IO.MemoryStream ms = new System.IO.MemoryStream()) { s.CopyTo(ms); return (T) (object) ENCODING.GetString(ms.ToArray()); } } else { using (var sr = new System.IO.StreamReader(s, ENCODING, true)) return Serializer.Deserialize(sr); } } } private void ExecuteAndNotify(string method, string urifragment, Dictionary req) { lock (m_lock) { m_workQueue.Enqueue(new BackgroundRequest(method, urifragment, req)); m_waitLock.Set(); } } public void Pause(string duration = null) { var req = new Dictionary(); if (!string.IsNullOrWhiteSpace(duration)) req.Add("duration", duration); ExecuteAndNotify("POST", "/serverstate/pause", req); } public void Resume() { var req = new Dictionary(); ExecuteAndNotify("POST", "/serverstate/resume", req); } public void StopTask(long id) { var req = new Dictionary(); ExecuteAndNotify("POST", string.Format("/task/{0}/stop", Library.Utility.Uri.UrlPathEncode(id.ToString())), req); } public void AbortTask(long id) { var req = new Dictionary(); ExecuteAndNotify("POST", string.Format("/task/{0}/abort", Library.Utility.Uri.UrlPathEncode(id.ToString())), req); } public void RunBackup(long id, bool forcefull = false) { var req = new Dictionary(); if (forcefull) req.Add("full", "true"); ExecuteAndNotify("POST", string.Format("/backup/{0}/start", Library.Utility.Uri.UrlPathEncode(id.ToString())), req); } public void DismissNotification(long id) { var req = new Dictionary(); ExecuteAndNotify("DELETE", string.Format("/notification/{0}", Library.Utility.Uri.UrlPathEncode(id.ToString())), req); } public void Dispose() { Close(); } public string StatusWindowURL { get { if (m_authtoken != null) return m_baseUri + STATUS_WINDOW + (m_disableTrayIconLogin ? string.Empty : "?auth-token=" + GetAuthToken()); return m_baseUri + STATUS_WINDOW; } } } }