# 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.
