diff --git a/.gitignore b/.gitignore index ed038a6..53f963f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ scratch/ # Laufzeitsperren der CLI-Skripte var/*.lock +# Gebaute Installer-Binaries (rund 100 MB je Durchlauf). Erzeugt von +# scripts/build_installer.ps1, ausgeliefert nach /installer/ auf dem Server. +artifacts/ *.bak .DS_Store bin/ diff --git a/.htaccess b/.htaccess index ec5f0e1..5ff373a 100644 --- a/.htaccess +++ b/.htaccess @@ -33,6 +33,7 @@ Options -Indexes RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA] RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA] RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA] + RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA] # Fallback fuer direkt adressierte Dateien unterhalb von /api/ RewriteRule ^api/(.*)$ public/api/$1 [L,QSA] diff --git a/cli/tick.php b/cli/tick.php index 83a3c32..4d9635c 100644 --- a/cli/tick.php +++ b/cli/tick.php @@ -129,6 +129,14 @@ if (!$options['no-lock']) { // ohnehin frei. Fuer den regulaeren Weg steht die Freigabe am Ende. $startedAt = microtime(true); $db = Db::init(); + +if ($options['report']) { + $reportRes = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($db, true); + if (!$options['quiet']) { + printf("Rocket.Chat Statusbericht: %s (%s)\n", $reportRes['sent'] ? 'ERFOLGREICH' : 'FEHLER', $reportRes['reason']); + } +} + $result = Evaluator::run($db); $wallMs = (int)round((microtime(true) - $startedAt) * 1000); @@ -154,7 +162,7 @@ exit(0); */ function parseArguments(array $argv): array { - $options = ['help' => false, 'json' => false, 'quiet' => false, 'no-lock' => false]; + $options = ['help' => false, 'json' => false, 'quiet' => false, 'no-lock' => false, 'report' => false]; foreach (array_slice($argv, 1) as $arg) { switch ($arg) { @@ -173,6 +181,11 @@ function parseArguments(array $argv): array case '--no-lock': $options['no-lock'] = true; break; + case '-r': + case '--report': + case '--rocketchat-report': + $options['report'] = true; + break; default: fwrite(STDERR, "Unbekannte Option: {$arg}\n\n"); printUsage(); diff --git a/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs b/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs new file mode 100644 index 0000000..75271ec --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Models/SetupDefinition.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Deploymentcenter.Client.Models +{ + /// + /// Beschreibt, was eine Anwendung zum Laufen braucht - als Datei + /// setup.json im Wurzelverzeichnis des Pakets, neben der + /// manifest.json. + /// + /// Bewusst im Paket und nicht zentral im Deploymentcenter: so ist die + /// Beschreibung mit der Anwendung versioniert. Braucht Version 2.0 ein + /// Feld mehr als 1.9, stimmt es automatisch - eine zweite Pflegestelle + /// wuerde frueher oder spaeter auseinanderlaufen. + /// + /// Fehlt die Datei, laesst sich die Anwendung trotzdem installieren; der + /// Installer entpackt sie dann nur und fragt nichts ab. + /// + public class SetupDefinition + { + /// Format-Version dieser Datei. + [JsonPropertyName("schema")] + public int Schema { get; set; } = 1; + + /// Anzeigename, sonst wird der Projekt-Slug verwendet. + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Kurzer Hinweistext, der vor der Abfrage angezeigt wird. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// Wohin die ermittelten Werte geschrieben werden. + [JsonPropertyName("targets")] + public List Targets { get; set; } = new List(); + + /// Die benoetigten Werte. + [JsonPropertyName("fields")] + public List Fields { get; set; } = new List(); + } + + /// Eine Datei, in die Werte geschrieben werden. + public class SetupTarget + { + /// Pfad relativ zum Installationsverzeichnis. + [JsonPropertyName("file")] + public string File { get; set; } = string.Empty; + + /// "json" oder "env". + [JsonPropertyName("format")] + public string Format { get; set; } = "json"; + + /// + /// Kennung, ueber die Felder dieser Datei zugeordnet werden. Ohne + /// Angabe schreiben alle Felder in das erste Ziel. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + } + + /// Ein einzelner Konfigurationswert. + public class SetupField + { + /// + /// Schluessel im Ziel. Bei JSON trennt ein Doppelpunkt die Ebenen + /// ("ConnectionStrings:Main"), passend zur Schreibweise von + /// Microsoft.Extensions.Configuration. + /// + [JsonPropertyName("key")] + public string Key { get; set; } = string.Empty; + + /// Beschriftung fuer die Abfrage. + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Erlaeuterung, die unter der Frage steht. + [JsonPropertyName("help")] + public string Help { get; set; } = string.Empty; + + /// string | secret | url | int | bool | enum + [JsonPropertyName("type")] + public string Type { get; set; } = "string"; + + /// Muss ein Wert vorliegen? + [JsonPropertyName("required")] + public bool Required { get; set; } = true; + + /// Vorbelegung. + [JsonPropertyName("default")] + public string Default { get; set; } = string.Empty; + + /// + /// Woher der Wert kommt, wenn nicht gefragt werden soll: + /// detect:hostname | detect:platform | detect:installdir | detect:username + /// provision - Token beim Deploymentcenter anfordern + /// ask - Vorgabe: nachfragen + /// + [JsonPropertyName("source")] + public string Source { get; set; } = "ask"; + + /// Rechte fuer source = provision. + [JsonPropertyName("scopes")] + public List Scopes { get; set; } = new List(); + + /// Auswahlmoeglichkeiten fuer type = enum. + [JsonPropertyName("options")] + public List Options { get; set; } = new List(); + + /// Regulaerer Ausdruck, gegen den der Wert geprueft wird. + [JsonPropertyName("validate")] + public string Validate { get; set; } = string.Empty; + + /// Meldung, wenn die Pruefung fehlschlaegt. + [JsonPropertyName("validationMessage")] + public string ValidationMessage { get; set; } = string.Empty; + + /// Kennung des Ziels aus . + [JsonPropertyName("target")] + public string Target { get; set; } = string.Empty; + + /// Wird der Wert verdeckt eingegeben und nicht angezeigt? + [JsonIgnore] + public bool IsSecret => + string.Equals(Type, "secret", StringComparison.OrdinalIgnoreCase) + || string.Equals(Source, "provision", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/client-dotnet/Deploymentcenter.Client/SetupClient.cs b/client-dotnet/Deploymentcenter.Client/SetupClient.cs new file mode 100644 index 0000000..ff8ba3f --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/SetupClient.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Deploymentcenter.Client +{ + /// Ergebnis einer Setup-Anmeldung. + public sealed class SetupSession + { + public string Token { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } + + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; + + /// Verbleibende Gueltigkeit, nie negativ. + public TimeSpan Remaining + { + get + { + var left = ExpiresAt - DateTimeOffset.UtcNow; + return left < TimeSpan.Zero ? TimeSpan.Zero : left; + } + } + } + + /// Eine installierbare Anwendung aus dem Katalog. + public sealed class CatalogEntry + { + public string Slug { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Notes { get; set; } + public List Channels { get; set; } = new List(); + } + + /// Ein Kanal mit dem dort neuesten Release. + public sealed class CatalogChannel + { + public string Channel { get; set; } = "prod"; + public string Version { get; set; } = string.Empty; + public string Platform { get; set; } = PlatformId.Any; + public long SizeBytes { get; set; } + public bool IsCritical { get; set; } + public bool Signed { get; set; } + public string? ReleaseNotes { get; set; } + public string DownloadUrl { get; set; } = string.Empty; + } + + /// Fehler beim Ansprechen der Setup-Schnittstelle. + public sealed class SetupException : Exception + { + public SetupException(string message, string code = "") : base(message) + { + Code = code; + } + + /// Stabiler Fehlercode der API, sofern vorhanden. + public string Code { get; } + } + + /// + /// Spricht /api/setup/v1/* an: anmelden, Katalog holen, Anwendungstoken + /// ausstellen lassen. + /// + /// Das Setup-Token lebt ausschliesslich im Speicher dieses Objekts. Es + /// wird nirgends abgelegt - es entsteht aus Zugangsdaten, die auf einem + /// fremden Rechner eingegeben wurden, und laeuft nach 30 Minuten ohnehin ab. + /// + public sealed class SetupClient + { + private readonly HttpClient _http; + private readonly string _baseUrl; + + public SetupClient(string baseUrl, HttpClient? httpClient = null) + { + _baseUrl = (baseUrl ?? string.Empty).TrimEnd('/'); + _http = httpClient ?? new HttpClient(); + } + + public SetupSession? Session { get; private set; } + + /// Meldet sich an und merkt sich das Token fuer die Folgeaufrufe. + public async Task LoginAsync( + string username, + string password, + string? hostname = null, + CancellationToken cancellationToken = default) + { + var payload = new Dictionary + { + ["username"] = username, + ["password"] = password, + ["hostname"] = hostname ?? SafeHostName() + }; + + using var doc = await PostAsync("login", payload, useToken: false, cancellationToken) + .ConfigureAwait(false); + + var root = doc.RootElement; + + var session = new SetupSession + { + Token = root.TryGetProperty("setup_token", out var t) ? (t.GetString() ?? string.Empty) : string.Empty, + Role = root.TryGetProperty("role", out var r) ? (r.GetString() ?? string.Empty) : string.Empty, + }; + + int expiresIn = root.TryGetProperty("expires_in", out var e) && e.TryGetInt32(out int seconds) + ? seconds + : 1800; + + // Etwas Sicherheitsabstand, damit ein Aufruf nicht genau auf der + // Ablaufgrenze scheitert. + session.ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(30, expiresIn - 15)); + + if (session.Token.Length == 0) + { + throw new SetupException("Die Anmeldung lieferte kein Token.", "no_token"); + } + + Session = session; + return session; + } + + /// Holt die auf dieser Plattform installierbaren Anwendungen. + public async Task> GetCatalogAsync( + string? platform = null, + CancellationToken cancellationToken = default) + { + RequireSession(); + + string rid = PlatformId.Normalize(platform ?? PlatformId.Current); + string url = $"{_baseUrl}/api/setup/v1/catalog?platform={Uri.EscapeDataString(rid)}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("Authorization", "Bearer " + Session!.Token); + + using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + using var doc = ParseOrThrow(body, response.IsSuccessStatusCode, (int)response.StatusCode); + + var result = new List(); + + if (!doc.RootElement.TryGetProperty("projects", out var projects) + || projects.ValueKind != JsonValueKind.Array) + { + return result; + } + + foreach (var project in projects.EnumerateArray()) + { + var entry = new CatalogEntry + { + Slug = GetString(project, "slug"), + Name = GetString(project, "name"), + Notes = project.TryGetProperty("notes", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() + : null + }; + + if (project.TryGetProperty("channels", out var channels) + && channels.ValueKind == JsonValueKind.Array) + { + foreach (var channel in channels.EnumerateArray()) + { + entry.Channels.Add(new CatalogChannel + { + Channel = GetString(channel, "channel"), + Version = GetString(channel, "version"), + Platform = GetString(channel, "platform"), + DownloadUrl = GetString(channel, "download_url"), + ReleaseNotes = channel.TryGetProperty("release_notes", out var rn) && rn.ValueKind == JsonValueKind.String + ? rn.GetString() + : null, + SizeBytes = channel.TryGetProperty("size_bytes", out var sb) && sb.TryGetInt64(out long size) ? size : 0, + IsCritical = channel.TryGetProperty("is_critical", out var ic) && ic.ValueKind == JsonValueKind.True, + Signed = channel.TryGetProperty("signed", out var sg) && sg.ValueKind == JsonValueKind.True + }); + } + } + + result.Add(entry); + } + + return result; + } + + /// + /// Laesst ein Dauertoken fuer die eingerichtete Anwendung ausstellen. + /// Welche Rechte tatsaechlich vergeben werden, entscheidet der Server - + /// die Anfrage kann nur einschraenken. + /// + public async Task RequestApplicationTokenAsync( + string project, + IEnumerable scopes, + string? hostname = null, + CancellationToken cancellationToken = default) + { + RequireSession(); + + var payload = new Dictionary + { + ["project"] = project, + ["scopes"] = new List(scopes), + ["hostname"] = hostname ?? SafeHostName() + }; + + using var doc = await PostAsync("token", payload, useToken: true, cancellationToken) + .ConfigureAwait(false); + + string token = GetString(doc.RootElement, "token"); + + if (token.Length == 0) + { + throw new SetupException("Der Server hat kein Anwendungstoken geliefert.", "no_token"); + } + + return token; + } + + // ------------------------------------------------------------------ + + private void RequireSession() + { + if (Session == null) + { + throw new SetupException("Nicht angemeldet.", "not_authenticated"); + } + + if (Session.IsExpired) + { + throw new SetupException( + "Die Setup-Sitzung ist abgelaufen. Bitte erneut anmelden.", "session_expired"); + } + } + + private async Task PostAsync( + string action, + Dictionary payload, + bool useToken, + CancellationToken cancellationToken) + { + if (useToken) + { + RequireSession(); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/setup/v1/{action}") + { + Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + }; + + if (useToken) + { + request.Headers.Add("Authorization", "Bearer " + Session!.Token); + } + + using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + return ParseOrThrow(body, response.IsSuccessStatusCode, (int)response.StatusCode); + } + + /// + /// Wertet die einheitliche Antwortform aus. Bei einem Fehler wird die + /// Meldung des Servers weitergereicht - sie ist fuer Menschen gedacht + /// und deutlich hilfreicher als ein blosser Statuscode. + /// + private static JsonDocument ParseOrThrow(string body, bool success, int statusCode) + { + JsonDocument doc; + + try + { + doc = JsonDocument.Parse(body); + } + catch (JsonException) + { + throw new SetupException( + $"Unerwartete Antwort des Servers (HTTP {statusCode}).", "invalid_response"); + } + + if (success) + { + return doc; + } + + string message = $"HTTP {statusCode}"; + string code = string.Empty; + + if (doc.RootElement.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object) + { + code = GetString(error, "code"); + string serverMessage = GetString(error, "message"); + if (serverMessage.Length > 0) + { + message = serverMessage; + } + } + + doc.Dispose(); + throw new SetupException(message, code); + } + + private static string GetString(JsonElement element, string property) + { + return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? (value.GetString() ?? string.Empty) + : string.Empty; + } + + private static string SafeHostName() + { + try + { + return Environment.MachineName; + } + catch + { + return "unbekannt"; + } + } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/SetupWriter.cs b/client-dotnet/Deploymentcenter.Client/SetupWriter.cs new file mode 100644 index 0000000..db99473 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/SetupWriter.cs @@ -0,0 +1,286 @@ +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 + { + public SetupValue(string key, string value, string type = "string") + { + Key = key; + Value = value; + Type = type; + } + + /// Schluessel, Ebenen durch Doppelpunkt getrennt. + public string Key { get; } + + public string Value { get; } + + /// string | secret | url | int | bool | enum + public string Type { get; } + } + + /// + /// 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) + { + if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase)) + { + WriteEnv(path, values); + return; + } + + WriteJson(path, values); + } + + /// + /// 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); + } + } + } +} diff --git a/client-dotnet/Deploymentcenter.Packager/Program.cs b/client-dotnet/Deploymentcenter.Packager/Program.cs index dc78078..7424fac 100644 --- a/client-dotnet/Deploymentcenter.Packager/Program.cs +++ b/client-dotnet/Deploymentcenter.Packager/Program.cs @@ -358,6 +358,8 @@ namespace Deploymentcenter.Packager try { using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort); + ftp.Config.EncryptionMode = FtpEncryptionMode.Explicit; + ftp.Config.ValidateAnyCertificate = true; await ftp.Connect(); await ftp.CreateDirectory(remoteVersionPath); diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs new file mode 100644 index 0000000..39c47ad --- /dev/null +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Installation.cs @@ -0,0 +1,675 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Deploymentcenter.Client; +using Deploymentcenter.Client.Models; +using Spectre.Console; + +namespace Deploymentcenter.UpdateAgent +{ + /// + /// Erstinstallation einer Anwendung. + /// + /// Der Ablauf ist bewusst so geschnitten, dass er den vorhandenen + /// Update-Weg wiederverwendet: erst kommen die Dateien ins Zielverzeichnis + /// (mit Pruefsumme, Signatur und Rollback wie bei jedem Update), danach + /// wird konfiguriert. Ein zweiter Download-Pfad waere ein zweiter Ort, an + /// dem dieselben Fehler wieder entstehen koennen. + /// + /// Gefragt wird nur nach dem, was sich nicht anders ermitteln laesst - + /// siehe . + /// + internal static class Installation + { + private const string SetupFileName = "setup.json"; + + public static async Task RunAsync(AgentOptions options, HttpClient http) + { + AnsiConsole.Write( + new FigletText("Installer") + .LeftJustified() + .Color(Color.DodgerBlue1)); + + // ---------------------------------------------------------- + // 1. Anmeldung + // ---------------------------------------------------------- + string baseUrl = AnsiConsole.Prompt( + new TextPrompt("[bold]Deploymentcenter[/]:") + .DefaultValue(options.BaseUrl) + .Validate(value => value.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? ValidationResult.Success() + : ValidationResult.Error("[red]Bitte eine vollstaendige Adresse angeben.[/]"))); + + options.BaseUrl = baseUrl.TrimEnd('/'); + + var client = new SetupClient(options.BaseUrl, http); + SetupSession session; + + while (true) + { + string username = AnsiConsole.Prompt(new TextPrompt("[bold]Benutzer[/]:")); + string password = AnsiConsole.Prompt(new TextPrompt("[bold]Passwort[/]:").Secret()); + + try + { + session = await client.LoginAsync(username, password, Environment.MachineName); + break; + } + catch (SetupException ex) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); + + if (ex.Code == "locked_out" || ex.Code == "rate_limited") + { + return 1; + } + + if (!AnsiConsole.Confirm("Erneut versuchen?")) + { + return 1; + } + } + catch (HttpRequestException ex) + { + AnsiConsole.MarkupLine($"[red]Keine Verbindung zum Deploymentcenter: {Markup.Escape(ex.Message)}[/]"); + return 1; + } + } + + AnsiConsole.MarkupLine($"[green]Angemeldet.[/] [grey]Die Sitzung laeuft in " + + $"{(int)session.Remaining.TotalMinutes} Minuten ab.[/]"); + + if (session.Role == "admin") + { + AnsiConsole.MarkupLine( + "[yellow]Hinweis: Das ist ein Administratorkonto. Fuer Zielsysteme ist ein Konto " + + "der Rolle [bold]installer[/] gedacht - es kann sich nicht am WebUI anmelden.[/]"); + } + + // ---------------------------------------------------------- + // 2. Auswahl + // ---------------------------------------------------------- + List catalog; + try + { + catalog = await client.GetCatalogAsync(options.Platform); + } + catch (SetupException ex) + { + AnsiConsole.MarkupLine($"[red]Katalog nicht abrufbar: {Markup.Escape(ex.Message)}[/]"); + return 1; + } + + if (catalog.Count == 0) + { + AnsiConsole.MarkupLine( + $"[yellow]Fuer die Plattform [bold]{Markup.Escape(options.Platform)}[/] ist nichts hinterlegt.[/]"); + AnsiConsole.MarkupLine("[grey]Releases ohne Plattformangabe gelten als \"any\" und waeren hier " + + "sichtbar. Moeglicherweise wurde nur fuer eine andere Plattform veroeffentlicht.[/]"); + return 1; + } + + var chosen = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("\n[bold]Welche Anwendung soll installiert werden?[/]") + .PageSize(12) + .UseConverter(entry => + { + string versions = string.Join(", ", + entry.Channels.Select(c => $"{c.Channel} {c.Version}")); + return $"{Markup.Escape(entry.Name)} [grey]({Markup.Escape(versions)})[/]"; + }) + .AddChoices(catalog)); + + CatalogChannel channel = chosen.Channels.Count == 1 + ? chosen.Channels[0] + : AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"\n[bold]Welcher Kanal von {Markup.Escape(chosen.Name)}?[/]") + .UseConverter(c => + { + string mark = c.Signed ? "" : " [yellow](unsigniert)[/]"; + return $"{c.Channel} - v{c.Version}{mark}"; + }) + .AddChoices(chosen.Channels)); + + if (!channel.Signed) + { + AnsiConsole.MarkupLine("[yellow]Dieses Release ist nicht signiert - die Herkunft laesst sich " + + "nicht pruefen.[/]"); + if (options.RequireSignature) + { + AnsiConsole.MarkupLine("[red]Abbruch: --require-signature ist gesetzt.[/]"); + return 1; + } + } + + // ---------------------------------------------------------- + // 3. Zielverzeichnis + // ---------------------------------------------------------- + string suggested = SuggestTargetDirectory(chosen.Slug); + + string targetDir = AnsiConsole.Prompt( + new TextPrompt("[bold]Zielverzeichnis[/]:").DefaultValue(suggested)); + + targetDir = Path.GetFullPath(targetDir); + + if (Directory.Exists(targetDir) && Directory.GetFileSystemEntries(targetDir).Length > 0) + { + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(targetDir)} ist nicht leer.[/]"); + if (!AnsiConsole.Confirm("Trotzdem dorthin installieren?", false)) + { + return 1; + } + } + + options.Project = chosen.Slug; + options.Channel = channel.Channel; + options.TargetDir = targetDir; + + // ---------------------------------------------------------- + // 4. Dateien uebertragen - ueber den regulaeren Update-Weg + // ---------------------------------------------------------- + AnsiConsole.MarkupLine($"\n[bold]Installiere {Markup.Escape(chosen.Name)} v{channel.Version} " + + $"nach {Markup.Escape(targetDir)}[/]\n"); + + int fileResult = await Program.DoUpdateOrRepair(options, channel.Version, null); + if (fileResult != 0) + { + return fileResult; + } + + // ---------------------------------------------------------- + // 5. Einrichten + // ---------------------------------------------------------- + return await ConfigureAsync(options, client, chosen.Slug, interactive: true); + } + + /// + /// Nur die Einrichtung, ohne Dateien anzufassen. Nuetzlich, wenn die + /// Installation stand, die Konfiguration aber nachgeholt oder + /// korrigiert werden muss. + /// + public static async Task ConfigureOnlyAsync(AgentOptions options, HttpClient http) + { + string setupPath = Path.Combine(options.TargetDir, SetupFileName); + + if (!File.Exists(setupPath)) + { + AnsiConsole.MarkupLine($"[yellow]In {Markup.Escape(options.TargetDir)} liegt keine " + + $"{SetupFileName} - es gibt nichts einzurichten.[/]"); + return 0; + } + + var client = new SetupClient(options.BaseUrl, http); + + // Ein Token wird nur gebraucht, wenn die Beschreibung ein Feld mit + // source = provision enthaelt. Danach richtet sich, ob ueberhaupt + // nach Zugangsdaten gefragt wird. + var definition = ReadDefinition(setupPath); + if (definition == null) + { + return 1; + } + + bool needsToken = definition.Fields.Any(f => + string.Equals(f.Source, "provision", StringComparison.OrdinalIgnoreCase)); + + if (needsToken) + { + AnsiConsole.MarkupLine("[grey]Fuer ein Anwendungstoken ist eine Anmeldung noetig.[/]"); + + string username = AnsiConsole.Prompt(new TextPrompt("[bold]Benutzer[/]:")); + string password = AnsiConsole.Prompt(new TextPrompt("[bold]Passwort[/]:").Secret()); + + try + { + await client.LoginAsync(username, password, Environment.MachineName); + } + catch (SetupException ex) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); + return 1; + } + } + + return await ConfigureAsync(options, client, options.Project, interactive: true); + } + + // ------------------------------------------------------------------ + + private static async Task ConfigureAsync( + AgentOptions options, + SetupClient client, + string project, + bool interactive) + { + string setupPath = Path.Combine(options.TargetDir, SetupFileName); + + if (!File.Exists(setupPath)) + { + AnsiConsole.MarkupLine($"\n[green]Fertig.[/] [grey]Das Paket bringt keine {SetupFileName} mit, " + + "es ist also nichts einzurichten.[/]"); + return 0; + } + + var definition = ReadDefinition(setupPath); + if (definition == null) + { + return 1; + } + + if (definition.Schema > 1) + { + AnsiConsole.MarkupLine($"[yellow]Die {SetupFileName} verwendet Schema {definition.Schema}; " + + "dieser Installer kennt Schema 1. Unbekannte Angaben werden uebergangen.[/]"); + } + + AnsiConsole.Write(new Rule("[bold]Einrichtung[/]").LeftJustified()); + + if (definition.Description.Length > 0) + { + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(definition.Description)}[/]\n"); + } + + var resolved = await ResolveFields(definition, options, client, project, interactive); + if (resolved == null) + { + return 1; + } + + // Werte den Zieldateien zuordnen und schreiben. + var targets = definition.Targets.Count > 0 + ? definition.Targets + : new List { new SetupTarget { File = "appsettings.json", Format = "json" } }; + + foreach (var target in targets) + { + var forTarget = resolved + .Where(pair => BelongsToTarget(pair.Field, target, targets)) + .Select(pair => new SetupValue(pair.Field.Key, pair.Value, pair.Field.Type)) + .ToList(); + + if (forTarget.Count == 0) + continue; + + string path = Path.Combine(options.TargetDir, target.File.Replace('/', Path.DirectorySeparatorChar)); + + try + { + SetupWriter.Write(path, target.Format, forTarget); + AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(target.File)} " + + $"[grey]({forTarget.Count} Wert(e))[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(target.File)} konnte nicht geschrieben werden: " + + $"{Markup.Escape(ex.Message)}[/]"); + return 1; + } + } + + AnsiConsole.MarkupLine("\n[bold green]Installation abgeschlossen.[/]"); + AnsiConsole.MarkupLine($"[grey]Verzeichnis: {Markup.Escape(options.TargetDir)}[/]"); + + return 0; + } + + /// + /// Ermittelt jeden Wert und fragt nur nach dem, was uebrig bleibt. + /// + /// Reihenfolge je Feld: + /// 1. bereits in der Zieldatei gesetzt -> uebernehmen, nicht fragen + /// 2. source = detect:... -> aus dem System ableiten + /// 3. source = provision -> Token beim Server holen + /// 4. sonst -> fragen, mit default vorbelegt + /// + private static async Task?> ResolveFields( + SetupDefinition definition, + AgentOptions options, + SetupClient client, + string project, + bool interactive) + { + var resolved = new List<(SetupField, string)>(); + var targets = definition.Targets; + + int answered = 0; + int automatic = 0; + int kept = 0; + + foreach (var field in definition.Fields) + { + if (string.IsNullOrWhiteSpace(field.Key)) + continue; + + // 1. Schon eingerichtet? + var target = FindTarget(field, targets); + if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase)) + { + string path = Path.Combine(options.TargetDir, + target.File.Replace('/', Path.DirectorySeparatorChar)); + + string? existing = SetupWriter.ReadExistingJson(path, field.Key); + + if (!string.IsNullOrWhiteSpace(existing) && !IsPlaceholder(existing!)) + { + AnsiConsole.MarkupLine($"[grey]Bereits gesetzt:[/] {Markup.Escape(field.Key)}"); + kept++; + continue; + } + } + + // 2. Ableitbar? + string? detected = Detect(field.Source, options, project); + if (detected != null) + { + resolved.Add((field, detected)); + AnsiConsole.MarkupLine($"[grey]Ermittelt:[/] {Markup.Escape(field.Key)} = {Markup.Escape(detected)}"); + automatic++; + continue; + } + + // 3. Token anfordern? + if (string.Equals(field.Source, "provision", StringComparison.OrdinalIgnoreCase)) + { + try + { + var scopes = field.Scopes.Count > 0 ? field.Scopes : new List { "watchdog:ping" }; + string token = await client.RequestApplicationTokenAsync( + project, scopes, Environment.MachineName); + + resolved.Add((field, token)); + AnsiConsole.MarkupLine($"[grey]Token ausgestellt:[/] {Markup.Escape(field.Key)} " + + $"[grey]({Markup.Escape(string.Join(", ", scopes))})[/]"); + automatic++; + continue; + } + catch (SetupException ex) + { + AnsiConsole.MarkupLine($"[red]Token fuer {Markup.Escape(field.Key)} nicht erhalten: " + + $"{Markup.Escape(ex.Message)}[/]"); + + if (field.Required) + { + return null; + } + + continue; + } + } + + // 4. Fragen. + if (!interactive) + { + if (field.Default.Length > 0) + { + resolved.Add((field, field.Default)); + continue; + } + + if (field.Required) + { + AnsiConsole.MarkupLine($"[red]Ohne Rueckfrage nicht ermittelbar: {Markup.Escape(field.Key)}[/]"); + return null; + } + + continue; + } + + string? answer = Ask(field); + + if (answer == null) + { + // Nicht ausgefuellt und nicht erforderlich. + continue; + } + + resolved.Add((field, answer)); + answered++; + } + + AnsiConsole.MarkupLine($"\n[grey]{automatic} Wert(e) automatisch ermittelt, {kept} bereits " + + $"vorhanden, {answered} abgefragt.[/]"); + + return resolved.Select(r => (r.Item1, r.Item2)).ToList(); + } + + private static string? Ask(SetupField field) + { + string label = field.Label.Length > 0 ? field.Label : field.Key; + + if (field.Help.Length > 0) + { + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(field.Help)}[/]"); + } + + // Auswahl aus festen Moeglichkeiten. + if (string.Equals(field.Type, "enum", StringComparison.OrdinalIgnoreCase) && field.Options.Count > 0) + { + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[bold]{Markup.Escape(label)}[/]:") + .AddChoices(field.Options)); + } + + if (string.Equals(field.Type, "bool", StringComparison.OrdinalIgnoreCase)) + { + bool preset = bool.TryParse(field.Default, out bool parsed) && parsed; + return AnsiConsole.Confirm($"[bold]{Markup.Escape(label)}[/]?", preset) + ? "true" + : "false"; + } + + var prompt = new TextPrompt($"[bold]{Markup.Escape(label)}[/]:"); + + if (field.IsSecret) + { + prompt.Secret(); + } + + if (field.Default.Length > 0) + { + prompt.DefaultValue(field.Default); + + // Ein vorbelegtes Geheimnis darf nicht im Klartext erscheinen. + if (field.IsSecret) + { + prompt.HideDefaultValue(); + } + } + else if (!field.Required) + { + prompt.AllowEmpty(); + } + + var validate = BuildValidator(field); + if (validate != null) + { + prompt.Validate(validate); + } + + string answer = AnsiConsole.Prompt(prompt); + + return answer.Length == 0 && !field.Required ? null : answer; + } + + private static Func? BuildValidator(SetupField field) + { + Regex? pattern = null; + + if (field.Validate.Length > 0) + { + try + { + pattern = new Regex(field.Validate, RegexOptions.CultureInvariant); + } + catch (ArgumentException) + { + // Ein fehlerhafter Ausdruck in der setup.json darf die + // Installation nicht verhindern - dann wird eben nicht + // geprueft. + AnsiConsole.MarkupLine($"[yellow]Pruefmuster fuer {Markup.Escape(field.Key)} ist ungueltig " + + "und wird uebergangen.[/]"); + } + } + + string type = (field.Type ?? "string").ToLowerInvariant(); + + if (pattern == null && type != "int" && type != "url") + { + return null; + } + + string message = field.ValidationMessage.Length > 0 + ? field.ValidationMessage + : "Die Eingabe passt nicht zum erwarteten Format."; + + return value => + { + if (value.Length == 0 && !field.Required) + { + return ValidationResult.Success(); + } + + if (type == "int" && !long.TryParse(value, out _)) + { + return ValidationResult.Error("[red]Bitte eine ganze Zahl eingeben.[/]"); + } + + if (type == "url" + && !Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + return ValidationResult.Error("[red]Bitte eine vollstaendige Adresse eingeben.[/]"); + } + + if (pattern != null && !pattern.IsMatch(value)) + { + return ValidationResult.Error($"[red]{Markup.Escape(message)}[/]"); + } + + return ValidationResult.Success(); + }; + } + + /// Werte, die sich aus dem System selbst ergeben. + private static string? Detect(string source, AgentOptions options, string project) + { + if (string.IsNullOrWhiteSpace(source) + || !source.StartsWith("detect:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string what = source.Substring("detect:".Length).Trim().ToLowerInvariant(); + + switch (what) + { + case "hostname": return SafeMachineName(); + case "platform": + case "rid": return options.Platform; + case "installdir": return options.TargetDir; + case "project": return project; + case "baseurl": return options.BaseUrl; + case "username": + try { return Environment.UserName; } catch { return null; } + default: + return null; + } + } + + private static SetupTarget? FindTarget(SetupField field, List targets) + { + if (targets.Count == 0) + return null; + + if (field.Target.Length == 0) + return targets[0]; + + return targets.FirstOrDefault(t => + string.Equals(t.Id, field.Target, StringComparison.OrdinalIgnoreCase)) + ?? targets[0]; + } + + private static bool BelongsToTarget(SetupField field, SetupTarget target, List all) + { + var resolved = FindTarget(field, all); + return resolved != null && ReferenceEquals(resolved, target); + } + + /// + /// Erkennt Platzhalter, die zwar in der Datei stehen, aber keinen + /// eingerichteten Wert darstellen. Ohne diese Pruefung wuerde eine + /// mitgelieferte Vorlage als "schon konfiguriert" gelten und die Frage + /// entfiele - die Anwendung liefe dann mit dem Platzhalter los. + /// + private static bool IsPlaceholder(string value) + { + string trimmed = value.Trim(); + + if (trimmed.Length == 0) + return true; + + string[] markers = + { + "changeme", "change-me", "todo", "xxx", "placeholder", "vorlage", + "your-", "dein-", "<", "bitte-", "example.com", "beispiel" + }; + + string lower = trimmed.ToLowerInvariant(); + return markers.Any(marker => lower.Contains(marker)); + } + + private static SetupDefinition? ReadDefinition(string path) + { + try + { + var definition = JsonSerializer.Deserialize(File.ReadAllText(path)); + + if (definition == null) + { + AnsiConsole.MarkupLine($"[red]{SetupFileName} ist leer oder unlesbar.[/]"); + return null; + } + + return definition; + } + catch (JsonException ex) + { + AnsiConsole.MarkupLine($"[red]{SetupFileName} ist kein gueltiges JSON: {Markup.Escape(ex.Message)}[/]"); + return null; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]{SetupFileName} nicht lesbar: {Markup.Escape(ex.Message)}[/]"); + return null; + } + } + + private static string SuggestTargetDirectory(string slug) + { + if (OperatingSystemHelpers.IsWindows()) + { + string programs = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (!string.IsNullOrWhiteSpace(programs)) + { + return Path.Combine(programs, slug); + } + + return Path.Combine(@"C:\Programme", slug); + } + + return "/opt/" + slug; + } + + private static string SafeMachineName() + { + try { return Environment.MachineName; } catch { return "unbekannt"; } + } + } +} diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs index fe22469..0c86fab 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs @@ -114,6 +114,12 @@ namespace Deploymentcenter.UpdateAgent case "list": return await DoList(options); + case "install": + return await Installation.RunAsync(options, HttpClient); + + case "configure": + return await Installation.ConfigureOnlyAsync(options, HttpClient); + default: return ShowHelp(); } @@ -276,7 +282,7 @@ namespace Deploymentcenter.UpdateAgent // Update und Reparatur // ------------------------------------------------------------------ - static async Task DoUpdateOrRepair( + internal static async Task DoUpdateOrRepair( AgentOptions options, string targetVersion, PackageManifest? installedManifest) @@ -1055,7 +1061,8 @@ namespace Deploymentcenter.UpdateAgent Console.WriteLine(); Console.WriteLine(" --project, -p Projekt-Slug"); Console.WriteLine(" --channel, -c prod | beta | dev"); - Console.WriteLine(" --action, -a interactive | check | update | repair | list"); + Console.WriteLine(" --action, -a interactive | install | configure | check |"); + Console.WriteLine(" update | repair | list"); Console.WriteLine(" --version, -v Zielversion oder 'latest'"); Console.WriteLine(" --target-dir, -t Zu aktualisierendes Verzeichnis"); Console.WriteLine(" --platform Laufzeitkennung (Vorgabe: die des Systems)"); diff --git a/config/config.example.php b/config/config.example.php index 901e8a5..4015c93 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -67,4 +67,14 @@ return [ // Wie lange ein Agent ein Item exklusiv beansprucht (Minuten). 'lease_minutes' => 30, ], + 'rocketchat' => [ + 'enabled' => (bool)dc_env('DC_RC_ENABLED', true), + 'url' => dc_env('DC_RC_URL', 'https://chat.wh1.mhdf.de'), + 'username' => dc_env('DC_RC_USER', 'deploymentcenter'), + 'password' => dc_env('DC_RC_PASS', 'cNt.m.KcWHrb8_X9Tv8-'), + 'status_channel' => dc_env('DC_RC_STATUS_CHANNEL', '#DC-Systemstatus'), + 'alert_channel' => dc_env('DC_RC_ALERT_CHANNEL', '#DC-Alerts'), + 'verify_ssl' => (bool)dc_env('DC_RC_VERIFY_SSL', false), + 'report_interval' => (int)dc_env('DC_RC_REPORT_INTERVAL', 43200), + ], ]; diff --git a/docs/README.md b/docs/README.md index 6d584bc..0c43da6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen. - **[Lizenzsystem (Hardware-ID v2)](./LICENSE_INTEGRATION_GUIDE.md)** — Hardware-Anbindung, Schlüsselvalidierung, Offline-Cache, CLI, Windows und Linux/Docker - **[Watchdog (Heartbeat & Telemetrie)](./WATCHDOG_INTEGRATION_GUIDE.md)** — Überwachung von Anwendungen, Diensten und Infrastruktur - **[UpdateService](./UPDATESERVICE_INTEGRATION_GUIDE.md)** — Release-Verteilung und Update-Prüfung +- **[Erstinstallation](./SETUP_INTEGRATION_GUIDE.md)** — `setup.json`, Installationskonto, `update-agent --action install` - **[Bugtracker](./BUGTRACKER_INTEGRATION_GUIDE.md)** — Anbindung aus Anwendungen heraus --- @@ -31,6 +32,7 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen. | **Watchdog** | Heartbeat-Monitoring, Zustandsbewertung, Alarmierung | `/api/watchdog/v1/ping`
`/api/watchdog/v1/evaluate` | Token mit `watchdog:ping` | | **Lizenzen** | Lizenzprüfung, Hardware-ID v2, Offline-Cache | `/api/license/v1/validate`
`/api/license/v1/deactivate` | Validierung offen, Deaktivierung authentifiziert | | **Tokens** | Selbst-Provisionierung von Sub-Tokens | `/api/tokens/v1/provision` | Master-Token | +| **Setup** | Erstinstallation: Anmeldung, Katalog, Anwendungstoken | `/api/setup/v1/login`
`/api/setup/v1/catalog`
`/api/setup/v1/token` | Login offen, Rest `setup:*` | | **System** | Verfügbarkeit, Schema-Status, Schnittstellenbeschreibung | `/api/health`
`/api/openapi.json` | Health optional, OpenAPI offen | --- diff --git a/docs/SETUP_INTEGRATION_GUIDE.md b/docs/SETUP_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..f3bce2f --- /dev/null +++ b/docs/SETUP_INTEGRATION_GUIDE.md @@ -0,0 +1,285 @@ +# Deploymentcenter — Erstinstallation von Anwendungen + +> **Neu in Version 2.3.** Bis dahin gab es nur den Update-Weg: eine Anwendung +> musste bereits installiert und eingerichtet sein, damit sich etwas +> aktualisieren ließ. Die Erstinstallation auf einem neuen System war +> Handarbeit — Paket kopieren, Konfiguration abtippen, Token besorgen, +> Monitoring nachtragen. + +Die Erstinstallation läuft jetzt über denselben `update-agent`, der auch +Updates einspielt. Kein eigener Installer je Anwendung, kein MSI, kein +Setup-Assistent im Anwendungscode. + +```bash +update-agent --action install +``` + +Mehr braucht es nicht. Der Rest ist ein Dialog. + +--- + +## 0. Den Installer besorgen + +Im WebUI unter **UpdateService → ⬇️ Installer**, oder direkt vom Server. + +**Linux** — lädt das zur Architektur passende Binary, prüft die Prüfsumme und legt es ab: + +```bash +wget -qO- https://dc.mhdf.de/installer/install.sh | sh +``` + +Ohne Skript geht es genauso: + +```bash +wget https://dc.mhdf.de/installer/update-agent-linux-x64 -O update-agent +``` + +**Windows** (PowerShell): + +```powershell +irm https://dc.mhdf.de/installer/install.ps1 | iex +``` + +| Datei | Plattform | +|---|---| +| `/installer/update-agent-win-x64.exe` | Windows x64 | +| `/installer/update-agent-linux-x64` | Linux x64 | +| `/installer/update-agent-linux-arm64` | Linux ARM64 | +| `/installer/installer.json` | Version, Größen und Prüfsummen aller Binaries | + +Zu jedem Binary liegt eine `.sha256` daneben. **Die Prüfsumme vergleichen** — +„Programm herunterladen und Zugangsdaten eingeben" ist genau das Muster, das +Phishing nachahmt; die Prüfsumme ist der Grund, warum man es hier trotzdem tun +kann. Die Bootstrap-Skripte tun das automatisch und brechen bei Abweichung ab. + +Die Skripte richten **nichts** von selbst ein. Sie laden, prüfen, legen ab und +sagen, wie es weitergeht — ein Skript aus dem Netz, das ungefragt eine +Anwendung aufsetzt und dabei nach Zugangsdaten fragt, wäre genau das, wovor man +Nutzer sonst warnt. + +Rund 34 MB je Plattform: Das Binary bringt die .NET-Laufzeit mit, damit auf +einem frisch aufgesetzten System nichts vorinstalliert sein muss. Bewusst ohne +NativeAOT und ohne Trimming — Spectre.Console löst seine Eingabeaufforderungen +über Reflexion auf; getrimmt baut das zwar, bricht aber erst beim Anwender. + +### Neu bauen und hochladen + +```bash +pwsh scripts/build_installer.ps1 +python scripts/upload_installer.py ./artifacts/installer +``` + +`deploy.py` klammert `client-dotnet` bewusst aus — der Quelltext des Agenten +gehört nicht auf den Webserver, die übersetzten Binaries schon. Deshalb der +eigene Upload-Weg. + +--- + +## 1. Der Ablauf + +``` +update-agent --action install + ├ Deploymentcenter? https://dc.mhdf.de + ├ Benutzer / Passwort → kurzlebiges Token (30 Minuten) + ├ Auswahl aus dem Katalog → nur, was auf dieser Plattform läuft + ├ Kanal (prod / beta / dev) + ├ Zielverzeichnis → vorbelegt mit /opt/ bzw. Programme\ + ├ Paket laden, Prüfsumme, Signatur, Entpacken ← der reguläre Update-Weg + ├ setup.json auflösen → fragt nur, was übrig bleibt + └ Konfiguration schreiben +``` + +Die Dateien kommen über **denselben Pfad wie ein Update** ins Zielverzeichnis: +mit SHA256-Prüfung, Signaturprüfung, Staging und Rollback. Ein zweiter +Download-Weg wäre ein zweiter Ort, an dem dieselben Fehler wieder entstehen. + +--- + +## 2. Das Installationskonto + +Für Zielsysteme gibt es die Rolle **`installer`**. Im WebUI unter +**System → 👤 Benutzer** anzulegen. + +Ein solches Konto kann genau zwei Dinge: sich über `/api/setup/v1/login` +anmelden und Anwendungen einrichten. **Am WebUI kann es sich nicht anmelden** — +der Versuch wird abgewiesen und protokolliert. + +Das ist der Kern der Sache: Die Zugangsdaten werden auf jedem Zielsystem +eingetippt, auf dem je etwas installiert wird. Mit einem Administratorkonto +verteilte man damit den Zugang zu Tokens, Lizenzen, Monitoren und dem +Bugtracker auf all diese Rechner. + +| | admin | installer | +|---|---|---| +| WebUI | ja | **nein** | +| `/api/setup/v1/*` | ja | ja | +| Token ausstellen | alle Rechte | nur `watchdog:ping`, `watchdog:read`, `bugtracker:report`, `updateservice:read` | + +> **Noch offen: der zweite Faktor.** `dc_users.totp_secret` existiert als +> Spalte, wird aber von keinem Anmeldeweg ausgewertet — es gibt bislang keine +> TOTP-Prüfung im Deploymentcenter. Ein Konto mit hinterlegtem Geheimnis wird +> von der Setup-Anmeldung deshalb ausdrücklich **abgelehnt** (`501 +> totp_not_supported`), statt den zweiten Faktor stillschweigend zu übergehen. +> Bis das nachgezogen ist, tragen ein langes Passwort und die enge +> Rechtevergabe die Sicherheit. + +--- + +## 3. `setup.json` + +Die Datei beschreibt, was eine Anwendung zum Laufen braucht. Sie gehört in das +Publish-Verzeichnis und landet damit im Paket, neben der `manifest.json`. + +**Bewusst im Paket und nicht zentral im Deploymentcenter.** So ist die +Beschreibung mit der Anwendung versioniert: Braucht Version 2.0 ein Feld mehr +als 1.9, stimmt es automatisch. Eine zweite Pflegestelle liefe früher oder +später auseinander. + +Vollständige Vorlage: **[setup.example.json](./setup.example.json)** + +```json +{ + "schema": 1, + "displayName": "Beispielanwendung", + "targets": [ + { "id": "app", "file": "appsettings.json", "format": "json" } + ], + "fields": [ + { "key": "ConnectionStrings:Main", "label": "Datenbank", "type": "secret" }, + { "key": "Deploymentcenter:BaseUrl", "source": "detect:baseurl", "type": "url" }, + { "key": "Deploymentcenter:Token", "source": "provision", + "scopes": ["watchdog:ping"] } + ] +} +``` + +Fehlt die Datei, lässt sich die Anwendung trotzdem installieren — der Installer +entpackt sie dann nur und fragt nichts ab. + +### Felder + +| Eigenschaft | Bedeutung | +|---|---| +| `key` | Schlüssel im Ziel. Doppelpunkte trennen Ebenen (`ConnectionStrings:Main`) — die Schreibweise von `Microsoft.Extensions.Configuration` | +| `label` | Beschriftung der Frage | +| `help` | Erläuterung, die darüber steht | +| `type` | `string`, `secret`, `url`, `int`, `bool`, `enum` | +| `required` | Vorgabe `true` | +| `default` | Vorbelegung der Eingabe | +| `source` | `ask` (Vorgabe), `detect:…`, `provision` | +| `scopes` | Rechte, wenn `source: "provision"` | +| `options` | Auswahl bei `type: "enum"` | +| `validate` | Regulärer Ausdruck | +| `target` | `id` des Ziels, wenn es mehrere gibt | + +`type` bestimmt auch, **wie** geschrieben wird: `int` und `bool` landen als +JSON-Zahl beziehungsweise -Wahrheitswert, nicht als Zeichenkette. Sonst +scheitert die Bindung in der Anwendung. + +### Nur die wirklich fehlenden Informationen + +Je Feld gilt die erste zutreffende Regel: + +| Regel | fragt | +|---|---| +| Wert steht schon in der Zieldatei und ist kein Platzhalter | nein | +| `source: "detect:…"` | nein | +| `source: "provision"` | nein | +| sonst | **ja**, mit `default` vorbelegt | + +Erkannte Platzhalter (`changeme`, `TODO`, `your-…`, `<…>`, `example.com` und +ähnliche) gelten **nicht** als eingerichteter Wert — sonst liefe die Anwendung +mit der ausgelieferten Vorlage los. + +Ableitbare Werte: `detect:hostname`, `detect:platform`, `detect:installdir`, +`detect:project`, `detect:baseurl`, `detect:username`. + +### Das Token der Anwendung + +`source: "provision"` lässt den Server ein Dauertoken ausstellen, das an das +Projekt und den Rechnernamen gebunden ist. Der Installer schreibt es in das +angegebene Feld; niemand muss es sehen oder kopieren. + +Welche Rechte vergeben werden, entscheidet **der Server**, nicht die Anfrage. +Die `scopes` aus der `setup.json` können nur einschränken. Andernfalls wäre der +Umweg über ein kurzlebiges Setup-Token wirkungslos — wer einmal installieren +darf, stellte sich sonst ein Token mit allen Rechten aus und behielte es. + +--- + +## 4. Konfiguration schreiben + +**Vorhandene Inhalte bleiben erhalten.** Eine `appsettings.json` enthält neben +den abgefragten Werten fast immer Logging-Einstellungen, Feature-Schalter und +anderes. Die Datei neu zu erzeugen wäre der bequemere Weg und verlöre all das — +und zwar bei einer Neuinstallation, bei der niemand ein Backup hat. + +- **JSON**: Ebenen werden aus dem Schlüssel gebildet, bestehende Zweige bleiben + stehen. Kommentare und nachgestellte Kommata werden beim Lesen toleriert. +- **env**: bestehende Zeilen werden ersetzt, unbekannte Schlüssel angehängt. + Kommentare und Reihenfolge bleiben erhalten. `A:B` wird zu `A__B`. + +Ist eine vorhandene Datei kein gültiges JSON, wird sie als +`.unlesbar-` zur Seite gelegt statt überschrieben. + +Zusammenspiel mit `preservePatterns` des Packagers: Die Vorlage wird +ausgeliefert (Erstinstallation bekommt sie), ein Update ersetzt sie nicht, und +der Installer schreibt die abgefragten Werte hinein. Siehe +**[UPDATESERVICE_INTEGRATION_GUIDE §3A](./UPDATESERVICE_INTEGRATION_GUIDE.md#3a-ausschließen-oder-schützen)**. + +--- + +## 5. Nachträglich einrichten + +```bash +update-agent --action configure --target-dir /opt/myapp --project myapp +``` + +Liest die `setup.json` der bestehenden Installation und fragt nur, was noch +fehlt. Nach Zugangsdaten wird dabei nur gefragt, wenn ein Feld mit +`source: "provision"` vorkommt — sonst braucht es den Server gar nicht. + +--- + +## 6. Schnittstelle + +| Aufruf | Zweck | Authentifizierung | +|---|---|---| +| `POST /api/setup/v1/login` | Benutzername + Passwort → Setup-Token | keine | +| `GET /api/setup/v1/catalog` | Was ist hier installierbar? | `setup:catalog` | +| `POST /api/setup/v1/token` | Dauertoken der Anwendung | `setup:install` | + +```bash +curl -X POST https://dc.mhdf.de/api/setup/v1/login \ + -H "Content-Type: application/json" \ + -d '{"username":"installer","password":"…","hostname":"srv-07"}' +``` + +```json +{ "status": "success", "setup_token": "dc_setup_…", "expires_in": 1800, + "scopes": ["setup:install"], "role": "installer" } +``` + +Das Setup-Token lebt **nur im Speicher** des Installers, wird nirgends abgelegt +und läuft nach 30 Minuten ab. Was auf dem System zurückbleibt, ist das +Dauertoken der Anwendung mit genau den Rechten, die sie braucht. + +Der Katalog zeigt ausschließlich, was zur Laufzeitkennung des anfragenden +Systems passt. Ein Projekt, von dem es nur ein Windows-Paket gibt, taucht auf +einem Linux-Rechner gar nicht erst auf — alles andere wäre eine Auswahl, die +beim Anklicken fehlschlägt. + +--- + +## 7. Was noch fehlt + +Diese Fassung ist Stufe 1. Bewusst noch nicht enthalten: + +| Fehlt | Bedeutet | +|---|---| +| **Zentrale Geheimnisse** | Datenbankzugang und API-Schlüssel werden je Installation eingetippt. Stufe 2 hinterlegt sie verschlüsselt je *(Projekt, Umgebung)* im Deploymentcenter, sodass `source: "bundle:…"` sie automatisch zieht | +| **Übernahme früherer Antworten** | Die zweite Installation derselben Anwendung fragt dasselbe noch einmal | +| **Verbindungstests** | Ob die eingegebene Datenbankverbindung wirklich trägt, zeigt sich erst beim ersten Start | +| **TOTP** | Siehe [§2](#2-das-installationskonto) | +| **Dienstregistrierung** | systemd-Unit beziehungsweise Windows-Dienst legt der Installer nicht an | +| **Watchdog-Eintrag** | Der Monitor entsteht beim ersten Heartbeat, nicht schon bei der Installation | diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md index e1999ec..ecf394b 100644 --- a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -634,3 +634,47 @@ auf. War der Wechsel beabsichtigt, die Datei löschen. Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die Vertrauenskette, und darauf wird bei jedem Update hingewiesen. + +--- + +## 7. Release-Verwaltung (Bearbeiten & Löschen) + +Veröffentlichte Releases können sowohl über das Web-Interface als auch über die API nachträglich bearbeitet oder gelöscht werden. + +### Web-Interface (`public/index.php`) +- Im Tab **UpdateService** → **📦 Veröffentlichte Software Releases** enthält jede Zeile die Aktionen `✏️ Bearbeiten` und `🗑️ Löschen`. +- **Bearbeiten**: Öffnet einen Modal-Dialog, in dem Release-Notes, Download-URL, SHA256-Hash, Git Commit, Plattform, Kanal, Version und IsCritical angepasst werden können. Beim Speichern wird die kanonische Signatur serverseitig neu berechnet. +- **Löschen**: Entfernt nach Bestätigung den Release-Datensatz aus der Datenbank. + +### API-Endpunkte + +#### Release Bearbeiten +- **POST** `/api/updateservice/v1/update` (oder `action=update` / `action=edit`) +- Scope: `updateservice:publish` +- Parameter: `release_id` (oder `id`), optional updated `product_slug`, `version`, `channel`, `platform`, `download_url`, `sha256_hash`, `git_commit`, `release_notes`, `size_bytes`, `is_critical`. +- Antwort (200 OK): + ```json + { + "status": "ok", + "release_id": 42, + "updated": true, + "platform": "win-x64", + "signed": true, + "message": "Release 1.2.0 (prod, win-x64) fuer \"myapp\" erfolgreich aktualisiert." + } + ``` + +#### Release Löschen +- **POST / DELETE** `/api/updateservice/v1/delete` (oder `action=delete`) +- Scope: `updateservice:publish` +- Parameter: `release_id` (oder `id`) ODER die Kombination aus `product_slug`, `version`, `channel`, `platform`. +- Antwort (200 OK): + ```json + { + "status": "ok", + "release_id": 42, + "deleted": true, + "message": "Release v1.2.0 (prod, win-x64) fuer \"myapp\" geloescht." + } + ``` + diff --git a/docs/setup.example.json b/docs/setup.example.json new file mode 100644 index 0000000..11fad65 --- /dev/null +++ b/docs/setup.example.json @@ -0,0 +1,72 @@ +{ + "_comment": "Vorlage fuer setup.json. Gehoert ins Wurzelverzeichnis des Publish-Ordners, damit sie im Paket neben der manifest.json landet. Der Installer liest sie nach dem Entpacken und fragt genau die Werte ab, die er nicht selbst ermitteln kann.", + + "schema": 1, + "displayName": "Beispielanwendung", + "description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.", + + "_targets_comment": "Wohin geschrieben wird. Pfade sind relativ zum Installationsverzeichnis. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.", + "targets": [ + { "id": "app", "file": "appsettings.json", "format": "json" }, + { "id": "env", "file": ".env", "format": "env" } + ], + + "_fields_comment": "source steuert, ob gefragt wird: 'ask' (Vorgabe) fragt nach, 'detect:...' leitet ab, 'provision' laesst ein Token ausstellen. Ist der Wert in der Zieldatei bereits gesetzt und kein Platzhalter, wird er uebernommen und nicht erneut erfragt.", + "fields": [ + { + "key": "ConnectionStrings:Main", + "label": "Datenbankverbindung", + "help": "Beispiel: Server=localhost;Database=beispiel;User Id=app;Password=...;", + "type": "secret", + "required": true + }, + { + "key": "Marktdaten:ApiKey", + "label": "API-Schluessel des Kursdatenanbieters", + "type": "secret", + "required": true, + "validate": "^[A-Za-z0-9_-]{20,}$", + "validationMessage": "Der Schluessel besteht aus mindestens 20 Zeichen ohne Leerzeichen." + }, + { + "key": "Deploymentcenter:BaseUrl", + "label": "Deploymentcenter", + "type": "url", + "source": "detect:baseurl" + }, + { + "key": "Deploymentcenter:Token", + "label": "Token dieser Installation", + "source": "provision", + "scopes": ["watchdog:ping", "bugtracker:report"] + }, + { + "key": "Watchdog:Source", + "label": "Name im Monitoring", + "source": "detect:hostname" + }, + { + "key": "Watchdog:IntervalSeconds", + "label": "Heartbeat-Intervall in Sekunden", + "type": "int", + "default": "60", + "required": false + }, + { + "key": "Features:BetaAktiv", + "label": "Beta-Funktionen einschalten", + "type": "bool", + "default": "false", + "required": false + }, + { + "key": "LOGLEVEL", + "label": "Protokollierungsstufe", + "target": "env", + "type": "enum", + "options": ["Debug", "Information", "Warning", "Error"], + "default": "Information", + "required": false + } + ] +} diff --git a/public/api/openapi.php b/public/api/openapi.php index 69261fa..3ecb709 100644 --- a/public/api/openapi.php +++ b/public/api/openapi.php @@ -515,6 +515,154 @@ $spec = [ ], ], + '/api/setup/v1/login' => [ + 'post' => [ + 'tags' => ['Setup'], + 'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen', + 'description' => + "Ohne Token erreichbar - hier entsteht das Token erst.\n\n" + . "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am " + . "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem " + . "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur " + . "gesamten Verwaltung.\n\n" + . "Das Token laeuft nach 30 Minuten ab, traegt nur `setup:install` und wird " + . "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n" + . "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine " + . "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen " + . "waere ein Rueckschritt.", + 'security' => [], + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['username', 'password'], + 'properties' => [ + 'username' => ['type' => 'string'], + 'password' => ['type' => 'string', 'format' => 'password'], + 'hostname' => ['type' => 'string', 'description' => 'Rechnername, erscheint im Token-Namen und im Protokoll.'], + ], + ]]], + ], + 'responses' => [ + '201' => [ + 'description' => 'Angemeldet', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'setup_token' => ['type' => 'string'], + 'expires_at' => ['type' => 'string', 'format' => 'date-time'], + 'expires_in' => ['type' => 'integer', 'example' => 1800], + 'scopes' => ['type' => 'array', 'items' => ['type' => 'string']], + 'role' => ['type' => 'string', 'enum' => ['admin', 'installer']], + ], + ]]], + ], + '401' => $errorResponse, + '429' => $errorResponse, + '501' => $errorResponse, + ], + ], + ], + + '/api/setup/v1/catalog' => [ + 'get' => [ + 'tags' => ['Setup'], + 'summary' => 'Installierbare Anwendungen fuer dieses System', + 'description' => + "Scope `setup:catalog`. Zeigt ausschliesslich, was zur angefragten " + . "Laufzeitkennung passt - ein Projekt, von dem es nur ein Windows-Paket gibt, " + . "taucht auf einem Linux-Rechner gar nicht erst auf.\n\n" + . "Eine WebUI-Session wird hier bewusst **nicht** akzeptiert.", + 'parameters' => [ + ['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'], + 'description' => 'Laufzeitkennung des Zielsystems. Ohne Angabe gelten nur Releases mit platform=any.'], + ], + 'responses' => [ + '200' => [ + 'description' => 'Katalog', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'platform' => ['type' => 'string'], + 'count' => ['type' => 'integer'], + 'projects' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'slug' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'notes' => ['type' => 'string', 'nullable' => true], + 'channels' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'channel' => ['type' => 'string'], + 'version' => ['type' => 'string'], + 'platform' => ['type' => 'string'], + 'size_bytes' => ['type' => 'integer'], + 'is_critical' => ['type' => 'boolean'], + 'signed' => ['type' => 'boolean'], + 'download_url' => ['type' => 'string'], + ], + ], + ], + ], + ], + ], + ], + ]]], + ], + '401' => $errorResponse, + ], + ], + ], + + '/api/setup/v1/token' => [ + 'post' => [ + 'tags' => ['Setup'], + 'summary' => 'Dauertoken fuer die eingerichtete Anwendung', + 'description' => + "Scope `setup:install`. Stellt das Token aus, mit dem die frisch installierte " + . "Anwendung spaeter Heartbeats sendet und Fehler meldet.\n\n" + . "**Welche Rechte vergeben werden, entscheidet der Server.** Die angefragten " + . "`scopes` koennen nur einschraenken; erlaubt sind ausschliesslich " + . "`watchdog:ping`, `watchdog:read`, `bugtracker:report` und " + . "`updateservice:read`. Andernfalls waere der Umweg ueber ein kurzlebiges " + . "Setup-Token wirkungslos - wer einmal installieren darf, stellte sich sonst " + . "ein Token mit allen Rechten aus und behielte es.", + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['project'], + 'properties' => [ + 'project' => ['type' => 'string'], + 'scopes' => ['type' => 'array', 'items' => ['type' => 'string']], + 'hostname' => ['type' => 'string'], + ], + ]]], + ], + 'responses' => [ + '201' => [ + 'description' => 'Token ausgestellt - wird nur einmal ausgeliefert', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'token' => ['type' => 'string'], + 'project' => ['type' => 'string'], + 'scopes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + ]]], + ], + '401' => $errorResponse, + '403' => $errorResponse, + '404' => $errorResponse, + ], + ], + ], + '/api/watchdog/v1/ping' => [ 'post' => [ 'tags' => ['Watchdog'], diff --git a/public/api/setup/v1/index.php b/public/api/setup/v1/index.php new file mode 100644 index 0000000..5ca0830 --- /dev/null +++ b/public/api/setup/v1/index.php @@ -0,0 +1,253 @@ + kurzlebiges Token + * GET /api/setup/v1/catalog Was ist hier installierbar? + * POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung + * + * Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste + * vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde, + * die eine Erstinstallation umstaendlich macht. Zugangsdaten hat der + * Einrichtende ohnehin. + * + * Das ausgestellte Token laeuft nach 30 Minuten ab und traegt nur + * setup:install. Es liegt auf einem fremden Rechner; alles Langlebige waere + * dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das + * Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht. + * + * ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der + * Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI + * anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt, + * verteilt den Zugang zur gesamten Verwaltung. + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../../../src/bootstrap.php'; + +use Deploymentcenter\Core\ApiAuth; +use Deploymentcenter\Core\Auth; +use Deploymentcenter\Core\Db; +use Deploymentcenter\Core\Http; +use Deploymentcenter\Core\Logger; +use Deploymentcenter\Core\TokenManager; +use Deploymentcenter\Modules\License\RateLimiter; +use Deploymentcenter\Modules\Setup\SetupCatalog; +use Deploymentcenter\Modules\UpdateService\UpdateManager; + +Http::beginJson(['GET', 'POST', 'OPTIONS'], true); + +$db = Db::init(); + +/** Gueltigkeit des ausgestellten Tokens in Sekunden. */ +const SETUP_TOKEN_TTL = 1800; + +switch (resolveSetupAction()) { + + case 'login': + if (Http::method() !== 'POST') { + Http::fail(405, 'method_not_allowed', 'Die Anmeldung erwartet POST.'); + } + + // Deutlich enger als die uebrigen Endpunkte: hier werden Passwoerter + // geprueft. Die Sperre aus Auth greift zusaetzlich pro IP, sie zaehlt + // aber erst ab dem zehnten Fehlversuch. + $limiter = new RateLimiter($db, 10, 60, 'setup_login'); + if (!$limiter->check(Http::clientIp())) { + Http::fail(429, 'rate_limited', 'Zu viele Anmeldeversuche. Bitte kurz warten.'); + } + + $username = Http::str('username'); + $password = Http::str('password'); + + if ($username === null || $password === null || $username === '' || $password === '') { + Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.'); + } + + $lockout = Auth::lockoutSeconds($db, Http::clientIp()); + if ($lockout > 0) { + Http::fail(429, 'locked_out', sprintf( + 'Zu viele fehlgeschlagene Anmeldungen. Bitte in %d Minute(n) erneut versuchen.', + (int)ceil($lockout / 60) + )); + } + + $user = Auth::verifyCredentials($db, $username, $password); + + if ($user === null) { + // Bewusst dieselbe Meldung fuer falschen Benutzer, falsches + // Passwort und deaktiviertes Konto. + Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.'); + } + + // TOTP ist in dc_users vorgesehen, wird aber noch von keinem + // Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier + // durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein + // stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt. + if (!empty($user['totp_secret'])) { + Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [ + 'username' => $username, + 'ip' => Http::clientIp(), + ]); + Http::fail( + 501, + 'totp_not_supported', + 'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung ' + . 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.' + ); + } + + $manager = new TokenManager($db); + $hostname = Http::str('hostname') ?? Http::str('instance_id'); + + $token = $manager->createEphemeralToken( + sprintf('Setup %s%s', $username, $hostname !== null ? ' @ ' . $hostname : ''), + ['setup:install'], + SETUP_TOKEN_TTL, + $hostname + ); + + Logger::info('Setup-Anmeldung erfolgreich', [ + 'username' => $username, + 'role' => Auth::roleOf($user), + 'hostname' => $hostname, + 'ip' => Http::clientIp(), + 'token_id' => $token['token_id'], + ]); + + Http::ok([ + 'setup_token' => $token['raw_token'], + 'token_id' => $token['token_id'], + 'expires_at' => $token['expires_at'], + 'expires_in' => SETUP_TOKEN_TTL, + 'scopes' => $token['scopes'], + 'role' => Auth::roleOf($user), + 'message' => 'Angemeldet. Das Token laeuft nach 30 Minuten ab und wird nicht gespeichert.', + ], 201); + + case 'catalog': + // allowSession bewusst aus: dieser Weg ist fuer den Installer, nicht + // fuer einen angemeldeten Browser. + $context = ApiAuth::requireScope($db, 'setup:catalog', null, false); + + $platform = Http::str('platform') ?? Http::str('rid'); + $catalog = (new SetupCatalog($db))->forPlatform( + $platform, + ApiAuth::projectFilter($context) + ); + + Http::ok([ + 'platform' => UpdateManager::normalizePlatform($platform), + 'count' => count($catalog), + 'projects' => $catalog, + 'message' => $catalog === [] + ? 'Fuer diese Plattform ist nichts hinterlegt. Releases ohne Plattformangabe gelten als "any".' + : sprintf('%d Anwendung(en) installierbar.', count($catalog)), + ]); + + case 'token': + if (Http::method() !== 'POST') { + Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.'); + } + + $context = ApiAuth::requireScope($db, 'setup:install', null, false); + + $project = Http::str('project') ?? Http::str('product_slug'); + if ($project === null || $project === '') { + Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.'); + } + + ApiAuth::enforceProject($context, $project); + + $exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1'); + $exists->execute([':slug' => $project]); + if ($exists->fetch() === false) { + Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project)); + } + + $requested = Http::input('scopes', []); + if (is_string($requested)) { + $requested = array_map('trim', explode(',', $requested)); + } + if (!is_array($requested) || $requested === []) { + $requested = ['watchdog:ping']; + } + + // Was ein Installer ausstellen darf, ist fest umrissen und nicht + // verhandelbar. Wuerde die Anfrage die Rechte bestimmen, waere der + // Umweg ueber ein kurzlebiges Setup-Token wirkungslos: wer einmal + // installieren darf, koennte sich sonst ein Token mit allen Rechten + // ausstellen und behalten. + $allowed = ['watchdog:ping', 'watchdog:read', 'bugtracker:report', 'updateservice:read']; + + $granted = array_values(array_intersect( + array_map(static fn($s): string => strtolower(trim((string)$s)), $requested), + $allowed + )); + + if ($granted === []) { + Http::fail(403, 'scopes_not_allowed', 'Keines der angeforderten Rechte darf ueber den Setup-Weg vergeben werden.', null, [ + 'allowed' => $allowed, + ]); + } + + $hostname = Http::str('hostname') ?? Http::str('instance_id'); + + $manager = new TokenManager($db); + $appToken = $manager->createMasterToken( + sprintf('%s @ %s', $project, $hostname ?? 'unbekannt'), + $project, + null, + 'host', + $hostname, + $granted, + Http::str('environment') ?? 'all' + ); + + Logger::info('Anwendungstoken ueber den Setup-Weg ausgestellt', [ + 'project' => $project, + 'hostname' => $hostname, + 'scopes' => $granted, + 'actor' => $context['actor'], + 'token_id' => $appToken['token_id'], + ]); + + Http::ok([ + 'token' => $appToken['raw_token'], + 'token_id' => $appToken['token_id'], + 'project' => $project, + 'scopes' => $granted, + 'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.', + ], 201); + + default: + Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ + 'available' => ['login', 'catalog', 'token'], + ]); +} + +/** + * Bestimmt die Aktion aus dem Pfadsegment oder ?action=. + * Feste Liste statt Teilstring-Suche. + */ +function resolveSetupAction(): string +{ + $explicit = Http::str('action'); + if ($explicit !== null) { + return strtolower($explicit); + } + + $segments = array_values(array_filter( + explode('/', trim(Http::path(), '/')), + static fn(string $s): bool => $s !== '' + )); + + $last = strtolower((string)end($segments)); + + return match ($last) { + 'login', 'catalog', 'token' => $last, + default => 'unknown', + }; +} diff --git a/public/api/updateservice/v1/index.php b/public/api/updateservice/v1/index.php index 0e00255..e157532 100644 --- a/public/api/updateservice/v1/index.php +++ b/public/api/updateservice/v1/index.php @@ -244,9 +244,132 @@ switch ($action) { ), ], $result['created'] ? 201 : 200); + case 'update': + case 'edit': + if (Http::method() !== 'POST') { + Http::fail(405, 'method_not_allowed', 'Das Bearbeiten eines Releases erwartet POST.'); + } + + $context = ApiAuth::requireScope($db, 'updateservice:publish'); + + $releaseId = Http::int('release_id', Http::int('id', 0)); + if ($releaseId <= 0) { + Http::fail(400, 'missing_release_id', 'Der Parameter "release_id" oder "id" ist erforderlich.'); + } + + $existing = $manager->getReleaseById($releaseId); + if ($existing === null) { + Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId)); + } + + $product = Http::str('product_slug') ?? Http::str('product') ?? $existing['product_slug']; + $version = Http::str('version') ?? $existing['version']; + $url = Http::str('download_url') ?? $existing['download_url']; + $channel = Http::str('channel') ?? $existing['channel']; + $platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid') ?? $existing['platform']); + $notes = Http::str('release_notes') ?? $existing['release_notes']; + $hash = Http::str('sha256_hash') ?? $existing['sha256_hash']; + $git = Http::str('git_commit') ?? $existing['git_commit']; + $size = Http::int('size_bytes', (int)$existing['size_bytes']); + $critical = Http::input('is_critical', null) !== null + ? filter_var(Http::input('is_critical'), FILTER_VALIDATE_BOOLEAN) + : (bool)$existing['is_critical']; + + ApiAuth::enforceProject($context, (string)$product); + + if ($hash !== null && $hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) { + Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.'); + } + + $signature = ReleaseSigner::sign(ReleaseSigner::canonical( + (string)$product, + (string)$version, + (string)$channel, + (string)$platform, + $hash !== '' ? $hash : null, + (string)$url, + $size + )); + + try { + $ok = $manager->updateRelease( + $releaseId, + (string)$product, + (string)$version, + (string)$channel, + $notes, + (string)$url, + $hash !== '' ? $hash : null, + $git, + $size, + $existing['manifest_json'] ?? null, + $critical, + $context['actor'], + (string)$platform, + $signature + ); + } catch (\PDOException $e) { + if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') { + Http::fail(409, 'conflict', 'Ein Release mit dieser Kombination aus Produkt, Version, Kanal und Plattform existiert bereits.'); + } + throw $e; + } + + Http::ok([ + 'release_id' => $releaseId, + 'updated' => $ok, + 'platform' => $platform, + 'signed' => $signature !== null, + 'message' => sprintf('Release %s (%s, %s) fuer "%s" erfolgreich aktualisiert.', $version, $channel, $platform, $product), + ]); + + case 'delete': + if (Http::method() !== 'POST' && Http::method() !== 'DELETE') { + Http::fail(405, 'method_not_allowed', 'Das Loeschen erwartet POST oder DELETE.'); + } + + $context = ApiAuth::requireScope($db, 'updateservice:publish'); + + $releaseId = Http::int('release_id', Http::int('id', 0)); + if ($releaseId <= 0) { + $product = Http::str('product_slug') ?? Http::str('product'); + $version = Http::str('version'); + $channel = Http::str('channel') ?? 'prod'; + $platform = Http::str('platform') ?? Http::str('rid'); + + if ($product !== null && $version !== null) { + $found = $manager->findRelease($product, $version, $channel, $platform); + if ($found !== null) { + $releaseId = (int)$found['id']; + } + } + } + + if ($releaseId <= 0) { + Http::fail(400, 'missing_release_id', 'Ungueltige oder fehlende release_id.'); + } + + $existing = $manager->getReleaseById($releaseId); + if ($existing === null) { + Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId)); + } + + ApiAuth::enforceProject($context, (string)$existing['product_slug']); + + $deleted = $manager->deleteRelease($releaseId); + if (!$deleted) { + Http::fail(500, 'delete_failed', 'Das Release konnte nicht geloescht werden.'); + } + + Http::ok([ + 'release_id' => $releaseId, + 'deleted' => true, + 'message' => sprintf('Release v%s (%s, %s) fuer "%s" geloescht.', $existing['version'], $existing['channel'], $existing['platform'] ?? 'any', $existing['product_slug']), + ]); + default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ - 'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'], + 'available' => ['check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'delete'], ]); } @@ -270,7 +393,7 @@ function resolveUpdateAction(): string $last = strtolower((string)end($segments)); return match ($last) { - 'check', 'latest', 'releases', 'pubkey', 'publish' => $last, + 'check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last, 'publish_release' => 'publish', default => 'check', }; diff --git a/public/api/watchdog/v1/index.php b/public/api/watchdog/v1/index.php index 03cbf63..7d04b50 100644 --- a/public/api/watchdog/v1/index.php +++ b/public/api/watchdog/v1/index.php @@ -188,12 +188,20 @@ switch ($action) { // ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben. ApiAuth::requireScope($db, 'watchdog:evaluate'); + $reportRes = null; + if (Http::str('force_report') === '1' || Http::str('report') === '1') { + $reportRes = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($db, true); + } + $result = Evaluator::run($db); - Http::ok($result + ['message' => sprintf( - '%d Monitor(e) geprueft, %d Zustandswechsel.', - $result['checked'], - $result['changed'] - )]); + Http::ok($result + [ + 'rocketchat_report' => $reportRes, + 'message' => sprintf( + '%d Monitor(e) geprueft, %d Zustandswechsel.', + $result['checked'], + $result['changed'] + ) + ]); default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ diff --git a/public/index.php b/public/index.php index 69d7456..17a3abd 100644 --- a/public/index.php +++ b/public/index.php @@ -224,6 +224,31 @@ function dc_flash(string $message, string $type = 'success'): void $_SESSION['dc_flash'] = ['message' => $message, 'type' => $type]; } +/** + * Wie viele handlungsfaehige Administratorkonten gibt es noch? + * + * Grundlage fuer die Sperren beim Deaktivieren und Loeschen: Faellt das + * letzte Konto weg, das sich am WebUI anmelden kann, kommt niemand mehr an + * die Oberflaeche - auch nicht, um den Schritt rueckgaengig zu machen. + * Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar + * nicht anmelden. + */ +function dc_active_admin_count(PDO $pdo): int +{ + try { + $stmt = $pdo->query("SELECT COUNT(*) FROM dc_users WHERE role = 'admin' AND disabled = 0"); + return (int)$stmt->fetchColumn(); + } catch (Throwable $e) { + // Vor Migration 012 gibt es weder role noch disabled; dann sind alle + // Konten Administratoren. + try { + return (int)$pdo->query('SELECT COUNT(*) FROM dc_users')->fetchColumn(); + } catch (Throwable $inner) { + return 0; + } + } +} + /** * Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit * ein Neuladen das Formular nicht erneut abschickt. @@ -767,6 +792,122 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#tab-updateservice'); } + case 'edit_release': { + $releaseId = (int)($_POST['release_id'] ?? 0); + if ($releaseId <= 0) { + dc_flash('Ungültige Release-ID.', 'danger'); + dc_redirect('#tab-updateservice'); + } + + $updateManager = new UpdateManager($pdo); + $existing = $updateManager->getReleaseById($releaseId); + if ($existing === null) { + dc_flash('Das zu bearbeitende Release wurde nicht gefunden.', 'danger'); + dc_redirect('#tab-updateservice'); + } + + $productSlug = trim((string)($_POST['product_slug'] ?? '')); + $version = trim((string)($_POST['version'] ?? '')); + $url = trim((string)($_POST['download_url'] ?? '')); + + if ($productSlug === '' || $version === '' || $url === '') { + dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger'); + dc_redirect('#tab-updateservice'); + } + + $hash = trim((string)($_POST['sha256_hash'] ?? '')); + if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) { + dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger'); + dc_redirect('#tab-updateservice'); + } + + $channel = trim((string)($_POST['channel'] ?? 'prod')); + $platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? '')); + $size = (int)($_POST['size_bytes'] ?? 0); + + $signature = ReleaseSigner::sign(ReleaseSigner::canonical( + $productSlug, + $version, + $channel, + $platform, + $hash !== '' ? $hash : null, + $url, + $size + )); + + try { + $ok = $updateManager->updateRelease( + $releaseId, + $productSlug, + $version, + $channel, + trim((string)($_POST['release_notes'] ?? '')), + $url, + $hash !== '' ? $hash : null, + trim((string)($_POST['git_commit'] ?? '')), + $size, + $existing['manifest_json'] ?? null, + isset($_POST['is_critical']), + $actor, + $platform, + $signature + ); + + if ($ok) { + dc_flash(sprintf( + 'Release %s (%s, %s) für "%s" erfolgreich aktualisiert.%s', + e($version), + e($channel), + e($platform), + e($productSlug), + $signature === null + ? ' Hinweis: unsigniert, kein Signierschlüssel hinterlegt.' + : '' + )); + } else { + dc_flash('Das Release konnte nicht aktualisiert werden.', 'danger'); + } + } catch (\PDOException $e) { + if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') { + dc_flash('Ein Release mit dieser Kombination aus Projekt, Version, Kanal und Plattform existiert bereits.', 'danger'); + } else { + dc_flash('Fehler beim Aktualisieren des Releases: ' . $e->getMessage(), 'danger'); + } + } + + dc_redirect('#tab-updateservice'); + } + + case 'delete_release': { + $releaseId = (int)($_POST['release_id'] ?? 0); + if ($releaseId <= 0) { + dc_flash('Ungültige Release-ID.', 'danger'); + dc_redirect('#tab-updateservice'); + } + + $updateManager = new UpdateManager($pdo); + $existing = $updateManager->getReleaseById($releaseId); + if ($existing === null) { + dc_flash('Das zu löschende Release wurde nicht gefunden.', 'danger'); + dc_redirect('#tab-updateservice'); + } + + $deleted = $updateManager->deleteRelease($releaseId); + if ($deleted) { + dc_flash(sprintf( + 'Release v%s (%s, %s) für "%s" erfolgreich gelöscht.', + e($existing['version']), + e($existing['channel']), + e($existing['platform'] ?? 'any'), + e($existing['product_slug']) + )); + } else { + dc_flash('Das Release konnte nicht gelöscht werden.', 'danger'); + } + + dc_redirect('#tab-updateservice'); + } + // ------------------------------------------------------ Tokens case 'create_master_token': { $name = trim((string)($_POST['name'] ?? '')); @@ -1025,6 +1166,199 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#tab-bugtracker'); } + case 'save_rocketchat_settings': { + $enabled = isset($_POST['rocketchat_enabled']) ? '1' : '0'; + $url = trim((string)($_POST['rocketchat_url'] ?? '')); + $username = trim((string)($_POST['rocketchat_username'] ?? '')); + $password = (string)($_POST['rocketchat_password'] ?? ''); + $statusChannel = trim((string)($_POST['rocketchat_status_channel'] ?? '')); + $alertChannel = trim((string)($_POST['rocketchat_alert_channel'] ?? '')); + $verifySsl = isset($_POST['rocketchat_verify_ssl']) ? '1' : '0'; + $reportInterval = max(300, (int)($_POST['rocketchat_report_interval'] ?? 43200)); + + $settings = [ + 'rocketchat_enabled' => $enabled, + 'rocketchat_url' => $url, + 'rocketchat_username' => $username, + 'rocketchat_password' => $password, + 'rocketchat_status_channel' => $statusChannel, + 'rocketchat_alert_channel' => $alertChannel, + 'rocketchat_verify_ssl' => $verifySsl, + 'rocketchat_report_interval' => (string)$reportInterval, + ]; + + $stmt = $pdo->prepare(" + INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v) + ON DUPLICATE KEY UPDATE svalue = VALUES(svalue) + "); + foreach ($settings as $k => $v) { + $stmt->execute([':k' => $k, ':v' => $v]); + } + + $stmtCron = $pdo->prepare(" + INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled) + VALUES ('rocketchat_status_report', :interval, :enabled) + ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec), enabled = VALUES(enabled) + "); + $stmtCron->execute([':interval' => $reportInterval, ':enabled' => (int)$enabled]); + + dc_flash('Rocket.Chat Einstellungen erfolgreich gespeichert.', 'success'); + dc_redirect('#sub-system-rocketchat'); + } + + case 'test_rocketchat_connection': { + $config = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo); + $res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::testConnection($config); + dc_flash($res['message'], $res['success'] ? 'success' : 'danger'); + dc_redirect('#sub-system-rocketchat'); + } + + case 'send_rocketchat_report_now': { + $res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($pdo, true); + dc_flash($res['reason'], $res['sent'] ? 'success' : 'warning'); + dc_redirect('#sub-system-rocketchat'); + } + + // ------------------------------------------------- Benutzerkonten + case 'add_user': { + $newUser = trim((string)($_POST['username'] ?? '')); + $newPass = (string)($_POST['password'] ?? ''); + $newRole = ($_POST['role'] ?? 'installer') === 'admin' ? 'admin' : 'installer'; + + if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newUser) !== 1) { + dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger'); + dc_redirect('#sub-system-users'); + } + + // Ein Installationskonto wird auf fremden Rechnern eingegeben. + // Ein kurzes Passwort waere dort schnell mitgelesen und liesse + // sich ebenso schnell durchprobieren. + if (strlen($newPass) < 12) { + dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u LIMIT 1'); + $exists->execute([':u' => $newUser]); + if ($exists->fetch() !== false) { + dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newUser)), 'danger'); + dc_redirect('#sub-system-users'); + } + + $ins = $pdo->prepare(' + INSERT INTO dc_users (username, password_hash, role, created_at) + VALUES (:u, :h, :r, UTC_TIMESTAMP()) + '); + $ins->execute([ + ':u' => $newUser, + ':h' => password_hash($newPass, PASSWORD_DEFAULT), + ':r' => $newRole, + ]); + + Logger::info('Benutzerkonto angelegt', [ + 'username' => $newUser, + 'role' => $newRole, + 'actor' => $actor, + ]); + + dc_flash(sprintf( + 'Konto "%s" mit der Rolle %s angelegt.%s', + e($newUser), + e($newRole), + $newRole === 'installer' + ? ' Es kann sich nicht am WebUI anmelden, sondern nur Anwendungen einrichten.' + : '' + )); + dc_redirect('#sub-system-users'); + } + + case 'set_user_password': { + $userId = (int)($_POST['user_id'] ?? 0); + $newPass = (string)($_POST['password'] ?? ''); + + if (strlen($newPass) < 12) { + dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id'); + $upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]); + + Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]); + dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.', + $upd->rowCount() > 0 ? 'success' : 'danger'); + dc_redirect('#sub-system-users'); + } + + case 'toggle_user_disabled': { + $userId = (int)($_POST['user_id'] ?? 0); + + $row = $pdo->prepare('SELECT id, username, role, disabled FROM dc_users WHERE id = :id LIMIT 1'); + $row->execute([':id' => $userId]); + $target = $row->fetch(); + + if (!is_array($target)) { + dc_flash('Konto nicht gefunden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $disable = (int)$target['disabled'] === 0; + + // Ohne diese Sperre liesse sich das letzte handlungsfaehige + // Administratorkonto abschalten - danach kaeme niemand mehr an + // die Oberflaeche, auch nicht, um es rueckgaengig zu machen. + if ($disable && $target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) { + dc_flash('Das letzte aktive Administratorkonto kann nicht deaktiviert werden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $upd = $pdo->prepare('UPDATE dc_users SET disabled = :d WHERE id = :id'); + $upd->execute([':d' => $disable ? 1 : 0, ':id' => $userId]); + + Logger::info('Konto ' . ($disable ? 'deaktiviert' : 'aktiviert'), [ + 'username' => $target['username'], + 'actor' => $actor, + ]); + + dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']), + $disable ? 'deaktiviert' : 'wieder aktiviert')); + dc_redirect('#sub-system-users'); + } + + case 'delete_user': { + $userId = (int)($_POST['user_id'] ?? 0); + + $row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1'); + $row->execute([':id' => $userId]); + $target = $row->fetch(); + + if (!is_array($target)) { + dc_flash('Konto nicht gefunden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + if ($userId === Auth::userId()) { + dc_flash('Das eigene Konto kann nicht geloescht werden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + if ($target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) { + dc_flash('Das letzte Administratorkonto kann nicht geloescht werden.', 'danger'); + dc_redirect('#sub-system-users'); + } + + $del = $pdo->prepare('DELETE FROM dc_users WHERE id = :id'); + $del->execute([':id' => $userId]); + + Logger::info('Benutzerkonto geloescht', [ + 'username' => $target['username'], + 'actor' => $actor, + ]); + + dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username']))); + dc_redirect('#sub-system-users'); + } + default: dc_flash('Unbekannte Aktion.', 'danger'); dc_redirect(); @@ -1271,6 +1605,39 @@ $recentEvents = $eventLog->getRecentEvents(100); $updateManager = new UpdateManager($pdo); $releases = $updateManager->getReleases(); +// Benutzerkonten fuer die Verwaltung. Der Passwort-Hash wird bewusst nicht +// mitgelesen - er hat in der Ansicht nichts verloren. +try { + $users = $pdo->query(' + SELECT id, username, role, disabled, last_login_at, created_at + FROM dc_users ORDER BY role ASC, username ASC + ')->fetchAll() ?: []; +} catch (Throwable $e) { + // Vor Migration 012 fehlen role, disabled und last_login_at. + $users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: []; +} +$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); + +// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und +// werden nicht von deploy.py uebertragen, sondern von +// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf +// den Webserver, die uebersetzten Dateien schon. +$installerManifest = null; +$installerPath = dirname(__DIR__) . '/installer/installer.json'; + +if (is_file($installerPath)) { + $raw = (string)file_get_contents($installerPath); + + // Ein vorangestelltes BOM laesst json_decode() scheitern. Windows-Werkzeuge + // schreiben es gern mit; die Datei ist deswegen nicht kaputt. + $raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw; + + $decoded = json_decode($raw, true); + if (is_array($decoded) && isset($decoded['binaries']) && is_array($decoded['binaries'])) { + $installerManifest = $decoded; + } +} + $coreTokenManager = new CoreTokenManager($pdo); $coreMasterTokens = $coreTokenManager->getAllMasterTokens(); $coreAllTokens = $coreTokenManager->getAllTokens(); @@ -1391,6 +1758,10 @@ $evaluatorLastRun = is_array($evaluatorJob) ? ($evaluatorJob['last_run_utc'] ?? $evaluatorStale = $evaluatorLastRun === null || (time() - (int)strtotime((string)$evaluatorLastRun . ' UTC')) > 900; +// Rocket.Chat Konfiguration und Cron-Status +$rcConfig = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo); +$rcCronJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'")->fetch() ?: []; + $baseUrl = Http::baseUrl(); $csrfField = Csrf::field(); ?> @@ -2625,6 +2996,7 @@ Antwort: Release Notes Download & SHA256 Datum + Aktionen @@ -2664,6 +3036,10 @@ Antwort: + + + + @@ -2671,6 +3047,110 @@ Antwort: +
+
+

