#!/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://). 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//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 # # 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 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 — 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"