feat(installer): Laufzeitpruefung, Host-Ueberwachung, Zugang fuer Adminkonten

Drei Dinge, die beim ersten Lauf des Installers auf einer Linux-Maschine
auffielen.

1. Die Release-Ablage wies Administratorkonten ab. ReleaseGuard nahm nur
   die Rolle 'installer' in die .htpasswd auf, waehrend Installskripte und
   Agent ausdruecklich sagten, ein Administratorkonto tue es auch:
   Anmeldung und Katalog gelangen, erst der Download endete mit 401 - und
   die Meldung sprach von abgelaufenen Lizenzen, die es bei einer
   Erstinstallation gar nicht geben kann. Adminkonten zaehlen jetzt zu den
   Installationskonten. FORMAT_VERSION auf 3, damit reconcile() die
   Dateien sofort neu schreibt statt erst beim naechsten turnusmaessigen
   Lauf; ein neu angelegtes Konto landet ausserdem unabhaengig von seiner
   Rolle sofort darin. Bei einem 401 mit Benutzerzugangsdaten nennt der
   Client jetzt Konto und zugangsberechtigte Rollen, und der Agent bricht
   ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar
   Schritte spaeter ein zweites Mal zu zeigen.

2. Der Installer prueft die .NET-Laufzeit. Bisher endete eine gelungene
   Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich
   nicht starten laesst - und die Fehlersuche begann beim
   Deploymentcenter, weil das der letzte bewusste Schritt war. Gelesen
   wird die runtimeconfig.json der Anwendung und mit "dotnet
   --list-runtimes" verglichen; fehlt etwas, nennt der Installer den
   Installationsbefehl fuer diese Plattform. Eigenstaendig
   veroeffentlichte Pakete werden nicht bemaengelt, rollForward wird
   beachtet.

3. Die Ueberwachung der Maschine entsteht im Installer. Zwei Fragen -
   Name im Dashboard und ob eingeplant werden soll - statt fuenf Schritten
   in der Oberflaeche an einem anderen Rechner. Monitor, Token mit genau
   watchdog:ping, Skript, Dateirechte, ein Heartbeat zur Probe und der
   Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen
   ohne Installation: --action monitor.

Die Agent-Skripte werden jetzt in src/Modules/Watchdog/AgentScript.php
erzeugt - von Oberflaeche und Installer gemeinsam - und melden Last,
Speicher, Plattenbelegung und Laufzeit mit, statt nur "status: ok". Beim
Ausfuehren fielen zwei Fehler auf, die dort behoben sind: df -P verrutscht
bei Geraetenamen mit Leerzeichen (gezaehlt wird jetzt von hinten), und
ohne LC_ALL=C erzeugt awk auf einem deutschen System "12,5" und damit
kaputtes JSON.

Neu: POST /api/setup/v1/agent. SDK 2.6.0 mit
SetupClient.RequestWatchdogAgentAsync().

