From 548bab93e55f6e514557571d3c9da9b1c1d59c35 Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Do, 23 Jul 2026 15:07:49 +0200
Subject: [PATCH] Zugriffsbeschraenkung: voller Zugriff nur lokal am Server, Netzwerk nur Software-Anforderung + Zugriffsprotokoll

---
 templates/base.html                   |   11 ++
 app.py                                |   77 +++++++++++++++
 templates/zugriff_eingeschraenkt.html |   27 +++++
 ARCHITEKTUR.md                        |   49 ++++++++-
 templates/zugriffslog.html            |   56 +++++++++++
 CHANGELOG.md                          |   21 ++++
 templates/einrichten.html             |   15 ++
 README.md                             |   10 ++
 8 files changed, 253 insertions(+), 13 deletions(-)

diff --git a/ARCHITEKTUR.md b/ARCHITEKTUR.md
index e90bc6b..9af377f 100644
--- a/ARCHITEKTUR.md
+++ b/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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index edf3a0c..97cb563 100644
--- a/CHANGELOG.md
+++ b/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)
diff --git a/README.md b/README.md
index 5d6ffd4..18bc5b9 100644
--- a/README.md
+++ b/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
diff --git a/app.py b/app.py
index a50b718..9dff962 100644
--- a/app.py
+++ b/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)
diff --git a/templates/base.html b/templates/base.html
index 8ababe5..f21dcc9 100644
--- a/templates/base.html
+++ b/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) %}
diff --git a/templates/einrichten.html b/templates/einrichten.html
index 52e392c..06e1907 100644
--- a/templates/einrichten.html
+++ b/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
diff --git a/templates/zugriff_eingeschraenkt.html b/templates/zugriff_eingeschraenkt.html
new file mode 100644
index 0000000..bde86d3
--- /dev/null
+++ b/templates/zugriff_eingeschraenkt.html
@@ -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>
diff --git a/templates/zugriffslog.html b/templates/zugriffslog.html
new file mode 100644
index 0000000..11edb5e
--- /dev/null
+++ b/templates/zugriffslog.html
@@ -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 %}

--
Gitblit v1.9.3