== PortalUI mit User-Verwaltung == Mein Server hat u.A. zwei Docker Container: Einmal das Portal, und einmal einen Authentifikations-Dienst. AppServerPortalUI: image: nginx:alpine container_name: appServer_PortalUI volumes: - /home/chk/Documents/appServerPortalUI/nginx.conf:/etc/nginx/conf.d/default.conf:ro - /home/chk/Documents/appServerPortalUI/public:/usr/share/nginx/html:ro # Let's Encrypt mounts - /home/chk/Documents/appServerPortalUI/letsencrypt/conf:/etc/letsencrypt:ro - /home/chk/Documents/appServerPortalUI/letsencrypt/www:/var/www/certbot:ro # PortForwarding-Script-etc - /home/chk/Documents/appServerPortalUI/forwarding.conf:/etc/nginx/forwarding.conf:ro - /home/chk/Documents/appServerPortalUI/connect-proxies.sh:/docker-entrypoint.d/40-connect-proxies.sh:ro ports: - "80:80" - "443:443" networks: - appRobotNet restart: unless-stopped command: ["nginx", "-g", "daemon off;"] AppServerAuth: image: node:24-alpine container_name: appServer_Auth volumes: - /home/chk/Documents/appServerPortalUI/auth:/usr/src/app working_dir: /usr/src/app command: sh -c "npm install && node auth.js" ports: - "10300:3000" # optional, für Tests networks: - default - appRobotNet restart: unless-stopped == /home/chk/Documents/appServerPortalUI/nginx.conf == # /etc/nginx/conf.d/default.conf # IMMER aktive HTTP-Konfiguration (Port 80) server { listen 80 default_server; listen [::]:80 default_server; # ACME HTTP-01 Challenge (Certbot) location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; default_type "text/plain; charset=utf-8"; } # Deine Default-Page root /usr/share/nginx/html; index index.html; location = / { try_files /index.html =404; } location / { try_files $uri $uri/ =404; } } == nginxPages/10-server-schooltech.conf == Soll die "Nutzlast" die Pages, die der User verwenden soll, weiterleiten. hier gibt es eine ganze reihe weiterer pages, die bereitstehen. server { listen 443 ssl http2; server_name server.schooltech.ch; ssl_certificate /etc/letsencrypt/live/server.schooltech.ch/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/server.schooltech.ch/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; root /usr/share/nginx/html; index index.html; # UI / SPA location / { try_files $uri $uri/ /index.html; } # API forwarding (auth) location /api/ { proxy_pass http://appserverauth:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # Internal auth endpoint for auth_request location = /nginxauth { internal; proxy_pass http://appserverauth:3000/internal/auth; proxy_set_header Cookie $http_cookie; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Host $host; proxy_set_header X-Forwarded-Host $host; } # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; } == nginxPages/50-subdomains-userA.conf == Für jeden User gibt es eine reihe von Pages, die eben hier weitergeleitet werden sollen. server { listen 443 ssl http2 default_server; server_name _; ssl_certificate /etc/letsencrypt/live/server.schooltech.ch/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/server.schooltech.ch/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; return 444; } # ------------------------------------------------------------ # portainer.server.schooltech.ch # ------------------------------------------------------------ server { listen 443 ssl http2; server_name portainer.server.schooltech.ch; ssl_certificate /etc/letsencrypt/live/server.schooltech.ch/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/server.schooltech.ch/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; # Auth nur auf UI location / { auth_request /nginxauth; proxy_pass http://portainer:9000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # iFrame-freundlich proxy_hide_header X-Frame-Options; add_header X-Frame-Options "ALLOWALL" always; proxy_hide_header Content-Security-Policy; add_header Content-Security-Policy "frame-ancestors *" always; } location = /nginxauth { internal; proxy_pass http://appserverauth:3000/internal/auth; proxy_set_header Cookie $http_cookie; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Host $host; proxy_set_header X-Forwarded-Host $host; } } # ------------------------------------------------------------ # abc.server.schooltech.ch # ------------------------------------------------------------ server { listen 443 ssl http2; server_name abc.server.schooltech.ch; ssl_certificate /etc/letsencrypt/live/server.schooltech.ch/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/server.schooltech.ch/privkey.pem; root /usr/share/nginx/abc; index index.html; location / { try_files $uri $uri/ /index.html; } } # ------------------------------------------------------------ # xyz.server.schooltech.ch # ------------------------------------------------------------ server { listen 443 ssl http2; server_name xyz.server.schooltech.ch; ssl_certificate /etc/letsencrypt/live/server.schooltech.ch/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/server.schooltech.ch/privkey.pem; root /usr/share/nginx/xyz; index index.html; location / { try_files $uri $uri/ /index.html; } } diese sind alle unter xxx.server.schooltech.ch erreichbar. Die ganze nginxPages/50-subdomains-userA.conf werden mit einem Script erstellt: == connect-proxies.sh ========== #!/bin/sh # /docker-entrypoint.d/40-connect-proxies.sh # Generiert pro Zeile in /etc/nginx/forwarding.conf: # - HTTPS vHost (Proxy oder local static) NUR wenn Zertifikate existieren # - optional 80->443 Redirect-Server (mit ACME-Ausnahme) bei http_behavior=redirect # Läuft automatisch beim Containerstart (nginx:alpine EntryPoint). set -eu CONF_DIR="/etc/nginx/conf.d" LIVE_DIR="/etc/letsencrypt/live" FWD_FILE="/etc/nginx/forwarding.conf" HTTPS_SUFFIX="-https.generated.conf" HTTP_REDIRECT_SUFFIX="-http-redirect.generated.conf" GLOBALS_FILE="$CONF_DIR/_globals.generated.conf" echo "[connect-proxies] start …" # 0) Forwarding-Datei vorhanden? if [ ! -f "$FWD_FILE" ]; then echo "[connect-proxies] WARN: $FWD_FILE fehlt – keine Proxies zu generieren." exit 0 fi # 1) Globale HTTP-Kontext-Map + Resolver (idempotent) # >>> CHANGE: Resolver NICHT hardcoden. Dynamisch aus /etc/resolv.conf ableiten, Fallback 127.0.0.11 RESOLVERS="$(awk '/^nameserver/{print $2}' /etc/resolv.conf | xargs || true)" if [ -n "${RESOLVERS:-}" ]; then RESOLVER_LINE="resolver $RESOLVERS ipv6=off valid=30s;" else RESOLVER_LINE="resolver 127.0.0.11 ipv6=off valid=30s;" fi cat > "$GLOBALS_FILE" </dev/null || true rm -f "$CONF_DIR/"*"$HTTP_REDIRECT_SUFFIX" 2>/dev/null || true # 3) Zeilen verarbeiten LINE_NO=0 while IFS= read -r RAW || [ -n "$RAW" ]; do LINE_NO=$((LINE_NO+1)) # trim + CR entfernen LINE="$(printf '%s' "$RAW" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" [ -z "$LINE" ] && continue case "$LINE" in \#*) continue;; esac # Spalten splitten (mindestens 2 erforderlich) set -- $LINE SERVER_NAME="${1:-}" UPSTREAM_URL="${2:-}" HTTP_BEHAVIOR="${3:-redirect}" WEBSOCKETS="${4:-false}" VERIFY_TLS="${5:-false}" CERT_DOMAIN="${6:-$SERVER_NAME}" LISTEN_PORT="${7:-443}" if [ -z "$SERVER_NAME" ] || [ -z "$UPSTREAM_URL" ]; then echo "[connect-proxies] WARN(Line $LINE_NO): unvollständig -> $LINE" continue fi FULLCHAIN="$LIVE_DIR/$CERT_DOMAIN/fullchain.pem" PRIVKEY="$LIVE_DIR/$CERT_DOMAIN/privkey.pem" HTTPS_OUT="$CONF_DIR/${SERVER_NAME}-p${LISTEN_PORT}${HTTPS_SUFFIX}" HTTP_REDIRECT_OUT="$CONF_DIR/${SERVER_NAME}${HTTP_REDIRECT_SUFFIX}" # >>> NEW: Upstream normalisieren (Trailing Slash entfernen) + DNS-Check vorbereiten SANITIZED_UPSTREAM="${UPSTREAM_URL%/}" DNS_OK="true" SCHEME=""; HOST=""; PORT="" if [ "$SANITIZED_UPSTREAM" != "local" ]; then case "$SANITIZED_UPSTREAM" in http://*) URI="${SANITIZED_UPSTREAM#http://}"; SCHEME="http"; DEFAULT_PORT="80" ;; https://*) URI="${SANITIZED_UPSTREAM#https://}"; SCHEME="https"; DEFAULT_PORT="443" ;; *) echo "[connect-proxies] WARN(Line $LINE_NO): $SERVER_NAME upstream_url ungültig: '$UPSTREAM_URL' – überspringe." continue ;; esac HOSTPORT="${URI%%/*}" HOST="${HOSTPORT%%:*}" PORT="${HOSTPORT#*:}"; [ "$PORT" = "$HOSTPORT" ] && PORT="$DEFAULT_PORT" if command -v getent >/dev/null 2>&1; then if ! getent hosts "$HOST" >/dev/null 2>&1; then DNS_OK="false" echo "[connect-proxies] [-] $SERVER_NAME: DNS nicht auflösbar ($HOST) – erzeuge 503-Placeholder statt Proxy." fi else DNS_OK="unknown" fi fi # <<< END NEW if [ -f "$FULLCHAIN" ] && [ -f "$PRIVKEY" ]; then echo "[connect-proxies] [+] $SERVER_NAME: Zertifikat OK (cert_domain=$CERT_DOMAIN). Erzeuge 443 …" # Fall A: local (statisch, kein proxy_pass) if [ "$SANITIZED_UPSTREAM" = "local" ]; then cat > "$HTTPS_OUT" <>> NEW: Zwei Pfade – DNS_OK=false => Placeholder; sonst Proxy mit Laufzeit-Resolver if [ "$DNS_OK" = "false" ]; then # 443 Placeholder – keine Proxy-Verbindung, saubere 503 cat > "$HTTPS_OUT" <Service temporarily unavailable