Die OpenAPI-Beschreibung des neuen Endpunkts bleibt zunaechst aussen vor:
public/api/openapi.php traegt gerade auch fremde, noch nicht committete
Aenderungen aus einer parallel laufenden Arbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-21 20:51:37 +02:00
co-authored by Claude Opus 5
parent fc9b698141
commit 687ee0cefc
15 changed files with 1836 additions and 84 deletions
+19 -9
View File
@@ -26,9 +26,9 @@ use PDO;
* ueber product_id an genau ein Projekt gebunden ist, faellt die Trennung beim
* Erzeugen von selbst ab.
*
* Zusaetzlich stehen die Installationskonten in jeder Datei: Bei einer
* Erstinstallation gibt es noch keinen Lizenzschluessel, mit dem sich das
* Paket holen liesse.
* Zusaetzlich stehen die Installationskonten in jeder Datei - die der Rolle
* 'installer' wie die der Rolle 'admin': Bei einer Erstinstallation gibt es
* noch keinen Lizenzschluessel, mit dem sich das Paket holen liesse.
*/
final class ReleaseGuard
{
@@ -234,6 +234,14 @@ final class ReleaseGuard
// ist die Anwendung noch nicht eingerichtet und hat keinen
// Lizenzschluessel, mit dem sie das Paket holen koennte.
//
// Administratoren zaehlen dazu. Zuvor stand hier nur die Rolle
// 'installer' - waehrend die Installationsskripte und der Agent
// ausdruecklich sagten, ein Administratorkonto tue es auch. Es tat es
// nicht: der Login gelang, der Katalog kam, und erst der Download
// endete mit 401 und einer Meldung ueber abgelaufene Lizenzen. Ein
// Administrator darf ohnehin alles, was hier zu holen waere - die
// Zeile in der .htpasswd eroeffnet ihm nichts Neues.
//
// Der in dc_users abgelegte Hash wird unveraendert uebernommen - PHPs
// password_hash() erzeugt bcrypt im Format $2y$, genau das versteht
// Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht.
@@ -241,7 +249,7 @@ final class ReleaseGuard
$stmt = $db->query("
SELECT username, password_hash
FROM dc_users
WHERE role = 'installer' AND disabled = 0
WHERE role IN ('installer', 'admin') AND disabled = 0
");
foreach ($stmt->fetchAll() ?: [] as $row) {
@@ -408,19 +416,21 @@ final class ReleaseGuard
}
/**
* Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien
* neu geschrieben werden, sonst passen Server und Client nicht mehr
* zusammen.
* Aufbau der erzeugten .htpasswd. Aendert sich hier etwas, muessen alle
* Dateien neu geschrieben werden, sonst passen Server und Client nicht
* mehr zusammen.
*
* 1 - Benutzername = Lizenzschluessel im Klartext
* 2 - Benutzername = lic_<sha256(schluessel), 16 Hexzeichen>
* 3 - Administratorkonten zaehlen zu den Installationskonten
*
* Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben:
* reconcile() sah keinen Anlass, und die Dateien behielten bis zum
* naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients
* bereits das neue schicken.
* bereits das neue schicken. Fuer 3 gilt dasselbe: die Administratoren
* kaemen sonst bis zu sechs Stunden lang weiter nicht an ihr Paket.
*/
private const FORMAT_VERSION = 2;
private const FORMAT_VERSION = 3;
/**
* Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
+356
View File
@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog;
/**
* Erzeugt die Agent-Skripte, die auf einer Host-Maschine minuetlich laufen
* und einen Heartbeat samt Kennzahlen schicken.
*
* Warum hier und nicht dort, wo sie gebraucht werden: Die Skripte werden an
* zwei Stellen ausgeliefert - als Download aus der Oberflaeche und ueber den
* Setup-Weg an den Installer. Zwei Fassungen desselben Skripts laufen
* unweigerlich auseinander; auffallen wuerde das erst, wenn ein Host andere
* Werte liefert als der daneben.
*
* Die Erstfassung (inline in index.php) schickte nur "status: ok" ohne eine
* einzige Kennzahl. Damit beantwortete der Monitor genau eine Frage - laeuft
* die Kiste noch - und keine der Fragen, wegen derer man auf einen Host
* schaut: Ist die Platte voll? Frisst etwas den Speicher? Die Skripte hier
* melden Last, Speicher, Belegung und Laufzeit mit; der MetricStore nimmt
* jede numerische Angabe ohne weiteres Zutun auf.
*
* Bewusst ohne Fremdwerkzeuge: bash, awk und curl bzw. Windows PowerShell 5.1
* sind ueberall da. Ein Agent, der erst installiert werden muss, wird nicht
* installiert.
*/
final class AgentScript
{
/** Ab dieser Belegung meldet der Host "warning" statt "ok". */
private const DISK_WARN_PERCENT = 90;
/** Dasselbe fuer den Arbeitsspeicher. */
private const MEM_WARN_PERCENT = 95;
/**
* Erlaubte Zeichen in einem Monitor-Namen.
*
* Der Name landet in einem Shell- bzw. PowerShell-Literal. Alles, was
* dort ausbrechen koennte - Anfuehrungszeichen, Dollar, Backticks,
* Zeilenumbrueche - hat darin nichts zu suchen.
*/
public static function sanitizeSource(string $source): string
{
$clean = trim(preg_replace('/[^A-Za-z0-9._ -]/', '', trim($source)) ?? '');
return $clean !== '' ? mb_substr($clean, 0, 100) : 'host';
}
/** Dasselbe fuer ein Token - die Zeichen, die dc_tokens vergibt. */
public static function sanitizeToken(string $token): string
{
return preg_replace('/[^A-Za-z0-9._-]/', '', trim($token)) ?? '';
}
/** Normalisiert die Betriebssystemangabe auf "windows" oder "linux". */
public static function normalizeOs(?string $os): string
{
$value = strtolower(trim((string)$os));
return in_array($value, ['windows', 'win', 'win32', 'win-x64', 'win-arm64'], true)
? 'windows'
: 'linux';
}
/**
* Baut das Skript.
*
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
* schedule ist der Befehl, mit dem sich das Skript einplanen
* laesst - der Installer setzt ihn ein, die Oberflaeche zeigt ihn
* an. {PFAD} steht darin fuer den Ablageort, den erst der kennt,
* der die Datei hinlegt.
*/
public static function build(
string $os,
string $source,
string $token,
string $endpoint,
int $intervalSec = 60
): array {
$os = self::normalizeOs($os);
$source = self::sanitizeSource($source);
$token = self::sanitizeToken($token);
$interval = max(30, min($intervalSec, 3600));
// Der Endpunkt stammt aus der eigenen Konfiguration, nicht aus einer
// Anfrage - trotzdem nicht ungeprueft in ein Skript schreiben.
$endpoint = filter_var($endpoint, FILTER_VALIDATE_URL) !== false ? $endpoint : '';
return $os === 'windows'
? self::buildWindows($source, $token, $endpoint, $interval)
: self::buildLinux($source, $token, $endpoint, $interval);
}
/**
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
*/
private static function buildLinux(string $source, string $token, string $endpoint, int $interval): array
{
$diskWarn = self::DISK_WARN_PERCENT;
$memWarn = self::MEM_WARN_PERCENT;
$filename = 'watchdog-agent-' . self::fileSlug($source) . '.sh';
// Die Werte stammen aus /proc und df - kein Paket, das erst
// nachinstalliert werden muesste. awk rechnet, damit auch ein System
// ohne bc auskommt.
$script = <<<BASH
#!/usr/bin/env bash
#
# Deploymentcenter Watchdog Agent
#
# Meldet Erreichbarkeit und Kennzahlen dieser Maschine. Erzeugt vom
# Deploymentcenter fuer den Monitor "{$source}" - nicht von Hand
# aendern, ein neu erzeugtes Skript ueberschreibt diese Datei.
#
# Einplanen (minuetlich):
# crontab -e
# * * * * * /pfad/zu/{$filename} >/dev/null 2>&1
#
# Die Datei enthaelt ein Token. Rechte 0600 belassen.
set -uo pipefail
# Ohne feste Locale gibt awk auf einem deutschen System "12,5" statt
# "12.5" aus - das erzeugte JSON waere kaputt, und der Server lehnte
# jeden Heartbeat mit einem Parserfehler ab.
export LC_ALL=C
WATCHDOG_URL="{$endpoint}"
TOKEN="{$token}"
SOURCE="{$source}"
INTERVAL={$interval}
# Ab diesen Werten wird "warning" gemeldet statt "ok".
DISK_WARN={$diskWarn}
MEM_WARN={$memWarn}
# --- Kennzahlen ---------------------------------------------------
# Last als Anteil der verfuegbaren Kerne: 1.0 bedeutet auf einer
# Maschine mit vier Kernen etwas anderes als auf einer mit einem.
cores=\$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
load1=\$(awk '{print \$1}' /proc/loadavg 2>/dev/null || echo 0)
load_percent=\$(awk -v l="\$load1" -v c="\$cores" 'BEGIN { if (c <= 0) c = 1; printf "%.1f", (l / c) * 100 }')
mem_total=\$(awk '/^MemTotal:/ {print \$2}' /proc/meminfo 2>/dev/null || echo 0)
# MemAvailable gibt es erst ab Kernel 3.14. Fehlt es, ist MemFree die
# naechstbeste Auskunft - ohne diesen Rueckfall stuende dort 0, und
# der Host meldete auf ewig 100 Prozent Speicherbelegung.
mem_avail=\$(awk '/^MemAvailable:/ {print \$2}' /proc/meminfo 2>/dev/null)
if [ -z "\${mem_avail:-}" ]; then
mem_avail=\$(awk '/^MemFree:/ {print \$2}' /proc/meminfo 2>/dev/null || echo 0)
fi
mem_percent=\$(awk -v t="\$mem_total" -v a="\${mem_avail:-0}" 'BEGIN { if (t <= 0) { print 0 } else { printf "%.1f", ((t - a) / t) * 100 } }')
mem_total_mb=\$(awk -v t="\$mem_total" 'BEGIN { printf "%.0f", t / 1024 }')
# Von hinten gezaehlt, nicht von vorn: -P erzwingt zwar eine Zeile je
# Dateisystem, aber ein Geraetename mit Leerzeichen verschiebt trotzdem
# jede Spalte. Belegung und freier Platz stehen verlaesslich an
# vorletzter und drittletzter Stelle, weil der Mountpunkt hier "/" ist.
disk_percent=\$(df -P / 2>/dev/null | awk 'NR==2 {gsub("%","",\$(NF-1)); print \$(NF-1)}')
disk_free_gb=\$(df -P / 2>/dev/null | awk 'NR==2 {printf "%.1f", \$(NF-2) / 1048576}')
uptime_sec=\$(awk '{printf "%.0f", \$1}' /proc/uptime 2>/dev/null || echo 0)
: "\${disk_percent:=0}"
: "\${disk_free_gb:=0}"
# Sicherheitsnetz gegen ein df, das anders zaehlt als erwartet: ein
# Prozentwert jenseits von 0 bis 100 ist keine Belegung, sondern ein
# verrutschtes Feld - und wuerde als Dauerwarnung durchschlagen.
case "\$disk_percent" in
''|*[!0-9]*) disk_percent=0 ;;
*) [ "\$disk_percent" -gt 100 ] && disk_percent=0 ;;
esac
# --- Bewertung ----------------------------------------------------
status="ok"
message="Heartbeat"
if awk -v v="\$disk_percent" -v w="\$DISK_WARN" 'BEGIN { exit !(v >= w) }'; then
status="warning"
message="Platte zu \${disk_percent}% belegt"
elif awk -v v="\$mem_percent" -v w="\$MEM_WARN" 'BEGIN { exit !(v >= w) }'; then
status="warning"
message="Arbeitsspeicher zu \${mem_percent}% belegt"
fi
payload=\$(cat <<JSON
{
"source": "\$SOURCE",
"type": "host",
"os": "linux",
"status": "\$status",
"message": "\$message",
"interval": \$INTERVAL,
"metrics": {
"cpu_load_percent": \$load_percent,
"load_1min": \$load1,
"cpu_cores": \$cores,
"memory_used_percent": \$mem_percent,
"memory_total_mb": \$mem_total_mb,
"disk_used_percent": \$disk_percent,
"disk_free_gb": \$disk_free_gb,
"uptime_sec": \$uptime_sec
}
}
JSON
)
# --fail laesst curl bei 4xx/5xx einen Fehlercode liefern; ohne das
# galte auch eine abgelehnte Anmeldung als erfolgreich gesendet.
if curl -fsS --max-time 20 -X POST "\$WATCHDOG_URL" \\
-H "Content-Type: application/json" \\
-H "X-Agent-Token: \$TOKEN" \\
-d "\$payload" > /dev/null; then
echo "[OK] Heartbeat gesendet (\$status)."
else
echo "[FEHLER] Heartbeat nicht angenommen." >&2
exit 1
fi
BASH;
return [
'filename' => $filename,
'script' => $script,
'os' => 'linux',
'interval' => $interval,
'schedule' => '* * * * * {PFAD} >/dev/null 2>&1',
];
}
/**
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
*/
private static function buildWindows(string $source, string $token, string $endpoint, int $interval): array
{
$diskWarn = self::DISK_WARN_PERCENT;
$memWarn = self::MEM_WARN_PERCENT;
$filename = 'watchdog-agent-' . self::fileSlug($source) . '.ps1';
// Get-CimInstance statt Get-WmiObject: letzteres gibt es in
// PowerShell 7 nicht mehr, und dort landet man inzwischen schneller,
// als einem lieb ist.
$script = <<<POWERSHELL
# Deploymentcenter Watchdog Agent
#
# Meldet Erreichbarkeit und Kennzahlen dieser Maschine. Erzeugt vom
# Deploymentcenter fuer den Monitor "{$source}" - nicht von Hand
# aendern, ein neu erzeugtes Skript ueberschreibt diese Datei.
#
# Einplanen (minuetlich):
# schtasks /Create /SC MINUTE /MO 1 /TN "Deploymentcenter Watchdog {$source}"
# /TR "powershell -NoProfile -ExecutionPolicy Bypass -File <Pfad>\\{$filename}"
#
# Die Datei enthaelt ein Token.
\$ErrorActionPreference = "Stop"
\$WatchdogUrl = "{$endpoint}"
\$Token = "{$token}"
\$Source = "{$source}"
\$Interval = {$interval}
# Ab diesen Werten wird "warning" gemeldet statt "ok".
\$DiskWarn = {$diskWarn}
\$MemWarn = {$memWarn}
function Get-HostMetrics {
\$os = Get-CimInstance Win32_OperatingSystem
\$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
\$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='\$(\$env:SystemDrive)'"
\$memTotalMb = [math]::Round(\$os.TotalVisibleMemorySize / 1KB, 0)
\$memUsedPct = if (\$os.TotalVisibleMemorySize -gt 0) {
[math]::Round(((\$os.TotalVisibleMemorySize - \$os.FreePhysicalMemory) / \$os.TotalVisibleMemorySize) * 100, 1)
} else { 0 }
\$diskUsedPct = if (\$disk -and \$disk.Size -gt 0) {
[math]::Round(((\$disk.Size - \$disk.FreeSpace) / \$disk.Size) * 100, 1)
} else { 0 }
\$diskFreeGb = if (\$disk) { [math]::Round(\$disk.FreeSpace / 1GB, 1) } else { 0 }
# LastBootUpTime kommt als DateTime - die Differenz ist die Laufzeit.
\$uptimeSec = [math]::Round(((Get-Date) - \$os.LastBootUpTime).TotalSeconds, 0)
return [ordered]@{
cpu_load_percent = \$(if (\$null -ne \$cpu) { [double]\$cpu } else { 0 })
cpu_cores = [int]\$env:NUMBER_OF_PROCESSORS
memory_used_percent = \$memUsedPct
memory_total_mb = \$memTotalMb
disk_used_percent = \$diskUsedPct
disk_free_gb = \$diskFreeGb
uptime_sec = \$uptimeSec
}
}
try {
\$metrics = Get-HostMetrics
\$status = "ok"
\$message = "Heartbeat"
if (\$metrics.disk_used_percent -ge \$DiskWarn) {
\$status = "warning"
\$message = "Platte zu \$(\$metrics.disk_used_percent)% belegt"
} elseif (\$metrics.memory_used_percent -ge \$MemWarn) {
\$status = "warning"
\$message = "Arbeitsspeicher zu \$(\$metrics.memory_used_percent)% belegt"
}
\$body = [ordered]@{
source = \$Source
type = "host"
os = "windows"
status = \$status
message = \$message
interval = \$Interval
metrics = \$metrics
} | ConvertTo-Json -Depth 4 -Compress
# Ohne diese Zeile scheitert der Aufruf auf Maschinen, die noch
# mit TLS 1.0 als Vorgabe laufen - der Fehler sieht dort aus wie
# ein Netzwerkproblem.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-RestMethod -Uri \$WatchdogUrl -Method Post -Body \$body -ContentType "application/json" -Headers @{ "X-Agent-Token" = \$Token } | Out-Null
Write-Host "[OK] Heartbeat gesendet (\$status)."
} catch {
Write-Error "[FEHLER] Heartbeat nicht angenommen: \$_"
exit 1
}
POWERSHELL;
return [
'filename' => $filename,
'script' => $script,
'os' => 'windows',
'interval' => $interval,
'schedule' => 'schtasks /Create /SC MINUTE /MO 1 /TN "Deploymentcenter Watchdog ' . $source . '" '
. '/TR "powershell -NoProfile -ExecutionPolicy Bypass -File {PFAD}" /F',
];
}
/** Dateinamensteil aus dem Monitor-Namen: nur Unverfaengliches. */
private static function fileSlug(string $source): string
{
$slug = trim(strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $source) ?? ''), '-');
return $slug !== '' ? $slug : 'host';
}
}