mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
legacy importer in admin page aswell as setup.
This commit is contained in:
@@ -11,7 +11,11 @@ const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validat
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
|
||||
const {
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
importConfigurationFile,
|
||||
} = require('./configurationFileImporter');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
@@ -355,6 +359,58 @@ bandwidthSavings:
|
||||
});
|
||||
});
|
||||
|
||||
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
|
||||
const database = createTestDatabase();
|
||||
const initial = database.getClientConfiguration();
|
||||
const seededRevision = database.updateConfiguration({
|
||||
value: initial.config,
|
||||
expectedRevision: initial.revision,
|
||||
actor: 'secret-seed',
|
||||
secretOperations: {
|
||||
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
|
||||
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
|
||||
'discord.token': { action: 'replace', value: 'replace-this-token' },
|
||||
},
|
||||
});
|
||||
const yamlText = `
|
||||
admins:
|
||||
- this obsolete account entry is deliberately malformed
|
||||
timezone: America/Chicago
|
||||
ptzCamera:
|
||||
password:
|
||||
discord:
|
||||
token: new-discord-token
|
||||
`;
|
||||
|
||||
/*
|
||||
An initialized installation treats the YAML as configuration data only.
|
||||
Even malformed account data is ignored, while presence-aware secret
|
||||
operations preserve an omitted credential, clear an explicit empty value,
|
||||
and replace an explicit non-empty value.
|
||||
*/
|
||||
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
||||
assert.deepEqual(parsed.administrators, []);
|
||||
assert.equal(parsed.uploadedAdministratorCount, 1);
|
||||
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
|
||||
const revision = database.updateConfiguration({
|
||||
value: parsed.config,
|
||||
expectedRevision: seededRevision,
|
||||
secretOperations: buildSecretOperationsForImport(parsed),
|
||||
actor: 'admin-import-test',
|
||||
source: 'admin-yaml:production.yaml',
|
||||
});
|
||||
|
||||
const active = database.getActiveConfigurationRecord();
|
||||
assert.equal(active.revision, revision);
|
||||
assert.equal(active.source, 'admin-yaml:production.yaml');
|
||||
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
|
||||
assert.equal(active.config.ptzCamera.password, '');
|
||||
assert.equal(active.config.discord.token, 'new-discord-token');
|
||||
assert.equal(active.config.timezone, 'America/Chicago');
|
||||
assert.equal(database.listAdministrators().length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
|
||||
temporaryRoots.push(root);
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
// 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.
|
||||
// Purpose: Validates a deliberately uploaded legacy YAML file for first-run setup or an explicit administrative replacement.
|
||||
// Scope: Startup and installation never search for or consume configuration files; every import begins with a browser-selected file.
|
||||
const yaml = require('js-yaml');
|
||||
const { rootSchema, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
const {
|
||||
rootSchema,
|
||||
secretPaths,
|
||||
normalizeConfig,
|
||||
assertValidConfig,
|
||||
} = require('./validation');
|
||||
|
||||
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
function getAtPath(object, dottedPath) {
|
||||
return String(dottedPath || '').split('.').filter(Boolean)
|
||||
.reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
||||
function setAtPath(object, dottedPath, value) {
|
||||
const parts = String(dottedPath || '').split('.').filter(Boolean);
|
||||
let cursor = object;
|
||||
parts.slice(0, -1).forEach((key) => {
|
||||
cursor = cursor[key];
|
||||
});
|
||||
cursor[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function hasAtPath(object, dottedPath) {
|
||||
/*
|
||||
Presence, rather than truthiness, distinguishes an omitted legacy secret
|
||||
from an explicitly empty one. An omitted credential must preserve the
|
||||
running installation's value, while an empty YAML value deliberately
|
||||
clears it through the same operation used by the schema form.
|
||||
*/
|
||||
let cursor = object;
|
||||
for (const key of String(dottedPath || '').split('.').filter(Boolean)) {
|
||||
if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key)) return false;
|
||||
cursor = cursor[key];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function keepCurrentSchemaFields(value, schema) {
|
||||
/*
|
||||
@@ -46,28 +82,65 @@ function normalizeUploadedAdministrator(entry, index) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigurationFile(text) {
|
||||
function parseConfigurationFile(text, { includeAdministrators = true } = {}) {
|
||||
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 uploadedAdministrators = Array.isArray(parsed.admins) ? parsed.admins : [];
|
||||
/*
|
||||
First-run setup is the sole workflow allowed to create accounts from the
|
||||
legacy file. An initialized server ignores the entire admins collection,
|
||||
including obsolete or malformed entries, so importing configuration can
|
||||
never rename accounts, replace password hashes, or remove the last
|
||||
lockdown administrator.
|
||||
*/
|
||||
const administrators = includeAdministrators
|
||||
? uploadedAdministrators.map(normalizeUploadedAdministrator)
|
||||
: [];
|
||||
const configInput = Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
||||
);
|
||||
secretPaths.forEach((secretPath) => {
|
||||
/*
|
||||
YAML commonly represents `token:` as null even though the application
|
||||
models an unconfigured credential as an empty string. Translate null only
|
||||
for known secret fields so a plainly empty legacy credential has the same
|
||||
clear meaning as the admin form; null in any ordinary current field still
|
||||
fails its schema normally.
|
||||
*/
|
||||
if (hasAtPath(configInput, secretPath) && getAtPath(configInput, secretPath) === null) {
|
||||
setAtPath(configInput, secretPath, '');
|
||||
}
|
||||
});
|
||||
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
|
||||
const providedSecretPaths = secretPaths.filter((secretPath) => hasAtPath(configInput, secretPath));
|
||||
|
||||
// Filtering applies only to nonexistent keys. Values retained for current
|
||||
// schema fields still have to satisfy every type, range, and format rule
|
||||
// before the importer can atomically initialize the database.
|
||||
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 };
|
||||
return {
|
||||
config,
|
||||
administrators,
|
||||
uploadedAdministratorCount: uploadedAdministrators.length,
|
||||
providedSecretPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSecretOperationsForImport({ config, providedSecretPaths = [] }) {
|
||||
return Object.fromEntries(providedSecretPaths.map((secretPath) => {
|
||||
const value = getAtPath(config, secretPath);
|
||||
/*
|
||||
Current secret schemas are strings. Keeping this conversion beside the
|
||||
importer produces the database's narrow replace/clear contract and
|
||||
avoids granting the import route a way around ordinary secret handling.
|
||||
*/
|
||||
return value === ''
|
||||
? [secretPath, { action: 'clear' }]
|
||||
: [secretPath, { action: 'replace', value }];
|
||||
}));
|
||||
}
|
||||
|
||||
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
|
||||
@@ -85,6 +158,8 @@ function importConfigurationFile({ text, database, actor = 'setup-file-upload',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
importConfigurationFile,
|
||||
};
|
||||
|
||||
@@ -161,7 +161,13 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return { ...record, ...redacted };
|
||||
}
|
||||
|
||||
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) {
|
||||
function updateConfiguration({
|
||||
value,
|
||||
expectedRevision,
|
||||
secretOperations = {},
|
||||
actor,
|
||||
source = 'admin-ui',
|
||||
}) {
|
||||
const active = getActiveConfigurationRecord();
|
||||
const candidate = clone(value);
|
||||
|
||||
@@ -187,7 +193,12 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return commitRevisionTransaction(candidate, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: 'admin-ui',
|
||||
/*
|
||||
Administrative imports use this same safe update path but identify the
|
||||
selected filename in revision and audit history. The source remains
|
||||
server-controlled metadata and never contains configuration values.
|
||||
*/
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ const {
|
||||
applyCommittedConfiguration,
|
||||
rootSchema,
|
||||
} = require('../../configuration');
|
||||
const {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
} = require('../../configuration/configurationFileImporter');
|
||||
const { getRole } = require('../roleService');
|
||||
|
||||
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
|
||||
@@ -34,6 +39,18 @@ function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function safeUploadedFileName(value) {
|
||||
/*
|
||||
The filename is audit metadata only and is never opened on the server.
|
||||
Removing control characters keeps logs and history readable while
|
||||
retaining the operator-visible name that identifies the imported file.
|
||||
*/
|
||||
return String(value || 'uploaded-config.yaml')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255) || 'uploaded-config.yaml';
|
||||
}
|
||||
|
||||
function errorPayload(error) {
|
||||
return {
|
||||
error: error.message,
|
||||
@@ -102,6 +119,37 @@ io.on('connection', (socket) => {
|
||||
return { revision, application, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:importConfigurationFile', requireRecentPassword, async (payload) => {
|
||||
const yamlText = String(payload.yaml || '');
|
||||
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.');
|
||||
}
|
||||
|
||||
/*
|
||||
Parsing deliberately excludes administrators on an initialized server.
|
||||
Configuration is still filtered to today's schema and strictly
|
||||
validated, then committed through the ordinary optimistic update path so
|
||||
missing secrets survive and explicitly supplied secrets replace or clear
|
||||
their current values.
|
||||
*/
|
||||
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
||||
const fileName = safeUploadedFileName(payload.fileName);
|
||||
const revision = database.updateConfiguration({
|
||||
value: parsed.config,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
secretOperations: buildSecretOperationsForImport(parsed),
|
||||
actor: actorFor(socket),
|
||||
source: `admin-yaml:${fileName}`,
|
||||
});
|
||||
const application = await applyCommittedConfiguration();
|
||||
return {
|
||||
revision,
|
||||
application,
|
||||
ignoredAdministratorCount: parsed.uploadedAdministratorCount,
|
||||
snapshot: buildAdminSnapshot(),
|
||||
};
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, async (payload) => {
|
||||
const revision = database.restoreConfigurationRevision({
|
||||
revision: payload.revision,
|
||||
|
||||
@@ -6,10 +6,12 @@ const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('setupService');
|
||||
const { getConfigurationDatabase, applyCommittedConfiguration } = require('../../configuration');
|
||||
const { importConfigurationFile } = require('../../configuration/configurationFileImporter');
|
||||
const {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
importConfigurationFile,
|
||||
} = require('../../configuration/configurationFileImporter');
|
||||
const { createSetupCodeFile } = require('./setupCodeFile');
|
||||
|
||||
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
||||
const database = getConfigurationDatabase();
|
||||
const setupCodeFile = createSetupCodeFile();
|
||||
let setupNoticeLogged = false;
|
||||
|
||||
Reference in New Issue
Block a user