6 Dateien geändert
2 Dateien hinzugefügt
478 ■■■■ Geänderte Dateien
ARCHITEKTUR.md 92 ●●●● Patch | Ansicht | Raw | Blame | Historie
CHANGELOG.md 41 ●●●●● Patch | Ansicht | Raw | Blame | Historie
README.md 39 ●●●●● Patch | Ansicht | Raw | Blame | Historie
app.py 173 ●●●● Patch | Ansicht | Raw | Blame | Historie
templates/base.html 11 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/einrichten.html 39 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/zugriff_eingeschraenkt.html 27 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/zugriffslog.html 56 ●●●●● Patch | Ansicht | Raw | Blame | Historie
ARCHITEKTUR.md
@@ -146,45 +146,101 @@
## 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.
## 6a. Zugriffsbeschränkung: lokal (Server) vs. Netzwerk
Seit Runde r17 unterscheidet die Anwendung, ob eine Anfrage direkt vom Server
selbst kommt (z. B. per RDP-Sitzung, im Browser dort `localhost`/`127.0.0.1`
aufgerufen) oder über das Netzwerk von einem anderen Rechner.
- `ist_lokaler_zugriff()` prüft `request.remote_addr` gegen `127.0.0.1`/`::1`
- `@app.before_request`-Hook `_zugriff_beschraenken()` blockiert für
  Netzwerk-Zugriffe alle Routen außer denen in `OEFFENTLICHE_ENDPUNKTE`
  (aktuell nur `einrichten` und `static`) mit einer 403-Seite
- Die Seite `/einrichten` zeigt sich bei Netzwerkzugriff unter anderem Namen
  ("Software anfordern" statt "Rechner einrichten") und mit Hinweis auf die
  Protokollierung
- Jeder Aufruf/Download über `/einrichten` wird in der Tabelle `zugriffslog`
  festgehalten (Zeitpunkt, IP-Adresse, per Reverse-DNS ermittelter
  Rechnername falls auflösbar, selbst angegebener Name, Aktion) – einsehbar
  unter `/zugriffslog` (selbst wieder nur lokal erreichbar)
**Wichtige Einschränkung:** Ein echter Windows-Benutzername lässt sich über
reines HTTP **nicht** zuverlässig ermitteln – dafür wäre eine
NTLM/Kerberos-Integration nötig (z. B. über einen IIS-Reverse-Proxy vor
Flask, der die Windows-Anmeldung übernimmt und den Benutzernamen per Header
an die Flask-App durchreicht). Das ist in diesem Prototyp **nicht**
umgesetzt. Aktuell basiert die Identifizierung auf der IP-Adresse, einem per
Reverse-DNS aufgelösten Rechnernamen (funktioniert nur, wenn im
Firmennetzwerk DNS-Einträge für Client-Rechner existieren) und dem selbst im
Formular angegebenen Namen (nicht verifiziert). Für eine belastbarere
Identifizierung müsste diese IIS/NTLM-Integration nachgerüstet werden.
**Anpassung an andere Infrastruktur:** Sollte der Server z. B. hinter einem
Reverse-Proxy laufen, liefert `request.remote_addr` ggf. die Proxy-IP statt
der echten Client-IP – in dem Fall müssten `X-Forwarded-For`-Header
ausgewertet werden (aktuell nicht implementiert, da kein Proxy im Einsatz).
## 7. Bekannte Grenzen / nicht vollständig getestet
- **Keine Authentifizierung.** Es gibt kein Login-System. Jeder, der den
  Server im Netzwerk erreichen kann, kann Software verwalten, Installer
  herunterladen und Installations-Pakete erzeugen. Das war für den internen,
  vertrauenswürdigen Einsatz im Firmennetz eine bewusste Vereinfachung –
  **bei einer Anpassung an eine andere/größere Infrastruktur unbedingt
  prüfen, ob das so bleiben darf**, insbesondere wenn der Server auch von
  außerhalb des Firmennetzes erreichbar sein könnte.
- **Keine echte Authentifizierung, nur Herkunfts-Beschränkung.** Seit r17
  ist die Verwaltung (Katalog, Protokolle, Zugriffsprotokoll) nur noch vom
  Server selbst aus erreichbar (siehe Abschnitt 6a) – das ist aber eine
  Netzwerk-/IP-basierte Einschränkung, **kein Login mit Benutzername/Passwort**.
  Wer direkt am Server sitzt (oder sich als diesen ausgibt, z. B. über
  IP-Spoofing im selben Netzwerksegment), hat vollen Zugriff. Für ein
  belastbares Sicherheitskonzept mit echten Benutzerkonten wäre ein
  richtiges Login-System nötig.
