From 9fcb4df30e55f07819a9ce1af86e284805883ade Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 28 Sep 2026 11:50:29 +0300 Subject: [PATCH] feat(proxy): plain-HTTP LAN entry for waiter (:80) and manager (:8081), no certs required Phones can now open the waiter app at http:// with no domain, DNS record or certificate (works around DNS-rebinding failures, KI-001). The manager gets http://:8081, with http:///manager redirecting there. waiter.*/manager.* hostnames on :80 still redirect to https, so legacy domain sites behave as before. - Both plain-HTTP servers are LAN-only (allow RFC1918/loopback/ULA/link-local, deny all -> 403), so a router port-forward can't expose an unencrypted POS - nginx-proxy/nginx.conf and the install.sh heredoc are now byte-identical (one canonical config, routing map in its header) - install.sh generates a 10-year self-signed cert when certs/ is empty (nginx won't start its TLS listeners without one), detects HOST_IP via 'ip route get', prompts for it on fresh installs and backfills it into an existing .env, always starts the stack, prints the LAN URLs - docker-compose publishes 8081; .env.example documents HOST_IP - pack README: ports/request path updated, CS-5 byte-identical check Verified: nginx -t; install.sh in Debian (fresh / upgrade without HOST_IP / re-run - no duplicate HOST_IP, cert SAN includes HOST_IP, key 600); full stack from freshly built images: every entry point returns the expected 200/301/302, and removing the gateway's range from the allow list yields 403 on :80 and :8081. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 + docker-compose.yml | 1 + docs/README.md | 16 ++-- install.sh | 207 ++++++++++++++++++++++++++++++++--------- nginx-proxy/nginx.conf | 156 +++++++++++++++++++++++++++++-- 5 files changed, 330 insertions(+), 54 deletions(-) diff --git a/.env.example b/.env.example index 7e3c2dd..79a2df0 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ CLOUD_URL=https://xenia-admin.bonamin.gr SECRET_KEY=generate-with-openssl-rand-hex-32 LICENSE_GRACE_HOURS=24 +# This machine's LAN IP — the address phones open (http://) and the +# pairing QR code encodes. install.sh detects it; reserve it in the router's DHCP. +HOST_IP= + # Break-glass support account (leave blank to disable) MASTER_USERNAME= MASTER_PASSWORD= diff --git a/docker-compose.yml b/docker-compose.yml index 0a951ee..a4279d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: - "80:80" - "443:443" - "4443:4443" + - "8081:8081" # manager over plain HTTP (LAN only) volumes: - ./nginx-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./certs:/etc/nginx/certs:ro diff --git a/docs/README.md b/docs/README.md index 7d261d5..8227c78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,13 +13,15 @@ This folder is its **own git repo**, nested inside the `xenia-pos` parent repo. | Service | Stack | Dev port | Prod exposure | Image | |---|---|---|---|---| | `local_backend` | FastAPI + SQLAlchemy + SQLite (`/app/data/pos.db`) | 8000 | only through proxy / inner nginx | `pos-backend` | -| `waiter_pwa` | React + Vite + vite-plugin-pwa, axios, zustand, react-query, Dexie (IndexedDB) | 5173 | `https://` (443) | `pos-waiter` | -| `manager_dashboard` | React + Vite | 5174 | `https://:4443` | `pos-manager` | -| `proxy` | nginx:alpine, TLS termination | — | 80 → 443 redirect, 443, 4443 | stock | +| `waiter_pwa` | React + Vite + vite-plugin-pwa, axios, zustand, react-query, Dexie (IndexedDB) | 5173 | **`http://`** (80, LAN only) · `https://waiter.` / `https://` (443) | `pos-waiter` | +| `manager_dashboard` | React + Vite | 5174 | **`http://:8081`** (LAN only; `http:///manager` redirects there) · `https://manager.` (443) · `https://:4443` | `pos-manager` | +| `proxy` | nginx:alpine | — | 80, 443, 4443, 8081. See the header of `nginx-proxy/nginx.conf` for the full routing map | stock | ### Request path in production +Phones normally use **plain HTTP by LAN IP** (`http://` → proxy:80 default_server). Only private source IPs are allowed; everything else gets 403. +Legacy domain sites use `https://waiter.` (proxy:443), and `waiter.*`/`manager.*` on port 80 still redirect to https. ``` -phone ──https──▶ proxy:443 ──http──▶ waiter_pwa nginx:80 ──┬─ / → static SPA +phone ──http(s)──▶ proxy:80/443 ──http──▶ waiter_pwa nginx:80 ──┬─ / → static SPA ├─ /api/ → backend:8000 ├─ /api/ws/ → backend:8000 (WebSocket upgrade) └─ /static/ → backend:8000/static/ @@ -64,8 +66,10 @@ Waiters walk in and out of WiFi range and phones sleep. Any new waiter flow must **CS-4. Money and prices are snapshotted.** Prices, costs and discounts are copied onto order items and logs when the action happens (snapshot pattern, `PriceEventLog`). Reports read the snapshots, never the live catalogue. -**CS-5. The proxy config lives in two places.** -Edit `nginx-proxy/nginx.conf` **and** the heredoc in `install.sh` together (global Rule 9). +**CS-5. The proxy config lives in two places and must stay byte-identical.** +`install.sh` writes the heredoc on client sites, and `nginx-proxy/nginx.conf` is the repo copy. Edit both together (global Rule 9) and check with: +`sed -n "/<< 'EOF'/,/^EOF/p" install.sh | sed '1d;$d' | diff - nginx-proxy/nginx.conf` +The plain-HTTP servers (80 default_server, 8081) must keep their LAN-only `allow`/`deny` block. **CS-6. Permissions are checked on the backend.** Hiding a button in the UI is not access control. Every new endpoint declares its role or `perm_*` requirement through `routers/deps.py`. diff --git a/install.sh b/install.sh index 9604cb3..d847e91 100644 --- a/install.sh +++ b/install.sh @@ -7,6 +7,20 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# LAN IP of this machine (the address phones use: http://). +# `ip route get` picks the interface that actually routes out, which avoids +# docker0 / bridge addresses that `hostname -I` may list first. +detect_host_ip() { + local ip="" + if command -v ip >/dev/null 2>&1; then + ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}') + fi + if [ -z "$ip" ]; then + ip=$(hostname -I 2>/dev/null | awk '{print $1}') + fi + echo "$ip" +} + echo "=== Xenia POS Install ===" echo "" @@ -34,6 +48,9 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then read -rp " Enter SITE_ID: " INPUT_SITE_ID read -rp " Enter SITE_KEY: " INPUT_SITE_KEY read -rp " Enter SECRET_KEY (leave blank to auto-generate): " INPUT_SECRET_KEY + DETECTED_IP=$(detect_host_ip) + read -rp " Enter this machine's LAN IP [${DETECTED_IP}]: " INPUT_HOST_IP + INPUT_HOST_IP=${INPUT_HOST_IP:-$DETECTED_IP} if [ -z "$INPUT_SECRET_KEY" ]; then INPUT_SECRET_KEY=$(openssl rand -hex 32) @@ -43,30 +60,119 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then sed -i "s/^SITE_ID=.*/SITE_ID=${INPUT_SITE_ID}/" "$SCRIPT_DIR/.env" sed -i "s/^SITE_KEY=.*/SITE_KEY=${INPUT_SITE_KEY}/" "$SCRIPT_DIR/.env" sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${INPUT_SECRET_KEY}/" "$SCRIPT_DIR/.env" + sed -i "s/^HOST_IP=.*/HOST_IP=${INPUT_HOST_IP}/" "$SCRIPT_DIR/.env" echo "" echo " .env written. Review it at: $SCRIPT_DIR/.env" echo "" else - echo " .env already exists — skipping." + echo " .env already exists — keeping it." + if ! grep -q '^HOST_IP=.\+' "$SCRIPT_DIR/.env"; then + DETECTED_IP=$(detect_host_ip) + sed -i '/^HOST_IP=/d' "$SCRIPT_DIR/.env" + echo "HOST_IP=${DETECTED_IP}" >> "$SCRIPT_DIR/.env" + echo " HOST_IP was not set — added HOST_IP=${DETECTED_IP} (edit .env if wrong)." + fi fi # ── 3. Write nginx-proxy/nginx.conf ────────────────────────────────────────── echo "[ 3/5 ] Writing nginx proxy config..." cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF' -# Generated by install.sh — keep in sync with nginx-proxy/nginx.conf in the repo. -# Every proxied location must forward WebSocket upgrades (/api/ws/connect) and -# must not buffer (SSE), otherwise live events never reach waiters / KDS. +# Xenia POS — on-site proxy config. +# install.sh writes this exact file on client sites; nginx-proxy/nginx.conf in the +# repo is a byte-identical copy. Change both together (global Working Rule 9). +# +# :80 waiter.* / manager.* hostnames → redirect to https (legacy domain setup) +# :80 anything else (bare LAN IP) → waiter app over plain HTTP, LAN only +# /manager → redirect to :8081 +# :8081 → manager dashboard over plain HTTP, LAN only +# :443 waiter.* / manager.* / bare IP → https (needs certs/cert.pem + key.pem) +# :4443 → manager over https +# +# Every proxied location forwards WebSocket upgrades (/api/ws/connect) and +# disables buffering (SSE), otherwise live events never reach phones / KDS. + map $http_upgrade $connection_upgrade { default upgrade; '' close; } +# ── Plain HTTP ──────────────────────────────────────────────────────────────── + server { listen 80; + server_name waiter.* manager.*; return 301 https://$host$request_uri; } +server { + listen 80 default_server; + server_name _; + + # LAN only: plain HTTP must never be reachable from the internet, even if the + # client forwards ports on their router. Relies on Docker preserving the real + # client IP (default iptables port publishing on Linux). + allow 10.0.0.0/8; + allow 172.16.0.0/12; + allow 192.168.0.0/16; + allow 127.0.0.0/8; + allow fc00::/7; + allow fe80::/10; + allow ::1; + deny all; + + location ~ ^/manager/?$ { + return 302 http://$host:8081/; + } + + location / { + proxy_pass http://waiter_pwa:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} + +server { + listen 8081 default_server; + server_name _; + + # LAN only: plain HTTP must never be reachable from the internet, even if the + # client forwards ports on their router. Relies on Docker preserving the real + # client IP (default iptables port publishing on Linux). + allow 10.0.0.0/8; + allow 172.16.0.0/12; + allow 192.168.0.0/16; + allow 127.0.0.0/8; + allow fc00::/7; + allow fe80::/10; + allow ::1; + deny all; + + location / { + proxy_pass http://manager_dashboard:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} + +# ── HTTPS ───────────────────────────────────────────────────────────────────── + server { listen 443 ssl; server_name waiter.*; @@ -117,6 +223,7 @@ server { server { listen 443 ssl default_server; + server_name _; ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate_key /etc/nginx/certs/key.pem; @@ -151,39 +258,53 @@ server { proxy_buffering off; } } + +server { + listen 4443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://manager_dashboard:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} EOF # ── 4. SSL certificates ─────────────────────────────────────────────────────── echo "[ 4/5 ] Checking SSL certificates..." +# Phones normally use plain HTTP on the LAN (http://), which needs no +# certificate. nginx still needs *a* cert to start its HTTPS listeners, so when +# none is present we generate a self-signed one. A real certificate (legacy +# domain setup) can replace certs/cert.pem + certs/key.pem at any time. if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then - echo " Certificates already exist — skipping." + echo " Certificates already exist — keeping them." else - echo "" - echo " No certificates found in certs/" - echo "" - echo " DNS requirement:" - echo " Two subdomains must point to this machine's IP:" - echo " waiter.YOURDOMAIN → this machine's IP" - echo " manager.YOURDOMAIN → this machine's IP" - echo " The waiter domain should also be registered in the sysadmin" - echo " panel as the 'Waiter Domain' so phones get the QR code." - echo "" - echo " Option A — Let's Encrypt (recommended):" - echo " sudo apt install certbot" - echo " sudo certbot certonly --manual --preferred-challenges dns \\" - echo " -d waiter.YOURDOMAIN -d manager.YOURDOMAIN" - echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/fullchain.pem certs/cert.pem" - echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/privkey.pem certs/key.pem" - echo "" - echo " Option B — Self-signed / mkcert (local testing only):" - echo " sudo apt install mkcert libnss3-tools" - echo " mkcert -install" - echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem \\" - echo " waiter.YOURDOMAIN manager.YOURDOMAIN" - echo "" - echo " Add certs then run: docker compose up -d" - echo "" + CERT_IP=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2) + CERT_SAN="DNS:localhost,IP:127.0.0.1" + [ -n "$CERT_IP" ] && CERT_SAN="$CERT_SAN,IP:$CERT_IP" + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout "$SCRIPT_DIR/certs/key.pem" \ + -out "$SCRIPT_DIR/certs/cert.pem" \ + -subj "/CN=xenia-pos" \ + -addext "subjectAltName=$CERT_SAN" >/dev/null 2>&1 + chmod 600 "$SCRIPT_DIR/certs/key.pem" + echo " No certificates found — generated a self-signed one (valid 10 years)." + echo " Phones use plain HTTP on the LAN, so this is only for the HTTPS ports." fi # ── 5. Logo ─────────────────────────────────────────────────────────────────── @@ -195,18 +316,20 @@ if [ ! -s "$SCRIPT_DIR/logo.png" ]; then fi # ── Done ───────────────────────────────────────────────────────────────────── +HOST_IP_NOW=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2) +HOST_IP_NOW="${HOST_IP_NOW:-SERVER-IP}" + echo "" echo "=== Setup complete ===" echo "" - -if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then - echo "Starting stack..." - docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d - echo "" - echo "Done! Services running." - echo " Waiter app: https://waiter.YOURDOMAIN" - echo " Manager app: https://manager.YOURDOMAIN" -else - echo "Add SSL certificates to certs/ then run:" - echo " docker compose up -d" -fi +echo "Starting stack..." +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d +echo "" +echo "Done! Services running. From any phone or PC on this network:" +echo " Waiter app: http://${HOST_IP_NOW}" +echo " Manager app: http://${HOST_IP_NOW}/manager (→ port 8081)" +echo "" +echo "Tip: reserve ${HOST_IP_NOW} for this machine in the router's DHCP settings" +echo " so the address never changes." +echo "" +echo "If the proxy config changed, restart it: docker compose restart proxy" diff --git a/nginx-proxy/nginx.conf b/nginx-proxy/nginx.conf index 1f295ae..8c5fb12 100644 --- a/nginx-proxy/nginx.conf +++ b/nginx-proxy/nginx.conf @@ -1,19 +1,101 @@ -# Repo/dev proxy config. install.sh writes its own copy on client sites — keep both in sync. -# Every proxied location must forward WebSocket upgrades (/api/ws/connect) and -# must not buffer (SSE), otherwise live events never reach waiters / KDS. +# Xenia POS — on-site proxy config. +# install.sh writes this exact file on client sites; nginx-proxy/nginx.conf in the +# repo is a byte-identical copy. Change both together (global Working Rule 9). +# +# :80 waiter.* / manager.* hostnames → redirect to https (legacy domain setup) +# :80 anything else (bare LAN IP) → waiter app over plain HTTP, LAN only +# /manager → redirect to :8081 +# :8081 → manager dashboard over plain HTTP, LAN only +# :443 waiter.* / manager.* / bare IP → https (needs certs/cert.pem + key.pem) +# :4443 → manager over https +# +# Every proxied location forwards WebSocket upgrades (/api/ws/connect) and +# disables buffering (SSE), otherwise live events never reach phones / KDS. + map $http_upgrade $connection_upgrade { default upgrade; '' close; } +# ── Plain HTTP ──────────────────────────────────────────────────────────────── + server { listen 80; + server_name waiter.* manager.*; return 301 https://$host$request_uri; } +server { + listen 80 default_server; + server_name _; + + # LAN only: plain HTTP must never be reachable from the internet, even if the + # client forwards ports on their router. Relies on Docker preserving the real + # client IP (default iptables port publishing on Linux). + allow 10.0.0.0/8; + allow 172.16.0.0/12; + allow 192.168.0.0/16; + allow 127.0.0.0/8; + allow fc00::/7; + allow fe80::/10; + allow ::1; + deny all; + + location ~ ^/manager/?$ { + return 302 http://$host:8081/; + } + + location / { + proxy_pass http://waiter_pwa:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} + +server { + listen 8081 default_server; + server_name _; + + # LAN only: plain HTTP must never be reachable from the internet, even if the + # client forwards ports on their router. Relies on Docker preserving the real + # client IP (default iptables port publishing on Linux). + allow 10.0.0.0/8; + allow 172.16.0.0/12; + allow 192.168.0.0/16; + allow 127.0.0.0/8; + allow fc00::/7; + allow fe80::/10; + allow ::1; + deny all; + + location / { + proxy_pass http://manager_dashboard:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} + +# ── HTTPS ───────────────────────────────────────────────────────────────────── + server { listen 443 ssl; - server_name waiter.* _; + server_name waiter.*; ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate_key /etc/nginx/certs/key.pem; @@ -36,8 +118,70 @@ server { } server { - listen 4443 ssl; - server_name manager.* _; + listen 443 ssl; + server_name manager.*; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://manager_dashboard:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} + +server { + listen 443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + + location / { + proxy_pass http://waiter_pwa:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + 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_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +} + +server { + listen 4443 ssl default_server; + server_name _; ssl_certificate /etc/nginx/certs/cert.pem; ssl_certificate_key /etc/nginx/certs/key.pem;