\$server_name nicht erreichbar

DNS-Auflösung fehlgeschlagen. Bitte später erneut versuchen.

"; } } NGINX else # 443 Proxy – DNS ok/unknown: Laufzeit-Auflösung + freundlicher 503 bei Downstreams cat > "$HTTPS_OUT" <>> CHANGE: variable proxy_pass -> DNS zur Laufzeit (verhindert nginx -t Crash) set \$target $SANITIZED_UPSTREAM; proxy_pass \$target; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; proxy_http_version 1.1; NGINX if [ "$WEBSOCKETS" = "true" ]; then cat >> "$HTTPS_OUT" <<'NGINX' proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; NGINX else cat >> "$HTTPS_OUT" <<'NGINX' proxy_set_header Upgrade ""; proxy_set_header Connection close; NGINX fi case "$SANITIZED_UPSTREAM" in https://*) if [ "$VERIFY_TLS" = "true" ]; then cat >> "$HTTPS_OUT" <<'NGINX' proxy_ssl_verify on; proxy_ssl_server_name on; NGINX else cat >> "$HTTPS_OUT" <<'NGINX' proxy_ssl_verify off; proxy_ssl_server_name on; NGINX fi ;; esac cat >> "$HTTPS_OUT" <<'NGINX' client_max_body_size 50m; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } location @service_down { default_type text/html; return 503 "Service temporarily unavailable