⬇️ Installer herunterladen

+ +

+ Ein Werkzeug für alles: Dasselbe Programm richtet eine Anwendung erstmalig + ein und aktualisiert sie später. Es bringt die .NET-Laufzeit mit — auf dem + Zielsystem muss nichts vorinstalliert sein. +

+ + +
+ Noch nichts hinterlegt. Die Binaries werden mit + scripts/build_installer.ps1 gebaut und mit + scripts/upload_installer.py nach /installer/ + hochgeladen. +
+ +
+ Version + · Build + · +
+ + + + + + + + + + + + + + + + + + + + + +
PlattformGrößeSHA256Download
+ MB + + + … + + + ⬇️ Herunterladen +
+ +
+ Prüfsumme vergleichen. „Programm herunterladen und + Zugangsdaten eingeben“ ist genau das Muster, das Phishing nachahmt. + Die vollständigen Hashes stehen im Tooltip und unter + installer.json. +
+ +
+ +
+

Direkt auf dem Zielsystem

+ +

+ Linux — lädt das passende Binary, prüft die Prüfsumme und legt es ab: +

+
wget -qO- /installer/install.sh | sh
+ +

+ Oder ohne Skript, direkt das Binary: +

+
wget /installer/update-agent-linux-x64 -O update-agent +chmod +x update-agent +./update-agent --action install
+ +

