server restart thingy yay

This commit is contained in:
legop3
2026-09-14 18:42:42 -04:00
parent c56a8b1000
commit edc1b825f2
26 changed files with 330 additions and 312 deletions
+11
View File
@@ -342,6 +342,16 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
}));
}
function recordAuditEvent(actor, action, details = {}) {
/*
Operational admin services need the same persistent audit trail as
configuration changes, but they must not gain access to the underlying
statement or database handle. This narrow method retains the existing
redacted-details contract at the database boundary.
*/
writeAudit(actor, action, details);
}
const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => {
// A setup upload initializes an empty installation; it is deliberately not
// a general-purpose replacement path for a running server's configuration.
@@ -377,6 +387,7 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
countLockdownAdministrators,
isSetupComplete,
listAuditEvents,
recordAuditEvent,
importConfigurationFile,
close: () => db.close(),
};
@@ -208,4 +208,5 @@ io.on('connection', (socket) => {
module.exports = {
PASSWORD_CONFIRMATION_WINDOW_MS,
requireLockdownAdministrator,
requireRecentPassword,
};
+13
View File
@@ -15,3 +15,16 @@ httpServer.listen(config.port, () => {
*/
startMediaMtx();
});
function stopAcceptingConnections() {
/*
Child-process services already own their SIGTERM cleanup. The HTTP service
only stops accepting new work; MediaMTX's bounded signal handler remains
responsible for ending the Node process even if an existing socket keeps
the close callback waiting.
*/
if (httpServer.listening) httpServer.close();
}
process.once('SIGINT', stopAcceptingConnections);
process.once('SIGTERM', stopAcceptingConnections);
@@ -1,55 +1,54 @@
// server Control Service
// Purpose: Defines the server Control Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { spawn } = require('child_process');
// Purpose: Lets a lockdown administrator restart the Node application without rebooting or controlling the host.
// Scope: Authorizes and announces one restart request; existing SIGTERM hooks own service and child-process cleanup.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('serverControlService');
const { isAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const { getConfigurationDatabase } = require('../../configuration');
const { requireRecentPassword } = require('../adminConfigurationService');
const ALERT_COLOR = '#ff5722';
let rebootPending = false;
const database = getConfigurationDatabase();
let restartPending = false;
function scheduleSystemReboot() {
if (rebootPending) {
throw new Error('Server reboot already pending');
}
rebootPending = true;
function actorFor(socket) {
return socket?.data?.user?.username || socket.id;
}
function scheduleApplicationRestart() {
if (restartPending) throw new Error('Application restart already pending.');
restartPending = true;
/*
Socket acknowledgements are asynchronous network writes. This short delay
lets the response and restarting notification leave before SIGTERM invokes
the cleanup hooks already owned by the server's long-running services.
*/
setTimeout(() => {
logger.warn('Issuing system reboot command');
try {
const child = spawn('systemctl', ['reboot'], {
detached: true,
stdio: 'ignore',
});
child.unref();
} catch (err) {
rebootPending = false;
logger.error('Server reboot command failed', err.message);
process.kill(process.pid, 'SIGTERM');
} catch (error) {
// If signaling fails, this process is still usable and must allow the
// administrator to try again instead of remaining permanently pending.
restartPending = false;
logger.error('Application restart signal failed', error.message);
}
}, 400);
}, 250);
}
io.on('connection', (socket) => {
socket.on('server:reboot', (_, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
try {
scheduleSystemReboot();
const who = socket?.data?.user?.username || socket.id;
logger.warn('Server reboot requested', { by: who });
sendAlert({
color: ALERT_COLOR,
title: 'Server Reboot',
message: `Reboot requested by ${who}`,
});
requireRecentPassword(socket);
const actor = actorFor(socket);
if (restartPending) throw new Error('Application restart already pending.');
database.recordAuditEvent(actor, 'application.restart-requested');
scheduleApplicationRestart();
logger.warn('Application restart requested', { actor });
cb({ success: true });
} catch (err) {
cb({ error: err.message });
// Every connected browser receives one explicit reason for the upcoming
// disconnect instead of interpreting the brief outage as a network fault.
io.emit('server:restarting', { reason: 'administrator-requested' });
} catch (error) {
cb({ error: error.message, code: error.code || null });
}
});
});