Serve the site over HTTPS with self signed certificates

This commit is contained in:
2026-08-10 13:34:14 -04:00
parent 404a35e1b1
commit f002c1416c
7 changed files with 357 additions and 2 deletions
+1
View File
@@ -15,3 +15,4 @@ target
data data
PROJECT.md PROJECT.md
UPDATE.md UPDATE.md
infra/certs
+69 -2
View File
@@ -72,6 +72,9 @@ docker compose -f infra/docker-compose.yml up -d
| API | 8080 | | API | 8080 |
| LiveKit | 7880 | | 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: 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 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. 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 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 devices can reach and a container on a bridge network has none. That is Linux only; on macOS or
Windows, run LiveKit outside Docker. 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 browser engine and the media stack are both running. Without more, the kernel starts killing
processes and the kiosk appears to restart at random. processes and the kiosk appears to restart at random.
Useful flags: `--server`, `--join-url`, `--package-url`, `--user`, `--profile`, `--swap`, On a self signed server, copy `infra/certs/ca.crt` to the Pi first and point the installer at it,
`--skip-reboot`. 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 <token> --ca-cert ./ca.crt
```
Useful flags: `--server`, `--join-url`, `--package-url`, `--user`, `--ca-cert`, `--profile`,
`--swap`, `--skip-reboot`.
## Configuration ## Configuration
@@ -145,6 +211,7 @@ Set in `infra/.env`:
| `PUBLIC_API_URL` / `PUBLIC_LIVEKIT_URL` | Addresses browsers and kiosks use to reach you | | `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 | | `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 | | `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: Video encoding on the kiosk is tunable per device, without rebuilding:
+4
View File
@@ -5,6 +5,10 @@ PUBLIC_LIVEKIT_URL=ws://localhost:7880
PUBLIC_API_URL=http://localhost:8080 PUBLIC_API_URL=http://localhost:8080
PUBLIC_WEB_URL=http://localhost:3000 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_SECRET=change-me-to-a-long-random-string
SESSION_TTL_HOURS=12 SESSION_TTL_HOURS=12
Executable
+194
View File
@@ -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 <file> Read addresses from this file, defaults to infra/.env
--out <dir> Write certificates here, defaults to infra/certs
--host <name> Cover an extra hostname or IP, repeatable
--days <n> 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
+18
View File
@@ -55,5 +55,23 @@ services:
ports: ports:
- "3000:3000" - "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: volumes:
server-data: server-data:
+49
View File
@@ -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;
}
}
+22
View File
@@ -6,6 +6,7 @@ PACKAGE_URL=""
ENROLLMENT_KEY="" ENROLLMENT_KEY=""
JOIN_URL="" JOIN_URL=""
KIOSK_USER="pistation" KIOSK_USER="pistation"
CA_CERT=""
SKIP_REBOOT="no" SKIP_REBOOT="no"
FORCED_PROFILE="" FORCED_PROFILE=""
FORCED_SWAP="" FORCED_SWAP=""
@@ -26,6 +27,7 @@ Options
--join-url <host> Address shown on screen for people joining, defaults to the server host --join-url <host> Address shown on screen for people joining, defaults to the server host
--package-url <url> Override where the kiosk .deb is downloaded from --package-url <url> Override where the kiosk .deb is downloaded from
--user <name> System user to run the kiosk as, defaults to pistation --user <name> System user to run the kiosk as, defaults to pistation
--ca-cert <path|url> Trust this certificate authority, needed for a self signed server
--profile <name> Force a video profile: zero2, pi4, pi5 or generic --profile <name> Force a video profile: zero2, pi4, pi5 or generic
--swap <mb> Swap size in MB, defaults to the profile's value --swap <mb> Swap size in MB, defaults to the profile's value
--skip-reboot Install and enable, but do not reboot at the end --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 ;; --package-url=*) PACKAGE_URL="${1#*=}"; shift ;;
--user) KIOSK_USER="${2:-}"; shift 2 ;; --user) KIOSK_USER="${2:-}"; shift 2 ;;
--user=*) KIOSK_USER="${1#*=}"; shift ;; --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="${2:-}"; shift 2 ;;
--profile=*) FORCED_PROFILE="${1#*=}"; shift ;; --profile=*) FORCED_PROFILE="${1#*=}"; shift ;;
--swap) FORCED_SWAP="${2:-}"; shift 2 ;; --swap) FORCED_SWAP="${2:-}"; shift 2 ;;
@@ -150,6 +154,24 @@ apt-get install -y --no-install-recommends \
libnice10 \ libnice10 \
libgles2 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" log "downloading the kiosk package from $PACKAGE_URL"
PACKAGE_FILE="$(mktemp /tmp/pistation-kiosk.XXXXXX.deb)" PACKAGE_FILE="$(mktemp /tmp/pistation-kiosk.XXXXXX.deb)"
trap 'rm -f "$PACKAGE_FILE"' EXIT trap 'rm -f "$PACKAGE_FILE"' EXIT