Compare commits

...
Author SHA1 Message Date
legop3 609eb6c35e always let through audio forwarding
Container image / image (push) Waiting to run
2026-09-15 16:35:55 -04:00
legop3 c3c60fde18 switchreplay workers to rtsp 2026-09-15 16:27:08 -04:00
legop3 aa3c0c0e7b improve admin user list ui 2026-09-15 15:53:14 -04:00
legop3 8b5d7372f9 get lifecycle out of data 2026-09-15 15:00:22 -04:00
legop3 7539898f98 fix restore breaking lifecycle stuff 2026-09-15 14:39:17 -04:00
legop3 73255831f1 fix discord admin id stuff 2026-09-15 13:18:24 -04:00
legop3 3b585b06b4 smallify the compose yaml 2026-09-15 13:14:42 -04:00
legop3 55ee6e05bb container manager update and restart stuffs 2026-09-15 13:06:50 -04:00
legop3 5d4f7fe5cc docker healthcheck api thingy 2026-09-15 12:33:45 -04:00
legop3 bdb32d13ac switch to docker volume for data folder, and get real ffmpeg from rpm fusion for replays 2026-09-15 12:09:40 -04:00
legop3 6b6fbcf871 unignore package-locks for the action build to have reproducible stable buildings
Container image / image (push) Waiting to run
2026-09-15 01:15:48 -04:00
legop3 5a81ff7716 dockerfile and ghcr action!! 2026-09-15 01:10:51 -04:00
34 changed files with 5758 additions and 158 deletions
+16
View File
@@ -0,0 +1,16 @@
# Docker Build Context
# Purpose: Keeps local state, cached dependencies, and host-built artifacts out
# of the production image build context. Every required artifact is recreated
# by the Dockerfile from tracked source and lockfiles.
.git
.gitignore
**/node_modules
# Neither the legacy state directory nor the Compose-mounted replacement may
# enter an image build. This prevents credentials and backups from becoming
# image layers after an operator has started using either deployment layout.
/server/data
/data
server/public
server/src/services/kinectService/native/kinect_worker
server/src/services/balanceBoardService/native/balance_board_worker
**/*.log
+67
View File
@@ -0,0 +1,67 @@
# Build the exact production image on pull requests, publish branch-named images
# for development, and replace `latest` only after a successful main-branch
# push. Keeping every channel in one job prevents a second build path from
# drifting away from what servers actually download.
name: Container image
on:
pull_request:
# Every repository branch gets one moving development image. GitHub does not
# grant package-write access to untrusted fork pull requests, which remain
# build-only through the separate pull_request event above.
push:
# Repository contents are read to build the image. Package write access is used
# only by the conditional GHCR login and push steps on repository branch pushes.
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7
# Buildx supplies the cache and the explicit amd64 build used both for
# pull-request verification and publication. QEMU is intentionally absent
# because the central server image supports only linux/amd64.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# GitHub's built-in token can publish to this repository's package. Pull
# requests never authenticate to GHCR and therefore cannot publish.
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Docker's maintained metadata action converts branch names into valid
# container tags, including replacing separators such as `/`. Main gets
# only `latest`; every other pushed branch gets only its branch tag.
- name: Select image tag
id: image-metadata
uses: docker/metadata-action@v6
with:
images: ghcr.io/legop3/multiroombarover
flavor: latest=false
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,enable={{is_not_default_branch}}
# The push switch keeps pull requests build-only. Branch images are
# replaced on each successful push, just as main replaces `latest`.
- name: Build and optionally publish
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.image-metadata.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3 -3
View File
@@ -13,8 +13,6 @@ config.h
robots.json
roverd-dummy
server/config.yaml
server/package-lock.json
package-lock.json
server/data/discord-guilds.json
server/data/global-objective.json
server/data/admin-reason.json
@@ -22,7 +20,9 @@ server/data/buttonbox-state.json
server/data/barcode-tts-cache/
server/data/rover-odometers.json
server/data/mediamtx.yml
webui/package-lock.json
# npm lockfiles are deliberately tracked. The production image uses `npm ci`,
# so a clean GitHub checkout must contain the exact dependency resolution used
# by local builds rather than resolving a different dependency tree.
!server/data/
!server/data/barcode-registry.json
webui/src/config/analytics.jsx
+190
View File
@@ -0,0 +1,190 @@
# syntax=docker/dockerfile:1
# MultiRover Production Application Image
# Purpose: Builds the web application, Node dependencies, native hardware
# workers, and pinned runtime tools into one amd64 server image.
# Scope: Packages the application and its private controller command in one
# image. Compose still isolates their processes, mounts, and privileges.
ARG FEDORA_VERSION=43
FROM fedora:${FEDORA_VERSION} AS architecture-check
ARG TARGETARCH
# The central server is currently deployed and verified only on amd64. Failing
# here avoids publishing an ARM image whose native workers and hardware paths
# have never been exercised on a real ARM server.
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
FROM architecture-check AS webui-build
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
&& dnf clean all
WORKDIR /build
# Copy dependency manifests first so ordinary source edits retain the expensive
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
# source rather than resolving a new tree during image publication.
COPY webui/package.json webui/package-lock.json ./webui/
RUN --mount=type=cache,target=/root/.npm \
cd webui && npm ci
COPY webui ./webui
# Vite deliberately emits into ../server/public. Create that destination in the
# isolated builder and copy only its finished files into the runtime stage.
RUN mkdir -p server/public && cd webui && npm run build
FROM architecture-check AS server-dependencies
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
&& dnf clean all
WORKDIR /build/server
COPY server/package.json server/package-lock.json ./
# Native Node modules compile here when a matching prebuild is unavailable;
# neither the compiler nor npm's download cache is copied into the final image.
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM architecture-check AS native-workers
RUN dnf install -y --setopt=install_weak_deps=False \
bluez-libs-devel \
gcc-c++ \
libcap \
libfreenect-devel \
libusb1-devel \
make \
pkgconf-pkg-config \
wiiuse-devel \
&& dnf clean all
WORKDIR /build
COPY server/src/services/kinectService/native ./kinect
COPY server/src/services/balanceBoardService/native ./balance-board
# Build both hardware workers from source for the image's Fedora ABI instead of
# copying workstation binaries whose linked libraries may not match.
RUN make -C kinect \
&& make -C balance-board
FROM architecture-check AS packaged-tools
ARG MEDIAMTX_VERSION=1.15.3
ARG MEDIAMTX_SHA256=cddc98d17f23689848d5a935151264e117cfb27cee2db87b5079b19572e4b48d
ARG NEOLINK_VERSION=0.6.2
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
ARG GOOGLE_TTS_VERSION=26.5
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
&& dnf clean all
WORKDIR /build/tools
# Each external artifact is pinned and checked before extraction. A changed or
# truncated upstream download therefore fails the image build instead of being
# silently promoted as the new production server.
RUN curl --fail --location --retry 3 \
"https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" \
--output mediamtx.tar.gz \
&& echo "${MEDIAMTX_SHA256} mediamtx.tar.gz" | sha256sum --check --strict \
&& tar -xzf mediamtx.tar.gz mediamtx \
&& install -D -m 0755 mediamtx /output/usr/local/bin/mediamtx
RUN curl --fail --location --retry 3 \
"https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}/neolink_linux_x86_64_ubuntu.zip" \
--output neolink.zip \
&& echo "${NEOLINK_SHA256} neolink.zip" | sha256sum --check --strict \
&& unzip -q neolink.zip -d neolink \
&& neolink_binary="$(find neolink -type f -name neolink -print -quit)" \
&& test -n "${neolink_binary}" \
&& install -D -m 0755 "${neolink_binary}" /output/usr/local/bin/neolink
RUN curl --fail --location --retry 3 \
"https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-${GOOGLE_TTS_VERSION}.tar.xz" \
--output googletts.tar.xz \
&& echo "${GOOGLE_TTS_SHA256} googletts.tar.xz" | sha256sum --check --strict \
&& tar -xf googletts.tar.xz en-us-x-multi.zvoice libchrometts_x86_64.so \
&& install -D -m 0644 libchrometts_x86_64.so /output/opt/roverd/googletts/libchrometts.so \
&& mkdir -p /output/opt/roverd/googletts/en-us-x-multi-r30 \
&& unzip -q en-us-x-multi.zvoice -d /output/opt/roverd/googletts/en-us-x-multi-r30 \
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
FROM fedora:${FEDORA_VERSION} AS runtime
ARG FEDORA_VERSION
ARG TARGETARCH
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
# Fedora's restricted ffmpeg-free build omits the libx264 encoder used by every
# replay output path. Enable RPM Fusion Free before installing runtime packages
# so the image receives the complete FFmpeg build instead of requiring replay
# code to work around a deployment-only codec omission.
RUN dnf install -y --setopt=install_weak_deps=False \
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
# This is the complete runtime package set. Build headers and compilers live in
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
# because enabled services invoke them after startup. Weak dependencies are
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
# themes, and GPU drivers that a headless server neither starts nor uses.
RUN dnf install -y --setopt=install_weak_deps=False \
bluez \
bluez-libs \
espeak \
ffmpeg \
flite \
gstreamer1 \
gstreamer1-plugins-bad-free \
gstreamer1-plugins-base \
gstreamer1-plugins-good \
gstreamer1-rtsp-server \
libcxx \
libcxxabi \
libcap \
libfreenect \
libusb1 \
nodejs \
python3 \
shadow-utils \
tini \
wiiuse \
--allowerasing \
&& dnf clean all \
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
WORKDIR /opt/multirover/server
COPY server/index.js server/package.json server/package-lock.json ./
COPY server/src ./src
COPY server/assets ./assets
COPY server/prompts ./prompts
COPY --from=server-dependencies /build/server/node_modules ./node_modules
COPY --from=webui-build /build/server/public ./public
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
COPY --from=packaged-tools /output/ /
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
# Only the audited Balance Board bridge receives its two required socket
# capabilities. Node and the rest of the application continue to run without
# ambient capabilities; Compose must also allow these capabilities when the
# optional Balance Board feature is used.
RUN setcap cap_net_admin,cap_net_bind_service+ep \
./src/services/balanceBoardService/native/balance_board_worker \
&& /usr/local/bin/chromegtts-wav \
--text "test" \
--voice tpf \
--pitch 1 \
--speed 1 \
--output /tmp/chromegtts-smoke.wav \
&& test -s /tmp/chromegtts-smoke.wav \
&& rm /tmp/chromegtts-smoke.wav
ENV NODE_ENV=production \
SERVER_DATA_DIR=/data \
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
USER multirover
# Declaring the persistence boundary also protects direct `docker run` users:
# when no explicit host path or named volume is supplied, Docker still places
# `/data` on an anonymous volume rather than the replaceable image layer.
VOLUME ["/data"]
EXPOSE 8080/tcp 8554/tcp 8189/tcp 8189/udp
# Use Node's built-in fetch so container readiness does not require curl or a
# second probe binary in the runtime image. The endpoint verifies the writable
# data mount and MediaMTX; reaching it already proves Node is accepting HTTP.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "index.js"]
# OCI metadata links the unversioned latest image to its source without
# introducing release numbers or additional image tags.
LABEL org.opencontainers.image.source="https://github.com/legop3/MultiRoombaRover" \
org.opencontainers.image.title="MultiRoombaRover"
+56
View File
@@ -0,0 +1,56 @@
# MultiRover production deployment
name: multirover
# Change this one line to use a development branch image.
x-multirover-image: &multirover-image ghcr.io/legop3/multiroombarover:latest
services:
server:
image: *multirover-image
container_name: multirover
network_mode: host
restart: unless-stopped
stop_grace_period: 20s
security_opt:
- label=disable
volumes:
# All persistent application data is stored in this volume.
- data:/data
- lifecycle-socket:/run/multirover
# Required for Bluetooth hardware such as the Balance Board.
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
# Required for Kinect USB access.
devices:
- /dev/bus/usb:/dev/bus/usb
cap_add:
- NET_ADMIN
lifecycle:
image: *multirover-image
container_name: multirover-lifecycle
command: ["node", "src/services/serverControlService/controller.js"]
user: root
network_mode: none
restart: unless-stopped
healthcheck:
disable: true
security_opt:
- label=disable
environment:
MULTIROVER_TARGET_IMAGE: *multirover-image
volumes:
- lifecycle-socket:/run/multirover
# Do not add this Docker socket mount to the server service.
- /var/run/docker.sock:/var/run/docker.sock
volumes:
# `docker compose down -v` permanently deletes these volumes.
data:
name: multirover-data
lifecycle-socket:
name: multirover-lifecycle-socket
+113 -33
View File
@@ -7,10 +7,15 @@ This document is the live implementation tracker for the migration.
- [x] Phase 1, step 1: Establish the single data-directory contract
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
- [x] Phase 1, steps 6-8: Restart, backup/restore, and internal video proxy
- [ ] Phase 1, step 9: Complete the remaining legacy-deployment integration and hardware verification
- [x] Phase 1, step 9: Complete the remaining legacy-deployment integration and hardware verification
- [x] Phase 2, step 10: Build and locally verify the production application image
- [x] Phase 2, steps 11-12: Add the single-container Compose deployment and locally verify its host-access contract
- [x] Phase 2, step 13: Add application and container health checks
- [x] Phase 2, step 14: Add the restricted lifecycle container and connect the System UI
- [x] Phase 2, step 15: Build pull requests and publish the main branch to the single GHCR `latest` channel
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
The single data-directory implementation and local verification are complete. Real snapshot generation, legacy-directory cleanup, and runtime filesystem tracing remain deployment checks for the actual server; they do not leave the implementation step open.
Phase 1 is complete. The current application has run successfully on the production server with the new configuration, administration, persistence, backup/restore, and internal video-proxy contracts.
The work is deliberately split into two phases:
@@ -21,6 +26,9 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
- 2026-09-14: Apply every committed configuration revision immediately. The configuration coordinator atomically replaces the process-wide snapshot, compares top-level service sections, serially reloads only affected service runtimes, and then refreshes all sessions. Long-lived HTTP/socket handlers remain registered once and delegate to the current runtime; integrations may reconnect or replace their own child process, worker, client, timers, and subscriptions without restarting Node.
- 2026-09-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
- 2026-09-14: Restart only the application process, never the host. A lockdown administrator with recent password confirmation requests one audited restart, Node acknowledges and announces it, then sends itself SIGTERM. Existing service signal handlers clean up their owned children, while systemd `Restart=always` and the later container restart policy start the application again.
@@ -44,9 +52,9 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
- A dedicated `/admin` application contains all server administration.
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
- Release images are built automatically and published to GHCR.
- The latest successful main-branch image is built automatically and published to GHCR as `ghcr.io/legop3/multiroombarover:latest`.
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
- The final host installation contains as little project-specific material as possible: a Compose file, a data directory, and unavoidable hardware preparation.
- The final host installation contains as little project-specific material as possible: a Compose file, one Docker-managed data volume, and unavoidable hardware preparation.
## Important boundary: application files versus server data
@@ -465,6 +473,10 @@ Implemented on 2026-09-14:
- Fixed production WAL-mode snapshots creating unmanifested SQLite `-wal` and `-shm` files during schema inspection. Backup and restore validation now remove only those temporary staged sidecars before archiving or applying data, and the regression fixture uses WAL mode to match the real databases.
- Added the early streaming `/video` middleware with `http-proxy-middleware`. Express removes the public prefix before forwarding WHEP/WHIP requests to `127.0.0.1:8889`, while root-relative MediaMTX session locations receive the prefix again so subsequent browser `PATCH` and `DELETE` requests follow the same path. MediaMTX signaling now binds to loopback; its ICE UDP/TCP listener remains directly reachable on port 8189.
- Replaced Discord's `siteUrl`, the inter-instance profile's `publicUrl`, and media `whepBaseUrl` with one top-level `publicUrl`. A numbered internal database migration transforms every saved configuration revision before current validation, and the media section now contains only optional additional ICE hosts. WHEP and microphone WHIP URLs are fixed relative paths, so they work through the current origin without knowing its hostname.
- Discord command authorization and lockdown moderation recipients now read the live administrator registry, so setup imports and later Discord-ID or role edits take effect without restarting the server.
- Full-data restore now leaves `runtime/` untouched, matching its existing exclusion from backup archives and preventing the non-root application from trying to remove lifecycle-controller state owned by the root controller container.
- The Users and administrators tab now requests at most 100 lightweight identity summaries through one bounded SQLite query. Search and moderation filters run on the server, while complete signals, permissions, and feature state load only after selecting a user, preventing large identity databases from blocking Socket.IO heartbeats or freezing the browser.
- Removed the remaining server-local SRT hops after Fedora's newer libSRT rejected the zero-payload ACKACK packets emitted by MediaMTX's GoSRT implementation on every acknowledgement cycle. PTZ publishing, replay capture, and snapshot capture now share the existing RTSP/TCP listener, SRT is disabled, and browser-session authorization is bypassed only for loopback readers and rover `-fwd` speaker feeds.
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
Local verification completed:
@@ -507,7 +519,7 @@ Use a Fedora-based multi-stage build to remain close to the dependencies already
- Build the Kinect worker against libfreenect/libusb.
- Build the Balance Board worker against wiiuse/BlueZ.
- Build for the target image architecture rather than copying checked-in workstation binaries.
- Build for `linux/amd64` rather than copying checked-in workstation binaries.
### Packaged runtime tools
@@ -531,19 +543,39 @@ The final image should:
- Use a minimal init process to reap child processes.
- Treat `/data` as its only persistent writable location.
- Use `/tmp` only for disposable work.
- Include release version and commit metadata.
- Include ordinary OCI source metadata linking the image to this repository, without introducing an application version number.
- Handle `SIGTERM` through the Phase 1 graceful shutdown coordinator.
### Production image implementation notes
Implemented and locally verified on 2026-09-15:
- Added one root multi-stage `Dockerfile` that builds the Vite application, locked production Node dependencies, Kinect worker, and Balance Board worker, then copies only their runtime outputs into a Fedora 43 image.
- Downloaded pinned amd64 MediaMTX 1.15.3, Neolink 0.6.2, and ChromeOS Google TTS 26.5 artifacts during the build and rejected downloads that did not match their recorded SHA-256 checksums.
- Installed the media, TTS, USB, Bluetooth, and native-worker runtime libraries without Fedora weak dependencies. This avoids pulling unrelated desktop recommendations into the headless image while retaining the libraries explicitly required by the current server installer.
- Added a root `.dockerignore` so local dependencies, mutable server data, generated public assets, compiled host workers, logs, and Git metadata cannot leak into the image build context.
- Configured `/data` as `SERVER_DATA_DIR`, ran Node as the dedicated uid 1000 `multirover` user, retained only the Balance Board worker's required capabilities, and used `tini` as the container init process.
- Successfully built and loaded `multiroombarover:local` for `linux/amd64`. Its registry-style compressed content size is approximately 828 MB; Docker reports approximately 2.87 GB of local unpacked disk usage because the complete GStreamer, ffmpeg, Kinect, Node, and offline TTS runtime is intentionally included.
- Confirmed at build time that Chrome TTS loads its packaged voice model and produces a nonempty WAV file.
- Replaced Fedora's restricted `ffmpeg-free` package with RPM Fusion Free's complete `ffmpeg` package after development-container testing exposed that `ffmpeg-free` omits the `libx264` encoder required by rover replay capture, room-camera replay rendering, replay sidebars, and final replay assembly.
- Started the image with host networking and a temporary SELinux-relabeled `/data` bind mount. The application reached its HTTP listener, generated first-run state only inside the mount, and started the packaged MediaMTX with its generated configuration under `/data`.
- Confirmed `/`, `/setup`, and `/admin` return the production UI; `/video/` reaches the loopback MediaMTX proxy; MediaMTX and Neolink execute; both native workers link against the runtime image; and the Balance Board worker retains only `cap_net_admin` and `cap_net_bind_service`.
- Restarted the same container and confirmed the setup credential and configuration database were byte-for-byte unchanged, then confirmed `/admin` returned successfully again.
- Stopped and removed the smoke-test container and deleted its temporary data. No test server process was left running on the development machine.
## 11. Compose deployment
The host-visible installation should be only:
The host-visible project installation should be only:
```text
multirover/
── compose.yaml
└── data/
── compose.yaml
```
Docker owns the separately persisted `multirover-data` volume. Operators move
or inspect its complete contents through the administration backup/restore UI
rather than coordinating host filesystem ownership with the container user.
The Compose project contains:
- The main Multirover application container
@@ -552,7 +584,7 @@ The Compose project contains:
The application mounts:
```text
./data:/data
data:/data
```
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
@@ -563,7 +595,17 @@ Expected externally relevant listeners are:
- Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise.
MediaMTX WHEP on 8889 and API/metrics listeners should stay on loopback unless an identified remote consumer requires otherwise. Publishers and server-local replay/snapshot readers use the single RTSP/TCP listener on 8554; SRT is disabled.
### Compose implementation notes
Implemented and locally verified on 2026-09-15:
- Added one root `compose.yaml` containing only the main application. It uses `ghcr.io/legop3/multiroombarover:latest`, host networking, `restart: unless-stopped`, and the single `data:/data` persistent named-volume mount. The lifecycle service remains a later, separate step rather than a placeholder in the initial deployment.
- Added both possible local data directories to `.dockerignore`, alongside the legacy `server/data`, so credentials, databases, recordings, backups, and generated state cannot enter later image builds even during development or manual inspection.
- Started the exact Compose definition from an empty Docker-managed volume using the locally built image tagged with the final GHCR name. The application created its configuration database, setup credential, and generated MediaMTX configuration only under that volume.
- Confirmed the production UI responds on `/`, `/setup`, and `/admin`. A request to `/video/` reached the internal MediaMTX proxy and received MediaMTX's expected not-found response because the empty configuration had no requested stream.
- Restarted through Compose and confirmed the setup credential and configuration database remained byte-for-byte unchanged. A separate marker created through `/data` also remained present after restart.
## 12. Hardware access with minimal host setup
@@ -584,6 +626,15 @@ Balance Board requirements:
The exact capabilities and device permissions must be proven on the real server hardware. This development machine is not the actual server and cannot complete that validation.
### Hardware-access implementation notes
Implemented and locally verified as far as this development host permits on 2026-09-15:
- Added the BlueZ command-line package to the runtime image because the Balance Board service commissions devices through `bluetoothctl`; the rebuilt image reports BlueZ 5.87.
- Exposed `/dev/bus/usb` so reconnecting Kinect devices do not depend on a temporary bus/device number, and granted only `NET_ADMIN` for the Balance Board worker rather than using privileged mode.
- Mounted only the host system D-Bus socket for BlueZ access. Docker's per-container SELinux label is disabled because Fedora blocks access to the shared host socket and USB device nodes otherwise, while relabeling the system socket would affect the host; the process remains non-root and Docker's namespace, capability, and seccomp isolation remain active.
- Confirmed the container can open the mounted system D-Bus socket and that its native Balance Board worker retains only its existing file capabilities. The development host's Bluetooth daemon is inactive and no production Kinect or Balance Board is attached, so real discovery, reconnect, and streaming remain part of the actual-server validation.
The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
## 13. Health checks
@@ -600,6 +651,14 @@ Optional remote integrations should report degraded status to administrators wit
Compose should use the health endpoint and a restart policy suitable for unattended operation.
### Health-check implementation notes
Implemented on 2026-09-15:
- Added an unauthenticated `GET /health` readiness endpoint that exposes only two non-sensitive booleans: whether the application user can read and write the configured data directory and whether MediaMTX answers through its loopback-only metrics listener.
- Treated successful route execution as proof that Node is accepting HTTP and that configuration initialization completed. This avoids repeatedly querying every SQLite database or turning optional integrations and currently offline media sources into container restart conditions.
- Added the image-level Docker health check using Node's built-in `fetch`, so Compose receives the readiness state without installing another command-line probe utility.
## 14. Restricted lifecycle container
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
@@ -611,15 +670,15 @@ A small lifecycle container should be the only component with Docker control. It
- Operate only on the fixed Multirover application service.
- Reject arbitrary command lines, service names, image names, and Compose arguments.
- Persist update job state so it survives replacement of the application container.
- Report current version and image digest.
- Pull the configured release image.
- Compare the running image's internal digest with the current `latest` digest.
- Pull the fixed `ghcr.io/legop3/multiroombarover:latest` image.
- Restart or recreate the application container.
- Wait for the application health check.
- Retain and restore the previous image when the replacement fails.
The `/admin` System section should expose:
- Current version and image digest
- Whether the running application is current or an update is available
- Check for update
- Update and restart
- Restart application
@@ -629,21 +688,42 @@ The `/admin` System section should expose:
These operations require a lockdown administrator and recent password confirmation. The browser must expect its socket to disappear, show a reconnect state, and retrieve the persistent job result after the new application becomes healthy.
The Compose contract should remain stable so ordinary application releases replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
The Compose contract should remain stable so ordinary main-branch image updates replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
## 15. GHCR release automation
### Lifecycle-controller implementation notes
Implemented on 2026-09-15:
- Reused the single published application image for the lifecycle service with a controller-only command. This avoids a second Dockerfile, image name, GHCR workflow, and release lifecycle while the two containers still run separate processes with separate privileges.
- Mounted `/var/run/docker.sock` only in the network-disabled lifecycle container. The application communicates through a dedicated Unix-socket volume and cannot submit an image name, container name, command, or Docker option; the controller operates only on the fixed `multirover` container and the deployment-selected MultiRover image.
- Added fixed status, update-check, restart, and update operations. Update checks pull the configured moving image and compare Docker image IDs. Updates retain the previous image ID, recreate the application with its existing Compose host contract, wait for the image health check, and restore the previous image when replacement health fails.
- Persisted the current operation and result in the private lifecycle Unix-socket volume. This survives ordinary application replacement and browser reconnection without mounting the root lifecycle controller into the application's `/data` volume, leaving all application runtime paths owned by the non-root server.
- Connected the existing lockdown-administrator password confirmation and audit history to the lifecycle operations. The Administration overview polls persisted progress, reports update and rollback results, and keeps the legacy process-level restart only when no controller socket exists.
- Defined the deployment image once through a Compose YAML anchor. Both services and the controller target reuse that exact value, so production stays on `ghcr.io/legop3/multiroombarover:latest` and development requires changing only the single visible selector line to a branch tag.
## 15. GHCR publishing automation
Add repository automation that:
- Builds the production image from a clean checkout.
- Runs server tests, focused web UI tests/lint, and the production web build before publishing.
- Builds each explicitly supported server architecture.
- Publishes immutable commit/release tags to GHCR.
- Publishes one documented stable channel used by the lifecycle updater.
- Records image digests and source revision metadata.
- Avoids publishing when required verification fails.
- On pull requests, runs all required verification and proves that the production image builds without publishing it.
- On each repository branch push, builds the production image from a clean checkout.
- Uses the production Dockerfile as the single verification path. Its locked dependency installs, web UI production build, native worker builds, external-artifact checksum checks, and TTS smoke test must all pass before publication.
- Builds the supported `linux/amd64` image without QEMU or a multi-architecture manifest.
- Publishes `ghcr.io/legop3/multiroombarover:latest` from main and one sanitized branch-name tag from every other repository branch; there are no numbered, commit, stable, edge, or release tags.
- Leaves the previously published image for that branch untouched when any required verification or build step fails.
- Uses registry-generated digests only inside the lifecycle implementation for update comparison and rollback.
The deployed server pulls a prebuilt image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
The deployed server pulls the prebuilt `latest` image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
### GHCR automation implementation notes
Implemented on 2026-09-15:
- Added one `Container image` GitHub Actions workflow. Pull requests build the complete production Dockerfile without logging in or publishing. Main-branch pushes publish `ghcr.io/legop3/multiroombarover:latest`, while every other repository branch publishes one moving image using its sanitized branch name.
- Used GitHub's repository-scoped token with only contents-read and packages-write permissions. No separate registry secret, release process, version calculation, QEMU setup, or custom tag-generation code is required.
- Kept one Buildx job for all event types so pull-request verification, development branches, and main-branch publication cannot drift into different image recipes. Docker's maintained metadata action owns branch-name sanitization, and GitHub Actions layer caching avoids repeatedly downloading and rebuilding the image's large pinned media and TTS dependencies.
- Removed the legacy package-lock ignore rules and added the server lockfile required by `npm ci` to the migration change set. Local Docker builds and clean GitHub checkouts now receive the same locked server and web UI dependency inputs instead of allowing an ignored workstation file to mask a missing build input.
- The workflow file was parsed locally and its event, permission, architecture, tag-selection, and conditional-publish contract were checked. The first actual GHCR publication necessarily remains a GitHub-hosted verification after these changes are pushed.
## 16. Container cutover
@@ -652,7 +732,7 @@ The actual deployment migration should:
1. Download and validate a full Phase 1 backup.
2. Stop and disable the legacy Multirover systemd service.
3. Ensure no legacy MediaMTX service remains active.
4. Place the Compose file beside the existing data directory or move that directory once while the service is stopped.
4. Place the Compose file on the host; Docker creates the named data volume on first start.
5. Start the application and lifecycle containers.
6. Confirm that database migrations complete.
7. Confirm the active configuration revision and administrator access.
@@ -667,16 +747,16 @@ The old systemd application and the Compose application must never run concurren
Containerization is complete when:
- A new host can start from one Compose file and an empty data directory.
- A new host can start from one Compose file and an automatically created empty data volume.
- Existing state can be restored from a Phase 1 full backup.
- `./data:/data` is the only persistent application mount.
- `data:/data` is the only persistent application mount.
- Replacing the application container preserves all state.
- The special external `/video` MediaMTX route is unnecessary.
- The main container has no Docker socket access and is not fully privileged.
- Admin-triggered restart works.
- Admin-triggered update works and persists progress across reconnection.
- A failed image health check rolls back to the prior image.
- GHCR images are reproducibly built from repository releases.
- The GHCR `latest` image is reproducibly built from the newest successful main-branch commit.
- Kinect and Balance Board behavior has been verified on the actual host.
- Node, npm, application source, and media binaries are no longer installed directly on the host.
@@ -695,11 +775,11 @@ Within the two hard phase boundaries, the safest order is:
- [x] Standardize graceful application restart.
- [x] Implement online backup and restart-bound staged restore in one service.
- [x] Add the internal `/video` proxy and make the special external route unnecessary.
- [ ] Run the full Phase 1 completion gate on the legacy deployment.
- [ ] Build and verify the production application image.
- [ ] Add Compose, data mounting, networking, and hardware access.
- [ ] Add GHCR build and publication automation.
- [ ] Add the restricted lifecycle container and connect the System UI.
- [x] Run the full Phase 1 completion gate on the legacy deployment.
- [x] Build and verify the production application image.
- [x] Add Compose, data mounting, networking, and hardware access.
- [x] Add GHCR build and publication automation.
- [x] Add the restricted lifecycle container and connect the System UI.
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
- [ ] Perform the final systemd-to-Compose cutover.
+56
View File
@@ -0,0 +1,56 @@
{
"name": "perf",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.60.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+2 -1
View File
@@ -40,7 +40,8 @@ case "$PATH_NAME" in
esac
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
-rtsp_transport tcp \
-i "rtsp://127.0.0.1:8554/${PATH_NAME}" \
-an \
-vf "$FILTER" \
-q:v "$QUALITY" \
+4400
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -14,6 +14,7 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.11.1",
"discord.js": "^14.25.1",
"dockerode": "^5.0.1",
"express": "^4.19.2",
"fuse.js": "^7.4.2",
"home-assistant-js-websocket": "^3.1.2",
+14
View File
@@ -16,6 +16,20 @@ app.use(morgan('dev'));
*/
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger }));
app.use(express.json());
/*
Docker and the later lifecycle controller need one stable readiness result,
but they do not need administrator credentials or application details. Load
the health service only when the route is called so the global HTTP module
remains safe to initialize before the service graph during bootstrap.
*/
app.get('/health', async (_req, res) => {
const { getContainerHealth } = require('../services/healthService');
const health = await getContainerHealth();
res.status(health.healthy ? 200 : 503).json({
status: health.healthy ? 'healthy' : 'unhealthy',
checks: health.checks,
});
});
app.use(express.static(config.staticDir, { index: false }));
const httpServer = http.createServer(app);
@@ -116,7 +116,9 @@ test('applies validated replacement data and removes rollback only after startup
const applied = startupRestore.applyPendingRestore();
assert.equal(applied.status, 'awaiting-health');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'old-state.txt')), false);
assert.equal(fs.existsSync(path.join(temporaryRoot, 'runtime')), false);
// Runtime is outside the durable backup payload and can contain state owned
// by the separate lifecycle controller, so applying a restore preserves it.
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'utf8'), 'discard during restore');
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), '{"preserved":true}\n');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'backup-restore', 'rollback', 'old-state.txt')), true);
@@ -35,7 +35,11 @@ function listActiveDataEntries({ includeRuntime = true } = {}) {
}
function removeActiveData() {
for (const name of listActiveDataEntries()) {
// Runtime contains disposable work owned by active companion processes as
// well as the lifecycle controller's status directory. It is deliberately
// absent from backup archives, so restore must leave it untouched instead
// of trying to delete root-owned controller state from the non-root server.
for (const name of listActiveDataEntries({ includeRuntime: false })) {
fs.rmSync(path.join(resolveDataDir(), name), { recursive: true, force: true });
}
}
+26 -9
View File
@@ -90,12 +90,7 @@ const {
const config = structuredClone(loadConfig());
const discordConfig = config.discord || {};
let enabled = Boolean(discordConfig.enabled);
// These normalized command names mirror the command router. Bridge-channel
// command replies are mirrored into web chat, so this entrypoint needs to know
// the configured command names before it wraps message.reply.
const configuredAdministrators = getConfigurationDatabase().listAdministrators();
const adminIds = new Set(configuredAdministrators.map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
const lockdownAdminIds = new Set(configuredAdministrators.filter((admin) => admin.role === 'lockdown').map((admin) => String(admin.discordId || '').trim()).filter(Boolean));
const configurationDatabase = getConfigurationDatabase();
if (!enabled) logger.info('Discord disabled by config');
@@ -122,12 +117,34 @@ function sanitizeMentions(text) {
.replace(/@here/gi, '[here]');
}
function findDiscordAdministrator(discordId) {
const normalizedDiscordId = String(discordId || '').trim();
if (!normalizedDiscordId) return null;
// Read the administrator registry at the moment Discord checks permission.
// Setup imports and administrator edits happen after this module starts, so
// a startup-only Set would remain stale until the whole server restarted.
return configurationDatabase.listAdministrators().find(
(administrator) => String(administrator.discordId || '').trim() === normalizedDiscordId,
) || null;
}
function isAdminUser(discordId) {
return adminIds.has(String(discordId || '').trim());
return Boolean(findDiscordAdministrator(discordId));
}
function isLockdownAdminUser(discordId) {
return lockdownAdminIds.has(String(discordId || '').trim());
return findDiscordAdministrator(discordId)?.role === 'lockdown';
}
function getLockdownAdminIds() {
// Moderation requests use the same live registry as command authorization,
// ensuring newly imported or edited lockdown accounts receive DMs without a
// restart or a second cache-synchronization system.
return configurationDatabase.listAdministrators()
.filter((administrator) => administrator.role === 'lockdown')
.map((administrator) => String(administrator.discordId || '').trim())
.filter(Boolean);
}
function countReady() {
@@ -301,7 +318,7 @@ const commands = createCommandHandlers(commandDependencies);
getPrivateAccessRequestByMessageId,
approvePrivateAccessRequest,
denyPrivateAccessRequest,
lockdownAdminIds,
getLockdownAdminIds,
isAdminUser,
isLockdownAdminUser,
sendToChannel: channelIO.sendToChannel,
@@ -5,7 +5,7 @@ function createDmModerationHandlers(deps) {
const {
logger,
client,
lockdownAdminIds,
getLockdownAdminIds,
attachDmMessage,
getRequestByMessageId,
approveRequest,
@@ -36,7 +36,9 @@ function createDmModerationHandlers(deps) {
'',
`React with ${APPROVE} to approve or ${DENY} to deny.`,
].join('\n');
await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => {
// Resolve recipients when the request occurs so setup imports and account
// edits take effect immediately instead of waiting for a server restart.
await Promise.all(getLockdownAdminIds().map(async (adminId) => {
try {
const user = await client.users.fetch(String(adminId));
if (!user) return;
@@ -69,7 +71,9 @@ function createDmModerationHandlers(deps) {
'',
`React with ${APPROVE} to approve or ${DENY} to deny.`,
].join('\n');
await Promise.all(Array.from(lockdownAdminIds).map(async (adminId) => {
// Keep private-access moderation on the same live administrator registry
// used by command authorization and verification requests.
await Promise.all(getLockdownAdminIds().map(async (adminId) => {
try {
const user = await client.users.fetch(String(adminId));
if (!user) return;
+47 -1
View File
@@ -2,8 +2,9 @@
// Purpose: Defines the health Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fsp = require('fs/promises');
const fs = require('fs');
const path = require('path');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const { resolveDataDir, resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService');
@@ -13,6 +14,8 @@ const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
const MEDIAMTX_HEALTH_URL = 'http://127.0.0.1:9998/metrics';
const MEDIAMTX_HEALTH_TIMEOUT_MS = 1500;
let latest = {
updatedAt: Date.now(),
@@ -88,6 +91,49 @@ function getHealthSnapshot() {
return latest;
}
async function getContainerHealth() {
let dataDirectory = false;
let mediaMtx = false;
try {
/*
The mounted data root is the container's only persistent storage. Check
the directory itself instead of creating a probe file on every request;
this proves that the running application user can reach the mount without
adding health-check writes to backups or administrative file listings.
*/
await fsp.access(resolveDataDir(), fs.constants.R_OK | fs.constants.W_OK);
dataDirectory = true;
} catch {
dataDirectory = false;
}
try {
/*
MediaMTX already exposes metrics only on loopback, so it is also the
smallest reliable readiness probe. Reading the response closes the body
before this request completes and avoids accumulating idle connections
across Docker's recurring health checks.
*/
const response = await fetch(MEDIAMTX_HEALTH_URL, {
signal: AbortSignal.timeout(MEDIAMTX_HEALTH_TIMEOUT_MS),
});
await response.text();
mediaMtx = response.ok;
} catch {
mediaMtx = false;
}
return {
healthy: dataDirectory && mediaMtx,
checks: {
dataDirectory,
mediaMtx,
},
};
}
module.exports = {
getContainerHealth,
getHealthSnapshot,
};
@@ -5,7 +5,7 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService');
const { getRole } = require('../roleService');
const {
listUsersForAdmin,
listUserSummariesForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
@@ -71,10 +71,13 @@ function ackHandler(socket, eventName, handler) {
}
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:listUsers', ({ query, filter }) => {
const result = listUserSummariesForAdmin({ query, filter });
return {
...result,
permissions: listRegisteredPermissions(),
};
});
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
permissions: listRegisteredPermissions(),
@@ -19,6 +19,7 @@ const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 4;
const ADMIN_USER_LIST_LIMIT = 100;
const identityEvents = new EventEmitter();
let db = null;
@@ -560,6 +561,85 @@ function listUsersForAdmin() {
}));
}
function listUserSummariesForAdmin({ query = '', filter = 'all' } = {}) {
const conn = getDb();
const normalizedQuery = String(query || '').trim().toLowerCase().slice(0, 200);
const normalizedFilter = ['all', 'verified', 'deterred', 'muted', 'unverified'].includes(filter)
? filter
: 'all';
const conditions = [];
const parameters = [];
if (normalizedFilter === 'verified') conditions.push('coalesce(user_status.verified_enabled, 0) = 1');
if (normalizedFilter === 'deterred') conditions.push('coalesce(user_status.deterrence_enabled, 0) = 1');
if (normalizedFilter === 'muted') conditions.push('coalesce(user_status.muted_enabled, 0) = 1');
if (normalizedFilter === 'unverified') conditions.push('coalesce(user_status.verified_enabled, 0) = 0');
if (normalizedQuery) {
const pattern = `%${normalizedQuery}%`;
/*
Search stays inside one bounded SQLite statement. EXISTS checks preserve
lookup by any known identity signal without constructing every user's
complete signal and feature-state record in JavaScript first.
*/
conditions.push(`(
lower(users.id) like ?
or exists (select 1 from user_nicknames where user_id = users.id and lower(nickname) like ?)
or exists (select 1 from user_cookie_ids where user_id = users.id and lower(cookie_user_id) like ?)
or exists (select 1 from user_fingerprint_ids where user_id = users.id and lower(fingerprint_id) like ?)
or exists (select 1 from user_known_ips where user_id = users.id and lower(ip) like ?)
or exists (select 1 from user_feature_state where user_id = users.id and lower(namespace) like ?)
or exists (select 1 from user_permissions where user_id = users.id and lower(permission_key) like ?)
)`);
parameters.push(pattern, pattern, pattern, pattern, pattern, pattern, pattern);
}
const where = conditions.length ? `where ${conditions.join(' and ')}` : '';
/*
The list needs only the newest visible signal and moderation flags. Full
signal histories, permissions, and feature JSON remain available through
getUserForAdmin after an administrator selects one of these summaries.
Reading one extra row tells the UI whether it should ask for a narrower
search without running a second full COUNT query.
*/
const rows = conn.prepare(`
select
users.id,
users.created_at,
users.updated_at,
users.last_seen_at,
coalesce(user_status.verified_enabled, 0) as verified_enabled,
coalesce(user_status.deterrence_enabled, 0) as deterrence_enabled,
coalesce(user_status.muted_enabled, 0) as muted_enabled,
(select nickname from user_nicknames where user_id = users.id order by last_seen_at desc limit 1) as nickname,
(select cookie_user_id from user_cookie_ids where user_id = users.id order by last_seen_at desc limit 1) as cookie_user_id,
(select fingerprint_id from user_fingerprint_ids where user_id = users.id order by last_seen_at desc limit 1) as fingerprint_id
from users
left join user_status on user_status.user_id = users.id
${where}
order by coalesce(users.last_seen_at, users.updated_at, users.created_at) desc
limit ?
`).all(...parameters, ADMIN_USER_LIST_LIMIT + 1);
return {
truncated: rows.length > ADMIN_USER_LIST_LIMIT,
users: rows.slice(0, ADMIN_USER_LIST_LIMIT).map((row) => ({
id: row.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastSeenAt: row.last_seen_at,
nickname: row.nickname || null,
cookieUserIds: row.cookie_user_id ? [row.cookie_user_id] : [],
fingerprintIds: row.fingerprint_id ? [row.fingerprint_id] : [],
verified: { enabled: Boolean(row.verified_enabled) },
deterrence: {
enabled: Boolean(row.deterrence_enabled),
muted: Boolean(row.muted_enabled),
},
})),
};
}
function getUserForAdmin(userId) {
const user = getUserById(userId, { includeFeatures: true });
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
@@ -1111,6 +1191,7 @@ module.exports = {
attachIdentitySignals,
getUserById,
listUsersForAdmin,
listUserSummariesForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
@@ -80,3 +80,36 @@ test('unknown permission keys cannot be persisted', () => {
/Unknown user permission/,
);
});
test('administrator user summaries are bounded and searchable without loading full records', () => {
const db = identityService.getDb();
const insertUser = db.prepare('insert or ignore into users (id, created_at, updated_at, last_seen_at) values (?, ?, ?, ?)');
const insertStatus = db.prepare('insert or ignore into user_status (user_id, deterrence_enabled) values (?, ?)');
const insertNickname = db.prepare('insert or ignore into user_nicknames (user_id, nickname, first_seen_at, last_seen_at) values (?, ?, ?, ?)');
/*
Seed more records than one response may contain. Direct inserts keep this
focused test independent from browser identity generation while exercising
the real normalized tables and the same query used by the admin socket.
*/
db.transaction(() => {
for (let index = 0; index < 105; index += 1) {
const userId = `usr_${index.toString(16).padStart(32, '0')}`;
insertUser.run(userId, index, index, index);
insertStatus.run(userId, index === 104 ? 1 : 0);
insertNickname.run(userId, index === 104 ? 'Unique Search Target' : `User ${index}`, index, index);
}
})();
const recent = identityService.listUserSummariesForAdmin();
assert.equal(recent.users.length, 100);
assert.equal(recent.truncated, true);
const searched = identityService.listUserSummariesForAdmin({ query: 'unique search target' });
assert.equal(searched.users.length, 1);
assert.equal(searched.users[0].nickname, 'Unique Search Target');
const deterred = identityService.listUserSummariesForAdmin({ filter: 'deterred' });
assert.equal(deterred.users.length, 1);
assert.equal(deterred.users[0].deterrence.enabled, true);
});
@@ -69,13 +69,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
{ url: 'stun:stun.cloudflare.com:3478' },
],
/*
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
writer. Rover media moves to RTSP, but removing this listener would break those independent
consumers, so both listeners remain deliberately enabled.
*/
srt: true,
srtAddress: ':9000',
// Every publisher and server-local reader uses RTSP over TCP. Disable SRT
// completely so MediaMTX cannot reintroduce the GoSRT/libSRT ACKACK mismatch.
srt: false,
authMethod: 'http',
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
@@ -20,6 +20,8 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
assert.equal(generated.rtsp, true);
assert.equal(generated.rtspAddress, ':8554');
assert.deepEqual(generated.rtspTransports, ['tcp']);
assert.equal(generated.srt, false);
assert.equal(Object.hasOwn(generated, 'srtAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
+8 -11
View File
@@ -573,7 +573,7 @@ function schedulePublisherRestart(reason = 'publisher-restart') {
function startPublisher() {
if (!enabled || !state.rtspUri || publisherProcess) return;
const input = addCredentialsToRtsp(state.rtspUri);
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
const output = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
/*
The full-quality autotrack profile is H265, which is the right camera-side
feed but has been unreliable through browser WHEP playback. Re-encoding is
@@ -607,10 +607,9 @@ function startPublisher() {
dead session. The existing exit handler then starts a new process, which is
the part that creates a fresh RTSP connection after the camera comes back.
The mpegts muxer can also hold packets briefly before writing them to SRT.
flush_packets/muxdelay/muxpreload are output-side latency knobs; they do not
ask the camera or demuxer to discard frames, so they are a safer next step
than the stale-frame dropping experiments that made the Reolink feed freeze.
The MediaMTX output uses RTSP over TCP, matching every rover publisher and
server-local reader. Keeping one media transport avoids the incompatible
empty SRT ACKACK packets produced between GoSRT and Fedora's newer libSRT.
*/
const proc = spawn('ffmpeg', [
'-hide_banner',
@@ -671,12 +670,10 @@ function startPublisher() {
'-2',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-rtsp_transport',
'tcp',
'-f',
'mpegts',
'rtsp',
output,
], { stdio: ['ignore', 'ignore', 'pipe'] });
publisherProcess = proc;
@@ -1890,7 +1887,7 @@ module.exports = {
audio the same way it already mixes rover audio.
*/
if (!enabled || !isReplayEnabled()) return [];
const inputUrl = `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`;
const inputUrl = `rtsp://127.0.0.1:8554/${encodeURIComponent(PTZ_STREAM_PATH)}`;
const label = cameraConfig.name || 'PTZ Camera';
return [
{
+10 -5
View File
@@ -15,8 +15,8 @@ function sourceDirForKey(activeSegmentRoot, key) {
return path.join(activeSegmentRoot, key);
}
function toSrtReadPath(streamId) {
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(streamId)}`;
function toRtspReadPath(streamId) {
return `rtsp://127.0.0.1:8554/${encodeURIComponent(streamId)}`;
}
function getRoomCameraStream(camera) {
@@ -37,9 +37,9 @@ function listDesiredSources() {
const sources = [];
for (const rover of roverManager.getRoster()) {
const roverId = String(rover.id);
sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toSrtReadPath(roverId) });
sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toRtspReadPath(roverId) });
if (hasRoverAudioCapture(rover)) {
sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toSrtReadPath(`${roverId}-audio`) });
sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toRtspReadPath(`${roverId}-audio`) });
}
}
for (const camera of getRoomCameras()) {
@@ -57,7 +57,12 @@ function listDesiredSources() {
function buildWorkerArgs(activeSegmentRoot, source) {
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
const pattern = path.join(dir, 'seg-%06d.mp4');
const common = ['-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', '-i', source.inputUrl];
// MediaMTX and RTSP cameras use TCP so replay capture has one reliable
// transport and never falls back to separate RTP/RTCP UDP listeners.
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
? ['-rtsp_transport', 'tcp']
: [];
const common = ['-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', ...inputTransport, '-i', source.inputUrl];
if (source.kind === 'audio') {
return [
@@ -0,0 +1,339 @@
// Restricted Container Lifecycle Controller
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts no caller-selected targets.
const fs = require('fs');
const path = require('path');
const http = require('http');
const Docker = require('dockerode');
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const TARGET_CONTAINER_NAME = 'multirover';
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
// Status belongs beside the controller's private socket instead of in the
// application's data volume. The controller runs as root for Docker access;
// keeping it out of /data prevents it from creating directories that the
// non-root application cannot later use for replay and audio runtime work.
const STATUS_PATH = process.env.MULTIROVER_LIFECYCLE_STATUS || '/run/multirover/status.json';
const HEALTH_TIMEOUT_MS = 2 * 60 * 1000;
const HEALTH_POLL_MS = 1000;
let operationRunning = false;
let status = readStatus();
function readStatus() {
try {
const saved = JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
// A controller replacement cannot resume an in-flight Docker operation.
// Preserve its history but make the interrupted result explicit.
if (saved.state === 'running') {
return {
...saved,
state: 'failed',
message: 'The lifecycle controller restarted before the operation completed.',
completedAt: Date.now(),
};
}
return saved;
} catch (error) {
if (error.code !== 'ENOENT') process.stderr.write(`Unable to read lifecycle status: ${error.message}\n`);
return {
state: 'idle',
operation: null,
message: 'No lifecycle operation has run yet.',
startedAt: null,
completedAt: null,
runningImage: null,
availableImage: null,
updateAvailable: null,
rollback: null,
};
}
}
function writeStatus(patch) {
status = { ...status, ...patch };
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
const temporaryPath = `${STATUS_PATH}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(temporaryPath, STATUS_PATH);
return status;
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function inspectTargetContainer() {
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
}
async function inspectLocalTargetImage() {
try {
return await docker.getImage(TARGET_IMAGE).inspect();
} catch (error) {
if (error.statusCode === 404) return null;
throw error;
}
}
async function pullTargetImage() {
const stream = await new Promise((resolve, reject) => {
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
});
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
});
const image = await inspectLocalTargetImage();
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
return image;
}
function environmentMap(values = []) {
const result = new Map();
values.forEach((entry) => {
const separator = String(entry).indexOf('=');
const key = separator >= 0 ? entry.slice(0, separator) : entry;
result.set(key, String(entry));
});
return result;
}
function mergeEnvironment({ current = [], previousDefaults = [], nextDefaults = [] }) {
const previous = environmentMap(previousDefaults);
const overrides = current.filter((entry) => previous.get(String(entry).split('=', 1)[0]) !== entry);
const merged = environmentMap(nextDefaults);
overrides.forEach((entry) => merged.set(String(entry).split('=', 1)[0], entry));
return [...merged.values()];
}
async function buildReplacementOptions({ current, imageReference }) {
const [previousImage, nextImage] = await Promise.all([
docker.getImage(current.Image).inspect(),
docker.getImage(imageReference).inspect(),
]);
const imageConfig = nextImage.Config || {};
/*
Image-owned defaults come from the replacement image so updates can change
their command, health check, or runtime environment. Only environment
values that Compose overrode on the running container survive. HostConfig
is copied intact because it contains the named volume, host networking,
device access, capability, security, and restart-policy contract.
*/
return {
name: TARGET_CONTAINER_NAME,
Image: imageReference,
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
Tty: false,
OpenStdin: false,
StdinOnce: false,
Env: mergeEnvironment({
current: current.Config?.Env,
previousDefaults: previousImage.Config?.Env,
nextDefaults: imageConfig.Env,
}),
Cmd: imageConfig.Cmd,
Entrypoint: imageConfig.Entrypoint,
WorkingDir: imageConfig.WorkingDir,
User: imageConfig.User,
ExposedPorts: imageConfig.ExposedPorts,
Volumes: imageConfig.Volumes,
Healthcheck: imageConfig.Healthcheck,
StopSignal: imageConfig.StopSignal,
StopTimeout: imageConfig.StopTimeout,
Labels: { ...(imageConfig.Labels || {}), ...(current.Config?.Labels || {}) },
HostConfig: current.HostConfig,
};
}
async function removeContainerIfPresent() {
const container = docker.getContainer(TARGET_CONTAINER_NAME);
try {
const current = await container.inspect();
if (current.State?.Running) {
try {
await container.stop({ t: 20 });
} catch (error) {
if (error.statusCode !== 304) throw error;
}
}
await container.remove();
} catch (error) {
if (error.statusCode !== 404) throw error;
}
}
async function replaceContainer({ template, imageReference }) {
const options = await buildReplacementOptions({ current: template, imageReference });
await removeContainerIfPresent();
const replacement = await docker.createContainer(options);
await replacement.start();
return replacement;
}
async function waitForHealthyContainer() {
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
while (Date.now() < deadline) {
const current = await inspectTargetContainer();
const health = current.State?.Health?.Status;
if (current.State?.Running && health === 'healthy') return current;
if (health === 'unhealthy') throw new Error('The replacement container failed its health check.');
await sleep(HEALTH_POLL_MS);
}
throw new Error('The replacement container did not become healthy in time.');
}
async function refreshImageStatus() {
const [container, image] = await Promise.all([
inspectTargetContainer(),
inspectLocalTargetImage(),
]);
return writeStatus({
runningImage: container.Image,
availableImage: image?.Id || null,
updateAvailable: image ? container.Image !== image.Id : null,
});
}
async function runCheck() {
writeStatus({ message: `Pulling ${TARGET_IMAGE}` });
await pullTargetImage();
const current = await refreshImageStatus();
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
}
async function runRestart() {
writeStatus({ message: 'Restarting the application container…' });
await docker.getContainer(TARGET_CONTAINER_NAME).restart({ t: 20 });
await waitForHealthyContainer();
await refreshImageStatus();
return 'The application container restarted successfully.';
}
async function runUpdate() {
writeStatus({ message: `Pulling ${TARGET_IMAGE}`, rollback: null });
const targetImage = await pullTargetImage();
const previousContainer = await inspectTargetContainer();
const previousImage = previousContainer.Image;
let replacementStarted = false;
try {
writeStatus({
message: 'Replacing the application container…',
runningImage: previousImage,
availableImage: targetImage.Id,
updateAvailable: previousImage !== targetImage.Id,
});
replacementStarted = true;
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE });
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
const healthy = await waitForHealthyContainer();
writeStatus({
runningImage: healthy.Image,
availableImage: targetImage.Id,
updateAvailable: false,
});
return previousImage === targetImage.Id
? 'The current application image was restarted successfully.'
: 'The application was updated successfully.';
} catch (error) {
if (!replacementStarted) throw error;
writeStatus({ message: 'The update failed; restoring the previous application image…' });
try {
await replaceContainer({ template: previousContainer, imageReference: previousImage });
await waitForHealthyContainer();
writeStatus({
rollback: { status: 'succeeded', image: previousImage, completedAt: Date.now() },
runningImage: previousImage,
updateAvailable: true,
});
} catch (rollbackError) {
writeStatus({
rollback: { status: 'failed', image: previousImage, error: rollbackError.message, completedAt: Date.now() },
});
throw new Error(`${error.message} Rollback also failed: ${rollbackError.message}`);
}
throw new Error(`${error.message} The previous image was restored.`);
}
}
function queueOperation(operation, runner) {
if (operationRunning) throw new Error('Another lifecycle operation is already running.');
operationRunning = true;
writeStatus({
state: 'running',
operation,
message: `Starting ${operation}`,
startedAt: Date.now(),
completedAt: null,
});
// Respond to the application before replacement disconnects its Socket.IO
// clients. The persisted status remains available when the new process asks
// for the result after reconnecting.
setTimeout(async () => {
try {
const message = await runner();
writeStatus({ state: 'succeeded', message, completedAt: Date.now() });
} catch (error) {
writeStatus({ state: 'failed', message: error.message, completedAt: Date.now() });
} finally {
operationRunning = false;
}
}, 500);
return status;
}
function sendJson(response, statusCode, value) {
response.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
response.end(JSON.stringify(value));
}
const server = http.createServer((request, response) => {
try {
if (request.method === 'GET' && request.url === '/status') {
refreshImageStatus()
.catch(() => status)
.then((current) => sendJson(response, 200, { available: true, ...current }));
return;
}
if (request.method === 'POST' && request.url === '/check') {
sendJson(response, 202, { available: true, ...queueOperation('check', runCheck) });
return;
}
if (request.method === 'POST' && request.url === '/restart') {
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
return;
}
if (request.method === 'POST' && request.url === '/update') {
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) });
return;
}
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
} catch (error) {
sendJson(response, 409, { error: error.message });
}
});
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true });
fs.rmSync(SOCKET_PATH, { force: true });
server.listen(SOCKET_PATH, () => {
// The application runs as uid 1000 while this Docker-authorized controller
// runs as root. The socket carries only the fixed API above, so permitting
// the application user to connect does not expose arbitrary Docker access.
fs.chmodSync(SOCKET_PATH, 0o666);
process.stdout.write(`Lifecycle controller listening on ${SOCKET_PATH}\n`);
});
function stop() {
server.close(() => {
fs.rmSync(SOCKET_PATH, { force: true });
process.exit(0);
});
}
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
@@ -4,7 +4,8 @@
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('serverControlService');
const { getConfigurationDatabase } = require('../../configuration');
const { requireRecentPassword } = require('../adminConfigurationService');
const { requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService');
const lifecycleClient = require('./lifecycleClient');
const database = getConfigurationDatabase();
let restartPending = false;
@@ -45,15 +46,60 @@ function requestApplicationRestart({ actor, reason = 'administrator-requested' }
}
io.on('connection', (socket) => {
socket.on('server:lifecycleStatus', (_payload = {}, cb = () => {}) => {
Promise.resolve()
.then(() => requireLockdownAdministrator(socket))
.then(() => lifecycleClient.getLifecycleStatus())
.then((lifecycle) => cb({ success: true, lifecycle }))
.catch((error) => cb({ error: error.message, code: error.code || null }));
});
socket.on('server:checkForUpdate', (_payload = {}, cb = () => {}) => {
Promise.resolve()
.then(() => requireRecentPassword(socket))
.then(() => lifecycleClient.checkForUpdate())
.then((lifecycle) => {
database.recordAuditEvent(actorFor(socket), 'application.update-check-requested', {});
cb({ success: true, lifecycle });
})
.catch((error) => cb({ error: error.message, code: error.code || null }));
});
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => {
Promise.resolve()
.then(() => requireRecentPassword(socket))
.then(() => lifecycleClient.updateApplication())
.then((lifecycle) => {
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
io.emit('server:restarting', { reason: 'application-update' });
cb({ success: true, lifecycle });
})
.catch((error) => cb({ error: error.message, code: error.code || null }));
});
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
try {
Promise.resolve().then(async () => {
requireRecentPassword(socket);
if (restartPending) throw new Error('Application restart already pending.');
const actor = actorFor(socket);
requestApplicationRestart({ actor });
cb({ success: true });
} catch (error) {
cb({ error: error.message, code: error.code || null });
}
try {
const lifecycle = await lifecycleClient.restartApplication();
restartPending = true;
database.recordAuditEvent(actor, 'application.restart-requested', { reason: 'administrator-requested' });
logger.warn('Application container restart requested', { actor });
io.emit('server:restarting', { reason: 'administrator-requested' });
cb({ success: true, lifecycle });
} catch (error) {
/*
Legacy installations intentionally have no controller socket. Keep
their existing process-signal restart during migration, but never
bypass a real controller rejection such as an operation conflict.
*/
if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
requestApplicationRestart({ actor });
cb({ success: true, legacy: true });
}
}).catch((error) => cb({ error: error.message, code: error.code || null }));
});
});
@@ -0,0 +1,72 @@
// Container Lifecycle Client
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
// Scope: This client cannot select images, containers, or Docker arguments; the controller owns those constants.
const http = require('http');
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
const REQUEST_TIMEOUT_MS = 5000;
function requestLifecycleController({ method = 'GET', path }) {
return new Promise((resolve, reject) => {
const request = http.request({
socketPath: LIFECYCLE_SOCKET_PATH,
method,
path,
headers: { Accept: 'application/json' },
}, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
// Controller responses contain only a small status document. Keeping a
// hard bound prevents a broken peer from growing application memory.
body += chunk;
if (body.length > 64 * 1024) request.destroy(new Error('Lifecycle controller response is too large.'));
});
response.on('end', () => {
let parsed;
try {
parsed = body ? JSON.parse(body) : {};
} catch {
reject(new Error('Lifecycle controller returned an invalid response.'));
return;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
reject(new Error(parsed.error || 'Lifecycle controller request failed.'));
return;
}
resolve(parsed);
});
});
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('Lifecycle controller did not respond.')));
request.on('error', reject);
request.end();
});
}
async function getLifecycleStatus() {
try {
return await requestLifecycleController({ path: '/status' });
} catch (error) {
/*
The legacy development/server installation deliberately has no lifecycle
controller. Report that as an unavailable capability so the existing
in-process restart remains usable instead of turning absence into a noisy
server error.
*/
return {
available: false,
state: 'unavailable',
message: error.message,
operation: null,
updateAvailable: null,
};
}
}
module.exports = {
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
getLifecycleStatus,
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }),
};
@@ -32,13 +32,16 @@ function registerVideoAuthRoute(deps) {
});
}
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt');
const isRtspProtocol = protocol === 'rtsp' || protocol.startsWith('rtsp');
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
return res.status(200).end();
}
if (action === 'publish' && isSrtLikeProtocol) {
const isLoopback = ip === '127.0.0.1' || ip === '::1';
const isRoverForwardAudioRead = action === 'read'
&& isRtspProtocol
&& streamInfo?.id?.endsWith('-fwd');
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
// not represent a browser session. Rovers likewise read their dedicated
// -fwd speaker feed without browser credentials; every other non-loopback
// RTSP read remains subject to normal session authorization below.
if ((action === 'read' && isRtspProtocol && isLoopback) || isRoverForwardAudioRead) {
return res.status(200).end();
}
/*
@@ -5,7 +5,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const { registerVideoAuthRoute } = require('./httpRoute');
function createHarness() {
function createHarness({ requestIp = '127.0.0.1' } = {}) {
let handler;
const app = { post: (_path, fn) => { handler = fn; } };
registerVideoAuthRoute({
@@ -13,7 +13,7 @@ function createHarness() {
io: { sockets: { sockets: new Map() } },
logger: { info() {}, warn() {} },
videoSessions: { getSession: () => null, revokeSession() {} },
getRequestIp: () => '127.0.0.1',
getRequestIp: () => requestIp,
logAdminEvent() {},
extractStreamInfoFromBody: (body) => ({ type: 'rover', id: body.path, baseId: body.path }),
canAccessStream: () => false,
@@ -42,6 +42,21 @@ test('allows an RTSP rover publisher without a browser session', () => {
assert.equal(request({ protocol: 'rtsp', action: 'publish', path: 'rover-one' }), 200);
});
test('allows server-local RTSP replay and snapshot readers without a browser session', () => {
const { request } = createHarness();
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one' }), 200);
});
test('continues rejecting an unauthenticated remote RTSP reader', () => {
const { request } = createHarness({ requestIp: '192.0.2.10' });
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one' }), 401);
});
test('allows a rover to read its RTSP speaker-forward stream without a browser session', () => {
const { request } = createHarness({ requestIp: '192.0.2.10' });
assert.equal(request({ protocol: 'rtsp', action: 'read', path: 'rover-one-fwd' }), 200);
});
test('continues rejecting an unauthenticated WebRTC read', () => {
const { request } = createHarness();
assert.equal(request({ protocol: 'webrtc', action: 'read', path: 'rover-one' }), 401);
@@ -1,6 +1,6 @@
// Video Auth Stream Parsing
// Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks.
// Scope: Handles native MediaMTX WHEP/WHIP paths and SRT streamid extraction without performing auth decisions.
// Scope: Handles native MediaMTX path forms without performing auth decisions.
const PTZ_STREAM_PATH = 'ptz-camera';
@@ -44,42 +44,10 @@ function extractStreamInfo(path) {
return null;
}
function extractSrtStreamId(rawValue) {
const value = decodeURIComponent(String(rawValue || '').trim());
if (!value) return '';
const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/);
if (match?.[1]) {
return match[1];
}
if (!/[?&=,:]/.test(value)) {
return value;
}
return '';
}
function extractStreamInfoFromBody(body = {}) {
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, ''));
if (fromPath) return fromPath;
const srtId =
extractSrtStreamId(body.streamid) ||
extractSrtStreamId(body.streamId) ||
extractSrtStreamId(body.query);
if (!srtId) return null;
if (srtId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: srtId };
}
if (srtId.endsWith('-fwd')) {
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
}
const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId;
return { type: 'rover', id: srtId, baseId };
// All enabled MediaMTX protocols now report the canonical path directly;
// there is no second SRT stream-id syntax to normalize or authorize.
return extractStreamInfo((body.path || '').replace(/^\//, ''));
}
module.exports = {
+5 -3
View File
@@ -8,9 +8,11 @@ and make sure each toggle and counter actually has a purpose besides debugging o
2. bot clears the channel, then sends a new message
3. has a fancy big embed that shows way more stuff
4. also has a small text copy at the top (readable on smartwatch lol)
1. ```freaky: docked
wall-e: user1 driving
bweeble: NEEDS HELP```
1. ```
freaky: docked
wall-e: user1 driving
bweeble: NEEDS HELP
```
2. setting to disable replay popups in spectator settings menu
3. add admin ui for VIP and private requests instead of only through discord
4. make roverd self update checkout to main branch
+3
View File
@@ -26,6 +26,9 @@ export const createAdministrator = (socket, payload) => emitAdminRequest(socket,
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication');
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
+79 -6
View File
@@ -4,7 +4,13 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import CardFrame from '../../components/CardFrame/index.jsx';
import { restartApplication, restoreConfigurationRevision } from '../api.js';
import {
checkForApplicationUpdate,
getApplicationLifecycleStatus,
restartApplication,
restoreConfigurationRevision,
updateApplication,
} from '../api.js';
function formatDate(value) {
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
@@ -13,6 +19,33 @@ function formatDate(value) {
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
const config = snapshot.configuration;
const [restartRequested, setRestartRequested] = useState(false);
const [lifecycle, setLifecycle] = useState(null);
const [lifecycleError, setLifecycleError] = useState('');
useEffect(() => {
let mounted = true;
async function refreshLifecycle() {
if (!socket.connected) return;
try {
const response = await getApplicationLifecycleStatus(socket);
if (!mounted) return;
setLifecycle(response.lifecycle);
setLifecycleError('');
} catch (error) {
if (mounted) setLifecycleError(error.message);
}
}
refreshLifecycle();
// Update work continues while this application is being replaced. Polling
// the controller gives the page current progress before disconnect and the
// persisted final result immediately after Socket.IO reconnects.
const timer = window.setInterval(refreshLifecycle, 2500);
return () => {
mounted = false;
window.clearInterval(timer);
};
}, [socket]);
useEffect(() => {
if (!restartRequested) return undefined;
@@ -47,6 +80,33 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
}
}
async function checkForUpdate() {
try {
const response = await runSensitive(() => checkForApplicationUpdate(socket));
setLifecycle(response.lifecycle);
} catch (error) {
setLifecycleError(error.message);
}
}
async function update() {
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return;
try {
const response = await runSensitive(() => updateApplication(socket));
setLifecycle(response.lifecycle);
} catch (error) {
setLifecycleError(error.message);
}
}
const lifecycleBusy = lifecycle?.state === 'running';
const lifecycleAvailable = Boolean(lifecycle?.available);
const updateSummary = lifecycle?.updateAvailable === true
? 'An update is available.'
: lifecycle?.updateAvailable === false
? 'The running application matches the latest checked image.'
: 'Check for updates to compare the running container with the configured image.';
return (
<div className="space-y-0.5">
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
@@ -58,11 +118,24 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
<Link className="button-dark" to="/reports">Open fleet reports</Link>
<Link className="button-dark" to="/">Open driver application</Link>
</CardFrame>
<CardFrame title="Application" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-slate-300">Restart only the MultiRover application. The process supervisor starts it again automatically without rebooting the host.</p>
<button type="button" className="button-danger" disabled={restartRequested} onClick={restart}>
{restartRequested ? 'Waiting for application…' : 'Restart application'}
</button>
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? `${lifecycle.rollback.error}` : ''}</p> : null}
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
</div>
<div className="flex flex-wrap gap-0.5">
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
</button>
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}>
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
</button>
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
{restartRequested ? 'Waiting for application…' : 'Restart application'}
</button>
</div>
</CardFrame>
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
{snapshot.revisions.map((revision) => (
+24 -16
View File
@@ -1,7 +1,7 @@
// Identity Database Panel
// Purpose: Implements the lockdown admin identity database editor UI inside the centralized administration application.
// Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import CardFrame from '../components/CardFrame/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../components/Tabs/index.jsx';
import { useSocket } from '../context/SocketContext.jsx';
@@ -24,8 +24,6 @@ import {
maskValue,
parseEditableJson,
stringifyJson,
userMatchesFilter,
userMatchesQuery,
} from './identityDatabaseUtils.js';
const FILTERS = [
@@ -44,21 +42,16 @@ function StatusPill({ active, children }) {
);
}
function UserListCard({ users, selectedUserId, query, filter, loading, onQuery, onFilter, onRefresh, onSelect }) {
const filtered = useMemo(
() => users.filter((user) => userMatchesFilter(user, filter) && userMatchesQuery(user, query)),
[filter, query, users],
);
function UserListCard({ users, truncated, selectedUserId, query, filter, loading, onQuery, onFilter, onRefresh, onSearch, onSelect }) {
const actions = (
<button type="button" className="button-dark text-xs" onClick={onRefresh} disabled={loading}>
<button type="button" className="button-dark text-xs" onClick={() => onRefresh()} disabled={loading}>
{loading ? 'Loading' : 'Refresh'}
</button>
);
return (
<CardFrame title="Identity database" meta={filtered.length} actions={actions} bodyClassName="flex min-h-0 flex-col gap-0.5 p-0.5 text-sm">
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_10rem]">
<CardFrame title="Identity database" meta={truncated ? `${users.length}+` : users.length} actions={actions} bodyClassName="flex min-h-0 flex-col gap-0.5 p-0.5 text-sm">
<form className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_10rem_auto]" onSubmit={onSearch}>
<input
className="field-input text-sm"
type="search"
@@ -71,9 +64,15 @@ function UserListCard({ users, selectedUserId, query, filter, loading, onQuery,
<option key={entry.key} value={entry.key}>{entry.label}</option>
))}
</select>
</div>
<button type="submit" className="button-dark text-xs" disabled={loading}>Search</button>
</form>
{truncated ? (
<p className="surface-muted px-1 py-0.5 text-xs text-slate-400">
Showing the first 100 matches. Narrow the search to find older users.
</p>
) : null}
<div className="min-h-[18rem] flex-1 overflow-y-auto">
{filtered.length ? filtered.map((user) => (
{users.length ? users.map((user) => (
<button
key={user.id}
type="button"
@@ -333,14 +332,16 @@ export default function IdentityDatabasePanel() {
const [selectedUser, setSelectedUser] = useState(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const refreshUsers = useCallback(async () => {
setLoading(true);
try {
const resp = await listUsers(socket);
const resp = await listUsers(socket, { query, filter });
setUsers(resp.users || []);
setTruncated(Boolean(resp.truncated));
setPermissions(resp.permissions || []);
if (selectedUser?.id) {
const updated = await getUser(socket, selectedUser.id);
@@ -352,7 +353,7 @@ export default function IdentityDatabasePanel() {
} finally {
setLoading(false);
}
}, [selectedUser?.id, socket]);
}, [filter, query, selectedUser?.id, socket]);
const selectUser = useCallback(async (userId) => {
setLoading(true);
@@ -391,6 +392,11 @@ export default function IdentityDatabasePanel() {
refreshUsers();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const submitSearch = (event) => {
event.preventDefault();
refreshUsers();
};
const handleAddSignal = (type, value) =>
runMutation((userId) => addSignal(socket, userId, type, value), 'Signal added.');
const handleRemoveSignal = (type, value) =>
@@ -415,6 +421,7 @@ export default function IdentityDatabasePanel() {
<div className="grid min-h-0 flex-1 gap-0.5 lg:grid-cols-[24rem_minmax(0,1fr)]">
<UserListCard
users={users}
truncated={truncated}
selectedUserId={selectedUser?.id || null}
query={query}
filter={filter}
@@ -422,6 +429,7 @@ export default function IdentityDatabasePanel() {
onQuery={setQuery}
onFilter={setFilter}
onRefresh={refreshUsers}
onSearch={submitSearch}
onSelect={selectUser}
/>
<div className="min-h-0 space-y-0.5 overflow-y-auto">
+2 -2
View File
@@ -13,8 +13,8 @@ export function emitIdentityAdmin(socket, eventName, payload = {}) {
});
}
export function listUsers(socket) {
return emitIdentityAdmin(socket, 'identityAdmin:listUsers');
export function listUsers(socket, { query = '', filter = 'all' } = {}) {
return emitIdentityAdmin(socket, 'identityAdmin:listUsers', { query, filter });
}
export function getUser(socket, userId) {