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

813
doc/2026_03_21___q1_Auth.txt Executable file
View File

@@ -0,0 +1,813 @@
== 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" <<NGINX
# Automatisch generiert nicht editieren
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
$RESOLVER_LINE
NGINX
# <<< END CHANGE
# 2) Alte generierte Confs entfernen
rm -f "$CONF_DIR/"*"$HTTPS_SUFFIX" 2>/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" <<NGINX
# Auto-generated - 443 static site
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /usr/share/nginx/html;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
}
NGINX
else
# >>> 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" <<NGINX
# Auto-generated - 443 placeholder (DNS failed)
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
default_type text/html;
return 503 "<!doctype html><html><head><meta charset='utf-8'><title>Service temporarily unavailable</title></head><body style='font-family:sans-serif;margin:3rem'><h1>\$server_name nicht erreichbar</h1><p>DNS-Auflösung fehlgeschlagen. Bitte später erneut versuchen.</p></body></html>";
}
}
NGINX
else
# 443 Proxy DNS ok/unknown: Laufzeit-Auflösung + freundlicher 503 bei Downstreams
cat > "$HTTPS_OUT" <<NGINX
# Auto-generated - 443 reverse proxy
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Fehler sauber abfangen und 503 liefern (statt rohe 502/504)
proxy_intercept_errors on;
error_page 502 503 504 = @service_down;
location / {
# >>> 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 "<!doctype html><html><head><meta charset='utf-8'><title>Service temporarily unavailable</title></head><body style='font-family:sans-serif;margin:3rem'><h1>$server_name nicht erreichbar</h1><p>Der Dienst ist momentan nicht verfügbar. Bitte später erneut versuchen.</p></body></html>";
}
}
NGINX
fi
# <<< END NEW
fi
# 80->443 Redirect-Server nur, wenn gewünscht
if [ "$HTTP_BEHAVIOR" = "redirect" ]; then
cat > "$HTTP_REDIRECT_OUT" <<NGINX
# Auto-generated 80->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
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>Service Portal</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header id="header">
<div class="logo">schooltech</div>
<nav id="services"></nav>
<div class="user">
<button id="login-btn">Login</button>
</div>
</header>
<main>
<h1>server.schooltech.ch - Service Portal</h1>
<!-- iFrame für Services -->
<iframe id="service-frame" style="width:100%;height:80vh;display:none;"></iframe>
<!-- Login Modal -->
<div id="login-modal" style="display:none;">
<label>User: <input type="text" id="username"/></label>
<label>Pass: <input type="password" id="password"/></label>
<button id="login-submit">Login</button>
<div id="login-msg"></div>
</div>
</main>
<script src="app.js"></script>
</body>
</html>
// ==== 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?

414
doc/2026_03_21___q2_WS.txt Executable file
View File

@@ -0,0 +1,414 @@
Ich habe einen weitergeleitete Applikation, bei dem der WSS momentan nicht durch kmmt.
https://tccontrol.server.schooltech.ch/
WebService.js:124 WebSocket connection to 'wss://tccontrol.server.schooltech.ch/echo' failed:
_connect @ WebService.js:124
(anonymous) @ WebService.js:179Understand this error
WebService.js:184 WebSocket error: Event {isTrusted: true, type: 'error', target: WebSocket, currentTarget: WebSocket, eventPhase: 2, …}
Aufbau des Systems: NGinx in docker container soll auf Tunnel weiter leiten. Service der erreicht werden soll, ist unter "https://thinkcentre.local:10010/" wunderbar erreichbar.
Von dort soll es über einen Tunnel gehen:
appRobot_Tunnel:
image: alpine:latest
container_name: appRobot_Tunnel
restart: unless-stopped
environment:
- TZ=Europe/Zurich
volumes:
- /home/chk/Documents/AppServerPortalUI/.ssh:/root/.ssh:ro
command: >
/bin/sh -c "
apk add --no-cache openssh-client autossh &&
autossh -M 0 -N -o StrictHostKeyChecking=no \
-i /root/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=10 \
-o ExitOnForwardFailure=yes \
-N \
-R 0.0.0.0:9780:appRobot_guacamole:8080 \
-R 0.0.0.0:9710:appRobot_Control:10010 \
-R 0.0.0.0:9703:portainer:9000 \
-R 0.0.0.0:9712:appRobot_Simulation:1003\
-R 0.0.0.0:9793:appRobot_Homing:2093 \
tunnel@server.schooltech.ch -p 2255
"
und dann vom nginx verarbeitet werden, denn der nginx ist von aussen per server.schooltech.ch erreichbar.
Dazu:
== nginx.conf ==
# /etc/nginx/conf.d/default.conf
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";
}
# PortalUI root
root /usr/share/nginx/html;
index index.html;
# === API forwarding for Auth-Service ===
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;
}
# === SPA routing (Portal UI) ===
location / {
try_files $uri $uri/ /index.html;
}
}
sowie die verschiedenen subdomains, im 50-subdomains-userA.conf, die erzeugt werden über
== 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" <<NGINX
# Automatisch generiert nicht editieren
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
$RESOLVER_LINE
NGINX
# <<< END CHANGE
# 2) Alte generierte Confs entfernen
rm -f "$CONF_DIR/"*"$HTTPS_SUFFIX" 2>/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" <<NGINX
# Auto-generated - 443 static site
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /usr/share/nginx/html;
index index.html;
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;
}
location / {
try_files \$uri \$uri/ /index.html;
}
}
NGINX
else
# >>> 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" <<NGINX
# Auto-generated - 443 placeholder (DNS failed)
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
default_type text/html;
return 503 "<!doctype html><html><head><meta charset='utf-8'><title>Service temporarily unavailable</title></head><body style='font-family:sans-serif;margin:3rem'><h1>\$server_name nicht erreichbar</h1><p>DNS-Auflösung fehlgeschlagen. Bitte später erneut versuchen.</p></body></html>";
}
}
NGINX
else
# 443 Proxy DNS ok/unknown: Laufzeit-Auflösung + freundlicher 503 bei Downstreams
cat > "$HTTPS_OUT" <<NGINX
# Auto-generated - 443 reverse proxy
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Fehler sauber abfangen und 503 liefern (statt rohe 502/504)
proxy_intercept_errors on;
error_page 502 503 504 = @service_down;
location / {
# >>> 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 "<!doctype html><html><head><meta charset='utf-8'><title>Service temporarily unavailable</title></head><body style='font-family:sans-serif;margin:3rem'><h1>$server_name nicht erreichbar</h1><p>Der Dienst ist momentan nicht verfügbar. Bitte später erneut versuchen.</p></body></html>";
}
}
NGINX
fi
# <<< END NEW
fi
# 80->443 Redirect-Server nur, wenn gewünscht
if [ "$HTTP_BEHAVIOR" = "redirect" ]; then
cat > "$HTTP_REDIRECT_OUT" <<NGINX
# Auto-generated 80->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."
das connect proxies verwendet:
=== forwaring.conf ===
# server_name upstream_url http_behavior websockets verify_upstream_tls [cert_domain]
#fluidncRed.server.schooltech.ch http://appServer_TunnelHead:8120 redirect true false
# 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
server.schooltech.ch local redirect false false server.schooltech.ch 443
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
#RP3 ist Raspi für die Scara-Robots, per Tunnel angeschlossen. Er hat 81xx Ports am TunnelHead
rp3Portainer.server.schooltech.ch http://appServer_TunnelHead:8100 redirect true false
rp3Guac.server.schooltech.ch http://appServer_TunnelHead:8180 redirect true false
fluidncRed.server.schooltech.ch http://appServer_TunnelHead:8120 redirect true false
fluidncWhite.server.schooltech.ch https://appServer_TunnelHead:8104 redirect true false
# ThinkCentre ist ein MiniPC der neben dem einen Roboter steht. Hier sind die 97xx Ports zugewiesen
tcGuac.server.schooltech.ch http://appServer_TunnelHead:9780 redirect false false
tcPortainer.server.schooltech.ch http://appServer_TunnelHead:9703 redirect false false
tcSimulation.server.schooltech.ch https://appServer_TunnelHead:9712 redirect false false
#tcVideocontroller.server.schooltech.ch https://tcvideo:9443 redirect true false
robotHoming.server.schooltech.ch https://appServer_TunnelHead:9793 redirect false false
tcControl.server.schooltech.ch https://appServer_TunnelHead:9710 redirect false false
# Beispiel mit abweichendem Zertifikats-Ordner (Lineage-Suffix)
# tcGuac.server.schooltech.ch https://guac:8443 redirect true false server.schooltech.ch-0002
# Beispiel für WS auf port+1 (zwei Einträge, einer nur für WS-Endpunkt)
# wsApp.server.schooltech.ch https://wsapp:443 redirect true false
===============
Jetzt die Frage:
1) Siehst du, wie es aufgebaut ist? Siehst du, wie die adresse vom nginx weiter geleitet wird? Hast du fragen dazu?
Die weiterleitung läuft. ich sehe die WebPage. Nur der WSS kommt nicht durch
2) siehst du, woran das liegt, dass der WSS nicht durch kommt?

