diff --git a/docker-bundle/.env.example b/docker-bundle/.env.example index c488ff3..00046d9 100644 --- a/docker-bundle/.env.example +++ b/docker-bundle/.env.example @@ -9,6 +9,7 @@ PUBLIC_SCHEME=https APP_DOMAIN=nyone.example.com HLS_DOMAIN=nyone-hls.example.com APP_HTTP_PORT=80 +HLS_HTTP_PORT=127.0.0.1:8888 RTMP_PUBLIC_PORT=1935 APP_NAME=Nyone diff --git a/docker-bundle/README.md b/docker-bundle/README.md index ec2047e..2774c20 100644 --- a/docker-bundle/README.md +++ b/docker-bundle/README.md @@ -14,14 +14,14 @@ This folder is a self-contained deployment bundle. A server only needs the `dock Default ports use the services' normal defaults: -- HTTP app/HLS proxy: `80` +- HTTP app proxy: `80` - RTMP ingest: `1935` -- MediaMTX HLS inside Docker: `8888` +- MediaMTX HLS on localhost: `8888` - MediaMTX API inside Docker: `9997` - PostgreSQL inside Docker: `5432` - Redis inside Docker: `6379` -Only HTTP and RTMP are published by default. HLS is served through Apache on `HLS_DOMAIN`, not directly from MediaMTX. +HTTP and RTMP are published by default. HLS is published only on `127.0.0.1:8888` by default so host Nginx can proxy `HLS_DOMAIN` directly to MediaMTX without exposing the HLS port publicly. ## Server Requirements @@ -33,7 +33,7 @@ Only HTTP and RTMP are published by default. HLS is served through Apache on `HL - DNS for both `APP_DOMAIN` and `HLS_DOMAIN`. - External TLS termination through a reverse proxy, load balancer, or CDN. -If a host-level reverse proxy already uses port `80`, set `APP_HTTP_PORT` to another local port and forward both hostnames to that port. +If a host-level reverse proxy already uses port `80`, set `APP_HTTP_PORT` to another local port for the app and keep `HLS_HTTP_PORT` on a local MediaMTX port for HLS. ## First Deployment @@ -78,6 +78,7 @@ Important values: - `APP_DOMAIN`: Laravel app hostname. - `HLS_DOMAIN`: HLS playback hostname. Must differ from `APP_DOMAIN`. - `APP_HTTP_PORT`: host port published by Apache, default `80`. +- `HLS_HTTP_PORT`: host address published by MediaMTX HLS, default `127.0.0.1:8888`. - `RTMP_PUBLIC_PORT`: host RTMP port, default `1935`. - `PUBLIC_SCHEME`: usually `https` behind external TLS. - `SEED_DATABASE`: defaults to `false`; set to `true` only if you intentionally want the app seeder to run. @@ -136,7 +137,7 @@ Do not remove volumes unless you intend to delete database, Redis, and stored ap ## External Proxy Notes -Forward both `APP_DOMAIN` and `HLS_DOMAIN` to the Apache container's published HTTP port. Preserve the `Host` header and set `X-Forwarded-Proto: https` when TLS is terminated outside Docker. +Forward `APP_DOMAIN` to the Apache container's published HTTP port. Forward `HLS_DOMAIN` directly to the MediaMTX HLS localhost port. Preserve the `Host` header for the Laravel app and set `X-Forwarded-Proto: https` when TLS is terminated outside Docker. Expose TCP `1935` publicly for OBS/RTMP ingest. Do not expose MediaMTX API port `9997` publicly. @@ -146,13 +147,14 @@ Use this pattern when Nginx runs on the host and handles public HTTP/HTTPS. In t ```env APP_HTTP_PORT=127.0.0.1:8080 +HLS_HTTP_PORT=127.0.0.1:8888 PUBLIC_SCHEME=https APP_DOMAIN=app.example.com HLS_DOMAIN=hls.example.com RTMP_PUBLIC_PORT=1935 ``` -Run `./scripts/deploy.sh` after changing `.env`, then point Nginx at `http://127.0.0.1:8080`. Replace the example domains and certificate paths before enabling the config. +Run `./scripts/deploy.sh` after changing `.env`, then point Nginx app traffic at `http://127.0.0.1:8080` and HLS traffic at `http://127.0.0.1:8888`. Replace the example domains and certificate paths before enabling the config. ```nginx # /etc/nginx/sites-available/nyone-docker-bundle.conf @@ -203,7 +205,11 @@ server { ssl_certificate_key /etc/letsencrypt/live/hls.example.com/privkey.pem; location / { - proxy_pass http://127.0.0.1:8080; + if ($request_method = OPTIONS) { + return 204; + } + + proxy_pass http://127.0.0.1:8888; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -215,6 +221,10 @@ server { proxy_request_buffering off; proxy_read_timeout 60s; proxy_send_timeout 60s; + add_header Access-Control-Allow-Origin "https://app.example.com" always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always; + add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; } } @@ -229,3 +239,15 @@ sudo systemctl reload nginx ``` Keep RTMP separate from this HTTP proxy. Open TCP `1935` to the Docker host so OBS can publish directly to MediaMTX. + +## HLS CORS Check + +Host Nginx owns the public HLS CORS headers. MediaMTX is configured with `hlsAllowOrigins: []` so the response does not include duplicate `Access-Control-Allow-Origin` headers after proxying. + +Check the deployed headers with the exact app origin: + +```bash +curl -I -H "Origin: https://app.example.com" "https://hls.example.com/channel/index.m3u8" +``` + +The response should contain one `Access-Control-Allow-Origin` header matching the app origin. If two `Access-Control-Allow-Origin` headers appear, rebuild the MediaMTX container from this bundle and reload Nginx. diff --git a/docker-bundle/SKILL.md b/docker-bundle/SKILL.md index 712337a..3edb1f6 100644 --- a/docker-bundle/SKILL.md +++ b/docker-bundle/SKILL.md @@ -11,7 +11,7 @@ description: Work on the Nyone docker-bundle deployment bundle under docker-bund - Treat `docker-bundle/.env` and `docker-bundle/runtime/` as server-local generated state; do not commit or depend on them. - Do not make this bundle depend on the cloned app repository's `.env` or `deploy/mediamtx.yml`. - Keep MediaMTX config Docker-owned at `docker/mediamtx/mediamtx.yml.template`. -- Keep `APP_DOMAIN` and `HLS_DOMAIN` separate because Apache uses separate virtual hosts. +- Keep `APP_DOMAIN` and `HLS_DOMAIN` separate because the app and HLS are served by different host-level Nginx virtual hosts. - Do not run `scripts/deploy.sh`, `docker compose up`, or image builds unless the user confirms this is a deployment server. ## Architecture @@ -21,19 +21,20 @@ description: Work on the Nyone docker-bundle deployment bundle under docker-bund - `docker-compose.yml` defines `app`, `db`, `redis`, `mediamtx`, `queue`, `scheduler`, `viewer-sync`, and one-shot `migrate`. - `docker/app/Dockerfile` builds the Laravel Apache image from `runtime/source`. - `docker/app/entrypoint.sh` selects behavior by `CONTAINER_ROLE`. -- `docker/app/apache/nyone.conf.template` serves Laravel on `APP_DOMAIN` and proxies HLS for `HLS_DOMAIN`. +- `docker/app/apache/nyone.conf.template` serves Laravel on `APP_DOMAIN`. +- Host Nginx should proxy `HLS_DOMAIN` directly to the locally published MediaMTX HLS port. - `docker/mediamtx/entrypoint.sh` renders the MediaMTX template at container start. ## Port Defaults - Apache published HTTP: `80`. - MediaMTX RTMP: `1935`. -- MediaMTX HLS internal: `8888`. +- MediaMTX HLS local publish/internal: `8888`. - MediaMTX API internal: `9997`. - PostgreSQL internal: `5432`. - Redis internal: `6379`. -Only publish HTTP and RTMP by default. Keep MediaMTX HLS behind Apache and keep the API private on the Docker network. +Publish Apache HTTP and RTMP by default, and bind MediaMTX HLS to `127.0.0.1:8888` for host Nginx. Keep the MediaMTX API private on the Docker network. ## Editing Guidance @@ -51,9 +52,11 @@ Only publish HTTP and RTMP by default. Keep MediaMTX HLS behind Apache and keep ## Nginx TLS Proxy Guidance - Document Nginx as a host-level TLS reverse proxy in `README.md`; do not add Nginx as another Compose service unless the user asks. -- Keep Docker Apache as the upstream for both app and HLS hostnames. Nginx should proxy both domains to the published Apache port and preserve the `Host` header so Apache can select the correct virtual host. +- Keep Docker Apache as the upstream for the app hostname only. Nginx should proxy HLS directly to the locally published MediaMTX HLS port. - Recommend `APP_HTTP_PORT=127.0.0.1:8080` when Nginx owns host ports `80` and `443`. +- Recommend `HLS_HTTP_PORT=127.0.0.1:8888` so HLS is reachable by host Nginx but not publicly exposed as a raw port. - Include `X-Forwarded-Proto https`, `X-Forwarded-Port 443`, `X-Forwarded-Host`, `X-Real-IP`, and `X-Forwarded-For` in proxy examples. +- Let host Nginx own public HLS CORS headers. Keep `hlsAllowOrigins: []` in the MediaMTX template so proxied HLS responses do not contain duplicate `Access-Control-Allow-Origin` values. - Keep HLS proxy examples buffering off and cache headers set to no-store. - Keep RTMP outside the HTTP proxy guidance unless explicitly using Nginx stream proxying; the default is direct TCP `1935` to MediaMTX through Docker. diff --git a/docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md b/docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md index bb9e222..592a61a 100644 --- a/docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md +++ b/docker-bundle/UBUNTU_VPS_NYONE_APP_DEPLOYMENT.md @@ -8,6 +8,7 @@ It assumes: - HLS playback domain: `hls.nyone.app` - RTMP ingest URL: `rtmp://nyone.app:1935` - Docker Apache is private on `127.0.0.1:8080` +- Docker MediaMTX HLS is private on `127.0.0.1:8888` - Host Nginx handles public HTTP, HTTPS, and SSL certificates The application repository itself does not need to exist on the server before deployment. The deploy script clones it into `docker-bundle/runtime/source`. @@ -56,7 +57,7 @@ ufw --force enable ufw status ``` -Docker can publish container ports outside some UFW rules. In this deployment, Apache is bound to `127.0.0.1:8080`, while RTMP is intentionally public on `1935`. +Docker can publish container ports outside some UFW rules. In this deployment, Apache is bound to `127.0.0.1:8080`, MediaMTX HLS is bound to `127.0.0.1:8888`, and RTMP is intentionally public on `1935`. ## 4. Install Docker Engine and Compose plugin @@ -135,6 +136,7 @@ PUBLIC_SCHEME=https APP_DOMAIN=nyone.app HLS_DOMAIN=hls.nyone.app APP_HTTP_PORT=127.0.0.1:8080 +HLS_HTTP_PORT=127.0.0.1:8888 RTMP_PUBLIC_PORT=1935 APP_NAME=Nyone @@ -329,7 +331,11 @@ server { ssl_certificate_key /etc/letsencrypt/live/nyone.app/privkey.pem; location / { - proxy_pass http://127.0.0.1:8080; + if ($request_method = OPTIONS) { + return 204; + } + + proxy_pass http://127.0.0.1:8888; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -341,6 +347,10 @@ server { proxy_request_buffering off; proxy_read_timeout 60s; proxy_send_timeout 60s; + add_header Access-Control-Allow-Origin "https://nyone.app" always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always; + add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always; } } @@ -385,6 +395,14 @@ Run one viewer-count sync pass: docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v ``` +Check HLS CORS headers: + +```bash +curl -I -H "Origin: https://nyone.app" "https://hls.nyone.app//index.m3u8" +``` + +The response should include exactly one `Access-Control-Allow-Origin: https://nyone.app` header. A `404` can be normal when the channel is not currently publishing, but duplicate `Access-Control-Allow-Origin` headers mean both Nginx and an upstream are setting CORS; rebuild the MediaMTX container from this bundle and reload Nginx. + Check that MediaMTX API is not exposed on the host. This should fail because the API is only available inside the Docker network: ```bash diff --git a/docker-bundle/docker-compose.yml b/docker-bundle/docker-compose.yml index 3876066..2a3e30f 100644 --- a/docker-bundle/docker-compose.yml +++ b/docker-bundle/docker-compose.yml @@ -59,7 +59,6 @@ x-app-environment: &app-environment MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-} APP_DOMAIN: ${APP_DOMAIN:-nyone.example.com} HLS_DOMAIN: ${HLS_DOMAIN:-nyone-hls.example.com} - HLS_INTERNAL_PORT: 8888 APP_OPTIMIZE_ON_BOOT: ${APP_OPTIMIZE_ON_BOOT:-true} SEED_DATABASE: ${SEED_DATABASE:-false} @@ -142,13 +141,12 @@ services: dockerfile: docker/mediamtx/Dockerfile restart: unless-stopped environment: - APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com} - HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com} MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-} RTMP_INTERNAL_PORT: 1935 HLS_INTERNAL_PORT: 8888 ports: - "${RTMP_PUBLIC_PORT:-1935}:1935" + - "${HLS_HTTP_PORT:-127.0.0.1:8888}:8888" volumes: - app-storage:/var/www/html/storage depends_on: diff --git a/docker-bundle/docker/app/Dockerfile b/docker-bundle/docker/app/Dockerfile index c2ee589..05fcff0 100644 --- a/docker-bundle/docker/app/Dockerfile +++ b/docker-bundle/docker/app/Dockerfile @@ -33,7 +33,7 @@ RUN apt-get update \ zip \ && pecl install redis \ && docker-php-ext-enable redis \ - && a2enmod headers proxy proxy_http rewrite setenvif \ + && a2enmod headers rewrite setenvif \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/docker-bundle/docker/app/apache/nyone.conf.template b/docker-bundle/docker/app/apache/nyone.conf.template index 40593a3..52d30f5 100644 --- a/docker-bundle/docker/app/apache/nyone.conf.template +++ b/docker-bundle/docker/app/apache/nyone.conf.template @@ -22,22 +22,3 @@ ServerName ${APP_DOMAIN} ErrorLog /proc/self/fd/2 CustomLog /proc/self/fd/1 combined - - - ServerName ${HLS_DOMAIN} - - ProxyRequests Off - ProxyPreserveHost On - - ProxyPass "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" retry=0 - ProxyPassReverse "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" - - Header always set Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" - Header always set Access-Control-Allow-Origin "${APP_URL}" - Header always set Access-Control-Allow-Methods "GET, HEAD, OPTIONS" - Header always set Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" - - ErrorLog /proc/self/fd/2 - CustomLog /proc/self/fd/1 combined - - diff --git a/docker-bundle/docker/app/entrypoint.sh b/docker-bundle/docker/app/entrypoint.sh index e63f255..8e8c1f4 100644 --- a/docker-bundle/docker/app/entrypoint.sh +++ b/docker-bundle/docker/app/entrypoint.sh @@ -41,9 +41,9 @@ artisan() { } render_apache_config() { - export APP_DOMAIN HLS_DOMAIN HLS_INTERNAL_PORT APP_URL + export APP_DOMAIN - envsubst '${APP_DOMAIN} ${HLS_DOMAIN} ${HLS_INTERNAL_PORT} ${APP_URL}' \ + envsubst '${APP_DOMAIN}' \ < /etc/apache2/sites-available/nyone.conf.template \ > /etc/apache2/sites-available/000-default.conf } diff --git a/docker-bundle/docker/mediamtx/entrypoint.sh b/docker-bundle/docker/mediamtx/entrypoint.sh index 24fe033..9ad0577 100644 --- a/docker-bundle/docker/mediamtx/entrypoint.sh +++ b/docker-bundle/docker/mediamtx/entrypoint.sh @@ -10,16 +10,14 @@ if [ -z "${MEDIAMTX_SHARED_SECRET:-}" ] || [ "${MEDIAMTX_SHARED_SECRET#change-me fail "MEDIAMTX_SHARED_SECRET is required. Run docker-bundle/scripts/deploy.sh or set it in docker-bundle/.env." fi -: "${APP_URL:?APP_URL is required.}" -: "${HLS_PUBLIC_URL:?HLS_PUBLIC_URL is required.}" : "${RTMP_INTERNAL_PORT:=1935}" : "${HLS_INTERNAL_PORT:=8888}" mkdir -p /config /var/www/html/storage/app/private/recordings -export APP_URL HLS_PUBLIC_URL MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT +export MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT -envsubst '${APP_URL} ${HLS_PUBLIC_URL} ${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \ +envsubst '${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \ < /etc/mediamtx/mediamtx.yml.template \ > /config/mediamtx.yml diff --git a/docker-bundle/docker/mediamtx/mediamtx.yml.template b/docker-bundle/docker/mediamtx/mediamtx.yml.template index c936810..1d1f700 100644 --- a/docker-bundle/docker/mediamtx/mediamtx.yml.template +++ b/docker-bundle/docker/mediamtx/mediamtx.yml.template @@ -16,9 +16,7 @@ rtmpAddress: :${RTMP_INTERNAL_PORT} hls: yes hlsAddress: :${HLS_INTERNAL_PORT} hlsVariant: mpegts -hlsAllowOrigins: - - ${APP_URL} - - ${HLS_PUBLIC_URL} +hlsAllowOrigins: [] pathDefaults: record: yes @@ -32,4 +30,3 @@ pathDefaults: paths: all_others: source: publisher - diff --git a/docker-bundle/scripts/deploy.sh b/docker-bundle/scripts/deploy.sh index bfb67b1..6dcffbf 100644 --- a/docker-bundle/scripts/deploy.sh +++ b/docker-bundle/scripts/deploy.sh @@ -158,7 +158,7 @@ require_config_value APP_DOMAIN "nyone.example.com" require_config_value HLS_DOMAIN "nyone-hls.example.com" if [ "$(env_value APP_DOMAIN)" = "$(env_value HLS_DOMAIN)" ]; then - fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate Apache virtual hosts." + fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate app and HLS hostnames." fi GIT_REF="${GIT_REF:-main}"