Server-Bibliothek
2026-07-23 d39052c1ea7710585e0e24a2fd1125c2eb54caf8
Installer-Dateien direkt ins Einrichtungs-Paket buendeln

Statt die Installer-Dateien zur Laufzeit vom Server nachzuladen (setzte
Netzwerkzugriff des Zielrechners auf den Server voraus), werden sie jetzt
direkt beim Erzeugen des Pakets eingebettet. Der Zielrechner braucht fuer
die eigentliche Installation kein Netzwerk mehr, nur die abschliessende
optionale Protokoll-Meldung. Dokumentation (ARCHITEKTUR.md, CHANGELOG.md,
README.md) entsprechend aktualisiert.
5 Dateien geändert
214 ■■■■ Geänderte Dateien
ARCHITEKTUR.md 43 ●●●● Patch | Ansicht | Raw | Blame | Historie
CHANGELOG.md 20 ●●●●● Patch | Ansicht | Raw | Blame | Historie
README.md 29 ●●●●● Patch | Ansicht | Raw | Blame | Historie
app.py 98 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/einrichten.html 24 ●●●● Patch | Ansicht | Raw | Blame | Historie
ARCHITEKTUR.md
@@ -146,32 +146,53 @@
## 6. "Rechner einrichten" – Verteilungs-Mechanismus
Route `/einrichten` (app.py) erzeugt bei Bedarf ein individuelles
Installations-Paket (ZIP mit `einrichten.ps1` + `Rechner-einrichten-starten.bat`),
Installations-Paket (ZIP mit `einrichten.ps1` + `Rechner-einrichten-starten.bat`
**+ den echten Installer-Dateien/ZIPs selbst** unter `tools/<id>.<endung>`),
statt vorab für jede Mitarbeiter/Tool-Kombination ein festes Skript zu pflegen.
**Wichtige Design-Entscheidung (Runde r16):** Die Installer-Dateien werden
bereits beim Erzeugen des Pakets (auf dem PC, der die Checkliste ausfüllt)
eingebettet, nicht erst beim Ausführen auf dem Zielrechner nachgeladen. Ein
früherer Ansatz lud die Dateien per `Invoke-WebRequest` zur Laufzeit vom
Server nach – das erforderte Netzwerkzugriff des Zielrechners auf den Server
in genau dem Moment der Einrichtung, was in der Praxis nicht immer gegeben
ist (z. B. Rechner noch nicht ans Firmennetz angebunden). Jetzt braucht der
Zielrechner für die eigentliche Installation **kein Netzwerk mehr** – nur die
abschließende, optionale Meldung ans zentrale Protokoll (`/zuweisungen/neu`)
versucht noch einen Netzwerkzugriff, scheitert dieser, bleibt trotzdem das
lokale Protokoll (`protokoll.log`) vollständig.
Ablauf beim Ausführen auf dem Zielrechner:
1. `.bat` kopiert sich selbst + das `.ps1` nach `C:\ClientBibliothek`
1. `.bat` kopiert den **kompletten entpackten Ordner** (inkl. `tools/`-
   Unterordner mit den echten Installer-Dateien) nach `C:\ClientBibliothek`
   (per `xcopy /E /I /Y`)
2. Self-Elevation (Admin-Rechte-Anfrage) über `Start-Process -Verb RunAs`
3. Automatische Windows-Defender-Ausnahme für `C:\ClientBibliothek`
   (`Add-MpPreference -ExclusionPath`) – ein einziger Pfad statt vieler
   Einzelausnahmen