358
doc/2026_03_21___q3_Auth.txt Executable file
View File

@@ -0,0 +1,358 @@
die Authentifizierung von server.schooltech.ch funktioniert. Ich komme wie gewünscht nur mit aktivem
cookie rein.
# /etc/nginx/conf.d/default.conf
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";
}
# PortalUI root
root /usr/share/nginx/html;
index index.html;
# === API forwarding for Auth-Service ===
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;
}
# === SPA routing (Portal UI) ===
location / {
try_files $uri $uri/ /index.html;
}
}
Die SubDomains werden in 50-subdomains-userA.conf verwaltet. Und bei diesen Subdomains sollen einige auch nur mit aktivem cookie erreichbar sein.
Aber nicht alle. Deshalb: Eine weitere spalte in der forwarding.conf datei.
#!/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).
#
#
# Die Datei: connect-proxies.sh läuft, wenn nginx docker container startet
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" <<NGINX
# Automatisch generiert nicht editieren
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
$RESOLVER_LINE
NGINX
# <<< END CHANGE
# 2) Alte generierte Confs entfernen
rm -f "$CONF_DIR/"*"$HTTPS_SUFFIX" 2>/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" <<NGINX
# Auto-generated - 443 static site
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /usr/share/nginx/html;
index index.html;
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;
}
location / {
try_files \$uri \$uri/ /index.html;
}
}
NGINX
else
# >>> 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" <<NGINX
# Auto-generated - 443 placeholder (DNS failed)
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
default_type text/html;
return 503 "<!doctype html><html><head><meta charset='utf-8'><title>Service temporarily unavailable</title></head><body style='font-family:sans-serif;margin:3rem'><h1>\$server_name nicht erreichbar</h1><p>DNS-Auflösung fehlgeschlagen. Bitte später erneut versuchen.</p></body></html>";
}
}
NGINX
else
# 443 Proxy DNS ok/unknown: Laufzeit-Auflösung + freundlicher 503 bei Downstreams
cat > "$HTTPS_OUT" <<NGINX
# Auto-generated - 443 reverse proxy
server {
listen ${LISTEN_PORT} ssl http2;
listen [::]:${LISTEN_PORT} ssl http2;
server_name $SERVER_NAME;
ssl_certificate $FULLCHAIN;
ssl_certificate_key $PRIVKEY;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Fehler sauber abfangen und 503 liefern (statt rohe 502/504)
proxy_intercept_errors on;
error_page 502 503 504 = @service_down;
location / {
# >>> 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 "<!doctype html><html><head><meta charset='utf-8'><title>Service temporarily unavailable</title></head><body style='font-family:sans-serif;margin:3rem'><h1>$server_name nicht erreichbar</h1><p>Der Dienst ist momentan nicht verfügbar. Bitte später erneut versuchen.</p></body></html>";
}
}
NGINX
fi
# <<< END NEW
fi
# 80->443 Redirect-Server nur, wenn gewünscht
if [ "$HTTP_BEHAVIOR" = "redirect" ]; then
cat > "$HTTP_REDIRECT_OUT" <<NGINX
# Auto-generated 80->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."
# server_name upstream_url http_behavior websockets verify_upstream_tls [cert_domain]
#fluidncRed.server.schooltech.ch http://appServer_TunnelHead:8120 redirect true false
# 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
server.schooltech.ch local redirect false false server.schooltech.ch 443
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
#RP3 ist Raspi für die Scara-Robots, per Tunnel angeschlossen. Er hat 81xx Ports am TunnelHead
rp3Portainer.server.schooltech.ch http://appServer_TunnelHead:8100 redirect true false
rp3Guac.server.schooltech.ch http://appServer_TunnelHead:8180 redirect true false
fluidncRed.server.schooltech.ch http://appServer_TunnelHead:8120 redirect true false
fluidncWhite.server.schooltech.ch https://appServer_TunnelHead:8104 redirect true false
============
Fragen:
1) ist der aufbau einigermassen klar?
2) weisst du, wo die User-Authentifizierung eingebaut wereden könnte?
3) Ist es sinnvoll, das in der forwaring.conf abzuspeichern?

