23 KiB
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
- Die Portal-Seite (
server.schooltech.ch) - Die Dienste / Subdomains
forwarding.conf– das Herzstückconnect-proxies.sh– Konfig-Generator- Authentifizierung
- TLS / Let's Encrypt
- Container & Betrieb
- Eine neue Seite ins Portal aufnehmen
- 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:
Bild:
doc/Architektur.png· Vektor-Quelle:doc/Architektur.svg(für PDF/beliebige Skalierung).
Dieselbe Übersicht als ASCII-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*)
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#servicesund 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 anPOST /api/event), - Login/Logout laufen über
POST /api/loginbzw.POST /api/logout.
- hält eine Liste der verlinkten Dienste (
public/style.css– Styling der Leiste, der Buttons und des Login-Dialogs.
Ablauf aus Nutzersicht:
Oben die Navigationsleiste mit Logo und Dienst-Buttons, darunter der gewählte
Dienst im iFrame. Bild: doc/Portal.png · Quellen:
doc/Portal.svg, doc/Portal.pdf.
Layout-Skizze als ASCII-Text
┌──────────────────────────────────────────────────────────────┐
│ schooltech [ Control GamePad ][ Guacamole ][ Simulation ]… [Logout] │ ← Navigationsleiste
├──────────────────────────────────────────────────────────────┤
│ │
│ « ausgewählter Dienst im iFrame » │
│ │
└──────────────────────────────────────────────────────────────┘
- Seite öffnen → ist man nicht eingeloggt, zeigt der Button „Login“.
- Login (User/Passwort) → der Auth-Service setzt ein Session-Cookie für die
gesamte Domain
.server.schooltech.ch. - Die Navigationsleiste füllt sich mit den Dienst-Buttons.
- 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 ausforwarding.conferzeugt. Ein neuer Dienst inforwarding.conftaucht also erst dann in der Navigationsleiste auf, wenn er auch insservices-Array eingetragen wird (siehe 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:
- 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 perssl_reject_handshakeabweist (siehe Hinweis unten). - Alte generierte Configs löschen (
*-https.generated.conf,*-http-redirect.generated.conf). - Pro Dienst eine vHost-Datei erzeugen – mit Fallunterscheidung:
local→ statischer Server, derpublic/ausliefert (+/api/-Proxy zum Auth-Service).- Reverse-Proxy →
proxy_passauf den Upstream; optional WebSocket-Header, optionalproxy_ssl_verify, optionalauth_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 = redirectzusätzlich ein 80→443-Redirect (mit Ausnahme für die ACME-Challenge).
- Abschließend
nginx -tzur 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_namemit dem ersten 443-Block (alphabetisch – das warai.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-alllisten 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.shausforwarding.conferzeugt. Die statischen Dateien innginxPages/sind nicht indocker-compose.yamlgemountet und dienen nur als Referenz/Vorlage (z. B.10-server-schooltech.confzeigt 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 gegenauth/users.json(bcrypt-Hashes) und setzt bei Erfolg ein CookieSESSIONID.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 nginxauth_request: liefert200bei gültiger Session, sonst401.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 erzeugtauth/cretePassword.js.
TLS / Let's Encrypt
- Zertifikate liegen unter
letsencrypt/conf/live/<domain>/und werden in den nginx-Container gemountet (/etc/letsencrypt). letsEncrypt.shholt/erneuert percertbot certonly --webrootein Zertifikat pro Domain (über denappServer_LetsEncryptFetcher-Container, HTTP-01-Challenge unter/.well-known/acme-challenge/).letsEncrypt_crontab.txtenthä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:
# 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 wiethinkcentre.locallöst der Container nicht auf (Docker-DNS kann kein mDNS) → der Dienst wird zum 503-Platzhalter. Immer IP oder Tunnel verwenden.
- lokaler Container im Docker-Netz
- 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-sshdGatewayPorts clientspecifiedaktiv ist (ist es).
Rezept A: neue Seite über den Tunnel
- Tunnel öffnen – geräteseitig im autossh-Command eine
-R-Zeile ergänzen (freien HubPort wählen), z. B.:Stack neu deployen (Portainer). Der Dienst ist nun-R 0.0.0.0:9750:appRobot_NeuerDienst:8080 \appServer_TunnelHead:9750. 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- Zertifikat – Domain in
letsEncrypt.shaufnehmen (mit--cert-name!) und ausführen; ohne Zertifikat überspringtconnect-proxies.shden vHost. - nginx neu starten:
docker restart appServer_PortalUI, dann im Log prüfen:[+] … Zertifikat OK … Erzeuge 443. - Optional – im Portal-Menü zeigen: Eintrag im
services-Array inpublic/app.js:{ id: "neu", name: "Neuer Dienst", url: "https://neuerdienst.server.schooltech.ch/" } - Optional – Login erzwingen: in
forwarding.conf8. 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
- Zeile in
forwarding.conflöschen (oder mit#auskommentieren) und ggf. denapp.js-Eintrag entfernen →docker restart appServer_PortalUI. - Falls über Tunnel: die
-R-Zeile aus demappRobot_Tunnel-Stack entfernen und neu deployen. - 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 sieheforwarding.conf– 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.confist ein Snapshot der aktuell laufenden Konfiguration und wird hier bewusst (noch) nicht behandelt.