using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; using System.Text; using System.Xml; namespace BizTalkCheckmkPulse.Setup { internal sealed class InstallerEngine { internal const string TaskName = "BizTalk Checkmk Pulse Provider"; private static readonly TimeSpan PostInstallValidationTimeout = TimeSpan.FromMinutes(4); private readonly string packageDirectory; private readonly string installDirectory; private readonly string runtimeDirectory; private readonly string checkmkLocalDirectory; public InstallerEngine(string packageDirectory) { this.packageDirectory = Path.GetFullPath(packageDirectory); installDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "BizTalkCheckmkPulse"); runtimeDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BizTalkCheckmkPulse"); checkmkLocalDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "checkmk", "agent", "local"); } public void Install( string account, string password, bool isGmsa, string environmentName, bool allowServiceNameChange, Action report) { Validate(account, password, isGmsa, environmentName); report = report ?? delegate { }; var accountSid = (SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier)); report("Konto aufgeloest: " + account + " (" + accountSid.Value + ")"); var sourceApplication = Path.Combine(packageDirectory, "application"); var sourceExe = Path.Combine(sourceApplication, "BizTalkCheckmkPulse.exe"); var sourceConfig = sourceExe + ".config"; var sourceWrapper = Path.Combine(packageDirectory, "biztalk_checkmk_pulse.cmd"); RequireFile(sourceExe); RequireFile(sourceConfig); RequireFile(sourceWrapper); var targetExe = Path.Combine(installDirectory, "BizTalkCheckmkPulse.exe"); var targetConfig = targetExe + ".config"; var stagingDirectory = installDirectory + ".staging." + Guid.NewGuid().ToString("N"); var backupDirectory = installDirectory + ".backup." + Guid.NewGuid().ToString("N"); var targetWrapper = Path.Combine(checkmkLocalDirectory, "biztalk_checkmk_pulse.cmd"); var previousWrapper = File.Exists(targetWrapper) ? File.ReadAllBytes(targetWrapper) : null; var hadExistingInstallation = Directory.Exists(installDirectory); var hadExistingExecutable = File.Exists(targetExe); var mutationStarted = false; var backupCreated = false; var filesActivated = false; var scheduler = new TaskSchedulerService(); try { RunSelfTest(sourceExe); report("Paket-Vorpruefung erfolgreich: neun Checkmk-Services."); if (!isGmsa) { ValidateBatchLogon(account, password); report("Collector-Anmeldung und 'Log on as a batch job' vor dem Update bestaetigt."); } Directory.CreateDirectory(stagingDirectory); var stagedExe = Path.Combine(stagingDirectory, "BizTalkCheckmkPulse.exe"); var stagedConfig = stagedExe + ".config"; File.Copy(sourceExe, stagedExe, false); var effectiveEnvironment = PrepareConfig( sourceConfig, stagedConfig, File.Exists(targetConfig) ? targetConfig : null, environmentName); var stagedServiceNames = RunSelfTest(stagedExe); report("Update-Staging validiert. Umgebung=" + (effectiveEnvironment.Length == 0 ? "(keine)" : effectiveEnvironment) + "."); if (File.Exists(targetExe)) { var installedServiceNames = RunSelfTest(targetExe); var serviceNameChange = EnsureServiceNameCompatibility( installedServiceNames, stagedServiceNames, allowServiceNameChange); report(serviceNameChange.Length == 0 ? "Checkmk-Servicevertrag unveraendert: keine Service Discovery erforderlich." : "Checkmk-Service-Rename ausdruecklich bestaetigt; Service Discovery erforderlich. " + serviceNameChange); } // Erst nach vollstaendiger Staging-Pruefung wird der laufende Provider angehalten. mutationStarted = true; scheduler.DeleteIfExists(TaskName); report("Vorhandener Scheduled Task angehalten und fuer das Update entfernt."); if (hadExistingInstallation) { Directory.Move(installDirectory, backupDirectory); backupCreated = true; } Directory.Move(stagingDirectory, installDirectory); filesActivated = true; report(hadExistingInstallation ? "Programmdateien atomar auf die neue Version umgestellt." : "Programmdateien installiert: " + installDirectory); var dataDirectory = Path.Combine(runtimeDirectory, "data"); var logDirectory = Path.Combine(runtimeDirectory, "logs"); Directory.CreateDirectory(runtimeDirectory); Directory.CreateDirectory(dataDirectory); Directory.CreateDirectory(logDirectory); ApplyDirectoryAcl(installDirectory, accountSid, FileSystemRights.ReadAndExecute, FileSystemRights.ReadAndExecute); ApplyDirectoryAcl(runtimeDirectory, accountSid, FileSystemRights.ReadAndExecute, FileSystemRights.ReadAndExecute); ApplyDirectoryAcl(dataDirectory, accountSid, FileSystemRights.Modify, FileSystemRights.ReadAndExecute); ApplyDirectoryAcl(logDirectory, accountSid, FileSystemRights.Modify, FileSystemRights.Modify); report("Least-Privilege-Verzeichnisrechte gesetzt; vorhandene Runtime-Daten bleiben erhalten."); Directory.CreateDirectory(checkmkLocalDirectory); File.Copy(sourceWrapper, targetWrapper, true); report("Checkmk Local Check installiert: " + checkmkLocalDirectory); RunSelfTest(targetExe); report("Installierter Self-Test erfolgreich: neun Checkmk-Services."); var runtimeValidationStartedUtc = DateTime.UtcNow; scheduler.RegisterValidationAndStart( TaskName, targetExe, installDirectory, account, isGmsa ? null : password, isGmsa); report("Einmaliger Provider-Abnahmelauf mit erzwungenem Endpoint-Katalogabgleich gestartet."); var completedRun = scheduler.WaitForSuccessfulRun( TaskName, runtimeValidationStartedUtc, PostInstallValidationTimeout, report); report("Provider-Abnahmelauf erfolgreich: LastTaskResult=" + completedRun.ExitCode + "."); var runtimeValidation = RunRuntimeValidation( targetExe, runtimeValidationStartedUtc, account); report("Post-Install-Runtime-Abnahme erfolgreich: " + runtimeValidation); scheduler.RegisterRecurring( TaskName, targetExe, installDirectory, account, isGmsa ? null : password, isGmsa); report("Validierter minuetlicher Scheduled Task registriert: " + TaskName); TryDeleteDirectory(backupDirectory, report); } catch (Exception installException) { var rollbackFailures = new List(); if (mutationStarted) { try { scheduler.DeleteIfExists(TaskName); } catch (Exception ex) { rollbackFailures.Add("Task stoppen: " + ex.Message); } } if (filesActivated || backupCreated) { try { if (Directory.Exists(installDirectory)) Directory.Delete(installDirectory, true); if (backupCreated && Directory.Exists(backupDirectory)) Directory.Move(backupDirectory, installDirectory); report("Vorherige Programmversion wiederhergestellt."); } catch (Exception ex) { rollbackFailures.Add("Programmdateien wiederherstellen: " + ex.Message); } } if (mutationStarted) { try { RestoreWrapper(targetWrapper, previousWrapper); } catch (Exception ex) { rollbackFailures.Add("Checkmk-Wrapper wiederherstellen: " + ex.Message); } } if (mutationStarted && hadExistingExecutable && File.Exists(targetExe)) { try { var rollbackRunStartedUtc = DateTime.UtcNow; scheduler.RegisterRecurringAndStart( TaskName, targetExe, installDirectory, account, isGmsa ? null : password, isGmsa); scheduler.WaitForSuccessfulRun( TaskName, rollbackRunStartedUtc, PostInstallValidationTimeout, report); report("Scheduled Task und frischer Snapshot der vorherigen Version wiederhergestellt."); } catch (Exception ex) { rollbackFailures.Add("Scheduled Task wiederherstellen: " + ex.Message); } } var rollback = rollbackFailures.Count == 0 ? "Rollback erfolgreich." : "Rollback unvollstaendig: " + string.Join(" | ", rollbackFailures); throw new InvalidOperationException( "Installation/Update fehlgeschlagen. " + rollback + " Ursache: " + installException.Message, installException); } finally { TryDeleteDirectory(stagingDirectory, null); } } public void Uninstall(bool keepRuntimeData, Action report) { new TaskSchedulerService().DeleteIfExists(TaskName); report("Scheduled Task entfernt."); var wrapper = Path.Combine(checkmkLocalDirectory, "biztalk_checkmk_pulse.cmd"); if (File.Exists(wrapper)) File.Delete(wrapper); if (Directory.Exists(installDirectory)) Directory.Delete(installDirectory, true); if (!keepRuntimeData && Directory.Exists(runtimeDirectory)) Directory.Delete(runtimeDirectory, true); } private static void Validate(string account, string password, bool isGmsa, string environmentName) { if (string.IsNullOrWhiteSpace(account)) throw new ArgumentException("Collector-Konto fehlt."); if (account.IndexOf('\\') <= 0 || account.EndsWith("\\", StringComparison.Ordinal)) throw new ArgumentException(@"Collector-Konto im Format DOMAIN\Benutzer eingeben, z.B. BEW\t231bizmon."); if (isGmsa && !account.EndsWith("$", StringComparison.Ordinal)) throw new ArgumentException(@"Ein gMSA-Konto muss mit '$' enden, z.B. BEW\svc_biztalk_cmk$."); if (!isGmsa && string.IsNullOrEmpty(password)) throw new ArgumentException("Kennwort fehlt."); if (!new[] { "", "ACC", "DEV", "TST", "PRD" }.Contains(environmentName)) throw new ArgumentException("Ungueltige Umgebung."); } private static void RequireFile(string path) { if (!File.Exists(path)) throw new FileNotFoundException("Installationspaket ist unvollstaendig. Datei fehlt: " + path, path); } internal string GetInstalledEnvironment() { var config = Path.Combine(installDirectory, "BizTalkCheckmkPulse.exe.config"); if (!File.Exists(config)) return string.Empty; try { var document = LoadXml(config); var setting = FindAppSetting(document, "EnvironmentName"); return setting == null ? string.Empty : setting.GetAttribute("value").Trim(); } catch { return string.Empty; } } internal bool IsInstalled { get { return File.Exists(Path.Combine(installDirectory, "BizTalkCheckmkPulse.exe")); } } internal static string PrepareConfig( string sourceConfig, string stagedConfig, string existingConfig, string requestedEnvironment) { var document = LoadXml(sourceConfig); if (!string.IsNullOrWhiteSpace(existingConfig) && File.Exists(existingConfig)) { var existing = LoadXml(existingConfig); var existingSettings = existing.SelectNodes("/configuration/appSettings/add[@key]"); if (existingSettings != null) { foreach (XmlNode node in existingSettings) { var element = node as XmlElement; if (element == null) continue; var key = element.GetAttribute("key"); var value = element.GetAttribute("value"); if (IsSupersededDefault(key, value)) continue; var target = FindAppSetting(document, key); if (target != null) target.SetAttribute("value", value); } } } var environmentSetting = FindAppSetting(document, "EnvironmentName"); if (environmentSetting == null) throw new InvalidDataException("EnvironmentName fehlt in " + sourceConfig + "."); if (!string.IsNullOrWhiteSpace(requestedEnvironment)) environmentSetting.SetAttribute("value", requestedEnvironment.Trim()); document.Save(stagedConfig); return environmentSetting.GetAttribute("value").Trim(); } private static XmlDocument LoadXml(string path) { var document = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; using (var reader = XmlReader.Create(path, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null })) { document.Load(reader); } return document; } private static XmlElement FindAppSetting(XmlDocument document, string key) { var nodes = document.SelectNodes("/configuration/appSettings/add[@key]"); if (nodes == null) return null; foreach (XmlNode node in nodes) { var element = node as XmlElement; if (element != null && string.Equals(element.GetAttribute("key"), key, StringComparison.Ordinal)) return element; } return null; } private static bool IsSupersededDefault(string key, string value) { return string.Equals(key, "EndpointProbeMaxConcurrency", StringComparison.Ordinal) && string.Equals(value, "12", StringComparison.Ordinal) || string.Equals(key, "EndpointMaxCount", StringComparison.Ordinal) && string.Equals(value, "500", StringComparison.Ordinal); } private static void ValidateBatchLogon(string account, string password) { var separator = account.IndexOf('\\'); var domain = account.Substring(0, separator); var user = account.Substring(separator + 1); IntPtr token; if (!LogonUser(user, domain, password, 4, 0, out token)) { throw new InvalidOperationException( "Collector-Anmeldung als Batch fehlgeschlagen: " + new Win32Exception(Marshal.GetLastWin32Error()).Message + ". Kennwort und lokales Recht 'Log on as a batch job' pruefen."); } CloseHandle(token); } [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool LogonUser( string userName, string domain, string password, int logonType, int logonProvider, out IntPtr token); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle); private static void RestoreWrapper(string path, byte[] previousContent) { if (previousContent == null) { if (File.Exists(path)) File.Delete(path); return; } Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, previousContent); } private static void TryDeleteDirectory(string path, Action report) { if (!Directory.Exists(path)) return; try { Directory.Delete(path, true); } catch (Exception ex) { if (report != null) report("Hinweis: temporaeres Verzeichnis konnte nicht entfernt werden: " + path + " (" + ex.Message + ")"); } } private static void ApplyDirectoryAcl( string path, SecurityIdentifier collectorSid, FileSystemRights collectorRights, FileSystemRights systemRights) { var inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; var security = new DirectorySecurity(); security.SetAccessRuleProtection(true, false); security.AddAccessRule(new FileSystemAccessRule( new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), FileSystemRights.FullControl, inheritance, PropagationFlags.None, AccessControlType.Allow)); security.AddAccessRule(new FileSystemAccessRule( new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), systemRights, inheritance, PropagationFlags.None, AccessControlType.Allow)); security.AddAccessRule(new FileSystemAccessRule( collectorSid, collectorRights, inheritance, PropagationFlags.None, AccessControlType.Allow)); new DirectoryInfo(path).SetAccessControl(security); } private static IReadOnlyList RunSelfTest(string executable) { var start = new ProcessStartInfo(executable, "--self-test") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; using (var process = Process.Start(start)) { if (process == null) throw new InvalidOperationException("Self-Test konnte nicht gestartet werden."); var output = process.StandardOutput.ReadToEnd(); var error = process.StandardError.ReadToEnd(); if (!process.WaitForExit(30000)) { process.Kill(); throw new InvalidOperationException("Self-Test hat das Zeitlimit ueberschritten."); } var lines = output.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); if (process.ExitCode != 0 || lines.Length != 9 || lines.Any(x => !x.StartsWith("0 ", StringComparison.Ordinal))) throw new InvalidOperationException("Self-Test fehlgeschlagen. Exitcode=" + process.ExitCode + ", Zeilen=" + lines.Length + ". " + error); var serviceNames = lines.Select(ExtractServiceName).ToArray(); if (serviceNames.Distinct(StringComparer.Ordinal).Count() != serviceNames.Length) throw new InvalidOperationException("Self-Test fehlgeschlagen: Checkmk-Servicenamen sind nicht eindeutig."); return serviceNames; } } private static string RunRuntimeValidation( string executable, DateTime notBeforeUtc, string expectedIdentity) { var identityBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(expectedIdentity)); var arguments = "--validate-runtime --validation-not-before-utc " + notBeforeUtc.ToUniversalTime().ToString("o") + " --expected-identity-base64 " + identityBase64; var start = new ProcessStartInfo(executable, arguments) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; using (var process = Process.Start(start)) { if (process == null) throw new InvalidOperationException("Runtime-Abnahme konnte nicht gestartet werden."); var output = process.StandardOutput.ReadToEnd().Trim(); var error = process.StandardError.ReadToEnd().Trim(); if (!process.WaitForExit(30000)) { process.Kill(); throw new TimeoutException("Runtime-Abnahme hat das Zeitlimit ueberschritten."); } if (process.ExitCode != 0 || !output.StartsWith("RUNTIME_VALIDATION_V1 ", StringComparison.Ordinal)) { throw new InvalidOperationException( "Runtime-Abnahme fehlgeschlagen. Exitcode=" + process.ExitCode + ". " + (error.Length == 0 ? output : error)); } return output; } } private static string ExtractServiceName(string line) { var firstQuote = line.IndexOf('"'); var secondQuote = firstQuote < 0 ? -1 : line.IndexOf('"', firstQuote + 1); if (firstQuote < 0 || secondQuote <= firstQuote + 1) throw new InvalidOperationException("Self-Test fehlgeschlagen: Checkmk-Servicename kann nicht gelesen werden: " + line); return line.Substring(firstQuote + 1, secondQuote - firstQuote - 1); } internal static string EnsureServiceNameCompatibility( IEnumerable installedServiceNames, IEnumerable stagedServiceNames, bool allowServiceNameChange) { var installed = new HashSet(installedServiceNames ?? Enumerable.Empty(), StringComparer.Ordinal); var staged = new HashSet(stagedServiceNames ?? Enumerable.Empty(), StringComparer.Ordinal); if (installed.SetEquals(staged)) return string.Empty; var removed = installed.Except(staged, StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray(); var added = staged.Except(installed, StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray(); var description = "Entfernt=[" + string.Join(", ", removed) + "]; Neu=[" + string.Join(", ", added) + "]."; if (!allowServiceNameChange) { throw new InvalidOperationException( "Sicherheitsstopp: Das Update wuerde Checkmk-Servicenamen aendern. " + description + " Ohne Service Discovery entstehen verwaiste bzw. fehlende Services. " + "Nur wenn die Aenderung beabsichtigt ist, im Setup 'Service Discovery ist eingeplant' bestaetigen und anschliessend die Discovery ausfuehren."); } return description; } } }