using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace Deploymentcenter.Client { /// Ein zu schreibender Konfigurationswert. public sealed class SetupValue { private readonly bool? _isSecret; public SetupValue(string key, string value, string type = "string", bool? isSecret = null) { Key = key; Value = value; Type = type; _isSecret = isSecret; } /// Schluessel, Ebenen durch Doppelpunkt getrennt. public string Key { get; } public string Value { get; } /// string | secret | url | int | bool | enum public string Type { get; } /// /// Ist der Wert schutzbeduerftig? Ohne ausdrueckliche Angabe wird der /// Typ herangezogen. Ein per source: "provision" geholtes Token /// traegt haeufig den Typ "string" und muss deshalb gesondert /// gekennzeichnet werden. /// public bool IsSecret => _isSecret ?? string.Equals(Type, "secret", StringComparison.OrdinalIgnoreCase); } /// /// Schreibt ermittelte Werte in die Konfigurationsdateien der Anwendung. /// /// Durchgaengiges Prinzip: vorhandene Inhalte bleiben erhalten. Eine /// appsettings.json enthaelt neben den abgefragten Werten fast immer noch /// Logging-Einstellungen, Feature-Schalter und anderes. Die Datei neu zu /// erzeugen waere der bequemere Weg und wuerde all das verlieren - und /// zwar genau bei einer Neuinstallation, bei der niemand ein Backup hat. /// public static class SetupWriter { /// /// Schreibt Werte in eine JSON-Datei. Ebenen werden aus dem Schluessel /// gebildet: "ConnectionStrings:Main" wird zu /// { "ConnectionStrings": { "Main": ... } }. /// public static void WriteJson(string path, IEnumerable values) { JsonObject root = LoadJsonObject(path); foreach (var value in values) { string[] segments = SplitKey(value.Key); if (segments.Length == 0) continue; JsonObject node = root; // Bis zur vorletzten Ebene hinabsteigen und fehlende Objekte // anlegen. Steht dort bereits ein Wert, der kein Objekt ist, // wird er ersetzt - anders liesse sich der Schluessel nicht // abbilden. for (int i = 0; i < segments.Length - 1; i++) { string segment = segments[i]; if (node[segment] is JsonObject child) { node = child; } else { var created = new JsonObject(); node[segment] = created; node = created; } } node[segments[segments.Length - 1]] = ToJsonNode(value); } EnsureDirectory(path); var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(path, root.ToJsonString(options), new UTF8Encoding(false)); } /// /// Schreibt Werte in eine Datei im Stil von .env. Vorhandene /// Zeilen werden aktualisiert, unbekannte Schluessel angehaengt; /// Kommentare und Reihenfolge bleiben erhalten. /// public static void WriteEnv(string path, IEnumerable values) { var pending = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var value in values) { pending[ToEnvKey(value.Key)] = value.Value; } var lines = new List(); if (File.Exists(path)) { lines.AddRange(File.ReadAllLines(path)); } for (int i = 0; i < lines.Count; i++) { string line = lines[i]; string trimmed = line.TrimStart(); if (trimmed.Length == 0 || trimmed.StartsWith("#", StringComparison.Ordinal)) continue; int equals = line.IndexOf('='); if (equals <= 0) continue; string key = line.Substring(0, equals).Trim(); if (pending.TryGetValue(key, out string? replacement)) { lines[i] = key + "=" + QuoteEnv(replacement); pending.Remove(key); } } foreach (var entry in pending) { lines.Add(entry.Key + "=" + QuoteEnv(entry.Value)); } EnsureDirectory(path); File.WriteAllLines(path, lines, new UTF8Encoding(false)); } /// Schreibt in das Format, das zum Ziel passt. public static void Write(string path, string format, IEnumerable values) { var list = values as IList ?? new List(values); if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase)) { WriteEnv(path, list); } else { WriteJson(path, list); } // Enthaelt die Datei Geheimnisse, gehoert sie niemandem sonst. foreach (var value in list) { if (value.IsSecret) { RestrictPermissions(path); break; } } } /// /// Beschraenkt die Datei auf den eigenen Benutzer. /// /// Ohne das schreibt .NET mit der Standardmaske - unter Linux /// ueblicherweise 644, also weltlesbar. In dieser Datei stehen der /// Lizenzschluessel und das Token der Anwendung; jeder Benutzer des /// Systems koennte sie lesen. Der Lizenz-Cache in /// wird aus demselben Grund auf 600 gesetzt. /// /// Unter Windows erben Dateien in %APPDATA% die Zugriffsrechte des /// Profils; dort ist nichts zu tun. /// public static void RestrictPermissions(string path) { if (OperatingSystemHelpers.IsWindows()) return; #if NET8_0_OR_GREATER try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { // Auf Dateisystemen ohne Unix-Rechte schlicht nicht anwendbar. } #else try { var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "chmod", Arguments = $"600 \"{path}\"", UseShellExecute = false, CreateNoWindow = true }); proc?.WaitForExit(); } catch { } #endif } /// /// Liest einen bereits vorhandenen Wert aus einer JSON-Datei, damit der /// Installer nicht nach etwas fragt, das schon eingerichtet ist. /// Liefert null, wenn die Datei fehlt oder der Schluessel nicht gesetzt ist. /// public static string? ReadExistingJson(string path, string key) { try { if (!File.Exists(path)) return null; JsonNode? node = JsonNode.Parse(File.ReadAllText(path)); foreach (string segment in SplitKey(key)) { if (node is not JsonObject obj) return null; node = obj[segment]; if (node == null) return null; } if (node is JsonValue value) { return value.ToString(); } return null; } catch { return null; } } // ------------------------------------------------------------------ private static JsonObject LoadJsonObject(string path) { if (!File.Exists(path)) return new JsonObject(); try { string content = File.ReadAllText(path); if (string.IsNullOrWhiteSpace(content)) return new JsonObject(); // Kommentare und nachgestellte Kommata sind in appsettings.json // verbreitet, obwohl JSON sie nicht kennt. var options = new JsonNodeOptions { PropertyNameCaseInsensitive = false }; var documentOptions = new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; JsonNode? parsed = JsonNode.Parse(content, options, documentOptions); return parsed as JsonObject ?? new JsonObject(); } catch (JsonException) { // Eine unlesbare Datei zu ueberschreiben hiesse, unbekannten // Inhalt zu verwerfen. Stattdessen wird sie zur Seite gelegt. string backup = path + ".unlesbar-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); try { File.Copy(path, backup, overwrite: false); } catch { } return new JsonObject(); } } private static JsonNode? ToJsonNode(SetupValue value) { switch ((value.Type ?? string.Empty).ToLowerInvariant()) { case "int": return long.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long number) ? JsonValue.Create(number) : JsonValue.Create(value.Value); case "bool": return bool.TryParse(value.Value, out bool flag) ? JsonValue.Create(flag) : JsonValue.Create(value.Value); default: return JsonValue.Create(value.Value); } } private static string[] SplitKey(string key) { return (key ?? string.Empty) .Split(new[] { ':', '.' }, StringSplitOptions.RemoveEmptyEntries); } /// /// "ConnectionStrings:Main" wird zu CONNECTIONSTRINGS__MAIN - die /// Schreibweise, die .NET fuer Umgebungsvariablen erwartet. /// private static string ToEnvKey(string key) { if (string.IsNullOrEmpty(key)) return key ?? string.Empty; // Enthaelt der Schluessel bereits Unterstriche und keine Trenner, // ist er vermutlich schon eine Umgebungsvariable. if (key.IndexOf(':') < 0 && key.IndexOf('.') < 0) return key; return string.Join("__", SplitKey(key).Select(s => s.ToUpperInvariant())); } private static string QuoteEnv(string value) { if (value == null) return string.Empty; bool needsQuotes = value.Length == 0 || value.IndexOfAny(new[] { ' ', '\t', '"', '\'', '#', '$' }) >= 0; if (!needsQuotes) return value; return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; } private static void EnsureDirectory(string path) { string? dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } } } }