mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
remove useless slop stuff
This commit is contained in:
@@ -256,22 +256,6 @@ fi
|
||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||
|
||||
# Import an existing legacy file only when this installation does not yet have
|
||||
# its configuration database. The importer validates the complete document and
|
||||
# preserves administrator password hashes without printing secrets. Fresh
|
||||
# installations intentionally skip this branch and complete setup through the
|
||||
# one-time code printed by the server.
|
||||
if [[ -f "$SERVER_DIR/config.yaml" && ! -f "$DATA_DIR/configuration.sqlite" ]]; then
|
||||
echo " Importing legacy config.yaml into configuration.sqlite"
|
||||
runuser -u "$TARGET_USER" -- env SERVER_DATA_DIR="$DATA_DIR" \
|
||||
"$NODE_BIN" "$SERVER_DIR/scripts/importLegacyConfig.js" "$SERVER_DIR/config.yaml"
|
||||
# The importer exits successfully only after the complete configuration and
|
||||
# administrator catalog have committed and can be read back. Remove this
|
||||
# exact obsolete source file afterward so secrets do not remain in a second,
|
||||
# unmanaged configuration source on upgraded installations.
|
||||
rm -f "$SERVER_DIR/config.yaml"
|
||||
fi
|
||||
|
||||
# Validate database-backed MediaMTX inputs before disabling a working legacy
|
||||
# service. This performs the same build and serialization as startup without
|
||||
# opening listeners or leaving a process behind.
|
||||
@@ -341,7 +325,8 @@ echo
|
||||
echo "Services installed:"
|
||||
echo " multirover.service (Node.js control server with MediaMTX child)"
|
||||
echo
|
||||
echo "Open /setup for a fresh installation or /admin for an imported installation."
|
||||
echo "Open /setup to initialize the installation, then use /admin for administration."
|
||||
echo "For fresh setup, read the one-time code from $DATA_DIR/setup-code.txt."
|
||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
||||
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"check:media": "node scripts/checkMedia.js",
|
||||
"config:import": "node scripts/importLegacyConfig.js",
|
||||
"admin:recover": "node scripts/adminAccount.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+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
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
import{e as E,r as s,j as e,S as P,C as c,L as F}from"./index-B3PBxGLi.js";import{e as I,f as $,i as q}from"./api-C8p-OT8N.js";function R(){const i=E(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,N]=s.useState(""),[p,C]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,l]=s.useState("");s.useEffect(()=>{I(i).then(t=>m(t.required)).catch(t=>l(t.message))},[i]);async function y(t){h(!0),l("");try{await t(),m(!1),l("Setup completed. You can now open the administration application and log in.")}catch(u){const L=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(j=>`${j.path}: ${j.message}`).join("; ")}`:"";l(`${u.message}${L}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){l("Passwords do not match.");return}y(()=>$(i,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&y(async()=>q(i,{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 setup code printed in the server log, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(F,{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=>N(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>C(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 legacy configuration",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"The selected YAML is uploaded directly for one-time validation and import. 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-Ct8z7xZT.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-CRbxLzE1.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.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-Dv9kbAgD.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,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=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
|
||||
//# sourceMappingURL=api-C0PIt_OP.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"api-C8p-OT8N.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 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 importLegacyConfiguration = (socket, payload) => emitAdminRequest(socket, 'setup:importLegacy', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importLegacyConfiguration"],"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,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA4B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,qBAAsBE,CAAO"}
|
||||
{"version":3,"file":"api-C0PIt_OP.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 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","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,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
@@ -1,2 +0,0 @@
|
||||
function r(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(n={})=>{if(n?.error){const o=new Error(n.error);o.code=n.code||null,o.validationErrors=n.validationErrors||[],o.currentRevision=n.currentRevision||null,s(o);return}e(n)})})}const d=t=>r(t,"adminConfig:get"),c=(t,i)=>r(t,"adminConfig:confirmPassword",{password:i}),m=(t,i)=>r(t,"adminConfig:updateConfiguration",i),u=(t,i)=>r(t,"adminConfig:restoreRevision",i),g=(t,i)=>r(t,"adminConfig:createAdministrator",i),f=(t,i)=>r(t,"adminConfig:updateAdministrator",i),A=(t,i)=>r(t,"adminConfig:deleteAdministrator",{id:i}),C=t=>r(t,"setup:status"),l=(t,i)=>r(t,"setup:createAdministrator",i),p=(t,i)=>r(t,"setup:importLegacy",i);export{m as a,c as b,g as c,A as d,C as e,l as f,d as g,p as i,u as r,f as u};
|
||||
//# sourceMappingURL=api-C8p-OT8N.js.map
|
||||
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-B3PBxGLi.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CRbxLzE1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C935Fjdg.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Legacy Configuration Import Command
|
||||
// Purpose: Imports or validates an explicitly selected YAML file before normal database-only startup.
|
||||
// Scope: Provides recovery/unattended migration using the same importer as first-run setup.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getConfigurationDatabase } = require('../src/configuration');
|
||||
const { importLegacyConfiguration } = require('../src/configuration/legacyImporter');
|
||||
|
||||
function usage() {
|
||||
process.stderr.write('Usage: node scripts/importLegacyConfig.js <config.yaml> [--dry-run]\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const selectedPath = args.find((arg) => arg !== '--dry-run');
|
||||
if (!selectedPath) {
|
||||
usage();
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(selectedPath);
|
||||
const text = fs.readFileSync(absolutePath, 'utf8');
|
||||
const result = importLegacyConfiguration({
|
||||
text,
|
||||
database: getConfigurationDatabase(),
|
||||
actor: 'command-line-import',
|
||||
source: path.basename(absolutePath),
|
||||
dryRun,
|
||||
});
|
||||
|
||||
/*
|
||||
Never print parsed configuration values: the legacy document commonly
|
||||
contains Discord, Home Assistant, and camera credentials. A concise count
|
||||
and revision are sufficient for an unattended migration log.
|
||||
*/
|
||||
process.stdout.write(`${dryRun ? 'Legacy configuration is valid' : 'Legacy configuration imported'}: ${result.administratorCount} administrator(s)${result.revision ? `, revision ${result.revision}` : ''}.\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`Legacy configuration import failed: ${error.message}\n`);
|
||||
if (Array.isArray(error.validationErrors)) {
|
||||
error.validationErrors.forEach((entry) => process.stderr.write(`- ${entry.path}: ${entry.message}\n`));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Configuration System Tests
|
||||
// Purpose: Verifies strict defaults, immutable revisions, secret handling, legacy import, and administrator safety.
|
||||
// Purpose: Verifies strict defaults, immutable revisions, secret handling, explicit setup-file import, and administrator safety.
|
||||
// Scope: Uses isolated temporary databases and never opens the development server's data store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
@@ -10,7 +10,7 @@ const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validat
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { parseLegacyConfiguration, importLegacyConfiguration } = require('./legacyImporter');
|
||||
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
@@ -168,7 +168,7 @@ test('administrator storage never exposes hashes or removes the final lockdown a
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('legacy YAML imports configuration and bcrypt hashes exactly once', () => {
|
||||
test('an explicitly uploaded YAML file imports configuration and bcrypt hashes exactly once', () => {
|
||||
const yamlText = `
|
||||
admins:
|
||||
- username: owner
|
||||
@@ -179,14 +179,14 @@ timezone: America/Chicago
|
||||
media:
|
||||
whepBaseUrl: http://localhost:8889/video
|
||||
`;
|
||||
const parsed = parseLegacyConfiguration(yamlText);
|
||||
const parsed = parseConfigurationFile(yamlText);
|
||||
assert.equal(parsed.config.timezone, 'America/Chicago');
|
||||
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
|
||||
|
||||
const database = createTestDatabase();
|
||||
const result = importLegacyConfiguration({ text: yamlText, database, dryRun: false });
|
||||
const result = importConfigurationFile({ text: yamlText, database });
|
||||
assert.equal(result.administratorCount, 1);
|
||||
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
|
||||
assert.throws(() => importLegacyConfiguration({ text: yamlText, database }), /cannot replace an initialized installation/);
|
||||
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
|
||||
database.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Configuration File Importer
|
||||
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
|
||||
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
|
||||
const yaml = require('js-yaml');
|
||||
const { normalizeConfig, assertValidConfig } = require('./validation');
|
||||
|
||||
function normalizeUploadedAdministrator(entry, index) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
throw new Error(`Administrator ${index + 1} must be an object.`);
|
||||
}
|
||||
const username = String(entry.username || '').trim();
|
||||
const passwordHash = String(entry.password_hash || '').trim();
|
||||
if (!username || !passwordHash) {
|
||||
throw new Error(`Administrator ${index + 1} requires username and password_hash.`);
|
||||
}
|
||||
return {
|
||||
username,
|
||||
passwordHash,
|
||||
discordId: String(entry.discord_id || '').trim(),
|
||||
role: entry.lockdown ? 'lockdown' : 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigurationFile(text) {
|
||||
const parsed = yaml.load(String(text || ''));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('The configuration file must contain a YAML object.');
|
||||
}
|
||||
|
||||
const administrators = Array.isArray(parsed.admins)
|
||||
? parsed.admins.map(normalizeUploadedAdministrator)
|
||||
: [];
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
const config = normalizeConfig(configInput);
|
||||
|
||||
/*
|
||||
Unknown fields remain in the normalized document so strict schema
|
||||
validation reports them to the operator instead of silently losing data
|
||||
from the explicitly selected file.
|
||||
*/
|
||||
assertValidConfig(config);
|
||||
if (!administrators.some((admin) => admin.role === 'lockdown')) {
|
||||
throw new Error('The configuration file must contain at least one lockdown administrator.');
|
||||
}
|
||||
return { config, administrators };
|
||||
}
|
||||
|
||||
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
|
||||
const result = parseConfigurationFile(text);
|
||||
const revision = database.importConfigurationFile({
|
||||
config: result.config,
|
||||
administrators: result.administrators,
|
||||
actor,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
revision,
|
||||
administratorCount: result.administrators.length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseConfigurationFile,
|
||||
importConfigurationFile,
|
||||
};
|
||||
@@ -331,8 +331,10 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
}));
|
||||
}
|
||||
|
||||
const importLegacyTransaction = db.transaction(({ config, administrators, actor, source }) => {
|
||||
if (isSetupComplete()) throw new Error('Legacy configuration cannot replace an initialized installation.');
|
||||
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.
|
||||
if (isSetupComplete()) throw new Error('A configuration file cannot replace an initialized installation.');
|
||||
const normalized = assertValidConfig(normalizeConfig(config));
|
||||
const revision = commitRevisionTransaction(normalized, {
|
||||
expectedRevision: getActiveConfigurationRecord().revision,
|
||||
@@ -340,13 +342,13 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
source,
|
||||
});
|
||||
administrators.forEach((admin) => createAdministratorTransaction(admin, actor, false));
|
||||
if (!isSetupComplete()) throw new Error('Legacy import must contain at least one lockdown administrator.');
|
||||
writeAudit(actor, 'legacy-import.completed', { revision, administratorCount: administrators.length, source });
|
||||
if (!isSetupComplete()) throw new Error('The configuration file must contain at least one lockdown administrator.');
|
||||
writeAudit(actor, 'setup.configuration-file-imported', { revision, administratorCount: administrators.length, source });
|
||||
return revision;
|
||||
});
|
||||
|
||||
function importLegacy(payload) {
|
||||
return importLegacyTransaction(payload);
|
||||
function importConfigurationFile(payload) {
|
||||
return importConfigurationFileTransaction(payload);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -364,7 +366,7 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
countLockdownAdministrators,
|
||||
isSetupComplete,
|
||||
listAuditEvents,
|
||||
importLegacy,
|
||||
importConfigurationFile,
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Legacy YAML Configuration Importer
|
||||
// Purpose: Converts one explicitly supplied config.yaml document into the database-backed configuration model.
|
||||
// Scope: Parses and validates legacy input without becoming a runtime fallback or watcher.
|
||||
const yaml = require('js-yaml');
|
||||
const { normalizeConfig, assertValidConfig } = require('./validation');
|
||||
|
||||
function normalizeLegacyAdministrator(entry, index) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
throw new Error(`Legacy administrator ${index + 1} must be an object.`);
|
||||
}
|
||||
const username = String(entry.username || '').trim();
|
||||
const passwordHash = String(entry.password_hash || '').trim();
|
||||
if (!username || !passwordHash) {
|
||||
throw new Error(`Legacy administrator ${index + 1} requires username and password_hash.`);
|
||||
}
|
||||
return {
|
||||
username,
|
||||
passwordHash,
|
||||
discordId: String(entry.discord_id || '').trim(),
|
||||
role: entry.lockdown ? 'lockdown' : 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyConfiguration(text) {
|
||||
const parsed = yaml.load(String(text || ''));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Legacy configuration must contain a YAML object.');
|
||||
}
|
||||
|
||||
const administrators = Array.isArray(parsed.admins)
|
||||
? parsed.admins.map(normalizeLegacyAdministrator)
|
||||
: [];
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
const config = normalizeConfig(configInput);
|
||||
|
||||
/*
|
||||
Unknown fields are retained by normalizeConfig and therefore appear in
|
||||
Ajv's precise validation errors instead of being silently discarded during
|
||||
the one-time import.
|
||||
*/
|
||||
assertValidConfig(config);
|
||||
if (!administrators.some((admin) => admin.role === 'lockdown')) {
|
||||
throw new Error('Legacy configuration must contain at least one lockdown administrator.');
|
||||
}
|
||||
return { config, administrators };
|
||||
}
|
||||
|
||||
function importLegacyConfiguration({ text, database, actor = 'legacy-import', source = 'config.yaml', dryRun = false }) {
|
||||
const result = parseLegacyConfiguration(text);
|
||||
if (dryRun) {
|
||||
return {
|
||||
dryRun: true,
|
||||
administratorCount: result.administrators.length,
|
||||
};
|
||||
}
|
||||
const revision = database.importLegacy({
|
||||
config: result.config,
|
||||
administrators: result.administrators,
|
||||
actor,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
dryRun: false,
|
||||
revision,
|
||||
administratorCount: result.administrators.length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseLegacyConfiguration,
|
||||
importLegacyConfiguration,
|
||||
};
|
||||
@@ -1,34 +1,38 @@
|
||||
// First-Run Setup Service
|
||||
// Purpose: Allows an empty data directory to create its first lockdown administrator or import legacy YAML safely.
|
||||
// Purpose: Allows an empty data directory to create its first lockdown administrator or import an explicitly uploaded YAML file.
|
||||
// Scope: Exposes setup-only socket operations and permanently closes them once a lockdown administrator exists.
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('setupService');
|
||||
const { getConfigurationDatabase } = require('../../configuration');
|
||||
const { importLegacyConfiguration } = require('../../configuration/legacyImporter');
|
||||
const { importConfigurationFile } = require('../../configuration/configurationFileImporter');
|
||||
const { createSetupCodeFile } = require('./setupCodeFile');
|
||||
|
||||
const MAX_LEGACY_YAML_BYTES = 1024 * 1024;
|
||||
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
||||
const database = getConfigurationDatabase();
|
||||
let setupCode = null;
|
||||
const setupCodeFile = createSetupCodeFile();
|
||||
let setupNoticeLogged = false;
|
||||
|
||||
function isSetupRequired() {
|
||||
return !database.isSetupComplete();
|
||||
}
|
||||
|
||||
function ensureSetupCode() {
|
||||
if (!isSetupRequired()) return null;
|
||||
if (!setupCode) {
|
||||
setupCode = crypto.randomBytes(6).toString('hex');
|
||||
/*
|
||||
The code is intentionally logged only on a server that has no lockdown
|
||||
administrator. It lives in process memory, changes on restart, and is
|
||||
permanently irrelevant as soon as setup succeeds, so it cannot become a
|
||||
recurring environment-variable authentication bypass.
|
||||
*/
|
||||
logger.warn('First-run setup is required', { setupCode });
|
||||
if (!isSetupRequired()) {
|
||||
// Setup authorization permanently closes when the first lockdown account
|
||||
// exists. Remove a stale credential left by an interrupted final response.
|
||||
setupCodeFile.remove();
|
||||
return null;
|
||||
}
|
||||
return setupCode;
|
||||
const code = setupCodeFile.ensure();
|
||||
// Logs may be retained or shipped elsewhere, so they identify the local file
|
||||
// containing the credential without ever including the credential itself.
|
||||
if (!setupNoticeLogged) {
|
||||
logger.warn('First-run setup is required', { setupCodePath: setupCodeFile.filePath });
|
||||
setupNoticeLogged = true;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function requireOpenSetup(candidateCode) {
|
||||
@@ -73,25 +77,25 @@ io.on('connection', (socket) => {
|
||||
discordId: payload.discordId,
|
||||
role: 'lockdown',
|
||||
}, 'first-run-setup');
|
||||
setupCode = null;
|
||||
setupCodeFile.remove();
|
||||
return { administrator };
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('setup:importLegacy', (payload = {}, cb = () => {}) => {
|
||||
socket.on('setup:importConfigurationFile', (payload = {}, cb = () => {}) => {
|
||||
respond(cb, () => {
|
||||
requireOpenSetup(payload.setupCode);
|
||||
const yamlText = String(payload.yaml || '');
|
||||
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_LEGACY_YAML_BYTES) {
|
||||
throw new Error('Legacy YAML must be present and no larger than 1 MiB.');
|
||||
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
|
||||
throw new Error('The YAML configuration file must be present and no larger than 1 MiB.');
|
||||
}
|
||||
const result = importLegacyConfiguration({
|
||||
const result = importConfigurationFile({
|
||||
text: yamlText,
|
||||
database,
|
||||
actor: 'first-run-setup',
|
||||
source: String(payload.fileName || 'uploaded-config.yaml').slice(0, 255),
|
||||
});
|
||||
setupCode = null;
|
||||
setupCodeFile.remove();
|
||||
return result;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Setup Code File
|
||||
// Purpose: Persists the one-time first-run credential inside the server data directory.
|
||||
// Scope: Owns secure file creation, validation, reuse, and removal without knowing whether setup is complete.
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
|
||||
const DEFAULT_SETUP_CODE_PATH = resolveDataPath('setup-code.txt');
|
||||
const SETUP_CODE_PATTERN = /^[0-9a-f]{12}$/;
|
||||
|
||||
function createSetupCodeFile({ filePath = DEFAULT_SETUP_CODE_PATH } = {}) {
|
||||
function read() {
|
||||
const fileStats = fs.lstatSync(filePath);
|
||||
if (!fileStats.isFile()) {
|
||||
// In particular, reject symbolic links before chmod or read operations so
|
||||
// a writable data directory cannot redirect setup handling to another file.
|
||||
throw new Error(`Setup code path is not a regular file: ${filePath}`);
|
||||
}
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
const code = fs.readFileSync(filePath, 'utf8').trim().toLowerCase();
|
||||
if (!SETUP_CODE_PATTERN.test(code)) {
|
||||
/*
|
||||
Never silently replace a malformed credential. An operator may already
|
||||
be reading that file, and changing it behind their back would make setup
|
||||
failures mysterious while concealing possible filesystem corruption.
|
||||
*/
|
||||
throw new Error(`Setup code file is invalid: ${filePath}`);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function ensure() {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
if (fs.existsSync(filePath)) {
|
||||
return read();
|
||||
}
|
||||
|
||||
const code = crypto.randomBytes(6).toString('hex');
|
||||
try {
|
||||
/*
|
||||
Exclusive creation prevents two accidentally overlapping server starts
|
||||
from overwriting one another's setup credential. The file is the source
|
||||
an operator reads, so the accepted code must always match its contents.
|
||||
*/
|
||||
fs.writeFileSync(filePath, `${code}\n`, {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
return code;
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
return read();
|
||||
}
|
||||
}
|
||||
|
||||
function remove() {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
ensure,
|
||||
read,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SETUP_CODE_PATH,
|
||||
SETUP_CODE_PATTERN,
|
||||
createSetupCodeFile,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// Setup Code File Tests
|
||||
// Purpose: Verifies the first-run credential remains private, stable, and removable.
|
||||
// Scope: Uses an isolated operating-system temporary directory and never touches development server data.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
|
||||
const { SETUP_CODE_PATTERN, createSetupCodeFile } = require('./setupCodeFile');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
function createTestStore() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-setup-code-'));
|
||||
temporaryRoots.push(root);
|
||||
return createSetupCodeFile({ filePath: path.join(root, 'setup-code.txt') });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
|
||||
});
|
||||
|
||||
test('creates one owner-readable code and reuses it across startup initialization', () => {
|
||||
const store = createTestStore();
|
||||
const firstCode = store.ensure();
|
||||
const secondCode = store.ensure();
|
||||
|
||||
assert.match(firstCode, SETUP_CODE_PATTERN);
|
||||
assert.equal(secondCode, firstCode);
|
||||
assert.equal(fs.readFileSync(store.filePath, 'utf8'), `${firstCode}\n`);
|
||||
// Mask off file-type bits so this assertion checks only Unix permissions.
|
||||
assert.equal(fs.statSync(store.filePath).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('rejects a malformed existing credential instead of replacing it', () => {
|
||||
const store = createTestStore();
|
||||
fs.writeFileSync(store.filePath, 'not-a-valid-code\n', { mode: 0o600 });
|
||||
|
||||
assert.throws(() => store.ensure(), /Setup code file is invalid/);
|
||||
assert.equal(fs.readFileSync(store.filePath, 'utf8'), 'not-a-valid-code\n');
|
||||
});
|
||||
|
||||
test('rejects a setup-code symlink without reading or changing its target', () => {
|
||||
const store = createTestStore();
|
||||
const targetPath = path.join(path.dirname(store.filePath), 'unrelated.txt');
|
||||
fs.writeFileSync(targetPath, 'unrelated-content\n', { mode: 0o644 });
|
||||
fs.symlinkSync(targetPath, store.filePath);
|
||||
|
||||
assert.throws(() => store.ensure(), /not a regular file/);
|
||||
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'unrelated-content\n');
|
||||
assert.equal(fs.statSync(targetPath).mode & 0o777, 0o644);
|
||||
});
|
||||
|
||||
test('removes the credential after setup completes', () => {
|
||||
const store = createTestStore();
|
||||
store.ensure();
|
||||
store.remove();
|
||||
|
||||
assert.equal(fs.existsSync(store.filePath), false);
|
||||
});
|
||||
Reference in New Issue
Block a user