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
+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()