- **Kein HTTPS/TLS.** Die Kommunikation läuft unverschlüsselt über HTTP. Für
  ein internes, abgeschottetes Netzwerk meist unkritisch, sollte aber vor
  einem größeren Rollout bewertet werden.
CHANGELOG.md
@@ -93,6 +93,47 @@
  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).
## v9 – Zugriffsbeschränkung lokal (Server) vs. Netzwerk
Auslöser: Wird der Server über das Netzwerk erreicht (nicht direkt am
Server), soll nur die Software-Anforderung sichtbar sein, um "Blödsinn" zu
verhindern (Katalog verändern, Protokolle einsehen usw. nur direkt am
Server). Wer aus dem Netzwerk Software herunterlädt, muss sich mit Namen
eintragen, und alle Zugriffe werden protokolliert.
- Unterscheidung über `request.remote_addr` (127.0.0.1/::1 = lokal, alles
  andere = Netzwerk)
- Für Netzwerk-Zugriffe: alle Routen außer der Software-Anforderung
  (`/einrichten`) geben 403 mit Hinweisseite zurück
- Software-Anforderung heißt bei Netzwerkzugriff bewusst anders
  ("Software anfordern" statt "Rechner einrichten") und weist auf die
  Protokollierung hin
- Neues Zugriffsprotokoll (`/zugriffslog`, ebenfalls nur lokal einsehbar):
  Zeitpunkt, IP-Adresse, per Reverse-DNS ermittelter Rechnername (falls
  auflösbar), selbst angegebener Name, Aktion
- **Bewusst nicht umgesetzt:** echte Windows-Benutzername-Erkennung. Das
  bräuchte NTLM/Kerberos-Integration (z. B. IIS-Reverse-Proxy vor Flask),
  was über reines Flask/HTTP nicht möglich ist – im Architektur-Dokument als
  offener Punkt festgehalten, nicht stillschweigend als "erledigt" behandelt
## 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,45 @@
---
## 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.
**Zugriff aus dem Netzwerk:** Ruft jemand die Bibliothek nicht direkt am
Server, sondern über das Netzwerk auf (z. B. von einem anderen PC), sieht
diese Person nur diese eingeschränkte Seite (dort "Software anfordern"
genannt) – der Software-Katalog, die Protokolle usw. bleiben dem Server
selbst vorbehalten (z. B. per RDP-Sitzung, dort im Browser "localhost"
statt der Netzwerk-Adresse aufrufen). Jeder Zugriff/Download aus dem
Netzwerk wird im Zugriffsprotokoll (Menüpunkt "Zugriffsprotokoll",
ebenfalls nur direkt am Server einsehbar) mit IP-Adresse und angegebenem
Namen festgehalten.
---
## 4. Automatische Aktualisierung
Bei jedem Tool kann beim Anlegen/Bearbeiten "Automatische Aktualisierung
app.py
@@ -22,15 +22,62 @@
# 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 · r17 (Zugriffsbeschränkung lokal/Netzwerk + Zugriffsprotokoll)"
@app.context_processor
def _version_injizieren():
    return {"app_version": APP_VERSION}
    return {"app_version": APP_VERSION, "ist_lokal": ist_lokaler_zugriff()}
# Nach wie vielen Tagen ohne Pruefung ein Tool als "bitte pruefen" markiert wird
TAGE_BIS_WARNUNG = 30
# ---------------------------------------------------------------------------
# Zugriffsbeschraenkung: voller Zugriff nur direkt am Server (z. B. per RDP,
# im Browser "localhost" statt der Netzwerk-IP aufgerufen). Von jedem anderen
# Rechner im Netzwerk aus ist NUR die eingeschraenkte Software-Anforderung
# erreichbar - alles andere (Katalog verwalten, Protokolle einsehen usw.)
# bleibt dem Server selbst vorbehalten.
# ---------------------------------------------------------------------------
OEFFENTLICHE_ENDPUNKTE = {"einrichten", "static"}
def ist_lokaler_zugriff():
    return request.remote_addr in ("127.0.0.1", "::1")
@app.before_request
def _zugriff_beschraenken():
    if ist_lokaler_zugriff():
        return  # voller Zugriff, direkt am Server
    if request.endpoint in OEFFENTLICHE_ENDPUNKTE:
        return  # eingeschraenkter Bereich bleibt aus dem Netzwerk erreichbar
    return render_template("zugriff_eingeschraenkt.html"), 403
