Server-Bibliothek
2026-07-23 548bab93e55f6e514557571d3c9da9b1c1d59c35
Zugriffsbeschraenkung: voller Zugriff nur lokal am Server, Netzwerk nur Software-Anforderung + Zugriffsprotokoll

Unterscheidung ueber request.remote_addr (127.0.0.1/::1 = lokal). Bei
Netzwerkzugriff sind alle Routen ausser /einrichten (dort umbenannt zu
'Software anfordern') gesperrt (403). Jeder Zugriff/Download wird in der
neuen Tabelle zugriffslog protokolliert (IP, Reverse-DNS-Rechnername falls
aufloesbar, selbst angegebener Name), einsehbar unter /zugriffslog
(ebenfalls lokal-only). Echte Windows-Benutzername-Erkennung bewusst NICHT
umgesetzt (braeuchte NTLM/IIS-Integration) - als offener Punkt in
ARCHITEKTUR.md festgehalten.
6 Dateien geändert
2 Dateien hinzugefügt
266 ■■■■■ Geänderte Dateien
ARCHITEKTUR.md 49 ●●●● Patch | Ansicht | Raw | Blame | Historie
CHANGELOG.md 21 ●●●●● Patch | Ansicht | Raw | Blame | Historie
README.md 10 ●●●●● Patch | Ansicht | Raw | Blame | Historie
app.py 77 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/base.html 11 ●●●●● Patch | Ansicht | Raw | Blame | Historie
templates/einrichten.html 15 ●●●● 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
@@ -197,15 +197,50 @@
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
@@ -102,6 +102,27 @@
  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)
README.md
@@ -102,6 +102,16 @@
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
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-23 · r16 (Installer-Dateien direkt im Paket, kein Netzwerk zur Laufzeit nötig)"
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
        );
        """
    )
@@ -323,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"
@@ -383,6 +451,11 @@
        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)
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,14 +1,23 @@
{% 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
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 %}