Wieder anfassen nach Monaten

This commit is contained in:
chk
2026-06-06 17:14:11 +02:00
parent 87557db09b
commit 263e9b4565
16 changed files with 705 additions and 0 deletions

352
README.md Normal file
View File

@@ -0,0 +1,352 @@
# 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) (daraus lässt
> sich bei Bedarf eine PDF/PNG erzeugen).
**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.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.
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).
- 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.
> **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.