mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
backup / restoreslop
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
const backupRestoreService = require('./src/services/backupRestoreService');
|
||||
|
||||
// A staged restore must replace data before configuration, identity, or report
|
||||
// services open SQLite. Requiring the service here is safe because its runtime
|
||||
// HTTP/socket dependencies remain lazy until register() is called below.
|
||||
backupRestoreService.applyPendingRestore();
|
||||
|
||||
require('./src/globals/logger');
|
||||
require('./src/globals/config');
|
||||
require('./src/globals/http');
|
||||
@@ -66,4 +73,5 @@ require('./src/services/replayEngineV2');
|
||||
// Discord feature so web requests always have a local delivery path.
|
||||
require('./src/services/replayDeliveryService');
|
||||
require('./src/services/discordBotService');
|
||||
backupRestoreService.register();
|
||||
require('./src/services/httpServer');
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"reolink-nvr-api": "^0.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.7.5",
|
||||
"tar": "^7.5.22",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
+41
-41
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-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
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-ClagM2Tt.js";import{l as q,m as D,n as I}from"./api-BVIrbPAA.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-D6F60Ddu.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
function o(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const n=new Error(r.error);n.code=r.code||null,n.validationErrors=r.validationErrors||[],n.currentRevision=r.currentRevision||null,s(n);return}e(r)})})}const c=t=>o(t,"adminConfig:get"),u=(t,i)=>o(t,"adminConfig:confirmPassword",{password:i}),d=(t,i)=>o(t,"adminConfig:updateConfiguration",i),m=(t,i)=>o(t,"adminConfig:importConfigurationFile",i),f=(t,i)=>o(t,"adminConfig:restoreRevision",i),p=(t,i)=>o(t,"adminConfig:createAdministrator",i),l=(t,i)=>o(t,"adminConfig:updateAdministrator",i),g=(t,i)=>o(t,"adminConfig:deleteAdministrator",{id:i}),C=t=>o(t,"server:restartApplication"),A=t=>o(t,"backupRestore:status"),R=t=>o(t,"backupRestore:createBackup"),k=t=>o(t,"backupRestore:createRestoreUpload"),v=(t,i)=>o(t,"backupRestore:confirmRestore",{restoreId:i}),F=t=>o(t,"setup:status"),b=(t,i)=>o(t,"setup:createAdministrator",i),w=(t,i)=>o(t,"setup:importConfigurationFile",i);export{C as a,R as b,p as c,g as d,k as e,v as f,A as g,d as h,m as i,c as j,u as k,F as l,b as m,w as n,f as r,l as u};
|
||||
//# sourceMappingURL=api-BVIrbPAA.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-BVIrbPAA.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');\nexport const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');\nexport const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');\nexport const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');\nexport const confirmFullRestore = (socket, restoreId) => emitAdminRequest(socket, 'backupRestore:confirmRestore', { restoreId });\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","getBackupRestoreStatus","createFullBackup","createRestoreUpload","confirmFullRestore","restoreId","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,EACrFkB,EAA0BlB,GAAWD,EAAiBC,EAAQ,sBAAsB,EACpFmB,EAAoBnB,GAAWD,EAAiBC,EAAQ,4BAA4B,EACpFoB,EAAuBpB,GAAWD,EAAiBC,EAAQ,mCAAmC,EAC9FqB,EAAqB,CAACrB,EAAQsB,IAAcvB,EAAiBC,EAAQ,+BAAgC,CAAE,UAAAsB,CAAS,CAAE,EAElHC,EAAkBvB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEwB,EAA2B,CAACxB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GuB,EAA0B,CAACzB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
@@ -1,2 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
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-BwGg4g8v.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-ClagM2Tt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -372,6 +372,15 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return importConfigurationFileTransaction(payload);
|
||||
}
|
||||
|
||||
function backupDatabase(destinationPath) {
|
||||
/*
|
||||
SQLite's online backup API produces one coherent database file while the
|
||||
live WAL-backed connection remains open. The backup service receives only
|
||||
this narrow operation, never the private database handle.
|
||||
*/
|
||||
return db.backup(destinationPath);
|
||||
}
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
getActiveConfigurationRecord,
|
||||
@@ -389,6 +398,7 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
listAuditEvents,
|
||||
recordAuditEvent,
|
||||
importConfigurationFile,
|
||||
backupDatabase,
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Full Data Backup
|
||||
// Purpose: Creates one validated archive of the durable server data without stopping live services or writers.
|
||||
// Scope: Uses service-owned online database snapshots and stable file copies inside the canonical data directory.
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const tar = require('tar');
|
||||
const Database = require('better-sqlite3');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const packageInfo = require('../../../package.json');
|
||||
|
||||
const FORMAT_VERSION = 1;
|
||||
const CONTROL_DIR_NAME = 'backup-restore';
|
||||
const EXCLUDED_TOP_LEVEL_NAMES = new Set([CONTROL_DIR_NAME, 'runtime']);
|
||||
const DATABASE_NAMES = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite'];
|
||||
const DATABASE_FILES = new Set(DATABASE_NAMES.flatMap((name) => [name, `${name}-wal`, `${name}-shm`]));
|
||||
const FILE_COPY_ATTEMPTS = 3;
|
||||
|
||||
function readDatabaseSchemaVersions(payloadDir) {
|
||||
const configuration = new Database(path.join(payloadDir, 'configuration.sqlite'), { readonly: true });
|
||||
const identity = new Database(path.join(payloadDir, 'identity.sqlite'), { readonly: true });
|
||||
const fleetReports = new Database(path.join(payloadDir, 'fleet-reports.sqlite'), { readonly: true });
|
||||
try {
|
||||
return {
|
||||
configuration: Number(configuration.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version) || 0,
|
||||
identity: Number(identity.pragma('user_version', { simple: true })) || 0,
|
||||
// Fleet reporting currently evolves with additive startup checks and has
|
||||
// no numbered migration table, so zero accurately identifies that scheme.
|
||||
fleetReports: Number(fleetReports.pragma('user_version', { simple: true })) || 0,
|
||||
};
|
||||
} finally {
|
||||
configuration.close();
|
||||
identity.close();
|
||||
fleetReports.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function copyStableFile(sourcePath, destinationPath) {
|
||||
for (let attempt = 0; attempt < FILE_COPY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const before = await fsp.stat(sourcePath);
|
||||
if (!before.isFile()) throw new Error(`Backup source is not a regular file: ${sourcePath}`);
|
||||
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fsp.copyFile(sourcePath, destinationPath);
|
||||
await fsp.chmod(destinationPath, before.mode & 0o777);
|
||||
|
||||
/*
|
||||
A writer may replace or append to a media file while it is copied. Size
|
||||
and timestamp checks catch ordinary changes, while comparing hashes
|
||||
catches a same-size replacement. Only the unstable file is retried;
|
||||
no owning service is paused.
|
||||
*/
|
||||
const [sourceHash, destinationHash] = await Promise.all([
|
||||
sha256File(sourcePath),
|
||||
sha256File(destinationPath),
|
||||
]);
|
||||
// Read the final metadata only after both hashes finish. Starting this
|
||||
// stat concurrently could miss a write that occurred during hashing.
|
||||
const after = await fsp.stat(sourcePath);
|
||||
if (before.size === after.size
|
||||
&& before.mtimeMs === after.mtimeMs
|
||||
&& sourceHash === destinationHash) return true;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
// A rotating snapshot can disappear between directory enumeration and
|
||||
// copying. Treat that exact race like any other unstable active file.
|
||||
}
|
||||
await fsp.rm(destinationPath, { force: true });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(path.join(sourceDir, relativeDir), { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const skipped = [];
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
||||
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
|
||||
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
|
||||
if (entry.isDirectory()) {
|
||||
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) throw new Error(`Backup cannot include special file: ${relativePath}`);
|
||||
const copied = await copyStableFile(
|
||||
path.join(sourceDir, relativePath),
|
||||
path.join(destinationDir, relativePath),
|
||||
);
|
||||
if (!copied) skipped.push(relativePath.split(path.sep).join('/'));
|
||||
}
|
||||
return skipped;
|
||||
}
|
||||
|
||||
async function listManifestFiles(rootDir, relativeDir = '') {
|
||||
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await listManifestFiles(rootDir, relativePath));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) throw new Error(`Backup staging contains a special file: ${relativePath}`);
|
||||
const filePath = path.join(rootDir, relativePath);
|
||||
const stat = await fsp.stat(filePath);
|
||||
files.push({
|
||||
path: relativePath.split(path.sep).join('/'),
|
||||
size: stat.size,
|
||||
sha256: await sha256File(filePath),
|
||||
});
|
||||
}
|
||||
return files.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
async function createFullBackup({ configurationDatabase, identityService, fleetReportService, jobId }) {
|
||||
const dataDir = resolveDataDir();
|
||||
const jobDir = resolveDataPath(path.join(CONTROL_DIR_NAME, `backup-${jobId}`));
|
||||
const payloadDir = path.join(jobDir, 'data');
|
||||
const archivePath = path.join(jobDir, 'multirover-backup.tar.gz');
|
||||
await fsp.rm(jobDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(payloadDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Each database owner remains live and writes a coherent SQLite snapshot
|
||||
// directly into the same staging tree as the ordinary durable files.
|
||||
await Promise.all([
|
||||
configurationDatabase.backupDatabase(path.join(payloadDir, DATABASE_NAMES[0])),
|
||||
identityService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[1])),
|
||||
fleetReportService.backupDatabase(path.join(payloadDir, DATABASE_NAMES[2])),
|
||||
]);
|
||||
const skippedUnstableFiles = await copyDurableTree(dataDir, payloadDir);
|
||||
const files = await listManifestFiles(payloadDir);
|
||||
const manifest = {
|
||||
format: 'multirover-full-backup',
|
||||
formatVersion: FORMAT_VERSION,
|
||||
applicationVersion: packageInfo.version,
|
||||
createdAt: Date.now(),
|
||||
databaseSchemaVersions: readDatabaseSchemaVersions(payloadDir),
|
||||
files,
|
||||
skippedUnstableFiles,
|
||||
};
|
||||
await fsp.writeFile(path.join(jobDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
await tar.c({ cwd: jobDir, file: archivePath, gzip: true, portable: true }, ['manifest.json', 'data']);
|
||||
return { archivePath, jobDir, manifest };
|
||||
} catch (error) {
|
||||
await fsp.rm(jobDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONTROL_DIR_NAME,
|
||||
DATABASE_NAMES,
|
||||
FORMAT_VERSION,
|
||||
createFullBackup,
|
||||
readDatabaseSchemaVersions,
|
||||
sha256File,
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
// Backup and Restore Service Tests
|
||||
// Purpose: Verifies complete archive round trips, exclusions, malicious entry rejection, startup replacement, and rollback.
|
||||
// Scope: Uses one isolated SERVER_DATA_DIR and injected SQLite owners; it never reads or changes development server data.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const tar = require('tar');
|
||||
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-backup-restore-'));
|
||||
process.env.SERVER_DATA_DIR = temporaryRoot;
|
||||
|
||||
const { createFullBackup } = require('./backup');
|
||||
const { inspectArchive, prepareRestoreArchive, validateExtractedRestore } = require('./restore');
|
||||
const startupRestore = require('./startupRestore');
|
||||
const VALID_RESTORE_ID = 'a'.repeat(64);
|
||||
|
||||
function createSourceDatabase(name) {
|
||||
const filePath = path.join(temporaryRoot, `source-${name}`);
|
||||
const database = new Database(filePath);
|
||||
database.exec('CREATE TABLE example (value TEXT NOT NULL); INSERT INTO example VALUES (\'preserved\');');
|
||||
if (name === 'configuration.sqlite') {
|
||||
database.exec('CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); INSERT INTO schema_migrations VALUES (1);');
|
||||
} else if (name === 'identity.sqlite') {
|
||||
database.pragma('user_version = 4');
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('creates and validates a complete backup while excluding runtime and control data', async () => {
|
||||
await fsp.writeFile(path.join(temporaryRoot, 'state.json'), '{"preserved":true}\n');
|
||||
await fsp.mkdir(path.join(temporaryRoot, 'replays'), { recursive: true });
|
||||
await fsp.writeFile(path.join(temporaryRoot, 'replays', 'complete.mp4'), 'complete replay');
|
||||
await fsp.mkdir(path.join(temporaryRoot, 'runtime'), { recursive: true });
|
||||
await fsp.writeFile(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard me');
|
||||
|
||||
const sources = ['configuration.sqlite', 'identity.sqlite', 'fleet-reports.sqlite']
|
||||
.map(createSourceDatabase);
|
||||
const owners = sources.map((database) => ({
|
||||
backupDatabase: (destinationPath) => database.backup(destinationPath),
|
||||
}));
|
||||
const result = await createFullBackup({
|
||||
configurationDatabase: owners[0],
|
||||
identityService: owners[1],
|
||||
fleetReportService: owners[2],
|
||||
jobId: 'test-backup',
|
||||
});
|
||||
|
||||
assert.ok(fs.statSync(result.archivePath).size > 0);
|
||||
assert.ok(result.manifest.files.some((entry) => entry.path === 'state.json'));
|
||||
assert.ok(result.manifest.files.some((entry) => entry.path === 'replays/complete.mp4'));
|
||||
assert.equal(result.manifest.files.some((entry) => entry.path.includes('runtime')), false);
|
||||
assert.equal(result.manifest.files.some((entry) => entry.path.includes('backup-restore')), false);
|
||||
|
||||
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
|
||||
await fsp.mkdir(restoreJob, { recursive: true });
|
||||
const uploadedArchive = path.join(restoreJob, 'upload.tar.gz');
|
||||
await fsp.copyFile(result.archivePath, uploadedArchive);
|
||||
const summary = await prepareRestoreArchive({ archivePath: uploadedArchive, jobDir: restoreJob, actor: 'test' });
|
||||
assert.equal(summary.fileCount, result.manifest.files.length);
|
||||
|
||||
sources.forEach((database) => database.close());
|
||||
});
|
||||
|
||||
test('rejects a staged restore whose contents no longer match the manifest', async () => {
|
||||
const restoreJob = path.join(temporaryRoot, 'backup-restore', `restore-${VALID_RESTORE_ID}`);
|
||||
const statePath = path.join(restoreJob, 'extracted', 'data', 'state.json');
|
||||
await fsp.writeFile(statePath, '{"tampered":true}\n');
|
||||
await assert.rejects(validateExtractedRestore(path.join(restoreJob, 'extracted')), /checksum or size mismatch/);
|
||||
// Restore the known source content so the following startup-application test
|
||||
// continues to exercise a genuinely validated replacement payload.
|
||||
await fsp.writeFile(statePath, '{"preserved":true}\n');
|
||||
});
|
||||
|
||||
test('rejects symbolic links before extracting an archive', async () => {
|
||||
const unsafeRoot = path.join(temporaryRoot, 'unsafe-archive');
|
||||
await fsp.mkdir(path.join(unsafeRoot, 'data'), { recursive: true });
|
||||
await fsp.writeFile(path.join(unsafeRoot, 'manifest.json'), '{}');
|
||||
await fsp.symlink('/etc/passwd', path.join(unsafeRoot, 'data', 'escape'));
|
||||
const archivePath = path.join(temporaryRoot, 'unsafe.tar.gz');
|
||||
await tar.c({ cwd: unsafeRoot, file: archivePath, gzip: true }, ['manifest.json', 'data']);
|
||||
await assert.rejects(inspectArchive(archivePath), /unsupported entry type/);
|
||||
});
|
||||
|
||||
test('applies validated replacement data and removes rollback only after startup succeeds', () => {
|
||||
const restoreId = VALID_RESTORE_ID;
|
||||
const jobDir = path.join(temporaryRoot, 'backup-restore', `restore-${restoreId}`);
|
||||
fs.writeFileSync(path.join(temporaryRoot, 'old-state.txt'), 'old');
|
||||
fs.mkdirSync(path.join(temporaryRoot, 'runtime'), { recursive: true });
|
||||
fs.writeFileSync(path.join(temporaryRoot, 'runtime', 'active.tmp'), 'discard during restore');
|
||||
startupRestore.writeJson(startupRestore.pendingPath, {
|
||||
restoreId,
|
||||
state: 'pending',
|
||||
requestedAt: Date.now(),
|
||||
actor: 'test',
|
||||
});
|
||||
|
||||
const applied = startupRestore.applyPendingRestore();
|
||||
assert.equal(applied.status, 'awaiting-health');
|
||||
assert.equal(fs.existsSync(path.join(temporaryRoot, 'old-state.txt')), false);
|
||||
assert.equal(fs.existsSync(path.join(temporaryRoot, 'runtime')), false);
|
||||
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), '{"preserved":true}\n');
|
||||
assert.equal(fs.existsSync(path.join(temporaryRoot, 'backup-restore', 'rollback', 'old-state.txt')), true);
|
||||
|
||||
const completed = startupRestore.markStartupSuccessful();
|
||||
assert.equal(completed.status, 'restored');
|
||||
assert.equal(fs.existsSync(jobDir), false);
|
||||
assert.equal(fs.existsSync(startupRestore.pendingPath), false);
|
||||
});
|
||||
|
||||
test('restores the rollback copy when a replaced application did not reach health', () => {
|
||||
const restoreId = 'b'.repeat(64);
|
||||
const rollbackDir = path.join(temporaryRoot, 'backup-restore', 'rollback');
|
||||
fs.mkdirSync(rollbackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rollbackDir, 'state.json'), 'previous state');
|
||||
fs.writeFileSync(path.join(temporaryRoot, 'state.json'), 'failed replacement');
|
||||
startupRestore.writeJson(startupRestore.pendingPath, {
|
||||
restoreId,
|
||||
state: 'awaiting-health',
|
||||
requestedAt: Date.now(),
|
||||
actor: 'test',
|
||||
});
|
||||
|
||||
const result = startupRestore.applyPendingRestore();
|
||||
assert.equal(result.status, 'rolled-back');
|
||||
assert.equal(fs.readFileSync(path.join(temporaryRoot, 'state.json'), 'utf8'), 'previous state');
|
||||
assert.equal(startupRestore.getLastRestoreResult().status, 'rolled-back');
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
// Backup and Restore Service
|
||||
// Purpose: Owns full-data archive creation, browser transfer, staged restore confirmation, startup replacement, and rollback.
|
||||
// Scope: All backup/restore orchestration stays in this service; database owners expose only their online snapshot operations.
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { Transform } = require('stream');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { CONTROL_DIR_NAME, createFullBackup } = require('./backup');
|
||||
const { MAX_ARCHIVE_BYTES, prepareRestoreArchive } = require('./restore');
|
||||
const startupRestore = require('./startupRestore');
|
||||
|
||||
const TOKEN_LIFETIME_MS = 10 * 60 * 1000;
|
||||
const controlDir = resolveDataPath(CONTROL_DIR_NAME);
|
||||
const downloads = new Map();
|
||||
const uploads = new Map();
|
||||
let backupInProgress = false;
|
||||
let registered = false;
|
||||
let app;
|
||||
let io;
|
||||
let logger;
|
||||
let getConfigurationDatabase;
|
||||
let identityService;
|
||||
let fleetReportService;
|
||||
let requireLockdownAdministrator;
|
||||
let requireRecentPassword;
|
||||
let requestApplicationRestart;
|
||||
let isApplicationRestartPending;
|
||||
|
||||
function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function createToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function pruneExpiredTransfers() {
|
||||
const now = Date.now();
|
||||
for (const [token, transfer] of [...downloads, ...uploads]) {
|
||||
if (transfer.expiresAt > now) continue;
|
||||
downloads.delete(token);
|
||||
uploads.delete(token);
|
||||
if (transfer.jobDir) fsp.rm(transfer.jobDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function responseError(cb, error) {
|
||||
logger.warn('Backup or restore request failed', { error: error.message });
|
||||
cb({ error: error.message, code: error.code || null });
|
||||
}
|
||||
|
||||
function removeOrphanedStaging() {
|
||||
let pendingRestoreId = null;
|
||||
try {
|
||||
pendingRestoreId = JSON.parse(fs.readFileSync(startupRestore.pendingPath, 'utf8')).restoreId || null;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
for (const entry of fs.readdirSync(controlDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const preservedName = pendingRestoreId ? `restore-${pendingRestoreId}` : null;
|
||||
if ((entry.name.startsWith('backup-') || entry.name.startsWith('restore-'))
|
||||
&& entry.name !== preservedName) {
|
||||
/*
|
||||
Transfer authorization lives only in process memory. After a restart,
|
||||
an unconfirmed upload or undownloaded archive can no longer be reached,
|
||||
so deleting it prevents abandoned full-data copies from accumulating.
|
||||
*/
|
||||
fs.rmSync(path.join(controlDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerSocketApi() {
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('backupRestore:status', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireLockdownAdministrator(socket);
|
||||
cb({ success: true, lastRestore: startupRestore.getLastRestoreResult() });
|
||||
} catch (error) {
|
||||
responseError(cb, error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('backupRestore:createBackup', (_payload = {}, cb = () => {}) => {
|
||||
Promise.resolve().then(async () => {
|
||||
requireRecentPassword(socket);
|
||||
if (backupInProgress) throw new Error('A full backup is already being created.');
|
||||
backupInProgress = true;
|
||||
try {
|
||||
pruneExpiredTransfers();
|
||||
const jobId = createToken();
|
||||
const result = await createFullBackup({
|
||||
configurationDatabase: getConfigurationDatabase(),
|
||||
identityService,
|
||||
fleetReportService,
|
||||
jobId,
|
||||
});
|
||||
const token = createToken();
|
||||
downloads.set(token, {
|
||||
archivePath: result.archivePath,
|
||||
jobDir: result.jobDir,
|
||||
expiresAt: Date.now() + TOKEN_LIFETIME_MS,
|
||||
});
|
||||
getConfigurationDatabase().recordAuditEvent(actorFor(socket), 'backup.created', {
|
||||
fileCount: result.manifest.files.length,
|
||||
skippedUnstableFileCount: result.manifest.skippedUnstableFiles.length,
|
||||
});
|
||||
cb({
|
||||
success: true,
|
||||
downloadUrl: `/admin-api/backup/${token}`,
|
||||
fileCount: result.manifest.files.length,
|
||||
skippedUnstableFiles: result.manifest.skippedUnstableFiles,
|
||||
});
|
||||
} finally {
|
||||
backupInProgress = false;
|
||||
}
|
||||
}).catch((error) => responseError(cb, error));
|
||||
});
|
||||
|
||||
socket.on('backupRestore:createRestoreUpload', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireRecentPassword(socket);
|
||||
pruneExpiredTransfers();
|
||||
const token = createToken();
|
||||
uploads.set(token, {
|
||||
actor: actorFor(socket),
|
||||
expiresAt: Date.now() + TOKEN_LIFETIME_MS,
|
||||
});
|
||||
cb({ success: true, uploadUrl: `/admin-api/restore/${token}` });
|
||||
} catch (error) {
|
||||
responseError(cb, error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('backupRestore:confirmRestore', ({ restoreId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
requireRecentPassword(socket);
|
||||
const safeRestoreId = String(restoreId || '');
|
||||
if (!/^[a-f0-9]{64}$/.test(safeRestoreId)) throw new Error('Validated restore was not found.');
|
||||
if (isApplicationRestartPending()) throw new Error('Application restart already pending.');
|
||||
if (fs.existsSync(startupRestore.pendingPath)) throw new Error('A restore is already pending.');
|
||||
const jobDir = path.join(controlDir, `restore-${safeRestoreId}`);
|
||||
if (!fs.existsSync(path.join(jobDir, 'validated.json'))) throw new Error('Validated restore was not found.');
|
||||
startupRestore.writeJson(startupRestore.pendingPath, {
|
||||
restoreId: safeRestoreId,
|
||||
state: 'pending',
|
||||
requestedAt: Date.now(),
|
||||
actor: actorFor(socket),
|
||||
});
|
||||
getConfigurationDatabase().recordAuditEvent(actorFor(socket), 'restore.requested', { restoreId: safeRestoreId });
|
||||
requestApplicationRestart({ actor: actorFor(socket), reason: 'restore-requested' });
|
||||
cb({ success: true });
|
||||
} catch (error) {
|
||||
responseError(cb, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerHttpApi() {
|
||||
app.get('/admin-api/backup/:token', (req, res) => {
|
||||
pruneExpiredTransfers();
|
||||
const transfer = downloads.get(String(req.params.token || ''));
|
||||
if (!transfer) {
|
||||
res.status(404).send('Backup download is missing or expired.');
|
||||
return;
|
||||
}
|
||||
downloads.delete(req.params.token);
|
||||
res.set({
|
||||
'Content-Type': 'application/gzip',
|
||||
'Content-Disposition': `attachment; filename="multirover-backup-${new Date().toISOString().slice(0, 10)}.tar.gz"`,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
const cleanup = () => fsp.rm(transfer.jobDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
res.once('close', cleanup);
|
||||
fs.createReadStream(transfer.archivePath).on('error', (error) => {
|
||||
logger.warn('Backup download failed', { error: error.message });
|
||||
if (!res.headersSent) res.status(500).end();
|
||||
else res.destroy(error);
|
||||
}).pipe(res);
|
||||
});
|
||||
|
||||
app.put('/admin-api/restore/:token', async (req, res) => {
|
||||
pruneExpiredTransfers();
|
||||
const token = String(req.params.token || '');
|
||||
const transfer = uploads.get(token);
|
||||
uploads.delete(token);
|
||||
if (!transfer) {
|
||||
res.status(404).json({ error: 'Restore upload is missing or expired.' });
|
||||
return;
|
||||
}
|
||||
const contentLength = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_ARCHIVE_BYTES) {
|
||||
res.status(413).json({ error: 'Backup archive exceeds the restore size limit.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreId = createToken();
|
||||
const jobDir = path.join(controlDir, `restore-${restoreId}`);
|
||||
const archivePath = path.join(jobDir, 'upload.tar.gz');
|
||||
try {
|
||||
await fsp.mkdir(jobDir, { recursive: true });
|
||||
let receivedBytes = 0;
|
||||
const limiter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
receivedBytes += chunk.length;
|
||||
callback(receivedBytes > MAX_ARCHIVE_BYTES
|
||||
? new Error('Backup archive exceeds the restore size limit.')
|
||||
: null, chunk);
|
||||
},
|
||||
});
|
||||
await pipeline(req, limiter, fs.createWriteStream(archivePath, { mode: 0o600 }));
|
||||
const summary = await prepareRestoreArchive({ archivePath, jobDir, actor: transfer.actor });
|
||||
getConfigurationDatabase().recordAuditEvent(transfer.actor, 'restore.validated', {
|
||||
restoreId,
|
||||
fileCount: summary.fileCount,
|
||||
totalBytes: summary.totalBytes,
|
||||
});
|
||||
res.set('Cache-Control', 'no-store').json({ success: true, restoreId, summary });
|
||||
} catch (error) {
|
||||
await fsp.rm(jobDir, { recursive: true, force: true });
|
||||
logger.warn('Restore upload failed validation', { actor: transfer.actor, error: error.message });
|
||||
res.status(error.message.includes('size limit') ? 413 : 400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function register() {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
/*
|
||||
These runtime dependencies are deliberately loaded only after earliest
|
||||
startup restore has run. Several of them open SQLite immediately, so
|
||||
importing them at module scope would make replacement too late and unsafe.
|
||||
*/
|
||||
({ app } = require('../../globals/http'));
|
||||
io = require('../../globals/io');
|
||||
logger = require('../../globals/logger').child('backupRestoreService');
|
||||
({ getConfigurationDatabase } = require('../../configuration'));
|
||||
identityService = require('../identityService');
|
||||
fleetReportService = require('../fleetReportService');
|
||||
({ requireLockdownAdministrator, requireRecentPassword } = require('../adminConfigurationService'));
|
||||
({ isApplicationRestartPending, requestApplicationRestart } = require('../serverControlService'));
|
||||
fs.mkdirSync(controlDir, { recursive: true });
|
||||
removeOrphanedStaging();
|
||||
registerHttpApi();
|
||||
registerSocketApi();
|
||||
}
|
||||
|
||||
function markStartupSuccessful() {
|
||||
const result = startupRestore.markStartupSuccessful();
|
||||
if (result) {
|
||||
const configuration = require('../../configuration');
|
||||
configuration.getConfigurationDatabase().recordAuditEvent('system', 'restore.completed', { restoreId: result.restoreId });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyPendingRestore: startupRestore.applyPendingRestore,
|
||||
markStartupSuccessful,
|
||||
register,
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
// Full Data Restore Validation
|
||||
// Purpose: Safely extracts and validates an uploaded MultiRover backup before it can become a pending restore.
|
||||
// Scope: Never changes active data; startupRestore owns the later replacement and rollback transaction.
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const tar = require('tar');
|
||||
const {
|
||||
DATABASE_NAMES,
|
||||
FORMAT_VERSION,
|
||||
readDatabaseSchemaVersions,
|
||||
sha256File,
|
||||
} = require('./backup');
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 * 1024;
|
||||
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,
|
||||
identity: 4,
|
||||
fleetReports: 0,
|
||||
};
|
||||
|
||||
function normalizeArchivePath(value) {
|
||||
const raw = String(value || '');
|
||||
if (!raw || raw.includes('\\') || path.posix.isAbsolute(raw)) return null;
|
||||
const withoutTrailingSlash = raw.replace(/\/+$/, '');
|
||||
const normalized = path.posix.normalize(withoutTrailingSlash);
|
||||
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertAllowedArchiveEntry(entry) {
|
||||
const normalized = normalizeArchivePath(entry.path);
|
||||
if (!normalized || (normalized !== 'manifest.json' && normalized !== 'data' && !normalized.startsWith('data/'))) {
|
||||
throw new Error(`Backup contains an invalid archive path: ${entry.path}`);
|
||||
}
|
||||
if (!['File', 'Directory'].includes(entry.type)) {
|
||||
throw new Error(`Backup contains unsupported entry type ${entry.type}: ${entry.path}`);
|
||||
}
|
||||
if (normalized.startsWith('data/')) {
|
||||
const topLevelName = normalized.slice('data/'.length).split('/')[0];
|
||||
if (RESERVED_DATA_NAMES.has(topLevelName)) {
|
||||
throw new Error(`Backup contains reserved data path: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function inspectArchive(archivePath) {
|
||||
let entryCount = 0;
|
||||
let extractedBytes = 0;
|
||||
const paths = new Set();
|
||||
let validationError = null;
|
||||
await tar.t({
|
||||
file: archivePath,
|
||||
strict: true,
|
||||
onentry: (entry) => {
|
||||
if (validationError) return;
|
||||
try {
|
||||
entryCount += 1;
|
||||
extractedBytes += Number(entry.size) || 0;
|
||||
if (entryCount > MAX_ARCHIVE_ENTRIES) throw new Error('Backup contains too many files.');
|
||||
if (extractedBytes > MAX_EXTRACTED_BYTES) throw new Error('Backup expands beyond the restore size limit.');
|
||||
const normalized = assertAllowedArchiveEntry(entry);
|
||||
if (paths.has(normalized)) throw new Error(`Backup contains duplicate path: ${normalized}`);
|
||||
paths.add(normalized);
|
||||
} catch (error) {
|
||||
/*
|
||||
tar invokes onentry from its parser event stack, where throwing would
|
||||
become an uncaught exception instead of rejecting tar.t(). Retain the
|
||||
first failure and raise it immediately after the bounded listing.
|
||||
*/
|
||||
validationError = error;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (validationError) throw validationError;
|
||||
if (!paths.has('manifest.json') || !paths.has('data')) {
|
||||
throw new Error('Backup must contain manifest.json and one data directory.');
|
||||
}
|
||||
}
|
||||
|
||||
async function listExtractedFiles(rootDir, relativeDir = '') {
|
||||
const entries = await fsp.readdir(path.join(rootDir, relativeDir), { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const fullPath = path.join(rootDir, relativePath);
|
||||
const stat = await fsp.lstat(fullPath);
|
||||
if (stat.isSymbolicLink()) throw new Error(`Restored data contains symbolic link: ${relativePath}`);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await listExtractedFiles(rootDir, relativePath));
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) throw new Error(`Restored data contains special file: ${relativePath}`);
|
||||
files.push({
|
||||
path: relativePath.split(path.sep).join('/'),
|
||||
size: stat.size,
|
||||
sha256: await sha256File(fullPath),
|
||||
});
|
||||
}
|
||||
return files.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
function verifySqliteDatabase(filePath, name) {
|
||||
const database = new Database(filePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const result = database.pragma('quick_check', { simple: true });
|
||||
if (result !== 'ok') throw new Error(`${name} failed SQLite integrity validation.`);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExtractedRestore(extractDir) {
|
||||
const manifestPath = path.join(extractDir, 'manifest.json');
|
||||
const payloadDir = path.join(extractDir, 'data');
|
||||
const manifestStat = await fsp.stat(manifestPath);
|
||||
if (manifestStat.size > 10 * 1024 * 1024) throw new Error('Backup manifest is unreasonably large.');
|
||||
const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8'));
|
||||
if (manifest.format !== 'multirover-full-backup' || manifest.formatVersion !== FORMAT_VERSION) {
|
||||
throw new Error('Backup format or version is not supported.');
|
||||
}
|
||||
if (!Array.isArray(manifest.files)) throw new Error('Backup manifest has no file inventory.');
|
||||
|
||||
const expected = [...manifest.files].sort((left, right) => String(left.path).localeCompare(String(right.path)));
|
||||
const actual = await listExtractedFiles(payloadDir);
|
||||
if (expected.length !== actual.length) throw new Error('Backup file inventory does not match the archive.');
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
const wanted = expected[index];
|
||||
const found = actual[index];
|
||||
if (wanted.path !== found.path || wanted.size !== found.size || wanted.sha256 !== found.sha256) {
|
||||
throw new Error(`Backup checksum or size mismatch: ${wanted.path || found.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const databaseName of DATABASE_NAMES) {
|
||||
if (!actual.some((entry) => entry.path === databaseName)) {
|
||||
throw new Error(`Backup is missing required database: ${databaseName}`);
|
||||
}
|
||||
verifySqliteDatabase(path.join(payloadDir, databaseName), databaseName);
|
||||
}
|
||||
const actualSchemaVersions = readDatabaseSchemaVersions(payloadDir);
|
||||
for (const [databaseName, version] of Object.entries(actualSchemaVersions)) {
|
||||
// JSON object property order has no meaning. Compare each known database by
|
||||
// name so an otherwise valid manifest is not rejected merely because a
|
||||
// different JSON writer emitted its keys in another order.
|
||||
if (Number(manifest.databaseSchemaVersions?.[databaseName]) !== version) {
|
||||
throw new Error('Backup database schema versions do not match its manifest.');
|
||||
}
|
||||
if (version > SUPPORTED_DATABASE_SCHEMA_VERSIONS[databaseName]) {
|
||||
throw new Error(`Backup ${databaseName} database is newer than this application supports.`);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function prepareRestoreArchive({ archivePath, jobDir, actor }) {
|
||||
const archiveStat = await fsp.stat(archivePath);
|
||||
if (!archiveStat.isFile() || archiveStat.size <= 0 || archiveStat.size > MAX_ARCHIVE_BYTES) {
|
||||
throw new Error('Backup archive is empty or exceeds the restore size limit.');
|
||||
}
|
||||
await inspectArchive(archivePath);
|
||||
const extractDir = path.join(jobDir, 'extracted');
|
||||
await fsp.rm(extractDir, { recursive: true, force: true });
|
||||
await fsp.mkdir(extractDir, { recursive: true });
|
||||
// Data files never need executable or set-id permissions from an uploaded
|
||||
// archive. Let the server account's umask choose safe extraction modes.
|
||||
await tar.x({ cwd: extractDir, file: archivePath, strict: true, preservePaths: false, noChmod: true });
|
||||
const manifest = await validateExtractedRestore(extractDir);
|
||||
const summary = {
|
||||
createdAt: manifest.createdAt,
|
||||
applicationVersion: manifest.applicationVersion,
|
||||
fileCount: manifest.files.length,
|
||||
totalBytes: manifest.files.reduce((total, entry) => total + Number(entry.size || 0), 0),
|
||||
skippedUnstableFiles: Array.isArray(manifest.skippedUnstableFiles) ? manifest.skippedUnstableFiles : [],
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(jobDir, 'validated.json'),
|
||||
`${JSON.stringify({ actor, validatedAt: Date.now(), summary }, null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 },
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_ARCHIVE_BYTES,
|
||||
inspectArchive,
|
||||
prepareRestoreArchive,
|
||||
validateExtractedRestore,
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
// Startup Data Restore
|
||||
// Purpose: Applies a previously validated restore before any application database opens and rolls back an interrupted start.
|
||||
// Scope: Operates only on top-level entries inside SERVER_DATA_DIR while preserving backupRestoreService control state.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { CONTROL_DIR_NAME } = require('./backup');
|
||||
|
||||
const controlDir = resolveDataPath(CONTROL_DIR_NAME);
|
||||
const pendingPath = path.join(controlDir, 'pending.json');
|
||||
const rollbackDir = path.join(controlDir, 'rollback');
|
||||
const lastResultPath = path.join(controlDir, 'last-result.json');
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
function readPending() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(pendingPath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function listActiveDataEntries({ includeRuntime = true } = {}) {
|
||||
fs.mkdirSync(resolveDataDir(), { recursive: true });
|
||||
return fs.readdirSync(resolveDataDir()).filter((name) => (
|
||||
name !== CONTROL_DIR_NAME && (includeRuntime || name !== 'runtime')
|
||||
));
|
||||
}
|
||||
|
||||
function removeActiveData() {
|
||||
for (const name of listActiveDataEntries()) {
|
||||
fs.rmSync(path.join(resolveDataDir(), name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function moveChildren(sourceDir, destinationDir) {
|
||||
fs.mkdirSync(destinationDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(sourceDir)) {
|
||||
fs.renameSync(path.join(sourceDir, name), path.join(destinationDir, name));
|
||||
}
|
||||
}
|
||||
|
||||
function restoreRollback(pending, errorMessage) {
|
||||
removeActiveData();
|
||||
moveChildren(rollbackDir, resolveDataDir());
|
||||
writeJson(lastResultPath, {
|
||||
status: 'rolled-back',
|
||||
restoreId: pending.restoreId,
|
||||
completedAt: Date.now(),
|
||||
error: errorMessage,
|
||||
});
|
||||
fs.rmSync(pendingPath, { force: true });
|
||||
}
|
||||
|
||||
function applyPendingRestore() {
|
||||
const pending = readPending();
|
||||
if (!pending) return null;
|
||||
|
||||
/*
|
||||
Reaching startup again in either state means the replacement process did
|
||||
not reach the HTTP-listening success marker. The complete rollback copy was
|
||||
created before active data was touched, so restoring it is deterministic.
|
||||
*/
|
||||
if (pending.state === 'applying' || pending.state === 'awaiting-health') {
|
||||
restoreRollback(pending, 'The restored application did not finish starting.');
|
||||
return { status: 'rolled-back', restoreId: pending.restoreId };
|
||||
}
|
||||
|
||||
const jobDir = path.join(controlDir, `restore-${pending.restoreId}`);
|
||||
const replacementDir = path.join(jobDir, 'extracted', 'data');
|
||||
if (!fs.existsSync(path.join(jobDir, 'validated.json'))
|
||||
|| !fs.existsSync(replacementDir)
|
||||
|| !fs.statSync(replacementDir).isDirectory()) {
|
||||
fs.rmSync(pendingPath, { force: true });
|
||||
writeJson(lastResultPath, {
|
||||
status: 'failed',
|
||||
restoreId: pending.restoreId,
|
||||
completedAt: Date.now(),
|
||||
error: 'Validated restore staging is missing.',
|
||||
});
|
||||
return { status: 'failed', restoreId: pending.restoreId };
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(rollbackDir, { recursive: true });
|
||||
// Startup runs before any database or writer opens. Copying the entire
|
||||
// current payload first gives every later replacement step one complete,
|
||||
// local rollback source even if the process is interrupted halfway through.
|
||||
for (const name of listActiveDataEntries({ includeRuntime: false })) {
|
||||
fs.cpSync(path.join(resolveDataDir(), name), path.join(rollbackDir, name), { recursive: true });
|
||||
}
|
||||
writeJson(pendingPath, { ...pending, state: 'applying', applyingAt: Date.now() });
|
||||
removeActiveData();
|
||||
moveChildren(replacementDir, resolveDataDir());
|
||||
writeJson(pendingPath, { ...pending, state: 'awaiting-health', appliedAt: Date.now() });
|
||||
return { status: 'awaiting-health', restoreId: pending.restoreId };
|
||||
} catch (error) {
|
||||
if (fs.existsSync(rollbackDir)) restoreRollback(pending, error.message);
|
||||
else fs.rmSync(pendingPath, { force: true });
|
||||
return { status: 'rolled-back', restoreId: pending.restoreId, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function markStartupSuccessful() {
|
||||
const pending = readPending();
|
||||
if (!pending || pending.state !== 'awaiting-health') return null;
|
||||
const jobDir = path.join(controlDir, `restore-${pending.restoreId}`);
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
fs.rmSync(jobDir, { recursive: true, force: true });
|
||||
fs.rmSync(pendingPath, { force: true });
|
||||
const result = {
|
||||
status: 'restored',
|
||||
restoreId: pending.restoreId,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
writeJson(lastResultPath, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLastRestoreResult() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(lastResultPath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyPendingRestore,
|
||||
getLastRestoreResult,
|
||||
markStartupSuccessful,
|
||||
pendingPath,
|
||||
writeJson,
|
||||
};
|
||||
@@ -136,5 +136,17 @@ module.exports = {
|
||||
get reportBuilder() {
|
||||
return runtime?.reportBuilder || null;
|
||||
},
|
||||
backupDatabase(destinationPath) {
|
||||
/*
|
||||
Backups include reporting history even when collection is currently
|
||||
disabled. Lazily opening the existing store keeps this one operation
|
||||
behind the report service's normal database ownership boundary.
|
||||
*/
|
||||
if (!storage) {
|
||||
storage = createStorage({ logger });
|
||||
storage.open();
|
||||
}
|
||||
return storage.backupDatabase(destinationPath);
|
||||
},
|
||||
stop: stopRuntime,
|
||||
};
|
||||
|
||||
@@ -508,6 +508,16 @@ function createStorage({ logger }) {
|
||||
}), { available: false, path: DB_PATH });
|
||||
}
|
||||
|
||||
function backupDatabase(destinationPath) {
|
||||
/*
|
||||
Fleet collection may continue during an online SQLite backup. Each
|
||||
resulting database file represents a valid point-in-time snapshot even
|
||||
when new telemetry commits before the copy completes.
|
||||
*/
|
||||
if (!open()) throw new Error('Fleet report database is unavailable.');
|
||||
return db.backup(destinationPath);
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
insertEvent,
|
||||
@@ -525,6 +535,7 @@ function createStorage({ logger }) {
|
||||
getActiveBattery,
|
||||
replaceBattery,
|
||||
getDiagnostics,
|
||||
backupDatabase,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { httpServer } = require('../../globals/http');
|
||||
const config = require('../../globals/config');
|
||||
const logger = require('../../globals/logger').child('httpServer');
|
||||
const { startMediaMtx } = require('../mediaMtxService');
|
||||
const backupRestoreService = require('../backupRestoreService');
|
||||
|
||||
httpServer.listen(config.port, () => {
|
||||
logger.info(`Server listening on :${config.port}`);
|
||||
@@ -14,6 +15,13 @@ httpServer.listen(config.port, () => {
|
||||
first publisher attempts to authenticate.
|
||||
*/
|
||||
startMediaMtx();
|
||||
/*
|
||||
Give child processes and startup integrations a short stabilization window
|
||||
after restored databases migrate and HTTP begins listening. If the process
|
||||
exits during that window, earliest startup sees the awaiting-health marker
|
||||
and restores the prior data instead of accepting a broken replacement.
|
||||
*/
|
||||
setTimeout(() => backupRestoreService.markStartupSuccessful(), 5000);
|
||||
});
|
||||
|
||||
function stopAcceptingConnections() {
|
||||
|
||||
@@ -118,6 +118,15 @@ function getDb() {
|
||||
return db;
|
||||
}
|
||||
|
||||
function backupDatabase(destinationPath) {
|
||||
/*
|
||||
Keep identity writes live while SQLite copies a transactionally consistent
|
||||
view into backup staging. Exposing the operation instead of the connection
|
||||
preserves this service as the sole owner of identity.sqlite.
|
||||
*/
|
||||
return getDb().backup(destinationPath);
|
||||
}
|
||||
|
||||
function ensureSchema(conn) {
|
||||
conn.exec(`
|
||||
create table if not exists users (
|
||||
@@ -1087,6 +1096,7 @@ function createJsonStore({ path: filePath, normalizeStoreShape, cloneStore, logg
|
||||
module.exports = {
|
||||
identityEvents,
|
||||
getDb,
|
||||
backupDatabase,
|
||||
sanitizeNickname,
|
||||
normalizeCookieUserId,
|
||||
isValidCookieUserId,
|
||||
|
||||
@@ -34,21 +34,30 @@ function scheduleApplicationRestart() {
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function requestApplicationRestart({ actor, reason = 'administrator-requested' }) {
|
||||
if (restartPending) throw new Error('Application restart already pending.');
|
||||
database.recordAuditEvent(actor, 'application.restart-requested', { reason });
|
||||
scheduleApplicationRestart();
|
||||
logger.warn('Application restart requested', { actor, reason });
|
||||
// Restore and ordinary admin restarts share this one browser contract, so
|
||||
// clients can explain the disconnect without knowing which control invoked it.
|
||||
io.emit('server:restarting', { reason });
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('server:restartApplication', (_payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
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 });
|
||||
requestApplicationRestart({ actor });
|
||||
cb({ success: true });
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
isApplicationRestartPending: () => restartPending,
|
||||
requestApplicationRestart,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user