$server_name nicht erreichbar

Der Dienst ist momentan nicht verfügbar. Bitte später erneut versuchen.

"; } } NGINX fi # <<< END NEW fi # 80->443 Redirect-Server nur, wenn gewünscht if [ "$HTTP_BEHAVIOR" = "redirect" ]; then cat > "$HTTP_REDIRECT_OUT" <443 redirect for $SERVER_NAME server { listen 80; listen [::]:80; server_name $SERVER_NAME; # ACME-Ausnahme location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; default_type "text/plain; charset=utf-8"; } location / { return 301 https://\$host\$request_uri; } } NGINX else # Sicherstellen, dass kein alter Redirect liegen bleibt rm -f "$HTTP_REDIRECT_OUT" 2>/dev/null || true fi else echo "[connect-proxies] [-] $SERVER_NAME: keine Zertifikate (cert_domain=$CERT_DOMAIN). Entferne evtl. alte Confs." rm -f "$HTTPS_OUT" "$HTTP_REDIRECT_OUT" 2>/dev/null || true fi done < "$FWD_FILE" echo "[connect-proxies] nginx -t …" nginx -t echo "[connect-proxies] done." ===== wobei die daten dann im forwarding.conf stehen: # 444 → 8121 (WS/WSS) # fluidncRedWs.server.schooltech.ch http://appServer_TunnelHead:8121 redirect true false fluidncRedWs.server.schooltech.ch 444 server.schooltech.ch local static false false tcPortainer.server.schooltech.ch http://thinkcentre.local:9000 redirect true false tcGuac.server.schooltech.ch http://thinkcentre.local:9000 redirect true false #inf InformatiWeb ist per Tunnel angeschlossen. Soll auf 97xx Ports gehen infPortainer.server.schooltech.ch http://appServer_TunnelHead:9903 redirect true false infGuac.server.schooltech.ch http://appServer_TunnelHead:9980 redirect true false #RP5 ist "Lokal" der Server rp5Guac.server.schooltech.ch http://appServer_guacamole:8080 redirect true false rp5Portainer.server.schooltech.ch http://portainer:9000 redirect true false === Das PortalUI stellt neben den Weiterleitungen auch noch eine Navigations-Page zur Verfügung: index.html mit app.js Service Portal

