diff --git a/.env.example b/.env.example index ce6b4e1..9cfadee 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,14 @@ ZMES_PORT=9000 # The address browsers use for ZoneMinder, with a hostname rather than an IP address. Needed for # live view through go2rtc: set Options > System > GO2RTC_PATH to this plus /zm/go2rtc. +# go2rtc live streams need no login, even with ZoneMinder's authentication on. Anyone who can +# reach the web port can watch a monitor that has go2rtc enabled. Keep the port on a trusted network. # ZM_PUBLIC_URL=http://zm.lan:8080 +# Behind an https reverse proxy, ZoneMinder's own calls to go2rtc come through the proxy. List the +# proxy's address (IP or CIDR, space separated) so go2rtc accepts them. Leave empty otherwise. +# GO2RTC_API_ALLOW=172.18.0.0/16 + # Size of /dev/shm in bytes (1 GiB). Raise it for many or high resolution monitors. ZM_SHM_SIZE=1073741824 diff --git a/README.md b/README.md index a8de1cd..669509e 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,22 @@ to that address plus `/zm/go2rtc`, so `http://zm.lan:8080/zm/go2rtc`, and turn o monitor. The container serves go2rtc under ZoneMinder's own address, so the browser's WebSocket passes go2rtc's origin check. `ZM_PUBLIC_URL` also points that hostname at the container itself, because ZoneMinder calls the same URL from inside it, and a host firewall often blocks a container -from reaching its own published port. With an IP address instead of a name, that part depends on -your firewall. +from reaching its own published port. + +go2rtc's API has no login of its own, and ZoneMinder's login doesn't cover it. So from outside +the container, `/zm/go2rtc/` only serves what live view needs: the WebSocket for a monitor's +stream and the HLS fallback. The rest of the API is refused, including go2rtc's config, adding +streams, and its own web page. That means ZoneMinder's own calls must come from inside the container: +- **Hostname in `ZM_PUBLIC_URL`:** this works with no extra setup. +- **IP address:** ZoneMinder's calls arrive through the host, so go2rtc refuses them and live + view stays blank. Use a hostname. +- **https behind a reverse proxy:** set `GO2RTC_API_ALLOW` to the proxy's address. + +**go2rtc live streams are not protected by ZoneMinder's login.** Anyone who can reach the web port +can watch any monitor with go2rtc enabled, because the browser connects without credentials and +the IDs are small numbers. Keep the port on a network you trust, or put an authenticating +reverse proxy or a VPN in front of it. Monitors without go2rtc use ZoneMinder's own streaming, +which does check the login. `docker compose ps` shows the container's health. It reports unhealthy when Apache stops serving `/zm/`, when the `zmdc.pl` daemon is gone, or when the database doesn't answer. @@ -142,6 +156,7 @@ ZoneMinder container environment: | `ZM_DB_SSL_CA_CERT`, `ZM_DB_SSL_CLIENT_CERT`, `ZM_DB_SSL_CLIENT_KEY` | | TLS to the database | | `ZM_SERVER_HOST` | | Server name for multi-server installs | | `ZM_PUBLIC_URL` | | The address browsers use, e.g. `http://zm.lan:8080`. Needed for go2rtc live view, see above. | +| `GO2RTC_API_ALLOW` | | IP addresses or CIDR ranges, space separated, that get go2rtc's whole API through `/zm/go2rtc/`. Set it to an https reverse proxy's address. | | `TZ` | `UTC` | Applied to the OS and PHP. Give the database the same value, as the compose file does. | | `ZM_ES_ENABLE` | `1` in `-es` images | Writes OPT_USE_EVENTNOTIFICATION to `conf.d`, which takes priority over the UI setting | | `ZM_DB_WAIT_TIMEOUT` | `180` | Seconds to wait for the database | @@ -202,7 +217,7 @@ Volumes: | `/config` | `go2rtc.yaml`, event server config and certificates | | `/dev/shm` | tmpfs, 1 GiB by default (`ZM_SHM_SIZE`). ZoneMinder keeps each monitor's frame buffer here. | -Ports: 80 (web, with go2rtc's API and page under `/zm/go2rtc/`), 8555 tcp/udp (WebRTC), +Ports: 80 (web, with go2rtc's live view under `/zm/go2rtc/`), 8555 tcp/udp (WebRTC), 9000 (event server). ### Hardware decoding diff --git a/docker-compose.yaml b/docker-compose.yaml index 6a8f660..347eb35 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,6 +54,7 @@ services: PGID: ${PGID:-} # The address browsers use, e.g. http://zm.lan:8080. Needed for go2rtc live view (README). ZM_PUBLIC_URL: ${ZM_PUBLIC_URL:-} + GO2RTC_API_ALLOW: ${GO2RTC_API_ALLOW:-} # Outgoing mail. Leave SMTP_HOST empty to skip. SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-} diff --git a/dockerhub/zoneminder.md b/dockerhub/zoneminder.md index 846c193..3f0b8d9 100644 --- a/dockerhub/zoneminder.md +++ b/dockerhub/zoneminder.md @@ -56,7 +56,7 @@ To run the event server, set `ZM_TAG=latest-es` in `.env`. For object detection, | `ZM_DB_NAME` / `ZM_DB_USER` | `zm` / `zmuser` | | | `ZM_DB_PASS` / `ZM_DB_PASS_FILE` | required | Use the `_FILE` form with Docker secrets | | `TZ` | `UTC` | Give the database the same value | -| `ZM_PUBLIC_URL` | | Address browsers use, e.g. `http://zm.lan:8080`. Set GO2RTC_PATH to it plus `/zm/go2rtc` for go2rtc live view. | +| `ZM_PUBLIC_URL` | | Address browsers use, e.g. `http://zm.lan:8080`. Set GO2RTC_PATH to it plus `/zm/go2rtc` for go2rtc live view. go2rtc streams need no login, so keep the port on a trusted network. | | `PUID` / `PGID` | `33` / `33` | Match the owner of your bind mounts | | `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` | | Outgoing mail through msmtp | diff --git a/release/zoneminder/rootfs/etc/apache2/conf-available/zm-docker.conf b/release/zoneminder/rootfs/etc/apache2/conf-available/zm-docker.conf index b7438ac..c88bd32 100644 --- a/release/zoneminder/rootfs/etc/apache2/conf-available/zm-docker.conf +++ b/release/zoneminder/rootfs/etc/apache2/conf-available/zm-docker.conf @@ -6,10 +6,33 @@ RedirectMatch ^/$ /zm/ ServerTokens Prod ServerSignature Off -# go2rtc (API, WebSocket and its own web page) at /zm/go2rtc/, on the same address as ZoneMinder. -# Browsers then make same-origin requests, which go2rtc's WebSocket origin check accepts, and -# go2rtc's port stays inside the container. Set Options > System > GO2RTC_PATH to -# /zm/go2rtc. +# go2rtc at /zm/go2rtc/, on the same address as ZoneMinder. Browsers then make same-origin +# requests, which go2rtc's WebSocket origin check accepts, and go2rtc's port stays inside the +# container. Set Options > System > GO2RTC_PATH to /zm/go2rtc. ProxyPreserveHost On ProxyPass /zm/go2rtc/ http://127.0.0.1:1984/ upgrade=websocket ProxyPassReverse /zm/go2rtc/ http://127.0.0.1:1984/ + +# go2rtc's API has no authentication, and ZoneMinder's login doesn't cover it. Anyone who could +# reach it could rewrite go2rtc's config or point it at any source. So only processes inside the +# container get the whole API: zmc registers streams through this URL, reaching it over loopback +# via the /etc/hosts entry that init-config writes for ZM_PUBLIC_URL. init-config writes +# zm-go2rtc-allow.conf from GO2RTC_API_ALLOW, for a reverse proxy that zmc's calls come through. + + + Require ip 127.0.0.1 ::1 + Include /etc/apache2/zm-go2rtc-allow.conf + + +# Browsers get the live view WebSocket, only for ZoneMinder's stream names (monitor ID plus an +# optional channel suffix), and the HLS fallback that a WebSocket session hands out. + + + Require ip 127.0.0.1 ::1 + Include /etc/apache2/zm-go2rtc-allow.conf + Require expr "%{QUERY_STRING} =~ /^src=[0-9]+(_(CameraDirectPrimary|CameraDirectSecondary|ZoneMinderPrimary))?$/" + + + + Require all granted + diff --git a/release/zoneminder/rootfs/etc/apache2/zm-go2rtc-allow.conf b/release/zoneminder/rootfs/etc/apache2/zm-go2rtc-allow.conf new file mode 100644 index 0000000..1d0baa9 --- /dev/null +++ b/release/zoneminder/rootfs/etc/apache2/zm-go2rtc-allow.conf @@ -0,0 +1 @@ +# Written by init-config from GO2RTC_API_ALLOW diff --git a/release/zoneminder/rootfs/etc/s6-overlay/scripts/init-config b/release/zoneminder/rootfs/etc/s6-overlay/scripts/init-config index 8d790d6..7f5d8c4 100755 --- a/release/zoneminder/rootfs/etc/s6-overlay/scripts/init-config +++ b/release/zoneminder/rootfs/etc/s6-overlay/scripts/init-config @@ -108,6 +108,8 @@ if [[ ! -f /config/go2rtc.yaml ]]; then cat > /config/go2rtc.yaml <<'YAML' # go2rtc config. ZoneMinder adds camera streams through the API at runtime. # Browsers reach the API through Apache at /zm/go2rtc/, so it only listens inside the container. +# Apache only lets outside clients open live view streams. Those streams need no login, even +# with ZoneMinder's authentication on, so keep ZoneMinder's port on a trusted network. # Docs: https://github.com/AlexxIT/go2rtc#configuration api: listen: "127.0.0.1:1984" @@ -131,8 +133,10 @@ if [[ -n "${ZM_PUBLIC_URL:-}" ]]; then pub_scheme=${BASH_REMATCH[1]} pub_host=${BASH_REMATCH[2]} pub_port=${BASH_REMATCH[4]:-} if [[ "$pub_scheme" == https ]]; then log "ZM_PUBLIC_URL is https, so a proxy in front of the container answers it. ZoneMinder's daemons must be able to reach $pub_host." + [[ -n "${GO2RTC_API_ALLOW:-}" ]] \ + || log "WARNING: set GO2RTC_API_ALLOW to the proxy's address, or go2rtc refuses zmc's stream registrations and live view stays blank." elif [[ "$pub_host" =~ ^[0-9.]+$ ]]; then - log "WARNING: ZM_PUBLIC_URL uses an IP address. ZoneMinder's daemons then reach it through the host, which a host firewall can block. A hostname avoids that." + log "WARNING: ZM_PUBLIC_URL uses an IP address. ZoneMinder's daemons then reach it through the host, not over loopback, so go2rtc refuses their stream registrations and live view stays blank. Use a hostname." else grep -qE "^127\.0\.0\.1[[:space:]]+${pub_host//./\\.}\$" /etc/hosts || echo "127.0.0.1 $pub_host" >> /etc/hosts if [[ -n "$pub_port" && "$pub_port" != 80 ]]; then @@ -142,6 +146,18 @@ if [[ -n "${ZM_PUBLIC_URL:-}" ]]; then fi fi +# Addresses besides the container itself that get go2rtc's whole API through /zm/go2rtc/. +# zm-docker.conf includes this file in its access rules. +allow_conf=/etc/apache2/zm-go2rtc-allow.conf +echo "# Written by init-config from GO2RTC_API_ALLOW" > "$allow_conf" +if [[ -n "${GO2RTC_API_ALLOW:-}" ]]; then + for a in $GO2RTC_API_ALLOW; do + [[ "$a" =~ ^[0-9A-Fa-f.:]+(/[0-9]+)?$ ]] || die "GO2RTC_API_ALLOW takes IP addresses or CIDR ranges, not '$a'" + done + echo "Require ip $GO2RTC_API_ALLOW" >> "$allow_conf" + log "go2rtc API also open to: $GO2RTC_API_ALLOW" +fi + shm_kb=$(df -k --output=size /dev/shm | tail -1 | tr -d ' ') if (( shm_kb < 262144 )); then log "WARNING: /dev/shm is only $((shm_kb / 1024)) MB. ZoneMinder needs roughly width*height*4*buffer_frames per monitor." diff --git a/tests/smoke.sh b/tests/smoke.sh index 9779252..da3710a 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -165,6 +165,22 @@ zm 'url=$ZM_PUBLIC_URL/zm/go2rtc/api -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" "$url/ws?src=test" || true) [[ "$code" == 101 ]] || { echo "go2rtc WebSocket answered $code"; exit 1; }' \ || fail "go2rtc live view through /zm/go2rtc/" +# From outside the container, go2rtc's unauthenticated API is closed except the live view +# WebSocket for ZoneMinder's stream names. +outside() { # outside + local want=$1 what=$2 code; shift 2 + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$@" || true) + [[ "$code" == "$want" ]] || fail "outside: $what answered $code, expected $want" +} +g2=http://127.0.0.1:${ZM_HTTP_PORT:-18080}/zm/go2rtc +wsh=(-H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Origin: http://127.0.0.1:${ZM_HTTP_PORT:-18080}") +outside 403 "go2rtc config" "$g2/api/config" +outside 403 "go2rtc stream list" "$g2/api/streams" +outside 403 "adding a go2rtc stream" -X PUT "$g2/api/streams?name=9&src=rtsp://example" +outside 403 "go2rtc web page" "$g2/" +outside 403 "WebSocket with a URL as source" "${wsh[@]}" "$g2/api/ws?src=rtsp://example" +outside 101 "WebSocket for a monitor stream" "${wsh[@]}" "$g2/api/ws?src=1_CameraDirectPrimary" # The usage statistics prompt blocks image URLs until answered. Test shortcut for clicking "decline". zm 'source /etc/s6-overlay/scripts/common.sh; sql "UPDATE Config SET Value=0 WHERE Name=\"ZM_SHOW_PRIVACY\""' api monitors.json -X POST \