Files
appServerPortalUI/README.md
2026-06-07 07:21:30 +02:00

488 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# appServer Portal UI
Übersichts-Webserver für **`server.schooltech.ch`**. Er fasst eine Reihe von
Web-Diensten (Roboter-Steuerungen, Guacamole-Remotedesktops, Portainer,
VS Code, Nextcloud …) unter **einer Domain** zusammen und macht sie über
sprechende Subdomains erreichbar.
Alle Dienste laufen als Subdomain von `.server.schooltech.ch` egal ob sie
auf dem Server selbst, auf einem Gerät im LAN oder hinter einem SSH-Tunnel
stehen. Ein nginx-Reverse-Proxy nimmt jede Subdomain auf 443 entgegen,
terminiert TLS (Let's Encrypt) und leitet an den passenden Upstream weiter.
---
## Inhalt
- [Architektur-Übersicht](#architektur-übersicht)
- [Die Portal-Seite (`server.schooltech.ch`)](#die-portal-seite-serverschooltechch)
- [Die Dienste / Subdomains](#die-dienste--subdomains)
- [`forwarding.conf` das Herzstück](#forwardingconf--das-herzstück)
- [`connect-proxies.sh` Konfig-Generator](#connect-proxiessh--konfig-generator)
- [Authentifizierung](#authentifizierung)
- [TLS / Let's Encrypt](#tls--lets-encrypt)
- [Container & Betrieb](#container--betrieb)
- [Eine neue Seite ins Portal aufnehmen](#eine-neue-seite-ins-portal-aufnehmen)
- [Dateiübersicht](#dateiübersicht)
---
## Architektur-Übersicht
Alles hängt unter `*.server.schooltech.ch`. Der Unterschied zwischen den
Diensten ist nicht die Domain, sondern **wohin der Upstream zeigt**:
<img src="doc/Architektur.png" width="900" alt="Architektur-Übersicht: Internet → nginx Reverse-Proxy (appServer_PortalUI) → Subdomains, gruppiert nach Upstream-Ziel">
> Bild: [`doc/Architektur.png`](doc/Architektur.png) · Vektor-Quelle:
> [`doc/Architektur.svg`](doc/Architektur.svg) (für PDF/beliebige Skalierung).
<details>
<summary>Dieselbe Übersicht als ASCII-Text</summary>
```text
Internet (HTTPS :443 / HTTP :80)
┌────────────────────────────────────────────────┐
│ nginx Reverse-Proxy (Container: appServer_PortalUI)
│ ein vHost pro Subdomain *.server.schooltech.ch
│ TLS-Terminierung (Let's Encrypt) · 80→443 Redirect
└────────────────────────────────────────────────┘
┌───────────────┬───────────┼──────────────┬────────────────────┐
▼ ▼ ▼ ▼
server. rp5*. nextcloud. inf*/rp3*/tc*/robot*/fluidnc*
schooltech.ch schooltech. schooltech. .server.schooltech.ch
Portal-UI lokale Gerät im LAN über SSH-Tunnel-Hub
(diese App) Container (direkte IP) appServer_TunnelHead
│ │ │ │
▼ ▼ ▼ ▼
public/ appServer_ 192.168.0.210 appServer_TunnelHead (SSH-Reverse-Tunnels)
index.html guacamole / ├─ 99xx InformatikWeb (inf*)
+ Auth-API portainer ├─ 81xx RP3/SCARA (rp3*, fluidnc*)
└─ 97xx ThinkCentre (tc*, robot*)
```
</details>
**Bausteine (Docker-Container, siehe `docker-compose.yaml`):**
| Container | Image | Rolle |
|---|---|---|
| `appServer_PortalUI` | `nginx:alpine` | Reverse-Proxy + Auslieferung der Portal-Seite (Ports 80/443) |
| `AppServerAuth` | `node:24-alpine` | Login-/Session-Service (`auth/auth.js`, Port 3000 intern) |
| `appServer_TunnelHead` | `linuxserver/openssh-server` | SSH-Hub: entfernte Geräte bauen Reverse-Tunnel hierher auf |
| `appServer_guacamole` | `abesnier/guacamole` | Lokaler Guacamole-Remotedesktop |
| `appServer_LetsEncryptFetcher` | `certbot/certbot` | Holt/erneuert die TLS-Zertifikate |
Alle Container hängen im Docker-Netzwerk **`appRobotNet`** und sprechen sich
per Container-Namen an (z. B. `appserverauth:3000`).
---
## Die Portal-Seite (`server.schooltech.ch`)
Ruft man die nackte Domain `server.schooltech.ch` auf, erscheint die
**Portal-Oberfläche** eine schlanke Single-Page-App aus `public/`:
- **`public/index.html`** Grundgerüst: oben eine **Navigationsleiste**
(`<header>` mit Logo *„schooltech“*, der Service-Navigation `#services`
und einem Login/Logout-Button), darunter ein **`<iframe>`**, in dem der
gewählte Dienst eingeblendet wird.
- **`public/app.js`** die Logik:
- hält eine Liste der verlinkten Dienste (`services`-Array),
- prüft beim Laden via `GET /api/status`, ob eine Session besteht,
- blendet **nach dem Login** für jeden Dienst einen **Button in die
Navigationsleiste** ein,
- öffnet beim Klick den Dienst im `<iframe>` (und meldet das Ereignis an
`POST /api/event`),
- Login/Logout laufen über `POST /api/login` bzw. `POST /api/logout`.
- **`public/style.css`** Styling der Leiste, der Buttons und des
Login-Dialogs.
**Ablauf aus Nutzersicht:**
<img src="doc/Portal.png" width="720" alt="Portal-Ansicht: Navigationsleiste oben mit Logo und Dienst-Buttons, darunter der gewählte Dienst im iFrame">
*Oben die Navigationsleiste mit Logo und Dienst-Buttons, darunter der gewählte
Dienst im iFrame. Bild: [`doc/Portal.png`](doc/Portal.png) · Quellen:
[`doc/Portal.svg`](doc/Portal.svg), [`doc/Portal.pdf`](doc/Portal.pdf).*
<details>
<summary>Layout-Skizze als ASCII-Text</summary>
```text
┌──────────────────────────────────────────────────────────────┐
│ schooltech [ Control GamePad ][ Guacamole ][ Simulation ]… [Logout] │ ← Navigationsleiste
├──────────────────────────────────────────────────────────────┤
│ │
│ « ausgewählter Dienst im iFrame » │
│ │
└──────────────────────────────────────────────────────────────┘
```
</details>
1. Seite öffnen → ist man nicht eingeloggt, zeigt der Button **„Login“**.
2. Login (User/Passwort) → der Auth-Service setzt ein Session-Cookie für die
gesamte Domain `.server.schooltech.ch`.
3. Die Navigationsleiste füllt sich mit den Dienst-Buttons.
4. Klick auf einen Button blendet den Dienst im iFrame ein.
> **Hinweis:** Die im Portal angezeigten Buttons stehen **hardcodiert** in
> `public/app.js` (`services`-Array, aktuell 10 Einträge) sie werden *nicht*
> automatisch aus `forwarding.conf` erzeugt. Ein neuer Dienst in
> `forwarding.conf` taucht also erst dann in der Navigationsleiste auf, wenn er
> auch ins `services`-Array eingetragen wird (siehe
> [Eine neue Seite ins Portal aufnehmen](#eine-neue-seite-ins-portal-aufnehmen)).
Aktuell im Portal verlinkte Dienste (`public/app.js`):
| Button | Ziel-Subdomain |
|---|---|
| Control GamePad | `tccontrol.server.schooltech.ch` |
| Guacamole | `rp5guac.server.schooltech.ch` |
| Simulation | `tcSimulation.server.schooltech.ch` |
| Video | `robotVideo.server.schooltech.ch` |
| Homing | `robotHoming.server.schooltech.ch` |
| RobotBase | `robotBase.server.schooltech.ch` |
| RobotEllbow | `robotEllbow.server.schooltech.ch` |
| RobotHand | `robotHand.server.schooltech.ch` |
| RobotDriver | `robotDriver.server.schooltech.ch` |
| VSCode | `robotVSCode.server.schooltech.ch` |
---
## Die Dienste / Subdomains
Quelle der Wahrheit ist `forwarding.conf`. Aus jeder Zeile generiert
`connect-proxies.sh` einen nginx-vHost. Gruppiert nach Upstream-Ziel:
### Portal selbst
| Subdomain | Upstream | Bemerkung |
|---|---|---|
| `server.schooltech.ch` | `local` | liefert die Portal-Seite aus `public/` |
### Lokale Container (auf dem Server / „RP5“)
| Subdomain | Upstream | WebSocket |
|---|---|---|
| `rp5Guac.server.schooltech.ch` | `http://appServer_guacamole:8080` | ✓ |
| `rp5Portainer.server.schooltech.ch` | `http://portainer:9000` | ✓ |
### Gerät im LAN (direkte IP)
| Subdomain | Upstream | WebSocket |
|---|---|---|
| `nextcloud.server.schooltech.ch` | `http://192.168.0.210:9183` | ✓ |
### Über `appServer_TunnelHead` (SSH-Tunnel-Hub)
Entfernte Geräte bauen einen SSH-Reverse-Tunnel zum `appServer_TunnelHead`
auf; deren Web-Oberflächen erscheinen dort auf festen Ports.
**InformatikWeb (99xx):**
| Subdomain | Upstream | WS |
|---|---|---|
| `infPortainer.server.schooltech.ch` | `http://appServer_TunnelHead:9903` | ✓ |
| `infGuac.server.schooltech.ch` | `http://appServer_TunnelHead:9980` | ✓ |
**RP3 Raspi für die SCARA-Roboter (81xx):**
| Subdomain | Upstream | WS |
|---|---|---|
| `rp3Portainer.server.schooltech.ch` | `http://appServer_TunnelHead:8100` | ✓ |
| `rp3Guac.server.schooltech.ch` | `http://appServer_TunnelHead:8180` | ✓ |
| `fluidncRed.server.schooltech.ch` | `http://appServer_TunnelHead:8120` | ✓ |
| `fluidncWhite.server.schooltech.ch` | `https://appServer_TunnelHead:8104` | ✓ |
**ThinkCentre MiniPC neben einem Roboter (97xx):**
| Subdomain | Upstream | WS | Auth |
|---|---|---|---|
| `tcGuac.server.schooltech.ch` | `http://appServer_TunnelHead:9780` | | |
| `tcPortainer.server.schooltech.ch` | `http://appServer_TunnelHead:9703` | ✓ | |
| `tcSimulation.server.schooltech.ch` | `https://appServer_TunnelHead:9712` | ✓ | |
| `tcControl.server.schooltech.ch` | `https://appServer_TunnelHead:9710` | ✓ | **✓** |
| `robotHoming.server.schooltech.ch` | `https://appServer_TunnelHead:9793` | ✓ | |
| `robotVideo.server.schooltech.ch` | `https://appServer_TunnelHead:9743` | ✓ | **✓** |
| `robotBase.server.schooltech.ch` | `https://appServer_TunnelHead:9725` | ✓ | |
| `robotEllbow.server.schooltech.ch` | `https://appServer_TunnelHead:9726` | ✓ | |
| `robotHand.server.schooltech.ch` | `https://appServer_TunnelHead:9727` | ✓ | |
| `robotDriver.server.schooltech.ch` | `https://appServer_TunnelHead:9798` | ✓ | **✓** |
| `robotVSCode.server.schooltech.ch` | `http://appServer_TunnelHead:9744` | ✓ | |
> Die Port-Konvention am Tunnel-Hub: **81xx → RP3**, **97xx → ThinkCentre**,
> **99xx → InformatikWeb**.
---
## `forwarding.conf` das Herzstück
Eine Zeile pro Subdomain. Spalten (durch Whitespace getrennt):
```
server_name upstream_url http_behavior websockets verify_upstream_tls [cert_domain] [listen_port] [auth_required]
```
| # | Spalte | Werte | Default | Bedeutung |
|---|---|---|---|---|
| 1 | `server_name` | FQDN | | die Subdomain, z. B. `tcGuac.server.schooltech.ch` |
| 2 | `upstream_url` | `http(s)://host:port` oder `local` | | Ziel; `local` = Portal-Seite statisch ausliefern |
| 3 | `http_behavior` | `redirect` / sonst | `redirect` | `redirect` erzeugt zusätzlich einen 80→443-Redirect-vHost |
| 4 | `websockets` | `true` / `false` | `false` | bei `true` werden `Upgrade`/`Connection`-Header durchgereicht |
| 5 | `verify_upstream_tls` | `true` / `false` | `false` | nur bei `https`-Upstream: Zertifikat des Upstreams prüfen |
| 6 | `cert_domain` | Ordnername | = `server_name` | Let's-Encrypt-Verzeichnis unter `/etc/letsencrypt/live/<cert_domain>/` |
| 7 | `listen_port` | Port | `443` | abweichender HTTPS-Listen-Port |
| 8 | `auth_required` | `true` / `false` | `false` | bei `true` schützt nginx den vHost via `auth_request` |
Zeilen, die mit `#` beginnen, sowie Leerzeilen werden ignoriert.
---
## `connect-proxies.sh` Konfig-Generator
Läuft **automatisch beim Containerstart** des nginx-Containers (liegt als
`/docker-entrypoint.d/40-connect-proxies.sh`). Pro `forwarding.conf`-Zeile:
1. **Globale Map + Resolver** schreiben (`_globals.generated.conf`) nötig für
WebSockets und für die DNS-Auflösung der Upstreams zur Laufzeit. Außerdem ein
**443-Default-Server** (`00-default-server.generated.conf`), der unbekanntes
SNI per `ssl_reject_handshake` abweist (siehe Hinweis unten).
2. **Alte generierte Configs** löschen (`*-https.generated.conf`,
`*-http-redirect.generated.conf`).
3. Pro Dienst eine vHost-Datei erzeugen mit Fallunterscheidung:
- **`local`** → statischer Server, der `public/` ausliefert (+ `/api/`-Proxy
zum Auth-Service).
- **Reverse-Proxy** → `proxy_pass` auf den Upstream; optional WebSocket-Header,
optional `proxy_ssl_verify`, optional `auth_request`-Schutz.
- **DNS nicht auflösbar** → statt Proxy ein **503-Platzhalter** („Service
nicht erreichbar“), damit nginx trotzdem startet.
- **Kein Zertifikat vorhanden** → vHost wird **übersprungen** (keine 443-Conf);
solche Anfragen fängt der 443-Default-Server ab (siehe Hinweis unten).
- Bei `http_behavior = redirect` zusätzlich ein **80→443-Redirect** (mit
Ausnahme für die ACME-Challenge).
4. Abschließend **`nginx -t`** zur Syntaxprüfung.
So ist das System robust: fehlt ein Zertifikat oder ein Backend, fällt nur der
betroffene Dienst aus nginx selbst läuft weiter.
> **Anti-Leak / Default-Server:** Ohne expliziten Default bedient nginx eine
> Anfrage mit unbekanntem `server_name` mit dem *ersten* 443-Block (alphabetisch
> das war `ai.server.schooltech.ch`). Damit eine Domain ohne passenden vHost
> (z. B. fehlendes Zertifikat) **nicht still** bei einem fremden Dienst landet,
> erzeugt das Skript einen Catch-all `listen 443 ssl default_server;
> ssl_reject_handshake on;` unbekanntes SNI bekommt einen sauberen TLS-Abbruch.
> `server.schooltech.ch` & Co. treffen ihren eigenen vHost weiterhin per SNI-Match.
> **Aktiver Pfad vs. Referenz:** Die vHosts werden zur Laufzeit von
> `connect-proxies.sh` aus `forwarding.conf` erzeugt. Die statischen Dateien in
> `nginxPages/` sind **nicht** in `docker-compose.yaml` gemountet und dienen nur
> als Referenz/Vorlage (z. B. `10-server-schooltech.conf` zeigt eine Variante
> der Portal-vHost-Konfig).
---
## Authentifizierung
Der **Auth-Service** (`auth/auth.js`, Express) verwaltet Login und Sessions:
- **`POST /api/login`** prüft User/Passwort gegen `auth/users.json`
(bcrypt-Hashes) und setzt bei Erfolg ein Cookie **`SESSIONID`**.
- **`POST /api/logout`** löscht die Session und das Cookie.
- **`GET /api/status`** sagt dem Frontend, ob eine Session aktiv ist.
- **`GET /internal/auth`** Endpoint für nginx `auth_request`: liefert `200`
bei gültiger Session, sonst `401`.
- **`POST /api/event`** einfaches Logging der Button-Klicks.
Das Cookie wird auf **`domain: .server.schooltech.ch`** gesetzt und gilt damit
für **alle Subdomains**. Dienste mit `auth_required = true` (z. B. `tcControl`,
`robotVideo`, `robotDriver`) lässt nginx nur durch, wenn `auth_request` gegen
`/internal/auth` ein `200` zurückgibt sonst landet der Nutzer nicht auf dem
Dienst.
> Sessions liegen **in-memory** im Auth-Service ein Neustart des Containers
> meldet alle Nutzer ab.
>
> Benutzer/Passwörter pflegt man in `auth/users.json`; einen neuen
> bcrypt-Hash erzeugt `auth/cretePassword.js`.
---
## TLS / Let's Encrypt
- Zertifikate liegen unter `letsencrypt/conf/live/<domain>/` und werden in den
nginx-Container gemountet (`/etc/letsencrypt`).
- **`letsEncrypt.sh`** holt/erneuert per `certbot certonly --webroot` ein
Zertifikat pro Domain (über den `appServer_LetsEncryptFetcher`-Container,
HTTP-01-Challenge unter `/.well-known/acme-challenge/`).
- **`letsEncrypt_crontab.txt`** enthält den Cron-Eintrag für die automatische
Erneuerung.
---
## Container & Betrieb
Alles wird per `docker-compose.yaml` orchestriert. Wichtige Host-Pfade (auf dem
Server unter `/home/chk/Documents/appServerPortalUI/`):
| Host-Pfad | Mount im Container | Zweck |
|---|---|---|
| `nginx.conf` | `…/conf.d/default.conf` | Basis-vHost (Port 80, ACME, `/api/`, SPA) |
| `public/` | `…/nginx/html` | die Portal-Seite |
| `forwarding.conf` | `/etc/nginx/forwarding.conf` | Dienst-Definitionen |
| `connect-proxies.sh` | `/docker-entrypoint.d/40-…` | vHost-Generator |
| `letsencrypt/conf` | `/etc/letsencrypt` | Zertifikate |
| `letsencrypt/www` | `/var/www/certbot` | ACME-Webroot |
| `auth/` | `/usr/src/app` (Auth-Container) | Auth-Service-Code |
**Start / Neuladen:**
```bash
# Alles starten
docker compose up -d
# nginx-vHosts neu generieren (nach Änderung an forwarding.conf):
docker restart appServer_PortalUI
# Logs des Generators ansehen
docker logs appServer_PortalUI | grep connect-proxies
```
---
## Eine neue Seite ins Portal aufnehmen
Zwei Fragen entscheiden, wie eine Seite angebunden wird: **(1) Wie erreicht der
nginx-Container das Backend?** und **(2) unter welcher Subdomain** soll es laufen?
### Wie das Backend erreichbar ist zwei Wege
- **A) Direkt** das Backend ist vom nginx-Container aus erreichbar:
- **lokaler Container** im Docker-Netz `appRobotNet` → per Container-Name,
z. B. `http://open-webui:8080`.
- **Gerät im LAN** → per **IP**, z. B. `http://192.168.0.210:9183`.
- ⚠️ **Kein `.local`/mDNS!** Namen wie `thinkcentre.local` löst der Container
**nicht** auf (Docker-DNS kann kein mDNS) → der Dienst wird zum 503-Platzhalter.
Immer IP **oder** Tunnel verwenden.
- **B) Über den SSH-Reverse-Tunnel** das Backend steht hinter NAT/Firewall
(Roboter-Pi, ThinkCentre, InformatikWeb) und hat keine Route ins Server-LAN. Es
„meldet sich“ per autossh beim Server an und wird dort auf einem festen Port
erreichbar.
### Hintergrund: der SSH-Reverse-Tunnel
Auf der **Geräteseite** (z. B. im Portainer-Stack des Roboters) läuft ein
autossh-Container `appRobot_Tunnel`, der eine Dauerverbindung zu
`tunnel@server.schooltech.ch -p 2255` (= Container `appServer_TunnelHead`)
aufbaut. Pro Dienst eine Zeile:
```
-R 0.0.0.0:<HubPort>:<container>:<port>
```
Bedeutung: „Öffne am TunnelHead den Port `<HubPort>` und leite ihn an
`<container>:<port>` auf der Geräteseite weiter.“ Damit wird `<container>:<port>`
(Geräteseite) für nginx als **`appServer_TunnelHead:<HubPort>`** erreichbar und
`forwarding.conf` proxyt genau dorthin.
**Aktuelle Tunnel-Belegung** (aus dem autossh-Command des `appRobot_Tunnel`-Stacks):
| HubPort | → Geräteseite | Portal-Subdomain |
|---|---|---|
| 9703 | `portainer:9000` | `tcPortainer` |
| 9710 | `appRobot_Control:10010` | `tcControl` |
| 9712 | `appRobot_Simulation:1003` | `tcSimulation` |
| 9725 | `appRobot_AccessBase:443` | `robotBase` |
| 9726 | `appRobot_AccessEllbow:443` | `robotEllbow` |
| 9727 | `appRobot_AccessHand:443` | `robotHand` |
| 9743 | `AppRobotWebcam:8444` | `robotVideo` |
| 9744 | `appRobot_CodeServer:8443` | `robotVSCode` |
| 9780 | `appRobot_guacamole:8080` | `tcGuac` |
| 9793 | `appRobot_Homing:2093` | `robotHoming` |
| 9798 | `appRobot_Driver:2098` | `robotDriver` |
> Konvention der HubPorts: **81xx → RP3/SCARA**, **97xx → ThinkCentre/Roboter**,
> **99xx → InformatikWeb**. Das `0.0.0.0:` setzt voraus, dass am TunnelHead-sshd
> `GatewayPorts clientspecified` aktiv ist (ist es).
### Rezept A: neue Seite **über den Tunnel**
1. **Tunnel öffnen** geräteseitig im autossh-Command eine `-R`-Zeile ergänzen
(freien HubPort wählen), z. B.:
```
-R 0.0.0.0:9750:appRobot_NeuerDienst:8080 \
```
Stack neu deployen (Portainer). Der Dienst ist nun `appServer_TunnelHead:9750`.
2. **`forwarding.conf`** (Server) Zeile ergänzen; das Schema muss zu dem passen,
was das Backend spricht:
```
neuerdienst.server.schooltech.ch http://appServer_TunnelHead:9750 redirect true false
```
3. **Zertifikat** Domain in `letsEncrypt.sh` aufnehmen (**mit `--cert-name`!**)
und ausführen; ohne Zertifikat überspringt `connect-proxies.sh` den vHost.
4. **nginx neu starten:** `docker restart appServer_PortalUI`, dann im Log prüfen:
`[+] … Zertifikat OK … Erzeuge 443`.
5. **Optional im Portal-Menü zeigen:** Eintrag im `services`-Array in
`public/app.js`:
```js
{ id: "neu", name: "Neuer Dienst", url: "https://neuerdienst.server.schooltech.ch/" }
```
6. **Optional Login erzwingen:** in `forwarding.conf` 8. Spalte `… 443 true`.
### Rezept B: neue Seite **direkt** (lokaler Container / LAN-IP)
Wie A, nur **Schritt 1 entfällt**. Der Upstream in `forwarding.conf` zeigt direkt
auf den Container-Namen oder die LAN-IP (niemals `.local`):
```
neuerdienst.server.schooltech.ch http://192.168.0.50:8080 redirect true false
```
(Rest identisch: Zertifikat → Restart → optional `app.js`/Auth.)
### Eine Seite entfernen
1. Zeile in `forwarding.conf` löschen (oder mit `#` auskommentieren) und ggf. den
`app.js`-Eintrag entfernen → `docker restart appServer_PortalUI`.
2. Falls über Tunnel: die `-R`-Zeile aus dem `appRobot_Tunnel`-Stack entfernen und
neu deployen.
3. Optional Zertifikat aufräumen: `certbot delete --cert-name <domain>`.
> **Spalten-Kurzreferenz** der `forwarding.conf`:
> `server_name upstream_url http_behavior websockets verify_upstream_tls [cert_domain] [listen_port] [auth_required]`
> — Details siehe [`forwarding.conf` das Herzstück](#forwardingconf--das-herzstück).
---
## Dateiübersicht
```
appServerPortalUI/
├── docker-compose.yaml # Orchestrierung aller Container
├── nginx.conf # Basis-vHost (Port 80, ACME, /api/, SPA)
├── forwarding.conf # ← Dienst-Definitionen (Quelle der Wahrheit)
├── connect-proxies.sh # generiert die nginx-vHosts beim Start
├── letsEncrypt.sh # Zertifikate holen/erneuern
├── letsEncrypt_crontab.txt # Cron-Eintrag für Auto-Renewal
├── public/ # die Portal-Seite (server.schooltech.ch)
│ ├── index.html # Grundgerüst + Navigationsleiste
│ ├── app.js # Logik: Login, Dienst-Buttons, iFrame
│ └── style.css # Styling
├── auth/ # Auth-Service (Node/Express)
│ ├── auth.js # Login/Logout/Status/internal-auth
│ ├── users.json # Benutzer + bcrypt-Hashes
│ ├── cretePassword.js # Hilfsskript: neuen Hash erzeugen
│ └── package.json
├── letsencrypt/ # Zertifikate + ACME-Webroot
├── certs/ # (selbstsignierte) Zertifikate
├── nginxPages/ # Referenz-vHosts (NICHT gemountet)
└── doc/ # Doku, Diagramme & Notizen
├── Architektur.svg # Architektur-Diagramm (Quelle für PDF/PNG)
├── Portal.svg / Portal.pdf # Ansicht der Portal-Seite
└── AI_Gen.* , *_q?_*.txt # weitere Notizen / generierte Doku
```
> `forwarding_running_6_6_2026.conf` ist ein Snapshot der aktuell laufenden
> Konfiguration und wird hier bewusst (noch) nicht behandelt.