feat(setup): Erstinstallation ueber den Update-Agent, Installationskonto, Downloads
Bisher gab es nur den Update-Weg: eine Anwendung musste bereits installiert und eingerichtet sein, damit sich etwas aktualisieren liess. Die Erstinstallation auf einem neuen System war Handarbeit - Paket kopieren, Konfiguration abtippen, Token besorgen. Setup-API (neu) - POST /api/setup/v1/login tauscht Benutzername und Passwort gegen ein Token mit 30 Minuten Gueltigkeit und ausschliesslich setup:install. Es wird nicht mitgeschrieben und lebt im Installer nur im Speicher. - GET /api/setup/v1/catalog zeigt nur, was zur Laufzeitkennung des anfragenden Systems passt. Ein Projekt mit ausschliesslich Windows-Paket taucht auf einem Linux-Rechner gar nicht erst auf. - POST /api/setup/v1/token stellt das Dauertoken der Anwendung aus. Welche Rechte vergeben werden, entscheidet der Server; die Anfrage kann nur einschraenken. Sonst waere der Umweg ueber ein kurzlebiges Token wirkungslos. Rollentrennung (Migration 012) - dc_users bekommt role, disabled und last_login_at. Die Rolle "installer" darf sich ueber den Setup-Weg anmelden und nicht am WebUI. Die Zugangsdaten werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und Monitoren auf jeden Rechner, auf dem je etwas installiert wurde. - Auth::verifyCredentials() prueft sessionfrei, damit Setup- und WebUI-Login nicht zwei verschiedene Haertungsgrade haben (Drosselung, Timing-Angleichung, Rehash gelten fuer beide). - Konten mit hinterlegtem TOTP-Geheimnis werden am Setup-Weg mit 501 abgewiesen. Eine TOTP-Pruefung gibt es im Deploymentcenter noch nicht; sie stillschweigend zu uebergehen waere ein Rueckschritt. - Benutzerverwaltung im WebUI - es gab bisher gar keine, nur den einen von install_db.php angelegten Admin. Das letzte aktive Administratorkonto laesst sich weder deaktivieren noch loeschen. Installer - update-agent --action install fuehrt durch Anmeldung, Auswahl, Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und Rollback. Ein zweiter Download-Weg waere ein zweiter Ort fuer dieselben Fehler. - --action configure holt die Einrichtung nachtraeglich. - setup.json im Paket beschreibt die benoetigten Werte. Bewusst im Paket und nicht zentral: so ist sie mit der Anwendung versioniert. - Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... -> provision -> fragen. Platzhalter wie changeme oder <dein-wert> gelten dabei nicht als eingerichtet, sonst liefe die Anwendung mit der Vorlage los. - SetupWriter erhaelt vorhandene Inhalte. Eine appsettings.json fuehrt neben den abgefragten Werten meist Logging und anderes; sie neu zu erzeugen waere bequemer und verloere das - bei einer Neuinstallation ohne Backup. int und bool landen als JSON-Typ, nicht als Zeichenkette. Downloads - scripts/build_installer.ps1 baut selbstenthaltende Einzeldateien fuer win-x64, linux-x64 und linux-arm64 (rund 34 MB, .NET-Laufzeit inbegriffen). Ohne NativeAOT und ohne Trimming: Spectre.Console loest ueber Reflexion auf und braeche sonst erst beim Anwender. - scripts/upload_installer.py laedt sie nach /installer/. Getrennt von deploy.py, das client-dotnet bewusst ausklammert. - Bereich "Installer" auf der UpdateService-Seite mit Groessen, Pruefsummen und den wget-Befehlen; die Angaben stammen aus installer.json statt aus fest eingetragenem Text. - install.sh und install.ps1 laden, pruefen die Pruefsumme und legen ab - sie richten bewusst nichts selbst ein. Das Manifest wird BOM-frei geschrieben, sonst scheitert json_decode() daran. Enthaelt ausserdem die bislang nicht committete Arbeit an den RocketChat-Benachrichtigungen (Migrationen 010 und 011) sowie die Loesch- und Editierfunktion des UpdateService; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2388b5abe1
commit
c8f3e78635
@@ -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/
|
||||
|
||||
@@ -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]
|
||||
|
||||
+14
-1
@@ -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();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deploymentcenter.Client.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Beschreibt, was eine Anwendung zum Laufen braucht - als Datei
|
||||
/// <c>setup.json</c> 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.
|
||||
/// </summary>
|
||||
public class SetupDefinition
|
||||
{
|
||||
/// <summary>Format-Version dieser Datei.</summary>
|
||||
[JsonPropertyName("schema")]
|
||||
public int Schema { get; set; } = 1;
|
||||
|
||||
/// <summary>Anzeigename, sonst wird der Projekt-Slug verwendet.</summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Kurzer Hinweistext, der vor der Abfrage angezeigt wird.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Wohin die ermittelten Werte geschrieben werden.</summary>
|
||||
[JsonPropertyName("targets")]
|
||||
public List<SetupTarget> Targets { get; set; } = new List<SetupTarget>();
|
||||
|
||||
/// <summary>Die benoetigten Werte.</summary>
|
||||
[JsonPropertyName("fields")]
|
||||
public List<SetupField> Fields { get; set; } = new List<SetupField>();
|
||||
}
|
||||
|
||||
/// <summary>Eine Datei, in die Werte geschrieben werden.</summary>
|
||||
public class SetupTarget
|
||||
{
|
||||
/// <summary>Pfad relativ zum Installationsverzeichnis.</summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string File { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>"json" oder "env".</summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; set; } = "json";
|
||||
|
||||
/// <summary>
|
||||
/// Kennung, ueber die Felder dieser Datei zugeordnet werden. Ohne
|
||||
/// Angabe schreiben alle Felder in das erste Ziel.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Ein einzelner Konfigurationswert.</summary>
|
||||
public class SetupField
|
||||
{
|
||||
/// <summary>
|
||||
/// Schluessel im Ziel. Bei JSON trennt ein Doppelpunkt die Ebenen
|
||||
/// ("ConnectionStrings:Main"), passend zur Schreibweise von
|
||||
/// Microsoft.Extensions.Configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Beschriftung fuer die Abfrage.</summary>
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Erlaeuterung, die unter der Frage steht.</summary>
|
||||
[JsonPropertyName("help")]
|
||||
public string Help { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>string | secret | url | int | bool | enum</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "string";
|
||||
|
||||
/// <summary>Muss ein Wert vorliegen?</summary>
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; set; } = true;
|
||||
|
||||
/// <summary>Vorbelegung.</summary>
|
||||
[JsonPropertyName("default")]
|
||||
public string Default { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "ask";
|
||||
|
||||
/// <summary>Rechte fuer source = provision.</summary>
|
||||
[JsonPropertyName("scopes")]
|
||||
public List<string> Scopes { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Auswahlmoeglichkeiten fuer type = enum.</summary>
|
||||
[JsonPropertyName("options")]
|
||||
public List<string> Options { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Regulaerer Ausdruck, gegen den der Wert geprueft wird.</summary>
|
||||
[JsonPropertyName("validate")]
|
||||
public string Validate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Meldung, wenn die Pruefung fehlschlaegt.</summary>
|
||||
[JsonPropertyName("validationMessage")]
|
||||
public string ValidationMessage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Kennung des Ziels aus <see cref="SetupTarget.Id"/>.</summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string Target { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Wird der Wert verdeckt eingegeben und nicht angezeigt?</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsSecret =>
|
||||
string.Equals(Type, "secret", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(Source, "provision", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Ergebnis einer Setup-Anmeldung.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Verbleibende Gueltigkeit, nie negativ.</summary>
|
||||
public TimeSpan Remaining
|
||||
{
|
||||
get
|
||||
{
|
||||
var left = ExpiresAt - DateTimeOffset.UtcNow;
|
||||
return left < TimeSpan.Zero ? TimeSpan.Zero : left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Eine installierbare Anwendung aus dem Katalog.</summary>
|
||||
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<CatalogChannel> Channels { get; set; } = new List<CatalogChannel>();
|
||||
}
|
||||
|
||||
/// <summary>Ein Kanal mit dem dort neuesten Release.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Fehler beim Ansprechen der Setup-Schnittstelle.</summary>
|
||||
public sealed class SetupException : Exception
|
||||
{
|
||||
public SetupException(string message, string code = "") : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>Stabiler Fehlercode der API, sofern vorhanden.</summary>
|
||||
public string Code { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Meldet sich an und merkt sich das Token fuer die Folgeaufrufe.</summary>
|
||||
public async Task<SetupSession> LoginAsync(
|
||||
string username,
|
||||
string password,
|
||||
string? hostname = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <summary>Holt die auf dieser Plattform installierbaren Anwendungen.</summary>
|
||||
public async Task<List<CatalogEntry>> 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<CatalogEntry>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Laesst ein Dauertoken fuer die eingerichtete Anwendung ausstellen.
|
||||
/// Welche Rechte tatsaechlich vergeben werden, entscheidet der Server -
|
||||
/// die Anfrage kann nur einschraenken.
|
||||
/// </summary>
|
||||
public async Task<string> RequestApplicationTokenAsync(
|
||||
string project,
|
||||
IEnumerable<string> scopes,
|
||||
string? hostname = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireSession();
|
||||
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project"] = project,
|
||||
["scopes"] = new List<string>(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<JsonDocument> PostAsync(
|
||||
string action,
|
||||
Dictionary<string, object?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Ein zu schreibender Konfigurationswert.</summary>
|
||||
public sealed class SetupValue
|
||||
{
|
||||
public SetupValue(string key, string value, string type = "string")
|
||||
{
|
||||
Key = key;
|
||||
Value = value;
|
||||
Type = type;
|
||||
}
|
||||
|
||||
/// <summary>Schluessel, Ebenen durch Doppelpunkt getrennt.</summary>
|
||||
public string Key { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
/// <summary>string | secret | url | int | bool | enum</summary>
|
||||
public string Type { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt ermittelte Werte in die Konfigurationsdateien der Anwendung.
|
||||
///
|
||||
/// Durchgaengiges Prinzip: <b>vorhandene Inhalte bleiben erhalten</b>. 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.
|
||||
/// </summary>
|
||||
public static class SetupWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt Werte in eine JSON-Datei. Ebenen werden aus dem Schluessel
|
||||
/// gebildet: "ConnectionStrings:Main" wird zu
|
||||
/// { "ConnectionStrings": { "Main": ... } }.
|
||||
/// </summary>
|
||||
public static void WriteJson(string path, IEnumerable<SetupValue> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt Werte in eine Datei im Stil von <c>.env</c>. Vorhandene
|
||||
/// Zeilen werden aktualisiert, unbekannte Schluessel angehaengt;
|
||||
/// Kommentare und Reihenfolge bleiben erhalten.
|
||||
/// </summary>
|
||||
public static void WriteEnv(string path, IEnumerable<SetupValue> values)
|
||||
{
|
||||
var pending = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values)
|
||||
{
|
||||
pending[ToEnvKey(value.Key)] = value.Value;
|
||||
}
|
||||
|
||||
var lines = new List<string>();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Schreibt in das Format, das zum Ziel passt.</summary>
|
||||
public static void Write(string path, string format, IEnumerable<SetupValue> values)
|
||||
{
|
||||
if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WriteEnv(path, values);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteJson(path, values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "ConnectionStrings:Main" wird zu CONNECTIONSTRINGS__MAIN - die
|
||||
/// Schreibweise, die .NET fuer Umgebungsvariablen erwartet.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="ResolveFields"/>.
|
||||
/// </summary>
|
||||
internal static class Installation
|
||||
{
|
||||
private const string SetupFileName = "setup.json";
|
||||
|
||||
public static async Task<int> RunAsync(AgentOptions options, HttpClient http)
|
||||
{
|
||||
AnsiConsole.Write(
|
||||
new FigletText("Installer")
|
||||
.LeftJustified()
|
||||
.Color(Color.DodgerBlue1));
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 1. Anmeldung
|
||||
// ----------------------------------------------------------
|
||||
string baseUrl = AnsiConsole.Prompt(
|
||||
new TextPrompt<string>("[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<string>("[bold]Benutzer[/]:"));
|
||||
string password = AnsiConsole.Prompt(new TextPrompt<string>("[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<CatalogEntry> 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<CatalogEntry>()
|
||||
.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<CatalogChannel>()
|
||||
.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<string>("[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nur die Einrichtung, ohne Dateien anzufassen. Nuetzlich, wenn die
|
||||
/// Installation stand, die Konfiguration aber nachgeholt oder
|
||||
/// korrigiert werden muss.
|
||||
/// </summary>
|
||||
public static async Task<int> 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<string>("[bold]Benutzer[/]:"));
|
||||
string password = AnsiConsole.Prompt(new TextPrompt<string>("[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<int> 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<SetupTarget> { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
private static async Task<List<(SetupField Field, string Value)>?> 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<string> { "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<string>()
|
||||
.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<string>($"[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<string, ValidationResult>? 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();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Werte, die sich aus dem System selbst ergeben.</summary>
|
||||
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<SetupTarget> 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<SetupTarget> all)
|
||||
{
|
||||
var resolved = FindTarget(field, all);
|
||||
return resolved != null && ReferenceEquals(resolved, target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SetupDefinition>(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"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int> DoUpdateOrRepair(
|
||||
internal static async Task<int> DoUpdateOrRepair(
|
||||
AgentOptions options,
|
||||
string targetVersion,
|
||||
PackageManifest? installedManifest)
|
||||
@@ -1055,7 +1061,8 @@ namespace Deploymentcenter.UpdateAgent
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" --project, -p <slug> Projekt-Slug");
|
||||
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
|
||||
Console.WriteLine(" --action, -a <aktion> interactive | check | update | repair | list");
|
||||
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | check |");
|
||||
Console.WriteLine(" update | repair | list");
|
||||
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
|
||||
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
|
||||
Console.WriteLine(" --platform <rid> Laufzeitkennung (Vorgabe: die des Systems)");
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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`<br>`/api/watchdog/v1/evaluate` | Token mit `watchdog:ping` |
|
||||
| **Lizenzen** | Lizenzprüfung, Hardware-ID v2, Offline-Cache | `/api/license/v1/validate`<br>`/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`<br>`/api/setup/v1/catalog`<br>`/api/setup/v1/token` | Login offen, Rest `setup:*` |
|
||||
| **System** | Verfügbarkeit, Schema-Status, Schnittstellenbeschreibung | `/api/health`<br>`/api/openapi.json` | Health optional, OpenAPI offen |
|
||||
|
||||
---
|
||||
|
||||
@@ -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/<slug> bzw. Programme\<slug>
|
||||
├ 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
|
||||
`<name>.unlesbar-<zeitstempel>` 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 |
|
||||
@@ -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."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Setup API - Erstinstallation von Anwendungen
|
||||
*
|
||||
* POST /api/setup/v1/login Benutzername + Passwort -> 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',
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
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, [
|
||||
|
||||
@@ -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:
|
||||
<th>Release Notes</th>
|
||||
<th>Download & SHA256</th>
|
||||
<th>Datum</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -2664,6 +3036,10 @@ Antwort:
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($r['created_at']) ?></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditReleaseModal(<?= htmlspecialchars(json_encode($r, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)) ?>)" title="Release bearbeiten">✏️ Bearbeiten</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="openDeleteReleaseModal(<?= (int)$r['id'] ?>, '<?= htmlspecialchars(addslashes($r['version'])) ?>', '<?= htmlspecialchars(addslashes($r['product_slug'])) ?>')" title="Release löschen">🗑️ Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -2671,6 +3047,110 @@ Antwort:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-update-installer" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<?php if ($installerManifest === null): ?>
|
||||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||||
<strong>Noch nichts hinterlegt.</strong> Die Binaries werden mit
|
||||
<code>scripts/build_installer.ps1</code> gebaut und mit
|
||||
<code>scripts/upload_installer.py</code> nach <code>/installer/</code>
|
||||
hochgeladen.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||
Version <strong><?= e((string)($installerManifest['version'] ?? '?')) ?></strong>
|
||||
· Build <code><?= e((string)($installerManifest['gitCommit'] ?? '?')) ?></code>
|
||||
· <?= e(substr((string)($installerManifest['buildDateUtc'] ?? ''), 0, 10)) ?>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plattform</th>
|
||||
<th>Größe</th>
|
||||
<th>SHA256</th>
|
||||
<th>Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($installerManifest['binaries'] as $bin): ?>
|
||||
<?php
|
||||
$binFile = (string)($bin['file'] ?? '');
|
||||
$binHash = (string)($bin['sha256'] ?? '');
|
||||
$binUrl = $baseUrl . '/installer/' . rawurlencode($binFile);
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?= e((string)($bin['platform'] ?? '?')) ?></code></td>
|
||||
<td style="font-family:'Roboto Mono',monospace; font-size:0.8rem;">
|
||||
<?= number_format(((int)($bin['sizeBytes'] ?? 0)) / (1024 * 1024), 1, ',', '.') ?> MB
|
||||
</td>
|
||||
<td>
|
||||
<code style="font-size:0.65rem; color:var(--text-muted); word-break:break-all;" title="<?= e($binHash) ?>">
|
||||
<?= e(substr($binHash, 0, 24)) ?>…
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<?= e($binUrl) ?>" class="btn btn-sm">⬇️ Herunterladen</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-top:1rem;">
|
||||
<strong>Prüfsumme vergleichen.</strong> „Programm herunterladen und
|
||||
Zugangsdaten eingeben“ ist genau das Muster, das Phishing nachahmt.
|
||||
Die vollständigen Hashes stehen im Tooltip und unter
|
||||
<a href="<?= e($baseUrl) ?>/installer/installer.json" target="_blank" rel="noopener" style="color:#bfdbfe;">installer.json</a>.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Direkt auf dem Zielsystem</h2></div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:0.75rem;">
|
||||
<strong>Linux</strong> — lädt das passende Binary, prüft die Prüfsumme und legt es ab:
|
||||
</p>
|
||||
<div class="prompt-box">wget -qO- <?= e($baseUrl) ?>/installer/install.sh | sh</div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||||
Oder ohne Skript, direkt das Binary:
|
||||
</p>
|
||||
<div class="prompt-box">wget <?= e($baseUrl) ?>/installer/update-agent-linux-x64 -O update-agent
|
||||
chmod +x update-agent
|
||||
./update-agent --action install</div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||||
<strong>Windows</strong> (PowerShell):
|
||||
</p>
|
||||
<div class="prompt-box">irm <?= e($baseUrl) ?>/installer/install.ps1 | iex</div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||||
Die Skripte richten <strong>nichts</strong> 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.
|
||||
</p>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
|
||||
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
|
||||
gebraucht — anzulegen unter <em>System → 👤 Benutzer</em>. 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-update-publish" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Neues Software-Release veröffentlichen</h2></div>
|
||||
@@ -2740,6 +3220,106 @@ Antwort:
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Release Modal -->
|
||||
<div class="modal-backdrop" id="editReleaseModal">
|
||||
<div class="modal-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||||
<h3 style="margin:0;">✏️ Release bearbeiten</h3>
|
||||
<button type="button" onclick="closeEditReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<form method="POST" action="index.php#sub-update-releases">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="edit_release">
|
||||
<input type="hidden" name="release_id" id="edit_release_id" value="">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Projekt-Slug</label>
|
||||
<select name="product_slug" id="edit_release_product_slug" class="form-input" required>
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Release Kanal</label>
|
||||
<select name="channel" id="edit_release_channel" class="form-input" required>
|
||||
<option value="prod">prod (Produktiv)</option>
|
||||
<option value="beta">beta (Vorab-Test)</option>
|
||||
<option value="dev">dev (Entwicklung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Plattform</label>
|
||||
<select name="platform" id="edit_release_platform" class="form-input">
|
||||
<option value="any">any (plattformunabhängig)</option>
|
||||
<option value="win-x64">win-x64</option>
|
||||
<option value="win-arm64">win-arm64</option>
|
||||
<option value="linux-x64">linux-x64</option>
|
||||
<option value="linux-arm64">linux-arm64</option>
|
||||
<option value="linux-musl-x64">linux-musl-x64</option>
|
||||
<option value="osx-x64">osx-x64</option>
|
||||
<option value="osx-arm64">osx-arm64</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Version (z. B. 1.2.0)</label>
|
||||
<input type="text" name="version" id="edit_release_version" class="form-input" required placeholder="1.2.0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Git Commit (Kurz-Hash)</label>
|
||||
<input type="text" name="git_commit" id="edit_release_git_commit" class="form-input" placeholder="a1b2c3d">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Download URL</label>
|
||||
<input type="url" name="download_url" id="edit_release_download_url" class="form-input" required placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SHA256 Hash (Optional)</label>
|
||||
<input type="text" name="sha256_hash" id="edit_release_sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Größe (Bytes)</label>
|
||||
<input type="number" name="size_bytes" id="edit_release_size_bytes" class="form-input" min="0" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem;">
|
||||
<label class="form-label">Release Notes / Changelog</label>
|
||||
<textarea name="release_notes" id="edit_release_release_notes" class="form-input" rows="3" placeholder="Changelog..."></textarea>
|
||||
</div>
|
||||
<div style="margin-bottom:1.25rem;">
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
|
||||
<input type="checkbox" name="is_critical" id="edit_release_is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEditReleaseModal()">Abbrechen</button>
|
||||
<button type="submit" class="btn">Änderungen Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Release Modal -->
|
||||
<div class="modal-backdrop" id="deleteReleaseModal">
|
||||
<div class="modal-card" style="max-width:480px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||||
<h3 style="margin:0; color:#ef4444;">🗑️ Release löschen</h3>
|
||||
<button type="button" onclick="closeDeleteReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<p>Möchten Sie das Release <strong id="delete_release_version_display"></strong> für <strong id="delete_release_product_display"></strong> wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<form method="POST" action="index.php#sub-update-releases" style="margin-top:1.5rem;">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="delete_release">
|
||||
<input type="hidden" name="release_id" id="delete_release_id" value="">
|
||||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDeleteReleaseModal()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-danger">Release unwiderruflich löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
|
||||
@@ -3853,6 +4433,229 @@ Antwort:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-system-rocketchat" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">💬 Rocket.Chat Benachrichtigungen & Statusberichte</h2>
|
||||
</div>
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<form method="POST">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="save_rocketchat_settings">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||||
<input type="checkbox" name="rocketchat_enabled" value="1" <?= !empty($rcConfig['enabled']) ? 'checked' : '' ?>>
|
||||
<strong>Rocket.Chat Benachrichtigungen aktivieren</strong>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rocket.Chat Server-URL</label>
|
||||
<input type="url" name="rocketchat_url" class="form-input" required value="<?= e($rcConfig['url']) ?>" placeholder="https://chat.wh1.mhdf.de">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<input type="text" name="rocketchat_username" class="form-input" required value="<?= e($rcConfig['username']) ?>" placeholder="deploymentcenter">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passwort</label>
|
||||
<input type="password" name="rocketchat_password" class="form-input" required value="<?= e($rcConfig['password']) ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">SSL-Zertifikat verifizieren</label>
|
||||
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer; font-weight:normal; margin-top:0.3rem;">
|
||||
<input type="checkbox" name="rocketchat_verify_ssl" value="1" <?= !empty($rcConfig['verify_ssl']) ? 'checked' : '' ?>>
|
||||
SSL-Zertifikat prüfen (bei selbstsignierten/unsicheren Zertifikaten deaktivieren)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status-Kanal (12h Bericht)</label>
|
||||
<input type="text" name="rocketchat_status_channel" class="form-input" required value="<?= e($rcConfig['status_channel']) ?>" placeholder="#DC-Systemstatus">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alarm-Kanal (Kritische Ausfälle & Bugs)</label>
|
||||
<input type="text" name="rocketchat_alert_channel" class="form-input" required value="<?= e($rcConfig['alert_channel']) ?>" placeholder="#DC-Alerts">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label class="form-label">Statusbericht Intervall (Sekunden)</label>
|
||||
<input type="number" name="rocketchat_report_interval" class="form-input" min="300" step="60" value="<?= e((string)$rcConfig['report_interval']) ?>">
|
||||
<span style="font-size:0.75rem; color:var(--text-muted);">Standard: 43200 Sekunden (12 Stunden)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem; display:flex; gap:1rem;">
|
||||
<button type="submit" class="btn btn-primary">💾 Einstellungen Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem;">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">🧪 Aktionen & Testlauf</h2>
|
||||
</div>
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
|
||||
Hier können Sie die Erreichbarkeit Ihres Rocket.Chat-Servers testen oder sofort einen 12-Stunden-Statusbericht auslösen.
|
||||
</p>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:1rem;">
|
||||
<form method="POST">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="test_rocketchat_connection">
|
||||
<button type="submit" class="btn btn-secondary">🧪 Verbindung & Kanäle Testen</button>
|
||||
</form>
|
||||
|
||||
<form method="POST">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="send_rocketchat_report_now">
|
||||
<button type="submit" class="btn btn-secondary">📊 12h Statusbericht Jetzt Senden</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem; background:rgba(0,0,0,0.2); padding:1rem; border-radius:8px; font-size:0.85rem; color:var(--text-muted);">
|
||||
<strong>📌 Status des automatischen 12h Cron-Jobs:</strong><br>
|
||||
• Letzter Bericht: <?= !empty($rcCronJob['last_run_utc']) ? e(dc_dt((string)$rcCronJob['last_run_utc'])) . ' (' . e(dc_ago((string)$rcCronJob['last_run_utc'])) . ')' : 'Noch nie gelaufen' ?><br>
|
||||
• Letzter Status: <span class="badge badge-<?= ($rcCronJob['last_status'] ?? '') === 'ok' ? 'up' : 'warning' ?>"><?= e((string)($rcCronJob['last_status'] ?? 'bereit')) ?></span><br>
|
||||
• Nächster geplanter Lauf: In ca. <?= !empty($rcCronJob['last_run_utc']) ? max(0, (int)round(($rcConfig['report_interval'] - (time() - strtotime((string)$rcCronJob['last_run_utc'] . ' UTC'))) / 3600)) . ' Std.' : 'sofort beim nächsten Tick' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-system-users" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
|
||||
|
||||
<?php if (!$usersSchemaReady): ?>
|
||||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||||
<strong>Migration 012 fehlt.</strong> Rollen, Deaktivierung und der Zeitpunkt
|
||||
der letzten Anmeldung stehen erst danach zur Verfügung. Bis dahin gelten alle
|
||||
Konten als Administratoren.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||||
Für die Erstinstallation von Anwendungen gibt es die Rolle
|
||||
<strong>installer</strong>. Solche Konten melden sich ausschließlich über
|
||||
<code>/api/setup/v1/login</code> an und bekommen dafür ein Token, das nach
|
||||
30 Minuten verfällt. <strong>Am WebUI können sie sich nicht anmelden.</strong>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzte Anmeldung</th>
|
||||
<th>Angelegt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<?php
|
||||
$uRole = (string)($u['role'] ?? 'admin');
|
||||
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
|
||||
$isSelf = (int)$u['id'] === Auth::userId();
|
||||
?>
|
||||
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : '' ?>>
|
||||
<td>
|
||||
<strong><?= e((string)$u['username']) ?></strong>
|
||||
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= $uRole === 'admin' ? 'badge-up' : 'badge-warning' ?>">
|
||||
<?= e(strtoupper($uRole)) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $uDisabled ? '<span class="badge badge-down">DEAKTIVIERT</span>' : 'aktiv' ?></td>
|
||||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||||
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
|
||||
</td>
|
||||
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
|
||||
<td>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
|
||||
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
|
||||
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="set_user_password">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<input type="password" name="password" class="form-input" required minlength="12"
|
||||
placeholder="Neues Passwort" style="width:170px; padding:0.3rem 0.5rem; font-size:0.8rem;">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Setzen</button>
|
||||
</form>
|
||||
|
||||
<?php if (!$isSelf): ?>
|
||||
<form method="POST" action="index.php" style="display:inline;">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="toggle_user_disabled">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">
|
||||
<?= $uDisabled ? 'Aktivieren' : 'Deaktivieren' ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="index.php" style="display:inline;"
|
||||
onsubmit="return confirm('Konto <?= e((string)$u['username']) ?> endgültig löschen?');">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Neues Konto anlegen</h2></div>
|
||||
<form method="POST" action="index.php">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="add_user">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<input type="text" name="username" class="form-input" required
|
||||
pattern="[A-Za-z0-9._\-]{3,64}" placeholder="installer">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-input">
|
||||
<option value="installer" selected>installer (nur Erstinstallation)</option>
|
||||
<option value="admin">admin (volle Verwaltung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passwort (mindestens 12 Zeichen)</label>
|
||||
<input type="password" name="password" class="form-input" required minlength="12">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">Konto anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-system-swagger" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">📖 API-Dokumentation</h2></div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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 <verzeichnis>
|
||||
|
||||
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()
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
+10
-1
@@ -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 (
|
||||
|
||||
+82
-9
@@ -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<string,mixed>|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();
|
||||
|
||||
@@ -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<string> $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'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Notify;
|
||||
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Rocket.Chat Notifier.
|
||||
*
|
||||
* Verwaltet die Authentifizierung am Rocket.Chat-Server und versendet:
|
||||
* 1. Regelmaessige System-Statusberichte (standardmaessig alle 12 Stunden)
|
||||
* 2. Sofortige Warnmeldungen bei Ausfaellen oder kritischen Bugs (eigener Alarm-Kanal)
|
||||
*/
|
||||
final class RocketChatNotifier
|
||||
{
|
||||
private const TIMEOUT_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* Ermittelt die aktuelle Rocket.Chat-Konfiguration (Dateivorlage + Datenbank-Overrides).
|
||||
*
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* url: string,
|
||||
* username: string,
|
||||
* password: string,
|
||||
* status_channel: string,
|
||||
* alert_channel: string,
|
||||
* verify_ssl: bool,
|
||||
* report_interval: int
|
||||
* }
|
||||
*/
|
||||
public static function getConfig(PDO $db): array
|
||||
{
|
||||
$fileConfig = (array)Config::get('rocketchat', []);
|
||||
|
||||
$dbSettings = [];
|
||||
try {
|
||||
$stmt = $db->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<string,mixed> $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<string,mixed> $overrideConfig
|
||||
* @param list<array<string,mixed>> $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<string,mixed> $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<string,mixed> $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<string> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Setup;
|
||||
|
||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||
use Deploymentcenter\Modules\UpdateService\Version;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Stellt zusammen, was auf einem bestimmten System installierbar ist.
|
||||
*
|
||||
* Der Installer fragt diese Liste ab, nachdem er sich angemeldet hat, und
|
||||
* zeigt sie zur Auswahl. Massgeblich ist dabei die Laufzeitkennung des
|
||||
* Zielsystems: ein Projekt, von dem es nur ein Windows-Paket gibt, taucht auf
|
||||
* einem Linux-Rechner gar nicht erst auf. Alles andere waere eine Auswahl,
|
||||
* die beim Anklicken fehlschlaegt.
|
||||
*/
|
||||
final class SetupCatalog
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user