4. Pro ausgewähltem Tool: entweder stille Installation (Download + Ausführung
   mit hinterlegtem Silent-Parameter, `.msi` läuft über `msiexec`) oder – bei
   Tools ohne einzelne Installer-Datei ("Ordner-Typ", z. B. portable
   Programme wie Blender) – Download als ZIP nach `C:\ClientBibliothek\pakete\`,
   **bewusst ohne automatisches Entpacken** (Entscheidung in Runde r14: der
   Zielort für portable Tools variiert, das soll eine Person bewusst
4. Pro ausgewähltem Tool: entweder stille Installation aus der **lokal
   mitgelieferten** Datei unter `tools/<id>.<endung>` (mit hinterlegtem
   Silent-Parameter, `.msi` läuft über `msiexec`) oder – bei Tools ohne
   einzelne Installer-Datei ("Ordner-Typ", z. B. portable Programme wie
   Blender) – die mitgelieferte ZIP-Datei wird nach `C:\ClientBibliothek\pakete\`
   kopiert, **bewusst ohne automatisches Entpacken** (Entscheidung in Runde
   r14: der Zielort für portable Tools variiert, das soll eine Person bewusst
   entscheiden)
5. Lokales Protokoll unter `C:\ClientBibliothek\protokoll.log`
6. Automatischer POST an `/zuweisungen/neu` auf dem Server, um das zentrale
6. Optionaler POST an `/zuweisungen/neu` auf dem Server, um das zentrale
   Protokoll zu befüllen (Rechnername wird dabei über `$env:COMPUTERNAME`
   automatisch erkannt)
   automatisch erkannt) – best effort, kein Abbruch bei Fehlschlag
Die Ordner-Struktur unter `C:\ClientBibliothek`:
- `\downloads` – transiente Installer-Dateien, werden nach Gebrauch gelöscht
- `\tools` – die mitgelieferten Installer-Dateien/ZIPs (Quelle für die
  Installation, kommen direkt aus dem heruntergeladenen Paket)
- `\pakete` – ZIPs von portablen Tools, bleiben bewusst liegen
- `\protokoll.log` – lokale Mitschrift
Serverseitig teilen sich `/einrichten` (POST) und `/software/<id>/download`
die Hilfsfunktion `_zippe_ordner_in_bytes()` für das ZIP-Packen eines
Ordners, um Code-Duplikation zu vermeiden.
**Der Client-Ordner-Pfad ist eine Konstante** (`CLIENT_ORDNER` in `app.py`,
aktuell `C:\ClientBibliothek`) – zentral anpassbar, falls die Infrastruktur
einen anderen Standardpfad vorgibt.
CHANGELOG.md
@@ -93,6 +93,26 @@
  automatisch selbst eingetragen (`Add-MpPreference`)
- Firewall-Freigabe-Skript ergänzt, nachdem der Test im echten
  Firmennetzwerk anstand (Port 5000 war durch die Windows-Firewall blockiert)
- **Installer-Dateien werden direkt ins Paket gebündelt statt zur Laufzeit
  nachzuladen.** Auslöser: beim ersten Test im Firmennetz enthielt der
  Download nur die Skript-Dateien, die eigentlichen Installer wurden erst
  beim Ausführen auf dem Zielrechner per Netzwerk nachgeladen – das setzt
  Netzwerkzugriff des Zielrechners auf den Server in genau dem Moment
  voraus. Jetzt werden die Dateien beim Erzeugen des Pakets direkt mit
  eingepackt, der Zielrechner braucht für die Installation selbst kein
  Netzwerk mehr (nur die abschließende, optionale Protokoll-Meldung).
## Offene Design-Frage: Skript-Ansatz vs. automatische Verteilung
Nach dem ersten Test im Firmennetz wurde nochmal grundsätzlich hinterfragt,
ob der Skript-Ansatz (manueller Download + Ausführen auf dem Zielrechner)
der richtige Weg ist, oder ob Daten "automatisch" auf dem Zielrechner landen
sollten. Eine echte automatische Verteilung ohne manuellen Schritt am
Zielrechner würde entweder eine dauerhaft laufende Helfer-Software auf jedem
Zielrechner oder Windows-Remoting (PowerShell Remoting) voraussetzen – beide
Wege brauchen im Kern eine Domänen-Infrastruktur bzw. zentrale
Zugangsdaten-Verwaltung, die aktuell nicht vorhanden ist. Diese Frage wurde
bewusst nicht in diesem Prototyp entschieden, sondern als offener Punkt für
die Übernahme festgehalten (siehe README.md).
## Testmethodik (durchgehend)
Da die Zielumgebung Windows ist, die Entwicklung aber in einer Linux-Umgebung
README.md
@@ -75,6 +75,35 @@
---
## 3a. Rechner einrichten (Verteilung an neue Rechner)
Menüpunkt "Rechner einrichten": Häkchen bei den benötigten Tools setzen,
Mitarbeiter eintragen, "Installations-Paket herunterladen" klicken. Das
erzeugte ZIP enthält die ausgewählten Installer-Dateien **direkt mit** (kein
Nachladen vom Server nötig).
Auf dem **neuen Rechner**: ZIP entpacken, `Rechner-einrichten-starten.bat`
doppelklicken. Das Skript:
- fragt bei Bedarf automatisch nach Admin-Rechten
- installiert jedes Tool still mit dem hinterlegten Silent-Parameter
- trägt automatisch eine Windows-Defender-Ausnahme für `C:\ClientBibliothek`
  ein (dort landen alle Dateien gesammelt)
- meldet das Ergebnis am Ende automatisch ans zentrale Protokoll zurück
  (Rechnername wird dabei selbst erkannt)
Tools ohne einzelne Installer-Datei (z. B. bereits entpackte/portable Tools
wie Blender) werden als ZIP nach `C:\ClientBibliothek\pakete\` gelegt –
bewusst ohne automatisches Entpacken, das entscheidet ihr von Fall zu Fall
selbst.
⚠️ Die Windows-spezifischen Teile (Admin-Rechte-Anfrage, stille Installation)
wurden nicht auf einem echten Windows-Rechner entwickelt und getestet, nur
mit dem echten PowerShell-Parser auf Syntaxfehler geprüft sowie die
Netzwerk-/Datei-Logik separat verifiziert. Bei größerem Rollout zuerst an
1–2 unkritischen Rechnern gegentesten.
---
## 4. Automatische Aktualisierung
Bei jedem Tool kann beim Anlegen/Bearbeiten "Automatische Aktualisierung
app.py
@@ -22,7 +22,7 @@
# jederzeit im Fuss der Seitenleiste sichtbar ist, ob wirklich die neueste
# Version laeuft (nicht nur auf der Festplatte liegt, sondern auch vom
# gestarteten Programm geladen wurde).
APP_VERSION = "2026-07-22 · r15 (Firewall-Freigabe-Skript ergänzt)"
APP_VERSION = "2026-07-23 · r16 (Installer-Dateien direkt im Paket, kein Netzwerk zur Laufzeit nötig)"
@app.context_processor
@@ -189,8 +189,7 @@
    "@echo off\r\n"
    "set ZIEL=C:\\ClientBibliothek\r\n"
    'if not exist "%ZIEL%" mkdir "%ZIEL%"\r\n'
    'copy /Y "%~dp0einrichten.ps1" "%ZIEL%\\einrichten.ps1" >nul\r\n'
    'copy /Y "%~f0" "%ZIEL%\\Rechner-einrichten-starten.bat" >nul\r\n'
    'xcopy "%~dp0*" "%ZIEL%\\" /E /I /Y >nul\r\n'
    "echo ============================================================\r\n"
    "echo  Rechner-Einrichtung wird gestartet...\r\n"
    "echo  Admin-Rechte werden bei Bedarf per Windows-Abfrage angefragt.\r\n"
@@ -203,10 +202,12 @@
def _erstelle_einrichtungs_skript(server_url, mitarbeiter, tools):
    """Baut den Inhalt der einrichten.ps1 zusammen: laedt jedes ausgewaehlte
    Tool ueber die bestehende Download-Route herunter, installiert es mit
    dem hinterlegten Silent-Parameter und meldet das Ergebnis am Ende als
    Protokoll an die Server-Bibliothek zurueck."""
    """Baut den Inhalt der einrichten.ps1 zusammen: installiert jedes
    ausgewaehlte Tool aus der MITGELIEFERTEN Datei (liegt im 'tools'-Unterordner
    desselben Pakets, kein Netzwerkzugriff auf den Server noetig) und meldet
    das Ergebnis am Ende BEST-EFFORT als Protokoll an die Server-Bibliothek
    zurueck (falls Netzwerkzugriff vorhanden ist - schlaegt das fehl, bleibt
    trotzdem das lokale Protokoll erhalten)."""
    zeilen = [
        '$ErrorActionPreference = "Stop"',
        f"$server = '{_ps_escape(server_url)}'",
@@ -218,10 +219,9 @@
        "    exit",
        "}",
        "",
        "$tempOrdner = Join-Path $clientOrdner \"downloads\"",
        "$toolsOrdner = Join-Path $clientOrdner \"tools\"",
        "$paketeOrdner = Join-Path $clientOrdner \"pakete\"",
        "$protokollDatei = Join-Path $clientOrdner \"protokoll.log\"",
        "New-Item -ItemType Directory -Force -Path $tempOrdner | Out-Null",
        "New-Item -ItemType Directory -Force -Path $paketeOrdner | Out-Null",
        "",
        "try {",
@@ -246,41 +246,38 @@
        "",
        "function InstalliereTool($id, $name, $endung, $parameter) {",
        '    Write-Host "Installiere: $name ..." -ForegroundColor Cyan',
        '    $zielDatei = Join-Path $tempOrdner ("sb_" + $id + $endung)',
        "    try {",
        '        Invoke-WebRequest -Uri "$server/software/$id/download" -OutFile $zielDatei -UseBasicParsing',
        "    } catch {",
        '        Write-Host ("  FEHLER beim Herunterladen von " + $name + ": " + $_) -ForegroundColor Red',
        '        Protokolliere ($name + ": FEHLER beim Herunterladen")',
        '    $quelle = Join-Path $toolsOrdner ($id.ToString() + $endung)',
        "    if (-not (Test-Path $quelle)) {",
        '        Write-Host ("  FEHLER: Datei fehlt im Paket unter " + $quelle) -ForegroundColor Red',
        '        Protokolliere ($name + ": FEHLER, Datei fehlt im Paket")',
        "        return $false",
        "    }",
        "    try {",
        '        if ($endung -eq ".msi") {',
        "            Start-Process \"msiexec.exe\" -ArgumentList ('/i \"' + $zielDatei + '\" ' + $parameter) -Wait",
        "            Start-Process \"msiexec.exe\" -ArgumentList ('/i \"' + $quelle + '\" ' + $parameter) -Wait",
        "        } else {",
        "            Start-Process $zielDatei -ArgumentList $parameter -Wait",
        "            Start-Process $quelle -ArgumentList $parameter -Wait",
        "        }",
        "    } catch {",
        '        Write-Host ("  FEHLER bei der Installation von " + $name + ": " + $_) -ForegroundColor Red',
        '        Protokolliere ($name + ": FEHLER bei der Installation")',
        "        return $false",
        "    }",
        "    Remove-Item -Path $zielDatei -Force -ErrorAction SilentlyContinue",
        '    Protokolliere ($name + ": installiert")',
        '    Write-Host "  fertig." -ForegroundColor Green',
        "    return $true",
        "}",
        "",
        "function BereitstellenPortablesTool($id, $name) {",
        '    Write-Host "Lade herunter (portables Tool): $name ..." -ForegroundColor Cyan',
        '    $zielZip = Join-Path $paketeOrdner ($name + ".zip")',
        "    try {",
        '        Invoke-WebRequest -Uri "$server/software/$id/download" -OutFile $zielZip -UseBasicParsing',
        "    } catch {",
        '        Write-Host ("  FEHLER beim Herunterladen von " + $name + ": " + $_) -ForegroundColor Red',
        '        Protokolliere ($name + ": FEHLER beim Herunterladen")',
        '    Write-Host "Portables Tool: $name ..." -ForegroundColor Cyan',
        '    $quelle = Join-Path $toolsOrdner ($id.ToString() + ".zip")',
        "    if (-not (Test-Path $quelle)) {",
        '        Write-Host ("  FEHLER: ZIP fehlt im Paket unter " + $quelle) -ForegroundColor Red',
        '        Protokolliere ($name + ": FEHLER, ZIP fehlt im Paket")',
        "        return $false",
        "    }",
        '    $zielZip = Join-Path $paketeOrdner ($name + ".zip")',
        "    Copy-Item -Path $quelle -Destination $zielZip -Force",
        '    Protokolliere ($name + ": als ZIP bereitgestellt unter " + $zielZip)',
        '    Write-Host ("  fertig, liegt als ZIP unter " + $zielZip) -ForegroundColor Green',
        '    Write-Host "  (bewusst NICHT automatisch entpackt - bitte manuell an den Zielort entpacken)" -ForegroundColor DarkGray',
@@ -304,7 +301,7 @@
    zeilen += [
        "",
        'Write-Host ""',
        'Write-Host "Protokoll wird auf dem Server gespeichert..."',
        'Write-Host "Protokoll wird an den Server gemeldet (optional, braucht Netzwerkzugriff)..."',
        '$koerper = "mitarbeiter=" + [uri]::EscapeDataString($mitarbeiter)',
        '$koerper += "&rechnername=" + [uri]::EscapeDataString($env:COMPUTERNAME)',
        '$koerper += "&installiert_von=" + [uri]::EscapeDataString("Automatisches Einrichtungs-Skript")',
@@ -312,9 +309,9 @@
        "foreach ($id in $erfolgreicheIds) { $koerper += \"&software_ids=$id\" }",
        "try {",
        '    Invoke-RestMethod -Uri "$server/zuweisungen/neu" -Method POST -Body $koerper -ContentType "application/x-www-form-urlencoded" | Out-Null',
        '    Write-Host "Protokoll gespeichert." -ForegroundColor Green',
        '    Write-Host "Zentrales Protokoll auf dem Server aktualisiert." -ForegroundColor Green',
        "} catch {",
        '    Write-Host ("Konnte Protokoll nicht speichern: " + $_) -ForegroundColor Yellow',
        '    Write-Host "Kein Netzwerkzugriff auf den Server - zentrales Protokoll nicht aktualisiert (lokales Protokoll unter protokoll.log ist trotzdem vollstaendig)." -ForegroundColor Yellow',
        "}",
        "",
        'Write-Host ""',
@@ -353,6 +350,7 @@
                "typ": "installer",
                "endung": os.path.splitext(gefunden)[1].lower(),
                "parameter": sw["install_parameter"] or "",
                "installer_pfad": pfad,
            })
            continue
@@ -367,6 +365,7 @@
                    "typ": "ordner",
                    "endung": "",
                    "parameter": "",
                    "installer_pfad": pfad,
                })
                continue
@@ -388,6 +387,20 @@
        with zipfile.ZipFile(speicher, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("einrichten.ps1", ps1_inhalt)
            zf.writestr("Rechner-einrichten-starten.bat", _BAT_LAUNCHER)
            # Die echten Installer-Dateien/ZIPs direkt mit ins Paket packen,
            # damit auf dem Zielrechner beim Ausfuehren KEIN Netzwerkzugriff
            # auf den Server mehr noetig ist (nur die abschliessende
            # Protokoll-Rueckmeldung ist optional und best-effort).
            for t in ausgewaehlt:
                if t["typ"] == "installer":
                    quelldatei = updater.verwaltete_datei_pfad(t["installer_pfad"], t["name"])
                    if quelldatei and os.path.isfile(quelldatei):
                        zf.write(quelldatei, f"tools/{t['id']}{t['endung']}")
                else:
                    ordner = updater.effektiver_ablageordner(t["installer_pfad"], t["name"])
                    zf.writestr(f"tools/{t['id']}.zip", _zippe_ordner_in_bytes(ordner))
        speicher.seek(0)
        return send_file(
            speicher, as_attachment=True,
@@ -397,6 +410,21 @@
    return render_template(
        "einrichten.html", installierbar=installierbar, nicht_installierbar=nicht_installierbar
    )
def _zippe_ordner_in_bytes(ordner):
    """Packt einen Ordner (ohne '_sicherungen'-Unterordner) in ein ZIP im
    Arbeitsspeicher und gibt die rohen Bytes zurueck. Wird sowohl fuer den
    Download-Button als auch fuer das Einrichtungs-Paket verwendet."""
    speicher = io.BytesIO()
    with zipfile.ZipFile(speicher, "w", zipfile.ZIP_DEFLATED) as zf:
        for wurzel, unterordner, dateien in os.walk(ordner):
            unterordner[:] = [o for o in unterordner if o != "_sicherungen"]
            for datei in dateien:
                voller_pfad = os.path.join(wurzel, datei)
                rel_pfad = os.path.relpath(voller_pfad, ordner)
                zf.write(voller_pfad, rel_pfad)
    return speicher.getvalue()
def _ordner_fuer_installer_sicherstellen(installer_pfad, name):
@@ -913,17 +941,11 @@
        flash(f"Ordner nicht gefunden unter: {ordner}", "fehler")
        return redirect(url_for("software_detail", software_id=software_id))
    speicher = io.BytesIO()
    with zipfile.ZipFile(speicher, "w", zipfile.ZIP_DEFLATED) as zf:
        for wurzel, unterordner, dateien in os.walk(ordner):
            unterordner[:] = [o for o in unterordner if o != "_sicherungen"]
            for datei in dateien:
                voller_pfad = os.path.join(wurzel, datei)
                rel_pfad = os.path.relpath(voller_pfad, ordner)
                zf.write(voller_pfad, rel_pfad)
    speicher.seek(0)
    zip_bytes = _zippe_ordner_in_bytes(ordner)
    zip_name = os.path.basename(ordner.rstrip("\\/")) + ".zip"
    return send_file(speicher, as_attachment=True, download_name=zip_name, mimetype="application/zip")
    return send_file(
        io.BytesIO(zip_bytes), as_attachment=True, download_name=zip_name, mimetype="application/zip"
    )
if __name__ == "__main__":
templates/einrichten.html
@@ -12,17 +12,19 @@
<div class="panel" style="margin-bottom:18px;">
  <p style="margin:0 0 8px; font-size:13.5px;">
    Häkchen bei den benötigten Tools setzen, Mitarbeiter eintragen und
    herunterladen. Auf dem <strong>neuen Rechner</strong> das ZIP entpacken
    und <code>Rechner-einrichten-starten.bat</code> doppelklicken. Alles landet
    dabei gesammelt unter <code>C:\ClientBibliothek</code> – dafür wird
    automatisch eine Windows-Defender-Ausnahme für genau diesen einen Ordner
    eingetragen. Tools mit Installer-Datei werden still installiert
    (<code>\downloads</code>, danach automatisch aufgeräumt). Tools ohne
    Installer ("als ZIP bereitgestellt", z. B. portable Tools) landen
    unangetastet als ZIP in <code>\pakete</code> – die entpackt ihr bewusst
    von Hand an den gewünschten Zielort. Anschließend trägt das Skript selbst
    ein Protokoll ein (zentral auf dem Server und lokal unter
    <code>\protokoll.log</code>).
    herunterladen. Die ausgewählten Installer-Dateien werden dabei direkt mit
    ins Paket gepackt – auf dem <strong>neuen Rechner</strong> braucht ihr nur
    das ZIP zu entpacken und <code>Rechner-einrichten-starten.bat</code>
    doppelzuklicken, ganz ohne dass der Zielrechner Netzwerkzugriff auf
    diesen Server haben muss. Alles landet dabei gesammelt unter
    <code>C:\ClientBibliothek</code> – dafür wird automatisch eine
    Windows-Defender-Ausnahme für genau diesen einen Ordner eingetragen.
    Tools mit Installer-Datei werden still installiert. Tools ohne Installer
    ("als ZIP bereitgestellt", z. B. portable Tools) landen unangetastet als
    ZIP in <code>\pakete</code> – die entpackt ihr bewusst von Hand an den
    gewünschten Zielort. Nur die abschließende Meldung ans zentrale Protokoll
    braucht (optional) Netzwerkzugriff – schlägt das fehl, bleibt trotzdem
    das lokale Protokoll unter <code>\protokoll.log</code> vollständig.
  </p>
</div>