+ Windows (PowerShell): +

+
irm /installer/install.ps1 | iex
+ +

+ Die Skripte richten nichts von selbst ein — sie laden, prüfen + und sagen, wie es weitergeht. Ein Skript aus dem Netz, das ungefragt eine + Anwendung aufsetzt und dabei nach Zugangsdaten fragt, wäre genau das, wovor + man Nutzer sonst warnt. +

+ +

+ Für die Installation wird ein Konto der Rolle installer + gebraucht — anzulegen unter System → 👤 Benutzer. Ein + Administratorkonto funktioniert auch, gehört aber nicht auf ein Zielsystem: + seine Zugangsdaten würden damit auf jedem Rechner landen, auf dem je etwas + installiert wurde. +

+
+
+

Neues Software-Release veröffentlichen

@@ -2740,6 +3220,106 @@ Antwort:
+ + + + + + @@ -3853,6 +4433,229 @@ Antwort: +
+
+
+

💬 Rocket.Chat Benachrichtigungen & Statusberichte

+
+

+ Hier konfigurieren Sie die automatischen Rocket.Chat-Benachrichtigungen. Alle 12 Stunden wird ein detaillierter Systemstatusbericht an den Status-Kanal gesendet. Kritische Systemausfälle und Bugs landen sofort im Alarm-Kanal. +

