diff --git a/.gitignore b/.gitignore index 8884430..5b09893 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ target data PROJECT.md UPDATE.md +infra/certs diff --git a/README.md b/README.md index 3c92190..b9cb2f8 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ docker compose -f infra/docker-compose.yml up -d | API | 8080 | | LiveKit | 7880 | +Anything other than localhost also wants HTTPS, or browsers will refuse the camera and screen +sharing. See below. + If the host runs a firewall, open the LiveKit ports: ``` @@ -94,6 +97,61 @@ host. LiveKit reads that file literally, so it does not pick up environment vari Leaving `CORS_ORIGINS` on its localhost default while browsing by LAN address is the usual cause of requests failing with a missing `Access-Control-Allow-Origin` header. +### HTTPS + +Browsers only hand out the camera or the screen on a secure origin. `localhost` counts as one, a +LAN address does not, so on `http://192.168.1.45:3000` sharing is refused before PiStation ever +sees the request. That needs a certificate: + +``` +./infra/certs.sh +docker compose -f infra/docker-compose.yml --profile tls up -d +``` + +The script reads the addresses out of `infra/.env`, creates a certificate authority of your own +and issues one certificate covering all of them, plus `localhost` and the machine's LAN address. +Pass `--host` for a name it cannot work out, like a router alias. An nginx container then +terminates TLS in front of all three services: + +| Service | Plain | Encrypted | +| --- | --- | --- | +| Website | 3000 | 3443 | +| API | 8080 | 8443 | +| LiveKit | 7880 | 7443 | + +Point `infra/.env` at the encrypted ports and recreate the stack. The script prints the exact +lines to paste: + +``` +PUBLIC_WEB_URL=https://192.168.1.45:3443 +PUBLIC_API_URL=https://192.168.1.45:8443 +PUBLIC_LIVEKIT_URL=wss://192.168.1.45:7443 +CORS_ORIGINS=https://192.168.1.45:3443 +``` + +All three move together. A page served over HTTPS cannot call a plain API or open a plain +websocket, and the browser blocks the attempt as mixed content. + +Nobody will sign a certificate for an address they do not own, so this one is signed by an +authority that exists only on your machine and no device trusts it yet. Install +`infra/certs/ca.crt` once per device and the warnings stop: + +| Device | Where | +| --- | --- | +| Android | Settings, Security, Encryption and credentials, Install a certificate | +| iOS | Mail it to yourself, open it, install the profile, then trust it under General, About, Certificate Trust Settings | +| macOS | Open it in Keychain Access, add to System, set it to Always Trust | +| Windows | Install into Trusted Root Certification Authorities for the local machine | +| Linux and kiosks | Copy to `/usr/local/share/ca-certificates/` and run `update-ca-certificates` | + +Clicking through the browser warning instead mostly works, but it is three separate warnings, +since each port is its own origin, and it has to be redone on every device. Kiosks get no warning +to click through at all, so they need the authority installed. + +Certificates last 825 days, the longest Safari accepts. Re-running the script issues a fresh one +and keeps the same authority, so devices stay trusted. `--new-ca` replaces the authority and +means installing it everywhere again. + The LiveKit container uses host networking, because WebRTC needs to advertise an address your devices can reach and a container on a bridge network has none. That is Linux only; on macOS or Windows, run LiveKit outside Docker. @@ -128,8 +186,16 @@ Raspberry Pi OS ships with 100 MB of swap, which a 512 MB Zero 2 W exhausts as s browser engine and the media stack are both running. Without more, the kernel starts killing processes and the kiosk appears to restart at random. -Useful flags: `--server`, `--join-url`, `--package-url`, `--user`, `--profile`, `--swap`, -`--skip-reboot`. +On a self signed server, copy `infra/certs/ca.crt` to the Pi first and point the installer at it, +or nothing it downloads will verify: + +``` +scp infra/certs/ca.crt pi@your-kiosk: +curl -fsSL http://your-server:8080/install.sh | sudo bash -s -- --key --ca-cert ./ca.crt +``` + +Useful flags: `--server`, `--join-url`, `--package-url`, `--user`, `--ca-cert`, `--profile`, +`--swap`, `--skip-reboot`. ## Configuration @@ -145,6 +211,7 @@ Set in `infra/.env`: | `PUBLIC_API_URL` / `PUBLIC_LIVEKIT_URL` | Addresses browsers and kiosks use to reach you | | `PUBLIC_WEB_URL` | The address the website is served on. Must match what you type in the browser, scheme included, or server rendered pages disagree with the browser | | `CORS_ORIGINS` | Browser origins allowed to call the API, comma separated. Must include `PUBLIC_WEB_URL`. Kiosks are exempt, since a desktop app has no fixed web origin | +| `TLS_WEB_PORT` / `TLS_API_PORT` / `TLS_LIVEKIT_PORT` | Ports the `tls` profile serves HTTPS on, defaulting to 3443, 8443 and 7443 | Video encoding on the kiosk is tunable per device, without rebuilding: diff --git a/infra/.env.example b/infra/.env.example index ade3147..2999597 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -5,6 +5,10 @@ PUBLIC_LIVEKIT_URL=ws://localhost:7880 PUBLIC_API_URL=http://localhost:8080 PUBLIC_WEB_URL=http://localhost:3000 +TLS_WEB_PORT=3443 +TLS_API_PORT=8443 +TLS_LIVEKIT_PORT=7443 + SESSION_SECRET=change-me-to-a-long-random-string SESSION_TTL_HOURS=12 diff --git a/infra/certs.sh b/infra/certs.sh new file mode 100755 index 0000000..1321576 --- /dev/null +++ b/infra/certs.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" +OUT_DIR="$SCRIPT_DIR/certs" +LEAF_DAYS=825 +CA_DAYS=3650 +NEW_CA="no" +EXTRA_HOSTS=() + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'USAGE' +PiStation self signed certificates + + ./infra/certs.sh [options] + +Creates a local certificate authority and a server certificate covering every +address in your .env, so browsers treat the site as secure and allow camera and +screen sharing. Install certs/ca.crt on each device once and every PiStation +address is trusted. + +Options + --env Read addresses from this file, defaults to infra/.env + --out Write certificates here, defaults to infra/certs + --host Cover an extra hostname or IP, repeatable + --days How long the server certificate lasts, defaults to 825 + --new-ca Replace the authority instead of reusing it + --help Show this message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --env) ENV_FILE="${2:-}"; shift 2 ;; + --env=*) ENV_FILE="${1#*=}"; shift ;; + --out) OUT_DIR="${2:-}"; shift 2 ;; + --out=*) OUT_DIR="${1#*=}"; shift ;; + --host) EXTRA_HOSTS+=("${2:-}"); shift 2 ;; + --host=*) EXTRA_HOSTS+=("${1#*=}"); shift ;; + --days) LEAF_DAYS="${2:-}"; shift 2 ;; + --days=*) LEAF_DAYS="${1#*=}"; shift ;; + --new-ca) NEW_CA="yes"; shift ;; + --help|-h) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +command -v openssl >/dev/null 2>&1 || fail "openssl is not installed" + +env_value() { + [[ -f "$ENV_FILE" ]] || return 0 + sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$ENV_FILE" | + tail -n 1 | + tr -d '"'"'"'\r' +} + +url_host() { + local value="${1#*://}" + value="${value%%/*}" + value="${value%%\?*}" + printf '%s' "${value%%:*}" +} + +lan_address() { + ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -n 1 +} + +is_ip() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +HOSTS=(localhost 127.0.0.1) + +add_host() { + local candidate="$1" + [[ -n "$candidate" ]] || return 0 + local existing + for existing in "${HOSTS[@]}"; do + [[ "$existing" == "$candidate" ]] && return 0 + done + HOSTS+=("$candidate") +} + +PRIMARY="" + +for key in PUBLIC_WEB_URL PUBLIC_API_URL PUBLIC_LIVEKIT_URL; do + host="$(url_host "$(env_value "$key")")" + [[ -n "$host" ]] || continue + [[ -n "$PRIMARY" || "$host" == "localhost" ]] || PRIMARY="$host" + add_host "$host" +done + +for host in ${EXTRA_HOSTS[@]+"${EXTRA_HOSTS[@]}"}; do + [[ -n "$PRIMARY" ]] || PRIMARY="$host" + add_host "$host" +done + +add_host "$(lan_address)" + +if [[ -z "$PRIMARY" ]]; then + PRIMARY="$(lan_address)" + [[ -n "$PRIMARY" ]] || PRIMARY="localhost" + warn "no LAN address found in $ENV_FILE, falling back to $PRIMARY" +fi + +mkdir -p "$OUT_DIR" + +CA_KEY="$OUT_DIR/ca.key" +CA_CRT="$OUT_DIR/ca.crt" +LEAF_KEY="$OUT_DIR/pistation.key" +LEAF_CRT="$OUT_DIR/pistation.crt" + +if [[ "$NEW_CA" == "yes" || ! -f "$CA_KEY" || ! -f "$CA_CRT" ]]; then + log "creating certificate authority" + openssl req -x509 -newkey rsa:4096 -sha256 -nodes \ + -days "$CA_DAYS" \ + -keyout "$CA_KEY" \ + -out "$CA_CRT" \ + -subj "/CN=PiStation Local CA/O=PiStation" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1 +else + log "reusing certificate authority at $CA_CRT" +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +{ + printf '[req]\ndistinguished_name = dn\nprompt = no\n\n[dn]\nCN = %s\nO = PiStation\n\n' "$PRIMARY" + printf '[ext]\nbasicConstraints = critical, CA:FALSE\n' + printf 'keyUsage = critical, digitalSignature, keyEncipherment\n' + printf 'extendedKeyUsage = serverAuth\nsubjectAltName = @alt\n\n[alt]\n' + + dns_index=0 + ip_index=0 + for host in "${HOSTS[@]}"; do + if is_ip "$host"; then + ip_index=$((ip_index + 1)) + printf 'IP.%d = %s\n' "$ip_index" "$host" + else + dns_index=$((dns_index + 1)) + printf 'DNS.%d = %s\n' "$dns_index" "$host" + fi + done +} >"$WORK_DIR/leaf.cnf" + +log "issuing certificate for $PRIMARY" +openssl genrsa -out "$LEAF_KEY" 2048 >/dev/null 2>&1 +openssl req -new -key "$LEAF_KEY" -out "$WORK_DIR/leaf.csr" -config "$WORK_DIR/leaf.cnf" >/dev/null 2>&1 +openssl x509 -req -sha256 \ + -in "$WORK_DIR/leaf.csr" \ + -CA "$CA_CRT" \ + -CAkey "$CA_KEY" \ + -CAcreateserial \ + -days "$LEAF_DAYS" \ + -extfile "$WORK_DIR/leaf.cnf" \ + -extensions ext \ + -out "$WORK_DIR/leaf.crt" >/dev/null 2>&1 + +cat "$WORK_DIR/leaf.crt" "$CA_CRT" >"$LEAF_CRT" +chmod 600 "$CA_KEY" "$LEAF_KEY" +chmod 644 "$CA_CRT" "$LEAF_CRT" + +WEB_PORT="$(env_value TLS_WEB_PORT)" +API_PORT="$(env_value TLS_API_PORT)" +LIVEKIT_PORT="$(env_value TLS_LIVEKIT_PORT)" +WEB_PORT="${WEB_PORT:-3443}" +API_PORT="${API_PORT:-8443}" +LIVEKIT_PORT="${LIVEKIT_PORT:-7443}" + +log "done, valid for $LEAF_DAYS days" +echo +echo " covers: ${HOSTS[*]}" +echo +echo "Start the stack with TLS:" +echo +echo " docker compose -f infra/docker-compose.yml --profile tls up -d" +echo +echo "Then point $ENV_FILE at the encrypted ports:" +echo +echo " PUBLIC_WEB_URL=https://${PRIMARY}:${WEB_PORT}" +echo " PUBLIC_API_URL=https://${PRIMARY}:${API_PORT}" +echo " PUBLIC_LIVEKIT_URL=wss://${PRIMARY}:${LIVEKIT_PORT}" +echo " CORS_ORIGINS=https://${PRIMARY}:${WEB_PORT},http://localhost:3000,http://localhost:5173" +echo +echo "Install $CA_CRT on every device that connects, otherwise the browser" +echo "keeps warning and still refuses the camera." +echo diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 4f6f56b..753a196 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -55,5 +55,23 @@ services: ports: - "3000:3000" + proxy: + image: nginx:alpine + restart: unless-stopped + profiles: + - tls + depends_on: + - server + - web + volumes: + - ./proxy.conf:/etc/nginx/conf.d/pistation.conf:ro + - ./certs:/etc/nginx/certs:ro + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "${TLS_WEB_PORT:-3443}:3443" + - "${TLS_API_PORT:-8443}:8443" + - "${TLS_LIVEKIT_PORT:-7443}:7443" + volumes: server-data: diff --git a/infra/proxy.conf b/infra/proxy.conf new file mode 100644 index 0000000..dd9c8ef --- /dev/null +++ b/infra/proxy.conf @@ -0,0 +1,49 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +ssl_certificate /etc/nginx/certs/pistation.crt; +ssl_certificate_key /etc/nginx/certs/pistation.key; +ssl_protocols TLSv1.2 TLSv1.3; +ssl_session_cache shared:pistation:4m; + +client_max_body_size 0; +proxy_http_version 1.1; +proxy_set_header Host $http_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 https; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; +proxy_read_timeout 1h; +proxy_send_timeout 1h; + +server { + listen 3443 ssl; + http2 on; + server_name _; + + location / { + proxy_pass http://web:3000; + } +} + +server { + listen 8443 ssl; + http2 on; + server_name _; + + location / { + proxy_pass http://server:8080; + } +} + +server { + listen 7443 ssl; + server_name _; + + location / { + proxy_pass http://host.docker.internal:7880; + } +} diff --git a/server/assets/install.sh b/server/assets/install.sh index ab5dcb7..68f4701 100644 --- a/server/assets/install.sh +++ b/server/assets/install.sh @@ -6,6 +6,7 @@ PACKAGE_URL="" ENROLLMENT_KEY="" JOIN_URL="" KIOSK_USER="pistation" +CA_CERT="" SKIP_REBOOT="no" FORCED_PROFILE="" FORCED_SWAP="" @@ -26,6 +27,7 @@ Options --join-url Address shown on screen for people joining, defaults to the server host --package-url Override where the kiosk .deb is downloaded from --user System user to run the kiosk as, defaults to pistation + --ca-cert Trust this certificate authority, needed for a self signed server --profile Force a video profile: zero2, pi4, pi5 or generic --swap Swap size in MB, defaults to the profile's value --skip-reboot Install and enable, but do not reboot at the end @@ -45,6 +47,8 @@ while [[ $# -gt 0 ]]; do --package-url=*) PACKAGE_URL="${1#*=}"; shift ;; --user) KIOSK_USER="${2:-}"; shift 2 ;; --user=*) KIOSK_USER="${1#*=}"; shift ;; + --ca-cert) CA_CERT="${2:-}"; shift 2 ;; + --ca-cert=*) CA_CERT="${1#*=}"; shift ;; --profile) FORCED_PROFILE="${2:-}"; shift 2 ;; --profile=*) FORCED_PROFILE="${1#*=}"; shift ;; --swap) FORCED_SWAP="${2:-}"; shift 2 ;; @@ -150,6 +154,24 @@ apt-get install -y --no-install-recommends \ libnice10 \ libgles2 +# A kiosk has nobody to click through a certificate warning, so a self signed server has to be +# trusted here or every request it makes fails, starting with the package download below. +if [[ -n "$CA_CERT" ]]; then + log "trusting the certificate authority at $CA_CERT" + CA_TARGET="/usr/local/share/ca-certificates/pistation-ca.crt" + + if [[ "$CA_CERT" == http://* || "$CA_CERT" == https://* ]]; then + curl -fsSL "$CA_CERT" -o "$CA_TARGET" || + fail "could not download the certificate authority from $CA_CERT" + else + [[ -f "$CA_CERT" ]] || fail "no certificate authority at $CA_CERT" + cp "$CA_CERT" "$CA_TARGET" + fi + + chmod 644 "$CA_TARGET" + update-ca-certificates >/dev/null || fail "the certificate authority was rejected" +fi + log "downloading the kiosk package from $PACKAGE_URL" PACKAGE_FILE="$(mktemp /tmp/pistation-kiosk.XXXXXX.deb)" trap 'rm -f "$PACKAGE_FILE"' EXIT