Files
appServerPortalUI/README.md
2026-06-06 18:08:24 +02:00

364 lines
16 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)
- [Neuen Dienst hinzufügen](#neuen-dienst-hinzufügen)
- [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**:
![Architektur-Übersicht](doc/Architektur.svg)
> Diagramm-Quelle: [`doc/Architektur.svg`](doc/Architektur.svg) — auch als
> [`doc/Architektur.png`](doc/Architektur.png) (für Viewer ohne SVG-Support).
**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:**
![Portal-Ansicht](doc/Portal.svg)
*Oben die Navigationsleiste mit Logo und Dienst-Buttons, darunter der gewählte
Dienst im iFrame. (Quelle: [`doc/Portal.svg`](doc/Portal.svg) — auch als
[`doc/Portal.png`](doc/Portal.png) und [`doc/Portal.pdf`](doc/Portal.pdf).)*
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
> [Neuen Dienst hinzufügen](#neuen-dienst-hinzufügen)).
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
```
---
## Neuen Dienst hinzufügen
1. **Zeile in `forwarding.conf`** ergänzen, z. B.:
```
meinDienst.server.schooltech.ch http://appServer_TunnelHead:9999 redirect true false
```
2. **DNS:** sicherstellen, dass die Subdomain auf den Server zeigt
(Wildcard `*.server.schooltech.ch` oder A-Record).
3. **Zertifikat** holen Domain in `letsEncrypt.sh` aufnehmen und Skript laufen
lassen (sonst überspringt `connect-proxies.sh` den vHost).
4. **nginx neu starten:** `docker restart appServer_PortalUI`.
5. **Optional im Portal sichtbar machen:** Eintrag im `services`-Array in
`public/app.js` ergänzen:
```js
{ id: "mein", name: "Mein Dienst", url: "https://meinDienst.server.schooltech.ch/" }
```
6. **Optional Login erzwingen:** in `forwarding.conf` als 8. Spalte
`… server.schooltech.ch 443 true` setzen.
---
## 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.