24
doc/AI_Gen.aux Executable file
View File

@@ -0,0 +1,24 @@
\relax
\providecommand\hyper@newdestlabel[2]{}
\providecommand\HyField@AuxAddToFields[1]{}
\providecommand\HyField@AuxAddToCoFields[2]{}
\@writefile{toc}{\contentsline {section}{\numberline {1}Zielsetzung}{1}{section.1}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {2}Architekturübersicht (final)}{1}{section.2}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {2.1}Domänenstruktur}{1}{subsection.2.1}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {2.2}High-Level Architektur}{1}{subsection.2.2}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {2.3}Zentrale Prinzipien}{1}{subsection.2.3}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {3}Authentifikation (Variante A: auth\_request)}{2}{section.3}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {3.1}Prinzip}{2}{subsection.3.1}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {3.2}Ablauf}{2}{subsection.3.2}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {3.3}Header-Weitergabe}{2}{subsection.3.3}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {4}Nginx Konfiguration}{2}{section.4}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {4.1}Wildcard Server Block}{2}{subsection.4.1}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {4.2}Upstream Mapping}{3}{subsection.4.2}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {5}Docker Compose (Übersicht)}{3}{section.5}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {6}UI-Konzept (Navigations-Portal)}{3}{section.6}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {6.1}Funktion}{3}{subsection.6.1}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {6.2}UI-Layout}{4}{subsection.6.2}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {6.3}HTML-Prototyp (Portal)}{4}{subsection.6.3}\protected@file@percent }
\@writefile{toc}{\contentsline {subsection}{\numberline {6.4}Portal als Docker Container}{5}{subsection.6.4}\protected@file@percent }
\@writefile{toc}{\contentsline {section}{\numberline {7}Zusammenfassung}{5}{section.7}\protected@file@percent }
\gdef \@abspage@last{5}

370
doc/AI_Gen.log Executable file
View File

