- new :8443 server (both copies byte-identical): TLS with the backend's
data/tls key+cert, LAN-only allow list, proxies the whole waiter
origin incl. /api/ws/ upgrades
- compose: backend healthcheck; proxy waits for backend healthy (TLS files
exist) and mounts ${DATA_PATH}/tls read-only; publishes 8443;
TLS_PORT passed to the backend so it advertises the published port
Verified on an isolated stack: served key == advertised pin, identity and
WebSocket over TLS, proxy starts only after the backend is healthy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
15 KiB
Bash
395 lines
15 KiB
Bash
#!/bin/bash
|
|
# Xenia POS — first-time install script
|
|
# Run this on the client machine before starting the stack.
|
|
# Usage: bash install.sh
|
|
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
|
# LAN IP of this machine on the PHYSICAL network (the address phones use:
|
|
# http://<HOST_IP>). Must never be a VPN address: with a full-tunnel WireGuard /
|
|
# Tailscale / ZeroTier client, "the interface that reaches the internet" is the
|
|
# tunnel, which phones on the restaurant WiFi can't reach.
|
|
# Real NICs (Ethernet, WiFi) have /sys/class/net/<if>/device; VPN tunnels,
|
|
# Docker bridges, veths and loopback don't.
|
|
# XENIA_SYS_NET overrides the sysfs path (tests only).
|
|
detect_host_ip() {
|
|
local sys_net="${XENIA_SYS_NET:-/sys/class/net}" ifc ip=""
|
|
command -v ip >/dev/null 2>&1 || { hostname -I 2>/dev/null | awk '{print $1}'; return; }
|
|
|
|
first_ipv4() { ip -4 -o addr show dev "$1" 2>/dev/null | awk '{split($4, a, "/"); print a[1]; exit}'; }
|
|
|
|
# 1) Interface of the main-table default route, if it's real hardware.
|
|
# (wg-quick full-tunnel routing uses policy rules + its own table, so the
|
|
# main table's default route still points at the physical uplink.)
|
|
ifc=$(ip route show default 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')
|
|
if [ -n "$ifc" ] && [ -e "$sys_net/$ifc/device" ]; then
|
|
ip=$(first_ipv4 "$ifc")
|
|
fi
|
|
# 2) Otherwise the first real-hardware interface that has an IPv4 address.
|
|
if [ -z "$ip" ]; then
|
|
for path in "$sys_net"/*; do
|
|
ifc=$(basename "$path")
|
|
[ -e "$path/device" ] || continue
|
|
ip=$(first_ipv4 "$ifc")
|
|
[ -n "$ip" ] && break
|
|
done
|
|
fi
|
|
# 3) Last resort — the admin confirms it at the prompt anyway.
|
|
[ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
|
echo "$ip"
|
|
}
|
|
|
|
echo "=== Xenia POS Install ==="
|
|
echo ""
|
|
|
|
# ── 1. Create required directories ───────────────────────────────────────────
|
|
echo "[ 1/5 ] Creating directories..."
|
|
mkdir -p "$SCRIPT_DIR/data"
|
|
mkdir -p "$SCRIPT_DIR/certs"
|
|
mkdir -p "$SCRIPT_DIR/nginx-proxy"
|
|
mkdir -p /opt/xenia/data
|
|
touch /opt/xenia/logo.png 2>/dev/null || true
|
|
|
|
# ── 2. Create .env from .env.example if missing ───────────────────────────────
|
|
echo "[ 2/5 ] Configuring environment..."
|
|
|
|
if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
|
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
|
|
echo ""
|
|
echo " A .env file has been created from .env.example."
|
|
echo " You must fill in SITE_ID, SITE_KEY, and SECRET_KEY before starting."
|
|
echo ""
|
|
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.gr"
|
|
echo " Generate SECRET_KEY with: openssl rand -hex 32"
|
|
echo ""
|
|
|
|
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)
|
|
echo " Generated SECRET_KEY: $INPUT_SECRET_KEY"
|
|
fi
|
|
|
|
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 — 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'
|
|
# 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
|
|
# :8443 → native app over TLS (backend-managed key, pinned), LAN only
|
|
#
|
|
# 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.*;
|
|
|
|
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://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 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 8443 ssl default_server;
|
|
server_name _;
|
|
|
|
# Encrypted LAN entry for the native app (plan step 7). Key + self-signed cert
|
|
# are created and renewed by the backend (services/tls_identity.py) under
|
|
# ${DATA_PATH}/tls; phones pin the public key, so renewals need no action.
|
|
ssl_certificate /etc/nginx/xenia-tls/cert.pem;
|
|
ssl_certificate_key /etc/nginx/xenia-tls/key.pem;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
|
|
# 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://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;
|
|
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://<HOST_IP>), 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 — keeping them."
|
|
else
|
|
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 ───────────────────────────────────────────────────────────────────
|
|
echo "[ 5/5 ] Checking logo..."
|
|
if [ ! -s "$SCRIPT_DIR/logo.png" ]; then
|
|
echo " WARNING: logo.png not found or is empty."
|
|
echo " Place your restaurant logo at: $SCRIPT_DIR/logo.png"
|
|
touch "$SCRIPT_DIR/logo.png"
|
|
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 ""
|
|
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"
|