server.schooltech.ch - Service Portal

// ==== FRONTEND app.js ==== // Service-Liste const services = [ { id: "abc", name: "Control GamePad", url: "https://abc.server.schooltech.ch/" }, { id: "xyz", name: "Guacamole", url: "https://xyz.server.schooltech.ch/" }, { id: "sim", name: "Simulation", url: "https://simulation.server.schooltech.ch/" }, { id: "portainer", name: "Portainer", url: "https://portainer.server.schooltech.ch/" } ]; // DOM-Elemente const iframe = document.getElementById("service-frame"); const loginModal = document.getElementById("login-modal"); const loginBtn = document.getElementById("login-btn"); const loginSubmit = document.getElementById("login-submit"); const loginMsg = document.getElementById("login-msg"); const nav = document.getElementById("services"); const usernameInput = document.getElementById("username"); const passwordInput = document.getElementById("password"); let loggedIn = false; // =========================== // Login anzeigen // =========================== function switchToLogin() { loginBtn.textContent = "Login"; loginBtn.onclick = () => { loginModal.style.display = "block"; }; } // =========================== // Logout anzeigen // =========================== function switchToLogout() { loginBtn.textContent = "Logout"; loginBtn.onclick = async () => { try { await fetch("/api/logout", { method: "POST" }); } catch (e) { console.warn("Logout request failed:", e); } performLocalLogout(); }; } // =========================== // Lokales Logout // =========================== function performLocalLogout() { loggedIn = false; iframe.src = ""; iframe.style.display = "none"; nav.innerHTML = ""; loginModal.style.display = "block"; switchToLogin(); } // =========================== // Login-Logik // =========================== async function doLogin() { const user = usernameInput.value; const pass = passwordInput.value; try { const res = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user, pass }) }); if (res.ok) { loggedIn = true; loginModal.style.display = "none"; loginMsg.textContent = ""; setupServiceButtons(); switchToLogout(); } else { loginMsg.textContent = "Login fehlgeschlagen"; } } catch (e) { loginMsg.textContent = "Fehler: " + e.message; } } loginSubmit.onclick = doLogin; // Enter-Taste Login [usernameInput, passwordInput].forEach(input => { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doLogin(); } }); }); // =========================== // Buttons erzeugen // =========================== function setupServiceButtons() { nav.innerHTML = ""; services.forEach(svc => { const btn = document.createElement("button"); btn.textContent = svc.name; btn.onclick = async () => { try { await fetch('/api/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'open', service: svc.id, url: svc.url }) }); } catch(e) { console.error('Event log failed', e); } openService(svc); }; nav.appendChild(btn); }); } // =========================== // Service öffnen // =========================== function openService(svc) { iframe.src = svc.url; iframe.style.display = "block"; window.scrollTo(0,0); } // =========================== // Session Status beim Laden prüfen // =========================== (async function checkStatus() { try { const r = await fetch('/api/status'); if (r.ok) { loggedIn = true; setupServiceButtons(); switchToLogout(); } else { switchToLogin(); } } catch (e) { switchToLogin(); } })(); app.js muss sicherlich noch ausgebaut werden. Hier sind nur ein Bruchteil der relevanten Pages verlinkt. Aber das kann später erfolgen. == auth/auth.js import express from "express"; import cookieParser from "cookie-parser"; import bcrypt from "bcrypt"; import fs from "fs"; import crypto from "crypto"; const USERS = JSON.parse(fs.readFileSync("./users.json")); const SESSIONS = {}; // in-memory session store const app = express(); app.use(express.json()); app.use(cookieParser()); app.post("/api/login", async (req,res)=>{ const { user, pass } = req.body; console.log(`Auth-Service login attempt for ${user}`); const hash = USERS[user]; if(!hash) return res.status(401).send({ ok:false }); const valid = await bcrypt.compare(pass, hash); if(!valid) return res.status(401).send({ ok:false }); // create secure random session const sessionID = crypto.randomBytes(32).toString("hex"); SESSIONS[sessionID] = { user, created: Date.now() }; res.cookie("SESSIONID", sessionID, { httpOnly: true, secure: true, domain: ".server.schooltech.ch", sameSite: "None", path: "/" }); res.status(200).send({ ok:true }); }); // Logout endpoint app.post("/api/logout", (req, res) => { const sid = req.cookies.SESSIONID; if (sid && SESSIONS[sid]) { delete SESSIONS[sid]; } // Cookie löschen res.clearCookie("SESSIONID", { httpOnly: true, secure: true, domain: ".server.schooltech.ch", sameSite: "None", path: "/" }); return res.status(200).send({ ok: true }); }); // Event logging endpoint for frontend button presses app.post('/api/event', (req,res)=>{ const svc = req.body.service || req.body.action || 'unknown'; const user = req.cookies.SESSIONID || 'anonymous'; console.log(`Event: user=${user} service=${svc} payload=${JSON.stringify(req.body)}`); res.status(200).send({ ok:true }); }); // Optional für Nginx auth_request app.get("/internal/auth", (req,res)=>{ const sid = req.cookies.SESSIONID; if (sid && SESSIONS[sid]) { return res.sendStatus(200); } return res.sendStatus(401); }); // Status endpoint (unter /api so dass Nginx /api/ auf appserverauth proxyt) app.get("/api/status", (req, res) => { const sid = req.cookies.SESSIONID; if (sid && SESSIONS[sid]) { return res.status(200).send({ ok: true, user: SESSIONS[sid].user }); } return res.status(401).send({ ok: false }); }); app.listen(3000, ()=>console.log("Auth-Service läuft auf 3000")); ============== ============== Das läuft alles. Das ist leicht erweiterbar. Und soll eigentlich nicht geändert werden. Aber ich brauche Authentifikation. User sollen sich einmal einloggen, wenn sei auf die portalUI Index.html kommen. Das ist ja unter server.schooltech.ch erreichbar. Danach soll für alle weiteren xyzABC.server.schooltech.ch Pages die Identifikation akzeptiert werden. Soweit ich das sehe, kann ich eine cookie basierte Identifikation auf server.schooltech.ch laufen lassen, und dann später genau diese cookies in den einzelnen Pages überprüfen. Fragen: 1) ist der aufbau für dich einigermassen klar? Stell Fragen falls etwas unklar ist 2) Wie kann ich die Page server.schooltech.ch Passwort-Schützen? Was muss ich einstellen, dass per default nur die "Login" Option kommt, und sonst keine Links angezeigt werden?