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:
Deploymentcenter Bot
2026-08-13 10:33:26 +02:00
co-authored by Claude Opus 5
parent 2388b5abe1
commit c8f3e78635
34 changed files with 4704 additions and 21 deletions
+3
View File
@@ -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/
+1
View File
@@ -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
View File
@@ -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)");
+10
View File
@@ -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),
],
];
+2
View File
@@ -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 |
---
+285
View File
@@ -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 |
+44
View File
@@ -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."
}
```
+72
View File
@@ -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
}
]
}
+148
View File
@@ -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'],
+253
View File
@@ -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',
};
}
+125 -2
View File
@@ -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',
};
+10 -2
View File
@@ -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, [
+855
View File
@@ -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;">&times;</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;">&times;</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 &amp; 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 &amp; 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 &amp; 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 &amp; 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;
+121
View File
@@ -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
}
+72
View File
@@ -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
}
+109
View File
@@ -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."
+125
View File
@@ -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);
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+85
View File
@@ -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'],
];
/**
+506
View File
@@ -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,
];
}
}
+28
View File
@@ -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;
}
+147
View File
@@ -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;
}
}
+89 -1
View File
@@ -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;
}
/**
+8
View File
@@ -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));