Compare commits

...
Author SHA1 Message Date
legop3 785cc85476 fix interinstance popup thingy
Container image / image (push) Failing after 55s
2026-09-15 21:18:09 -04:00
legop3 b47f1ba2ae less stupid description 2026-09-15 21:00:15 -04:00
legop3 8fb9278109 kick everyone and set update 2026-09-15 20:19:25 -04:00
legop3 609eb6c35e always let through audio forwarding
Container image / image (push) Failing after 1m4s
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) Failing after 2m53s
2026-09-15 01:15:48 -04:00
legop3 5a81ff7716 dockerfile and ghcr action!! 2026-09-15 01:10:51 -04:00
legop3 470e95b0f9 fix inter instance config broken stuffs 2026-09-14 20:52:01 -04:00
legop3 9ce6550f13 fix sticky 2026-09-14 20:30:48 -04:00
legop3 ef9baf6063 some UI tweaking before testing in production lol 2026-09-14 20:12:11 -04:00
legop3 43274e7371 /video signaling proxy and a global public server http path config item 2026-09-14 19:49:37 -04:00
legop3 e7d7f2a270 backup restore slopfix 2026-09-14 19:17:48 -04:00
legop3 1c34849bd0 backup / restoreslop 2026-09-14 19:09:02 -04:00
legop3 edc1b825f2 server restart thingy yay 2026-09-14 18:42:42 -04:00
legop3 c56a8b1000 legacy importer in admin page aswell as setup. 2026-09-14 18:10:45 -04:00
legop3 91e0cc3886 service live configslop 2026-09-14 17:56:53 -04:00
139 changed files with 9312 additions and 1253 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 robots.json
roverd-dummy roverd-dummy
server/config.yaml server/config.yaml
server/package-lock.json
package-lock.json
server/data/discord-guilds.json server/data/discord-guilds.json
server/data/global-objective.json server/data/global-objective.json
server/data/admin-reason.json server/data/admin-reason.json
@@ -22,7 +20,9 @@ server/data/buttonbox-state.json
server/data/barcode-tts-cache/ server/data/barcode-tts-cache/
server/data/rover-odometers.json server/data/rover-odometers.json
server/data/mediamtx.yml 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/
!server/data/barcode-registry.json !server/data/barcode-registry.json
webui/src/config/analytics.jsx 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
+202 -101
View File
@@ -6,10 +6,16 @@ This document is the live implementation tracker for the migration.
- [x] Phase 1, step 1: Establish the single data-directory contract - [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 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
- [ ] Phase 1, steps 6-9: Backup/restore, restart, and internal video proxy - [x] Phase 1, steps 6-8: Restart, backup/restore, and internal video proxy
- [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 - [ ] 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: The work is deliberately split into two phases:
@@ -20,8 +26,15 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log ## 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: 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: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file path retained is an operator-selected YAML upload on `/setup`. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation. - 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.
- 2026-09-14: Keep backup and restore together in one server service after application restart exists. Neither operation stops running services or writers, and no command-line interface is maintained. Backup uses online SQLite snapshots and stable copies of non-database files; restore validates and stages an uploaded archive, records a marker, and uses the normal application restart to replace the data directory during earliest startup.
- 2026-09-14: Define this server's canonical `publicUrl` once at the top of configuration beside `timezone`. Discord links, inter-instance identity, page metadata, and MediaMTX's primary public ICE hostname derive from it. Browser WHEP and WHIP signaling always uses the same-origin `/video` path; `media.additionalHosts` remains only for genuinely additional ICE names or addresses.
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file paths retained are operator-selected YAML uploads on `/setup` and the protected Configuration page. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes. - 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice. - 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries. - 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
@@ -35,13 +48,13 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
- All operator-controlled server configuration is stored in a validated database and managed through the web UI. - All operator-controlled server configuration is stored in a validated database and managed through the web UI.
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory. - All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
- A complete backup can capture that one data directory consistently, and a restore can safely replace it. - A complete backup can capture that one data directory consistently, and a restore can safely replace it.
- `/setup` may initialize the database from a YAML file explicitly selected by the operator; no automatic host migration exists. - `/setup` may initialize the database from a YAML file explicitly selected by the operator, and the protected Configuration page may explicitly replace configuration from one later; no automatic host migration or persistent YAML source exists.
- A dedicated `/admin` application contains all server administration. - 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 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. - 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 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 ## Important boundary: application files versus server data
@@ -132,7 +145,7 @@ Implementation architecture:
- The database validates and commits that complete document as one coherent immutable revision. - The database validates and commits that complete document as one coherent immutable revision.
- The admin UI presents one continuous configuration page in the same top-to-bottom order as the former YAML file. - The admin UI presents one continuous configuration page in the same top-to-bottom order as the former YAML file.
- Nested cards make object relationships readable, but do not create separate categories, navigation destinations, persistence boundaries, or registries. - Nested cards make object relationships readable, but do not create separate categories, navigation destinations, persistence boundaries, or registries.
- Shared editor infrastructure owns loading, dirty state, validation errors, revision conflicts, secret operations, and restart-required status for the whole document. - Shared editor infrastructure owns loading, dirty state, validation errors, revision conflicts, secret operations, and live-application status for the whole document.
- The browser receives this same schema from the protected admin endpoint and renders it with a maintained JSON Schema form library. - The browser receives this same schema from the protected admin endpoint and renders it with a maintained JSON Schema form library.
- Standard JSON Schema types drive ordinary fields, nested objects, enums, and arrays. One field-agnostic widget handles every `writeOnly` secret; there are no feature-specific configuration components in React. - Standard JSON Schema types drive ordinary fields, nested objects, enums, and arrays. One field-agnostic widget handles every `writeOnly` secret; there are no feature-specific configuration components in React.
@@ -186,11 +199,15 @@ Configuration changes use one intentionally simple application rule:
1. Validate the complete proposed document. 1. Validate the complete proposed document.
2. Commit it as a new database revision. 2. Commit it as a new database revision.
3. Report that an application restart is required. 3. Atomically replace the process-wide configuration snapshot.
4. Let the administrator restart immediately or later. 4. Compare the old and new top-level sections.
5. Load one coherent configuration snapshot at the next process start. 5. Reload every service that owns a changed section, replacing its complete internal runtime when necessary.
6. Report per-service application failures without preventing unrelated services from applying the revision.
7. Refresh sessions only after all affected service reloads finish.
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain live actions and do not become restart-required configuration edits. HTTP routes, Socket.IO connection handlers, and process signal handlers are registered once. They consult live state or delegate to the current service runtime, preventing duplicate listeners after repeated saves. Service reloads may reconnect an integration or restart an application-owned child such as MediaMTX, ffmpeg, Kinect, or the Balance Board worker, but never restart the Node application.
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain direct live actions rather than configuration edits.
After migration is complete: After migration is complete:
@@ -199,25 +216,35 @@ After migration is complete:
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process. - Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable. - Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
## 3. Add optional configuration-file upload to setup ## 3. Add explicit configuration-file upload to setup and administration
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. This is not a startup loader, installer migration, command-line workflow, or permanent second source of truth. Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. The protected Configuration page may later replace only the configuration from another explicitly selected legacy file. Neither path is a startup loader, installer migration, command-line workflow, or permanent second source of truth.
The setup upload must: Every upload must:
- Accept only an explicitly selected YAML file from `/setup`. - Accept only an explicitly selected YAML file from `/setup` or the protected Configuration page.
- Require the one-time setup code before processing it.
- Parse the complete document. - Parse the complete document.
- Map every recognized field into the new configuration schema. - Map every recognized field into the new configuration schema.
- Preserve existing bcrypt administrator password hashes.
- Preserve lockdown roles and Discord IDs.
- Preserve secrets without printing them. - Preserve secrets without printing them.
- Apply current defaults for absent fields. - Apply current defaults for absent fields.
- Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields. - Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields.
- Validate the entire result before writing anything. - Validate the entire result before writing anything.
- Record the uploaded filename without storing secret values in the audit event.
The setup upload additionally must:
- Require the one-time setup code before processing it.
- Preserve existing bcrypt administrator password hashes, lockdown roles, and Discord IDs.
- Refuse to replace an already-configured database. - Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically. - Write the configuration, administrators, and audit event atomically.
- Record the uploaded filename without storing secret values in the audit event.
The initialized-server upload additionally must:
- Require a lockdown administrator with recent password confirmation.
- Use optimistic revision checking so it cannot overwrite an intervening edit.
- Ignore the entire legacy `admins` collection and leave all current accounts unchanged.
- Preserve stored secrets omitted from the file, replace supplied secrets, and clear explicitly empty secrets.
- Commit through the normal revision path and immediately reload affected services.
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes. The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
@@ -262,9 +289,26 @@ Authorization rules:
Configuration uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow. Configuration uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow.
## 6. Implement complete backup and restore ## 6. Standardize application restart
Everything durable living under one data directory makes the backup boundary simple, but copying live SQLite files and JSON files without coordination would not guarantee a consistent backup. The implementation must create a consistent snapshot before archiving it. Replace the current host reboot operation with one deployment-neutral **Restart application** operation.
The restart operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Reject a second request while one is already pending.
3. Persist an audit event, acknowledge the requester, and notify connected browsers.
4. Stop accepting new HTTP connections and send SIGTERM to the Node process after a short acknowledgement delay.
5. Reuse the cleanup hooks already owned by MediaMTX, ffmpeg, Kinect, Balance Board, and other child-process services.
6. Exit normally and rely on the process supervisor to start the application again.
During Phase 1, systemd uses `Restart=always`. During Phase 2, the container uses a restart policy such as `unless-stopped`. An explicit operator `systemctl stop` or container stop remains stopped; only a process exit is restarted. The browser shows the announced reconnect state and reloads the active administration snapshot after Socket.IO reconnects.
Host rebooting is a separate privilege and is not part of this application contract. The server never invokes `systemctl reboot`.
## 7. Implement complete backup and restore
Everything durable living under one data directory makes the backup boundary simple. Backup and restore remain together under one `backupRestoreService`; there is no generic maintenance framework and no command-line workflow.
### Full backup ### Full backup
@@ -279,29 +323,25 @@ The primary admin action is **Download full backup**. A full backup includes the
- Generated and cached files that are part of the current server state - Generated and cached files that are part of the current server state
- A manifest describing the application and schema versions - A manifest describing the application and schema versions
The backup service must: The backup operation must:
1. Require a lockdown administrator and recent password confirmation. 1. Require a lockdown administrator and recent password confirmation.
2. Enter a short maintenance/snapshot state that prevents new persistent mutations. 2. Leave every service and writer running.
3. Ask services with buffered state to flush it, stop active audio/replay workers, and clear `runtime/` so FIFOs and incomplete scratch files are never archived. 3. Create consistent SQLite snapshots using SQLite's online backup support rather than copying active WAL files.
4. Create consistent SQLite snapshots using SQLite's supported backup/checkpoint facilities rather than copying active WAL files blindly. 4. Copy non-database durable files and verify their size and modification time before and after each copy, retrying a file that changed during the copy.
5. Copy non-database durable files into temporary staging. 5. Exclude `runtime/`, backup/restore staging, SQLite WAL/SHM files, and incomplete files that never become stable during bounded retries.
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums. 6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
7. Create the archive in temporary storage and stream it to the browser. 7. Stream the completed archive to the authorized browser and remove temporary staging afterward.
8. Remove temporary staging whether the operation succeeds or fails.
9. Resume normal mutations after the consistent snapshot has been captured; archive compression does not need to hold the server in maintenance mode.
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory. The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
`runtime/` is inside the filesystem boundary but is not durable backup content. Excluding it is necessary because an audio FIFO is a live process primitive rather than a regular file, and incomplete uploads or replay builds have no restore value. The backup coordinator must quiesce the owning services before clearing it so exclusion cannot disrupt active work. `runtime/` is inside the filesystem boundary but is not durable backup content. Audio FIFOs, incomplete uploads, and in-progress replay builds have no restore value and are excluded without stopping their owners. The initial implementation provides only the authoritative full backup.
An optional smaller **Download settings and state backup** may exclude explicitly regenerable, high-volume snapshots, replay segments, completed replays, and caches. This is secondary; the full backup remains the authoritative complete-server backup.
### Restore ### Restore
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation. Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
The restore service must: The restore operation must:
1. Require a lockdown administrator and recent password confirmation. 1. Require a lockdown administrator and recent password confirmation.
2. Upload the archive into bounded staging controlled by the data directory. 2. Upload the archive into bounded staging controlled by the data directory.
@@ -312,45 +352,14 @@ The restore service must:
7. Display exactly what will be replaced. 7. Display exactly what will be replaced.
8. Require a final explicit confirmation. 8. Require a final explicit confirmation.
9. Record a pending-restore marker. 9. Record a pending-restore marker.
10. Gracefully stop the application. 10. Request the normal application restart.
11. Apply the restore before ordinary services open their databases on the next start. 11. Apply the restore before ordinary services open their databases on the next start.
12. Run database migrations against the restored data when necessary. 12. Run database migrations against the restored data when necessary.
13. Start the application and verify its health. 13. Start the application and verify its health.
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation. The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally. Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally. Backup and restore exist only in the protected admin application.
### Command-line recovery
Backup and restore must also have command-line entry points that use the same implementation as the admin UI. They are needed when the web server cannot start or authentication data is damaged.
The command-line tools must support:
- Creating a consistent backup while the server is stopped
- Validating a backup without applying it
- Restoring while the server is stopped
- Printing a concise manifest summary
- Refusing unsafe or malformed archives
The UI and command line must not develop separate archive formats or validation behavior.
## 7. Standardize graceful application restart
Replace the current host reboot operation with a deployment-neutral **Restart application** operation.
The restart coordinator must:
1. Authorize and acknowledge the request.
2. Stop accepting new persistent mutations.
3. Flush or close persistent stores.
4. Stop MediaMTX, ffmpeg, and native workers.
5. Close HTTP and socket listeners within a bounded timeout.
6. Exit with the status expected by the current supervisor.
During Phase 1, systemd restarts the process. During Phase 2, the container restart policy or lifecycle service restarts it. The browser should show a reconnect state and confirm the active configuration revision after reconnecting.
Host rebooting is a separate privilege and is not part of this application restart contract.
## 8. Internalize MediaMTX WHEP signaling ## 8. Internalize MediaMTX WHEP signaling
@@ -398,7 +407,7 @@ Phase 1 is complete only when all of the following are true:
- Configuration, administrator accounts, and secrets survive restart. - Configuration, administrator accounts, and secrets survive restart.
- The final lockdown administrator cannot be removed accidentally. - The final lockdown administrator cannot be removed accidentally.
- All administrative surfaces are available through `/admin` with server-side authorization. - All administrative surfaces are available through `/admin` with server-side authorization.
- Configuration changes create auditable revisions and apply after restart. - Configuration changes create auditable revisions and apply to the running services without an application restart.
- `/video` works through Node without a special public proxy rule for MediaMTX. - `/video` works through Node without a special public proxy rule for MediaMTX.
- Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled. - Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled.
@@ -439,6 +448,8 @@ Implemented on 2026-09-14:
- Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them. - Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them.
- Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML. - Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML.
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer. - Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
- Added the same explicit legacy YAML picker to the protected Configuration page for replacing an initialized server's configuration. It requires recent lockdown-password confirmation, ignores every YAML administrator entry, filters nonexistent settings, validates current fields, preserves omitted secrets, applies explicitly supplied or empty secrets, uses optimistic revision checking, records the selected filename in audit history, and reloads affected services immediately.
- The one-time setup upload now passes its committed configuration through the same live-application coordinator, so a fresh installation does not need an immediate restart after importing YAML.
- Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime. - Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime.
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes. - Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order. - Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
@@ -449,20 +460,40 @@ Implemented on 2026-09-14:
- Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content. - Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content.
- Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section. - Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section.
- Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment. - Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment.
- Added an opt-in sticky-header behavior to the shared `CardFrame`. Top-level configuration titles use it beneath the independently sticky action toolbar while nested titles remain in normal flow to prevent overlap, and scalar key labels are now visually stronger than their descriptions.
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect. - Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules. - Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads. - Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator. - Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
- Added configuration revision history, rollback, audit history, and restart-required reporting. Graceful restart itself remains step 7. - Added configuration revision history, rollback, audit history, and immediate application reporting.
- Added a serialized live-configuration coordinator and converted configurable service runtimes to apply changed sections without restarting Node. Passive policies read the current immutable snapshot; network, hardware, timer, and child-process services replace or retune their owned runtime while stable HTTP/socket handlers continue delegating to it. The admin editor reports any service-specific reload failure after the revision is safely committed.
- Replaced the privileged host-reboot action with one lockdown-only, recently confirmed, audited application restart on the admin Overview. Node announces the restart, stops accepting new HTTP connections, and signals itself after acknowledging the browser; the existing service signal hooks clean up owned child processes, and systemd now restarts clean application exits without making `systemctl stop` ineffective.
- Added one protected backup-and-restore service and admin page. Backups keep the application online, use SQLite's online snapshot API for all three databases, make verified stable copies of the remaining durable files, and produce a checksummed archive through a short-lived one-use download. Restore uploads are size-limited, reject unsafe archive entries, verify the complete manifest, checksums, SQLite integrity, and supported schema versions, then remain staged until explicit recent-password confirmation.
- Restore now uses the normal application restart rather than stopping services itself. The earliest server startup swaps the validated replacement into the data directory, retains one rollback copy, and removes that copy only after the restored application reaches a stabilization point; an interrupted or failed first startup automatically puts the previous data back on the following start. Backup/restore control files and all staging remain inside `data/backup-restore`.
- 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: Local verification completed:
- All 107 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, explicit setup-file import with recursive removal of nonexistent fields, and the earlier filesystem coverage. - All 119 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, setup and initialized-server YAML import safety, recursive removal of nonexistent fields, inter-instance payload generation with social links enabled, and the earlier filesystem coverage.
- All 27 server test files passed after live application, backup/restore, the internal media proxy, and the inter-instance regression coverage were added. The media tests stream exact SDP and trickle-ICE bodies through `POST`, `PATCH`, and `DELETE`, preserve headers, verify prefix and session-location rewriting, confirm loopback-only signaling, and derive the public ICE hostname from the canonical URL. The database migration and production-style WAL backup/restore paths are also covered. Application restart was not signaled on the development machine.
- Focused admin, route, and identity UI lint passed. - Focused admin, route, and identity UI lint passed.
- All 20 existing focused web UI tests passed. - All 20 existing focused web UI tests passed.
- The production web UI build completed successfully and regenerated the checked-in server assets. - The production web UI build completed successfully and regenerated the checked-in server assets.
- Installer syntax and repository whitespace checks passed. - Installer syntax and repository whitespace checks passed.
- A local startup smoke test reached listener initialization. MediaMTX then exited because `/usr/local/bin/mediamtx` is intentionally absent on this development machine; actual enabled integrations and media remain deployment checks for the real server. - A local startup smoke test reached listener initialization. MediaMTX then exited because `/usr/local/bin/mediamtx` is intentionally absent on this development machine; actual enabled integrations and media remain deployment checks for the real server.
- A second empty-data startup smoke test loaded every reloadable service and reached the HTTP listener without listener-limit warnings. A deliberately substituted failing MediaMTX executable then ended the process as expected; enabled hardware and external integrations still require verification on the actual server.
Testing-server verification completed:
- A full backup created from the running application successfully validated and restored through the admin UI after the WAL-sidecar fix.
- WHEP video playback works when the testing server is published through an ordinary whole-application reverse proxy. No special `/video` upstream, prefix rewrite, buffering rule, or direct public MediaMTX signaling route is present, confirming that Node now owns the complete public signaling path.
# Phase 2: containerization and image delivery # Phase 2: containerization and image delivery
@@ -488,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 Kinect worker against libfreenect/libusb.
- Build the Balance Board worker against wiiuse/BlueZ. - 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 ### Packaged runtime tools
@@ -512,19 +543,39 @@ The final image should:
- Use a minimal init process to reap child processes. - Use a minimal init process to reap child processes.
- Treat `/data` as its only persistent writable location. - Treat `/data` as its only persistent writable location.
- Use `/tmp` only for disposable work. - 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. - 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 ## 11. Compose deployment
The host-visible installation should be only: The host-visible project installation should be only:
```text ```text
multirover/ multirover/
── compose.yaml ── compose.yaml
└── data/
``` ```
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 Compose project contains:
- The main Multirover application container - The main Multirover application container
@@ -533,7 +584,7 @@ The Compose project contains:
The application mounts: The application mounts:
```text ```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. 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.
@@ -544,7 +595,17 @@ Expected externally relevant listeners are:
- Rover RTSP publishing on TCP 8554 - Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189 - 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 ## 12. Hardware access with minimal host setup
@@ -565,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. 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. The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
## 13. Health checks ## 13. Health checks
@@ -581,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. 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 ## 14. Restricted lifecycle container
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access. The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
@@ -592,15 +670,15 @@ A small lifecycle container should be the only component with Docker control. It
- Operate only on the fixed Multirover application service. - Operate only on the fixed Multirover application service.
- Reject arbitrary command lines, service names, image names, and Compose arguments. - Reject arbitrary command lines, service names, image names, and Compose arguments.
- Persist update job state so it survives replacement of the application container. - Persist update job state so it survives replacement of the application container.
- Report current version and image digest. - Compare the running image's internal digest with the current `latest` digest.
- Pull the configured release image. - Pull the fixed `ghcr.io/legop3/multiroombarover:latest` image.
- Restart or recreate the application container. - Restart or recreate the application container.
- Wait for the application health check. - Wait for the application health check.
- Retain and restore the previous image when the replacement fails. - Retain and restore the previous image when the replacement fails.
The `/admin` System section should expose: 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 - Check for update
- Update and restart - Update and restart
- Restart application - Restart application
@@ -610,21 +688,43 @@ 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. 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.
- Accepted self-updates, administrator restarts, and backup-restore restarts share a helper that sets the persistent admin reason to "server is restarting" and removes every current rover driver with the same notice. This does not change server mode or automatically clear the reason after startup.
- 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: Add repository automation that:
- Builds the production image from a clean checkout. - On pull requests, runs all required verification and proves that the production image builds without publishing it.
- Runs server tests, focused web UI tests/lint, and the production web build before publishing. - On each repository branch push, builds the production image from a clean checkout.
- Builds each explicitly supported server architecture. - 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.
- Publishes immutable commit/release tags to GHCR. - Builds the supported `linux/amd64` image without QEMU or a multi-architecture manifest.
- Publishes one documented stable channel used by the lifecycle updater. - 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.
- Records image digests and source revision metadata. - Leaves the previously published image for that branch untouched when any required verification or build step fails.
- Avoids publishing when required verification 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 ## 16. Container cutover
@@ -633,7 +733,7 @@ The actual deployment migration should:
1. Download and validate a full Phase 1 backup. 1. Download and validate a full Phase 1 backup.
2. Stop and disable the legacy Multirover systemd service. 2. Stop and disable the legacy Multirover systemd service.
3. Ensure no legacy MediaMTX service remains active. 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. 5. Start the application and lifecycle containers.
6. Confirm that database migrations complete. 6. Confirm that database migrations complete.
7. Confirm the active configuration revision and administrator access. 7. Confirm the active configuration revision and administrator access.
@@ -648,16 +748,16 @@ The old systemd application and the Compose application must never run concurren
Containerization is complete when: 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. - 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. - Replacing the application container preserves all state.
- The special external `/video` MediaMTX route is unnecessary. - The special external `/video` MediaMTX route is unnecessary.
- The main container has no Docker socket access and is not fully privileged. - The main container has no Docker socket access and is not fully privileged.
- Admin-triggered restart works. - Admin-triggered restart works.
- Admin-triggered update works and persists progress across reconnection. - Admin-triggered update works and persists progress across reconnection.
- A failed image health check rolls back to the prior image. - 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. - 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. - Node, npm, application source, and media binaries are no longer installed directly on the host.
@@ -672,14 +772,15 @@ Within the two hard phase boundaries, the safest order is:
- [x] Converge optional feature control into service-owned `enabled` switches and derive the public feature map from those definitions. - [x] Converge optional feature control into service-owned `enabled` switches and derive the public feature map from those definitions.
- [x] Build the centralized admin configuration UI. - [x] Build the centralized admin configuration UI.
- [x] Add persistent audit history. - [x] Add persistent audit history.
- [ ] Implement coordinated backup and staged restore. - [x] Apply every configuration revision to running services without restarting the application.
- [ ] Standardize graceful application restart. - [x] Standardize graceful application restart.
- [ ] Add the internal `/video` proxy and remove the special external route. - [x] Implement online backup and restart-bound staged restore in one service.
- [ ] Run the full Phase 1 completion gate on the legacy deployment. - [x] Add the internal `/video` proxy and make the special external route unnecessary.
- [ ] Build and verify the production application image. - [x] Run the full Phase 1 completion gate on the legacy deployment.
- [ ] Add Compose, data mounting, networking, and hardware access. - [x] Build and verify the production application image.
- [ ] Add GHCR build and publication automation. - [x] Add Compose, data mounting, networking, and hardware access.
- [ ] Add the restricted lifecycle container and connect the System UI. - [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. - [ ] Test update, rollback, backup restore, and hardware on the actual server.
- [ ] Perform the final systemd-to-Compose cutover. - [ ] 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"
}
}
}
}
+8
View File
@@ -1,3 +1,10 @@
const backupRestoreService = require('./src/services/backupRestoreService');
// A staged restore must replace data before configuration, identity, or report
// services open SQLite. Requiring the service here is safe because its runtime
// HTTP/socket dependencies remain lazy until register() is called below.
backupRestoreService.applyPendingRestore();
require('./src/globals/logger'); require('./src/globals/logger');
require('./src/globals/config'); require('./src/globals/config');
require('./src/globals/http'); require('./src/globals/http');
@@ -66,4 +73,5 @@ require('./src/services/replayEngineV2');
// Discord feature so web requests always have a local delivery path. // Discord feature so web requests always have a local delivery path.
require('./src/services/replayDeliveryService'); require('./src/services/replayDeliveryService');
require('./src/services/discordBotService'); require('./src/services/discordBotService');
backupRestoreService.register();
require('./src/services/httpServer'); require('./src/services/httpServer');
+4 -1
View File
@@ -305,7 +305,10 @@ Environment=NODE_ENV=production
Environment=SERVER_DATA_DIR=$DATA_DIR Environment=SERVER_DATA_DIR=$DATA_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure # Application-requested restarts use the same clean SIGTERM path as an
# operator stop. Restart=always lets that process exit come back automatically,
# while an explicit `systemctl stop` still remains stopped by systemd design.
Restart=always
RestartSec=2 RestartSec=2
SuccessExitStatus=130 143 SuccessExitStatus=130 143
+2 -1
View File
@@ -40,7 +40,8 @@ case "$PATH_NAME" in
esac esac
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \ 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 \ -an \
-vf "$FILTER" \ -vf "$FILTER" \
-q:v "$QUALITY" \ -q:v "$QUALITY" \
+4400
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,9 +14,11 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dockerode": "^5.0.1",
"express": "^4.19.2", "express": "^4.19.2",
"fuse.js": "^7.4.2", "fuse.js": "^7.4.2",
"home-assistant-js-websocket": "^3.1.2", "home-assistant-js-websocket": "^3.1.2",
"http-proxy-middleware": "^3.0.7",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"kokoro-js": "^1.2.1", "kokoro-js": "^1.2.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
@@ -27,6 +29,7 @@
"reolink-nvr-api": "^0.3.0", "reolink-nvr-api": "^0.3.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"tar": "^7.5.22",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
@@ -1 +0,0 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card-header{top:var(--configuration-sticky-top, 0px)}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key-label{font-size:1rem;line-height:1.5rem;font-weight:600;line-height:1.375;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-CqMkCfQw.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default}; import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-n0JxE1Mv.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-C2cP8ApY.js.map //# sourceMappingURL=SetupApp-CaWv689i.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function o(t,i,a={}){return new Promise((n,s)=>{t.emit(i,a,(e={})=>{if(e?.error){const r=new Error(e.error);r.code=e.code||null,r.validationErrors=e.validationErrors||[],r.currentRevision=e.currentRevision||null,s(r);return}n(e)})})}const c=t=>o(t,"adminConfig:get"),u=(t,i)=>o(t,"adminConfig:confirmPassword",{password:i}),d=(t,i)=>o(t,"adminConfig:updateConfiguration",i),p=(t,i)=>o(t,"adminConfig:importConfigurationFile",i),m=(t,i)=>o(t,"adminConfig:restoreRevision",i),l=(t,i)=>o(t,"adminConfig:createAdministrator",i),f=(t,i)=>o(t,"adminConfig:updateAdministrator",i),g=(t,i)=>o(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>o(t,"server:restartApplication"),C=t=>o(t,"server:lifecycleStatus"),R=t=>o(t,"server:checkForUpdate"),k=t=>o(t,"server:updateApplication"),v=t=>o(t,"backupRestore:status"),F=t=>o(t,"backupRestore:createBackup"),S=t=>o(t,"backupRestore:createRestoreUpload"),b=(t,i)=>o(t,"backupRestore:confirmRestore",{restoreId:i}),h=t=>o(t,"setup:status"),w=(t,i)=>o(t,"setup:createAdministrator",i),U=(t,i)=>o(t,"setup:importConfigurationFile",i);export{A as a,R as b,l as c,g as d,k as e,v as f,C as g,F as h,S as i,b as j,d as k,p as l,c as m,u as n,h as o,w as p,U as q,m as r,f as u};
//# sourceMappingURL=api-B8GGCEeo.js.map
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
//# sourceMappingURL=api-C0PIt_OP.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"api-C0PIt_OP.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject --> <!-- site-metadata:inject -->
<!-- analytics:inject --> <!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-CqMkCfQw.js"></script> <script type="module" crossorigin src="/assets/index-n0JxE1Mv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1O6avznD.css"> <link rel="stylesheet" crossorigin href="/assets/index-xCRVLGjq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+160 -7
View File
@@ -6,11 +6,18 @@ const assert = require('node:assert/strict');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { execFileSync } = require('child_process');
const Database = require('better-sqlite3');
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation'); const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition'); const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
const { migrations } = require('./migrations');
const { getFeatureFlags } = require('./index'); const { getFeatureFlags } = require('./index');
const { createConfigurationDatabase } = require('./database'); const { createConfigurationDatabase } = require('./database');
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter'); const {
parseConfigurationFile,
buildSecretOperationsForImport,
importConfigurationFile,
} = require('./configurationFileImporter');
const temporaryRoots = []; const temporaryRoots = [];
@@ -210,14 +217,12 @@ test('generated feature flags use only each declared enabled switch', () => {
}); });
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => { test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } }); const normalized = normalizeConfig({ media: { additionalHosts: [] } });
// Missing fields now receive the same populated template defaults as a new assert.equal(normalized.publicUrl, 'https://rover.example.com');
// installation; normalization must not silently revert this one collection assert.deepEqual(normalized.media.additionalHosts, []);
// to the former empty-safe-default policy.
assert.deepEqual(normalized.media.additionalHosts, ['rover.example.com', 'media-server.local']);
assert.doesNotThrow(() => assertValidConfig(normalized)); assert.doesNotThrow(() => assertValidConfig(normalized));
const invalid = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video', misspelledHost: 'x' } }); const invalid = normalizeConfig({ media: { additionalHosts: [], misspelledHost: 'x' } });
assert.throws(() => assertValidConfig(invalid), (error) => { assert.throws(() => assertValidConfig(invalid), (error) => {
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED'); assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost'))); assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
@@ -225,6 +230,40 @@ test('normalization fills missing legacy fields but strict validation rejects un
}); });
}); });
test('database migration consolidates existing public URLs and removes obsolete media addressing', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-public-url-migration-'));
temporaryRoots.push(root);
const databasePath = path.join(root, 'configuration.sqlite');
const legacyDatabase = new Database(databasePath);
legacyDatabase.exec(`
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
${migrations[0].sql}
`);
legacyDatabase.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(Date.now());
const legacyConfig = structuredClone(defaultConfig);
delete legacyConfig.publicUrl;
legacyConfig.interInstance.profile.publicUrl = 'https://rover.example.com';
legacyConfig.discord.enabled = true;
legacyConfig.discord.siteUrl = 'https://canonical.example.com';
legacyConfig.media.whepBaseUrl = 'http://127.0.0.1:8889/video';
const inserted = legacyDatabase.prepare(`
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
VALUES (?, ?, 'test', 'legacy-shape')
`).run(JSON.stringify(legacyConfig), Date.now());
legacyDatabase.prepare('INSERT INTO configuration_state (singleton, active_revision_id) VALUES (1, ?)')
.run(inserted.lastInsertRowid);
legacyDatabase.close();
const migrated = createConfigurationDatabase({ databasePath });
const active = migrated.getActiveConfigurationRecord().config;
assert.equal(active.publicUrl, 'https://canonical.example.com');
assert.equal(Object.hasOwn(active.interInstance.profile, 'publicUrl'), false);
assert.equal(Object.hasOwn(active.discord, 'siteUrl'), false);
assert.equal(Object.hasOwn(active.media, 'whepBaseUrl'), false);
migrated.close();
});
test('full-document updates preserve secrets and reject a stale browser revision', () => { test('full-document updates preserve secrets and reject a stale browser revision', () => {
const database = createTestDatabase(); const database = createTestDatabase();
const initial = database.getActiveConfigurationRecord(); const initial = database.getActiveConfigurationRecord();
@@ -286,6 +325,7 @@ admins:
password_hash: "$2b$10$preservedHash" password_hash: "$2b$10$preservedHash"
discord_id: "1234" discord_id: "1234"
lockdown: true lockdown: true
publicUrl: https://production.example.com
timezone: America/Chicago timezone: America/Chicago
media: media:
whepBaseUrl: http://localhost:8889/video whepBaseUrl: http://localhost:8889/video
@@ -315,6 +355,7 @@ fleetReports:
immediateCriticalAlerts: true immediateCriticalAlerts: true
`; `;
const parsed = parseConfigurationFile(yamlText); const parsed = parseConfigurationFile(yamlText);
assert.equal(parsed.config.publicUrl, 'https://production.example.com');
assert.equal(parsed.config.timezone, 'America/Chicago'); assert.equal(parsed.config.timezone, 'America/Chicago');
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash'); assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false); assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
@@ -353,3 +394,115 @@ bandwidthSavings:
return true; return true;
}); });
}); });
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
const database = createTestDatabase();
const initial = database.getClientConfiguration();
const seededRevision = database.updateConfiguration({
value: initial.config,
expectedRevision: initial.revision,
actor: 'secret-seed',
secretOperations: {
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
'discord.token': { action: 'replace', value: 'replace-this-token' },
},
});
const yamlText = `
admins:
- this obsolete account entry is deliberately malformed
timezone: America/Chicago
ptzCamera:
password:
discord:
token: new-discord-token
`;
/*
An initialized installation treats the YAML as configuration data only.
Even malformed account data is ignored, while presence-aware secret
operations preserve an omitted credential, clear an explicit empty value,
and replace an explicit non-empty value.
*/
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
assert.deepEqual(parsed.administrators, []);
assert.equal(parsed.uploadedAdministratorCount, 1);
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
const revision = database.updateConfiguration({
value: parsed.config,
expectedRevision: seededRevision,
secretOperations: buildSecretOperationsForImport(parsed),
actor: 'admin-import-test',
source: 'admin-yaml:production.yaml',
});
const active = database.getActiveConfigurationRecord();
assert.equal(active.revision, revision);
assert.equal(active.source, 'admin-yaml:production.yaml');
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
assert.equal(active.config.ptzCamera.password, '');
assert.equal(active.config.discord.token, 'new-discord-token');
assert.equal(active.config.timezone, 'America/Chicago');
assert.equal(database.listAdministrators().length, 0);
database.close();
});
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
temporaryRoots.push(root);
const serverRoot = path.resolve(__dirname, '../..');
const script = `
const configuration = require('./src/configuration');
const applied = [];
configuration.registerConfigurationHandler('timezone', (next, previous) => {
applied.push({ section: 'timezone', next, previous });
});
configuration.registerConfigurationHandler('media', () => {
throw new Error('simulated media reload failure');
});
const database = configuration.getConfigurationDatabase();
const record = database.getClientConfiguration();
const next = structuredClone(record.config);
next.timezone = 'America/Chicago';
next.media.additionalHosts = ['media.example.test'];
database.updateConfiguration({
value: next,
expectedRevision: record.revision,
actor: 'live-configuration-test',
});
configuration.applyCommittedConfiguration().then((application) => {
console.log(JSON.stringify({
application,
applied,
liveTimezone: configuration.loadConfig().timezone,
liveRevision: configuration.getRuntimeConfigurationRevision(),
}));
database.close();
});
`;
const output = execFileSync(process.execPath, ['-e', script], {
cwd: serverRoot,
env: { ...process.env, SERVER_DATA_DIR: root },
encoding: 'utf8',
});
const result = JSON.parse(output.trim());
/*
A failing integration remains visible in application status but cannot
roll back the valid revision or prevent an unrelated service from seeing
it. This is the central guarantee that makes live application usable on a
server where optional hardware may be offline during an ordinary edit.
*/
assert.equal(result.liveTimezone, 'America/Chicago');
assert.equal(result.liveRevision, result.application.revision);
assert.deepEqual(result.application.changedSections, ['timezone', 'media']);
assert.deepEqual(result.applied, [{
section: 'timezone',
next: 'America/Chicago',
previous: defaultConfig.timezone,
}]);
assert.deepEqual(result.application.services, [
{ section: 'timezone', status: 'applied' },
{ section: 'media', status: 'failed', error: 'simulated media reload failure' },
]);
});
@@ -1,8 +1,44 @@
// Configuration File Importer // Configuration File Importer
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database. // Purpose: Validates a deliberately uploaded legacy YAML file for first-run setup or an explicit administrative replacement.
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files. // Scope: Startup and installation never search for or consume configuration files; every import begins with a browser-selected file.
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation'); const {
rootSchema,
secretPaths,
normalizeConfig,
assertValidConfig,
} = require('./validation');
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
function getAtPath(object, dottedPath) {
return String(dottedPath || '').split('.').filter(Boolean)
.reduce((value, key) => value?.[key], object);
}
function setAtPath(object, dottedPath, value) {
const parts = String(dottedPath || '').split('.').filter(Boolean);
let cursor = object;
parts.slice(0, -1).forEach((key) => {
cursor = cursor[key];
});
cursor[parts.at(-1)] = value;
}
function hasAtPath(object, dottedPath) {
/*
Presence, rather than truthiness, distinguishes an omitted legacy secret
from an explicitly empty one. An omitted credential must preserve the
running installation's value, while an empty YAML value deliberately
clears it through the same operation used by the schema form.
*/
let cursor = object;
for (const key of String(dottedPath || '').split('.').filter(Boolean)) {
if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key)) return false;
cursor = cursor[key];
}
return true;
}
function keepCurrentSchemaFields(value, schema) { function keepCurrentSchemaFields(value, schema) {
/* /*
@@ -46,28 +82,65 @@ function normalizeUploadedAdministrator(entry, index) {
}; };
} }
function parseConfigurationFile(text) { function parseConfigurationFile(text, { includeAdministrators = true } = {}) {
const parsed = yaml.load(String(text || '')); const parsed = yaml.load(String(text || ''));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('The configuration file must contain a YAML object.'); throw new Error('The configuration file must contain a YAML object.');
} }
const administrators = Array.isArray(parsed.admins) const uploadedAdministrators = Array.isArray(parsed.admins) ? parsed.admins : [];
? parsed.admins.map(normalizeUploadedAdministrator) /*
First-run setup is the sole workflow allowed to create accounts from the
legacy file. An initialized server ignores the entire admins collection,
including obsolete or malformed entries, so importing configuration can
never rename accounts, replace password hashes, or remove the last
lockdown administrator.
*/
const administrators = includeAdministrators
? uploadedAdministrators.map(normalizeUploadedAdministrator)
: []; : [];
const configInput = Object.fromEntries( const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'), Object.entries(parsed).filter(([key]) => key !== 'admins'),
); );
secretPaths.forEach((secretPath) => {
/*
YAML commonly represents `token:` as null even though the application
models an unconfigured credential as an empty string. Translate null only
for known secret fields so a plainly empty legacy credential has the same
clear meaning as the admin form; null in any ordinary current field still
fails its schema normally.
*/
if (hasAtPath(configInput, secretPath) && getAtPath(configInput, secretPath) === null) {
setAtPath(configInput, secretPath, '');
}
});
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema)); const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
const providedSecretPaths = secretPaths.filter((secretPath) => hasAtPath(configInput, secretPath));
// Filtering applies only to nonexistent keys. Values retained for current // Filtering applies only to nonexistent keys. Values retained for current
// schema fields still have to satisfy every type, range, and format rule // schema fields still have to satisfy every type, range, and format rule
// before the importer can atomically initialize the database. // before the importer can atomically initialize the database.
assertValidConfig(config); assertValidConfig(config);
if (!administrators.some((admin) => admin.role === 'lockdown')) { return {
throw new Error('The configuration file must contain at least one lockdown administrator.'); config,
} administrators,
return { config, administrators }; uploadedAdministratorCount: uploadedAdministrators.length,
providedSecretPaths,
};
}
function buildSecretOperationsForImport({ config, providedSecretPaths = [] }) {
return Object.fromEntries(providedSecretPaths.map((secretPath) => {
const value = getAtPath(config, secretPath);
/*
Current secret schemas are strings. Keeping this conversion beside the
importer produces the database's narrow replace/clear contract and
avoids granting the import route a way around ordinary secret handling.
*/
return value === ''
? [secretPath, { action: 'clear' }]
: [secretPath, { action: 'replace', value }];
}));
} }
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) { function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
@@ -85,6 +158,8 @@ function importConfigurationFile({ text, database, actor = 'setup-file-upload',
} }
module.exports = { module.exports = {
MAX_CONFIGURATION_FILE_BYTES,
parseConfigurationFile, parseConfigurationFile,
buildSecretOperationsForImport,
importConfigurationFile, importConfigurationFile,
}; };
+34 -2
View File
@@ -161,7 +161,13 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
return { ...record, ...redacted }; return { ...record, ...redacted };
} }
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) { function updateConfiguration({
value,
expectedRevision,
secretOperations = {},
actor,
source = 'admin-ui',
}) {
const active = getActiveConfigurationRecord(); const active = getActiveConfigurationRecord();
const candidate = clone(value); const candidate = clone(value);
@@ -187,7 +193,12 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
return commitRevisionTransaction(candidate, { return commitRevisionTransaction(candidate, {
expectedRevision, expectedRevision,
actor, actor,
source: 'admin-ui', /*
Administrative imports use this same safe update path but identify the
selected filename in revision and audit history. The source remains
server-controlled metadata and never contains configuration values.
*/
source,
}); });
} }
@@ -331,6 +342,16 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
})); }));
} }
function recordAuditEvent(actor, action, details = {}) {
/*
Operational admin services need the same persistent audit trail as
configuration changes, but they must not gain access to the underlying
statement or database handle. This narrow method retains the existing
redacted-details contract at the database boundary.
*/
writeAudit(actor, action, details);
}
const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => { const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => {
// A setup upload initializes an empty installation; it is deliberately not // A setup upload initializes an empty installation; it is deliberately not
// a general-purpose replacement path for a running server's configuration. // a general-purpose replacement path for a running server's configuration.
@@ -351,6 +372,15 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
return importConfigurationFileTransaction(payload); return importConfigurationFileTransaction(payload);
} }
function backupDatabase(destinationPath) {
/*
SQLite's online backup API produces one coherent database file while the
live WAL-backed connection remains open. The backup service receives only
this narrow operation, never the private database handle.
*/
return db.backup(destinationPath);
}
return { return {
databasePath, databasePath,
getActiveConfigurationRecord, getActiveConfigurationRecord,
@@ -366,7 +396,9 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
countLockdownAdministrators, countLockdownAdministrators,
isSetupComplete, isSetupComplete,
listAuditEvents, listAuditEvents,
recordAuditEvent,
importConfigurationFile, importConfigurationFile,
backupDatabase,
close: () => db.close(), close: () => db.close(),
}; };
} }
+2 -1
View File
@@ -30,6 +30,7 @@ const fleetReports = require('../services/fleetReportService/configuration');
those fragments are placed independently at their historical positions. those fragments are placed independently at their historical positions.
*/ */
const definitions = [ const definitions = [
sessionConfiguration.publicUrl,
sessionConfiguration.timezone, sessionConfiguration.timezone,
interInstance, interInstance,
llmCommentary, llmCommentary,
@@ -61,7 +62,7 @@ const properties = Object.fromEntries(
); );
const rootSchema = strictObject(properties, { const rootSchema = strictObject(properties, {
title: 'Configuration', title: 'Configuration',
description: 'Complete server configuration. Changes are validated and saved as one revision, then loaded when the application restarts.', description: 'Complete server configuration. Changes are validated, saved as one revision, and applied live by reloading affected services.',
required: definitions.map(({ key }) => key), required: definitions.map(({ key }) => key),
}); });
+97 -19
View File
@@ -1,22 +1,34 @@
// Configuration Service // Configuration Service
// Purpose: Exposes the process-wide synchronous configuration snapshot and the underlying administration store. // Purpose: Exposes the process-wide live configuration snapshot, service reload registry, and administration store.
// Scope: Keeps existing require-time startup semantics while making SQLite the only runtime configuration source. // Scope: Makes SQLite the durable source while applying each committed revision coherently to the running process.
const EventEmitter = require('events');
const { isDeepStrictEqual } = require('util');
const { createConfigurationDatabase } = require('./database'); const { createConfigurationDatabase } = require('./database');
const { rootSchema, featureDefinitions } = require('./definition'); const { definitions, rootSchema, featureDefinitions } = require('./definition');
let singleton; let singleton;
let runtimeConfiguration;
let runtimeConfigurationRevision = null; let runtimeConfigurationRevision = null;
let applicationQueue = Promise.resolve();
let lastApplication = null;
const reloadHandlers = new Map();
const configurationEvents = new EventEmitter();
function getConfigurationDatabase() { function getConfigurationDatabase() {
if (!singleton) { if (!singleton) {
singleton = createConfigurationDatabase(); singleton = createConfigurationDatabase();
/* // Durable state is read once at startup and then replaced atomically after
Capture the active revision once when the process opens its configuration // each committed save or rollback. Every caller therefore sees one complete
store. Later admin saves are intentionally restart-bound, so comparing // revision rather than independently rereading SQLite mid-application.
against this value gives every reconnecting browser an authoritative const active = singleton.getActiveConfigurationRecord();
pending-restart indicator. runtimeConfiguration = Object.freeze(active.config);
*/ runtimeConfigurationRevision = active.revision;
runtimeConfigurationRevision = singleton.getActiveConfigurationRecord().revision; lastApplication = {
revision: active.revision,
changedSections: [],
services: [],
appliedAt: Date.now(),
};
} }
return singleton; return singleton;
} }
@@ -27,16 +39,78 @@ function getRuntimeConfigurationRevision() {
} }
function loadConfig() { function loadConfig() {
/* getConfigurationDatabase();
Services intentionally receive one coherent snapshot for this process. return runtimeConfiguration;
Configuration commits are restart-bound, so re-reading during runtime would }
let only some modules observe the new revision and create a split-brain
process. The database remains queryable through its administrative API. function registerConfigurationHandler(section, handler) {
*/ if (!rootSchema.properties?.[section]) {
if (!loadConfig.cached) { throw new Error(`Cannot register configuration handler for unknown section ${section}.`);
loadConfig.cached = Object.freeze(getConfigurationDatabase().getActiveConfigurationRecord().config);
} }
return loadConfig.cached; if (typeof handler !== 'function') {
throw new Error(`Configuration handler for ${section} must be a function.`);
}
const handlers = reloadHandlers.get(section) || new Set();
handlers.add(handler);
reloadHandlers.set(section, handlers);
return () => handlers.delete(handler);
}
async function applyCommittedConfiguration() {
/*
Saves are serialized even though SQLite commits synchronously. A service
reload may need to close a worker or network client asynchronously, and a
later revision must never overtake that cleanup and start a second runtime.
*/
const apply = async () => {
const active = getConfigurationDatabase().getActiveConfigurationRecord();
const previous = loadConfig();
const next = Object.freeze(active.config);
const changedSections = definitions
.map(({ key }) => key)
.filter((key) => !isDeepStrictEqual(previous[key], next[key]));
// Swap the complete document before invoking handlers. Any service helper
// consulted during a reload consequently observes the same new revision.
runtimeConfiguration = next;
runtimeConfigurationRevision = active.revision;
const services = [];
for (const section of changedSections) {
for (const handler of reloadHandlers.get(section) || []) {
try {
// Sequential application preserves the server's existing dependency
// order, notably Home Assistant before its Neato and lift consumers.
await handler(next[section], previous[section], next, previous);
services.push({ section, status: 'applied' });
} catch (error) {
// One unavailable integration must not prevent unrelated services or
// the session feature map from receiving the committed revision.
services.push({ section, status: 'failed', error: error.message });
}
}
}
lastApplication = {
revision: active.revision,
changedSections,
services,
appliedAt: Date.now(),
};
configurationEvents.emit('applied', lastApplication);
return lastApplication;
};
const queued = applicationQueue.then(apply, apply);
// Retain a fulfilled tail even if an unexpected coordinator error escapes;
// otherwise one failure would permanently poison every later save.
applicationQueue = queued.catch(() => undefined);
return queued;
}
function getLastConfigurationApplication() {
getConfigurationDatabase();
return lastApplication;
} }
function getValueAtPath(value, path) { function getValueAtPath(value, path) {
@@ -62,6 +136,10 @@ function isFeatureEnabled(featureName) {
module.exports = { module.exports = {
getConfigurationDatabase, getConfigurationDatabase,
getRuntimeConfigurationRevision, getRuntimeConfigurationRevision,
getLastConfigurationApplication,
registerConfigurationHandler,
applyCommittedConfiguration,
configurationEvents,
loadConfig, loadConfig,
getFeatureFlags, getFeatureFlags,
isFeatureEnabled, isFeatureEnabled,
+37 -1
View File
@@ -37,6 +37,41 @@ const migrations = [
); );
`, `,
}, },
{
version: 2,
run(db) {
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
rows.forEach((row) => {
const config = JSON.parse(row.config_json);
/*
publicUrl was formerly repeated under inter-instance, Discord, and
media settings. Preserve the public identity already selected by the
operator: enabled consumers win first, then non-example values, with
inter-instance winning an otherwise equal conflict. Remove only the
three fields replaced by the root setting and fixed /video proxy.
*/
const previousInterInstanceUrl = config.interInstance?.profile?.publicUrl;
const previousDiscordUrl = config.discord?.siteUrl;
const previousCandidates = [
config.interInstance?.enabled ? previousInterInstanceUrl : '',
config.discord?.enabled ? previousDiscordUrl : '',
previousInterInstanceUrl !== 'https://rover.example.com' ? previousInterInstanceUrl : '',
previousDiscordUrl !== 'https://rover.example.com' ? previousDiscordUrl : '',
previousInterInstanceUrl,
previousDiscordUrl,
];
config.publicUrl = config.publicUrl
|| previousCandidates.find((value) => typeof value === 'string' && value.trim())
|| 'https://rover.example.com';
if (config.interInstance?.profile) delete config.interInstance.profile.publicUrl;
if (config.discord) delete config.discord.siteUrl;
if (config.media) delete config.media.whepBaseUrl;
update.run(JSON.stringify(config), row.id);
});
},
},
]; ];
function applySchemaMigrations(db) { function applySchemaMigrations(db) {
@@ -57,7 +92,8 @@ function applySchemaMigrations(db) {
changed database whose version incorrectly appears current. changed database whose version incorrectly appears current.
*/ */
db.transaction(() => { db.transaction(() => {
db.exec(migration.sql); if (migration.sql) db.exec(migration.sql);
if (migration.run) migration.run(db);
record.run(migration.version, Date.now()); record.run(migration.version, Date.now());
})(); })();
}); });
+22
View File
@@ -4,10 +4,32 @@ const http = require('http');
const express = require('express'); const express = require('express');
const morgan = require('morgan'); const morgan = require('morgan');
const config = require('./config'); const config = require('./config');
const logger = require('./logger').child('mediaMtxProxy');
const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy } = require('../services/mediaMtxService/proxy');
const app = express(); const app = express();
app.use(morgan('dev')); app.use(morgan('dev'));
/*
Mount signaling before body parsers so SDP offers and trickle-ICE fragments
remain untouched streams. Express removes the /video mount prefix while the
proxy is active, giving MediaMTX its native /<path>/whep or /<path>/whip URL.
*/
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger }));
app.use(express.json()); 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 })); app.use(express.static(config.staticDir, { index: false }));
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
+8 -3
View File
@@ -13,8 +13,13 @@ const io = new SocketIOServer(httpServer, {
maxHttpBufferSize: 16 * 1024 * 1024, maxHttpBufferSize: 16 * 1024 * 1024,
}); });
// Allow more service listeners without warnings. /*
io.sockets.setMaxListeners(30); Optional feature gateways now remain registered while disabled so an admin
io.of('/').setMaxListeners(30); can enable them live without adding a second listener tree. Forty is a small
explicit allowance for those one-time service owners, not an unlimited value
that could hide duplicate registrations during repeated configuration saves.
*/
io.sockets.setMaxListeners(40);
io.of('/').setMaxListeners(40);
module.exports = io; module.exports = io;
+3 -3
View File
@@ -83,9 +83,9 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
function getBandwidthSavingsPolicy() { function getBandwidthSavingsPolicy() {
/* /*
loadConfig() is cached by configuration service, so rebuilding this small object per The configuration service returns an in-memory snapshot, so rebuilding this
caller is cheap while still letting tests pass explicit config objects into small normalized object per caller is cheap and immediately follows a newly
buildBandwidthSavingsPolicy(). applied revision. Tests may still supply explicit documents directly.
*/ */
return buildBandwidthSavingsPolicy(loadConfig()); return buildBandwidthSavingsPolicy(loadConfig());
} }
+7 -2
View File
@@ -87,6 +87,7 @@ function resolveSiteMetadata(config = loadConfig()) {
const interInstance = config?.interInstance; const interInstance = config?.interInstance;
const profile = interInstance?.profile; const profile = interInstance?.profile;
const profileName = asTrimmedString(profile?.name); const profileName = asTrimmedString(profile?.name);
const publicUrl = normalizePublicUrl(config?.publicUrl);
/* /*
A partially filled profile must not unexpectedly rename the site. The A partially filled profile must not unexpectedly rename the site. The
@@ -95,7 +96,11 @@ function resolveSiteMetadata(config = loadConfig()) {
the coherent default set above. the coherent default set above.
*/ */
if (interInstance?.enabled !== true || !profileName) { if (interInstance?.enabled !== true || !profileName) {
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) }; return {
...DEFAULT_SITE_METADATA,
publicUrl,
accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor),
};
} }
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor; const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
@@ -110,7 +115,7 @@ function resolveSiteMetadata(config = loadConfig()) {
BACKGROUND_BLEND_AMOUNT, BACKGROUND_BLEND_AMOUNT,
), ),
accentTextColor: getReadableAccentText(accentColor), accentTextColor: getReadableAccentText(accentColor),
publicUrl: normalizePublicUrl(profile.publicUrl), publicUrl,
}; };
} }
@@ -7,8 +7,15 @@ const logger = require('../../globals/logger').child('adminConfigurationService'
const { const {
getConfigurationDatabase, getConfigurationDatabase,
getRuntimeConfigurationRevision, getRuntimeConfigurationRevision,
getLastConfigurationApplication,
applyCommittedConfiguration,
rootSchema, rootSchema,
} = require('../../configuration'); } = require('../../configuration');
const {
MAX_CONFIGURATION_FILE_BYTES,
parseConfigurationFile,
buildSecretOperationsForImport,
} = require('../../configuration/configurationFileImporter');
const { getRole } = require('../roleService'); const { getRole } = require('../roleService');
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000; const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
@@ -32,6 +39,18 @@ function actorFor(socket) {
return socket?.data?.user?.username || socket.id; return socket?.data?.user?.username || socket.id;
} }
function safeUploadedFileName(value) {
/*
The filename is audit metadata only and is never opened on the server.
Removing control characters keeps logs and history readable while
retaining the operator-visible name that identifies the imported file.
*/
return String(value || 'uploaded-config.yaml')
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255) || 'uploaded-config.yaml';
}
function errorPayload(error) { function errorPayload(error) {
return { return {
error: error.message, error: error.message,
@@ -69,7 +88,8 @@ function buildAdminSnapshot() {
a second field definition. a second field definition.
*/ */
configuration: { ...configuration, schema: rootSchema }, configuration: { ...configuration, schema: rootSchema },
restartRequired: configuration.revision !== getRuntimeConfigurationRevision(), appliedRevision: getRuntimeConfigurationRevision(),
configurationApplication: getLastConfigurationApplication(),
administrators: database.listAdministrators(), administrators: database.listAdministrators(),
revisions: database.listConfigurationRevisions(), revisions: database.listConfigurationRevisions(),
auditEvents: database.listAuditEvents(), auditEvents: database.listAuditEvents(),
@@ -88,23 +108,56 @@ io.on('connection', (socket) => {
return { confirmedUntil: socket.data.adminPasswordConfirmedAt + PASSWORD_CONFIRMATION_WINDOW_MS }; return { confirmedUntil: socket.data.adminPasswordConfirmedAt + PASSWORD_CONFIRMATION_WINDOW_MS };
}); });
ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, (payload) => { ackHandler(socket, 'adminConfig:updateConfiguration', requireRecentPassword, async (payload) => {
const revision = database.updateConfiguration({ const revision = database.updateConfiguration({
value: payload.value, value: payload.value,
expectedRevision: payload.expectedRevision, expectedRevision: payload.expectedRevision,
secretOperations: payload.secretOperations, secretOperations: payload.secretOperations,
actor: actorFor(socket), actor: actorFor(socket),
}); });
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true }; const application = await applyCommittedConfiguration();
return { revision, application, snapshot: buildAdminSnapshot() };
}); });
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, (payload) => { ackHandler(socket, 'adminConfig:importConfigurationFile', requireRecentPassword, async (payload) => {
const yamlText = String(payload.yaml || '');
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
throw new Error('The YAML configuration file must be present and no larger than 1 MiB.');
}
/*
Parsing deliberately excludes administrators on an initialized server.
Configuration is still filtered to today's schema and strictly
validated, then committed through the ordinary optimistic update path so
missing secrets survive and explicitly supplied secrets replace or clear
their current values.
*/
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
const fileName = safeUploadedFileName(payload.fileName);
const revision = database.updateConfiguration({
value: parsed.config,
expectedRevision: payload.expectedRevision,
secretOperations: buildSecretOperationsForImport(parsed),
actor: actorFor(socket),
source: `admin-yaml:${fileName}`,
});
const application = await applyCommittedConfiguration();
return {
revision,
application,
ignoredAdministratorCount: parsed.uploadedAdministratorCount,
snapshot: buildAdminSnapshot(),
};
});
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, async (payload) => {
const revision = database.restoreConfigurationRevision({ const revision = database.restoreConfigurationRevision({
revision: payload.revision, revision: payload.revision,
expectedRevision: payload.expectedRevision, expectedRevision: payload.expectedRevision,
actor: actorFor(socket), actor: actorFor(socket),
}); });
return { revision, snapshot: buildAdminSnapshot(), restartRequired: true }; const application = await applyCommittedConfiguration();
return { revision, application, snapshot: buildAdminSnapshot() };
}); });
ackHandler(socket, 'adminConfig:createAdministrator', requireRecentPassword, async (payload) => { ackHandler(socket, 'adminConfig:createAdministrator', requireRecentPassword, async (payload) => {
@@ -155,4 +208,5 @@ io.on('connection', (socket) => {
module.exports = { module.exports = {
PASSWORD_CONFIRMATION_WINDOW_MS, PASSWORD_CONFIRMATION_WINDOW_MS,
requireLockdownAdministrator, requireLockdownAdministrator,
requireRecentPassword,
}; };
@@ -7,7 +7,7 @@ function registerAudioForwardHooks(deps) {
roverManager, roverManager,
turnService, turnService,
logger, logger,
serviceEnabled, isServiceEnabled,
workers, workers,
whipOwners, whipOwners,
ensureWorker, ensureWorker,
@@ -57,7 +57,7 @@ function registerAudioForwardHooks(deps) {
stopWorker(roverId); stopWorker(roverId);
return; return;
} }
if (action === 'upsert' && serviceEnabled && !workers.has(roverId)) { if (action === 'upsert' && isServiceEnabled() && !workers.has(roverId)) {
// A rover coming online should not create ffmpeg publishers by itself. // A rover coming online should not create ffmpeg publishers by itself.
// The audio worker is intentionally lazy because uploads, mic forwarding, // The audio worker is intentionally lazy because uploads, mic forwarding,
// and automatic sounds are the moments that actually need a media pipe; // and automatic sounds are the moments that actually need a media pipe;
@@ -5,7 +5,7 @@ const path = require('path');
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioForwardService'); const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveRuntimePath } = require('../../helpers/dataPaths'); const { resolveRuntimePath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const turnService = require('../turnService'); const turnService = require('../turnService');
@@ -17,18 +17,7 @@ const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound'); const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const audioForwardEvents = new EventEmitter(); const audioForwardEvents = new EventEmitter();
const config = loadConfig(); let serviceEnabled = false;
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
// Configuration defaults always provide this boolean. Treat only an explicit
// true as enabled so no credential, path, or historical fallback can opt the
// service in on the operator's behalf.
const serviceEnabled = Boolean(audioForwardConfig.enabled);
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
/* /*
FIFOs and uploaded clips are disposable, but they are deliberately created FIFOs and uploaded clips are disposable, but they are deliberately created
and managed by this application. A fixed path below SERVER_DATA_DIR keeps the and managed by this application. A fixed path below SERVER_DATA_DIR keeps the
@@ -37,9 +26,6 @@ const streamSuffix =
*/ */
const runtimeDir = resolveRuntimePath('audio-forward'); const runtimeDir = resolveRuntimePath('audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads'); const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt } const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker const workers = new Map(); // roverId -> worker
@@ -70,51 +56,65 @@ function getAudioForwardState() {
return payload; return payload;
} }
const audioForwardPolicy = createAudioForwardPolicy({ let operations;
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
mediaConfig,
});
const {
ensureAudioForwardPermission,
resolveForwardUrl,
resolveForwardPathId,
buildWhipUrl,
} = audioForwardPolicy;
const workerEngine = createAudioForwardWorkerEngine({ function replaceAudioForwardRuntime(fullConfig) {
logger, operations?.stopAllWorkers('configuration-change');
io, const audioForwardConfig = fullConfig.audioForward || {};
roverManager, serviceEnabled = Boolean(audioForwardConfig.enabled);
turnService, const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
videoSessions, ? audioForwardConfig.streamSuffix.trim()
serviceEnabled, : '-fwd';
ffmpegBin, const policy = createAudioForwardPolicy({
runtimeDir, isVerified,
uploadsDir, isMuted,
maxUploadBytes, roverManager,
workers, turnService,
whipOwners, streamSuffix,
setState, });
resolveForwardUrl, const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
resolveForwardPathId, ? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
}); : 8 * 1024 * 1024;
operations = {
...policy,
...createAudioForwardWorkerEngine({
logger,
io,
roverManager,
turnService,
videoSessions,
serviceEnabled,
ffmpegBin: audioForwardConfig.ffmpegBin || 'ffmpeg',
runtimeDir,
uploadsDir,
maxUploadBytes,
workers,
whipOwners,
setState,
resolveForwardUrl: policy.resolveForwardUrl,
resolveForwardPathId: policy.resolveForwardPathId,
}),
};
}
const { replaceAudioForwardRuntime(loadConfig());
ensureWorker,
stopWorker, // Stable delegates keep the one-time socket/event registrations below pointed
stopAllWorkers, // at the newest policy and worker engine after audio-forward changes.
playUploadedAudio, const delegate = (name) => (...args) => operations[name](...args);
playServerAudioFile, const ensureWorker = delegate('ensureWorker');
stopPlayback, const stopWorker = delegate('stopWorker');
revokeWhipSessionForRover, const stopAllWorkers = delegate('stopAllWorkers');
stopWhipForRover, const playUploadedAudio = delegate('playUploadedAudio');
stopOwnedAudioIfUnauthorized, const playServerAudioFile = delegate('playServerAudioFile');
startSilenceWriter, const stopPlayback = delegate('stopPlayback');
} = workerEngine; const revokeWhipSessionForRover = delegate('revokeWhipSessionForRover');
const stopWhipForRover = delegate('stopWhipForRover');
const stopOwnedAudioIfUnauthorized = delegate('stopOwnedAudioIfUnauthorized');
const startSilenceWriter = delegate('startSilenceWriter');
const ensureAudioForwardPermission = delegate('ensureAudioForwardPermission');
const resolveForwardPathId = delegate('resolveForwardPathId');
const buildWhipUrl = delegate('buildWhipUrl');
function installShutdownHooks() { function installShutdownHooks() {
const shutdown = (signal) => { const shutdown = (signal) => {
@@ -136,7 +136,7 @@ registerAudioForwardHooks({
roverManager, roverManager,
turnService, turnService,
logger, logger,
serviceEnabled, isServiceEnabled: () => serviceEnabled,
workers, workers,
whipOwners, whipOwners,
ensureWorker, ensureWorker,
@@ -161,6 +161,10 @@ registerChargeCompleteSound({
playServerAudioFile, playServerAudioFile,
}); });
registerConfigurationHandler('audioForward', (_section, _previous, nextConfig) => {
replaceAudioForwardRuntime(nextConfig);
});
module.exports = { module.exports = {
getAudioForwardState, getAudioForwardState,
audioForwardEvents, audioForwardEvents,
@@ -1,6 +1,8 @@
// audio Forward Service policy // audio Forward Service policy
// Purpose: Encapsulates permission checks and media path/url derivation helpers. // Purpose: Encapsulates permission checks and media path/url derivation helpers.
// Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic. // Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic.
const { PUBLIC_MEDIA_PREFIX } = require('../mediaMtxService/proxy');
function createAudioForwardPolicy(deps) { function createAudioForwardPolicy(deps) {
const { const {
isVerified, isVerified,
@@ -8,7 +10,6 @@ function createAudioForwardPolicy(deps) {
roverManager, roverManager,
turnService, turnService,
streamSuffix, streamSuffix,
mediaConfig,
} = deps; } = deps;
function ensureVipVerified(socket) { function ensureVipVerified(socket) {
@@ -43,23 +44,8 @@ function createAudioForwardPolicy(deps) {
return `${roverId}${streamSuffix}`; return `${roverId}${streamSuffix}`;
} }
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return String(base).replace(/\/+$/, '');
}
}
function buildWhipUrl(pathId) { function buildWhipUrl(pathId) {
const prefix = getMediaPrefix(); return `${PUBLIC_MEDIA_PREFIX}/${encodeURIComponent(pathId)}/whip`;
if (!prefix) {
throw new Error('Server media base URL missing');
}
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
} }
return { return {
@@ -12,7 +12,6 @@ function createPolicy({ verified = true, muted = false, driver = true, canDrive
roverManager: { isDriver: () => driver }, roverManager: { isDriver: () => driver },
turnService: { canDrive: () => canDrive }, turnService: { canDrive: () => canDrive },
streamSuffix: '-fwd', streamSuffix: '-fwd',
mediaConfig: {},
}); });
} }
@@ -30,3 +29,8 @@ test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
const policy = createPolicy(); const policy = createPolicy();
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd'); assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
}); });
test('publishes browser microphone signaling through the same-origin proxy', () => {
const policy = createPolicy();
assert.equal(policy.buildWhipUrl('rover one-fwd'), '/video/rover%20one-fwd/whip');
});
@@ -5,7 +5,7 @@ const fs = require('fs');
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioLevelsService'); const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin, roleEvents } = require('../roleService'); const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
@@ -344,6 +344,32 @@ io.on('connection', (socket) => {
loadState(); loadState();
registerConfigurationHandler('audioLevels', async (nextConfig) => {
/*
Audio levels also have a durable operational store because administrators
can adjust them outside the configuration editor. Applying a configuration
revision intentionally updates that same live state, rather than changing
startup fallbacks that an existing store would immediately override.
*/
const current = loadState();
persistState({
...current,
hornGain: clampGain(nextConfig.hornGain, current.hornGain),
ttsGain: clampGain(nextConfig.ttsGain, current.ttsGain),
forwardGain: clampGain(nextConfig.forwardGain, current.forwardGain),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
nextConfig.maxPersonalAdjustmentPercent,
current.maxPersonalAdjustmentPercent,
),
updatedAt: Date.now(),
updatedBy: 'configuration',
adjustmentRangeUpdatedAt: Date.now(),
adjustmentRangeUpdatedBy: 'configuration',
});
pushLevelsToAllRovers();
emitChange('configuration_applied');
});
module.exports = { module.exports = {
ADJUSTMENT_FIELDS, ADJUSTMENT_FIELDS,
PERSONAL_ADJUSTMENT_PERMISSION, PERSONAL_ADJUSTMENT_PERMISSION,
@@ -0,0 +1,199 @@
// Full Data Backup
// Purpose: Creates one validated archive of the durable server data without stopping live services or writers.
// Scope: Uses service-owned online database snapshots and stable file copies inside the canonical data directory.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const tar = require('tar');
const Database = require('better-sqlite3');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const packageInfo = require('../../../package.json');
const FORMAT_VERSION = 1;
const CONTROL_DIR_NAME = 'backup-restore';
const EXCLUDED_TOP_LEVEL_NAMES = new Set([CONTROL_DIR_NAME, 'runtime']);
const DATABASE_NAMES = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite'];
const DATABASE_FILES = new Set(DATABASE_NAMES.flatMap((name) => [name, `${name}-wal`, `${name}-shm`]));
const FILE_COPY_ATTEMPTS = 3;
async function removeSnapshotSidecars(payloadDir) {
/*
SQLite online backup produces a complete standalone main database file.
Reopening that snapshot to inspect its schema can still create empty WAL
and shared-memory coordination files because the database retains WAL as
its journal mode. Those files describe no durable backup content and must
be removed before the manifest inventory and tar archive are produced.
*/
await Promise.all(DATABASE_NAMES.flatMap((name) => [
fsp.rm(path.join(payloadDir, `${name}-wal`), { force: true }),
fsp.rm(path.join(payloadDir, `${name}-shm`), { force: true }),
]));
}
function readDatabaseSchemaVersions(payloadDir) {
const configuration = new Database(path.join(payloadDir, 'configuration.sqlite'), { readonly: true });
const identity = new Database(path.join(payloadDir, 'identity.sqlite'), { readonly: true });
const fleetReports = new Database(path.join(payloadDir, 'fleet-reports.sqlite'), { readonly: true });
try {
return {
configuration: Number(configuration.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version) || 0,
identity: Number(identity.pragma('user_version', { simple: true })) || 0,
// Fleet reporting currently evolves with additive startup checks and has
// no numbered migration table, so zero accurately identifies that scheme.
fleetReports: Number(fleetReports.pragma('user_version', { simple: true })) || 0,
};
} finally {
configuration.close();
identity.close();
fleetReports.close();
}
}
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
async function copyStableFile(sourcePath, destinationPath) {
for (let attempt = 0; attempt < FILE_COPY_ATTEMPTS; attempt += 1) {
try {
const before = await fsp.stat(sourcePath);
if (!before.isFile()) throw new Error(`Backup source is not a regular file: ${sourcePath}`);
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
await fsp.copyFile(sourcePath, destinationPath);
await fsp.chmod(destinationPath, before.mode & 0o777);
/*
A writer may replace or append to a media file while it is copied. Size
and timestamp checks catch ordinary changes, while comparing hashes
catches a same-size replacement. Only the unstable file is retried;
no owning service is paused.
*/
const [sourceHash, destinationHash] = await Promise.all([
sha256File(sourcePath),
sha256File(destinationPath),
]);
// Read the final metadata only after both hashes finish. Starting this
// stat concurrently could miss a write that occurred during hashing.
const after = await fsp.stat(sourcePath);
if (before.size === after.size
&& before.mtimeMs === after.mtimeMs
&& sourceHash === destinationHash) return true;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
// A rotating snapshot can disappear between directory enumeration and
// copying. Treat that exact race like any other unstable active file.
}
await fsp.rm(destinationPath, { force: true });
}
return false;
}
async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
let entries;
try {
entries = await fsp.readdir(path.join(sourceDir, relativeDir), { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
const skipped = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
if (entry.isDirectory()) {
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
continue;
}
if (!entry.isFile()) throw new Error(`Backup cannot include special file: ${relativePath}`);
const copied = await copyStableFile(
path.join(sourceDir, relativePath),
path.join(destinationDir, relativePath),
);
if (!copied) skipped.push(relativePath.split(path.sep).join('/'));
}
return skipped;
}
async function listManifestFiles(rootDir, relativeDir = '') {
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) {
files.push(...await listManifestFiles(rootDir, relativePath));
continue;
}
if (!entry.isFile()) throw new Error(`Backup staging contains a special file: ${relativePath}`);
const filePath = path.join(rootDir, relativePath);
const stat = await fsp.stat(filePath);
files.push({
path: relativePath.split(path.sep).join('/'),
size: stat.size,
sha256: await sha256File(filePath),
});
}
return files.sort((left, right) => left.path.localeCompare(right.path));
}
async function createFullBackup({ configurationDatabase, identityService, fleetReportService, jobId }) {
const dataDir = resolveDataDir();
const jobDir = resolveDataPath(path.join(CONTROL_DIR_NAME, `backup-${jobId}`));
const payloadDir = path.join(jobDir, 'data');
const archivePath = path.join(jobDir, 'multirover-backup.tar.gz');
await fsp.rm(jobDir, { recursive: true, force: true });
await fsp.mkdir(payloadDir, { recursive: true });
try {
// Each database owner remains live and writes a coherent SQLite snapshot
// directly into the same staging tree as the ordinary durable files.
await Promise.all([
configurationDatabase.backupDatabase(path.join(payloadDir, DATABASE_NAMES[0])),
identityService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[1])),
fleetReportService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[2])),
]);
const skippedUnstableFiles = await copyDurableTree(dataDir, payloadDir);
/*
Finish every operation that can create a staged file before inventorying
the payload. Production databases use WAL mode, so schema inspection must
precede both sidecar cleanup and the final immutable file list.
*/
const databaseSchemaVersions = readDatabaseSchemaVersions(payloadDir);
await removeSnapshotSidecars(payloadDir);
const files = await listManifestFiles(payloadDir);
const manifest = {
format: 'multirover-full-backup',
formatVersion: FORMAT_VERSION,
applicationVersion: packageInfo.version,
createdAt: Date.now(),
databaseSchemaVersions,
files,
skippedUnstableFiles,
};
await fsp.writeFile(path.join(jobDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await tar.c({ cwd: jobDir, file: archivePath, gzip: true, portable: true }, ['manifest.json', 'data']);
return { archivePath, jobDir, manifest };
} catch (error) {
await fsp.rm(jobDir, { recursive: true, force: true });
throw error;
}
}
module.exports = {
CONTROL_DIR_NAME,
DATABASE_NAMES,
FORMAT_VERSION,
createFullBackup,
readDatabaseSchemaVersions,
removeSnapshotSidecars,
sha256File,
};
@@ -0,0 +1,148 @@
// Backup and Restore Service Tests
// Purpose: Verifies complete archive round trips, exclusions, malicious entry rejection, startup replacement, and rollback.
// Scope: Uses one isolated SERVER_DATA_DIR and injected SQLite owners; it never reads or changes development server data.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const tar = require('tar');
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-backup-restore-'));
process.env.SERVER_DATA_DIR = temporaryRoot;
const { createFullBackup } = require('./backup');
const { inspectArchive, prepareRestoreArchive, validateExtractedRestore } = require('./restore');
const startupRestore = require('./startupRestore');
const VALID_RESTORE_ID = 'a'.repeat(64);
function createSourceDatabase(name) {
/*
Real service databases use WAL mode. Keeping fixture sources under the
excluded runtime directory both mirrors that behavior and ensures their
own live WAL files are not mistaken for ordinary durable backup content.
*/
const sourceDirectory = path.join(temporaryRoot, 'runtime', 'database-sources');
fs.mkdirSync(sourceDirectory, { recursive: true });
const filePath = path.join(sourceDirectory, name);
const database = new Database(filePath);
database.pragma('journal_mode = WAL');
database.exec('CREATE TABLE example (value TEXT NOT NULL); INSERT INTO example VALUES (\'preserved\');');
if (name === 'configuration.sqlite') {
database.exec('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); INSERT INTO schema_migrations VALUES (1);');
} else if (name === 'identity.sqlite') {
database.pragma('user_version = 4');
}
return database;
}
test.after(() => {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
});
test('creates and validates a complete backup while excluding runtime and control data', async () => {
await fsp.writeFile(path.join(temporaryRoot, 'state.json'), '{"preserved":true}\n');
await fsp.mkdir(path.join(temporaryRoot, 'replays'), { recursive: true });
await fsp.writeFile(path.join(temporaryRoot, 'replays', 'complete.mp4'), 'complete replay');
await fsp.mkdir(path.join(temporaryRoot, 'runtime'), { recursive: true });
await fsp.writeFile(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard me');
const sources = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite']
.map(createSourceDatabase);
const owners = sources.map((database) => ({
backupDatabase: (destinationPath) => database.backup(destinationPath),
}));
const result = await createFullBackup({
configurationDatabase: owners[0],
identityService: owners[1],
fleetReportService: owners[2],
jobId: 'test-backup',
});
assert.ok(fs.statSync(result.archivePath).size > 0);
assert.ok(result.manifest.files.some((entry) => entry.path === 'state.json'));
assert.ok(result.manifest.files.some((entry) => entry.path === 'replays/complete.mp4'));
assert.equal(result.manifest.files.some((entry) => entry.path.includes('runtime')), false);
assert.equal(result.manifest.files.some((entry) => entry.path.includes('backup-restore')), false);
assert.equal(result.manifest.files.some((entry) => entry.path.endsWith('-wal') || entry.path.endsWith('-shm')), false);
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
await fsp.mkdir(restoreJob, { recursive: true });
const uploadedArchive = path.join(restoreJob, 'upload.tar.gz');
await fsp.copyFile(result.archivePath, uploadedArchive);
const summary = await prepareRestoreArchive({ archivePath: uploadedArchive, jobDir: restoreJob, actor: 'test' });
assert.equal(summary.fileCount, result.manifest.files.length);
const restoredNames = await fsp.readdir(path.join(restoreJob, 'extracted', 'data'));
assert.equal(restoredNames.some((name) => name.endsWith('-wal') || name.endsWith('-shm')), false);
sources.forEach((database) => database.close());
});
test('rejects a staged restore whose contents no longer match the manifest', async () => {
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
const statePath = path.join(restoreJob, 'extracted', 'data', 'state.json');
await fsp.writeFile(statePath, '{"tampered":true}\n');
await assert.rejects(validateExtractedRestore(path.join(restoreJob, 'extracted')), /checksum or size mismatch/);
// Restore the known source content so the following startup-application test
// continues to exercise a genuinely validated replacement payload.
await fsp.writeFile(statePath, '{"preserved":true}\n');
});
test('rejects symbolic links before extracting an archive', async () => {
const unsafeRoot = path.join(temporaryRoot, 'unsafe-archive');
await fsp.mkdir(path.join(unsafeRoot, 'data'), { recursive: true });
await fsp.writeFile(path.join(unsafeRoot, 'manifest.json'), '{}');
await fsp.symlink('/etc/passwd', path.join(unsafeRoot, 'data', 'escape'));
const archivePath = path.join(temporaryRoot, 'unsafe.tar.gz');
await tar.c({ cwd: unsafeRoot, file: archivePath, gzip: true }, ['manifest.json', 'data']);
await assert.rejects(inspectArchive(archivePath), /unsupported entry type/);
});
test('applies validated replacement data and removes rollback only after startup succeeds', () => {
const restoreId = VALID_RESTORE_ID;
const jobDir = path.join(temporaryRoot, 'backup-restore', `restore-${restoreId}`);
fs.writeFileSync(path.join(temporaryRoot, 'old-state.txt'), 'old');
fs.mkdirSync(path.join(temporaryRoot, 'runtime'), { recursive: true });
fs.writeFileSync(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard during restore');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId,
state: 'pending',
requestedAt: Date.now(),
actor: 'test',
});
const applied = startupRestore.applyPendingRestore();
assert.equal(applied.status, 'awaiting-health');
assert.equal(fs.existsSync(path.join(temporaryRoot, 'old-state.txt')), 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);
const completed = startupRestore.markStartupSuccessful();
assert.equal(completed.status, 'restored');
assert.equal(fs.existsSync(jobDir), false);
assert.equal(fs.existsSync(startupRestore.pendingPath), false);
});
test('restores the rollback copy when a replaced application did not reach health', () => {
const restoreId = 'b'.repeat(64);
const rollbackDir = path.join(temporaryRoot, 'backup-restore', 'rollback');
fs.mkdirSync(rollbackDir, { recursive: true });
fs.writeFileSync(path.join(rollbackDir, 'state.json'), 'previous state');
fs.writeFileSync(path.join(temporaryRoot, 'state.json'), 'failed replacement');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId,
state: 'awaiting-health',
requestedAt: Date.now(),
actor: 'test',
});
const result = startupRestore.applyPendingRestore();
assert.equal(result.status, 'rolled-back');
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), 'previous state');
assert.equal(startupRestore.getLastRestoreResult().status, 'rolled-back');
});
@@ -0,0 +1,267 @@
// Backup and Restore Service
// Purpose: Owns full-data archive creation, browser transfer, staged restore confirmation, startup replacement, and rollback.
// Scope: All backup/restore orchestration stays in this service; database owners expose only their online snapshot operations.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const { Transform } = require('stream');
const { pipeline } = require('stream/promises');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { CONTROL_DIR_NAME, createFullBackup } = require('./backup');
const { MAX_ARCHIVE_BYTES, prepareRestoreArchive } = require('./restore');
const startupRestore = require('./startupRestore');
const TOKEN_LIFETIME_MS = 10 * 60 * 1000;
const controlDir = resolveDataPath(CONTROL_DIR_NAME);
const downloads = new Map();
const uploads = new Map();
let backupInProgress = false;
let registered = false;
let app;
let io;
let logger;
let getConfigurationDatabase;
let identityService;
let fleetReportService;
let requireLockdownAdministrator;
let requireRecentPassword;
let requestApplicationRestart;
let isApplicationRestartPending;
function actorFor(socket) {
return socket?.data?.user?.username || socket.id;
}
function createToken() {
return crypto.randomBytes(32).toString('hex');
}
function pruneExpiredTransfers() {
const now = Date.now();
for (const [token, transfer] of [...downloads, ...uploads]) {
if (transfer.expiresAt > now) continue;
downloads.delete(token);
uploads.delete(token);
if (transfer.jobDir) fsp.rm(transfer.jobDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function responseError(cb, error) {
logger.warn('Backup or restore request failed', { error: error.message });
cb({ error: error.message, code: error.code || null });
}
function removeOrphanedStaging() {
let pendingRestoreId = null;
try {
pendingRestoreId = JSON.parse(fs.readFileSync(startupRestore.pendingPath, 'utf8')).restoreId || null;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const entry of fs.readdirSync(controlDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const preservedName = pendingRestoreId ? `restore-${pendingRestoreId}` : null;
if ((entry.name.startsWith('backup-') || entry.name.startsWith('restore-'))
&& entry.name !== preservedName) {
/*
Transfer authorization lives only in process memory. After a restart,
an unconfirmed upload or undownloaded archive can no longer be reached,
so deleting it prevents abandoned full-data copies from accumulating.
*/
fs.rmSync(path.join(controlDir, entry.name), { recursive: true, force: true });
}
}
}
function registerSocketApi() {
io.on('connection', (socket) => {
socket.on('backupRestore:status', (_payload = {}, cb = () => {}) => {
try {
requireLockdownAdministrator(socket);
cb({ success: true, lastRestore: startupRestore.getLastRestoreResult() });
} catch (error) {
responseError(cb, error);
}
});
socket.on('backupRestore:createBackup', (_payload = {}, cb = () => {}) => {
Promise.resolve().then(async () => {
requireRecentPassword(socket);
if (backupInProgress) throw new Error('A full backup is already being created.');
backupInProgress = true;
try {
pruneExpiredTransfers();
const jobId = createToken();
const result = await createFullBackup({
configurationDatabase: getConfigurationDatabase(),
identityService,
fleetReportService,
jobId,
});
const token = createToken();
downloads.set(token, {
archivePath: result.archivePath,
jobDir: result.jobDir,
expiresAt: Date.now() + TOKEN_LIFETIME_MS,
});
getConfigurationDatabase().recordAuditEvent(actorFor(socket), 'backup.created', {
fileCount: result.manifest.files.length,
skippedUnstableFileCount: result.manifest.skippedUnstableFiles.length,
});
cb({
success: true,
downloadUrl: `/admin-api/backup/${token}`,
fileCount: result.manifest.files.length,
skippedUnstableFiles: result.manifest.skippedUnstableFiles,
});
} finally {
backupInProgress = false;
}
}).catch((error) => responseError(cb, error));
});
socket.on('backupRestore:createRestoreUpload', (_payload = {}, cb = () => {}) => {
try {
requireRecentPassword(socket);
pruneExpiredTransfers();
const token = createToken();
uploads.set(token, {
actor: actorFor(socket),
expiresAt: Date.now() + TOKEN_LIFETIME_MS,
});
cb({ success: true, uploadUrl: `/admin-api/restore/${token}` });
} catch (error) {
responseError(cb, error);
}
});
socket.on('backupRestore:confirmRestore', ({ restoreId } = {}, cb = () => {}) => {
try {
requireRecentPassword(socket);
const safeRestoreId = String(restoreId || '');
if (!/^[a-f0-9]{64}$/.test(safeRestoreId)) throw new Error('Validated restore was not found.');
if (isApplicationRestartPending()) throw new Error('Application restart already pending.');
if (fs.existsSync(startupRestore.pendingPath)) throw new Error('A restore is already pending.');
const jobDir = path.join(controlDir, `restore-${safeRestoreId}`);
if (!fs.existsSync(path.join(jobDir, 'validated.json'))) throw new Error('Validated restore was not found.');
startupRestore.writeJson(startupRestore.pendingPath, {
restoreId: safeRestoreId,
state: 'pending',
requestedAt: Date.now(),
actor: actorFor(socket),
});
getConfigurationDatabase().recordAuditEvent(actorFor(socket), 'restore.requested', { restoreId: safeRestoreId });
requestApplicationRestart({ actor: actorFor(socket), reason: 'restore-requested' });
cb({ success: true });
} catch (error) {
responseError(cb, error);
}
});
});
}
function registerHttpApi() {
app.get('/admin-api/backup/:token', (req, res) => {
pruneExpiredTransfers();
const transfer = downloads.get(String(req.params.token || ''));
if (!transfer) {
res.status(404).send('Backup download is missing or expired.');
return;
}
downloads.delete(req.params.token);
res.set({
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="multirover-backup-${new Date().toISOString().slice(0, 10)}.tar.gz"`,
'Cache-Control': 'no-store',
});
const cleanup = () => fsp.rm(transfer.jobDir, { recursive: true, force: true }).catch(() => undefined);
res.once('close', cleanup);
fs.createReadStream(transfer.archivePath).on('error', (error) => {
logger.warn('Backup download failed', { error: error.message });
if (!res.headersSent) res.status(500).end();
else res.destroy(error);
}).pipe(res);
});
app.put('/admin-api/restore/:token', async (req, res) => {
pruneExpiredTransfers();
const token = String(req.params.token || '');
const transfer = uploads.get(token);
uploads.delete(token);
if (!transfer) {
res.status(404).json({ error: 'Restore upload is missing or expired.' });
return;
}
const contentLength = Number(req.headers['content-length']);
if (Number.isFinite(contentLength) && contentLength > MAX_ARCHIVE_BYTES) {
res.status(413).json({ error: 'Backup archive exceeds the restore size limit.' });
return;
}
const restoreId = createToken();
const jobDir = path.join(controlDir, `restore-${restoreId}`);
const archivePath = path.join(jobDir, 'upload.tar.gz');
try {
await fsp.mkdir(jobDir, { recursive: true });
let receivedBytes = 0;
const limiter = new Transform({
transform(chunk, _encoding, callback) {
receivedBytes += chunk.length;
callback(receivedBytes > MAX_ARCHIVE_BYTES
? new Error('Backup archive exceeds the restore size limit.')
: null, chunk);
},
});
await pipeline(req, limiter, fs.createWriteStream(archivePath, { mode: 0o600 }));
const summary = await prepareRestoreArchive({ archivePath, jobDir, actor: transfer.actor });
getConfigurationDatabase().recordAuditEvent(transfer.actor, 'restore.validated', {
restoreId,
fileCount: summary.fileCount,
totalBytes: summary.totalBytes,
});
res.set('Cache-Control', 'no-store').json({ success: true, restoreId, summary });
} catch (error) {
await fsp.rm(jobDir, { recursive: true, force: true });
logger.warn('Restore upload failed validation', { actor: transfer.actor, error: error.message });
res.status(error.message.includes('size limit') ? 413 : 400).json({ error: error.message });
}
});
}
function register() {
if (registered) return;
registered = true;
/*
These runtime dependencies are deliberately loaded only after earliest
startup restore has run. Several of them open SQLite immediately, so
importing them at module scope would make replacement too late and unsafe.
*/
({ app } = require('../../globals/http'));
io = require('../../globals/io');
logger = require('../../globals/logger').child('backupRestoreService');
({ getConfigurationDatabase } = require('../../configuration'));
identityService = require('../identityService');
fleetReportService = require('../fleetReportService');
({ requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService'));
({ isApplicationRestartPending, requestApplicationRestart } = require('../serverControlService'));
fs.mkdirSync(controlDir, { recursive: true });
removeOrphanedStaging();
registerHttpApi();
registerSocketApi();
}
function markStartupSuccessful() {
const result = startupRestore.markStartupSuccessful();
if (result) {
const configuration = require('../../configuration');
configuration.getConfigurationDatabase().recordAuditEvent('system', 'restore.completed', { restoreId: result.restoreId });
}
return result;
}
module.exports = {
applyPendingRestore: startupRestore.applyPendingRestore,
markStartupSuccessful,
register,
};
@@ -0,0 +1,216 @@
// Full Data Restore Validation
// Purpose: Safely extracts and validates an uploaded MultiRover backup before it can become a pending restore.
// Scope: Never changes active data; startupRestore owns the later replacement and rollback transaction.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const Database = require('better-sqlite3');
const tar = require('tar');
const {
DATABASE_NAMES,
FORMAT_VERSION,
readDatabaseSchemaVersions,
removeSnapshotSidecars,
sha256File,
} = require('./backup');
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 * 1024;
const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 100000;
const RESERVED_DATA_NAMES = new Set(['backup-restore', 'runtime']);
const SUPPORTED_DATABASE_SCHEMA_VERSIONS = {
configuration: 2,
identity: 4,
fleetReports: 0,
};
function normalizeArchivePath(value) {
const raw = String(value || '');
if (!raw || raw.includes('\\') || path.posix.isAbsolute(raw)) return null;
const withoutTrailingSlash = raw.replace(/\/+$/, '');
const normalized = path.posix.normalize(withoutTrailingSlash);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
return normalized;
}
function assertAllowedArchiveEntry(entry) {
const normalized = normalizeArchivePath(entry.path);
if (!normalized || (normalized !== 'manifest.json' && normalized !== 'data' && !normalized.startsWith('data/'))) {
throw new Error(`Backup contains an invalid archive path: ${entry.path}`);
}
if (!['File', 'Directory'].includes(entry.type)) {
throw new Error(`Backup contains unsupported entry type ${entry.type}: ${entry.path}`);
}
if (normalized.startsWith('data/')) {
const topLevelName = normalized.slice('data/'.length).split('/')[0];
if (RESERVED_DATA_NAMES.has(topLevelName)) {
throw new Error(`Backup contains reserved data path: ${entry.path}`);
}
}
return normalized;
}
async function inspectArchive(archivePath) {
let entryCount = 0;
let extractedBytes = 0;
const paths = new Set();
let validationError = null;
await tar.t({
file: archivePath,
strict: true,
onentry: (entry) => {
if (validationError) return;
try {
entryCount += 1;
extractedBytes += Number(entry.size) || 0;
if (entryCount > MAX_ARCHIVE_ENTRIES) throw new Error('Backup contains too many files.');
if (extractedBytes > MAX_EXTRACTED_BYTES) throw new Error('Backup expands beyond the restore size limit.');
const normalized = assertAllowedArchiveEntry(entry);
if (paths.has(normalized)) throw new Error(`Backup contains duplicate path: ${normalized}`);
paths.add(normalized);
} catch (error) {
/*
tar invokes onentry from its parser event stack, where throwing would
become an uncaught exception instead of rejecting tar.t(). Retain the
first failure and raise it immediately after the bounded listing.
*/
validationError = error;
}
},
});
if (validationError) throw validationError;
if (!paths.has('manifest.json') || !paths.has('data')) {
throw new Error('Backup must contain manifest.json and one data directory.');
}
}
async function listExtractedFiles(rootDir, relativeDir = '') {
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const fullPath = path.join(rootDir, relativePath);
const stat = await fsp.lstat(fullPath);
if (stat.isSymbolicLink()) throw new Error(`Restored data contains symbolic link: ${relativePath}`);
if (stat.isDirectory()) {
files.push(...await listExtractedFiles(rootDir, relativePath));
continue;
}
if (!stat.isFile()) throw new Error(`Restored data contains special file: ${relativePath}`);
files.push({
path: relativePath.split(path.sep).join('/'),
size: stat.size,
sha256: await sha256File(fullPath),
});
}
return files.sort((left, right) => left.path.localeCompare(right.path));
}
function verifySqliteDatabase(filePath, name) {
const database = new Database(filePath, { readonly: true, fileMustExist: true });
try {
const result = database.pragma('quick_check', { simple: true });
if (result !== 'ok') throw new Error(`${name} failed SQLite integrity validation.`);
} finally {
database.close();
}
}
async function validateExtractedRestore(extractDir) {
const manifestPath = path.join(extractDir, 'manifest.json');
const payloadDir = path.join(extractDir, 'data');
const manifestStat = await fsp.stat(manifestPath);
if (manifestStat.size > 10 * 1024 * 1024) throw new Error('Backup manifest is unreasonably large.');
const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8'));
if (manifest.format !== 'multirover-full-backup' || manifest.formatVersion !== FORMAT_VERSION) {
throw new Error('Backup format or version is not supported.');
}
if (!Array.isArray(manifest.files)) throw new Error('Backup manifest has no file inventory.');
const expected = [...manifest.files].sort((left, right) => String(left.path).localeCompare(String(right.path)));
const actual = await listExtractedFiles(payloadDir);
if (expected.length !== actual.length) {
/*
Keep the strict complete-inventory check, but identify a few differences
so an operator can distinguish a missing file from an unexpected archive
entry without weakening restore validation or exposing file contents.
*/
const expectedPaths = new Set(expected.map((entry) => entry.path));
const actualPaths = new Set(actual.map((entry) => entry.path));
const missing = expected.filter((entry) => !actualPaths.has(entry.path)).map((entry) => entry.path).slice(0, 5);
const unexpected = actual.filter((entry) => !expectedPaths.has(entry.path)).map((entry) => entry.path).slice(0, 5);
const details = [
missing.length ? `missing: ${missing.join(', ')}` : '',
unexpected.length ? `unexpected: ${unexpected.join(', ')}` : '',
].filter(Boolean).join('; ');
throw new Error(`Backup file inventory does not match the archive${details ? ` (${details})` : ''}.`);
}
for (let index = 0; index < expected.length; index += 1) {
const wanted = expected[index];
const found = actual[index];
if (wanted.path !== found.path || wanted.size !== found.size || wanted.sha256 !== found.sha256) {
throw new Error(`Backup checksum or size mismatch: ${wanted.path || found.path}`);
}
}
for (const databaseName of DATABASE_NAMES) {
if (!actual.some((entry) => entry.path === databaseName)) {
throw new Error(`Backup is missing required database: ${databaseName}`);
}
verifySqliteDatabase(path.join(payloadDir, databaseName), databaseName);
}
const actualSchemaVersions = readDatabaseSchemaVersions(payloadDir);
for (const [databaseName, version] of Object.entries(actualSchemaVersions)) {
// JSON object property order has no meaning. Compare each known database by
// name so an otherwise valid manifest is not rejected merely because a
// different JSON writer emitted its keys in another order.
if (Number(manifest.databaseSchemaVersions?.[databaseName]) !== version) {
throw new Error('Backup database schema versions do not match its manifest.');
}
if (version > SUPPORTED_DATABASE_SCHEMA_VERSIONS[databaseName]) {
throw new Error(`Backup ${databaseName} database is newer than this application supports.`);
}
}
/*
Integrity and schema reads can create fresh WAL coordination files even
though the uploaded snapshot initially matched its manifest exactly. Remove
those validation-only files so startup applies only inventoried content.
*/
await removeSnapshotSidecars(payloadDir);
return manifest;
}
async function prepareRestoreArchive({ archivePath, jobDir, actor }) {
const archiveStat = await fsp.stat(archivePath);
if (!archiveStat.isFile() || archiveStat.size <= 0 || archiveStat.size > MAX_ARCHIVE_BYTES) {
throw new Error('Backup archive is empty or exceeds the restore size limit.');
}
await inspectArchive(archivePath);
const extractDir = path.join(jobDir, 'extracted');
await fsp.rm(extractDir, { recursive: true, force: true });
await fsp.mkdir(extractDir, { recursive: true });
// Data files never need executable or set-id permissions from an uploaded
// archive. Let the server account's umask choose safe extraction modes.
await tar.x({ cwd: extractDir, file: archivePath, strict: true, preservePaths: false, noChmod: true });
const manifest = await validateExtractedRestore(extractDir);
const summary = {
createdAt: manifest.createdAt,
applicationVersion: manifest.applicationVersion,
fileCount: manifest.files.length,
totalBytes: manifest.files.reduce((total, entry) => total + Number(entry.size || 0), 0),
skippedUnstableFiles: Array.isArray(manifest.skippedUnstableFiles) ? manifest.skippedUnstableFiles : [],
};
await fsp.writeFile(
path.join(jobDir, 'validated.json'),
`${JSON.stringify({ actor, validatedAt: Date.now(), summary }, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
return summary;
}
module.exports = {
MAX_ARCHIVE_BYTES,
inspectArchive,
prepareRestoreArchive,
validateExtractedRestore,
};
@@ -0,0 +1,147 @@
// Startup Data Restore
// Purpose: Applies a previously validated restore before any application database opens and rolls back an interrupted start.
// Scope: Operates only on top-level entries inside SERVER_DATA_DIR while preserving backupRestoreService control state.
const fs = require('fs');
const path = require('path');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { CONTROL_DIR_NAME } = require('./backup');
const controlDir = resolveDataPath(CONTROL_DIR_NAME);
const pendingPath = path.join(controlDir, 'pending.json');
const rollbackDir = path.join(controlDir, 'rollback');
const lastResultPath = path.join(controlDir, 'last-result.json');
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(temporaryPath, filePath);
}
function readPending() {
try {
return JSON.parse(fs.readFileSync(pendingPath, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
function listActiveDataEntries({ includeRuntime = true } = {}) {
fs.mkdirSync(resolveDataDir(), { recursive: true });
return fs.readdirSync(resolveDataDir()).filter((name) => (
name !== CONTROL_DIR_NAME && (includeRuntime || name !== 'runtime')
));
}
function removeActiveData() {
// 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 });
}
}
function moveChildren(sourceDir, destinationDir) {
fs.mkdirSync(destinationDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) {
fs.renameSync(path.join(sourceDir, name), path.join(destinationDir, name));
}
}
function restoreRollback(pending, errorMessage) {
removeActiveData();
moveChildren(rollbackDir, resolveDataDir());
writeJson(lastResultPath, {
status: 'rolled-back',
restoreId: pending.restoreId,
completedAt: Date.now(),
error: errorMessage,
});
fs.rmSync(pendingPath, { force: true });
}
function applyPendingRestore() {
const pending = readPending();
if (!pending) return null;
/*
Reaching startup again in either state means the replacement process did
not reach the HTTP-listening success marker. The complete rollback copy was
created before active data was touched, so restoring it is deterministic.
*/
if (pending.state === 'applying' || pending.state === 'awaiting-health') {
restoreRollback(pending, 'The restored application did not finish starting.');
return { status: 'rolled-back', restoreId: pending.restoreId };
}
const jobDir = path.join(controlDir, `restore-${pending.restoreId}`);
const replacementDir = path.join(jobDir, 'extracted', 'data');
if (!fs.existsSync(path.join(jobDir, 'validated.json'))
|| !fs.existsSync(replacementDir)
|| !fs.statSync(replacementDir).isDirectory()) {
fs.rmSync(pendingPath, { force: true });
writeJson(lastResultPath, {
status: 'failed',
restoreId: pending.restoreId,
completedAt: Date.now(),
error: 'Validated restore staging is missing.',
});
return { status: 'failed', restoreId: pending.restoreId };
}
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.mkdirSync(rollbackDir, { recursive: true });
// Startup runs before any database or writer opens. Copying the entire
// current payload first gives every later replacement step one complete,
// local rollback source even if the process is interrupted halfway through.
for (const name of listActiveDataEntries({ includeRuntime: false })) {
fs.cpSync(path.join(resolveDataDir(), name), path.join(rollbackDir, name), { recursive: true });
}
writeJson(pendingPath, { ...pending, state: 'applying', applyingAt: Date.now() });
removeActiveData();
moveChildren(replacementDir, resolveDataDir());
writeJson(pendingPath, { ...pending, state: 'awaiting-health', appliedAt: Date.now() });
return { status: 'awaiting-health', restoreId: pending.restoreId };
} catch (error) {
if (fs.existsSync(rollbackDir)) restoreRollback(pending, error.message);
else fs.rmSync(pendingPath, { force: true });
return { status: 'rolled-back', restoreId: pending.restoreId, error: error.message };
}
}
function markStartupSuccessful() {
const pending = readPending();
if (!pending || pending.state !== 'awaiting-health') return null;
const jobDir = path.join(controlDir, `restore-${pending.restoreId}`);
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.rmSync(jobDir, { recursive: true, force: true });
fs.rmSync(pendingPath, { force: true });
const result = {
status: 'restored',
restoreId: pending.restoreId,
completedAt: Date.now(),
};
writeJson(lastResultPath, result);
return result;
}
function getLastRestoreResult() {
try {
return JSON.parse(fs.readFileSync(lastResultPath, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
module.exports = {
applyPendingRestore,
getLastRestoreResult,
markStartupSuccessful,
pendingPath,
writeJson,
};
@@ -8,7 +8,7 @@ module.exports = {
feature: true, feature: true,
defaultValue: { enabled: false, simulate: false }, defaultValue: { enabled: false, simulate: false },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Starts the Wii Balance Board service and exposes its readings and controls after restart.' }), enabled: boolean({ description: 'Immediately starts the Wii Balance Board service and exposes its readings and controls.' }),
simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }), simulate: boolean({ description: 'Runs the native worker with generated cyclic sensor data instead of connecting to Bluetooth hardware.' }),
}, { }, {
title: 'Balance Board', title: 'Balance Board',
@@ -7,15 +7,15 @@ const { promisify } = require('util');
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('balanceBoardService'); const logger = require('../../globals/logger').child('balanceBoardService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin } = require('../roleService'); const { isAdmin } = require('../roleService');
const { sendAlert } = require('../alertService'); const { sendAlert } = require('../alertService');
const { createBalanceBoardHardware } = require('./hardware'); const { createBalanceBoardHardware } = require('./hardware');
const events = new EventEmitter(); const events = new EventEmitter();
const rawConfig = loadConfig().balanceBoard || {}; let rawConfig = loadConfig().balanceBoard || {};
const enabled = Boolean(rawConfig.enabled); let enabled = Boolean(rawConfig.enabled);
const DATA_DIR = resolveDataDir(); const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json'); const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers'; const FRAME_ROOM = 'balance-board-viewers';
@@ -452,12 +452,14 @@ function handleWorkerMessage(message = {}) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => { socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
socket.join(FRAME_ROOM); socket.join(FRAME_ROOM);
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame); if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
cb({ success: true }); cb({ success: true });
}); });
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM)); socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => { socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) { if (!isAdmin(socket)) {
cb({ error: 'Admin access required' }); cb({ error: 'Admin access required' });
return; return;
@@ -470,6 +472,7 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => { socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) { if (!isAdmin(socket)) {
cb({ error: 'Admin access required' }); cb({ error: 'Admin access required' });
return; return;
@@ -484,6 +487,7 @@ io.on('connection', (socket) => {
} }
}); });
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => { socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'Balance Board is disabled' });
if (!isAdmin(socket)) { if (!isAdmin(socket)) {
cb({ error: 'Admin access required' }); cb({ error: 'Admin access required' });
return; return;
@@ -546,7 +550,7 @@ io.on('connection', (socket) => {
}); });
}); });
if (enabled) { function startHardware() {
hardware = createBalanceBoardHardware({ hardware = createBalanceBoardHardware({
logger, logger,
address: store.address, address: store.address,
@@ -554,10 +558,38 @@ if (enabled) {
}); });
hardware.events.on('message', handleWorkerMessage); hardware.events.on('message', handleWorkerMessage);
hardware.start(); hardware.start();
}
if (enabled) {
startHardware();
} else { } else {
logger.info('Balance Board disabled by config'); logger.info('Balance Board disabled by config');
} }
registerConfigurationHandler('balanceBoard', (nextConfig = {}) => {
const wasEnabled = enabled;
hardware?.stop();
hardware = null;
clearZeroTimer();
rawConfig = nextConfig;
enabled = Boolean(rawConfig.enabled);
if (!wasEnabled && enabled) store = loadStore();
connected = false;
batteryPercent = null;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (enabled) {
status = store.address ? 'waiting' : 'starting';
detail = store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.';
startHardware();
} else {
status = 'disabled';
detail = 'Balance Board support is disabled.';
}
events.emit('change', getState());
});
function installShutdownHooks() { function installShutdownHooks() {
const shutdown = () => { const shutdown = () => {
clearZeroTimer(); clearZeroTimer();
+31 -25
View File
@@ -5,7 +5,7 @@
// remain thin IO surfaces that subscribe to state and send votes/scans. // remain thin IO surfaces that subscribe to state and send votes/scans.
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeGameService'); const logger = require('../../globals/logger').child('barcodeGameService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { sendSystemMessage } = require('../chatService'); const { sendSystemMessage } = require('../chatService');
const { getActiveDrivers } = require('../turnService'); const { getActiveDrivers } = require('../turnService');
@@ -28,11 +28,17 @@ const RESULTS_WINDOW_MS = 45 * 1000;
const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems]; const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game])); const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
const config = loadConfig(); let enabled;
const barcodeGamesConfig = config.barcodeGames || {}; let botName;
const enabled = Boolean(barcodeGamesConfig.enabled); let botProfileImageUrl;
const botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null; function applyBarcodeGameConfig(barcodeGamesConfig = {}) {
enabled = Boolean(barcodeGamesConfig.enabled);
botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
}
applyBarcodeGameConfig(loadConfig().barcodeGames || {});
function sendBarcodeGameChat(text) { function sendBarcodeGameChat(text) {
const message = String(text || '').trim(); const message = String(text || '').trim();
@@ -767,6 +773,7 @@ function settleActiveGameIfNeeded() {
} }
function handleScan(scan) { function handleScan(scan) {
if (!enabled) return;
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now(); const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
withGameStore((draft) => { withGameStore((draft) => {
updateGlobalCounters(draft, scan, now); updateGlobalCounters(draft, scan, now);
@@ -1121,15 +1128,9 @@ function broadcastState() {
}); });
} }
if (enabled) { io.on('connection', (socket) => {
/*
Barcode games are an optional layer on top of the physical scanner station.
The game's own switch controls whether its sockets and subscriptions exist.
Scanner availability is runtime state and must not silently override the
operator's explicit choice to enable the game service.
*/
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => { socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode games disabled' });
socket.join(GAME_SOCKET_ROOM); socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload(socket); const state = buildStatePayload(socket);
socket.emit('barcodeGame:state', state); socket.emit('barcodeGame:state', state);
@@ -1137,6 +1138,7 @@ if (enabled) {
}); });
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => { socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode games disabled' });
try { try {
cb(setVote(socket, gameId)); cb(setVote(socket, gameId));
} catch (err) { } catch (err) {
@@ -1145,9 +1147,9 @@ if (enabled) {
} }
}); });
}); });
subscribe('barcode.scanned', (event) => { subscribe('barcode.scanned', (event) => {
try { try {
handleScan(event.payload); handleScan(event.payload);
} catch (err) { } catch (err) {
@@ -1155,21 +1157,25 @@ if (enabled) {
// are logged and skipped so the scanner page can keep resolving barcodes. // are logged and skipped so the scanner page can keep resolving barcodes.
logger.warn('Barcode game scan handling failed', { error: err.message }); logger.warn('Barcode game scan handling failed', { error: err.message });
} }
}); });
} else {
if (!enabled) {
logger.info('Barcode games disabled by config'); logger.info('Barcode games disabled by config');
} }
registerConfigurationHandler('barcodeGames', (barcodeGamesConfig = {}) => {
applyBarcodeGameConfig(barcodeGamesConfig);
broadcastState();
});
module.exports = { module.exports = {
buildStatePayload, buildStatePayload,
handleScan, handleScan,
setVote, setVote,
}; };
if (enabled) { setInterval(() => {
setInterval(() => { if (enabled && settleActiveGameIfNeeded()) {
if (settleActiveGameIfNeeded()) { broadcastState();
broadcastState(); }
} }, GAME_TICK_MS).unref?.();
}, GAME_TICK_MS).unref?.();
}
@@ -8,7 +8,7 @@ module.exports = {
feature: true, feature: true,
defaultValue: { enabled: false }, defaultValue: { enabled: false },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Registers barcode scanning, barcode administration, and scan-triggered server behavior after restart.' }), enabled: boolean({ description: 'Immediately enables barcode scanning, barcode administration, and scan-triggered server behavior.' }),
}, { }, {
title: 'Barcode scanner', title: 'Barcode scanner',
description: 'Optional physical barcode scanning and barcode registry service.', description: 'Optional physical barcode scanning and barcode registry service.',
@@ -4,7 +4,7 @@
const fs = require('fs'); const fs = require('fs');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService'); const logger = require('../../globals/logger').child('barcodeScannerService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
@@ -15,7 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
const RECENT_SCAN_LIMIT = 8; const RECENT_SCAN_LIMIT = 8;
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/; const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
const SCANNER_SOCKET_ROOM = 'barcode-scanner'; const SCANNER_SOCKET_ROOM = 'barcode-scanner';
const enabled = Boolean(loadConfig().barcodeScanner?.enabled); let enabled = Boolean(loadConfig().barcodeScanner?.enabled);
let lastKnownGoodRegistry = null; let lastKnownGoodRegistry = null;
let lastRegistryError = null; let lastRegistryError = null;
@@ -312,19 +312,16 @@ async function applyScan(rawCode) {
return { result }; return { result };
} }
if (enabled) { io.on('connection', (socket) => {
/*
Barcode scanning is tied to a physical scanner station. Disabled installs
should not create the registry file or expose scanner socket commands.
*/
io.on('connection', (socket) => {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => { socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode scanner disabled' });
socket.join(SCANNER_SOCKET_ROOM); socket.join(SCANNER_SOCKET_ROOM);
socket.emit('barcode:state', buildStatePayload()); socket.emit('barcode:state', buildStatePayload());
cb({ success: true, state: buildStatePayload() }); cb({ success: true, state: buildStatePayload() });
}); });
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => { socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
if (!enabled) return cb({ error: 'barcode scanner disabled' });
try { try {
const { result } = await applyScan(code); const { result } = await applyScan(code);
cb({ success: true, result, state: buildStatePayload() }); cb({ success: true, result, state: buildStatePayload() });
@@ -336,20 +333,28 @@ if (enabled) {
cb({ error: err.message || 'barcode scan failed' }); cb({ error: err.message || 'barcode scan failed' });
} }
}); });
}); });
modeEvents.on('change', () => { modeEvents.on('change', () => {
// Access-mode changes affect whether the scanner page should beep when it // Access-mode changes affect whether the scanner page should beep when it
// submits a code, so scanner clients need a fresh state packet even without a // submits a code, so scanner clients need a fresh state packet even without a
// new scan. // new scan.
broadcastState(); broadcastState();
}); });
if (enabled) {
loadRegistryForScan(); loadRegistryForScan();
} else { } else {
logger.info('Barcode scanner disabled by config'); logger.info('Barcode scanner disabled by config');
} }
registerConfigurationHandler('barcodeScanner', (scannerConfig = {}) => {
const wasEnabled = enabled;
enabled = Boolean(scannerConfig.enabled);
if (!wasEnabled && enabled) loadRegistryForScan();
broadcastState();
});
module.exports = { module.exports = {
REGISTRY_PATH, REGISTRY_PATH,
applyScan: (...args) => { applyScan: (...args) => {
@@ -8,7 +8,7 @@ module.exports = {
feature: true, feature: true,
defaultValue: { enabled: false }, defaultValue: { enabled: false },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Registers the physical button-box input route and enables its persistent button rewards and effects after restart.' }), enabled: boolean({ description: 'Immediately enables the physical button-box input route and its persistent button rewards and effects.' }),
}, { }, {
title: 'Button box', title: 'Button box',
description: 'Optional physical button-box input and reward system.', description: 'Optional physical button-box input and reward system.',
@@ -10,6 +10,7 @@ function registerButtonBoxRoute(deps) {
buttonCount, buttonCount,
normalizeIp, normalizeIp,
isLocalNetwork, isLocalNetwork,
isEnabled,
applyPress, applyPress,
} = deps; } = deps;
@@ -39,6 +40,13 @@ function registerButtonBoxRoute(deps) {
} }
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => { app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
// The route stays registered for the life of Express, but the service gate
// is evaluated per request so the physical endpoint enables and disables
// immediately without accumulating duplicate routes.
if (!isEnabled()) {
res.status(503).json({ error: 'Button box is disabled' });
return;
}
if (denyIfNotLocal(req, res)) return; if (denyIfNotLocal(req, res)) return;
const buttonId = parseButtonId(req.body); const buttonId = parseButtonId(req.body);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) { if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) {
+25 -15
View File
@@ -4,7 +4,7 @@
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('buttonBoxService'); const logger = require('../../globals/logger').child('buttonBoxService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const { getRewardById, listRewards } = require('../../rewards'); const { getRewardById, listRewards } = require('../../rewards');
@@ -30,7 +30,7 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json'); const STORE_PATH = resolveDataPath('buttonbox-state.json');
const BUTTON_COUNT = 4; const BUTTON_COUNT = 4;
const STORE_VERSION = 1; const STORE_VERSION = 1;
const enabled = Boolean(loadConfig().buttonBox?.enabled); let enabled = Boolean(loadConfig().buttonBox?.enabled);
const store = createButtonBoxStore({ const store = createButtonBoxStore({
logger, logger,
@@ -73,28 +73,38 @@ const core = createButtonBoxCore({
store, store,
}); });
if (enabled) { registerButtonBoxRoute({
/* app,
The button box is physical local hardware, so disabled public installs logger,
should not expose its LAN-only press endpoint or initialize its reward file. buttonCount: BUTTON_COUNT,
*/ normalizeIp,
registerButtonBoxRoute({ isLocalNetwork,
app, isEnabled: () => enabled,
logger, applyPress: core.applyPress,
buttonCount: BUTTON_COUNT, });
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
function enableButtonBox() {
store.loadState(); store.loadState();
core.recoverEffects().catch((err) => { core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message); logger.warn('Button box effect recovery failed', err.message);
}); });
}
if (enabled) {
enableButtonBox();
} else { } else {
logger.info('Button box disabled by config'); logger.info('Button box disabled by config');
} }
registerConfigurationHandler('buttonBox', (buttonBoxConfig = {}) => {
const wasEnabled = enabled;
enabled = Boolean(buttonBoxConfig.enabled);
// Persistent state is loaded only on the transition to enabled. The core has
// no long-running hardware client, so disabling is completely represented by
// the route and public-method gates.
if (!wasEnabled && enabled) enableButtonBox();
});
module.exports = { module.exports = {
getButtonBoxState: () => { getButtonBoxState: () => {
/* /*
@@ -43,11 +43,8 @@ const {
createReplaySourceResolver, createReplaySourceResolver,
} = require('../replayDeliveryService/workflow'); } = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function isTextCommand(text) { function isTextCommand(text) {
return parseCommandText(text, config).matched; return parseCommandText(text).matched;
} }
function sanitizeMentions(text) { function sanitizeMentions(text) {
@@ -142,6 +139,11 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
async function runChatTextCommand({ text, socket, sendSystemMessage }) { async function runChatTextCommand({ text, socket, sendSystemMessage }) {
if (!isTextCommand(text)) return false; if (!isTextCommand(text)) return false;
// Commands are assembled per message already, so reading the live snapshot
// here applies prefix, URL, and integration settings without retaining a
// stale dependency object between configuration revisions.
const config = loadConfig();
const discordConfig = config.discord || {};
// ReplayEngineV2 has startup side effects by design. Loading it lazily here // ReplayEngineV2 has startup side effects by design. Loading it lazily here
// keeps ordinary chatService initialization from changing the service boot // keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline. // order, while still letting `rs replay` use the existing replay pipeline.
@@ -201,7 +203,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig, discordConfig,
siteUrl: String(discordConfig.siteUrl || ''), publicUrl: String(config.publicUrl || ''),
config, config,
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi), createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
}; };
@@ -33,7 +33,7 @@ function createReplayCommand({
getActiveDrivers, getActiveDrivers,
getNickname, getNickname,
rovers, rovers,
discordConfig, config,
}) { }) {
const sourceResolver = createReplaySourceResolver({ const sourceResolver = createReplaySourceResolver({
rovers, rovers,
@@ -137,8 +137,8 @@ function createReplayCommand({
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} }
const siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, ''); const publicBaseUrl = String(config?.publicUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url; const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS });
return; return;
} catch (fallbackError) { } catch (fallbackError) {
@@ -3,12 +3,12 @@
// Scope: Builds a concise time embed for common zones and server local zone. // Scope: Builds a concise time embed for common zones and server local zone.
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
function createTimeStatusCommand({ config, discordConfig }) { function createTimeStatusCommand({ config }) {
function buildEmbed({ title, description, color, includeSiteUrl = true }) { function buildEmbed({ title, description, color, includeSiteUrl = true }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : '';
if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description);
else if (siteUrl) embed.setDescription(siteUrl); else if (publicUrl) embed.setDescription(publicUrl);
embed.setTimestamp(new Date()); embed.setTimestamp(new Date());
return embed; return embed;
} }
@@ -12,7 +12,6 @@ module.exports = {
enabled: false, enabled: false,
token: '', token: '',
guildId: '123456789012345678', guildId: '123456789012345678',
siteUrl: 'https://rover.example.com',
channels: { channels: {
general: '123456789012345678', general: '123456789012345678',
announcements: '123456789012345678', announcements: '123456789012345678',
@@ -28,10 +27,9 @@ module.exports = {
}, },
}, },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Logs the Discord bot in and enables commands, chat bridges, replay delivery, and configured announcements after restart.' }), enabled: boolean({ description: 'Logs the Discord bot in and immediately enables commands, chat bridges, replay delivery, and configured announcements.' }),
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }), token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }), guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
siteUrl: string({ title: 'Public site URL', description: 'Public base URL appended to announcement embeds and server-hosted replay links.', examples: ['https://rover.example.com'], maxLength: 2048 }),
channels: strictObject({ channels: strictObject({
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }), general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }), announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
@@ -55,7 +53,7 @@ module.exports = {
}), }),
}, { }, {
title: 'Discord', title: 'Discord',
description: 'Optional Discord bot credentials, public URL, and notification routing.', description: 'Optional Discord bot credentials and notification routing; public links use the top-level public URL.',
required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'], required: ['enabled', 'token', 'guildId', 'channels', 'roles'],
}), }),
}; };
+108 -33
View File
@@ -9,7 +9,12 @@ const {
} = require('discord.js'); } = require('discord.js');
const logger = require('../../globals/logger').child('discordBot'); const logger = require('../../globals/logger').child('discordBot');
const io = require('../../globals/io'); const io = require('../../globals/io');
const { loadConfig, getConfigurationDatabase, isFeatureEnabled } = require('../../configuration'); const {
loadConfig,
getConfigurationDatabase,
isFeatureEnabled,
registerConfigurationHandler,
} = require('../../configuration');
const { parseCommandText } = require('../operatorCommandService/config'); const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager; const { getRoster, lockRover, rovers } = roverManager;
@@ -79,20 +84,15 @@ const {
buildStatusMessage, buildStatusMessage,
} = require('../replayDeliveryService/workflow'); } = require('../replayDeliveryService/workflow');
const config = loadConfig(); // Discord helper modules retain references to these objects. Mutating those
// references on configuration application updates command and integration
// behavior without registering a second tree of Discord/event listeners.
const config = structuredClone(loadConfig());
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.enabled); let enabled = Boolean(discordConfig.enabled);
// These normalized command names mirror the command router. Bridge-channel const configurationDatabase = getConfigurationDatabase();
// 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));
if (!enabled) { if (!enabled) logger.info('Discord disabled by config');
logger.info('Discord disabled by config');
return;
}
const intents = [ const intents = [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -117,12 +117,34 @@ function sanitizeMentions(text) {
.replace(/@here/gi, '[here]'); .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) { function isAdminUser(discordId) {
return adminIds.has(String(discordId || '').trim()); return Boolean(findDiscordAdministrator(discordId));
} }
function isLockdownAdminUser(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() { function countReady() {
@@ -157,10 +179,10 @@ const replayCaption = createReplayCaptionBuilder({
// Discord is the preferred replay host only while this optional feature is // Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically // active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails. // falls back to its local media store when any operation below fails.
if (discordConfig?.channels?.replay) { registerPreferredDeliveryProvider({
registerPreferredDeliveryProvider({
async begin(job) { async begin(job) {
const channelId = discordConfig.channels.replay; const channelId = discordConfig.channels?.replay;
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS); const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent'); if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId); const channel = await channelIO.fetchChannel(channelId);
@@ -201,8 +223,8 @@ if (discordConfig?.channels?.replay) {
} }
}, },
async completeFallback({ context, media }) { async completeFallback({ context, media }) {
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, ''); const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url; const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) { if (context?.progressMessage?.reply) {
await context.progressMessage.reply({ await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`, content: `Replay hosted by the rover server: ${publicUrl}`,
@@ -210,8 +232,7 @@ if (discordConfig?.channels?.replay) {
}); });
} }
}, },
}); });
}
const commandDependencies = { const commandDependencies = {
logger, logger,
@@ -297,7 +318,7 @@ const commands = createCommandHandlers(commandDependencies);
getPrivateAccessRequestByMessageId, getPrivateAccessRequestByMessageId,
approvePrivateAccessRequest, approvePrivateAccessRequest,
denyPrivateAccessRequest, denyPrivateAccessRequest,
lockdownAdminIds, getLockdownAdminIds,
isAdminUser, isAdminUser,
isLockdownAdminUser, isLockdownAdminUser,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
@@ -366,24 +387,78 @@ client.on('messageCreate', async (message) => {
} }
}); });
client.once('ready', () => { let fleetDailyReports = null;
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation(); function restartFleetDailyReports() {
// Discord is only a delivery consumer. Starting its scheduler after the bot fleetDailyReports?.stop();
// is ready avoids failed sends during login while the collector continues to fleetDailyReports = createFleetDailyReports({
// operate independently of Discord availability.
createFleetDailyReports({
logger, logger,
discordConfig, discordConfig,
fleetConfig: config.fleetReports || {}, fleetConfig: config.fleetReports || {},
fleetReportService, fleetReportService,
roverManager, roverManager,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
}).start(); });
fleetDailyReports.start();
}
client.on('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
restartFleetDailyReports();
}); });
client.login(discordConfig.token).catch((err) => { function replaceObject(target, source = {}) {
logger.error('Discord login failed', err.message); Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, structuredClone(source));
}
function applyDiscordConfig(nextDiscordConfig = {}) {
const wasEnabled = enabled;
const previousToken = discordConfig.token;
replaceObject(discordConfig, nextDiscordConfig);
config.discord = discordConfig;
enabled = Boolean(discordConfig.enabled);
if (!enabled) {
fleetDailyReports?.stop();
fleetDailyReports = null;
if (wasEnabled) client.destroy();
return;
}
if (!wasEnabled || previousToken !== discordConfig.token) {
if (wasEnabled) client.destroy();
// Login health is reported by Discord itself; do not hold the committed
// configuration request open while an external network service connects.
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed after configuration change', err.message);
});
} else if (client.isReady()) {
restartFleetDailyReports();
}
}
function applySharedConfigSection(section, value) {
config[section] = structuredClone(value);
// Fleet delivery owns a timer derived from both Discord and fleet settings.
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
if (section === 'fleetReports' && client.isReady()) {
restartFleetDailyReports();
}
}
registerConfigurationHandler('discord', applyDiscordConfig);
['commands', 'publicUrl', 'timezone', 'fleetReports'].forEach((section) => {
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
}); });
if (enabled) {
client.login(discordConfig.token).catch((err) => {
logger.error('Discord login failed', err.message);
});
}
module.exports = {}; module.exports = {};
@@ -5,15 +5,15 @@ const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds'); const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
function createBusEventHandler(deps) { function createBusEventHandler(deps) {
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps; const { logger, config, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']); const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
let skippedFirstModeAnnouncement = false; let skippedFirstModeAnnouncement = false;
function buildEmbed({ title, description, color, includeSiteUrl = true }) { function buildEmbed({ title, description, color, includeSiteUrl = true }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : '';
if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description);
else if (siteUrl) embed.setDescription(siteUrl); else if (publicUrl) embed.setDescription(publicUrl);
embed.setTimestamp(new Date()); embed.setTimestamp(new Date());
return embed; return embed;
} }
@@ -5,7 +5,7 @@ function createDmModerationHandlers(deps) {
const { const {
logger, logger,
client, client,
lockdownAdminIds, getLockdownAdminIds,
attachDmMessage, attachDmMessage,
getRequestByMessageId, getRequestByMessageId,
approveRequest, approveRequest,
@@ -36,7 +36,9 @@ function createDmModerationHandlers(deps) {
'', '',
`React with ${APPROVE} to approve or ${DENY} to deny.`, `React with ${APPROVE} to approve or ${DENY} to deny.`,
].join('\n'); ].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 { try {
const user = await client.users.fetch(String(adminId)); const user = await client.users.fetch(String(adminId));
if (!user) return; if (!user) return;
@@ -69,7 +71,9 @@ function createDmModerationHandlers(deps) {
'', '',
`React with ${APPROVE} to approve or ${DENY} to deny.`, `React with ${APPROVE} to approve or ${DENY} to deny.`,
].join('\n'); ].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 { try {
const user = await client.users.fetch(String(adminId)); const user = await client.users.fetch(String(adminId));
if (!user) return; if (!user) return;
@@ -14,6 +14,7 @@ const WATCHED_EVENT_TYPES = new Set([
function createUserAnnouncements(deps) { function createUserAnnouncements(deps) {
const { const {
discordConfig, discordConfig,
config,
getMode, getMode,
rovers, rovers,
roverManager, roverManager,
@@ -22,9 +23,12 @@ function createUserAnnouncements(deps) {
schedulePresenceRotation, schedulePresenceRotation,
} = deps; } = deps;
const announcementChannelId = discordConfig?.channels?.announcements || null; // The parent Discord service preserves this object identity and updates its
const announcementRoleId = discordConfig?.roles?.announcementPing || null; // contents on live configuration application. Resolve individual values at
const siteUrl = discordConfig?.siteUrl ? String(discordConfig.siteUrl) : ''; // send/render time so announcements do not retain stale channel or site data.
const getAnnouncementChannelId = () => discordConfig?.channels?.announcements || null;
const getAnnouncementRoleId = () => discordConfig?.roles?.announcementPing || null;
const getPublicUrl = () => (config?.publicUrl ? String(config.publicUrl) : '');
let previousSnapshot = buildSnapshot(); let previousSnapshot = buildSnapshot();
let skippedFirstModeChange = false; let skippedFirstModeChange = false;
@@ -124,10 +128,11 @@ function createUserAnnouncements(deps) {
}); });
} }
if (siteUrl) { const publicUrl = getPublicUrl();
if (publicUrl) {
embed.addFields({ embed.addFields({
name: 'Join', name: 'Join',
value: siteUrl, value: publicUrl,
inline: false, inline: false,
}); });
} }
@@ -136,6 +141,8 @@ function createUserAnnouncements(deps) {
} }
async function sendAnnouncement({ content, embeds, ping = false }) { async function sendAnnouncement({ content, embeds, ping = false }) {
const announcementChannelId = getAnnouncementChannelId();
const announcementRoleId = getAnnouncementRoleId();
if (!announcementChannelId) return; if (!announcementChannelId) return;
const shouldPing = Boolean(ping && announcementRoleId); const shouldPing = Boolean(ping && announcementRoleId);
const body = shouldPing ? `<@&${announcementRoleId}> ${content || ''}`.trim() : content; const body = shouldPing ? `<@&${announcementRoleId}> ${content || ''}`.trim() : content;
@@ -14,7 +14,7 @@ module.exports = {
privacy: { retainChatBodies: true }, privacy: { retainChatBodies: true },
}, },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery after restart.' }), enabled: boolean({ description: 'Immediately starts persistent fleet metric collection, reports, retention cleanup, and configured daily delivery.' }),
retention: strictObject({ retention: strictObject({
detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }), detailedDays: integer({ description: 'Days to retain detailed events, command observations, sessions, and other non-minute fleet records. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }), minuteSamplesDays: integer({ description: 'Days to retain per-minute rover metric aggregates. Zero retains them indefinitely.', minimum: 0, maximum: 36500 }),
+96 -66
View File
@@ -1,25 +1,44 @@
// Fleet Report Service // Fleet Report Service
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport. // Purpose: Owns the replaceable collection/report runtime and its stable browser API.
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets. // Scope: Applies the complete fleetReports section without restarting the Node process.
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const logger = require('../../globals/logger').child('fleetReportService'); const logger = require('../../globals/logger').child('fleetReportService');
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
const config = loadConfig().fleetReports || {}; let runtime = null;
let storage = null;
if (!config.enabled) { function retentionDays(value, fallback) {
module.exports = { const number = Number(value);
enabled: false, return Number.isFinite(number) && number >= 0 ? number : fallback;
getDailyReport: () => null, }
};
} else { function stopRuntime() {
const { subscribeAll } = require('../eventBus'); if (!runtime) return;
const roverManager = require('../roverManager'); runtime.unsubscribeEvents();
const { commandEvents } = require('../commandService'); if (runtime.batteryEnabled) roverManager.managerEvents.off('sensor', runtime.collector.collectSensor);
const { odometerEvents } = require('../odometerService'); commandEvents.off('observation', runtime.collector.collectCommand);
const { createStorage } = require('./storage'); odometerEvents.off('update', runtime.collector.collectOdometer);
const { createCollector } = require('./collector'); runtime.managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
const { createReportBuilder } = require('./reportBuilder'); clearInterval(runtime.flushTimer);
const { registerSocketGateway } = require('./socketGateway'); clearInterval(runtime.retentionTimer);
runtime.collector.flushMinutes();
runtime = null;
}
function startRuntime(config = {}) {
stopRuntime();
if (!config.enabled) {
logger.info('Fleet reporting disabled by config');
return;
}
const batteryConfig = config.battery || {}; const batteryConfig = config.battery || {};
const retentionConfig = config.retention || {}; const retentionConfig = config.retention || {};
@@ -31,11 +50,14 @@ if (!config.enabled) {
10, 10,
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60), Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
); );
// Battery collection follows its own explicit nested switch. Defaults are
// supplied by the validated configuration document, so a missing value does
// not need a compatibility fallback that could accidentally enable it.
const batteryEnabled = Boolean(batteryConfig.enabled); const batteryEnabled = Boolean(batteryConfig.enabled);
const storage = createStorage({ logger });
// Keep one SQLite connection for the process lifetime. Configuration reloads
// replace collectors and timers, not the durable database they share.
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
const collector = createCollector({ const collector = createCollector({
storage, storage,
logger, logger,
@@ -43,8 +65,6 @@ if (!config.enabled) {
minimumCapacityTestDepthPercent, minimumCapacityTestDepthPercent,
}); });
const reportBuilder = createReportBuilder({ storage, collector, roverManager }); const reportBuilder = createReportBuilder({ storage, collector, roverManager });
storage.open();
const unsubscribeEvents = subscribeAll(collector.collectEvent); const unsubscribeEvents = subscribeAll(collector.collectEvent);
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor); if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
commandEvents.on('observation', collector.collectCommand); commandEvents.on('observation', collector.collectCommand);
@@ -55,18 +75,9 @@ if (!config.enabled) {
roverManager.managerEvents.on(kind, handler); roverManager.managerEvents.on(kind, handler);
return [kind, handler]; return [kind, handler];
})); }));
registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger });
// Periodic upserts bound data-loss on an unclean shutdown while still
// avoiding writes at the 20 Hz sensor-frame rate.
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000); const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
flushTimer.unref?.(); flushTimer.unref?.();
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function pruneNow() { function pruneNow() {
const now = Date.now(); const now = Date.now();
const detailedDays = retentionDays(retentionConfig.detailedDays, 0); const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
@@ -80,43 +91,62 @@ if (!config.enabled) {
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000); const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
retentionTimer.unref?.(); retentionTimer.unref?.();
function getDailyReport({ since, until, roverIds } = {}) { runtime = {
const end = Number(until) || Date.now(); batteryEnabled,
return reportBuilder.build({ storage,
since: Number(since) || end - 24 * 60 * 60 * 1000, collector,
until: end, reportBuilder,
roverIds: Array.isArray(roverIds) ? roverIds : undefined, unsubscribeEvents,
// Daily Discord output is intentionally metric-only. Avoiding the event managerEventHandlers,
// query here also prevents irrelevant event volume from bloating the flushTimer,
// durable daily snapshot that supports delivery idempotency. retentionTimer,
includeEvents: false, };
});
}
logger.info('Fleet reporting enabled', { logger.info('Fleet reporting enabled', {
databaseAvailable: storage.getDiagnostics().available, databaseAvailable: storage.getDiagnostics().available,
maximumIntegrationGapMs, maximumIntegrationGapMs,
minimumCapacityTestDepthPercent, minimumCapacityTestDepthPercent,
batteryEnabled, batteryEnabled,
}); });
module.exports = {
enabled: true,
getDailyReport,
collector,
storage,
reportBuilder,
// Exposed for controlled tests and graceful future shutdown wiring. Normal
// runtime leaves subscriptions active for the lifetime of the server.
stop() {
unsubscribeEvents();
if (batteryEnabled) roverManager.managerEvents.off('sensor', collector.collectSensor);
commandEvents.off('observation', collector.collectCommand);
odometerEvents.off('update', collector.collectOdometer);
managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(flushTimer);
clearInterval(retentionTimer);
collector.flushMinutes();
},
};
} }
registerSocketGateway({ roverManager, getRuntime: () => runtime, logger });
startRuntime(loadConfig().fleetReports || {});
registerConfigurationHandler('fleetReports', startRuntime);
module.exports = {
get enabled() {
return Boolean(runtime);
},
getDailyReport({ since, until, roverIds } = {}) {
if (!runtime) return null;
const end = Number(until) || Date.now();
return runtime.reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
includeEvents: false,
});
},
get collector() {
return runtime?.collector || null;
},
get storage() {
return runtime?.storage || storage;
},
get reportBuilder() {
return runtime?.reportBuilder || null;
},
backupDatabase(destinationPath) {
/*
Backups include reporting history even when collection is currently
disabled. Lazily opening the existing store keeps this one operation
behind the report service's normal database ownership boundary.
*/
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
return storage.backupDatabase(destinationPath);
},
stop: stopRuntime,
};
@@ -17,10 +17,13 @@ function normalizeRange(payload = {}) {
return { since, until: Math.max(since + 1, until) }; return { since, until: Math.max(since + 1, until) };
} }
function registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger }) { function registerSocketGateway({ roverManager, getRuntime, logger }) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => { socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
try { try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { reportBuilder } = runtime;
const { since, until } = normalizeRange(payload); const { since, until } = normalizeRange(payload);
// getRosterForSocket is the canonical live private-rover visibility // getRosterForSocket is the canonical live private-rover visibility
// resolver. Historical queries use precisely those currently visible // resolver. Historical queries use precisely those currently visible
@@ -59,6 +62,9 @@ function registerSocketGateway({ roverManager, reportBuilder, storage, collector
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => { socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
try { try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { storage, collector } = runtime;
if (!isAdmin(socket)) throw new Error('Admin access required'); if (!isAdmin(socket)) throw new Error('Admin access required');
const roverId = String(payload.roverId || '').trim(); const roverId = String(payload.roverId || '').trim();
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required'); if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
@@ -508,6 +508,16 @@ function createStorage({ logger }) {
}), { available: false, path: DB_PATH }); }), { available: false, path: DB_PATH });
} }
function backupDatabase(destinationPath) {
/*
Fleet collection may continue during an online SQLite backup. Each
resulting database file represents a valid point-in-time snapshot even
when new telemetry commits before the copy completes.
*/
if (!open()) throw new Error('Fleet report database is unavailable.');
return db.backup(destinationPath);
}
return { return {
open, open,
insertEvent, insertEvent,
@@ -525,6 +535,7 @@ function createStorage({ logger }) {
getActiveBattery, getActiveBattery,
replaceBattery, replaceBattery,
getDiagnostics, getDiagnostics,
backupDatabase,
}; };
} }
+47 -1
View File
@@ -2,8 +2,9 @@
// Purpose: Defines the health Service module and the helpers/state used by this service unit. // 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. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const fsp = require('fs/promises'); const fsp = require('fs/promises');
const fs = require('fs');
const path = require('path'); const path = require('path');
const { resolveRoverSnapshotDir } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveRoverSnapshotDir } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService'); const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService'); const { getRoomCameraState } = require('../roomCameraService');
@@ -13,6 +14,8 @@ const ROVER_SNAPSHOT_DIR = resolveRoverSnapshotDir();
const HEALTH_INTERVAL_MS = 5000; const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000; const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_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 = { let latest = {
updatedAt: Date.now(), updatedAt: Date.now(),
@@ -88,6 +91,49 @@ function getHealthSnapshot() {
return latest; 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 = { module.exports = {
getContainerHealth,
getHealthSnapshot, getHealthSnapshot,
}; };
@@ -52,7 +52,7 @@ module.exports = {
], ],
}, },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls after restart.' }), enabled: boolean({ description: 'Immediately connects to Home Assistant and enables configured room entities, physical-button triggers, Neato controls, and lift controls.' }),
url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }), url: string({ title: 'Server URL', description: 'Base URL of the Home Assistant server used for its REST and WebSocket APIs.', format: 'uri', maxLength: 2048 }),
token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }), token: string({ title: 'Long-lived access token', description: 'Home Assistant long-lived access token used to authenticate every API request. The saved value is never returned to the browser.', examples: ['REPLACE_WITH_LONG_LIVED_TOKEN'], writeOnly: true, maxLength: 20000 }),
[neato.key]: neato.schema, [neato.key]: neato.schema,
@@ -8,7 +8,7 @@ const { isAdmin, isLockdownAdmin } = require('../roleService');
function registerHomeAssistantHooks(deps) { function registerHomeAssistantHooks(deps) {
const { const {
logger, logger,
haConfig, getHaConfig,
isLightControlLocked, isLightControlLocked,
setLightsLockedOn, setLightsLockedOn,
toggleEntity, toggleEntity,
@@ -110,7 +110,9 @@ function registerHomeAssistantHooks(deps) {
} }
try { try {
if (!entityId) throw new Error('entityId required'); if (!entityId) throw new Error('entityId required');
await setLightWhite(entityId, haConfig?.whiteKelvin); // Resolve configuration at interaction time because the socket handler
// is intentionally registered once and survives service reloads.
await setLightWhite(entityId, getHaConfig()?.whiteKelvin);
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
cb({ error: err.message }); cb({ error: err.message });
@@ -2,83 +2,88 @@
// Purpose: Composes Home Assistant transport, runtime automation engine, and event/socket hooks. // Purpose: Composes Home Assistant transport, runtime automation engine, and event/socket hooks.
// Scope: Exposes stable room-control APIs while delegating internals to focused modules. // Scope: Exposes stable room-control APIs while delegating internals to focused modules.
const logger = require('../../globals/logger').child('homeAssistantService'); const logger = require('../../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { events } = require('./state'); const { events } = require('./state');
const { createRuntimeEngine } = require('./runtimeEngine'); const { createRuntimeEngine } = require('./runtimeEngine');
const { createTransport } = require('./transport'); const { createTransport } = require('./transport');
const { registerHomeAssistantHooks } = require('./hooks'); const { registerHomeAssistantHooks } = require('./hooks');
const config = loadConfig(); let current;
const haConfig = config.homeAssistant || {};
const enabled = Boolean(haConfig.enabled);
let callHomeAssistantServiceImpl = async () => { function createHomeAssistantRuntime(haConfig = {}) {
throw new Error('Home Assistant not connected'); const enabled = Boolean(haConfig.enabled);
}; let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected');
const runtimeEngine = createRuntimeEngine({ };
logger, const runtimeEngine = createRuntimeEngine({
enabled,
haConfig,
callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
});
const transport = createTransport({
logger,
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig();
if (enabled) {
/*
Loading the module should be harmless on rover-only installs. The explicit
service-owned switch alone decides whether connection should be attempted;
missing credentials are then reported as a runtime connection failure.
*/
transport.connect();
}
if (enabled) {
/*
Socket routes are part of the visible Home Assistant feature. Register them
only when enabled so disabled installs do not expose hidden controls that
the UI has intentionally removed.
*/
registerHomeAssistantHooks({
logger, logger,
enabled,
haConfig, haConfig,
isLightControlLocked: runtimeEngine.isLightControlLocked, callHomeAssistantService: (...args) => callHomeAssistantServiceImpl(...args),
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleEntity: runtimeEngine.toggleEntity,
setEntityState: runtimeEngine.setEntityState,
setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite,
}); });
const transport = createTransport({
logger,
enabled,
haConfig,
onSnapshot: runtimeEngine.handleEntitySnapshot,
onStatus: () => runtimeEngine.emitStatus(runtimeEngine.getState),
});
callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig();
if (enabled) {
// A service reload creates one fresh transport with the new credentials and
// entity schema. Disabled installations perform no network work.
transport.connect();
}
return { enabled, haConfig, runtimeEngine, transport };
} }
function replaceHomeAssistantRuntime(haConfig) {
current?.transport.disconnect();
current = createHomeAssistantRuntime(haConfig);
}
replaceHomeAssistantRuntime(loadConfig().homeAssistant || {});
/*
Browser and mode hooks are registered exactly once. Their delegates resolve
`current` for every call, so a configuration save does not duplicate socket
listeners while still routing existing connections into the new runtime.
*/
registerHomeAssistantHooks({
logger,
getHaConfig: () => current.haConfig,
isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
setLightsLockedOn: (...args) => current.runtimeEngine.setLightsLockedOn(...args),
toggleEntity: (...args) => current.runtimeEngine.toggleEntity(...args),
setEntityState: (...args) => current.runtimeEngine.setEntityState(...args),
setLightColor: (...args) => current.runtimeEngine.setLightColor(...args),
setLightWhite: (...args) => current.runtimeEngine.setLightWhite(...args),
});
registerConfigurationHandler('homeAssistant', (haConfig) => {
replaceHomeAssistantRuntime(haConfig || {});
});
module.exports = { module.exports = {
getState: runtimeEngine.getState, getState: (...args) => current.runtimeEngine.getState(...args),
isConnected: transport.isConnected, isConnected: (...args) => current.transport.isConnected(...args),
enabled, get enabled() {
getLightPolicyState: runtimeEngine.getLightPolicyState, return current.enabled;
isLightControlLocked: runtimeEngine.isLightControlLocked, },
getRawEntitySnapshot: runtimeEngine.getRawEntitySnapshot, getLightPolicyState: (...args) => current.runtimeEngine.getLightPolicyState(...args),
getControllableEntityIds: runtimeEngine.getControllableEntityIds, isLightControlLocked: (...args) => current.runtimeEngine.isLightControlLocked(...args),
callHomeAssistantService: transport.callHomeAssistantService, getRawEntitySnapshot: (...args) => current.runtimeEngine.getRawEntitySnapshot(...args),
toggleEntity: runtimeEngine.toggleEntity, getControllableEntityIds: (...args) => current.runtimeEngine.getControllableEntityIds(...args),
setEntityState: runtimeEngine.setEntityState, callHomeAssistantService: (...args) => current.transport.callHomeAssistantService(...args),
setLightColor: runtimeEngine.setLightColor, toggleEntity: (...args) => current.runtimeEngine.toggleEntity(...args),
setLightWhite: runtimeEngine.setLightWhite, setEntityState: (...args) => current.runtimeEngine.setEntityState(...args),
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState, setLightColor: (...args) => current.runtimeEngine.setLightColor(...args),
setRandomColorScene: runtimeEngine.setRandomColorScene, setLightWhite: (...args) => current.runtimeEngine.setLightWhite(...args),
setLightsLockedOn: runtimeEngine.setLightsLockedOn, setAllControllableEntitiesState: (...args) => current.runtimeEngine.setAllControllableEntitiesState(...args),
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn, setRandomColorScene: (...args) => current.runtimeEngine.setRandomColorScene(...args),
setLightsLockedOn: (...args) => current.runtimeEngine.setLightsLockedOn(...args),
toggleLightsLockedOn: (...args) => current.runtimeEngine.toggleLightsLockedOn(...args),
homeAssistantEvents: events, homeAssistantEvents: events,
}; };
@@ -11,6 +11,9 @@ if (!global.WebSocket) {
function createTransport(deps) { function createTransport(deps) {
const { logger, enabled, haConfig, onSnapshot, onStatus } = deps; const { logger, enabled, haConfig, onSnapshot, onStatus } = deps;
let active = true;
let connection = null;
let unsubscribeEntities = null;
function getCallerFrame() { function getCallerFrame() {
const stack = new Error().stack || ''; const stack = new Error().stack || '';
const lines = stack.split('\n').slice(2).map((line) => line.trim()); const lines = stack.split('\n').slice(2).map((line) => line.trim());
@@ -37,33 +40,40 @@ function createTransport(deps) {
} }
function teardownConnection() { function teardownConnection() {
if (runtime.unsubscribeEntities) { const ownedUnsubscribe = unsubscribeEntities;
unsubscribeEntities = null;
if (ownedUnsubscribe) {
try { try {
runtime.unsubscribeEntities(); ownedUnsubscribe();
} catch (err) { } catch (err) {
logger.warn('Failed to unsubscribe entity stream', err.message); logger.warn('Failed to unsubscribe entity stream', err.message);
} }
} }
runtime.unsubscribeEntities = null; const ownedConnection = connection;
connection = null;
if (runtime.connection) { if (ownedConnection) {
try { try {
runtime.connection.close(); ownedConnection.close();
} catch (err) { } catch (err) {
logger.warn('Error closing Home Assistant connection', err.message); logger.warn('Error closing Home Assistant connection', err.message);
} }
} }
runtime.connection = null; // An old transport's delayed disconnected event must not clear the newer
const wasConnected = runtime.connected; // transport stored in shared runtime state after a configuration reload.
runtime.connected = false; if (runtime.connection === ownedConnection) {
if (wasConnected) { runtime.connection = null;
onStatus(); runtime.unsubscribeEntities = null;
const wasConnected = runtime.connected;
runtime.connected = false;
if (wasConnected) onStatus();
} }
} }
function scheduleReconnect(delayMs = 5000) { function scheduleReconnect(delayMs = 5000) {
if (!enabled) return; // A replaced transport must never reconnect after its successor has taken
// ownership of the shared Home Assistant connection state.
if (!active || !enabled) return;
if (runtime.reconnectTimer) return; if (runtime.reconnectTimer) return;
runtime.reconnectTimer = setTimeout(() => { runtime.reconnectTimer = setTimeout(() => {
runtime.reconnectTimer = null; runtime.reconnectTimer = null;
@@ -72,23 +82,30 @@ function createTransport(deps) {
} }
async function connect() { async function connect() {
if (!enabled) { if (!active || !enabled) {
// Disabled and misconfigured are intentionally different states. The // Disabled and misconfigured are intentionally different states. The
// explicit switch prevents connection attempts; missing credentials are // explicit switch prevents connection attempts; missing credentials are
// surfaced by buildAuth() as a runtime connection failure when enabled. // surfaced by buildAuth() as a runtime connection failure when enabled.
logger.info('Home Assistant disabled by config'); logger.info('Home Assistant disabled by config');
return; return;
} }
if (runtime.connection) return; if (connection) return;
try { try {
const auth = buildAuth(); const auth = buildAuth();
runtime.connection = await createConnection({ auth, setupRetry: 0 }); const nextConnection = await createConnection({ auth, setupRetry: 0 });
if (!active) {
nextConnection.close();
return;
}
connection = nextConnection;
runtime.connection = connection;
runtime.connected = true; runtime.connected = true;
onStatus(); onStatus();
logger.info('Connected to Home Assistant'); logger.info('Connected to Home Assistant');
runtime.unsubscribeEntities = subscribeEntities(runtime.connection, onSnapshot); unsubscribeEntities = subscribeEntities(connection, onSnapshot);
runtime.connection.addEventListener('disconnected', () => { runtime.unsubscribeEntities = unsubscribeEntities;
connection.addEventListener('disconnected', () => {
logger.warn('Home Assistant connection lost'); logger.warn('Home Assistant connection lost');
teardownConnection(); teardownConnection();
scheduleReconnect(); scheduleReconnect();
@@ -101,12 +118,12 @@ function createTransport(deps) {
} }
function isConnected() { function isConnected() {
return Boolean(runtime.connection && runtime.connected); return Boolean(connection && runtime.connection === connection && runtime.connected);
} }
async function callHomeAssistantService(domain, service, serviceData = {}) { async function callHomeAssistantService(domain, service, serviceData = {}) {
if (!enabled) throw new Error('Home Assistant not configured'); if (!active || !enabled) throw new Error('Home Assistant not configured');
if (!runtime.connection) throw new Error('Home Assistant not connected'); if (!connection || runtime.connection !== connection) throw new Error('Home Assistant not connected');
if (!domain || !service) throw new Error('domain and service required'); if (!domain || !service) throw new Error('domain and service required');
logger.info('Home Assistant outbound service call', { logger.info('Home Assistant outbound service call', {
domain: String(domain), domain: String(domain),
@@ -114,11 +131,24 @@ function createTransport(deps) {
serviceData: serviceData && typeof serviceData === 'object' ? { ...serviceData } : serviceData, serviceData: serviceData && typeof serviceData === 'object' ? { ...serviceData } : serviceData,
caller: getCallerFrame(), caller: getCallerFrame(),
}); });
await callService(runtime.connection, String(domain), String(service), serviceData || {}); await callService(connection, String(domain), String(service), serviceData || {});
}
function disconnect() {
// Configuration reloads deliberately retire the complete transport. Clear
// its pending retry before closing so the old credentials cannot race the
// newly created transport and reclaim the shared connection.
active = false;
if (runtime.reconnectTimer) {
clearTimeout(runtime.reconnectTimer);
runtime.reconnectTimer = null;
}
teardownConnection();
} }
return { return {
connect, connect,
disconnect,
isConnected, isConnected,
callHomeAssistantService, callHomeAssistantService,
}; };
+21
View File
@@ -5,6 +5,7 @@ const { httpServer } = require('../../globals/http');
const config = require('../../globals/config'); const config = require('../../globals/config');
const logger = require('../../globals/logger').child('httpServer'); const logger = require('../../globals/logger').child('httpServer');
const { startMediaMtx } = require('../mediaMtxService'); const { startMediaMtx } = require('../mediaMtxService');
const backupRestoreService = require('../backupRestoreService');
httpServer.listen(config.port, () => { httpServer.listen(config.port, () => {
logger.info(`Server listening on :${config.port}`); logger.info(`Server listening on :${config.port}`);
@@ -14,4 +15,24 @@ httpServer.listen(config.port, () => {
first publisher attempts to authenticate. first publisher attempts to authenticate.
*/ */
startMediaMtx(); startMediaMtx();
/*
Give child processes and startup integrations a short stabilization window
after restored databases migrate and HTTP begins listening. If the process
exits during that window, earliest startup sees the awaiting-health marker
and restores the prior data instead of accepting a broken replacement.
*/
setTimeout(() => backupRestoreService.markStartupSuccessful(), 5000);
}); });
function stopAcceptingConnections() {
/*
Child-process services already own their SIGTERM cleanup. The HTTP service
only stops accepting new work; MediaMTX's bounded signal handler remains
responsible for ending the Node process even if an existing socket keeps
the close callback waiting.
*/
if (httpServer.listening) httpServer.close();
}
process.once('SIGINT', stopAcceptingConnections);
process.once('SIGTERM', stopAcceptingConnections);
@@ -5,7 +5,7 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService'); const logger = require('../../globals/logger').child('identityAdminService');
const { getRole } = require('../roleService'); const { getRole } = require('../roleService');
const { const {
listUsersForAdmin, listUserSummariesForAdmin,
getUserForAdmin, getUserForAdmin,
addUserSignal, addUserSignal,
removeUserSignal, removeUserSignal,
@@ -71,10 +71,13 @@ function ackHandler(socket, eventName, handler) {
} }
io.on('connection', (socket) => { io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({ ackHandler(socket, 'identityAdmin:listUsers', ({ query, filter }) => {
users: listUsersForAdmin(), const result = listUserSummariesForAdmin({ query, filter });
permissions: listRegisteredPermissions(), return {
})); ...result,
permissions: listRegisteredPermissions(),
};
});
ackHandler(socket, 'identityAdmin:listPermissions', () => ({ ackHandler(socket, 'identityAdmin:listPermissions', () => ({
permissions: listRegisteredPermissions(), permissions: listRegisteredPermissions(),
@@ -19,6 +19,7 @@ const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json'); const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json'); const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 4; const STORE_VERSION = 4;
const ADMIN_USER_LIST_LIMIT = 100;
const identityEvents = new EventEmitter(); const identityEvents = new EventEmitter();
let db = null; let db = null;
@@ -118,6 +119,15 @@ function getDb() {
return db; return db;
} }
function backupDatabase(destinationPath) {
/*
Keep identity writes live while SQLite copies a transactionally consistent
view into backup staging. Exposing the operation instead of the connection
preserves this service as the sole owner of identity.sqlite.
*/
return getDb().backup(destinationPath);
}
function ensureSchema(conn) { function ensureSchema(conn) {
conn.exec(` conn.exec(`
create table if not exists users ( create table if not exists users (
@@ -551,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) { function getUserForAdmin(userId) {
const user = getUserById(userId, { includeFeatures: true }); const user = getUserById(userId, { includeFeatures: true });
return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null; return user ? { ...user, featureNamespaces: Object.keys(user.features || {}).sort() } : null;
@@ -1087,6 +1176,7 @@ function createJsonStore({ path: filePath, normalizeStoreShape, cloneStore, logg
module.exports = { module.exports = {
identityEvents, identityEvents,
getDb, getDb,
backupDatabase,
sanitizeNickname, sanitizeNickname,
normalizeCookieUserId, normalizeCookieUserId,
isValidCookieUserId, isValidCookieUserId,
@@ -1101,6 +1191,7 @@ module.exports = {
attachIdentitySignals, attachIdentitySignals,
getUserById, getUserById,
listUsersForAdmin, listUsersForAdmin,
listUserSummariesForAdmin,
getUserForAdmin, getUserForAdmin,
addUserSignal, addUserSignal,
removeUserSignal, removeUserSignal,
@@ -80,3 +80,36 @@ test('unknown permission keys cannot be persisted', () => {
/Unknown user permission/, /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);
});
@@ -14,7 +14,6 @@ module.exports = {
pollIntervalMs: 30000, pollIntervalMs: 30000,
requestTimeoutMs: 5000, requestTimeoutMs: 5000,
profile: { profile: {
publicUrl: 'https://rover.example.com',
name: 'Example Rover Server', name: 'Example Rover Server',
description: 'A short public description of this rover server.', description: 'A short public description of this rover server.',
color: '#38bdf8', color: '#38bdf8',
@@ -33,10 +32,9 @@ module.exports = {
pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }), pollIntervalMs: integer({ description: 'Milliseconds between peer-directory refreshes.', minimum: 1000, maximum: 86400000 }),
requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }), requestTimeoutMs: integer({ description: 'Maximum milliseconds allowed for each directory or peer information request before it is aborted.', minimum: 250, maximum: 120000 }),
profile: strictObject({ profile: strictObject({
publicUrl: string({ description: 'Public base URL peers and users use to reach this server; it also identifies and filters this instance from directory results.', examples: ['https://rover.example.com'], maxLength: 2048 }),
name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }), name: string({ description: 'Public instance name advertised to peer servers.', minLength: 1, maxLength: 120 }),
description: string({ description: 'Short public summary advertised with this instance.', examples: ['A short public description of this rover server.'], maxLength: 500 }), description: string({ description: 'Short public summary advertised with this instance.', examples: ['A short public description of this rover server.'], maxLength: 500 }),
color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }), color: string({ description: 'Six-digit hexadecimal accent color advertised for this instance.', pattern: '^#[0-9a-fA-F]{6}$' }),
}, { description: 'Public identity this server publishes through the inter-instance information endpoint.', required: ['publicUrl', 'name', 'description', 'color'] }), }, { description: 'Public identity this server publishes through the inter-instance information endpoint; its address comes from the top-level public URL.', required: ['name', 'description', 'color'] }),
}, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }), }, { title: 'Inter-instance directory', description: 'Controls discovery and public information exchange between independent MultiRover servers.', required: ['enabled', 'directoryUrls', 'pollIntervalMs', 'requestTimeoutMs', 'profile'] }),
}; };
@@ -6,7 +6,7 @@ const { v4: uuidv4 } = require('uuid');
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('interInstanceService'); const logger = require('../../globals/logger').child('interInstanceService');
const { loadConfig, getFeatureFlags } = require('../../configuration'); const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('../../configuration');
const { getConfiguredSocials } = require('../sessionService/configuration'); const { getConfiguredSocials } = require('../sessionService/configuration');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
@@ -20,13 +20,13 @@ const DEFAULT_POLL_INTERVAL_MS = 30000;
const DEFAULT_REQUEST_TIMEOUT_MS = 5000; const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
const INFO_PATH = '/api/inter-instance/info'; const INFO_PATH = '/api/inter-instance/info';
const INSTANCE_ID = uuidv4(); const INSTANCE_ID = uuidv4();
const config = loadConfig(); let interInstanceConfig = loadConfig().interInstance || {};
const interInstanceConfig = config.interInstance || {};
const profileConfig = interInstanceConfig.profile || {};
const interInstanceEvents = new EventEmitter(); const interInstanceEvents = new EventEmitter();
const remoteInstances = new Map(); const remoteInstances = new Map();
let polling = false; let pollGeneration = 0;
let pollingGeneration = null;
let pollTimer = null;
function asTrimmedString(value) { function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
@@ -59,7 +59,7 @@ function pollIntervalMs() {
} }
function ownPublicUrl() { function ownPublicUrl() {
return normalizeBaseUrl(profileConfig.publicUrl); return normalizeBaseUrl(loadConfig().publicUrl);
} }
function ownInstanceId() { function ownInstanceId() {
@@ -79,6 +79,7 @@ function buildPublicUrl(pathname) {
function publicProfile() { function publicProfile() {
const publicUrl = ownPublicUrl(); const publicUrl = ownPublicUrl();
const profileConfig = interInstanceConfig.profile || {};
return { return {
id: ownInstanceId(), id: ownInstanceId(),
name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server', name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server',
@@ -189,7 +190,15 @@ function filterPublicUsers(users = [], publicIds) {
function buildLocalInfo() { function buildLocalInfo() {
const mode = getMode(); const mode = getMode();
const lockdown = isLockdownMode(); const lockdown = isLockdownMode();
const features = getFeatureFlags(); /*
Build every configuration-derived part of one public response from the
same immutable revision. Besides preventing a revision change from mixing
feature flags with newer social links, this supplies the explicit snapshot
required by getConfiguredSocials instead of relying on the removed legacy
global configuration object.
*/
const config = loadConfig();
const features = getFeatureFlags(config);
const publicRoster = getPublicRoster(); const publicRoster = getPublicRoster();
const publicIds = publicRoverIdSet(publicRoster); const publicIds = publicRoverIdSet(publicRoster);
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover))); const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
@@ -429,25 +438,40 @@ async function pollRemoteInstance(entry) {
} }
} }
async function pollNow() { async function pollNow(expectedGeneration = pollGeneration) {
if (!isEnabled() || polling) return; if (!isEnabled() || expectedGeneration !== pollGeneration || pollingGeneration === expectedGeneration) return;
polling = true; pollingGeneration = expectedGeneration;
try { try {
const entries = await fetchDirectoryEntries(); const entries = await fetchDirectoryEntries();
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry))); const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
// Ignore responses from the previous directory/profile after a live edit;
// otherwise a slow retired request could repopulate peers after disable or
// overwrite results produced by the newly configured directory.
if (!isEnabled() || expectedGeneration !== pollGeneration) return;
replaceRemoteInstances(nextEntries); replaceRemoteInstances(nextEntries);
interInstanceEvents.emit('change'); interInstanceEvents.emit('change');
} catch (err) { } catch (err) {
logger.warn('Inter-instance poll failed', { error: err.message }); logger.warn('Inter-instance poll failed', { error: err.message });
} finally { } finally {
polling = false; if (pollingGeneration === expectedGeneration) pollingGeneration = null;
} }
} }
function startPolling() { function startPolling() {
if (!isEnabled()) return; pollGeneration += 1;
pollNow(); const expectedGeneration = pollGeneration;
setInterval(pollNow, pollIntervalMs()); if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (!isEnabled()) {
remoteInstances.clear();
interInstanceEvents.emit('change');
return;
}
pollNow(expectedGeneration);
pollTimer = setInterval(() => pollNow(expectedGeneration), pollIntervalMs());
pollTimer.unref?.();
} }
function getState() { function getState() {
@@ -462,6 +486,23 @@ function getState() {
startPolling(); startPolling();
registerConfigurationHandler('interInstance', (nextConfig = {}) => {
// Replacing this single reference updates request timeouts, identity fields,
// directory URLs, and peer lists together. Rebuilding the interval applies
// the new cadence immediately and clears stale peers when disabled.
interInstanceConfig = nextConfig;
startPolling();
});
registerConfigurationHandler('publicUrl', () => {
/*
The canonical URL participates in self-filtering as well as the published
profile. Start a fresh generation immediately so results from an in-flight
poll using the former identity cannot be committed afterward.
*/
startPolling();
});
module.exports = { module.exports = {
getState, getState,
interInstanceEvents, interInstanceEvents,
@@ -0,0 +1,76 @@
// Inter-Instance Public Payload Tests
// Purpose: Verifies that configuration-backed public metadata can be assembled for peer servers.
// Scope: Runs the service in an isolated child process so its socket gateways, timers, and configuration singleton never touch another test's runtime.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
test('builds the inter-instance payload when social links are enabled', () => {
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-inter-instance-'));
const serverRoot = path.resolve(__dirname, '../../..');
try {
/*
Enabling Social links is essential to this regression: disabled links
short-circuit before the configuration argument is read and therefore
cannot expose a stale or missing configuration reference. The child
process loads the real service graph and calls the same payload builder
used by GET /api/inter-instance/info.
*/
const script = `
const configuration = require('./src/configuration');
const database = configuration.getConfigurationDatabase();
const current = database.getClientConfiguration();
const next = structuredClone(current.config);
next.socials.enabled = true;
next.socials.links = [{
id: 'community',
label: 'Community',
url: 'https://community.example.test',
icon: 'FaUsers',
color: '#38bdf8'
}];
database.updateConfiguration({
value: next,
expectedRevision: current.revision,
actor: 'inter-instance-test'
});
configuration.applyCommittedConfiguration().then(() => {
const { buildLocalInfo } = require('./src/services/interInstanceService');
const payload = buildLocalInfo();
// Production services may write startup logs to stdout. A unique
// marker separates the assertion payload from that expected noise.
process.stdout.write('\\n__INTER_INSTANCE_RESULT__' + JSON.stringify(payload.socials));
database.close();
// Requiring the production service intentionally registers persistent
// Socket.IO gateways. The disposable child has completed its one real
// payload assertion, so it must not wait for those server-owned handles.
process.exit(0);
}).catch((error) => {
console.error(error);
process.exit(1);
});
`;
const output = execFileSync(process.execPath, ['-e', script], {
cwd: serverRoot,
env: { ...process.env, SERVER_DATA_DIR: dataRoot },
encoding: 'utf8',
});
const resultMarker = '__INTER_INSTANCE_RESULT__';
const resultOffset = output.lastIndexOf(resultMarker);
assert.notEqual(resultOffset, -1, 'child process did not emit its inter-instance result');
assert.deepEqual(JSON.parse(output.slice(resultOffset + resultMarker.length)), [{
id: 'community',
label: 'Community',
url: 'https://community.example.test',
icon: 'FaUsers',
color: '#38bdf8',
}]);
} finally {
fs.rmSync(dataRoot, { recursive: true, force: true });
}
});
@@ -8,7 +8,7 @@ module.exports = {
feature: true, feature: true,
defaultValue: { enabled: false, captureCooldownMs: 10000 }, defaultValue: { enabled: false, captureCooldownMs: 10000 },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Starts the Kinect worker and exposes authorized frame capture after restart.' }), enabled: boolean({ description: 'Immediately starts the Kinect worker and exposes authorized frame capture.' }),
captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }), captureCooldownMs: integer({ description: 'Minimum milliseconds between accepted Kinect frame-capture requests across all clients.', minimum: 0, maximum: 3600000 }),
}, { }, {
title: 'Kinect', title: 'Kinect',
+5 -1
View File
@@ -1,7 +1,7 @@
// Kinect Service // Kinect Service
// Purpose: Composes Kinect hardware capture and browser socket delivery. // Purpose: Composes Kinect hardware capture and browser socket delivery.
// Scope: Exposes session-readable state while keeping startup side effects in this service folder. // Scope: Exposes session-readable state while keeping startup side effects in this service folder.
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const hardware = require('./hardware'); const hardware = require('./hardware');
const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway'); const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway');
@@ -11,6 +11,10 @@ const gateway = registerKinectSocketGateway({
hardware, hardware,
}); });
registerConfigurationHandler('kinect', (kinectConfig) => {
gateway.reconfigure({ kinect: kinectConfig || {} });
});
module.exports = { module.exports = {
getState: gateway.getState, getState: gateway.getState,
kinectEvents, kinectEvents,
@@ -33,7 +33,7 @@ function normalizeKinectConfig(config = {}) {
} }
function registerKinectSocketGateway({ config, hardware }) { function registerKinectSocketGateway({ config, hardware }) {
const settings = normalizeKinectConfig(config); let settings = normalizeKinectConfig(config);
let captureCooldownUntil = 0; let captureCooldownUntil = 0;
let busy = false; let busy = false;
let lastAction = null; let lastAction = null;
@@ -206,8 +206,31 @@ function registerKinectSocketGateway({ config, hardware }) {
} }
} }
function reconfigure(nextConfig) {
const previousEnabled = settings.enabled;
settings = normalizeKinectConfig(nextConfig);
lastError = null;
// The native worker is the Kinect service's complete hardware runtime.
// Restarting only when the enabled state changes avoids interrupting an
// unrelated cooldown edit while still making enable/disable immediate.
if (previousEnabled && !settings.enabled) {
hardware.stopWorker();
busy = false;
} else if (!previousEnabled && settings.enabled) {
try {
hardware.startWorker();
} catch (err) {
lastError = err.message || 'kinect worker failed to start';
logger.warn('Kinect worker startup failed after configuration change', { err: lastError });
}
}
emitStatusChange();
}
return { return {
getState: buildStatus, getState: buildStatus,
reconfigure,
}; };
} }
@@ -16,7 +16,7 @@ module.exports = {
commandCooldownMs: 3000, commandCooldownMs: 3000,
}, },
schema: strictObject({ schema: strictObject({
enabled: boolean({ description: 'Enables lift status and commands through the two configured Home Assistant switches after restart.' }), enabled: boolean({ description: 'Immediately enables lift status and commands through the two configured Home Assistant switches.' }),
upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', examples: ['switch.lift_up'], maxLength: 255 }), upSwitch: string({ description: 'Home Assistant switch entity that powers upward lift movement.', examples: ['switch.lift_up'], maxLength: 255 }),
downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', examples: ['switch.lift_down'], maxLength: 255 }), downSwitch: string({ description: 'Home Assistant switch entity that powers downward lift movement.', examples: ['switch.lift_down'], maxLength: 255 }),
interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }), interlockMs: integer({ description: 'Milliseconds to wait after turning off the opposing direction before energizing the requested direction. Runtime always enforces at least 250 ms.', minimum: 0, maximum: 600000 }),
+33 -30
View File
@@ -4,27 +4,28 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('liftService'); const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { const homeAssistantService = require('../homeAssistantService');
homeAssistantEvents, const { homeAssistantEvents, getRawEntitySnapshot, callHomeAssistantService } = homeAssistantService;
getRawEntitySnapshot,
callHomeAssistantService,
isConnected: isHomeAssistantConnected,
enabled: homeAssistantEnabled,
} = require('../homeAssistantService');
const events = new EventEmitter(); const events = new EventEmitter();
const config = loadConfig(); let featureEnabled;
const haConfig = config.homeAssistant || {}; let upSwitchId;
const liftConfig = haConfig.lift || {}; let downSwitchId;
const featureEnabled = Boolean(liftConfig.enabled); let interlockMs;
let commandCooldownMs;
const upSwitchId = String(liftConfig.upSwitch || '').trim(); function applyLiftConfig(liftConfig = {}) {
const downSwitchId = String(liftConfig.downSwitch || '').trim(); featureEnabled = Boolean(liftConfig.enabled);
const interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 9000); upSwitchId = String(liftConfig.upSwitch || '').trim();
const commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 25000); downSwitchId = String(liftConfig.downSwitch || '').trim();
interlockMs = Math.max(250, Number(liftConfig.interlockMs) || 9000);
commandCooldownMs = Math.max(interlockMs, Number(liftConfig.commandCooldownMs) || 25000);
}
applyLiftConfig(loadConfig().homeAssistant?.lift || {});
const state = { const state = {
busy: false, busy: false,
@@ -70,7 +71,7 @@ function isConfigured() {
function getState() { function getState() {
const configured = isConfigured(); const configured = isConfigured();
const connected = isHomeAssistantConnected(); const connected = homeAssistantService.isConnected();
return { return {
enabled: featureEnabled, enabled: featureEnabled,
configured, configured,
@@ -105,8 +106,8 @@ function emitUpdate() {
function assertReady() { function assertReady() {
if (!featureEnabled) throw new Error('Lift is disabled'); if (!featureEnabled) throw new Error('Lift is disabled');
if (!isConfigured()) throw new Error('Lift not configured'); if (!isConfigured()) throw new Error('Lift not configured');
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured'); if (!homeAssistantService.enabled) throw new Error('Home Assistant not configured');
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected'); if (!homeAssistantService.isConnected()) throw new Error('Home Assistant not connected');
} }
async function applyPosition(target) { async function applyPosition(target) {
@@ -175,16 +176,10 @@ async function moveDown(actor = 'unknown') {
return requestPosition('down', actor); return requestPosition('down', actor);
} }
if (featureEnabled) { homeAssistantEvents.on('snapshot', emitUpdate);
/* homeAssistantEvents.on('status', emitUpdate);
Lift state depends on Home Assistant switch snapshots. Subscribe only when
the lift exists so disabled installs do not maintain hardware-specific UI
sync paths.
*/
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => { io.on('connection', (socket) => {
function assertFeatureAccess() { function assertFeatureAccess() {
const mode = getMode(); const mode = getMode();
// Lift is a public activity feature in open and turns modes. Restricted // Lift is a public activity feature in open and turns modes. Restricted
@@ -215,11 +210,19 @@ if (featureEnabled) {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
}); });
} else {
if (!featureEnabled) {
logger.info('Lift disabled by config'); logger.info('Lift disabled by config');
} }
registerConfigurationHandler('homeAssistant', (haConfig = {}) => {
// Lift is nested under the Home Assistant section, so it participates in the
// same section reload and immediately sees the replacement HA transport.
applyLiftConfig(haConfig.lift || {});
emitUpdate();
});
emitUpdate(); emitUpdate();
module.exports = { module.exports = {
@@ -5,7 +5,7 @@ const fsp = require('fs/promises');
const { Ollama } = require('ollama'); const { Ollama } = require('ollama');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('llmCommentary'); const logger = require('../../globals/logger').child('llmCommentary');
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getRole, roleEvents } = require('../roleService'); const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
@@ -34,13 +34,12 @@ const { createSnapshotEngine } = require('./snapshotEngine');
const { registerHooks } = require('./hooks'); const { registerHooks } = require('./hooks');
const { createRunner } = require('./runner'); const { createRunner } = require('./runner');
const config = loadConfig(); let enabled;
const commentaryConfig = config.llmCommentary || {}; let ollamaUrl;
const enabled = Boolean(commentaryConfig.enabled); let model;
const ollamaUrl = String(commentaryConfig.ollamaServer || '').trim(); let ollamaClient;
const model = String(commentaryConfig.model || '').trim(); let frequencyMs;
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null; let runner;
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
const runtime = { const runtime = {
timer: null, timer: null,
@@ -234,73 +233,67 @@ const snapshotEngine = createSnapshotEngine({
getSkipStreak: () => runtime.skipStreak, getSkipStreak: () => runtime.skipStreak,
}); });
const runner = createRunner({ function applyCommentaryConfig(commentaryConfig = {}) {
logger, runner?.stop('configuration changed');
enabled, enabled = Boolean(commentaryConfig.enabled);
model, ollamaUrl = String(commentaryConfig.ollamaServer || '').trim();
ollamaUrl, model = String(commentaryConfig.model || '').trim();
frequencyMs, ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
jitterMs: JITTER_MS, frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency));
postCooldownMs: POST_COOLDOWN_MS, status = { ...status, enabled, model, ollamaUrl, frequencyMs };
maxBotMessages: MAX_BOT_MESSAGES, runner = createRunner({
runtime, logger,
snapshotEngine, enabled,
readSystemPrompt, model,
buildModelMessages, ollamaUrl,
generateCommentary, frequencyMs,
normalizeDuplicateKey, jitterMs: JITTER_MS,
getRecentMessages, postCooldownMs: POST_COOLDOWN_MS,
sendSystemMessage, maxBotMessages: MAX_BOT_MESSAGES,
buildFailureInfo, runtime,
updatePhase, snapshotEngine,
startRunRecord, readSystemPrompt,
patchCurrentRun, buildModelMessages,
finalizeRunRecord, generateCommentary,
updateStatus, normalizeDuplicateKey,
}); getRecentMessages,
sendSystemMessage,
const canRunFromConfig = enabled && model && ollamaUrl; buildFailureInfo,
updatePhase,
if (canRunFromConfig) { startRunRecord,
registerHooks({ patchCurrentRun,
io, finalizeRunRecord,
roleEvents, updateStatus,
roverManager,
emitStatusToSocket,
isAdminSocket,
clearRuntimeHistory: runner.clearRuntimeHistory,
getAdminState: () => buildAdminState(status, runtime.runHistory),
onDriverActivity: runner.wakeForDriverActivity,
onSensorEvent: snapshotEngine.onSensorEvent,
onRoverRemoved: snapshotEngine.removeRover,
}); });
if (getMode() === MODES.LOCKDOWN) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown'); runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
} else { } else {
runner.start(); runner.start();
} }
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
return;
}
runner.start();
});
} else {
const disabledReason = !enabled
? 'llmCommentary.enabled is false'
: 'model or ollama server missing';
updatePhase('disabled', {
running: false,
inFlight: false,
currentRunId: null,
lastOutcome: 'disabled',
lastReason: disabledReason,
});
logger.info('LLM commentary service not started', { reason: disabledReason });
} }
applyCommentaryConfig(loadConfig().llmCommentary || {});
// Runtime hooks stay attached once and route actions through the newest runner.
registerHooks({
io,
roleEvents,
roverManager,
emitStatusToSocket,
isAdminSocket,
clearRuntimeHistory: (...args) => runner.clearRuntimeHistory(...args),
getAdminState: () => buildAdminState(status, runtime.runHistory),
onDriverActivity: (...args) => runner.wakeForDriverActivity(...args),
onSensorEvent: snapshotEngine.onSensorEvent,
onRoverRemoved: snapshotEngine.removeRover,
});
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
return;
}
runner.start();
});
registerConfigurationHandler('llmCommentary', applyCommentaryConfig);
@@ -26,12 +26,14 @@ function createRunner(deps) {
finalizeRunRecord, finalizeRunRecord,
updateStatus, updateStatus,
} = deps; } = deps;
let active = false;
function defaultTickDelayMs() { function defaultTickDelayMs() {
return frequencyMs + Math.floor(Math.random() * (jitterMs + 1)); return frequencyMs + Math.floor(Math.random() * (jitterMs + 1));
} }
function scheduleNextTick(runTick, delayMs = defaultTickDelayMs()) { function scheduleNextTick(runTick, delayMs = defaultTickDelayMs()) {
if (!active) return;
const safeDelay = Math.max(0, Number.isFinite(delayMs) ? Math.floor(delayMs) : defaultTickDelayMs()); const safeDelay = Math.max(0, Number.isFinite(delayMs) ? Math.floor(delayMs) : defaultTickDelayMs());
const nextRunAt = Date.now() + safeDelay; const nextRunAt = Date.now() + safeDelay;
updateStatus({ nextRunAt }); updateStatus({ nextRunAt });
@@ -39,6 +41,7 @@ function createRunner(deps) {
} }
function wakeForDriverActivity(runTick) { function wakeForDriverActivity(runTick) {
if (!active) return;
if (runtime.inFlight) return; if (runtime.inFlight) return;
if (runtime.timer) { if (runtime.timer) {
clearTimeout(runtime.timer); clearTimeout(runtime.timer);
@@ -48,6 +51,7 @@ function createRunner(deps) {
} }
function stop(reason = 'stopped') { function stop(reason = 'stopped') {
active = false;
if (runtime.timer) { if (runtime.timer) {
clearTimeout(runtime.timer); clearTimeout(runtime.timer);
runtime.timer = null; runtime.timer = null;
@@ -335,6 +339,8 @@ function createRunner(deps) {
}); });
return; return;
} }
if (active) return;
active = true;
logger.info('LLM commentary enabled', { model, ollamaUrl, frequencyMs }); logger.info('LLM commentary enabled', { model, ollamaUrl, frequencyMs });
updatePhase('idle', { updatePhase('idle', {
running: true, running: true,
+16 -19
View File
@@ -20,19 +20,17 @@ function normalizeAdditionalHosts(rawHosts) {
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) { function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
const media = config?.media || {}; const media = config?.media || {};
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts); const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
if (!additionalHosts.length && media.whepBaseUrl) { let publicHostname = '';
try { try {
/* publicHostname = new URL(config?.publicUrl).hostname;
Existing installations predate media.additionalHosts. Using the already-configured } catch {
WHEP hostname as a one-host migration default keeps them reachable on first restart; // The assembled configuration schema normally prevents this. Keeping the
administrators can still list every public and LAN candidate explicitly afterward. // builder tolerant makes its pure tests and startup error path explicit.
*/
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
} catch {
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
}
} }
// The canonical public hostname is always advertised once. Operators only
// maintain genuinely additional LAN names, aliases, or fixed IP addresses.
const additionalHosts = [...new Set([publicHostname, ...configuredHosts].filter(Boolean))];
const authPort = Number(serverPort) || 8080; const authPort = Number(serverPort) || 8080;
return { return {
@@ -56,6 +54,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
hls: false, hls: false,
webrtc: true, webrtc: true,
// WHEP and WHIP signaling is public only through the Node /video proxy.
// ICE transport on 8189 remains directly reachable by browsers.
webrtcAddress: '127.0.0.1:8889',
webrtcLocalUDPAddress: ':8189', webrtcLocalUDPAddress: ':8189',
webrtcLocalTCPAddress: ':8189', webrtcLocalTCPAddress: ':8189',
webrtcAdditionalHosts: additionalHosts, webrtcAdditionalHosts: additionalHosts,
@@ -68,13 +69,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
{ url: 'stun:stun.cloudflare.com:3478' }, { url: 'stun:stun.cloudflare.com:3478' },
], ],
/* // Every publisher and server-local reader uses RTSP over TCP. Disable SRT
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot // completely so MediaMTX cannot reintroduce the GoSRT/libSRT ACKACK mismatch.
writer. Rover media moves to RTSP, but removing this listener would break those independent srt: false,
consumers, so both listeners remain deliberately enabled.
*/
srt: true,
srtAddress: ':9000',
authMethod: 'http', authMethod: 'http',
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`, authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
@@ -8,8 +8,8 @@ const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
test('generates RTSP over TCP without deployment-specific hardcodes', () => { test('generates RTSP over TCP without deployment-specific hardcodes', () => {
const generated = buildMediaMtxConfig({ const generated = buildMediaMtxConfig({
config: { config: {
publicUrl: 'https://public.example.com',
media: { media: {
whepBaseUrl: 'http://media.internal:8889/video',
additionalHosts: ['public.example.com', '10.20.30.40'], additionalHosts: ['public.example.com', '10.20.30.40'],
}, },
}, },
@@ -20,15 +20,18 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => {
assert.equal(generated.rtsp, true); assert.equal(generated.rtsp, true);
assert.equal(generated.rtspAddress, ':8554'); assert.equal(generated.rtspAddress, ':8554');
assert.deepEqual(generated.rtspTransports, ['tcp']); 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, 'rtpAddress'), false);
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false); assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']); assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
assert.equal(generated.webrtcAddress, '127.0.0.1:8889');
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth'); assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
}); });
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => { test('derives the primary ICE hostname from the canonical public URL', () => {
const generated = buildMediaMtxConfig({ const generated = buildMediaMtxConfig({
config: { media: { whepBaseUrl: 'https://second-server.example/video' } }, config: { publicUrl: 'https://second-server.example', media: { additionalHosts: [] } },
serverPort: 8080, serverPort: 8080,
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh', snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
}); });
@@ -1,22 +1,18 @@
// Media Transport Configuration // Media Transport Configuration
// Purpose: Defines browser WHEP addressing and additional MediaMTX ICE hosts. // Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL.
// Scope: Contains configuration metadata only and never starts MediaMTX. // Scope: Contains configuration metadata only and never starts MediaMTX.
const { strictObject, string, stringArray } = require('../../configuration/schemaHelpers'); const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
module.exports = { module.exports = {
key: 'media', key: 'media',
defaultValue: { defaultValue: {
// Signaling remains server-local because the internal `/video` proxy owns additionalHosts: [],
// browser access; only ICE transport addresses come from the legacy sample.
whepBaseUrl: 'http://127.0.0.1:8889/video',
additionalHosts: ['rover.example.com', 'media-server.local'],
}, },
schema: strictObject({ schema: strictObject({
whepBaseUrl: string({ title: 'WHEP base URL', description: 'Base HTTP URL used to build browser WHEP playback and WHIP audio-publishing endpoints.', format: 'uri', maxLength: 2048 }),
additionalHosts: stringArray({ additionalHosts: stringArray({
title: 'Additional ICE hosts', title: 'Additional ICE hosts',
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 }, item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
array: { description: 'Additional public or LAN hostnames and addresses browsers may use to reach MediaMTX WebRTC transport.', uniqueItems: true }, array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
}), }),
}, { title: 'Media', description: 'Controls browser signaling addresses and WebRTC network candidates generated for the managed MediaMTX process.', required: ['whepBaseUrl', 'additionalHosts'] }), }, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }),
}; };
+28 -6
View File
@@ -1,21 +1,43 @@
// MediaMTX Service // MediaMTX Service
// Purpose: Composes server configuration, runtime paths, and child-process supervision. // Purpose: Composes server configuration, runtime paths, and child-process supervision.
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server. // Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
const { loadConfig } = require('../../configuration'); const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const globalConfig = require('../../globals/config'); const globalConfig = require('../../globals/config');
const logger = require('../../globals/logger').child('mediamtx'); const logger = require('../../globals/logger').child('mediamtx');
const { createMediaMtxSupervisor } = require('./supervisor'); const { createMediaMtxSupervisor } = require('./supervisor');
const supervisor = createMediaMtxSupervisor({ let supervisor = createSupervisor();
config: loadConfig(), let started = false;
serverPort: globalConfig.port,
logger, function createSupervisor() {
}); return createMediaMtxSupervisor({
config: loadConfig(),
serverPort: globalConfig.port,
logger,
});
}
function startMediaMtx() { function startMediaMtx() {
started = true;
return supervisor.start(); return supervisor.start();
} }
function stopSupervisor() {
return new Promise((resolve) => supervisor.stop(resolve));
}
async function reloadMediaMtx() {
// MediaMTX consumes a generated document rather than the Node configuration
// object directly. Replace its child process when either explicit media
// hosts or the canonical public hostname changes.
await stopSupervisor();
supervisor = createSupervisor();
if (started) supervisor.start();
}
registerConfigurationHandler('media', reloadMediaMtx);
registerConfigurationHandler('publicUrl', reloadMediaMtx);
/* /*
Other services already use process signal hooks for their own workers. This hook performs Other services already use process signal hooks for their own workers. This hook performs
only synchronous signal delivery; systemd's default control-group cleanup remains the final only synchronous signal delivery; systemd's default control-group cleanup remains the final
@@ -0,0 +1,69 @@
// MediaMTX HTTP Proxy
// Purpose: Exposes WHEP and WHIP signaling beneath the server-owned /video path while MediaMTX remains loopback-only.
// Scope: Proxies signaling HTTP only; WebRTC media continues to travel directly through MediaMTX's ICE listener.
const { createProxyMiddleware } = require('http-proxy-middleware');
const PUBLIC_MEDIA_PREFIX = '/video';
const INTERNAL_WEBRTC_ORIGIN = 'http://127.0.0.1:8889';
const SIGNALING_TIMEOUT_MS = 60 * 60 * 1000;
function rewriteSessionLocation(location, internalOrigin = INTERNAL_WEBRTC_ORIGIN) {
const value = String(location || '');
if (!value) return value;
/*
MediaMTX normally returns a root-relative WHEP/WHIP session URL. Browsers
subsequently PATCH and DELETE that exact Location, so restore the public
mount prefix that was removed before proxying the initial request.
*/
if (value.startsWith('/') && !value.startsWith(`${PUBLIC_MEDIA_PREFIX}/`)) {
return `${PUBLIC_MEDIA_PREFIX}${value}`;
}
try {
const parsed = new URL(value);
if (parsed.origin === new URL(internalOrigin).origin) {
return `${PUBLIC_MEDIA_PREFIX}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
} catch {
// A path relative to the WHEP/WHIP endpoint already resolves beneath
// /video in the browser and must not be converted into a root path.
}
return value;
}
function createMediaMtxProxy({ target = INTERNAL_WEBRTC_ORIGIN, logger = console } = {}) {
return createProxyMiddleware({
target,
changeOrigin: true,
proxyTimeout: SIGNALING_TIMEOUT_MS,
timeout: SIGNALING_TIMEOUT_MS,
logger,
on: {
proxyRes(proxyResponse) {
const location = proxyResponse.headers.location;
if (location) proxyResponse.headers.location = rewriteSessionLocation(location, target);
},
error(error, request, response) {
logger.warn?.('MediaMTX signaling proxy failed', {
method: request.method,
path: request.originalUrl || request.url,
error: error.message,
});
if (response.headersSent) {
response.destroy(error);
return;
}
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
response.end('Media signaling is temporarily unavailable.');
},
},
});
}
module.exports = {
INTERNAL_WEBRTC_ORIGIN,
PUBLIC_MEDIA_PREFIX,
createMediaMtxProxy,
rewriteSessionLocation,
};
@@ -0,0 +1,90 @@
// MediaMTX HTTP Proxy Tests
// Purpose: Verifies streaming WHEP/WHIP method, body, header, path, and session-location behavior.
// Scope: Uses ephemeral loopback HTTP servers and always closes them; it never starts MediaMTX or the application server.
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const express = require('express');
const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy, rewriteSessionLocation } = require('./proxy');
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
}
function close(server) {
return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
test('streams WHEP session requests and keeps follow-up locations beneath /video', async () => {
const received = [];
const mediaMtx = http.createServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
received.push({
method: request.method,
path: request.url,
authorization: request.headers.authorization,
contentType: request.headers['content-type'],
body: Buffer.concat(chunks).toString('utf8'),
});
response.writeHead(201, {
'Content-Type': 'application/sdp',
Location: '/rover-one/whep/session-id',
});
response.end('answer');
});
});
const mediaPort = await listen(mediaMtx);
const app = express();
const silentLogger = { info() {}, warn() {}, error() {} };
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({
target: `http://127.0.0.1:${mediaPort}`,
logger: silentLogger,
}));
// If the proxy accidentally falls through, this parser would consume the
// request and make the integration failure explicit instead of timing out.
app.use(express.json());
const nodeServer = http.createServer(app);
const nodePort = await listen(nodeServer);
try {
const requests = [
{ method: 'POST', path: '/video/rover-one/whep', type: 'application/sdp', body: 'v=0\r\no=offer' },
{ method: 'PATCH', path: '/video/rover-one/whep/session-id', type: 'application/trickle-ice-sdpfrag', body: 'a=candidate' },
{ method: 'DELETE', path: '/video/rover-one/whep/session-id', type: 'application/trickle-ice-sdpfrag', body: '' },
];
for (const request of requests) {
const response = await fetch(`http://127.0.0.1:${nodePort}${request.path}`, {
method: request.method,
headers: { Authorization: 'Bearer session-token', 'Content-Type': request.type },
body: request.method === 'DELETE' ? undefined : request.body,
});
assert.equal(response.status, 201);
assert.equal(response.headers.get('location'), '/video/rover-one/whep/session-id');
assert.equal(await response.text(), 'answer');
}
assert.deepEqual(received, requests.map((request) => ({
method: request.method,
path: request.path.slice('/video'.length),
authorization: 'Bearer session-token',
contentType: request.type,
body: request.body,
})));
} finally {
await close(nodeServer);
await close(mediaMtx);
}
});
test('rewrites only root-relative or internal absolute MediaMTX locations', () => {
assert.equal(rewriteSessionLocation('/camera/whep/id'), '/video/camera/whep/id');
assert.equal(rewriteSessionLocation('http://127.0.0.1:8889/camera/whep/id?one=two'), '/video/camera/whep/id?one=two');
assert.equal(rewriteSessionLocation('whep/id'), 'whep/id');
assert.equal(rewriteSessionLocation('https://example.com/camera/whep/id'), 'https://example.com/camera/whep/id');
});
@@ -39,6 +39,9 @@ function createMediaMtxSupervisor(deps) {
function start() { function start() {
if (child) return child; if (child) return child;
// `stop()` marks the old lifecycle as intentional. Reset that marker when
// the same supervisor is started again so later crashes remain fatal.
stopping = false;
const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }); const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
/* /*

Some files were not shown because too many files have changed in this diff Show More