Add Windows test-server setup behind nginx

- docker-compose.server.yml: app on APP_BIND for an existing nginx proxy,
  QLO_SSL passed to the container
- docker/setup-testserver.ps1: one-shot setup for Docker Desktop/WSL
  (random .env secrets, CLI install that never reinstalls over an
  existing shop, German texts, optional demo content, hardening)
- docker/harden-test-server.php: delete demo customer pub@qloapps.com,
  disable webservice API keys, set SSL mode, deny module log folders
- docker/nginx-qloapps.conf: HTTPS + basic auth + X-Forwarded-Proto
- apache: deny /modules/*/logs/ (qlocmconnector logs API secrets)
- docker/TESTSERVER.md: Windows Server guide (German)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DanielS
2026-11-11 00:45:15 +01:00
parent 9b4d6f1037
commit 1e9daa8858
7 changed files with 333 additions and 0 deletions

View File

@@ -8,3 +8,12 @@ DB_ROOT_PASSWORD=qlo_local_root
# Local admin created by the CLI installer (test values only) # Local admin created by the CLI installer (test values only)
ADMIN_EMAIL=admin@hotel.test ADMIN_EMAIL=admin@hotel.test
ADMIN_PASSWORD=change-me ADMIN_PASSWORD=change-me
# Test server only (docker-compose.server.yml)
# Public host name as nginx serves it, without https:// (QloApps stores it in the DB)
SHOP_DOMAIN=test.example.de
# Address:port the app listens on for nginx. Use the LAN IP of the Windows server
# if nginx runs on another machine, or 127.0.0.1:8080 if nginx runs on the same host.
APP_BIND=0.0.0.0:8080
# 1 when nginx serves HTTPS
QLO_SSL=1

16
docker-compose.server.yml Normal file
View File

@@ -0,0 +1,16 @@
# Test-server overlay (Windows Server + Docker Desktop, or Docker in WSL).
# Your existing nginx reverse proxy terminates HTTPS and asks for a password;
# the app only listens on APP_BIND (default: all interfaces, port 8080).
# docker compose -f docker-compose.yml -f docker-compose.server.yml up -d --build
# See docker/TESTSERVER.md and docker/nginx-qloapps.conf.
services:
app:
ports: !override
- "${APP_BIND:-0.0.0.0:8080}:80"
environment:
QLO_SSL: ${QLO_SSL:-1}
restart: unless-stopped
db:
restart: unless-stopped

87
docker/TESTSERVER.md Normal file
View File

@@ -0,0 +1,87 @@
# Testserver auf Windows Server
Shop mit Wasserrad-Design auf einem Windows Server, erreichbar über den vorhandenen nginx-Reverse-Proxy.
nginx übernimmt HTTPS und schützt die ganze Seite mit einem Passwort.
```
Browser ──HTTPS──> nginx (Zertifikat, Passwort) ──HTTP──> Windows Server :8080 ──> Docker: app (Apache/PHP) + db (MariaDB)
```
## 1. Docker auf dem Windows Server
**Variante A: Docker Desktop** (bevorzugt)
1. WSL 2 aktivieren (PowerShell als Administrator), danach neu starten:
```powershell
wsl --install --no-distribution
```
2. Docker Desktop installieren, Einstellung „Use the WSL 2 based engine“ aktiv lassen.
3. Unter *Settings → General* „Start Docker Desktop when you sign in“ aktivieren.
Hinweis: Docker Desktop ist offiziell für Windows 10/11 gedacht; auf Windows Server 2022/2025 läuft es mit WSL 2 in der Regel,
wird aber von Docker nicht unterstützt. Docker Desktop startet erst, wenn sich ein Benutzer anmeldet.
Ab 250 Mitarbeitenden oder 10 Mio. $ Umsatz braucht Docker Desktop eine kostenpflichtige Lizenz.
**Variante B: Docker Engine direkt in WSL** (falls Docker Desktop nicht startet)
```powershell
wsl --install -d Ubuntu-24.04
```
In Ubuntu: `curl -fsSL https://get.docker.com | sh`, dann die Schritte unten in Ubuntu mit `docker compose …` ausführen
(statt des PowerShell-Skripts die Befehle aus `docker-compose.yml` nutzen).
## 2. Code holen
```powershell
git clone -b hotel/no-telemetry-docker https://gitea.hephex.de/Daniel/hotel.git C:\hotel
cd C:\hotel
```
## 3. Einrichten (ein Befehl)
```powershell
powershell -ExecutionPolicy Bypass -File docker\setup-testserver.ps1 -ShopDomain test.example.de
```
Das Skript
- legt `.env` mit zufälligen Passwörtern an (Admin-Login steht dort: `ADMIN_EMAIL` / `ADMIN_PASSWORD`),
- baut und startet die Container,
- installiert QloApps auf Deutsch (überspringt das, wenn schon installiert, also kein versehentliches Löschen),
- ergänzt fehlende deutsche Texte, lädt die Wasserrad-Demo (`-NoDemo` zum Weglassen),
- räumt auf: Demo-Kunde gelöscht, API-Schlüssel abgeschaltet, Log-Ordner gesperrt.
Ohne Zertifikat im LAN: `-NoSsl`.
Port und Adresse der App stehen in `.env` unter `APP_BIND` (Standard `0.0.0.0:8080`).
Läuft nginx auf demselben Server: `APP_BIND=127.0.0.1:8080`. Sonst die Windows-Firewall für Port 8080 nur für die IP des nginx-Servers öffnen:
```powershell
New-NetFirewallRule -DisplayName "QloApps from nginx" -Direction Inbound -Protocol TCP -LocalPort 8080 -RemoteAddress <IP-des-nginx> -Action Allow
```
## 4. nginx
Vorlage: [`nginx-qloapps.conf`](nginx-qloapps.conf). Domain, Zertifikat und `APP_HOST` anpassen, Passwortdatei anlegen:
```bash
htpasswd -c /etc/nginx/.htpasswd-hotel tester
nginx -t && systemctl reload nginx
```
Wichtig ist `proxy_set_header X-Forwarded-Proto $scheme;` – daran erkennt QloApps HTTPS. Ohne die Zeile gibt es Umleitungsschleifen.
## Betrieb
| Aufgabe | Befehl (im Repo-Ordner) |
|---|---|
| Status | `docker compose -f docker-compose.yml -f docker-compose.server.yml ps` |
| Logs | `docker compose -f docker-compose.yml -f docker-compose.server.yml logs -f app` |
| Update | `git pull`, dann das Setup-Skript erneut ausführen |
| Stoppen | `docker compose -f docker-compose.yml -f docker-compose.server.yml down` (Daten bleiben im Volume `db_data`) |
| Backup DB | `docker compose exec db mariadb-dump -uroot -p<DB_ROOT_PASSWORD> qloapps > backup.sql` |
## Vor echtem Betrieb
Der Testserver ist für Design- und Ablauftests gedacht. Für echte Buchungen fehlen noch: eigene Rechtstexte
(Impressum, Datenschutz, AGB), E-Mail-Versand (SMTP), Zahlungsart, regelmäßige Backups und QloApps-Updates
(der automatische Update-Hinweis ist abgeschaltet).

View File

@@ -1,6 +1,8 @@
# The repo is bind-mounted as web root: never serve dotfiles (.env, .git, ...) or docker files. # The repo is bind-mounted as web root: never serve dotfiles (.env, .git, ...) or docker files.
RedirectMatch 404 "/\.(?!well-known/)" RedirectMatch 404 "/\.(?!well-known/)"
RedirectMatch 404 "^/(docker/|docker-compose\.yml)" RedirectMatch 404 "^/(docker/|docker-compose\.yml)"
# Module log folders (qlocmconnector logs full API requests incl. client_secret)
RedirectMatch 404 "^/modules/[^/]+/logs/"
# Installation is done via CLI (see docker-compose.yml); a web re-install would wipe the database. # Installation is done via CLI (see docker-compose.yml); a web re-install would wipe the database.
RedirectMatch 404 "^/install-dev" RedirectMatch 404 "^/install-dev"
ServerTokens Prod ServerTokens Prod

View File

@@ -0,0 +1,67 @@
<?php
/**
* Clean up demo leftovers before a QloApps install is reachable from the network.
* Idempotent; safe to run again.
* docker compose exec -u www-data app php docker/harden-test-server.php
*
* - deletes the demo customer pub@qloapps.com (its password is public in the QloApps fixtures)
* - disables the webservice API and every API key (the installer creates an active key)
* - makes sure developer mode is off and the shop runs in the expected SSL mode
* - protects module log folders (qlocmconnector logs API secrets) with a deny file
* QLO_SSL=1 in the environment (docker-compose.server.yml) switches the shop to HTTPS.
*/
if (PHP_SAPI !== 'cli') {
exit("CLI only\n");
}
define('_PS_ADMIN_DIR_', __DIR__);
require __DIR__.'/../config/config.inc.php';
$ok = function ($msg) { echo ' [ok] '.$msg.PHP_EOL; };
$warn = function ($msg) { echo ' [!!] '.$msg.PHP_EOL; };
echo "Demo customer\n";
$ids = Db::getInstance()->executeS('SELECT id_customer FROM '._DB_PREFIX_."customer WHERE email = 'pub@qloapps.com'");
foreach ($ids as $row) {
$c = new Customer((int)$row['id_customer']);
$c->delete();
}
$ok(count($ids) ? 'pub@qloapps.com deleted' : 'no demo customer present');
echo "Webservice API\n";
Configuration::updateValue('PS_WEBSERVICE', 0);
Db::getInstance()->update('webservice_account', array('active' => 0));
$ok('API disabled, all keys deactivated');
echo "Developer mode\n";
if (_PS_MODE_DEV_) {
$warn('_PS_MODE_DEV_ is on: set it to false in config/defines.inc.php');
} else {
$ok('off');
}
echo "SSL\n";
$ssl = getenv('QLO_SSL') === '1';
Configuration::updateValue('PS_SSL_ENABLED', (int)$ssl);
Configuration::updateValue('PS_SSL_ENABLED_EVERYWHERE', (int)$ssl);
$ok($ssl ? 'HTTPS everywhere' : 'HTTP (LAN test, no certificate)');
echo "Module log folders\n";
$deny = "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n";
foreach (glob(_PS_MODULE_DIR_.'*', GLOB_ONLYDIR) as $dir) {
$logs = $dir.'/logs';
if (basename($dir) === 'qlocmconnector' && !is_dir($logs)) {
@mkdir($logs, 0755, true);
}
if (is_dir($logs) && !is_file($logs.'/.htaccess')) {
file_put_contents($logs.'/.htaccess', $deny);
$ok(basename($dir).'/logs protected');
}
}
echo "Admin accounts\n";
foreach (Db::getInstance()->executeS('SELECT email, active FROM '._DB_PREFIX_.'employee') as $e) {
echo ' - '.$e['email'].($e['active'] ? '' : ' (inactive)').PHP_EOL;
}
Tools::clearSmartyCache();
echo "Done.\n";