@@ -0,0 +1,370 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.27 (MiKTeX 25.4) (preloaded format=pdflatex 2025.6.3) 2 FEB 2026 04:34
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
**./AI_Gen.tex
(AI_Gen.tex
LaTeX2e <2024-11-01> patch level 2
L3 programming layer <2025-04-29>
(C:\Program Files\MiKTeX\tex/latex/base\article.cls
Document Class: article 2024/06/29 v1.4n Standard LaTeX document class
(C:\Program Files\MiKTeX\tex/latex/base\size11.clo
File: size11.clo 2024/06/29 v1.4n Standard LaTeX file (size option)
)
\c@part=\count272
\c@section=\count273
\c@subsection=\count274
\c@subsubsection=\count275
\c@paragraph=\count276
\c@subparagraph=\count277
\c@figure=\count278
\c@table=\count279
\abovecaptionskip=\skip49
\belowcaptionskip=\skip50
\bibindent=\dimen146
)
(C:\Program Files\MiKTeX\tex/latex/base\inputenc.sty
Package: inputenc 2024/02/08 v1.3d Input encoding file
\inpenc@prehook=\toks17
\inpenc@posthook=\toks18
)
(C:\Program Files\MiKTeX\tex/latex/base\fontenc.sty
Package: fontenc 2021/04/29 v2.0v Standard LaTeX package
)
(C:\Program Files\MiKTeX\tex/latex/geometry\geometry.sty
Package: geometry 2020/01/02 v5.9 Page Geometry
(C:\Program Files\MiKTeX\tex/latex/graphics\keyval.sty
Package: keyval 2022/05/29 v1.15 key=value parser (DPC)
\KV@toks@=\toks19
)
(C:\Program Files\MiKTeX\tex/generic/iftex\ifvtex.sty
Package: ifvtex 2019/10/25 v1.7 ifvtex legacy package. Use iftex instead.
(C:\Program Files\MiKTeX\tex/generic/iftex\iftex.sty
Package: iftex 2024/12/12 v1.0g TeX engine tests
))
\Gm@cnth=\count280
\Gm@cntv=\count281
\c@Gm@tempcnt=\count282
\Gm@bindingoffset=\dimen147
\Gm@wd@mp=\dimen148
\Gm@odd@mp=\dimen149
\Gm@even@mp=\dimen150
\Gm@layoutwidth=\dimen151
\Gm@layoutheight=\dimen152
\Gm@layouthoffset=\dimen153
\Gm@layoutvoffset=\dimen154
\Gm@dimlist=\toks20
(C:\Program Files\MiKTeX\tex/latex/geometry\geometry.cfg))
(C:\Program Files\MiKTeX\tex/latex/hyperref\hyperref.sty
Package: hyperref 2025-05-20 v7.01m Hypertext links for LaTeX
(C:\Program Files\MiKTeX\tex/latex/kvsetkeys\kvsetkeys.sty
Package: kvsetkeys 2022-10-05 v1.19 Key value parser (HO)
)
(C:\Program Files\MiKTeX\tex/generic/kvdefinekeys\kvdefinekeys.sty
Package: kvdefinekeys 2019-12-19 v1.6 Define keys (HO)
)
(C:\Program Files\MiKTeX\tex/generic/pdfescape\pdfescape.sty
Package: pdfescape 2019/12/09 v1.15 Implements pdfTeX's escape features (HO)
(C:\Program Files\MiKTeX\tex/generic/ltxcmds\ltxcmds.sty
Package: ltxcmds 2023-12-04 v1.26 LaTeX kernel commands for general use (HO)
)
(C:\Program Files\MiKTeX\tex/generic/pdftexcmds\pdftexcmds.sty
Package: pdftexcmds 2020-06-27 v0.33 Utility functions of pdfTeX for LuaTeX (HO
)
(C:\Program Files\MiKTeX\tex/generic/infwarerr\infwarerr.sty
Package: infwarerr 2019/12/03 v1.5 Providing info/warning/error messages (HO)
)
Package pdftexcmds Info: \pdf@primitive is available.
Package pdftexcmds Info: \pdf@ifprimitive is available.
Package pdftexcmds Info: \pdfdraftmode found.
))
(C:\Program Files\MiKTeX\tex/latex/hycolor\hycolor.sty
Package: hycolor 2020-01-27 v1.10 Color options for hyperref/bookmark (HO)
)
(C:\Program Files\MiKTeX\tex/latex/hyperref\nameref.sty
Package: nameref 2023-11-26 v2.56 Cross-referencing by name of section
(C:\Program Files\MiKTeX\tex/latex/refcount\refcount.sty
Package: refcount 2019/12/15 v3.6 Data extraction from label references (HO)
)
(C:\Program Files\MiKTeX\tex/generic/gettitlestring\gettitlestring.sty
Package: gettitlestring 2019/12/15 v1.6 Cleanup title references (HO)
(C:\Program Files\MiKTeX\tex/latex/kvoptions\kvoptions.sty
Package: kvoptions 2022-06-15 v3.15 Key value format for package options (HO)
))
\c@section@level=\count283
)
(C:\Program Files\MiKTeX\tex/latex/etoolbox\etoolbox.sty
Package: etoolbox 2025/02/11 v2.5l e-TeX tools for LaTeX (JAW)
\etb@tempcnta=\count284
)
(C:\Program Files\MiKTeX\tex/generic/stringenc\stringenc.sty
Package: stringenc 2019/11/29 v1.12 Convert strings between diff. encodings (HO
)
)
\@linkdim=\dimen155
\Hy@linkcounter=\count285
\Hy@pagecounter=\count286
(C:\Program Files\MiKTeX\tex/latex/hyperref\pd1enc.def
File: pd1enc.def 2025-05-20 v7.01m Hyperref: PDFDocEncoding definition (HO)
Now handling font encoding PD1 ...
... no UTF-8 mapping file for font encoding PD1
)
(C:\Program Files\MiKTeX\tex/generic/intcalc\intcalc.sty
Package: intcalc 2019/12/15 v1.3 Expandable calculations with integers (HO)
)
\Hy@SavedSpaceFactor=\count287
(C:\Program Files\MiKTeX\tex/latex/hyperref\puenc.def
File: puenc.def 2025-05-20 v7.01m Hyperref: PDF Unicode definition (HO)
Now handling font encoding PU ...
... no UTF-8 mapping file for font encoding PU
)
Package hyperref Info: Hyper figures OFF on input line 4157.
Package hyperref Info: Link nesting OFF on input line 4162.
Package hyperref Info: Hyper index ON on input line 4165.
Package hyperref Info: Plain pages OFF on input line 4172.
Package hyperref Info: Backreferencing OFF on input line 4177.
Package hyperref Info: Implicit mode ON; LaTeX internals redefined.
Package hyperref Info: Bookmarks ON on input line 4424.
\c@Hy@tempcnt=\count288
(C:\Program Files\MiKTeX\tex/latex/url\url.sty
\Urlmuskip=\muskip17
Package: url 2013/09/16 ver 3.4 Verb mode for urls, etc.
)
LaTeX Info: Redefining \url on input line 4763.
\XeTeXLinkMargin=\dimen156
(C:\Program Files\MiKTeX\tex/generic/bitset\bitset.sty
Package: bitset 2019/12/09 v1.3 Handle bit-vector datatype (HO)
(C:\Program Files\MiKTeX\tex/generic/bigintcalc\bigintcalc.sty
Package: bigintcalc 2019/12/15 v1.5 Expandable calculations on big integers (HO
)
))
\Fld@menulength=\count289
\Field@Width=\dimen157
\Fld@charsize=\dimen158
Package hyperref Info: Hyper figures OFF on input line 6042.
Package hyperref Info: Link nesting OFF on input line 6047.
Package hyperref Info: Hyper index ON on input line 6050.
Package hyperref Info: backreferencing OFF on input line 6057.
Package hyperref Info: Link coloring OFF on input line 6062.
Package hyperref Info: Link coloring with OCG OFF on input line 6067.
Package hyperref Info: PDF/A mode OFF on input line 6072.
(C:\Program Files\MiKTeX\tex/latex/base\atbegshi-ltx.sty
Package: atbegshi-ltx 2021/01/10 v1.0c Emulation of the original atbegshi
package with kernel methods
)
\Hy@abspage=\count290
\c@Item=\count291
\c@Hfootnote=\count292
)
Package hyperref Info: Driver (autodetected): hpdftex.
(C:\Program Files\MiKTeX\tex/latex/hyperref\hpdftex.def
File: hpdftex.def 2025-05-20 v7.01m Hyperref driver for pdfTeX
\Fld@listcount=\count293
\c@bookmark@seq@number=\count294
(C:\Program Files\MiKTeX\tex/latex/rerunfilecheck\rerunfilecheck.sty
Package: rerunfilecheck 2022-07-10 v1.10 Rerun checks for auxiliary files (HO)
(C:\Program Files\MiKTeX\tex/latex/base\atveryend-ltx.sty
Package: atveryend-ltx 2020/08/19 v1.0a Emulation of the original atveryend pac
kage
with kernel methods
)
(C:\Program Files\MiKTeX\tex/generic/uniquecounter\uniquecounter.sty
Package: uniquecounter 2019/12/15 v1.4 Provide unlimited unique counter (HO)
)
Package uniquecounter Info: New unique counter `rerunfilecheck' on input line 2
85.
)
\Hy@SectionHShift=\skip51
)
(C:\Program Files\MiKTeX\tex/latex/graphics\graphicx.sty
Package: graphicx 2021/09/16 v1.2d Enhanced LaTeX Graphics (DPC,SPQR)
(C:\Program Files\MiKTeX\tex/latex/graphics\graphics.sty
Package: graphics 2024/08/06 v1.4g Standard LaTeX Graphics (DPC,SPQR)
(C:\Program Files\MiKTeX\tex/latex/graphics\trig.sty
Package: trig 2023/12/02 v1.11 sin cos tan (DPC)
)
(C:\Program Files\MiKTeX\tex/latex/graphics-cfg\graphics.cfg
File: graphics.cfg 2016/06/04 v1.11 sample graphics configuration
)
Package graphics Info: Driver file: pdftex.def on input line 106.
(C:\Program Files\MiKTeX\tex/latex/graphics-def\pdftex.def
File: pdftex.def 2024/04/13 v1.2c Graphics/color driver for pdftex
))
\Gin@req@height=\dimen159
\Gin@req@width=\dimen160
)
(C:\Program Files\MiKTeX\tex/latex/tools\longtable.sty
Package: longtable 2024-10-27 v4.22 Multi-page Table package (DPC)
\LTleft=\skip52
\LTright=\skip53
\LTpre=\skip54
\LTpost=\skip55
\LTchunksize=\count295
\LTcapwidth=\dimen161
\LT@head=\box53
\LT@firsthead=\box54
\LT@foot=\box55
\LT@lastfoot=\box56
\LT@gbox=\box57
\LT@cols=\count296
\LT@rows=\count297
\c@LT@tables=\count298
\c@LT@chunks=\count299
\LT@p@ftn=\toks21
)
(C:\Program Files\MiKTeX\tex/latex/base\textcomp.sty
Package: textcomp 2024/04/24 v2.1b Standard LaTeX package
)
(C:\Program Files\MiKTeX\tex/latex/l3backend\l3backend-pdftex.def
File: l3backend-pdftex.def 2025-04-14 L3 backend support: PDF output (pdfTeX)
\l__color_backend_stack_int=\count300
) (AI_Gen.aux)
\openout1 = `AI_Gen.aux'.
LaTeX Font Info: Checking defaults for OML/cmm/m/it on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for OMS/cmsy/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for OT1/cmr/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for T1/cmr/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for TS1/cmr/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for OMX/cmex/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for U/cmr/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for PD1/pdf/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
LaTeX Font Info: Checking defaults for PU/pdf/m/n on input line 17.
LaTeX Font Info: ... okay on input line 17.
*geometry* driver: auto-detecting
*geometry* detected driver: pdftex
*geometry* verbose mode - [ preamble ] result:
* driver: pdftex
* paper: a4paper
* layout: <same size as paper>
* layoutoffset:(h,v)=(0.0pt,0.0pt)
* modes:
* h-part:(L,W,R)=(71.13188pt, 455.24411pt, 71.13188pt)
* v-part:(T,H,B)=(71.13188pt, 702.78308pt, 71.13188pt)
* \paperwidth=597.50787pt
* \paperheight=845.04684pt
* \textwidth=455.24411pt
* \textheight=702.78308pt
* \oddsidemargin=-1.1381pt
* \evensidemargin=-1.1381pt
* \topmargin=-38.1381pt
* \headheight=12.0pt
* \headsep=25.0pt
* \topskip=11.0pt
* \footskip=30.0pt
* \marginparwidth=50.0pt
* \marginparsep=10.0pt
* \columnsep=10.0pt
* \skip\footins=10.0pt plus 4.0pt minus 2.0pt
* \hoffset=0.0pt
* \voffset=0.0pt
* \mag=1000
* \@twocolumnfalse
* \@twosidefalse
* \@mparswitchfalse
* \@reversemarginfalse
* (1in=72.27pt=25.4mm, 1cm=28.453pt)
Package hyperref Info: Link coloring OFF on input line 17.
(AI_Gen.out) (AI_Gen.out)
\@outlinefile=\write3
\openout3 = `AI_Gen.out'.
(C:\Program Files\MiKTeX\tex/context/base/mkii\supp-pdf.mkii
[Loading MPS to PDF converter (version 2006.09.02).]
\scratchcounter=\count301
\scratchdimen=\dimen162
\scratchbox=\box58
\nofMPsegments=\count302
\nofMParguments=\count303
\everyMPshowfont=\toks22
\MPscratchCnt=\count304
\MPscratchDim=\dimen163
\MPnumerator=\count305
\makeMPintoPDFobject=\count306
\everyMPtoPDFconversion=\toks23
) (C:\Program Files\MiKTeX\tex/latex/epstopdf-pkg\epstopdf-base.sty
Package: epstopdf-base 2020-01-24 v2.11 Base part for package epstopdf
Package epstopdf-base Info: Redefining graphics rule for `.eps' on input line 4
85.
(C:\Program Files\MiKTeX\tex/latex/00miktex\epstopdf-sys.cfg
File: epstopdf-sys.cfg 2021/03/18 v2.0 Configuration of epstopdf for MiKTeX
))
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <12> on input line 19.
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <8> on input line 19.
LaTeX Font Info: External font `cmex10' loaded for size
(Font) <6> on input line 19.
[1
{C:/Users/kech/AppData/Local/MiKTeX/fonts/map/pdftex/pdftex.map}]
[2]
[3]
[4]
[5] (AI_Gen.aux)
***********
LaTeX2e <2024-11-01> patch level 2
L3 programming layer <2025-04-29>
***********
Package rerunfilecheck Info: File `AI_Gen.out' has not changed.
(rerunfilecheck) Checksum: 98397D1D26258D766FC36BD7CFEC6FAB;2898.
)
Here is how much of TeX's memory you used:
9343 strings out of 469923
143002 string characters out of 5479241
540633 words of memory out of 5000000
36047 multiletter control sequences out of 15000+600000
635136 words of font info for 58 fonts, out of 8000000 for 9000
1141 hyphenation exceptions out of 8191
75i,6n,79p,319b,587s stack positions out of 10000i,1000n,20000p,200000b,200000s
<C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/ljfour/jknappen/ec/dpi600\ectt1
095.pk> <C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/ljfour/jknappen/ec/dpi600\
tcrm1095.pk> <C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/ljfour/jknappen/ec/dp
i600\ecbx1200.pk> <C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/ljfour/jknappen/
ec/dpi600\ecrm1095.pk> <C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/ljfour/jkna
ppen/ec/dpi600\ecbx1440.pk> <C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/ljfour
/jknappen/ec/dpi600\ecrm1200.pk> <C:\Users\kech\AppData\Local\MiKTeX\fonts/pk/l
jfour/jknappen/ec/dpi600\ecrm1728.pk>
Output written on AI_Gen.pdf (5 pages, 109855 bytes).
PDF statistics:
408 PDF objects out of 1000 (max. 8388607)
30 named destinations out of 1000 (max. 500000)
153 words of extra memory for PDF output out of 10000 (max. 10000000)

19
doc/AI_Gen.out Executable file
View File

@@ -0,0 +1,19 @@
\BOOKMARK [1][-]{section.1}{\376\377\000Z\000i\000e\000l\000s\000e\000t\000z\000u\000n\000g}{}% 1
\BOOKMARK [1][-]{section.2}{\376\377\000A\000r\000c\000h\000i\000t\000e\000k\000t\000u\000r\000\374\000b\000e\000r\000s\000i\000c\000h\000t\000\040\000\050\000f\000i\000n\000a\000l\000\051}{}% 2
\BOOKMARK [2][-]{subsection.2.1}{\376\377\000D\000o\000m\000\344\000n\000e\000n\000s\000t\000r\000u\000k\000t\000u\000r}{section.2}% 3
\BOOKMARK [2][-]{subsection.2.2}{\376\377\000H\000i\000g\000h\000-\000L\000e\000v\000e\000l\000\040\000A\000r\000c\000h\000i\000t\000e\000k\000t\000u\000r}{section.2}% 4
\BOOKMARK [2][-]{subsection.2.3}{\376\377\000Z\000e\000n\000t\000r\000a\000l\000e\000\040\000P\000r\000i\000n\000z\000i\000p\000i\000e\000n}{section.2}% 5
\BOOKMARK [1][-]{section.3}{\376\377\000A\000u\000t\000h\000e\000n\000t\000i\000f\000i\000k\000a\000t\000i\000o\000n\000\040\000\050\000V\000a\000r\000i\000a\000n\000t\000e\000\040\000A\000:\000\040\000a\000u\000t\000h\000\137\000r\000e\000q\000u\000e\000s\000t\000\051}{}% 6
\BOOKMARK [2][-]{subsection.3.1}{\376\377\000P\000r\000i\000n\000z\000i\000p}{section.3}% 7
\BOOKMARK [2][-]{subsection.3.2}{\376\377\000A\000b\000l\000a\000u\000f}{section.3}% 8
\BOOKMARK [2][-]{subsection.3.3}{\376\377\000H\000e\000a\000d\000e\000r\000-\000W\000e\000i\000t\000e\000r\000g\000a\000b\000e}{section.3}% 9
\BOOKMARK [1][-]{section.4}{\376\377\000N\000g\000i\000n\000x\000\040\000K\000o\000n\000f\000i\000g\000u\000r\000a\000t\000i\000o\000n}{}% 10
\BOOKMARK [2][-]{subsection.4.1}{\376\377\000W\000i\000l\000d\000c\000a\000r\000d\000\040\000S\000e\000r\000v\000e\000r\000\040\000B\000l\000o\000c\000k}{section.4}% 11
\BOOKMARK [2][-]{subsection.4.2}{\376\377\000U\000p\000s\000t\000r\000e\000a\000m\000\040\000M\000a\000p\000p\000i\000n\000g}{section.4}% 12
\BOOKMARK [1][-]{section.5}{\376\377\000D\000o\000c\000k\000e\000r\000\040\000C\000o\000m\000p\000o\000s\000e\000\040\000\050\000\334\000b\000e\000r\000s\000i\000c\000h\000t\000\051}{}% 13
\BOOKMARK [1][-]{section.6}{\376\377\000U\000I\000-\000K\000o\000n\000z\000e\000p\000t\000\040\000\050\000N\000a\000v\000i\000g\000a\000t\000i\000o\000n\000s\000-\000P\000o\000r\000t\000a\000l\000\051}{}% 14
\BOOKMARK [2][-]{subsection.6.1}{\376\377\000F\000u\000n\000k\000t\000i\000o\000n}{section.6}% 15
\BOOKMARK [2][-]{subsection.6.2}{\376\377\000U\000I\000-\000L\000a\000y\000o\000u\000t}{section.6}% 16
\BOOKMARK [2][-]{subsection.6.3}{\376\377\000H\000T\000M\000L\000-\000P\000r\000o\000t\000o\000t\000y\000p\000\040\000\050\000P\000o\000r\000t\000a\000l\000\051}{section.6}% 17
\BOOKMARK [2][-]{subsection.6.4}{\376\377\000P\000o\000r\000t\000a\000l\000\040\000a\000l\000s\000\040\000D\000o\000c\000k\000e\000r\000\040\000C\000o\000n\000t\000a\000i\000n\000e\000r}{section.6}% 18
\BOOKMARK [1][-]{section.7}{\376\377\000Z\000u\000s\000a\000m\000m\000e\000n\000f\000a\000s\000s\000u\000n\000g}{}% 19

BIN
doc/AI_Gen.pdf Executable file

Binary file not shown.

BIN
doc/AI_Gen.synctex.gz Executable file

Binary file not shown.

249
doc/AI_Gen.tex Executable file
View File

@@ -0,0 +1,249 @@
\documentclass[a4paper,11pt]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{geometry}
\usepackage{hyperref}
\usepackage{graphicx}
\usepackage{longtable}
\usepackage{textcomp}
\geometry{margin=2.5cm}
\title{Service-Portal Architektur}
\author{schooltech.ch}
\date{\today}
\begin{document}
\maketitle
\section{Zielsetzung}
Ziel ist der Aufbau eines zentralen Login- und Navigations-Portals für mehrere unabhängige Web-Services,
die in Docker-Containern betrieben werden.
Benutzer authentifizieren sich einmal zentral und können anschließend zwischen Services wechseln,
die jeweils unter eigenen Subdomains bereitgestellt werden.
\section{Architekturübersicht (final)}
\subsection{Domänenstruktur}
\begin{itemize}
\item \texttt{server.schooltech.ch} Login- und Navigations-Portal (SPA)
\item \texttt{<service>.server.schooltech.ch} einzelne Services (Docker Container)
\end{itemize}
\subsection{High-Level Architektur}
\begin{verbatim}
Internet
|
| HTTPS (*.server.schooltech.ch)
v
+---------------------+
| Nginx ReverseProxy |
| TLS, Auth, Routing |
+---------------------+
| |
| +--> Service Container (abc)
| abc.server.schooltech.ch
|
+--> Portal / Auth Service
server.schooltech.ch
\end{verbatim}
\subsection{Zentrale Prinzipien}
\begin{itemize}
\item Ein Einstiegspunkt (Nginx)
\item Zentrale Authentifikation (auth\_request)
\item Services kennen kein Login
\item Keine iFrames, kein Subpath-Rewrite
\item Services laufen auf Root-Pfad (\texttt{/})
\end{itemize}
\section{Authentifikation (Variante A: auth\_request)}
\subsection{Prinzip}
Nginx prüft jeden Request zu einem Service mittels \texttt{auth\_request} gegen den Auth-Service.
Der Auth-Service entscheidet ausschließlich anhand der Session (Cookie).
\subsection{Ablauf}
\begin{enumerate}
\item Benutzer loggt sich am Portal ein
\item Portal setzt Session-Cookie:
\begin{verbatim}
Domain=.server.schooltech.ch
Secure; HttpOnly; SameSite=None
\end{verbatim}
\item Benutzer ruft Service-Subdomain auf
\item Nginx fragt \texttt{/internal/auth} beim Auth-Service an
\item Bei Erfolg: Request wird an Service weitergeleitet
\end{enumerate}
\subsection{Header-Weitergabe}
Optional injiziert Nginx folgende Header:
\begin{itemize}
\item \texttt{X-Remote-User}
\item \texttt{X-User-Roles}
\end{itemize}
\section{Nginx Konfiguration}
\subsection{Wildcard Server Block}
\begin{verbatim}
server {
listen 443 ssl;
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;
location / {
auth_request /internal/auth;
proxy_pass http://$upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /internal/auth {
proxy_pass http://auth:3000/internal/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
}
\end{verbatim}
\subsection{Upstream Mapping}
\begin{verbatim}
map $host $upstream {
default portal:3000;
abc.server.schooltech.ch abc:8080;
xyz.server.schooltech.ch xyz:8080;
}
\end{verbatim}
\section{Docker Compose (Übersicht)}
\begin{verbatim}
version: "3.9"
services:
nginx:
image: nginx:alpine
ports:
- "443:443"
volumes:
- ./nginx:/etc/nginx/conf.d
- ./certs:/etc/letsencrypt
depends_on:
- auth
- portal
networks:
- internal
auth:
image: node:18
command: node auth.js
networks:
- internal
portal:
image: node:18
command: node portal.js
networks:
- internal
abc:
image: service-abc
networks:
- internal
networks:
internal:
driver: bridge
\end{verbatim}
\section{UI-Konzept (Navigations-Portal)}
\subsection{Funktion}
\begin{itemize}
\item Login
\item Anzeige verfügbarer Services
\item Wechsel per Full Navigation
\item Kollabierbare Kopfzeile
\item Floating Launcher (Bookmark)
\end{itemize}
\subsection{UI-Layout}
\begin{verbatim}
+------------------------------------------------+
| LOGO | Service A | Service B | User |
+------------------------------------------------+
| |
| Service Overview / Last Service / Status |
| |
+------------------------------------------------+
\end{verbatim}
\subsection{HTML-Prototyp (Portal)}
\begin{verbatim}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>Service Portal</title>
<style>
header {
position: fixed;
top: 0; left: 0; right: 0;
height: 60px;
backdrop-filter: blur(6px);
background: rgba(0,0,0,0.3);
color: white;
display: flex;
align-items: center;
padding: 0 20px;
}
main {
padding-top: 80px;
}
.service {
display: inline-block;
margin: 10px;
padding: 20px;
border: 1px solid #ccc;
cursor: pointer;
}
</style>
</head>
<body>
<header>
<strong>Portal</strong>
</header>
<main>
<div class="service" onclick="go('abc')">Service ABC</div>
<div class="service" onclick="go('xyz')">Service XYZ</div>
</main>
<script>
function go(name) {
localStorage.setItem("lastService", name);
window.location.href = "https://" + name + ".server.schooltech.ch";
}
</script>
</body>
</html>
\end{verbatim}
\subsection{Portal als Docker Container}
Das Portal wird als eigener Container betrieben und statisch oder via Node.js ausgeliefert.
Es besitzt keine direkte Kopplung zu den Services außer über URLs.
\section{Zusammenfassung}
Diese Architektur ermöglicht:
\begin{itemize}
\item saubere Trennung von Portal und Services
\item zentrale Sicherheit
\item einfache Erweiterbarkeit
\item wartbare Reverse-Proxy-Konfiguration
\end{itemize}
\end{document}

9
doc/AI_Gen.txt Executable file
View File

@@ -0,0 +1,9 @@
Ich habe eine WEbPage hinter einer Firewall. Die besteht aus mehreren Services die in Docker Containern mit jeweils eine Einstiegs-Seite (an einem offenem Port) breitstehen.
Jetzt will ich für Kunden eine Login-Page aufbauen. Auf der Login-Page soll in der Kopfzeile ein transparenter Balken sein, mit User- und Passwort Authentifikation. Wenn der User angemeldet ist sollen in der Kopf-Zeile verschiedene Buttons (für verschiedene Services) erscheinen. Jeder dieser Services soll auf einen Container zeigen, so dass die Kopfzeile als Navigation der Services möglich ist.
Wenn auf eine der Services gedrückt wird, soll die Kopfzeile zur "kollabieren" und nur noch als "offnebares-bookmark" oder so bereitstehen. Die Haupt-Page soll eben auf diesen Service zeigen.
Ich gehe davon aus, dass dafür 1) eine Login-Site (eventuell mit NodeJS, da ich das kenne) mit Navigation nötig ist, 2) Nginx im Hintergrund die Pages weiterleitet.
Skizziere erst mal einen Aufbau, bevor ich über Implementationen nachdenke.