+ +
+ + + +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Standard: 43200 Sekunden (12 Stunden) +
+
+ +
+ +
+
+
+ +
+
+

🧪 Aktionen & Testlauf

+
+

+ Hier können Sie die Erreichbarkeit Ihres Rocket.Chat-Servers testen oder sofort einen 12-Stunden-Statusbericht auslösen. +

+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ 📌 Status des automatischen 12h Cron-Jobs:
+ • Letzter Bericht:
+ • Letzter Status:
+ • Nächster geplanter Lauf: In ca. +
+
+
+ +
+
+

👤 Benutzerkonten

+ + +
+ Migration 012 fehlt. Rollen, Deaktivierung und der Zeitpunkt + der letzten Anmeldung stehen erst danach zur Verfügung. Bis dahin gelten alle + Konten als Administratoren. +
+ + +

+ Für die Erstinstallation von Anwendungen gibt es die Rolle + installer. Solche Konten melden sich ausschließlich über + /api/setup/v1/login an und bekommen dafür ein Token, das nach + 30 Minuten verfällt. Am WebUI können sie sich nicht anmelden. + Der Grund: Die Zugangsdaten werden auf jedem Zielsystem eingegeben — mit einem + Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und + Monitoren auf jeden Rechner, auf dem je etwas installiert wurde. +