def protokolliere_zugriff(aktion, angegebener_name=""):
    """Schreibt einen Eintrag ins Zugriffsprotokoll - IP-Adresse, per
    Reverse-DNS ermittelter Rechnername (falls aufloesbar) und der selbst
    angegebene Name. Hinweis: ein echter Windows-Benutzername laesst sich
    ueber reines HTTP ohne Domänen-/NTLM-Integration nicht zuverlaessig
    ermitteln, siehe ARCHITEKTUR.md."""
    import socket
    ip = request.remote_addr
    try:
        rechnername = socket.gethostbyaddr(ip)[0]
    except Exception:
        rechnername = None
    db = get_db()
    db.execute(
        """INSERT INTO zugriffslog (zeitpunkt, ip_adresse, rechnername, angegebener_name, aktion, lokal)
           VALUES (?, ?, ?, ?, ?, ?)""",
        (
            datetime.now().isoformat(timespec="seconds"), ip, rechnername, angegebener_name,
            aktion, 1 if ist_lokaler_zugriff() else 0,
        ),
    )
    db.commit()
# ---------------------------------------------------------------------------
@@ -109,6 +156,16 @@
            dateiname TEXT NOT NULL,
            hochgeladen_am TEXT NOT NULL,
            FOREIGN KEY (software_id) REFERENCES software (id) ON DELETE CASCADE
        );
        CREATE TABLE IF NOT EXISTS zugriffslog (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            zeitpunkt TEXT NOT NULL,
            ip_adresse TEXT,
            rechnername TEXT,
            angegebener_name TEXT,
            aktion TEXT,
            lokal INTEGER NOT NULL
        );
        """
    )
@@ -189,8 +246,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 +259,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 +276,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 +303,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 +358,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 +366,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 ""',
@@ -326,9 +380,20 @@
    return "\r\n".join(zeilen) + "\r\n"
@app.route("/zugriffslog")
def zugriffslog():
    db = get_db()
    eintraege = db.execute(
        "SELECT * FROM zugriffslog ORDER BY zeitpunkt DESC LIMIT 300"
    ).fetchall()
    return render_template("zugriffslog.html", eintraege=eintraege)
@app.route("/einrichten", methods=["GET", "POST"])
def einrichten():
    db = get_db()
    if request.method == "GET":
        protokolliere_zugriff("Seite aufgerufen")
    alle = db.execute(
        "SELECT id, name, kategorie, version, installer_pfad, install_parameter, auto_update FROM software "
        "ORDER BY kategorie COLLATE NOCASE, name COLLATE NOCASE"
@@ -353,6 +418,7 @@
                "typ": "installer",
                "endung": os.path.splitext(gefunden)[1].lower(),
                "parameter": sw["install_parameter"] or "",
                "installer_pfad": pfad,
            })
            continue
@@ -367,6 +433,7 @@
                    "typ": "ordner",
                    "endung": "",
                    "parameter": "",
                    "installer_pfad": pfad,
                })
                continue
@@ -384,10 +451,29 @@
        server_url = request.host_url.rstrip("/")
        ps1_inhalt = _erstelle_einrichtungs_skript(server_url, mitarbeiter, ausgewaehlt)
        protokolliere_zugriff(
            "Paket heruntergeladen: " + ", ".join(t["name"] for t in ausgewaehlt),
            angegebener_name=mitarbeiter,
        )
        speicher = io.BytesIO()
        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 +483,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 +1014,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/base.html
@@ -14,17 +14,26 @@
        <div class="brand-text">Server-Bibliothek<small>Software &amp; Doku-Ablage</small></div>
      </div>
      <nav class="nav">
        {% if ist_lokal %}
        <a href="{{ url_for('dashboard') }}" class="{{ 'active' if request.endpoint == 'dashboard' }}">
          <span class="nav-dot"></span> Software-Katalog
        </a>
        <a href="{{ url_for('protokolle') }}" class="{{ 'active' if request.endpoint in ['protokolle', 'protokoll_detail', 'zuweisung_neu'] }}">
          <span class="nav-dot"></span> Protokolle
        </a>
        <a href="{{ url_for('zugriffslog') }}" class="{{ 'active' if request.endpoint == 'zugriffslog' }}">
          <span class="nav-dot"></span> Zugriffsprotokoll
        </a>
        <a href="{{ url_for('einrichten') }}" class="{{ 'active' if request.endpoint == 'einrichten' }}">
          <span class="nav-dot"></span> Rechner einrichten
        </a>
        {% else %}
        <a href="{{ url_for('einrichten') }}" class="active">
          <span class="nav-dot"></span> Software anfordern
        </a>
        {% endif %}
      </nav>
      <div class="sidebar-foot">Version: {{ app_version }}</div>
      <div class="sidebar-foot">Version: {{ app_version }}{% if not ist_lokal %}<br>Netzwerkzugriff{% endif %}</div>
    </aside>
    <main class="main">
      {% with messages = get_flashed_messages(with_categories=true) %}
templates/einrichten.html
@@ -1,28 +1,39 @@
{% extends "base.html" %}
{% block title %}Rechner einrichten – Server-Bibliothek{% endblock %}
{% block title %}{{ "Rechner einrichten" if ist_lokal else "Software anfordern" }} – Server-Bibliothek{% endblock %}
{% block content %}
<div class="page-head">
  <div>
    <p class="eyebrow">Verteilung</p>
    <h1>Rechner einrichten</h1>
    <p class="eyebrow">{{ "Verteilung" if ist_lokal else "Selbstbedienung" }}</p>
    <h1>{{ "Rechner einrichten" if ist_lokal else "Software anfordern" }}</h1>
  </div>
</div>
{% if not ist_lokal %}
<div class="panel" style="margin-bottom:18px; border-color: var(--accent);">
  <p style="margin:0; font-size:13.5px;">
    Dein Name wird zusammen mit deiner Rechner-/IP-Adresse protokolliert.
    Bitte nur Tools anfordern, die du für deine Arbeit wirklich brauchst.
  </p>
</div>
{% endif %}
<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>
templates/zugriff_eingeschraenkt.html
Neue Datei
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Zugriff eingeschränkt – Server-Bibliothek</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
  <div class="layout">
    <main class="main" style="max-width:520px; margin:60px auto;">
      <div class="panel">
        <p class="eyebrow">Zugriff eingeschränkt</p>
        <h1 style="margin-bottom:14px;">Nur eingeschränkter Zugriff verfügbar</h1>
        <p style="font-size:13.5px; line-height:1.6;">
          Von diesem Rechner aus (über das Netzwerk) ist nur die Software-Anforderung
          verfügbar. Die vollständige Verwaltung (Software-Katalog, Protokolle usw.)
          ist ausschließlich direkt am Server selbst zugänglich.
        </p>
        <a href="{{ url_for('einrichten') }}" class="btn" style="margin-top:10px; display:inline-block;">
          Zur Software-Anforderung
        </a>
      </div>
    </main>
  </div>
</body>
</html>
templates/zugriffslog.html
Neue Datei
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Zugriffsprotokoll – Server-Bibliothek{% endblock %}
{% block content %}
<div class="page-head">
  <div>
    <p class="eyebrow">Sicherheit</p>
    <h1>Zugriffsprotokoll</h1>
  </div>
</div>
<div class="hint" style="margin-bottom:16px;">
  Zeigt jeden Zugriff auf die Software-Anforderung von außerhalb des Servers.
  Ein echter Windows-Benutzername lässt sich ohne Domänen-Integration nicht
  zuverlässig ermitteln – hier stehen IP-Adresse, per Reverse-DNS ermittelter
  Rechnername (falls auflösbar) und der selbst angegebene Name.
</div>
{% if eintraege %}
<div class="panel" style="padding:0; overflow-x:auto;">
  <table class="data-table" style="margin:0;">
    <thead>
      <tr>
        <th style="padding-left:22px;">Zeitpunkt</th>
        <th>Herkunft</th>
        <th>IP-Adresse</th>
        <th>Rechnername</th>
        <th>Angegebener Name</th>
        <th style="padding-right:22px;">Aktion</th>
      </tr>
    </thead>
    <tbody>
      {% for e in eintraege %}
      <tr>
        <td style="padding-left:22px;">{{ e.zeitpunkt.replace("T", " ") }}</td>
        <td>
          {% if e.lokal %}
            <span class="badge status-ok">Lokal (Server)</span>
          {% else %}
            <span class="badge status-warnung">Netzwerk</span>
          {% endif %}
        </td>
        <td>{{ e.ip_adresse or "–" }}</td>
        <td>{{ e.rechnername or "–" }}</td>
        <td>{{ e.angegebener_name or "–" }}</td>
        <td style="padding-right:22px;">{{ e.aktion }}</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
</div>
{% else %}
<div class="empty-state">Noch keine Zugriffe protokolliert.</div>
{% endif %}
{% endblock %}