From 43274e7371dad614d8ba1642c97d6aadd4da4e81 Mon Sep 17 00:00:00 2001 From: legop3 Date: Mon, 14 Sep 2026 19:49:37 -0400 Subject: [PATCH] /video signaling proxy and a global public server http path config item --- docs/server-admin-container-migration.md | 12 ++- server/package.json | 1 + .../src/configuration/configuration.test.js | 50 +++++++++-- server/src/configuration/definition.js | 1 + server/src/configuration/migrations.js | 38 +++++++- server/src/globals/http.js | 8 ++ server/src/helpers/siteMetadata.js | 9 +- .../src/services/audioForwardService/index.js | 7 +- .../services/audioForwardService/policy.js | 20 +---- .../audioForwardService/policy.test.js | 6 +- .../services/backupRestoreService/restore.js | 2 +- .../src/services/chatService/textCommands.js | 2 +- .../discordBotService/commands/replay.js | 6 +- .../discordBotService/commands/timeStatus.js | 8 +- .../discordBotService/configuration.js | 6 +- .../src/services/discordBotService/index.js | 6 +- .../integrations/busEvents.js | 8 +- .../discordBotService/userAnnouncements.js | 9 +- .../interInstanceService/configuration.js | 4 +- .../services/interInstanceService/index.js | 11 ++- server/src/services/mediaMtxService/config.js | 25 +++--- .../services/mediaMtxService/config.test.js | 7 +- .../services/mediaMtxService/configuration.js | 14 ++- server/src/services/mediaMtxService/index.js | 11 ++- server/src/services/mediaMtxService/proxy.js | 69 ++++++++++++++ .../services/mediaMtxService/proxy.test.js | 90 +++++++++++++++++++ .../mediaMtxService/supervisor.test.js | 2 +- .../operatorCommandService/webTransport.js | 4 +- .../services/sessionService/configuration.js | 16 +++- .../videoAuthService/streamParsing.js | 27 +----- .../src/services/videoSocketService/index.js | 26 +----- to-do.md | 4 +- 32 files changed, 364 insertions(+), 145 deletions(-) create mode 100644 server/src/services/mediaMtxService/proxy.js create mode 100644 server/src/services/mediaMtxService/proxy.test.js diff --git a/docs/server-admin-container-migration.md b/docs/server-admin-container-migration.md index d859569c..a20ef037 100644 --- a/docs/server-admin-container-migration.md +++ b/docs/server-admin-container-migration.md @@ -6,7 +6,8 @@ This document is the live implementation tracker for the migration. - [x] Phase 1, step 1: Establish the single data-directory contract - [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI -- [ ] Phase 1, steps 6-9: Restart, backup/restore, and internal video proxy +- [x] Phase 1, steps 6-8: Restart, backup/restore, and internal video proxy +- [ ] Phase 1, step 9: Legacy-deployment verification and removal of the external `/video` route - [ ] 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. @@ -24,6 +25,7 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu - 2026-09-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet. - 2026-09-14: Restart only the application process, never the host. A lockdown administrator with recent password confirmation requests one audited restart, Node acknowledges and announces it, then sends itself SIGTERM. Existing service signal handlers clean up their owned children, while systemd `Restart=always` and the later container restart policy start the application again. - 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: 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. @@ -460,11 +462,13 @@ Implemented on 2026-09-14: - 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. Local verification completed: -- All 114 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, and the earlier filesystem coverage. -- All 25 server test files passed after live application and backup/restore were added. The isolated backup/restore tests cover production-style WAL snapshots without transient sidecars, complete archive round trips, excluded runtime/control data, checksum tampering, unsafe symbolic-link entries, earliest-startup replacement, successful cleanup, and automatic rollback. Application-restart syntax, authorization wiring, and supervisor configuration were checked without exercising the real process signal on the development machine. +- All 118 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, and the earlier filesystem coverage. +- All 26 server test files passed after live application, backup/restore, and the internal media proxy 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. - All 20 existing focused web UI tests passed. - The production web UI build completed successfully and regenerated the checked-in server assets. @@ -683,7 +687,7 @@ Within the two hard phase boundaries, the safest order is: - [x] Apply every configuration revision to running services without restarting the application. - [x] Standardize graceful application restart. - [x] Implement online backup and restart-bound staged restore in one service. -- [ ] Add the internal `/video` proxy and remove the special external route. +- [x] Add the internal `/video` proxy and make the special external route unnecessary. - [ ] Run the full Phase 1 completion gate on the legacy deployment. - [ ] Build and verify the production application image. - [ ] Add Compose, data mounting, networking, and hardware access. diff --git a/server/package.json b/server/package.json index 9d3942a6..2cc4c4d3 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,7 @@ "express": "^4.19.2", "fuse.js": "^7.4.2", "home-assistant-js-websocket": "^3.1.2", + "http-proxy-middleware": "^3.0.7", "js-yaml": "^4.1.1", "kokoro-js": "^1.2.1", "luxon": "^3.7.2", diff --git a/server/src/configuration/configuration.test.js b/server/src/configuration/configuration.test.js index 192da4cf..4b3466cf 100644 --- a/server/src/configuration/configuration.test.js +++ b/server/src/configuration/configuration.test.js @@ -7,8 +7,10 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); +const Database = require('better-sqlite3'); const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation'); const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition'); +const { migrations } = require('./migrations'); const { getFeatureFlags } = require('./index'); const { createConfigurationDatabase } = require('./database'); const { @@ -215,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', () => { - const normalized = normalizeConfig({ media: { whepBaseUrl: 'http://localhost:8889/video' } }); - // Missing fields now receive the same populated template defaults as a new - // installation; normalization must not silently revert this one collection - // to the former empty-safe-default policy. - assert.deepEqual(normalized.media.additionalHosts, ['rover.example.com', 'media-server.local']); + const normalized = normalizeConfig({ media: { additionalHosts: [] } }); + assert.equal(normalized.publicUrl, 'https://rover.example.com'); + assert.deepEqual(normalized.media.additionalHosts, []); 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.equal(error.code, 'CONFIG_VALIDATION_FAILED'); assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost'))); @@ -230,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', () => { const database = createTestDatabase(); const initial = database.getActiveConfigurationRecord(); @@ -291,6 +325,7 @@ admins: password_hash: "$2b$10$preservedHash" discord_id: "1234" lockdown: true +publicUrl: https://production.example.com timezone: America/Chicago media: whepBaseUrl: http://localhost:8889/video @@ -320,6 +355,7 @@ fleetReports: immediateCriticalAlerts: true `; const parsed = parseConfigurationFile(yamlText); + assert.equal(parsed.config.publicUrl, 'https://production.example.com'); assert.equal(parsed.config.timezone, 'America/Chicago'); assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash'); assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false); @@ -428,7 +464,7 @@ test('committed revisions replace the live snapshot and isolate service reload f const record = database.getClientConfiguration(); const next = structuredClone(record.config); next.timezone = 'America/Chicago'; - next.media.whepBaseUrl = 'http://localhost:9999/video'; + next.media.additionalHosts = ['media.example.test']; database.updateConfiguration({ value: next, expectedRevision: record.revision, diff --git a/server/src/configuration/definition.js b/server/src/configuration/definition.js index 3a619ec6..2e1440f8 100644 --- a/server/src/configuration/definition.js +++ b/server/src/configuration/definition.js @@ -30,6 +30,7 @@ const fleetReports = require('../services/fleetReportService/configuration'); those fragments are placed independently at their historical positions. */ const definitions = [ + sessionConfiguration.publicUrl, sessionConfiguration.timezone, interInstance, llmCommentary, diff --git a/server/src/configuration/migrations.js b/server/src/configuration/migrations.js index 08c04919..c1c33bec 100644 --- a/server/src/configuration/migrations.js +++ b/server/src/configuration/migrations.js @@ -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) { @@ -57,7 +92,8 @@ function applySchemaMigrations(db) { changed database whose version incorrectly appears current. */ 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()); })(); }); diff --git a/server/src/globals/http.js b/server/src/globals/http.js index ddc0a65f..ca2d7d44 100644 --- a/server/src/globals/http.js +++ b/server/src/globals/http.js @@ -4,9 +4,17 @@ const http = require('http'); const express = require('express'); const morgan = require('morgan'); const config = require('./config'); +const logger = require('./logger').child('mediaMtxProxy'); +const { PUBLIC_MEDIA_PREFIX, createMediaMtxProxy } = require('../services/mediaMtxService/proxy'); const app = express(); 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 //whep or //whip URL. +*/ +app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger })); app.use(express.json()); app.use(express.static(config.staticDir, { index: false })); diff --git a/server/src/helpers/siteMetadata.js b/server/src/helpers/siteMetadata.js index af10385d..6b04732e 100644 --- a/server/src/helpers/siteMetadata.js +++ b/server/src/helpers/siteMetadata.js @@ -87,6 +87,7 @@ function resolveSiteMetadata(config = loadConfig()) { const interInstance = config?.interInstance; const profile = interInstance?.profile; const profileName = asTrimmedString(profile?.name); + const publicUrl = normalizePublicUrl(config?.publicUrl); /* A partially filled profile must not unexpectedly rename the site. The @@ -95,7 +96,11 @@ function resolveSiteMetadata(config = loadConfig()) { the coherent default set above. */ 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; @@ -110,7 +115,7 @@ function resolveSiteMetadata(config = loadConfig()) { BACKGROUND_BLEND_AMOUNT, ), accentTextColor: getReadableAccentText(accentColor), - publicUrl: normalizePublicUrl(profile.publicUrl), + publicUrl, }; } diff --git a/server/src/services/audioForwardService/index.js b/server/src/services/audioForwardService/index.js index 9f276e81..6451d8de 100644 --- a/server/src/services/audioForwardService/index.js +++ b/server/src/services/audioForwardService/index.js @@ -61,7 +61,6 @@ let operations; function replaceAudioForwardRuntime(fullConfig) { operations?.stopAllWorkers('configuration-change'); const audioForwardConfig = fullConfig.audioForward || {}; - const mediaConfig = fullConfig.media || {}; serviceEnabled = Boolean(audioForwardConfig.enabled); const streamSuffix = typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim() ? audioForwardConfig.streamSuffix.trim() @@ -72,7 +71,6 @@ function replaceAudioForwardRuntime(fullConfig) { roverManager, turnService, streamSuffix, - mediaConfig, }); const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes) ? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes)) @@ -102,7 +100,7 @@ function replaceAudioForwardRuntime(fullConfig) { replaceAudioForwardRuntime(loadConfig()); // Stable delegates keep the one-time socket/event registrations below pointed -// at the newest policy and worker engine after either audio or media changes. +// at the newest policy and worker engine after audio-forward changes. const delegate = (name) => (...args) => operations[name](...args); const ensureWorker = delegate('ensureWorker'); const stopWorker = delegate('stopWorker'); @@ -166,9 +164,6 @@ registerChargeCompleteSound({ registerConfigurationHandler('audioForward', (_section, _previous, nextConfig) => { replaceAudioForwardRuntime(nextConfig); }); -registerConfigurationHandler('media', (_section, _previous, nextConfig) => { - replaceAudioForwardRuntime(nextConfig); -}); module.exports = { getAudioForwardState, diff --git a/server/src/services/audioForwardService/policy.js b/server/src/services/audioForwardService/policy.js index 02134f89..de76eab0 100644 --- a/server/src/services/audioForwardService/policy.js +++ b/server/src/services/audioForwardService/policy.js @@ -1,6 +1,8 @@ // audio Forward Service policy // Purpose: Encapsulates permission checks and media path/url derivation helpers. // Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic. +const { PUBLIC_MEDIA_PREFIX } = require('../mediaMtxService/proxy'); + function createAudioForwardPolicy(deps) { const { isVerified, @@ -8,7 +10,6 @@ function createAudioForwardPolicy(deps) { roverManager, turnService, streamSuffix, - mediaConfig, } = deps; function ensureVipVerified(socket) { @@ -43,23 +44,8 @@ function createAudioForwardPolicy(deps) { 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) { - const prefix = getMediaPrefix(); - if (!prefix) { - throw new Error('Server media base URL missing'); - } - return `${prefix}/${encodeURIComponent(pathId)}/whip`; + return `${PUBLIC_MEDIA_PREFIX}/${encodeURIComponent(pathId)}/whip`; } return { diff --git a/server/src/services/audioForwardService/policy.test.js b/server/src/services/audioForwardService/policy.test.js index 26019289..6c20f840 100644 --- a/server/src/services/audioForwardService/policy.test.js +++ b/server/src/services/audioForwardService/policy.test.js @@ -12,7 +12,6 @@ function createPolicy({ verified = true, muted = false, driver = true, canDrive roverManager: { isDriver: () => driver }, turnService: { canDrive: () => canDrive }, streamSuffix: '-fwd', - mediaConfig: {}, }); } @@ -30,3 +29,8 @@ test('publishes forwarded audio to the local MediaMTX RTSP path', () => { const policy = createPolicy(); 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'); +}); diff --git a/server/src/services/backupRestoreService/restore.js b/server/src/services/backupRestoreService/restore.js index b25bb266..2e2795ce 100644 --- a/server/src/services/backupRestoreService/restore.js +++ b/server/src/services/backupRestoreService/restore.js @@ -19,7 +19,7 @@ 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: 1, + configuration: 2, identity: 4, fleetReports: 0, }; diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 2b5aab9e..2a1e970e 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -203,7 +203,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) { isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), discordConfig, - siteUrl: String(discordConfig.siteUrl || ''), + publicUrl: String(config.publicUrl || ''), config, createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi), }; diff --git a/server/src/services/discordBotService/commands/replay.js b/server/src/services/discordBotService/commands/replay.js index b8779e69..32241f6a 100644 --- a/server/src/services/discordBotService/commands/replay.js +++ b/server/src/services/discordBotService/commands/replay.js @@ -33,7 +33,7 @@ function createReplayCommand({ getActiveDrivers, getNickname, rovers, - discordConfig, + config, }) { const sourceResolver = createReplaySourceResolver({ rovers, @@ -137,8 +137,8 @@ function createReplayCommand({ if (progressMessage?.edit) { await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); } - const siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, ''); - const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url; + const publicBaseUrl = String(config?.publicUrl || '').replace(/\/$/, ''); + const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url; await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS }); return; } catch (fallbackError) { diff --git a/server/src/services/discordBotService/commands/timeStatus.js b/server/src/services/discordBotService/commands/timeStatus.js index 5f5ef472..ebf0b5a9 100644 --- a/server/src/services/discordBotService/commands/timeStatus.js +++ b/server/src/services/discordBotService/commands/timeStatus.js @@ -3,12 +3,12 @@ // Scope: Builds a concise time embed for common zones and server local zone. const { EmbedBuilder } = require('discord.js'); -function createTimeStatusCommand({ config, discordConfig }) { +function createTimeStatusCommand({ config }) { function buildEmbed({ title, description, color, includeSiteUrl = true }) { const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); - const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; - if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); - else if (siteUrl) embed.setDescription(siteUrl); + const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : ''; + if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description); + else if (publicUrl) embed.setDescription(publicUrl); embed.setTimestamp(new Date()); return embed; } diff --git a/server/src/services/discordBotService/configuration.js b/server/src/services/discordBotService/configuration.js index c53faca7..5e3175e8 100644 --- a/server/src/services/discordBotService/configuration.js +++ b/server/src/services/discordBotService/configuration.js @@ -12,7 +12,6 @@ module.exports = { enabled: false, token: '', guildId: '123456789012345678', - siteUrl: 'https://rover.example.com', channels: { general: '123456789012345678', announcements: '123456789012345678', @@ -31,7 +30,6 @@ module.exports = { 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 }), 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({ 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 }), @@ -55,7 +53,7 @@ module.exports = { }), }, { title: 'Discord', - description: 'Optional Discord bot credentials, public URL, and notification routing.', - required: ['enabled', 'token', 'guildId', 'siteUrl', 'channels', 'roles'], + description: 'Optional Discord bot credentials and notification routing; public links use the top-level public URL.', + required: ['enabled', 'token', 'guildId', 'channels', 'roles'], }), }; diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index b62d2884..e779a5fb 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -206,8 +206,8 @@ registerPreferredDeliveryProvider({ } }, async completeFallback({ context, media }) { - const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, ''); - const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url; + const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, ''); + const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url; if (context?.progressMessage?.reply) { await context.progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, @@ -434,7 +434,7 @@ function applySharedConfigSection(section, value) { } registerConfigurationHandler('discord', applyDiscordConfig); -['commands', 'timezone', 'fleetReports'].forEach((section) => { +['commands', 'publicUrl', 'timezone', 'fleetReports'].forEach((section) => { registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value)); }); diff --git a/server/src/services/discordBotService/integrations/busEvents.js b/server/src/services/discordBotService/integrations/busEvents.js index 5e8d0035..fd8c44a8 100644 --- a/server/src/services/discordBotService/integrations/busEvents.js +++ b/server/src/services/discordBotService/integrations/busEvents.js @@ -5,15 +5,15 @@ const { EmbedBuilder, AttachmentBuilder } = require('discord.js'); const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds'); 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']); let skippedFirstModeAnnouncement = false; function buildEmbed({ title, description, color, includeSiteUrl = true }) { const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); - const siteUrl = includeSiteUrl && discordConfig.siteUrl ? String(discordConfig.siteUrl) : ''; - if (description) embed.setDescription(siteUrl ? `${description}\n\n${siteUrl}` : description); - else if (siteUrl) embed.setDescription(siteUrl); + const publicUrl = includeSiteUrl && config.publicUrl ? String(config.publicUrl) : ''; + if (description) embed.setDescription(publicUrl ? `${description}\n\n${publicUrl}` : description); + else if (publicUrl) embed.setDescription(publicUrl); embed.setTimestamp(new Date()); return embed; } diff --git a/server/src/services/discordBotService/userAnnouncements.js b/server/src/services/discordBotService/userAnnouncements.js index c7f0b5c6..621ec413 100644 --- a/server/src/services/discordBotService/userAnnouncements.js +++ b/server/src/services/discordBotService/userAnnouncements.js @@ -14,6 +14,7 @@ const WATCHED_EVENT_TYPES = new Set([ function createUserAnnouncements(deps) { const { discordConfig, + config, getMode, rovers, roverManager, @@ -27,7 +28,7 @@ function createUserAnnouncements(deps) { // 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 getSiteUrl = () => (discordConfig?.siteUrl ? String(discordConfig.siteUrl) : ''); + const getPublicUrl = () => (config?.publicUrl ? String(config.publicUrl) : ''); let previousSnapshot = buildSnapshot(); let skippedFirstModeChange = false; @@ -127,11 +128,11 @@ function createUserAnnouncements(deps) { }); } - const siteUrl = getSiteUrl(); - if (siteUrl) { + const publicUrl = getPublicUrl(); + if (publicUrl) { embed.addFields({ name: 'Join', - value: siteUrl, + value: publicUrl, inline: false, }); } diff --git a/server/src/services/interInstanceService/configuration.js b/server/src/services/interInstanceService/configuration.js index db5addb9..f2ebbaac 100644 --- a/server/src/services/interInstanceService/configuration.js +++ b/server/src/services/interInstanceService/configuration.js @@ -14,7 +14,6 @@ module.exports = { pollIntervalMs: 30000, requestTimeoutMs: 5000, profile: { - publicUrl: 'https://rover.example.com', name: 'Example Rover Server', description: 'A short public description of this rover server.', color: '#38bdf8', @@ -33,10 +32,9 @@ module.exports = { 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 }), 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 }), 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}$' }), - }, { 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'] }), }; diff --git a/server/src/services/interInstanceService/index.js b/server/src/services/interInstanceService/index.js index 28b7714b..7c416f67 100644 --- a/server/src/services/interInstanceService/index.js +++ b/server/src/services/interInstanceService/index.js @@ -59,7 +59,7 @@ function pollIntervalMs() { } function ownPublicUrl() { - return normalizeBaseUrl(interInstanceConfig.profile?.publicUrl); + return normalizeBaseUrl(loadConfig().publicUrl); } function ownInstanceId() { @@ -486,6 +486,15 @@ registerConfigurationHandler('interInstance', (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 = { getState, interInstanceEvents, diff --git a/server/src/services/mediaMtxService/config.js b/server/src/services/mediaMtxService/config.js index f89c5a4a..4b1e80ff 100644 --- a/server/src/services/mediaMtxService/config.js +++ b/server/src/services/mediaMtxService/config.js @@ -20,19 +20,17 @@ function normalizeAdditionalHosts(rawHosts) { function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) { const media = config?.media || {}; - let additionalHosts = normalizeAdditionalHosts(media.additionalHosts); - if (!additionalHosts.length && media.whepBaseUrl) { - try { - /* - Existing installations predate media.additionalHosts. Using the already-configured - WHEP hostname as a one-host migration default keeps them reachable on first restart; - administrators can still list every public and LAN candidate explicitly afterward. - */ - additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean); - } catch { - throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty'); - } + const configuredHosts = normalizeAdditionalHosts(media.additionalHosts); + let publicHostname = ''; + try { + publicHostname = new URL(config?.publicUrl).hostname; + } catch { + // The assembled configuration schema normally prevents this. Keeping the + // builder tolerant makes its pure tests and startup error path explicit. } + // 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; return { @@ -56,6 +54,9 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) { hls: false, 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', webrtcLocalTCPAddress: ':8189', webrtcAdditionalHosts: additionalHosts, diff --git a/server/src/services/mediaMtxService/config.test.js b/server/src/services/mediaMtxService/config.test.js index 8a7ba620..9d7f8efb 100644 --- a/server/src/services/mediaMtxService/config.test.js +++ b/server/src/services/mediaMtxService/config.test.js @@ -8,8 +8,8 @@ const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config'); test('generates RTSP over TCP without deployment-specific hardcodes', () => { const generated = buildMediaMtxConfig({ config: { + publicUrl: 'https://public.example.com', media: { - whepBaseUrl: 'http://media.internal:8889/video', additionalHosts: ['public.example.com', '10.20.30.40'], }, }, @@ -23,12 +23,13 @@ test('generates RTSP over TCP without deployment-specific hardcodes', () => { assert.equal(Object.hasOwn(generated, 'rtpAddress'), false); assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false); assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']); + assert.equal(generated.webrtcAddress, '127.0.0.1:8889'); 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({ - config: { media: { whepBaseUrl: 'https://second-server.example/video' } }, + config: { publicUrl: 'https://second-server.example', media: { additionalHosts: [] } }, serverPort: 8080, snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh', }); diff --git a/server/src/services/mediaMtxService/configuration.js b/server/src/services/mediaMtxService/configuration.js index a5248bed..0c2d384f 100644 --- a/server/src/services/mediaMtxService/configuration.js +++ b/server/src/services/mediaMtxService/configuration.js @@ -1,22 +1,18 @@ // 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. -const { strictObject, string, stringArray } = require('../../configuration/schemaHelpers'); +const { strictObject, stringArray } = require('../../configuration/schemaHelpers'); module.exports = { key: 'media', defaultValue: { - // Signaling remains server-local because the internal `/video` proxy owns - // 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'], + additionalHosts: [], }, 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({ 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 }, - 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'] }), }; diff --git a/server/src/services/mediaMtxService/index.js b/server/src/services/mediaMtxService/index.js index ed463a73..b7e1e78d 100644 --- a/server/src/services/mediaMtxService/index.js +++ b/server/src/services/mediaMtxService/index.js @@ -26,14 +26,17 @@ function stopSupervisor() { return new Promise((resolve) => supervisor.stop(resolve)); } -registerConfigurationHandler('media', async () => { +async function reloadMediaMtx() { // MediaMTX consumes a generated document rather than the Node configuration - // object directly. Replace its child process so every media setting is - // regenerated and applied as one coherent revision. + // 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 diff --git a/server/src/services/mediaMtxService/proxy.js b/server/src/services/mediaMtxService/proxy.js new file mode 100644 index 00000000..ea1350dd --- /dev/null +++ b/server/src/services/mediaMtxService/proxy.js @@ -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, +}; diff --git a/server/src/services/mediaMtxService/proxy.test.js b/server/src/services/mediaMtxService/proxy.test.js new file mode 100644 index 00000000..abf60d32 --- /dev/null +++ b/server/src/services/mediaMtxService/proxy.test.js @@ -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'); +}); diff --git a/server/src/services/mediaMtxService/supervisor.test.js b/server/src/services/mediaMtxService/supervisor.test.js index 2180743a..e7888ccd 100644 --- a/server/src/services/mediaMtxService/supervisor.test.js +++ b/server/src/services/mediaMtxService/supervisor.test.js @@ -37,7 +37,7 @@ test('passes the resolved data root to MediaMTX runOnReady hooks', () => { try { const supervisor = createMediaMtxSupervisor({ - config: { media: { additionalHosts: ['media.example.test'] } }, + config: { publicUrl: 'https://public.example.test', media: { additionalHosts: ['media.example.test'] } }, serverPort: 8080, logger, mediaMtxBin: '/test/bin/mediamtx', diff --git a/server/src/services/operatorCommandService/webTransport.js b/server/src/services/operatorCommandService/webTransport.js index bf789536..cc72f5f1 100644 --- a/server/src/services/operatorCommandService/webTransport.js +++ b/server/src/services/operatorCommandService/webTransport.js @@ -11,7 +11,7 @@ function formatTimeInZone(date, timeZone) { } } -function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' }) { +function createWebTransportHandlers({ rovers, roverManager, config, publicUrl = '' }) { return { async status(message, roverId) { const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null; @@ -59,7 +59,7 @@ function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' if (!zones.some(([, zone]) => zone.toLowerCase() === String(serverTimezone).toLowerCase())) { lines.push(`Server Local — ${formatTimeInZone(now, serverTimezone)} **(server local timezone)**`); } - const siteLink = siteUrl ? `\n\n${siteUrl}` : ''; + const siteLink = publicUrl ? `\n\n${publicUrl}` : ''; return message.reply(`Time Status\n${lines.join('\n')}${siteLink}\n\nServer local timezone: ${serverTimezone}`); }, }; diff --git a/server/src/services/sessionService/configuration.js b/server/src/services/sessionService/configuration.js index 9ceb24d9..fd79774d 100644 --- a/server/src/services/sessionService/configuration.js +++ b/server/src/services/sessionService/configuration.js @@ -3,6 +3,20 @@ // Scope: Contains configuration metadata only so the database can import it without initializing the session service. const { strictObject, string, boolean } = require('../../configuration/schemaHelpers'); +const publicUrl = { + key: 'publicUrl', + defaultValue: 'https://rover.example.com', + schema: string({ + title: 'Public URL', + description: 'Canonical public base URL used for links, peer identity, page metadata, and the public WebRTC hostname.', + examples: ['https://rover.example.com'], + format: 'uri', + pattern: '^https?://', + minLength: 1, + maxLength: 2048, + }), +}; + const timezone = { key: 'timezone', defaultValue: 'America/New_York', @@ -73,4 +87,4 @@ function getConfiguredSocials(config) { : []; } -module.exports = { timezone, socials, driverAd, getConfiguredSocials }; +module.exports = { publicUrl, timezone, socials, driverAd, getConfiguredSocials }; diff --git a/server/src/services/videoAuthService/streamParsing.js b/server/src/services/videoAuthService/streamParsing.js index d8ba20d6..8fde993d 100644 --- a/server/src/services/videoAuthService/streamParsing.js +++ b/server/src/services/videoAuthService/streamParsing.js @@ -1,40 +1,21 @@ // Video Auth Stream Parsing // Purpose: Parses MediaMTX path/body payloads into normalized stream targets for rover and room media checks. -// Scope: Handles WHEP/WHP path-prefix trimming and SRT streamid extraction without performing auth decisions. -const { loadConfig } = require('../../configuration'); +// Scope: Handles native MediaMTX WHEP/WHIP paths and SRT streamid extraction without performing auth decisions. const PTZ_STREAM_PATH = 'ptz-camera'; -function getPathPrefix() { - const base = loadConfig().media?.whepBaseUrl; - if (!base) return ''; - try { - const parsed = new URL(base); - return parsed.pathname || ''; - } catch { - return base.replace(/^[^/]*:\/\//, '').replace(/^[^/]+/, ''); - } -} - function extractStreamInfo(path) { - // The path prefix is tiny to derive and must follow media changes immediately; - // retaining it at module load would make auth disagree with newly issued URLs. - const whepPathPrefix = getPathPrefix().replace(/\/+$/, '').replace(/^\/+/, ''); - const whepPrefixSegments = whepPathPrefix ? whepPathPrefix.split('/').filter(Boolean) : []; + // Node removes its public /video mount before proxying, so MediaMTX reports + // only its native stream path to this authorization callback. const segments = (path || '').split('/').filter(Boolean); if (!segments.length) return null; - let start = 0; - if (whepPrefixSegments.length && whepPrefixSegments.every((segment, idx) => segments[idx] === segment)) { - start = whepPrefixSegments.length; - } - let end = segments.length; if (segments[end - 1] === 'whep' || segments[end - 1] === 'whip') { end -= 1; } - const remaining = segments.slice(start, end); + const remaining = segments.slice(0, end); if (remaining.length === 1) { const rawId = remaining[0] || ''; /* diff --git a/server/src/services/videoSocketService/index.js b/server/src/services/videoSocketService/index.js index ef5d3355..d5a9a210 100644 --- a/server/src/services/videoSocketService/index.js +++ b/server/src/services/videoSocketService/index.js @@ -9,31 +9,14 @@ const videoSessions = require('../videoSessions'); const roverManager = require('../roverManager'); const ptzCameraService = require('../ptzCameraService'); const turnService = require('../turnService'); -const { loadConfig } = require('../../configuration'); const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver'); +const { PUBLIC_MEDIA_PREFIX } = require('../mediaMtxService/proxy'); const { shouldUseSnapshotsForNonTurnVideo, shouldUseSnapshotsForExternalSpectatorVideo, } = require('../../helpers/bandwidthSavings'); -function getMediaPrefix() { - const base = loadConfig().media?.whepBaseUrl; - if (!base) { - return ''; - } - let prefix = base; - try { - const parsed = new URL(base); - prefix = `${parsed.origin}${parsed.pathname}`; - } catch (err) { - // leave prefix as-is when URL parsing fails; fall back to string cleanup below - } - return prefix.replace(/\/+$/, ''); -} - function buildWhepUrlForSource(source) { - const cleanBase = getMediaPrefix(); - if (!cleanBase) return ''; const segments = []; if (source.type === 'room') { segments.push('room', encodeURIComponent(source.id)); @@ -49,7 +32,9 @@ function buildWhepUrlForSource(source) { } else { segments.push(encodeURIComponent(source.id)); } - return `${cleanBase}/${segments.join('/')}/whep`; + // A same-origin path works through TLS proxies, LAN access, and future + // containers without exposing MediaMTX's internal listener to the browser. + return `${PUBLIC_MEDIA_PREFIX}/${segments.join('/')}/whep`; } function passesMode(socket) { @@ -173,9 +158,6 @@ io.on('connection', (socket) => { throw new Error('Unsupported video source'); } const url = buildWhepUrlForSource(target); - if (!url) { - throw new Error('Server video base URL missing'); - } const sessionId = videoSessions.createSession(socket, target); cb({ url, token: sessionId, type: target.type, id: target.id }); } catch (err) { diff --git a/to-do.md b/to-do.md index 29160d1e..d55809d8 100644 --- a/to-do.md +++ b/to-do.md @@ -9,8 +9,8 @@ and make sure each toggle and counter actually has a purpose besides debugging o 3. has a fancy big embed that shows way more stuff 4. also has a small text copy at the top (readable on smartwatch lol) 1. ```freaky: docked - wall-e: caydu - bweeble: docked``` + wall-e: user1 driving + bweeble: NEEDS HELP``` 2. setting to disable replay popups in spectator settings menu 3. add admin ui for VIP and private requests instead of only through discord 4. make roverd self update checkout to main branch