+ + + + + + + + + + + + + + + + > + + + + + + + + + +
BenutzerRolleStatusLetzte AnmeldungAngelegtAktionen
+ + Sie + + + + + DEAKTIVIERT' : 'aktiv' ?> + + +
+
+ + + + + + +
+ + +
+ + + + + +
+ +
+ + + + + +
+ +
+
+
+ +
+

Neues Konto anlegen

+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

📖 API-Dokumentation

@@ -3944,6 +4747,7 @@ SYSTEM ], 'updateservice': [ { id: 'sub-update-releases', label: '📊 Releases Overview', active: true }, + { id: 'sub-update-installer', label: '⬇️ Installer' }, { id: 'sub-update-publish', label: '➕ Release Veröffentlichen' } ], 'bugtracker': [ @@ -3959,6 +4763,8 @@ SYSTEM ], 'system': [ { id: 'sub-system-status', label: '⚙️ System-Status', active: true }, + { id: 'sub-system-users', label: '👤 Benutzer' }, + { id: 'sub-system-rocketchat', label: '💬 Rocket.Chat' }, { id: 'sub-system-swagger', label: '📖 API Swagger Docs' }, { id: 'sub-system-migration', label: '🗄️ DB Migration' } ] @@ -4373,6 +5179,55 @@ SYSTEM document.getElementById('projSubmitBtn').innerText = 'Projekt Anlegen'; } + // UpdateService Release Modal Functions + function openEditReleaseModal(r) { + const setVal = (id, val) => { + const el = document.getElementById(id); + if (el) el.value = val; + }; + + setVal('edit_release_id', r.id || 0); + setVal('edit_release_product_slug', r.product_slug || ''); + setVal('edit_release_channel', r.channel || 'prod'); + setVal('edit_release_platform', r.platform || 'any'); + setVal('edit_release_version', r.version || ''); + setVal('edit_release_git_commit', r.git_commit || ''); + setVal('edit_release_download_url', r.download_url || ''); + setVal('edit_release_sha256_hash', r.sha256_hash || ''); + setVal('edit_release_size_bytes', r.size_bytes || 0); + setVal('edit_release_release_notes', r.release_notes || ''); + + const critEl = document.getElementById('edit_release_is_critical'); + if (critEl) critEl.checked = Boolean(Number(r.is_critical)); + + const modal = document.getElementById('editReleaseModal'); + if (modal) modal.classList.add('active'); + } + + function closeEditReleaseModal() { + const modal = document.getElementById('editReleaseModal'); + if (modal) modal.classList.remove('active'); + } + + function openDeleteReleaseModal(id, version, product) { + const idEl = document.getElementById('delete_release_id'); + if (idEl) idEl.value = id; + + const verEl = document.getElementById('delete_release_version_display'); + if (verEl) verEl.textContent = 'v' + version; + + const prodEl = document.getElementById('delete_release_product_display'); + if (prodEl) prodEl.textContent = product; + + const modal = document.getElementById('deleteReleaseModal'); + if (modal) modal.classList.add('active'); + } + + function closeDeleteReleaseModal() { + const modal = document.getElementById('deleteReleaseModal'); + if (modal) modal.classList.remove('active'); + } + // Project Safety Deletion Modal function openDeleteProjectModal(id, slug, name) { document.getElementById('deleteProjId').value = id; diff --git a/scripts/build_installer.ps1 b/scripts/build_installer.ps1 new file mode 100644 index 0000000..4295f12 --- /dev/null +++ b/scripts/build_installer.ps1 @@ -0,0 +1,121 @@ +<# + Baut den Update-Agent als eigenstaendige Installer-Binaries und legt sie + samt Pruefsummen, Manifest und Bootstrap-Skripten zum Upload bereit. + + pwsh scripts/build_installer.ps1 + python scripts/upload_installer.py .\artifacts\installer + + Selbstenthaltend und als Einzeldatei: Auf einem frisch aufgesetzten + Zielsystem ist keine .NET-Laufzeit vorhanden, und ein Installer, der erst + eine Laufzeit nachinstalliert, hat sein Versprechen schon gebrochen. + + Bewusst OHNE NativeAOT und ohne Trimming: Spectre.Console loest seine + Eingabeaufforderungen ueber Reflexion auf. Getrimmt baut das zwar, bricht + aber erst beim Anwender - ein groesseres Binary ist der bessere Handel. +#> + +param( + [string] $Version = '2.3.0', + [string] $OutputDir = (Join-Path $PSScriptRoot '..\artifacts\installer'), + [string[]] $Runtimes = @('win-x64', 'linux-x64', 'linux-arm64') +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$project = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.UpdateAgent\Deploymentcenter.UpdateAgent.csproj' +$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-build-" + [guid]::NewGuid().ToString('N')) + +if (-not (Test-Path $project)) { + throw "Projekt nicht gefunden: $project" +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +Get-ChildItem $OutputDir -File | Remove-Item -Force + +$gitCommit = 'UNKNOWN' +try { $gitCommit = (git -C $repoRoot rev-parse --short HEAD).Trim() } catch { } + +$binaries = @() + +try { + foreach ($rid in $Runtimes) { + Write-Host "Baue $rid ..." -ForegroundColor Cyan + + $ridOut = Join-Path $staging $rid + + dotnet publish $project ` + -c Release -r $rid ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:EnableCompressionInSingleFile=true ` + -p:DebugType=None ` + -o $ridOut ` + -v q --nologo + + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish fuer $rid ist fehlgeschlagen." + } + + $isWindows = $rid.StartsWith('win') + $sourceName = if ($isWindows) { 'update-agent.exe' } else { 'update-agent' } + $targetName = if ($isWindows) { "update-agent-$rid.exe" } else { "update-agent-$rid" } + + $source = Join-Path $ridOut $sourceName + $target = Join-Path $OutputDir $targetName + + Copy-Item $source $target -Force + + $hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLower() + $size = (Get-Item $target).Length + + # Die Pruefsumme liegt als eigene Datei daneben, damit install.sh sie + # ohne JSON-Parser lesen kann. + [System.IO.File]::WriteAllText("$target.sha256", $hash) + + $binaries += [ordered]@{ + platform = $rid + file = $targetName + sha256 = $hash + sizeBytes = $size + } + + Write-Host (" {0,-22} {1,6:N1} MB {2}" -f $targetName, ($size / 1MB), $hash.Substring(0, 16)) + } + + $manifest = [ordered]@{ + tool = 'update-agent' + version = $Version + gitCommit = $gitCommit + buildDateUtc = (Get-Date).ToUniversalTime().ToString('o') + binaries = $binaries + } + + # Bewusst ueber WriteAllText mit einer BOM-freien Kodierung: Out-File + # -Encoding utf8 stellt unter Windows PowerShell 5.1 ein BOM voran, und + # PHPs json_decode() scheitert daran. Die Downloadseite haette dann + # dauerhaft "noch nichts hinterlegt" gemeldet. + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText( + (Join-Path $OutputDir 'installer.json'), + ($manifest | ConvertTo-Json -Depth 5), + $utf8NoBom) + + # Die Bootstrap-Skripte liegen versioniert im Repository und werden hier + # nur mitgenommen. + foreach ($script in @('install.sh', 'install.ps1')) { + $path = Join-Path $repoRoot "scripts\installer\$script" + if (Test-Path $path) { + Copy-Item $path (Join-Path $OutputDir $script) -Force + } else { + Write-Warning "$script nicht gefunden unter scripts\installer\ - wird nicht mit ausgeliefert." + } + } + + Write-Host '' + Write-Host "Fertig. Ergebnis in $OutputDir" -ForegroundColor Green + Write-Host "Hochladen mit: python scripts/upload_installer.py `"$OutputDir`"" +} +finally { + Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue +} diff --git a/scripts/installer/install.ps1 b/scripts/installer/install.ps1 new file mode 100644 index 0000000..1064872 --- /dev/null +++ b/scripts/installer/install.ps1 @@ -0,0 +1,72 @@ +<# + Holt den Deploymentcenter Update-Agent und legt ihn ausfuehrbar ab. + + irm https://dc.mhdf.de/installer/install.ps1 | iex + + Das Skript fuehrt die Installation NICHT selbst aus. Es laedt das Binary, + prueft die Pruefsumme und sagt, wie es weitergeht - was danach passiert, + entscheidet der Mensch davor. Ein Skript aus dem Netz, das ungefragt eine + Anwendung einrichtet und dabei nach Zugangsdaten fragt, waere genau das + Muster, vor dem man Nutzer sonst warnt. +#> + +$ErrorActionPreference = 'Stop' + +$baseUrl = if ($env:DC_BASE_URL) { $env:DC_BASE_URL.TrimEnd('/') } else { 'https://dc.mhdf.de' } +$targetDir = if ($env:DC_INSTALL_DIR) { $env:DC_INSTALL_DIR } else { $PWD.Path } + +# ------------------------------------------------------------------ Plattform +$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture + +switch ($arch) { + 'X64' { $rid = 'win-x64' } + default { + Write-Error "Nicht unterstuetzte Architektur: $arch. Verfuegbar ist derzeit win-x64." + return + } +} + +$binary = "update-agent-$rid.exe" +$target = Join-Path $targetDir 'update-agent.exe' +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-" + [guid]::NewGuid().ToString('N')) + +New-Item -ItemType Directory -Path $temp -Force | Out-Null + +try { + Write-Host "Lade $binary von $baseUrl ..." + + $tempBinary = Join-Path $temp 'agent.exe' + Invoke-WebRequest -Uri "$baseUrl/installer/$binary" -OutFile $tempBinary -UseBasicParsing + $expected = (Invoke-WebRequest -Uri "$baseUrl/installer/$binary.sha256" -UseBasicParsing).Content.Trim().ToLower() + + # --------------------------------------------------------- Pruefsumme + $actual = (Get-FileHash $tempBinary -Algorithm SHA256).Hash.ToLower() + + if ($actual -ne $expected) { + Write-Error "ABBRUCH: Pruefsumme stimmt nicht ueberein.`n erwartet: $expected`n erhalten: $actual" + return + } + + Write-Host 'Pruefsumme in Ordnung.' + + # ------------------------------------------------------------ Ablegen + Move-Item -Path $tempBinary -Destination $target -Force + + # Von Windows als "aus dem Internet" markierte Dateien loesen beim Start + # eine Sicherheitswarnung aus. Die Herkunft ist hier durch die gepruefte + # Pruefsumme belegt. + try { Unblock-File -Path $target -ErrorAction SilentlyContinue } catch { } + + Write-Host '' + Write-Host "Abgelegt: $target" + Write-Host '' + Write-Host 'Weiter mit:' + Write-Host " & '$target' --action install" + Write-Host '' + Write-Host "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'" + Write-Host 'gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein' + Write-Host 'Zielsystem.' +} +finally { + Remove-Item -Recurse -Force $temp -ErrorAction SilentlyContinue +} diff --git a/scripts/installer/install.sh b/scripts/installer/install.sh new file mode 100644 index 0000000..7b029a6 --- /dev/null +++ b/scripts/installer/install.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# +# Holt den Deploymentcenter Update-Agent und legt ihn ausfuehrbar ab. +# +# wget -qO- https://dc.mhdf.de/installer/install.sh | sh +# +# Das Skript fuehrt die Installation NICHT selbst aus. Es laedt das Binary, +# prueft die Pruefsumme und sagt, wie es weitergeht - was danach passiert, +# entscheidet der Mensch davor. Ein Skript aus dem Netz, das ungefragt eine +# Anwendung einrichtet und dabei nach Zugangsdaten fragt, waere genau das +# Muster, vor dem man Nutzer sonst warnt. + +set -eu + +BASE_URL="${DC_BASE_URL:-https://dc.mhdf.de}" +TARGET_DIR="${DC_INSTALL_DIR:-}" + +# ---------------------------------------------------------------- Plattform +os="$(uname -s)" +arch="$(uname -m)" + +case "$os" in + Linux) ;; + *) + echo "Nicht unterstuetzt: $os" >&2 + echo "Fuer Windows: https://dc.mhdf.de/installer/install.ps1" >&2 + exit 1 + ;; +esac + +case "$arch" in + x86_64|amd64) rid="linux-x64" ;; + aarch64|arm64) rid="linux-arm64" ;; + *) + echo "Nicht unterstuetzte Architektur: $arch" >&2 + exit 1 + ;; +esac + +binary="update-agent-$rid" + +# ------------------------------------------------------------- Zielverzeichnis +if [ -z "$TARGET_DIR" ]; then + if [ -w /usr/local/bin ] 2>/dev/null; then + TARGET_DIR="/usr/local/bin" + else + TARGET_DIR="$PWD" + fi +fi + +target="$TARGET_DIR/update-agent" + +# --------------------------------------------------------------- Werkzeuge +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -q "$1" -O "$2"; } +else + echo "Weder curl noch wget vorhanden." >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +# shellcheck disable=SC2064 +trap "rm -rf '$tmp'" EXIT INT TERM + +echo "Lade $binary von $BASE_URL ..." +fetch "$BASE_URL/installer/$binary" "$tmp/agent" +fetch "$BASE_URL/installer/$binary.sha256" "$tmp/agent.sha256" + +# ------------------------------------------------------------- Pruefsumme +expected="$(tr -d ' \t\r\n' < "$tmp/agent.sha256")" + +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$tmp/agent" | cut -d' ' -f1)" +elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$tmp/agent" | cut -d' ' -f1)" +else + actual="" +fi + +if [ -z "$actual" ]; then + echo "WARNUNG: Keine SHA256-Pruefung moeglich (weder sha256sum noch shasum)." >&2 +elif [ "$actual" != "$expected" ]; then + echo "ABBRUCH: Pruefsumme stimmt nicht ueberein." >&2 + echo " erwartet: $expected" >&2 + echo " erhalten: $actual" >&2 + exit 1 +else + echo "Pruefsumme in Ordnung." +fi + +# ------------------------------------------------------------------ Ablegen +chmod +x "$tmp/agent" + +if ! mv "$tmp/agent" "$target" 2>/dev/null; then + echo "Kein Schreibrecht in $TARGET_DIR - versuche es mit sudo oder setze DC_INSTALL_DIR." >&2 + exit 1 +fi + +echo "" +echo "Abgelegt: $target" +echo "" +echo "Weiter mit:" +echo " $target --action install" +echo "" +echo "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'" +echo "gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein" +echo "Zielsystem." diff --git a/scripts/upload_installer.py b/scripts/upload_installer.py new file mode 100644 index 0000000..b0c5f5b --- /dev/null +++ b/scripts/upload_installer.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Laedt die gebauten Installer-Binaries in den Webroot unter /installer/. + +Getrennt von deploy.py, weil dieses client-dotnet bewusst ausklammert: der +Quelltext des Agenten gehoert nicht auf den Webserver, die uebersetzten +Binaries schon. + +Aufruf: + python scripts/upload_installer.py + +Erwartet im Verzeichnis: die Binaries, je eine .sha256 dazu, installer.json +sowie install.sh und install.ps1. Erzeugt wird das alles von +scripts/build_installer.ps1. +""" + +import ftplib +import hashlib +import json +import os +import ssl +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_FILE = SCRIPT_DIR / 'deploy_config.json' +REMOTE_DIR = '/installer' + + +def load_config(): + if not CONFIG_FILE.exists(): + print(f'Fehler: {CONFIG_FILE} fehlt.') + sys.exit(1) + with open(CONFIG_FILE, 'r', encoding='utf-8') as handle: + return json.load(handle) + + +def connect(config): + if config.get('secure', False): + ftp = ftplib.FTP_TLS(context=ssl.create_default_context()) + ftp.connect(config['host'], config.get('port', 21)) + ftp.login(config['user'], config['pass']) + ftp.prot_p() + else: + ftp = ftplib.FTP() + ftp.connect(config['host'], config.get('port', 21)) + ftp.login(config['user'], config['pass']) + return ftp + + +def ensure_remote_dir(ftp, path): + current = '' + for part in [p for p in path.strip('/').split('/') if p]: + current += '/' + part + try: + ftp.cwd(current) + except ftplib.error_perm: + try: + ftp.mkd(current) + print(f'Verzeichnis angelegt: {current}') + except Exception as exc: # noqa: BLE001 + print(f'Warnung: {current} nicht anlegbar: {exc}') + + +def sha256(path): + digest = hashlib.sha256() + with open(path, 'rb') as handle: + while chunk := handle.read(65536): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + source = Path(sys.argv[1]).resolve() + if not source.is_dir(): + print(f'Fehler: {source} ist kein Verzeichnis.') + sys.exit(1) + + files = sorted(p for p in source.iterdir() if p.is_file()) + if not files: + print(f'Fehler: In {source} liegt nichts.') + sys.exit(1) + + config = load_config() + + print(f'Verbinde mit {config["host"]} ...') + ftp = connect(config) + print('Angemeldet.') + + ensure_remote_dir(ftp, REMOTE_DIR) + + uploaded = 0 + try: + for path in files: + remote = f'{REMOTE_DIR}/{path.name}' + size_mb = path.stat().st_size / (1024 * 1024) + print(f' {path.name} ({size_mb:.1f} MB) ...') + + ftp.cwd('/') + with open(path, 'rb') as handle: + ftp.storbinary(f'STOR {remote}', handle, blocksize=262144) + + uploaded += 1 + finally: + try: + ftp.quit() + except Exception: # noqa: BLE001 + pass + + print(f'\n{uploaded} von {len(files)} Datei(en) uebertragen nach {REMOTE_DIR}.') + + # Zur Kontrolle: die Pruefsummen, die auch auf der Downloadseite stehen. + print('\nPruefsummen:') + for path in files: + if path.suffix in ('.sha256', '.json', '.ps1', '.sh'): + continue + print(f' {path.name} {sha256(path)}') + + +if __name__ == '__main__': + main() diff --git a/sql/migrations/010_rocketchat_notifications.sql b/sql/migrations/010_rocketchat_notifications.sql new file mode 100644 index 0000000..c5b8bc7 --- /dev/null +++ b/sql/migrations/010_rocketchat_notifications.sql @@ -0,0 +1,17 @@ +-- 010_rocketchat_notifications.sql: Rocket.Chat Benachrichtigungen & 12h Statusbericht Cron-Job + +INSERT INTO dc_settings (skey, svalue) VALUES +('rocketchat_enabled', '1'), +('rocketchat_url', 'https://chat.wh1.mhdf.de'), +('rocketchat_username', 'deploymentcenter'), +('rocketchat_password', 'cNt.m.KcWHrb8_X9Tv8-'), +('rocketchat_status_channel', '#DC-Systemstatus'), +('rocketchat_alert_channel', '#DC-Alerts'), +('rocketchat_verify_ssl', '0'), +('rocketchat_report_interval', '43200') +ON DUPLICATE KEY UPDATE svalue = VALUES(svalue); + +-- Cronjob fuer den 12-Stunden-Systemstatusbericht (43200 Sekunden) +INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled) +VALUES ('rocketchat_status_report', 43200, 1) +ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec); diff --git a/sql/migrations/011_update_rocketchat_credentials.sql b/sql/migrations/011_update_rocketchat_credentials.sql new file mode 100644 index 0000000..aa5e070 --- /dev/null +++ b/sql/migrations/011_update_rocketchat_credentials.sql @@ -0,0 +1,12 @@ +-- 011_update_rocketchat_credentials.sql: Aktualisiere Rocket.Chat Zugangsdaten & Kanaele + +INSERT INTO dc_settings (skey, svalue) VALUES +('rocketchat_enabled', '1'), +('rocketchat_url', 'https://chat.wh1.mhdf.de'), +('rocketchat_username', 'deploymentcenter'), +('rocketchat_password', 'cNt.m.KcWHrb8_X9Tv8-'), +('rocketchat_status_channel', '#DC-Systemstatus'), +('rocketchat_alert_channel', '#DC-Alerts'), +('rocketchat_verify_ssl', '0'), +('rocketchat_report_interval', '43200') +ON DUPLICATE KEY UPDATE svalue = VALUES(svalue); diff --git a/sql/migrations/012_user_roles.sql b/sql/migrations/012_user_roles.sql new file mode 100644 index 0000000..6e66b89 --- /dev/null +++ b/sql/migrations/012_user_roles.sql @@ -0,0 +1,39 @@ +-- Migration 012: Benutzerrollen +-- +-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091. + +-- --------------------------------------------------------------------------- +-- Wozu darf sich dieses Konto anmelden? +-- --------------------------------------------------------------------------- +-- Die Erstinstallation einer Anwendung laeuft ueber ein Konsolenwerkzeug, das +-- auf dem jeweiligen Zielsystem nach Benutzername und Passwort fragt. Wuerde +-- man dort das Admin-Konto eingeben, verteilte man die Zugangsdaten zur +-- gesamten Verwaltungsoberflaeche auf jeden Rechner, auf dem je etwas +-- installiert wurde. +-- +-- 'installer' ist deshalb eine eigene Rolle: sie darf ueber +-- /api/setup/v1/login ein kurzlebiges Token ziehen und Anwendungen +-- einrichten - und sonst nichts. Insbesondere ist die Anmeldung am WebUI fuer +-- sie gesperrt. +-- +-- Bestandsschutz: alle vorhandenen Konten werden 'admin', damit sich am +-- bisherigen Verhalten nichts aendert. +ALTER TABLE dc_users + ADD COLUMN role ENUM('admin','installer') NOT NULL DEFAULT 'admin' AFTER password_hash; + +-- --------------------------------------------------------------------------- +-- Wann war dieses Konto zuletzt aktiv? +-- --------------------------------------------------------------------------- +-- Ein Installationskonto wird selten benutzt. Ohne diese Angabe laesst sich +-- nicht erkennen, ob ein angelegtes Konto noch gebraucht wird oder seit einem +-- Jahr ungenutzt herumliegt. +ALTER TABLE dc_users + ADD COLUMN last_login_at DATETIME NULL AFTER role; + +-- Ein deaktiviertes Konto bleibt erhalten (die Protokolle verweisen darauf), +-- kann sich aber nicht mehr anmelden. +ALTER TABLE dc_users + ADD COLUMN disabled TINYINT(1) NOT NULL DEFAULT 0 AFTER last_login_at; + +ALTER TABLE dc_users + ADD KEY ix_users_role (role); diff --git a/sql/schema.sql b/sql/schema.sql index 375bf1e..641751b 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -8,8 +8,17 @@ CREATE TABLE IF NOT EXISTS dc_users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, + -- 'installer' darf ausschliesslich ueber /api/setup/v1/login Anwendungen + -- einrichten, nicht aber das WebUI benutzen. Sonst muesste man auf jedem + -- Zielsystem die Zugangsdaten zur gesamten Verwaltung eingeben. + role ENUM('admin','installer') NOT NULL DEFAULT 'admin', + last_login_at DATETIME NULL, + disabled TINYINT(1) NOT NULL DEFAULT 0, + -- Vorgesehen, aber noch nicht ausgewertet: es gibt bislang keine + -- TOTP-Pruefung im Anmeldeweg. totp_secret VARCHAR(64) NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_users_role (role) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS dc_settings ( diff --git a/src/Core/Auth.php b/src/Core/Auth.php index 95001ce..7de128d 100644 --- a/src/Core/Auth.php +++ b/src/Core/Auth.php @@ -84,20 +84,26 @@ final class Auth } /** - * Prueft die Zugangsdaten und startet bei Erfolg eine frische Session. + * Prueft Zugangsdaten, ohne eine Session anzufassen. + * + * Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht, + * aber ein kurzlebiges Token statt eines Session-Cookies ausstellt. + * Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie + * hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden + * zu haben. + * + * @return array|null Der Benutzerdatensatz, oder null */ - public static function login(PDO $db, string $username, string $password): bool + public static function verifyCredentials(PDO $db, string $username, string $password): ?array { - self::startSession(); - $ip = Http::clientIp(); if (self::isLockedOut($db, $ip)) { Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]); - return false; + return null; } - $stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1'); + $stmt = $db->prepare('SELECT * FROM dc_users WHERE username = :u LIMIT 1'); $stmt->execute([':u' => $username]); $user = $stmt->fetch(); @@ -110,7 +116,18 @@ final class Auth if (!$verified || !$found) { self::recordAttempt($db, $ip, $username, false); - return false; + return null; + } + + // Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter + // darauf verweisen koennen - anmelden darf es sich nicht. + if ((int)($user['disabled'] ?? 0) === 1) { + self::recordAttempt($db, $ip, $username, false); + Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [ + 'username' => $username, + 'ip' => $ip, + ]); + return null; } // Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben. @@ -119,19 +136,75 @@ final class Auth $upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]); } + self::recordAttempt($db, $ip, $username, true); + self::touchLastLogin($db, (int)$user['id']); + + return $user; + } + + /** + * Rolle eines Datensatzes aus dc_users. + * + * Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist - + * sonst waere nach dem Einspielen des Codes und vor der Migration niemand + * mehr anmeldeberechtigt. + */ + public static function roleOf(?array $user): string + { + $role = is_array($user) ? (string)($user['role'] ?? 'admin') : 'admin'; + return $role === 'installer' ? 'installer' : 'admin'; + } + + /** + * Prueft die Zugangsdaten und startet bei Erfolg eine frische Session. + */ + public static function login(PDO $db, string $username, string $password): bool + { + self::startSession(); + + $user = self::verifyCredentials($db, $username, $password); + + if ($user === null) { + return false; + } + + // Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu + // suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto + // gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette + // damit auch Zugriff auf Tokens, Lizenzen und Monitore. + if (self::roleOf($user) === 'installer') { + Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [ + 'username' => $username, + 'ip' => Http::clientIp(), + ]); + return false; + } + session_regenerate_id(true); $_SESSION['dc_user_id'] = (int)$user['id']; $_SESSION['dc_username'] = (string)$user['username']; + $_SESSION['dc_role'] = self::roleOf($user); $_SESSION['dc_login_at'] = time(); $_SESSION['dc_last_seen'] = time(); - self::recordAttempt($db, $ip, $username, true); - Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]); + Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]); return true; } + private static function touchLastLogin(PDO $db, int $userId): void + { + try { + $stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id'); + $stmt->execute([':id' => $userId]); + } catch (\Throwable $e) { + // Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund, + // die Anmeldung scheitern zu lassen. + Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]); + } + } + public static function logout(): void { self::startSession(); diff --git a/src/Core/TokenManager.php b/src/Core/TokenManager.php index a4cfb0c..e35e729 100644 --- a/src/Core/TokenManager.php +++ b/src/Core/TokenManager.php @@ -40,6 +40,11 @@ final class TokenManager 'updateservice:read', 'updateservice:publish', 'tokens:provision', + // Erstinstallation: Katalog lesen und eine Anwendung einrichten. + // Diese Rechte traegt ausschliesslich das kurzlebige Token aus + // /api/setup/v1/login - sie gehoeren nicht auf ein Dauertoken. + 'setup:catalog', + 'setup:install', ]; private PDO $db; @@ -111,6 +116,82 @@ final class TokenManager ]; } + /** + * Erzeugt ein kurzlebiges Token ohne Elterntoken. + * + * Gedacht fuer die Erstinstallation: Der Installer meldet sich mit + * Benutzername und Passwort an und bekommt dafuer ein Token, das nach + * wenigen Minuten verfaellt. Bewusst kein Master-Token - es soll nichts + * weitervererben koennen - und bewusst mit Ablauf, weil es auf einem + * fremden Zielsystem im Speicher liegt. + * + * @param list $scopes + */ + public function createEphemeralToken( + string $name, + array $scopes, + int $ttlSeconds, + ?string $ownerIdentity = null + ): array { + $name = trim($name); + if ($name === '') { + throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.'); + } + + $effectiveScopes = self::normalizeScopes($scopes, []); + if ($effectiveScopes === []) { + throw new InvalidArgumentException('Ein kurzlebiges Token ohne Rechte waere wirkungslos.'); + } + + // Eine Obergrenze verhindert, dass aus einem Setup-Token durch einen + // grosszuegigen Aufrufer ein Dauertoken wird. + $ttlSeconds = max(60, min($ttlSeconds, 3600)); + + $tokenId = 'tok_s_' . bin2hex(random_bytes(8)); + $rawToken = 'dc_setup_' . bin2hex(random_bytes(24)); + $expires = gmdate('Y-m-d H:i:s', time() + $ttlSeconds); + + $stmt = $this->db->prepare(' + INSERT INTO dc_tokens ( + token_id, parent_token_id, token_hash, raw_token, name, + project_slug, license_key, owner_type, owner_identity, + type, scopes, environment, expires_at, created_at + ) VALUES ( + :id, NULL, :hash, NULL, :name, + NULL, NULL, "host", :identity, + "sub", :scopes, "all", :expires, UTC_TIMESTAMP() + ) + '); + + // raw_token bleibt hier immer NULL, auch wenn die Konfiguration das + // Mitschreiben erlaubt: ein Setup-Token wird einmal ausgeliefert und + // muss nirgends nachschlagbar sein. + $stmt->execute([ + ':id' => $tokenId, + ':hash' => hash('sha256', $rawToken), + ':name' => $name, + ':identity' => self::nullIfEmpty($ownerIdentity), + ':scopes' => json_encode($effectiveScopes), + ':expires' => $expires, + ]); + + Logger::info('Kurzlebiges Token erstellt', [ + 'token_id' => $tokenId, + 'name' => $name, + 'scopes' => $effectiveScopes, + 'expires_at' => $expires, + ]); + + return [ + 'token_id' => $tokenId, + 'raw_token' => $rawToken, + 'name' => $name, + 'type' => 'sub', + 'scopes' => $effectiveScopes, + 'expires_at' => $expires, + ]; + } + /** * Erzeugt ein Sub-Token aus einem gueltigen Master-Token. * Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden. @@ -353,6 +434,10 @@ final class TokenManager 'updateservice:publish' => ['updateservice:read'], 'watchdog:evaluate' => ['watchdog:read'], 'watchdog:ping' => ['watchdog:read'], + // Wer einrichten darf, muss den Katalog sehen und Releases lesen + // koennen - sonst gibt es nichts zu installieren. + 'setup:install' => ['setup:catalog', 'updateservice:read'], + 'setup:catalog' => ['updateservice:read'], ]; /** diff --git a/src/Modules/Notify/RocketChatNotifier.php b/src/Modules/Notify/RocketChatNotifier.php new file mode 100644 index 0000000..d4881d5 --- /dev/null +++ b/src/Modules/Notify/RocketChatNotifier.php @@ -0,0 +1,506 @@ +query(" + SELECT skey, svalue FROM dc_settings + WHERE skey LIKE 'rocketchat_%' + "); + if ($stmt !== false) { + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $dbSettings[$row['skey']] = $row['svalue']; + } + } + } catch (\Throwable $e) { + // Tabelle fehlt evtl. vor Migration + } + + $enabled = isset($dbSettings['rocketchat_enabled']) + ? (bool)(int)$dbSettings['rocketchat_enabled'] + : (bool)($fileConfig['enabled'] ?? true); + + $url = trim((string)($dbSettings['rocketchat_url'] ?? $fileConfig['url'] ?? 'https://chat.wh1.mhdf.de')); + $url = rtrim($url, '/'); + if (str_ends_with($url, '/home')) { + $url = substr($url, 0, -5); + } + + $username = trim((string)($dbSettings['rocketchat_username'] ?? $fileConfig['username'] ?? 'deploymentcenter')); + $password = (string)($dbSettings['rocketchat_password'] ?? $fileConfig['password'] ?? 'cNt.m.KcWHrb8_X9Tv8-'); + $statusChannel = trim((string)($dbSettings['rocketchat_status_channel'] ?? $fileConfig['status_channel'] ?? '#DC-Systemstatus')); + $alertChannel = trim((string)($dbSettings['rocketchat_alert_channel'] ?? $fileConfig['alert_channel'] ?? '#DC-Alerts')); + + $verifySsl = isset($dbSettings['rocketchat_verify_ssl']) + ? (bool)(int)$dbSettings['rocketchat_verify_ssl'] + : (bool)($fileConfig['verify_ssl'] ?? false); + + $reportInterval = isset($dbSettings['rocketchat_report_interval']) + ? (int)$dbSettings['rocketchat_report_interval'] + : (int)($fileConfig['report_interval'] ?? 43200); + + return [ + 'enabled' => $enabled, + 'url' => $url, + 'username' => $username, + 'password' => $password, + 'status_channel' => $statusChannel !== '' ? $statusChannel : '#general', + 'alert_channel' => $alertChannel !== '' ? $alertChannel : '#general', + 'verify_ssl' => $verifySsl, + 'report_interval' => max(300, $reportInterval), + ]; + } + + /** + * Fuehrt den Login am Rocket.Chat-Server aus und liefert Auth-Token und User-ID zurück. + * + * @param array $config + * @return array{authToken: string, userId: string}|null + */ + public static function authenticate(array $config): ?array + { + $url = rtrim((string)($config['url'] ?? ''), '/') . '/api/v1/login'; + $payload = json_encode([ + 'user' => (string)($config['username'] ?? ''), + 'password' => (string)($config['password'] ?? ''), + ]); + + [$ok, $status, $response, $error] = self::httpPost($url, $payload, ['Content-Type: application/json'], (bool)($config['verify_ssl'] ?? false)); + + if (!$ok || $status !== 200 || $response === null) { + Logger::warning('Rocket.Chat Login fehlgeschlagen', [ + 'status' => $status, + 'error' => $error ?? 'Keine Antwort', + ]); + return null; + } + + $json = json_decode($response, true); + if (!is_array($json) || empty($json['success'])) { + Logger::warning('Rocket.Chat Login ungueltige Antwort', ['json' => $json]); + return null; + } + + $authToken = (string)($json['data']['authToken'] ?? ''); + $userId = (string)($json['data']['userId'] ?? ''); + + if ($authToken === '' || $userId === '') { + return null; + } + + return [ + 'authToken' => $authToken, + 'userId' => $userId, + ]; + } + + /** + * Sendet eine Nachricht (optional mit Attachments) an einen Rocket.Chat-Kanal. + * + * @param array $overrideConfig + * @param list> $attachments + */ + public static function send(PDO $db, string $channel, string $text, array $attachments = [], array $overrideConfig = []): bool + { + $config = array_merge(self::getConfig($db), $overrideConfig); + + if (empty($config['enabled']) && empty($overrideConfig['ignore_enabled'])) { + return false; + } + + $auth = self::authenticate($config); + if ($auth === null) { + return false; + } + + $postUrl = rtrim((string)$config['url'], '/') . '/api/v1/chat.postMessage'; + + $body = [ + 'channel' => $channel, + 'text' => $text, + ]; + if ($attachments !== []) { + $body['attachments'] = $attachments; + } + + $jsonPayload = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($jsonPayload === false) { + return false; + } + + $headers = [ + 'Content-Type: application/json', + 'X-Auth-Token: ' . $auth['authToken'], + 'X-User-Id: ' . $auth['userId'], + ]; + + [$ok, $status, $response, $error] = self::httpPost($postUrl, $jsonPayload, $headers, (bool)$config['verify_ssl']); + + if (!$ok || $status !== 200) { + Logger::error('Rocket.Chat Nachrichten-Versand fehlgeschlagen', [ + 'channel' => $channel, + 'status' => $status, + 'error' => $error, + ]); + return false; + } + + return true; + } + + /** + * Erzeugt und versendet den 12-Stunden-Systemstatusbericht an Rocket.Chat. + * + * @return array{sent: bool, reason: string} + */ + public static function sendStatusReport(PDO $db, bool $force = false): array + { + $config = self::getConfig($db); + if (!$force && empty($config['enabled'])) { + return ['sent' => false, 'reason' => 'Rocket.Chat Benachrichtigungen sind deaktiviert.']; + } + + $intervalSec = $config['report_interval']; + + if (!$force) { + try { + $stmt = $db->prepare("SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'"); + $stmt->execute(); + $lastRun = $stmt->fetchColumn(); + + if ($lastRun !== false && $lastRun !== null) { + $lastRunTs = strtotime((string)$lastRun . ' UTC'); + if ($lastRunTs !== false && (time() - $lastRunTs) < $intervalSec) { + return ['sent' => false, 'reason' => 'Statusbericht noch nicht faellig.']; + } + } + } catch (\Throwable $e) { + // Bei Tabellenfehler fortfahren + } + } + + // Monitore ermitteln + $totalMonitors = 0; + $upCount = 0; + $warningCount = 0; + $downCount = 0; + $stoppedCount = 0; + $problemMonitors = []; + + try { + $stmt = $db->query('SELECT source, instance, state, last_message, updated_utc FROM watchdog_monitors'); + if ($stmt !== false) { + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + $totalMonitors = count($rows); + foreach ($rows as $m) { + $st = (string)($m['state'] ?? 'unknown'); + if ($st === 'up') { + $upCount++; + } elseif ($st === 'warning') { + $warningCount++; + $problemMonitors[] = "⚠️ **{$m['source']}** ({$m['instance']}): Warning - " . ($m['last_message'] ?: 'Intervall ueberschritten'); + } elseif ($st === 'down' || $st === 'error') { + $downCount++; + $problemMonitors[] = "🔴 **{$m['source']}** ({$m['instance']}): DOWN - " . ($m['last_message'] ?: 'Kein Heartbeat'); + } elseif ($st === 'stopped') { + $stoppedCount++; + } + } + } + } catch (\Throwable $e) { + Logger::warning('Fehler beim Abrufen der Monitore fuer RocketChat-Bericht', ['error' => $e->getMessage()]); + } + + // Bugtracker Statistiken + $openBugs = 0; + $criticalBugs = 0; + try { + $stmt = $db->query("SELECT COUNT(*) FROM bugtracker_items WHERE status IN ('open', 'planned', 'in_progress')"); + if ($stmt !== false) { + $openBugs = (int)$stmt->fetchColumn(); + } + + $stmt = $db->query("SELECT COUNT(*) FROM bugtracker_items WHERE status IN ('open', 'planned', 'in_progress') AND severity = 'critical'"); + if ($stmt !== false) { + $criticalBugs = (int)$stmt->fetchColumn(); + } + } catch (\Throwable $e) { + // Ignorieren falls nicht verfuegbar + } + + // Farbe und Titel bestimmen + $color = '#28a745'; // Gruen + $statusHeader = '🟢 **System-Statusbericht: Alle Systeme betriebsbereit**'; + + if ($downCount > 0) { + $color = '#dc3545'; // Rot + $statusHeader = "🔴 **System-Statusbericht: {$downCount} System(e) AUSGEFALLEN**"; + } elseif ($warningCount > 0 || $criticalBugs > 0) { + $color = '#ffc107'; // Gelb + $statusHeader = "⚠️ **System-Statusbericht: Warnungen vorhanden**"; + } + + $appUrl = (string)Config::get('app.url', ''); + + $fields = [ + ['title' => 'Monitore Gesamt', 'value' => (string)$totalMonitors, 'short' => true], + ['title' => 'Status UP', 'value' => (string)$upCount, 'short' => true], + ['title' => 'Status WARNING', 'value' => (string)$warningCount, 'short' => true], + ['title' => 'Status DOWN', 'value' => (string)$downCount, 'short' => true], + ['title' => 'Offene Bugtracker-Items', 'value' => (string)$openBugs, 'short' => true], + ['title' => 'Kritische Bugs', 'value' => (string)$criticalBugs, 'short' => true], + ]; + + $detailText = ''; + if ($problemMonitors !== []) { + $detailText .= "\n\n**Auffaellige Systeme:**\n" . implode("\n", array_slice($problemMonitors, 0, 10)); + } + if ($appUrl !== '') { + $detailText .= "\n\n🔗 [Zum Deploymentcenter Dashboard]({$appUrl})"; + } + + $attachments = [ + [ + 'color' => $color, + 'title' => 'Deploymentcenter Statusübersicht (12-Stunden-Intervall)', + 'text' => $statusHeader . $detailText, + 'fields' => $fields, + 'ts' => gmdate('Y-m-d\TH:i:s\Z'), + ] + ]; + + $sent = self::send($db, $config['status_channel'], '📊 **Deploymentcenter Statusbericht**', $attachments); + + if ($sent) { + self::recordReportRun($db, 'ok'); + return ['sent' => true, 'reason' => 'Statusbericht erfolgreich versendet.']; + } + + self::recordReportRun($db, 'failed'); + return ['sent' => false, 'reason' => 'Versand des Statusberichts an Rocket.Chat fehlgeschlagen.']; + } + + /** + * Sendet eine sofortige kritische Warnmeldung an den Alarm-Kanal. + * + * @param array $details + */ + public static function sendAlert(PDO $db, string $title, string $message, string $severity = 'alarm', array $details = []): bool + { + $config = self::getConfig($db); + if (empty($config['enabled'])) { + return false; + } + + $color = $severity === 'alarm' ? '#dc3545' : '#ffc107'; + $icon = $severity === 'alarm' ? '🚨' : '⚠️'; + + $fields = []; + foreach ($details as $k => $v) { + if (is_scalar($v)) { + $fields[] = [ + 'title' => ucfirst((string)$k), + 'value' => (string)$v, + 'short' => true, + ]; + } + } + + $appUrl = (string)Config::get('app.url', ''); + $text = "{$icon} **[ALARM] {$title}**\n{$message}"; + if ($appUrl !== '') { + $text .= "\n🔗 [Deploymentcenter Öffnen]({$appUrl})"; + } + + $attachments = [ + [ + 'color' => $color, + 'title' => "System-Warnung: {$title}", + 'text' => $text, + 'fields' => $fields, + 'ts' => gmdate('Y-m-d\TH:i:s\Z'), + ] + ]; + + return self::send($db, $config['alert_channel'], "{$icon} **Kritische Benachrichtigung vom Deploymentcenter**", $attachments); + } + + /** + * Testet die Rocket.Chat Verbindungsdaten und schickt eine Testnachricht an beide Kanaele. + * + * @param array $config + * @return array{success: bool, message: string} + */ + public static function testConnection(array $config): array + { + $config['ignore_enabled'] = true; + $auth = self::authenticate($config); + if ($auth === null) { + return [ + 'success' => false, + 'message' => 'Login fehlgeschlagen: Die Anmeldedaten oder die Server-URL sind ungueltig.', + ]; + } + + $statusChannel = (string)($config['status_channel'] ?? '#systemstatus'); + $alertChannel = (string)($config['alert_channel'] ?? '#alerts'); + + $dbMock = new class extends PDO { + public function __construct() {} + }; + + // Standard-Dummy-PDO fuer den Testaufruf ohne DB-Abhangigkeit + $sentStatus = self::sendDirect($config, $auth, $statusChannel, '✅ **Rocket.Chat Verbindungstest**: Status-Kanal erreichbar.'); + $sentAlert = self::sendDirect($config, $auth, $alertChannel, '🚨 **Rocket.Chat Verbindungstest**: Alarm-Kanal erreichbar.'); + + if ($sentStatus && $sentAlert) { + return [ + 'success' => true, + 'message' => "Verbindung erfolgreich! Testnachrichten wurden an {$statusChannel} und {$alertChannel} gesendet.", + ]; + } + + if ($sentStatus || $sentAlert) { + return [ + 'success' => true, + 'message' => "Teilweise erfolgreich: Login klappte, aber mindestens ein Kanal war nicht erreichbar.", + ]; + } + + return [ + 'success' => false, + 'message' => 'Login erfolgreich, aber Nachrichten konnten in den angegebenen Kanaelen nicht gepostet werden.', + ]; + } + + private static function sendDirect(array $config, array $auth, string $channel, string $text): bool + { + $postUrl = rtrim((string)$config['url'], '/') . '/api/v1/chat.postMessage'; + $body = json_encode([ + 'channel' => $channel, + 'text' => $text, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $headers = [ + 'Content-Type: application/json', + 'X-Auth-Token: ' . $auth['authToken'], + 'X-User-Id: ' . $auth['userId'], + ]; + + [$ok, $status] = self::httpPost($postUrl, (string)$body, $headers, (bool)($config['verify_ssl'] ?? false)); + return $ok && $status === 200; + } + + private static function recordReportRun(PDO $db, string $status): void + { + try { + $stmt = $db->prepare(' + INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, running, last_status, enabled) + VALUES ("rocketchat_status_report", 43200, UTC_TIMESTAMP(), 0, :status, 1) + ON DUPLICATE KEY UPDATE + last_run_utc = UTC_TIMESTAMP(), + running = 0, + last_status = VALUES(last_status) + '); + $stmt->execute([':status' => $status]); + } catch (\Throwable $e) { + // Ignorieren falls DB unvollstaendig + } + } + + /** + * @param list $headers + * @return array{0:bool, 1:int, 2:?string, 3:?string} + */ + private static function httpPost(string $url, string $payload, array $headers, bool $verifySsl): array + { + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch === false) { + return [false, 0, null, 'curl_init fehlgeschlagen']; + } + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => $verifySsl, + CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0, + ]); + + $response = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = $response === false ? curl_error($ch) : null; + curl_close($ch); + + return [$status >= 200 && $status < 300, $status, is_string($response) ? $response : null, $error]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $payload, + 'timeout' => self::TIMEOUT_SECONDS, + 'ignore_errors' => true, + ], + 'ssl' => [ + 'verify_peer' => $verifySsl, + 'verify_peer_name' => $verifySsl, + ], + ]); + + $response = @file_get_contents($url, false, $context); + $status = 0; + if (isset($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m) === 1) { + $status = (int)$m[1]; + } + + return [ + $response !== false && $status >= 200 && $status < 300, + $status, + $response !== false ? $response : null, + $response === false ? 'HTTP-Anfrage fehlgeschlagen' : null, + ]; + } +} diff --git a/src/Modules/Notify/WebhookDispatcher.php b/src/Modules/Notify/WebhookDispatcher.php index 5b0193d..9c19e05 100644 --- a/src/Modules/Notify/WebhookDispatcher.php +++ b/src/Modules/Notify/WebhookDispatcher.php @@ -41,6 +41,34 @@ final class WebhookDispatcher public static function dispatch(PDO $db, string $event, array $payload): void { + // Kritische Ereignisse direkt an Rocket.Chat spiegeln + if ($event === 'bug.critical') { + RocketChatNotifier::sendAlert( + $db, + 'Kritischer Bug gemeldet', + (string)($payload['title'] ?? $payload['error_message'] ?? 'Neuer kritischer Fehler'), + 'alarm', + [ + 'Projekt' => $payload['project_slug'] ?? 'Unbekannt', + 'Umgebung' => $payload['environment'] ?? 'production', + 'Item-ID' => $payload['item_id'] ?? 'neu', + ] + ); + } elseif ($event === 'monitor.down') { + RocketChatNotifier::sendAlert( + $db, + 'System-Ausfall erkannt (Watchdog)', + (string)($payload['source'] ?? 'Monitor') . ' (' . ($payload['instance'] ?? 'default') . '): ' . ($payload['reason'] ?? 'Heartbeat ausgeblieben'), + 'alarm', + [ + 'Quelle' => $payload['source'] ?? 'unbekannt', + 'Instanz' => $payload['instance'] ?? 'default', + 'Von Zustand' => $payload['from_state'] ?? 'up', + 'Nach Zustand' => $payload['to_state'] ?? 'down', + ] + ); + } + if (self::$dispatchedThisRequest >= self::MAX_TARGETS) { return; } diff --git a/src/Modules/Setup/SetupCatalog.php b/src/Modules/Setup/SetupCatalog.php new file mode 100644 index 0000000..d32ba79 --- /dev/null +++ b/src/Modules/Setup/SetupCatalog.php @@ -0,0 +1,147 @@ +db = $db; + } + + /** + * @return list> + */ + public function forPlatform(?string $platform, ?string $projectFilter = null): array + { + $requested = UpdateManager::normalizePlatform($platform); + + $candidates = $requested === UpdateManager::PLATFORM_ANY + ? [UpdateManager::PLATFORM_ANY] + : [$requested, UpdateManager::PLATFORM_ANY]; + + $placeholders = implode(', ', array_map( + static fn(int $i): string => ':platform' . $i, + array_keys($candidates) + )); + + $sql = ' + SELECT r.*, p.name AS project_name, p.notes AS project_notes + FROM updateservice_releases r + LEFT JOIN dc_projects p ON p.slug = r.product_slug + WHERE r.platform IN (' . $placeholders . ') + '; + + $params = []; + foreach ($candidates as $i => $candidate) { + $params[':platform' . $i] = $candidate; + } + + if ($projectFilter !== null && $projectFilter !== '') { + $sql .= ' AND r.product_slug = :slug'; + $params[':slug'] = $projectFilter; + } + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll() ?: []; + + // Nach Projekt und Kanal buendeln; je Version gewinnt - wie im + // UpdateManager - das plattformgenaue Paket vor dem generischen. + $grouped = []; + + foreach ($rows as $row) { + $slug = (string)$row['product_slug']; + $channel = (string)$row['channel']; + $version = (string)$row['version']; + + $key = $slug . "\0" . $channel . "\0" . $version; + $existing = $grouped[$key] ?? null; + + if ($existing === null) { + $grouped[$key] = $row; + continue; + } + + $existingPlatform = (string)($existing['platform'] ?? UpdateManager::PLATFORM_ANY); + $rowPlatform = (string)($row['platform'] ?? UpdateManager::PLATFORM_ANY); + + if ($existingPlatform === UpdateManager::PLATFORM_ANY + && $rowPlatform !== UpdateManager::PLATFORM_ANY) { + $grouped[$key] = $row; + } + } + + // Je Projekt und Kanal das hoechste Release ermitteln. + $byProject = []; + + foreach ($grouped as $row) { + $slug = (string)$row['product_slug']; + $channel = (string)$row['channel']; + + if (!isset($byProject[$slug])) { + $byProject[$slug] = [ + 'slug' => $slug, + 'name' => (string)($row['project_name'] ?? $slug), + 'notes' => $row['project_notes'] !== null ? (string)$row['project_notes'] : null, + 'channels' => [], + ]; + } + + $current = $byProject[$slug]['channels'][$channel] ?? null; + + if ($current === null + || Version::isNewer((string)$row['version'], (string)$current['version'])) { + $byProject[$slug]['channels'][$channel] = [ + 'channel' => $channel, + 'version' => (string)$row['version'], + 'platform' => (string)($row['platform'] ?? UpdateManager::PLATFORM_ANY), + 'size_bytes' => (int)$row['size_bytes'], + 'is_critical' => (bool)$row['is_critical'], + 'signed' => !empty($row['manifest_signature']), + 'release_notes'=> $row['release_notes'] !== null ? (string)$row['release_notes'] : null, + 'released_at' => (string)$row['created_at'], + 'download_url' => (string)$row['download_url'], + ]; + } + } + + // Kanaele in eine verlaessliche Reihenfolge bringen: was am ehesten + // gewaehlt werden soll, steht vorn. + $order = ['prod' => 0, 'beta' => 1, 'dev' => 2]; + + $catalog = []; + foreach ($byProject as $entry) { + $channels = array_values($entry['channels']); + usort($channels, static function (array $a, array $b) use ($order): int { + $rankA = $order[$a['channel']] ?? 99; + $rankB = $order[$b['channel']] ?? 99; + return $rankA === $rankB ? strcmp($a['channel'], $b['channel']) : $rankA <=> $rankB; + }); + + $entry['channels'] = $channels; + $catalog[] = $entry; + } + + usort($catalog, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name'])); + + return $catalog; + } +} diff --git a/src/Modules/UpdateService/UpdateManager.php b/src/Modules/UpdateService/UpdateManager.php index 1cab3bc..99c04fc 100644 --- a/src/Modules/UpdateService/UpdateManager.php +++ b/src/Modules/UpdateService/UpdateManager.php @@ -268,11 +268,99 @@ final class UpdateManager return is_array($row) ? $row : null; } + public function getReleaseById(int $id): ?array + { + $stmt = $this->db->prepare(' + SELECT * FROM updateservice_releases + WHERE id = :id + LIMIT 1 + '); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(); + return is_array($row) ? $row : null; + } + + public function updateRelease( + int $id, + string $productSlug, + string $version, + string $channel = 'prod', + ?string $releaseNotes = null, + string $downloadUrl = '', + ?string $sha256Hash = null, + ?string $gitCommit = null, + int $sizeBytes = 0, + ?string $manifestJson = null, + bool $isCritical = false, + string $author = 'admin', + ?string $platform = null, + ?string $manifestSignature = null + ): bool { + $platform = self::normalizePlatform($platform); + + $stmt = $this->db->prepare(' + UPDATE updateservice_releases SET + product_slug = :slug, + version = :version, + channel = :channel, + platform = :platform, + release_notes = :notes, + download_url = :url, + sha256_hash = :hash, + git_commit = :git, + size_bytes = :size, + manifest_json = :manifest, + manifest_signature = :signature, + is_critical = :critical + WHERE id = :id + '); + + $stmt->execute([ + ':id' => $id, + ':slug' => $productSlug, + ':version' => $version, + ':channel' => $channel, + ':platform' => $platform, + ':notes' => $releaseNotes, + ':url' => $downloadUrl, + ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null, + ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null, + ':size' => $sizeBytes, + ':manifest' => $manifestJson, + ':signature' => $manifestSignature !== null && $manifestSignature !== '' ? $manifestSignature : null, + ':critical' => $isCritical ? 1 : 0, + ]); + + Logger::info('Release aktualisiert', [ + 'id' => $id, + 'product' => $productSlug, + 'version' => $version, + 'channel' => $channel, + 'platform' => $platform, + 'author' => $author, + ]); + + return true; + } + public function deleteRelease(int $id): bool { + $existing = $this->getReleaseById($id); $stmt = $this->db->prepare('DELETE FROM updateservice_releases WHERE id = :id'); $stmt->execute([':id' => $id]); - return $stmt->rowCount() > 0; + $deleted = $stmt->rowCount() > 0; + + if ($deleted && $existing !== null) { + Logger::info('Release geloescht', [ + 'id' => $id, + 'product' => $existing['product_slug'], + 'version' => $existing['version'], + 'channel' => $existing['channel'], + 'platform' => $existing['platform'] ?? self::PLATFORM_ANY, + ]); + } + + return $deleted; } /** diff --git a/src/Modules/Watchdog/Evaluator.php b/src/Modules/Watchdog/Evaluator.php index e7c2e5e..7f3bd8e 100644 --- a/src/Modules/Watchdog/Evaluator.php +++ b/src/Modules/Watchdog/Evaluator.php @@ -6,6 +6,7 @@ namespace Deploymentcenter\Modules\Watchdog; use Deploymentcenter\Core\Logger; use Deploymentcenter\Modules\Bugtracker\BugRepo; +use Deploymentcenter\Modules\Notify\RocketChatNotifier; use Deploymentcenter\Modules\Notify\WebhookDispatcher; use PDO; @@ -150,6 +151,13 @@ final class Evaluator $purgedMetrics = (new MetricStore($db))->purge(); } + // Rocket.Chat 12-Stunden-Statusbericht (sofern faellig) + try { + RocketChatNotifier::sendStatusReport($db, false); + } catch (\Throwable $e) { + Logger::warning('RocketChat-Statusbericht fehlgeschlagen', ['error' => $e->getMessage()]); + } + $durationMs = (int)round((microtime(true) - $started) * 1000); self::recordRun($db, $durationMs, count($changes));