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
@@ -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 });
}
});
});