mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
/video signaling proxy and a global public server http path config item
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -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 /<path>/whep or /<path>/whip URL.
|
||||
*/
|
||||
app.use(PUBLIC_MEDIA_PREFIX, createMediaMtxProxy({ logger }));
|
||||
app.use(express.json());
|
||||
app.use(express.static(config.staticDir, { index: false }));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'] }),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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'] }),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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] || '';
|
||||
/*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user