import fs from 'node:fs'; import assert from 'node:assert/strict'; import { randomUUID, createHash } from 'node:crypto'; import { createRequire } from 'node:module'; const require = createRequire('/app/backend/package.json'); const { ListObjectVersionsCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); const { pool } = await import('/app/backend/dist/db/index.js'); const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js'); const { createS3ClientFromConfig, s3PutObject, s3GetObject } = await import('/app/backend/dist/services/s3-backup-client.js'); const phase = process.argv[2], statePath = '/checks/state239-private.json', publicPath = '/checks/s3-ui239-native.json'; const config = { endpoint: process.env.MMAPP_TEST_S3_ENDPOINT, region: process.env.MMAPP_TEST_S3_REGION, bucket: process.env.MMAPP_TEST_S3_BUCKET, accessKeyId: process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey: process.env.MMAPP_TEST_S3_SECRET_KEY, forcePathStyle: false }; assert.equal(config.endpoint, 'https://s3.ru-1.storage.selcloud.ru'); assert.equal(config.bucket, 'mikrotik-test-s3'); assert(config.secretAccessKey); const s3 = createS3ClientFromConfig(config); const server = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows; assert.equal(server.length, 1); const client = new MikrotikClient({ host: server[0].host, port: server[0].port, useSsl: server[0].use_ssl, verifySsl: false, username: server[0].username, password: server[0].password }); const rows = async table => (await pool.query(`SELECT to_jsonb(${table}) AS row FROM ${table} ORDER BY id`)).rows.map(r => r.row); const read = endpoint => client.get(endpoint, 10000); const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 }); async function versions(state) { const result = []; let keyMarker, versionMarker; for (let n = 0; n < 10; n++) { const r = await s3.send(new ListObjectVersionsCommand({ Bucket: config.bucket, Prefix: state.prefix + '/', KeyMarker: keyMarker, VersionIdMarker: versionMarker })); result.push(...(r.Versions ?? []).map(v => ({ ...v, kind: 'version' })), ...(r.DeleteMarkers ?? []).map(v => ({ ...v, kind: 'delete-marker' }))); if (!r.IsTruncated) return result; assert(r.NextKeyMarker || r.NextVersionIdMarker); keyMarker = r.NextKeyMarker; versionMarker = r.NextVersionIdMarker; } throw Error('Own version pagination limit'); } async function restoreTable(table, original) { assert.equal(original.length, 1); assert.equal(original[0].id, 1); const columns = Object.keys(original[0]).filter(k => k !== 'id'); assert(columns.every(k => /^[a-z][a-z0-9_]*$/.test(k))); await pool.query(`UPDATE ${table} SET ${columns.map((k, n) => `"${k}"=$${n + 1}`).join(',')} WHERE id=1`, columns.map(k => { const value = original[0][k]; return value && typeof value === 'object' ? JSON.stringify(value) : value; })); assert.deepEqual(await rows(table), original); } try { if (phase === 'init') { assert(!fs.existsSync(statePath)); const uuid = randomUUID(), bridge = 'mm-ui239-' + uuid.slice(0, 12), filename = 'mm-ui239-' + uuid + '.rsc'; const state = { id: 'mm-ui239-' + uuid, prefix: 'mmapp-acceptance/' + uuid, bridge, filename, key: 'mmapp-acceptance/' + uuid + '/' + filename, serverId: Number(server[0].id), serverName: server[0].name, storage: await rows('backup_storage_settings'), schedule: await rows('backup_schedule_settings'), catalog: await rows('backup_entries'), bridges: await read('/interface/bridge'), files: await read('/file') }; assert.equal(state.storage[0].provider, 'local'); assert(state.catalog.every(r => r.storage === 'local' && !r.s3_key)); assert(!state.bridges.some(b => b.name === bridge)); assert(!state.files.some(f => f.name === filename)); assert.equal((await versions(state)).length, 0); save(state); fs.writeFileSync('/checks/state239-public.json', JSON.stringify({ id: state.id, prefix: state.prefix, filename, bridge, serverId: state.serverId })); console.log('Own scope initialized; original settings/catalog saved privately'); } else { const state = JSON.parse(fs.readFileSync(statePath)); assert(/^mm-ui239-[a-f0-9-]+\.rsc$/.test(state.filename)); assert(state.key === state.prefix + '/' + state.filename); if (phase === 'upload') { const storage = (await rows('backup_storage_settings'))[0]; assert.equal(storage.provider, 's3'); assert.equal(storage.s3_prefix, state.prefix); assert.equal(storage.s3_bucket, config.bucket); assert.equal((await rows('backup_schedule_settings'))[0].enabled, false); const script = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`); assert.equal((await versions(state)).length, 0); await s3PutObject(s3, config.bucket, state.key, script); assert.deepEqual(await s3GetObject(s3, config.bucket, state.key), script); await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,notes,storage,s3_key,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,now())', [state.id, state.serverId, state.serverName, state.filename, script.length, 'manual', 'Own limited UI restore 239: bridge only', 's3', state.key]); state.bytes = script.length; state.sha256 = createHash('sha256').update(script).digest('hex'); save(state); console.log(JSON.stringify({ ownS3ScriptUploaded: true, bytes: state.bytes, catalogRowCreated: true })); } else if (phase === 'verify') { const bridges = await read('/interface/bridge'), own = bridges.filter(b => b.name === state.bridge); assert.equal(own.length, 1); assert.equal(own[0].comment, 'mm-ui239'); assert.equal((await read('/interface/bridge/port')).filter(p => p.bridge === state.bridge).length, 0); assert.equal((await read('/ip/address')).filter(p => p.interface === state.bridge).length, 0); assert.deepEqual(bridges.filter(b => b.name !== state.bridge), state.bridges); state.nativeVerified = true; save(state); console.log('Native bridge confirmed; no ports/addresses, original bridges unchanged'); } else if (phase === 'cleanup') { const own = (await read('/interface/bridge')).filter(b => b.name === state.bridge); assert(own.length <= 1); if (own.length) { assert.equal(own[0].comment, 'mm-ui239'); await client.delete('/interface/bridge/' + encodeURIComponent(own[0]['.id'])); } const ownFiles = (await read('/file')).filter(f => f.name === state.filename); assert(ownFiles.length <= 1); for (const file of ownFiles) await client.removeFile(file['.id']); await pool.query('DELETE FROM backup_entries WHERE id=$1 AND filename=$2 AND s3_key=$3', [state.id, state.filename, state.key]); const existing = await versions(state); assert(existing.every(v => v.Key === state.key && v.VersionId)); for (const v of existing) await s3.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: v.Key, VersionId: v.VersionId })); assert.equal((await versions(state)).length, 0); assert.deepEqual(await rows('backup_entries'), state.catalog); assert.deepEqual(await read('/interface/bridge'), state.bridges); assert(!(await read('/file')).some(f => f.name === state.filename)); await restoreTable('backup_storage_settings', state.storage); await restoreTable('backup_schedule_settings', state.schedule); const report = { date: '2026-10-14', block: '12.5.239', host: server[0].host, prefix: state.prefix, limitedScriptBytes: state.bytes ?? null, sha256: state.sha256 ?? null, nativeBridgeVerified: Boolean(state.nativeVerified), bridgeAndUploadedFileRemoved: true, originalBridgesUnchanged: true, originalCatalogAllFieldsMatched: true, originalCatalogRows: state.catalog.length, originalSettingsAllFieldsMatched: true, s3VersionsAndMarkersRemoved: existing.length, s3VersionsAndMarkersRemaining: 0, passwordChanges: 0, fullConfigurationApplied: false }; fs.writeFileSync(publicPath, JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report)); } else throw Error('Unsupported phase'); } } finally { s3.destroy(); await pool.end(); }