mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
server restart thingy yay
This commit is contained in:
@@ -305,7 +305,10 @@ Environment=NODE_ENV=production
|
||||
Environment=SERVER_DATA_DIR=$DATA_DIR
|
||||
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
# Application-requested restarts use the same clean SIGTERM path as an
|
||||
# operator stop. Restart=always lets that process exit come back automatically,
|
||||
# while an explicit `systemctl stop` still remains stopped by systemd design.
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
SuccessExitStatus=130 143
|
||||
|
||||
|
||||
+36
-36
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-Dt89YsCl.js";import{e as q,f as D,h as I}from"./api-DxMdZ4GH.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-DfXTyMt5.js.map
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-BwGg4g8v.js";import{f as q,h as D,j as I}from"./api-B_aBHpPa.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-TGICTsKn.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
function n(i,t,a={}){return new Promise((e,s)=>{i.emit(t,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=i=>n(i,"adminConfig:get"),m=(i,t)=>n(i,"adminConfig:confirmPassword",{password:t}),c=(i,t)=>n(i,"adminConfig:updateConfiguration",t),u=(i,t)=>n(i,"adminConfig:importConfigurationFile",t),f=(i,t)=>n(i,"adminConfig:restoreRevision",t),g=(i,t)=>n(i,"adminConfig:createAdministrator",t),C=(i,t)=>n(i,"adminConfig:updateAdministrator",t),l=(i,t)=>n(i,"adminConfig:deleteAdministrator",{id:t}),A=i=>n(i,"server:restartApplication"),p=i=>n(i,"setup:status"),v=(i,t)=>n(i,"setup:createAdministrator",t),F=(i,t)=>n(i,"setup:importConfigurationFile",t);export{A as a,c as b,g as c,l as d,m as e,p as f,d as g,v as h,u as i,F as j,f as r,C as u};
|
||||
//# sourceMappingURL=api-B_aBHpPa.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-B_aBHpPa.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\nexport const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","restartApplication","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EACxGC,EAAsBjB,GAAWD,EAAiBC,EAAQ,2BAA2B,EAErFkB,EAAkBlB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEmB,EAA2B,CAACnB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GkB,EAA0B,CAACpB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
@@ -1,2 +0,0 @@
|
||||
function n(i,t,a={}){return new Promise((e,s)=>{i.emit(t,a,(o={})=>{if(o?.error){const r=new Error(o.error);r.code=o.code||null,r.validationErrors=o.validationErrors||[],r.currentRevision=o.currentRevision||null,s(r);return}e(o)})})}const d=i=>n(i,"adminConfig:get"),m=(i,t)=>n(i,"adminConfig:confirmPassword",{password:t}),u=(i,t)=>n(i,"adminConfig:updateConfiguration",t),c=(i,t)=>n(i,"adminConfig:importConfigurationFile",t),f=(i,t)=>n(i,"adminConfig:restoreRevision",t),g=(i,t)=>n(i,"adminConfig:createAdministrator",t),C=(i,t)=>n(i,"adminConfig:updateAdministrator",t),l=(i,t)=>n(i,"adminConfig:deleteAdministrator",{id:t}),A=i=>n(i,"setup:status"),p=(i,t)=>n(i,"setup:createAdministrator",t),v=(i,t)=>n(i,"setup:importConfigurationFile",t);export{u as a,m as b,g as c,l as d,A as e,p as f,d as g,v as h,c as i,f as r,C as u};
|
||||
//# sourceMappingURL=api-DxMdZ4GH.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"api-DxMdZ4GH.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EAExGC,EAAkBjB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEkB,EAA2B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GiB,EAA0B,CAACnB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-Dt89YsCl.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BwGg4g8v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user