37
docker/nginx-qloapps.conf Normal file
View File

@@ -0,0 +1,37 @@
# nginx server block for the QloApps test server (HTTPS + password for the whole site).
# Replace test.example.de, the certificate paths and APP_HOST:8080 (the Windows server running Docker).
# Password file: htpasswd -c /etc/nginx/.htpasswd-hotel <user>
# (or: printf "user:$(openssl passwd -apr1)\n" > /etc/nginx/.htpasswd-hotel)
server {
listen 80;
server_name test.example.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name test.example.de;
ssl_certificate /etc/letsencrypt/live/test.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/test.example.de/privkey.pem;
# whole site (shop + /admin-dev) behind a password while testing
auth_basic "Hotel Test";
auth_basic_user_file /etc/nginx/.htpasswd-hotel;
client_max_body_size 20m; # image uploads in the back office
add_header X-Robots-Tag "noindex, nofollow" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location / {
proxy_pass http://APP_HOST:8080;
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; # QloApps uses this to detect HTTPS
proxy_read_timeout 300s; # installer / image regeneration
}
}

115
docker/setup-testserver.ps1 Normal file
View File

@@ -0,0 +1,115 @@
<#
.SYNOPSIS
Sets up the QloApps test server on Windows (Docker Desktop or Docker in WSL).
.DESCRIPTION
Run from the repository root in PowerShell:
powershell -ExecutionPolicy Bypass -File docker\setup-testserver.ps1 -ShopDomain test.example.de
- creates .env with random passwords (only if .env does not exist yet)
- builds and starts the containers (docker-compose.yml + docker-compose.server.yml)
- installs QloApps in German via CLI (skipped if config/settings.inc.php exists: no accidental wipe)
- fills German shop texts, optionally loads the Wasserrad demo content, then hardens the install
Re-running is safe: it never reinstalls over an existing shop.
.PARAMETER ShopDomain
Host name nginx serves the shop under, without https:// (e.g. test.example.de).
.PARAMETER NoSsl
nginx serves plain HTTP (LAN test without certificate).
.PARAMETER NoDemo
Skip the "Zum alten Wasserrad" demo content.
#>
param(
[Parameter(Mandatory = $true)][string]$ShopDomain,
[switch]$NoSsl,
[switch]$NoDemo
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$compose = @('compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.server.yml')
function Step($text) { Write-Host "`n==> $text" -ForegroundColor Cyan }
function New-Secret([int]$bytes = 18) {
$b = New-Object byte[] $bytes
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
return ([Convert]::ToBase64String($b) -replace '[^A-Za-z0-9]', '')
}
function Invoke-Docker {
& docker @args
if ($LASTEXITCODE -ne 0) { throw "docker $($args -join ' ') failed ($LASTEXITCODE)" }
}
function Read-EnvFile {
$h = @{}
foreach ($line in Get-Content .env) {
if ($line -match '^\s*([A-Z_]+)=(.*)$') { $h[$Matches[1]] = $Matches[2].Trim() }
}
return $h
}
Step 'Docker'
docker version --format '{{.Server.Version}}' | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Docker is not running (start Docker Desktop or the Docker engine in WSL).' }
Step '.env'
$ssl = if ($NoSsl) { '0' } else { '1' }
if (-not (Test-Path .env)) {
$content = (Get-Content .env.example -Raw)
$content = $content -replace '(?m)^DB_PASSWORD=.*$', "DB_PASSWORD=$(New-Secret)"
$content = $content -replace '(?m)^DB_ROOT_PASSWORD=.*$', "DB_ROOT_PASSWORD=$(New-Secret)"
$content = $content -replace '(?m)^ADMIN_EMAIL=.*$', "ADMIN_EMAIL=admin@$ShopDomain"
$content = $content -replace '(?m)^ADMIN_PASSWORD=.*$', "ADMIN_PASSWORD=$(New-Secret 15)"
$content = $content -replace '(?m)^SHOP_DOMAIN=.*$', "SHOP_DOMAIN=$ShopDomain"
$content = $content -replace '(?m)^QLO_SSL=.*$', "QLO_SSL=$ssl"
[System.IO.File]::WriteAllText((Join-Path $root '.env'), $content)
Write-Host '.env created with random passwords (keep it private, it is git-ignored).'
} else {
Write-Host '.env exists, keeping its values.'
# older .env files lack the server keys: add them from the parameters
$existing = Get-Content .env -Raw
$add = @{ 'SHOP_DOMAIN' = $ShopDomain; 'APP_BIND' = '0.0.0.0:8080'; 'QLO_SSL' = $ssl }
foreach ($k in $add.Keys) {
if ($existing -notmatch "(?m)^$k=") {
Add-Content -Path .env -Value "$k=$($add[$k])"
Write-Host " added $k=$($add[$k])"
}
}
}
$envVars = Read-EnvFile
Step 'Build and start containers'
Invoke-Docker @compose up -d --build
Step 'File permissions for www-data'
Invoke-Docker @compose exec -T app sh -c 'cd /var/www/html && chown -R www-data:www-data img mails modules translations cache config upload download themes override log'
if (Test-Path config/settings.inc.php) {
Step 'QloApps already installed (config/settings.inc.php exists) - skipping install'
} else {
Step 'Install QloApps (German, newsletter off)'
Invoke-Docker @compose exec -T -u www-data app php install-dev/index_cli.php `
--language=de --country=de --timezone=Europe/Berlin `
"--domain=$($envVars['SHOP_DOMAIN'])" "--ssl=$($envVars['QLO_SSL'])" `
--db_server=db "--db_user=$($envVars['DB_USER'])" "--db_password=$($envVars['DB_PASSWORD'])" "--db_name=$($envVars['DB_NAME'])" `
"--name=Zum alten Wasserrad" --firstname=Admin --lastname=Hotel `
"--email=$($envVars['ADMIN_EMAIL'])" "--password=$($envVars['ADMIN_PASSWORD'])" `
--newsletter=0
}
Step 'German shop texts'
Invoke-Docker @compose exec -T -u www-data app php docker/apply-translations.php de
if (-not $NoDemo) {
Step 'Wasserrad demo content'
Invoke-Docker @compose exec -T -u www-data app php docker/seed-wasserrad-demo.php
}
Step 'Harden'
Invoke-Docker @compose exec -T -u www-data app php docker/harden-test-server.php
$scheme = if ($envVars['QLO_SSL'] -eq '1') { 'https' } else { 'http' }
Write-Host "`nDone." -ForegroundColor Green
Write-Host "Shop: $scheme`://$($envVars['SHOP_DOMAIN'])/"
Write-Host "Admin: $scheme`://$($envVars['SHOP_DOMAIN'])/admin-dev/ (login: ADMIN_EMAIL / ADMIN_PASSWORD in .env)"
Write-Host "App listens on $($envVars['APP_BIND']) - point nginx there (docker/nginx-qloapps.conf)."