bandwidth savings configs

This commit is contained in:
legop3
2026-07-14 17:04:08 -04:00
parent 1c401ff90a
commit 0d6b4d68de
21 changed files with 424 additions and 39 deletions
+108
View File
@@ -0,0 +1,108 @@
// Bandwidth Savings Helper
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
// making individual services know raw YAML defaults or legacy config shapes.
const { loadConfig } = require('./configLoader');
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
const VIDEO_MODES = new Set(['snapshots', 'live']);
const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'admin']);
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
multiTabProtection: 'verifiedOnly',
nonTurnVideo: 'snapshots',
externalSpectatorVideo: 'snapshots',
externalSpectatorAccess: 'on',
});
function normalizeEnum(value, allowed, fallback) {
/*
Config files are hand-edited on the server, so a typo should not crash the
process or silently broaden access. Each option falls back to the current
conservative behavior unless it exactly matches a known value.
*/
const normalized = typeof value === 'string' ? value.trim() : '';
return allowed.has(normalized) ? normalized : fallback;
}
function buildBandwidthSavingsPolicy(config = loadConfig()) {
const raw = config.bandwidthSavings || {};
return {
multiTabProtection: normalizeEnum(
raw.multiTabProtection,
MULTI_TAB_MODES,
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
),
nonTurnVideo: normalizeEnum(
raw.nonTurnVideo,
VIDEO_MODES,
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
),
externalSpectatorVideo: normalizeEnum(
raw.externalSpectatorVideo,
VIDEO_MODES,
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorVideo,
),
externalSpectatorAccess: normalizeEnum(
raw.externalSpectatorAccess,
EXTERNAL_SPECTATOR_ACCESS_MODES,
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorAccess,
),
};
}
function getBandwidthSavingsPolicy() {
/*
loadConfig() is cached by configLoader, so rebuilding this small object per
caller is cheap while still letting tests pass explicit config objects into
buildBandwidthSavingsPolicy().
*/
return buildBandwidthSavingsPolicy(loadConfig());
}
function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } = {}) {
const { multiTabProtection } = getBandwidthSavingsPolicy();
if (multiTabProtection === 'allowed') return false;
if (multiTabProtection === 'notAllowed') return true;
/*
verifiedOnly preserves the old behavior: trusted users can run multiple
driver tabs for operations/testing, while anonymous users are limited to one
active driver surface for fairness and bandwidth.
*/
return !isVerified && !isAdmin;
}
function shouldUseSnapshotsForNonTurnVideo() {
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots';
}
function shouldUseSnapshotsForExternalSpectatorVideo() {
return getBandwidthSavingsPolicy().externalSpectatorVideo === 'snapshots';
}
function canUseExternalSpectatorAccess({
isLocal = false,
isAdmin = false,
hasGrant = false,
} = {}) {
/*
Local/LAN spectators are not the upload-bandwidth problem, and admins need
to retain access for maintenance. The configured external mode only applies
to ordinary non-local spectator sockets.
*/
if (isLocal || isAdmin) return true;
const { externalSpectatorAccess } = getBandwidthSavingsPolicy();
if (externalSpectatorAccess === 'off') return false;
if (externalSpectatorAccess === 'admin') return Boolean(hasGrant);
return true;
}
module.exports = {
DEFAULT_BANDWIDTH_SAVINGS,
buildBandwidthSavingsPolicy,
getBandwidthSavingsPolicy,
shouldEnforceSingleDriverTab,
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
canUseExternalSpectatorAccess,
};
+52 -1
View File
@@ -8,9 +8,19 @@ const { loadConfig } = require('../../helpers/configLoader');
const { clearLockdownTimer } = require('../lockdownGuard');
const { getMode, MODES } = require('../modeManager');
const { setRole } = require('../roleService');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const {
canUseExternalSpectatorAccess,
getBandwidthSavingsPolicy,
} = require('../../helpers/bandwidthSavings');
const {
getFeatureState,
getUserIdForSocket,
} = require('../identityService');
const config = loadConfig();
const admins = config.admins || [];
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
function findAdmin(username) {
return admins.find((admin) => admin.username === username);
@@ -36,9 +46,44 @@ function isLockdownAdmin(socket) {
return socket?.data?.role === 'lockdown';
}
function hasExternalSpectatorGrant(socket) {
const userId = getUserIdForSocket(socket);
if (!userId) return false;
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
/*
The identity database already owns per-user feature state. Keeping the grant
as a tiny namespaced boolean avoids a new table and lets the existing admin
database editor grant/revoke external spectator access immediately.
*/
return Boolean(state?.external);
}
function canBecomeSpectator(socket) {
const ip = getSocketIp(socket);
const local = isLocalNetwork(ip);
return canUseExternalSpectatorAccess({
isLocal: local,
isAdmin: isAdmin(socket),
hasGrant: hasExternalSpectatorGrant(socket),
});
}
function externalSpectatorAccessError() {
const mode = getBandwidthSavingsPolicy().externalSpectatorAccess;
if (mode === 'admin') {
return 'External spectator access requires admin approval for this identity.';
}
return 'External spectator access is disabled.';
}
io.on('connection', (socket) => {
const requestedRole = socket.handshake?.query?.role;
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
/*
Role is assigned before the browser's full identity heartbeat has completed.
For admin-gated external spectators, fail closed here; the spectator page can
identify the socket and then retry session:setRole once the grant exists.
*/
const initialRole = requestedRole === 'spectator' && canBecomeSpectator(socket) ? 'spectator' : 'user';
setRole(socket, initialRole);
logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole });
@@ -63,6 +108,12 @@ io.on('connection', (socket) => {
function handleRoleChange({ role } = {}, cb = () => {}) {
if (role === 'spectator' || role === 'user') {
if (role === 'spectator' && !canBecomeSpectator(socket)) {
const error = externalSpectatorAccessError();
logger.info('Spectator role denied by bandwidth policy', socket.id, { error });
cb({ error });
return;
}
setRole(socket, role);
socket.emit('auth:role', { role });
logger.info('Role changed via client request', socket.id, role);
+24 -1
View File
@@ -11,6 +11,10 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('ptzCamera');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const {
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const { isVerified } = require('../verificationService');
@@ -1291,7 +1295,26 @@ function canRequestLiveVideo(socket) {
if (!enabled || !passesMode(socket)) return false;
if (state.operatorSocketId === socket?.id) return true;
if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
return isLocalNetwork(getSocketIp(socket));
const role = getRole(socket);
const local = isLocalNetwork(getSocketIp(socket));
if (role === 'spectator') {
/*
Spectator PTZ viewing follows the spectator bandwidth switch. LAN viewers
stay live because they do not consume server upload; non-local spectators
only get live PTZ when the external spectator video policy allows it.
*/
return local || !shouldUseSnapshotsForExternalSpectatorVideo();
}
if (canUsePtzFeature(socket) && !shouldUseSnapshotsForNonTurnVideo()) {
/*
Verified/VIP users who can queue or claim the camera are PTZ "turn"
participants even before they become operator. When non-turn video is set
to live, they may watch the live feed while waiting; camera movement still
remains limited to the active operator by the command handlers.
*/
return true;
}
return false;
}
function getSnapshotPath() {
+39 -1
View File
@@ -3,7 +3,7 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('sessionService');
const { getRole, roleEvents } = require('../roleService');
const { getRole, isAdmin, roleEvents } = require('../roleService');
const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
const { managerEvents } = roverManager;
@@ -39,6 +39,14 @@ const { getAdminReason } = require('../adminReasonService');
const { subscribe } = require('../eventBus');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const { getFeatureFlags } = require('../../helpers/features');
const {
canUseExternalSpectatorAccess,
getBandwidthSavingsPolicy,
} = require('../../helpers/bandwidthSavings');
const {
getFeatureState,
getUserIdForSocket,
} = require('../identityService');
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
const { getButtonBoxState } = require('../buttonBoxService');
@@ -62,6 +70,35 @@ logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configure
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
function hasExternalSpectatorGrant(socket) {
const userId = getUserIdForSocket(socket);
if (!userId) return false;
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
return Boolean(state?.external);
}
function buildBandwidthSavingsSessionState(socket) {
const policy = getBandwidthSavingsPolicy();
const local = isLocalNetwork(getSocketIp(socket));
const granted = hasExternalSpectatorGrant(socket);
return {
...policy,
/*
These derived fields let browser routes make clear UI choices without
re-implementing IP/admin/grant logic. The server still enforces the same
decisions in auth and video services, so the UI remains advisory only.
*/
externalSpectatorGranted: granted,
canUseExternalSpectatorAccess: canUseExternalSpectatorAccess({
isLocal: local,
isAdmin: isAdmin(socket),
hasGrant: granted,
}),
};
}
function buildUserEntry(socket) {
if (!socket) return null;
const role = getRole(socket);
@@ -110,6 +147,7 @@ function buildSession(socket) {
role: getRole(socket),
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket),
/*
Features is the single UI contract for optional server capabilities. A
disabled feature should be absent from navigation/layout decisions even
@@ -31,6 +31,7 @@ const {
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
@@ -103,7 +104,7 @@ function identifySocket(socket, payload = {}) {
nickname: incomingNickname || getNickname(socket) || '',
});
refreshSocketIdentityFlags(socket);
enforceSingleUnverifiedSocketPerIdentity(socket);
enforceSingleDriverSocketPerIdentity(socket);
emitChange('identify', { socketId: socket.id, userId: result.userId });
return {
@@ -132,13 +133,18 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
}
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
function enforceSingleDriverSocketPerIdentity(currentSocket) {
const currentUserId = getUserIdForSocket(currentSocket);
const currentRole = getRole(currentSocket);
const enforceForCurrentSocket = shouldEnforceSingleDriverTab({
isVerified: Boolean(currentSocket?.data?.isVerified),
isAdmin: isAdminRole(currentRole),
});
if (
!currentSocket?.id ||
!currentUserId ||
currentSocket.data?.isVerified ||
currentSocket.data?.identitySurface !== 'driver'
currentSocket.data?.identitySurface !== 'driver' ||
!enforceForCurrentSocket
) {
return;
}
@@ -150,9 +156,21 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
});
if (!duplicates.length) return;
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
const verifiedDuplicate = duplicates.find((candidate) => {
/*
verifiedOnly keeps the previous "verified tab wins" rule. In notAllowed
mode, verified users are subject to the same single-driver-tab rule, so a
verified duplicate should not protect the newer socket from enforcement.
*/
const candidateRole = getRole(candidate);
const enforceForCandidate = shouldEnforceSingleDriverTab({
isVerified: Boolean(candidate?.data?.isVerified),
isAdmin: isAdminRole(candidateRole),
});
return candidate?.data?.isVerified && !enforceForCandidate;
});
if (verifiedDuplicate) {
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', {
logger.info('Disconnecting driver socket because its user is already active on an exempt verified socket', {
socketId: currentSocket.id,
retainedSocketId: verifiedDuplicate.id,
userId: currentUserId,
@@ -162,7 +180,7 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
}
duplicates.forEach((duplicate) => {
logger.info('Disconnecting older non-verified duplicate user socket', {
logger.info('Disconnecting older duplicate driver socket', {
socketId: duplicate.id,
retainedSocketId: currentSocket.id,
userId: currentUserId,
+14 -1
View File
@@ -1,6 +1,11 @@
// Video Auth Policy
// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks.
// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state.
const {
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
function createVideoAuthPolicy(deps) {
const {
getMode,
@@ -63,7 +68,7 @@ function createVideoAuthPolicy(deps) {
const isAudio = streamInfo.id?.endsWith('-audio');
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
const socketIp = getSocketIp(socket);
if (!isLocalNetwork(socketIp)) {
if (!isLocalNetwork(socketIp) && shouldUseSnapshotsForExternalSpectatorVideo()) {
return false;
}
}
@@ -73,6 +78,14 @@ function createVideoAuthPolicy(deps) {
if (!roverManager.isDriver(roverId, socket)) {
return false;
}
if (!isAudio && shouldUseSnapshotsForNonTurnVideo() && !turnService.canDrive(roverId, socket)) {
/*
This mirrors videoSocketService's token gate. MediaMTX can ask auth
after a token has been issued, so the active-turn bandwidth rule must
be evaluated here too instead of trusting an older browser decision.
*/
return false;
}
}
return true;
@@ -8,8 +8,13 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const videoSessions = require('../videoSessions');
const roverManager = require('../roverManager');
const ptzCameraService = require('../ptzCameraService');
const turnService = require('../turnService');
const { loadConfig } = require('../../helpers/configLoader');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const {
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
const config = loadConfig();
const mediaConfig = config.media || {};
@@ -116,10 +121,25 @@ io.on('connection', (socket) => {
const role = getRole(socket);
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
const ip = getSocketIp(socket);
if (!isLocalNetwork(ip)) {
if (!isLocalNetwork(ip) && shouldUseSnapshotsForExternalSpectatorVideo()) {
throw new Error('Not authorized for video');
}
}
if (
!isAudio &&
role !== 'spectator' &&
!isAdmin(socket) &&
shouldUseSnapshotsForNonTurnVideo() &&
!turnService.canDrive(baseId, socket)
) {
/*
The browser also forces snapshots for non-active turn holders, but
the socket token path must enforce the same rule. Otherwise a stale
component or direct socket caller could still mint a MediaMTX token
while the UI is showing snapshots.
*/
throw new Error('Live video is limited to the active turn');
}
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
} else if (target.type === 'ptz') {