97
doc/Architektur.svg Normal file
View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="1180" height="580" viewBox="0 0 1180 580" font-family="Segoe UI, Helvetica, Arial, sans-serif">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6 Z" fill="#64748b"/>
</marker>
</defs>
<!-- canvas -->
<rect x="0" y="0" width="1180" height="580" fill="#ffffff"/>
<rect x="4" y="4" width="1172" height="572" rx="14" fill="none" stroke="#e2e8f0" stroke-width="2"/>
<!-- title -->
<text x="590" y="36" text-anchor="middle" font-size="20" font-weight="700" fill="#0f172a">Architektur-Übersicht — server.schooltech.ch</text>
<!-- ===== Internet ===== -->
<rect x="490" y="56" width="200" height="44" rx="10" fill="#eef2f7" stroke="#94a3b8" stroke-width="1.5"/>
<text x="590" y="76" text-anchor="middle" font-size="15" font-weight="700" fill="#1e293b">Internet</text>
<text x="590" y="92" text-anchor="middle" font-size="11" fill="#475569">HTTPS :443 · HTTP :80</text>
<!-- arrow Internet -> nginx -->
<line x1="590" y1="100" x2="590" y2="130" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<!-- ===== nginx ===== -->
<rect x="70" y="132" width="1040" height="80" rx="10" fill="#2563eb" stroke="#1d4ed8" stroke-width="1.5"/>
<text x="590" y="160" text-anchor="middle" font-size="16" font-weight="700" fill="#ffffff">nginx Reverse-Proxy — Container: appServer_PortalUI</text>
<text x="590" y="181" text-anchor="middle" font-size="12.5" fill="#dbeafe">ein vHost pro Subdomain *.server.schooltech.ch</text>
<text x="590" y="199" text-anchor="middle" font-size="12.5" fill="#dbeafe">TLS-Terminierung (Let's Encrypt) · 80 → 443 Redirect</text>
<!-- distribution bus -->
<line x1="590" y1="212" x2="590" y2="248" stroke="#64748b" stroke-width="2"/>
<line x1="185" y1="248" x2="980" y2="248" stroke="#64748b" stroke-width="2"/>
<line x1="185" y1="248" x2="185" y2="274" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="445" y1="248" x2="445" y2="274" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="705" y1="248" x2="705" y2="274" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="980" y1="248" x2="980" y2="274" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<!-- ===== category boxes (row: Subdomain-Gruppe) ===== -->
<!-- col1 Portal (green) -->
<rect x="70" y="276" width="230" height="84" rx="10" fill="#dcfce7" stroke="#22c55e" stroke-width="1.5"/>
<text x="185" y="309" text-anchor="middle" font-size="14" font-weight="700" fill="#14532d">server.schooltech.ch</text>
<text x="185" y="331" text-anchor="middle" font-size="11.5" fill="#166534">Portal-UI (diese App)</text>
<!-- col2 lokale Container (amber) -->
<rect x="330" y="276" width="230" height="84" rx="10" fill="#ffedd5" stroke="#f97316" stroke-width="1.5"/>
<text x="445" y="309" text-anchor="middle" font-size="14" font-weight="700" fill="#7c2d12">rp5*.schooltech.ch</text>
<text x="445" y="331" text-anchor="middle" font-size="11.5" fill="#9a3412">lokale Container</text>
<!-- col3 LAN (purple) -->
<rect x="590" y="276" width="230" height="84" rx="10" fill="#f3e8ff" stroke="#a855f7" stroke-width="1.5"/>
<text x="705" y="309" text-anchor="middle" font-size="13.5" font-weight="700" fill="#581c87">nextcloud.schooltech.ch</text>
<text x="705" y="331" text-anchor="middle" font-size="11.5" fill="#6b21a8">Gerät im LAN · direkte IP</text>
<!-- col4 Tunnel-Hub (teal) -->
<rect x="850" y="276" width="260" height="84" rx="10" fill="#ccfbf1" stroke="#14b8a6" stroke-width="1.5"/>
<text x="980" y="303" text-anchor="middle" font-size="11.5" font-weight="700" fill="#134e4a">inf* · rp3* · tc* · robot* · fluidnc*</text>
<text x="980" y="322" text-anchor="middle" font-size="11.5" fill="#115e59">.server.schooltech.ch</text>
<text x="980" y="340" text-anchor="middle" font-size="11.5" fill="#115e59">über SSH-Tunnel-Hub</text>
<!-- arrows category -> backend -->
<line x1="185" y1="360" x2="185" y2="390" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="445" y1="360" x2="445" y2="390" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="705" y1="360" x2="705" y2="390" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="980" y1="360" x2="980" y2="390" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
<!-- ===== backend boxes (row: Ziel / Upstream) ===== -->
<!-- col1 -->
<rect x="70" y="392" width="230" height="84" rx="10" fill="#f0fdf4" stroke="#4ade80" stroke-width="1.5"/>
<text x="185" y="426" text-anchor="middle" font-size="12.5" fill="#14532d">public/index.html</text>
<text x="185" y="446" text-anchor="middle" font-size="12.5" fill="#14532d">+ Auth-API (/api/ → :3000)</text>
<!-- col2 -->
<rect x="330" y="392" width="230" height="84" rx="10" fill="#fff7ed" stroke="#fb923c" stroke-width="1.5"/>
<text x="445" y="426" text-anchor="middle" font-size="12.5" fill="#7c2d12">appServer_guacamole</text>
<text x="445" y="446" text-anchor="middle" font-size="12.5" fill="#7c2d12">portainer</text>
<!-- col3 -->
<rect x="590" y="392" width="230" height="60" rx="10" fill="#faf5ff" stroke="#c084fc" stroke-width="1.5"/>
<text x="705" y="427" text-anchor="middle" font-size="12.5" fill="#581c87">192.168.0.210:9183</text>
<!-- col4 (tunnel head, taller) -->
<rect x="850" y="392" width="260" height="150" rx="10" fill="#f0fdfa" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="980" y="416" text-anchor="middle" font-size="13" font-weight="700" fill="#134e4a">appServer_TunnelHead</text>
<text x="980" y="433" text-anchor="middle" font-size="10.5" font-style="italic" fill="#0f766e">SSH-Reverse-Tunnels · feste Ports</text>
<line x1="868" y1="444" x2="1092" y2="444" stroke="#99f6e4" stroke-width="1"/>
<text x="868" y="466" text-anchor="start" font-size="11" fill="#134e4a">99xx — InformatikWeb (inf*)</text>
<text x="868" y="490" text-anchor="start" font-size="11" fill="#134e4a">81xx — RP3 / SCARA (rp3*, fluidnc*)</text>
<text x="868" y="514" text-anchor="start" font-size="11" fill="#134e4a">97xx — ThinkCentre (tc*, robot*)</text>
<!-- row labels (left gutter) -->
<text x="40" y="176" text-anchor="middle" font-size="10" fill="#94a3b8" transform="rotate(-90 40 176)">PROXY</text>
<text x="40" y="318" text-anchor="middle" font-size="10" fill="#94a3b8" transform="rotate(-90 40 318)">SUBDOMAIN-GRUPPE</text>
<text x="40" y="450" text-anchor="middle" font-size="10" fill="#94a3b8" transform="rotate(-90 40 450)">UPSTREAM / ZIEL</text>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
doc/Portal.pdf Normal file

Binary file not shown.

194
doc/Portal.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB