// 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.Linq; using System.Collections.Generic; using System.IO; using Duplicati.Library.Utility; using Duplicati.Library.Common; using System.Diagnostics; namespace Duplicati.Library.AutoUpdater { public static class UpdaterManager { /// /// The RSA key used to sign the manifest /// private static readonly System.Security.Cryptography.RSA[] SIGN_KEYS = AutoUpdateSettings.SignKeys; /// /// Urls to check for updated packages /// private static readonly string[] MANIFEST_URLS = AutoUpdateSettings.URLs; /// /// The app name to show /// private static readonly string APPNAME = AutoUpdateSettings.AppName; /// /// The version that the updater supports /// public const int SUPPORTED_PACKAGE_UPDATER_VERSION = 2; /// /// The folder where the machine id is placed /// public static readonly string UPDATEDIR; /// /// The directory where the program is running from /// public static readonly string INSTALLATIONDIR; /// /// Env variable that allows fully disabling all update checks /// public static readonly bool DISABLE_UPDATE_CHECK = Debugger.IsAttached || Utility.Utility.ParseBool(Environment.GetEnvironmentVariable(string.Format(SKIPUPDATE_ENVNAME_TEMPLATE, APPNAME)), false); /// /// The update information for the running version /// public static readonly UpdateInfo SelfVersion; /// /// Event trigger for errors on update /// public static event Action OnError; /// /// Common formatting string for date-time values /// private const string DATETIME_FORMAT = "yyyymmddhhMMss"; /// /// The template for the environment variable name that allows an overriden root folder /// private const string UPDATEINSTALLDIR_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_UPDATE_ROOT"; /// /// The template for the environment variable that toggles disabling updates /// public const string SKIPUPDATE_ENVNAME_TEMPLATE = "AUTOUPDATER_{0}_SKIP_UPDATE"; /// /// The name of the file that contains the manifest, located in the folder /// private const string UPDATE_MANIFEST_FILENAME = "autoupdate.manifest"; /// /// The name of the file that contains the package type id, located in the folder /// private const string PACKAGE_TYPE_FILE = "package_type_id.txt"; /// /// The README file stored in the folder, explaining what the folder is for /// private const string README_FILE = "README.txt"; /// /// The installation ID filename stored in /// private const string INSTALL_FILE = "installation.txt"; /// /// Gets the last version found from an update /// public static UpdateInfo LastUpdateCheckVersion { get; private set; } /// /// Performs static initialization of the update manager, populating the readonly fields of the manager /// static UpdaterManager() { // Set the installation path INSTALLATIONDIR = Path.GetDirectoryName(Duplicati.Library.Utility.Utility.getEntryAssembly().Location); // Check for override if (string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME)))) { // OS specific folders for probing var candidates = new List(); if (Platform.IsClientWindows) { candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), APPNAME, "updates")); candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME, "updates")); } else { if (Platform.IsClientOSX) candidates.Add(System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", APPNAME, "updates")); candidates.Add(System.IO.Path.Combine(System.Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), APPNAME, "updates")); } // Find the first writeable directory in the list UPDATEDIR = candidates.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p) && System.IO.Directory.Exists(p) && TestDirectoryIsWriteable(p)); } else { // Use override, no checks UPDATEDIR = Environment.ExpandEnvironmentVariables(System.Environment.GetEnvironmentVariable(string.Format(UPDATEINSTALLDIR_ENVNAME_TEMPLATE, APPNAME))); } if (!string.IsNullOrWhiteSpace(UPDATEDIR)) { if (!System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, README_FILE))) System.IO.File.WriteAllText(System.IO.Path.Combine(UPDATEDIR, README_FILE), AutoUpdateSettings.UpdateFolderReadme); if (!System.IO.File.Exists(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE))) System.IO.File.WriteAllText(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE), AutoUpdateSettings.UpdateInstallFileText); } // Attempt to read the installed manifest file UpdateInfo selfVersion = null; try { selfVersion = ReadInstalledManifest(INSTALLATIONDIR); } catch { } // In case the installed manifest is broken, try to set some sane values if (selfVersion == null) { SelfVersion = new UpdateInfo() { Displayname = string.IsNullOrWhiteSpace(Duplicati.License.VersionNumbers.TAG) ? "Current" : Duplicati.License.VersionNumbers.TAG, Version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString(), ReleaseTime = new DateTime(0), ReleaseType = #if DEBUG "Debug" #else string.IsNullOrWhiteSpace(AutoUpdateSettings.BuildUpdateChannel) ? "Nightly" : AutoUpdateSettings.BuildUpdateChannel #endif }; } } public static Version TryParseVersion(string str) { Version v; if (Version.TryParse(str, out v)) return v; else return new Version(0, 0); } private static bool TestDirectoryIsWriteable(string path) { var p2 = System.IO.Path.Combine(path, "test-" + DateTime.UtcNow.ToString(DATETIME_FORMAT, System.Globalization.CultureInfo.InvariantCulture)); var probe = System.IO.Directory.Exists(path) ? p2 : path; if (!System.IO.Directory.Exists(probe)) { try { System.IO.Directory.CreateDirectory(probe); if (probe != path) System.IO.Directory.Delete(probe); return true; } catch { } } return false; } /// /// The unique machine installation ID /// public static string InstallID { get { try { return System.IO.File.ReadAllLines(System.IO.Path.Combine(UPDATEDIR, INSTALL_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } catch { } return ""; } } /// /// The package type ID /// public static string PackageTypeId { get { try { return System.IO.File.ReadAllLines(System.IO.Path.Combine(INSTALLATIONDIR, PACKAGE_TYPE_FILE)).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim() ?? ""; } catch { } #if DEBUG return "debug"; #else return ""; #endif } } public static UpdateInfo CheckForUpdate(ReleaseType channel = ReleaseType.Unknown) { if (channel == ReleaseType.Unknown) channel = AutoUpdateSettings.DefaultUpdateChannel; foreach (var rawurl in MANIFEST_URLS) { var url = rawurl; // Attempt to match the url to change the channel if possible // This allows overrides to the URLs for deployment of custom builds, // but does not require that they adopt the channel system var match = AutoUpdateSettings.MATCH_AUTOUPDATE_URL.Match(url); if (match.Success) { var mg = match.Groups[AutoUpdateSettings.MATCH_UPDATE_URL_CHANNEL_GROUP]; // Replace the channel name with the chosen channel url = url.Substring(0, mg.Index) + channel.ToString().ToLowerInvariant() + url.Substring(mg.Index + mg.Length); } try { using (var tmpfile = new Library.Utility.TempFile()) { System.Net.WebClient wc = new System.Net.WebClient(); wc.Headers.Add(System.Net.HttpRequestHeader.UserAgent, string.Format("{0} v{1}{2}", APPNAME, SelfVersion.Version, string.IsNullOrWhiteSpace(InstallID) ? "" : " -" + InstallID)); wc.Headers.Add("X-Install-ID", InstallID); wc.DownloadFile(url, tmpfile); using (var fs = System.IO.File.OpenRead(tmpfile)) using (var ss = new SignatureReadingStream(fs, SIGN_KEYS)) using (var tr = new System.IO.StreamReader(ss)) using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) { var update = new Newtonsoft.Json.JsonSerializer().Deserialize(jr); if (TryParseVersion(update.Version) <= TryParseVersion(SelfVersion.Version)) return null; // Don't install a debug update on a release build and vice versa if (string.Equals(SelfVersion.ReleaseType, "Debug", StringComparison.OrdinalIgnoreCase) && !string.Equals(update.ReleaseType, SelfVersion.ReleaseType, StringComparison.CurrentCultureIgnoreCase)) return null; ReleaseType rt; if (!Enum.TryParse(update.ReleaseType, true, out rt)) rt = ReleaseType.Unknown; // If the update is too low to be considered, skip it // Should never happen, but protects against mistakes in deployment if (rt > channel) return null; // In case the manifest does not contain a URL, use the one from this assembly if (string.IsNullOrWhiteSpace(update.GenericUpdatePageUrl)) update.GenericUpdatePageUrl = SelfVersion.GenericUpdatePageUrl; // In case there is no url, fall back to the project download page if (string.IsNullOrWhiteSpace(update.GenericUpdatePageUrl)) update.GenericUpdatePageUrl = "https://duplicati.com/download"; LastUpdateCheckVersion = update; return update; } } } catch (Exception ex) { if (OnError != null) OnError(ex); } } return null; } private static UpdateInfo ReadInstalledManifest(string folder) { var manifest = System.IO.Path.Combine(folder, UPDATE_MANIFEST_FILENAME); if (System.IO.File.Exists(manifest)) { try { using (var fs = System.IO.File.OpenRead(manifest)) using (var ss = new SignatureReadingStream(fs, SIGN_KEYS)) using (var tr = new System.IO.StreamReader(ss)) using (var jr = new Newtonsoft.Json.JsonTextReader(tr)) return new Newtonsoft.Json.JsonSerializer().Deserialize(jr); } catch (Exception ex) { if (OnError != null) OnError(ex); } } return null; } public static bool DownloadUpdate(UpdateInfo version, PackageEntry package, string targetPath, Action progress = null) { if (UPDATEDIR == null) return false; var updates = package.RemoteUrls.ToList(); // If alternate update URLs are specified, // we look for packages there as well if (AutoUpdateSettings.UsesAlternateURLs) { var packagepath = new Library.Utility.Uri(updates[0]).Path; var packagename = packagepath.Split('/').Last(); foreach (var alt_url in AutoUpdateSettings.URLs.Reverse()) { var alt_uri = new Library.Utility.Uri(alt_url); var path_components = alt_uri.Path.Split('/'); var path = string.Join("/", path_components.Take(path_components.Count() - 1).Union(new string[] { packagename })); var new_path = alt_uri.SetPath(path); updates.Insert(0, new_path.ToString()); } } using (var tempfilename = new Library.Utility.TempFile()) { foreach (var url in updates) { try { using (var tempfile = System.IO.File.Open(tempfilename, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) { Action cb = null; if (progress != null) cb = (s) => { progress(Math.Min(1.0, Math.Max(0.0, (double)s / package.Length))); }; var wreq = (System.Net.HttpWebRequest)System.Net.WebRequest.Create(url); wreq.UserAgent = string.Format("{0} v{1}", APPNAME, SelfVersion.Version); wreq.Headers.Add("X-Install-ID", InstallID); var areq = new Duplicati.Library.Utility.AsyncHttpRequest(wreq); using (var resp = areq.GetResponse()) using (var rss = areq.GetResponseStream()) using (var pgs = new Duplicati.Library.Utility.ProgressReportingStream(rss, cb)) Duplicati.Library.Utility.Utility.CopyStream(pgs, tempfile); var sha256 = System.Security.Cryptography.SHA256.Create(); var md5 = System.Security.Cryptography.MD5.Create(); if (tempfile.Length != package.Length) throw new Exception(string.Format("Invalid file size {0}, expected {1} for {2}", tempfile.Length, package.Length, url)); tempfile.Position = 0; var sha256hash = Convert.ToBase64String(sha256.ComputeHash(tempfile)); if (sha256hash != package.SHA256) throw new Exception(string.Format("Damaged or corrupted file, sha256 mismatch for {0}", url)); tempfile.Position = 0; var md5hash = Convert.ToBase64String(md5.ComputeHash(tempfile)); if (md5hash != package.MD5) throw new Exception(string.Format("Damaged or corrupted file, md5 mismatch for {0}", url)); } File.Copy(tempfilename, targetPath, true); return true; } catch (Exception ex) { if (OnError != null) OnError(ex); } } } return false; } /// /// Helper method to create a signed manifest file /// /// The key used for signing the manifest /// The template content in JSON format /// The folder where the signed manifest will be written to /// The version of the manifest /// The URL to use for V1 updates /// The URL to use for generic updates public static void CreateSignedManifest(System.Security.Cryptography.RSA key, string sourcedata, string outputfolder, string version = null, string updateFromV1Url = null, string genericUpdatePageUrl = null, string releaseType = null, IEnumerable packages = null) { // Read the existing manifest var remoteManifest = Newtonsoft.Json.JsonConvert.DeserializeObject(string.IsNullOrWhiteSpace(sourcedata) ? "{}" : sourcedata); if (remoteManifest.ReleaseTime.Ticks == 0) remoteManifest.ReleaseTime = DateTime.UtcNow; // No files to update with are allowed, as we currently do not use the information if (remoteManifest.Packages == null) remoteManifest.Packages = Array.Empty(); // Disable the warning as we enforce the field to be set to the default value #pragma warning disable CS0618 // Type or member is obsolete if (remoteManifest.RemoteURLS == null) remoteManifest.RemoteURLS = Array.Empty(); #pragma warning restore CS0618 // Type or member is obsolete if (version != null) remoteManifest.Version = version.ToString(); if (!string.IsNullOrWhiteSpace(updateFromV1Url)) remoteManifest.UpdateFromV1Url = updateFromV1Url; if (!string.IsNullOrWhiteSpace(genericUpdatePageUrl)) remoteManifest.GenericUpdatePageUrl = genericUpdatePageUrl; if (!string.IsNullOrWhiteSpace(releaseType)) remoteManifest.ReleaseType = releaseType; if (packages != null) remoteManifest.Packages = packages.ToArray(); if (string.IsNullOrWhiteSpace(remoteManifest.UpdateFromV1Url)) remoteManifest.UpdateFromV1Url = remoteManifest.GenericUpdatePageUrl; if (string.IsNullOrWhiteSpace(remoteManifest.UpdateFromV1Url)) throw new Exception($"Field must be set: {nameof(remoteManifest.UpdateFromV1Url)}"); if (string.IsNullOrWhiteSpace(remoteManifest.GenericUpdatePageUrl)) throw new Exception($"Field must be set: {nameof(remoteManifest.GenericUpdatePageUrl)}"); if (string.IsNullOrWhiteSpace(remoteManifest.Version)) throw new Exception($"Field must be set: {nameof(remoteManifest.Version)}"); if (string.IsNullOrWhiteSpace(remoteManifest.ReleaseType)) throw new Exception($"Field must be set: {nameof(remoteManifest.ReleaseType)}"); // Write a signed manifest for upload using (var tf = new Duplicati.Library.Utility.TempFile()) { using (var ms = new System.IO.MemoryStream()) using (var sw = new System.IO.StreamWriter(ms)) { new Newtonsoft.Json.JsonSerializer().Serialize(sw, remoteManifest); sw.Flush(); using (var fs = System.IO.File.Create(tf)) SignatureReadingStream.CreateSignedStream(ms, fs, key); } System.IO.File.Move(tf, System.IO.Path.Combine(outputfolder, UPDATE_MANIFEST_FILENAME)); } } } }