| | |
| | | # 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() |
| | | |
| | | |
| | | # --------------------------------------------------------------------------- |
| | |
| | | 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 |
| | | ); |
| | | """ |
| | | ) |
| | |
| | | "@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" |
| | |
| | | |
| | | |
| | | 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)}'", |
| | |
| | | " 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 {", |
| | |
| | | "", |
| | | "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', |
| | |
| | | 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")', |
| | |
| | | "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 ""', |
| | |
| | | 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" |
| | |
| | | "typ": "installer", |
| | | "endung": os.path.splitext(gefunden)[1].lower(), |
| | | "parameter": sw["install_parameter"] or "", |
| | | "installer_pfad": pfad, |
| | | }) |
| | | continue |
| | | |
| | |
| | | "typ": "ordner", |
| | | "endung": "", |
| | | "parameter": "", |
| | | "installer_pfad": pfad, |
| | | }) |
| | | continue |
| | | |
| | |
| | | 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, |
| | |
| | | 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): |
| | |
| | | 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__": |