UNPKG

homebridge-config-ui-x

Version:

A web based management, configuration and control platform for Homebridge.

607 lines • 27 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; import { EventEmitter } from 'node:events'; import { constants, createReadStream, statSync } from 'node:fs'; import { access, lstat, mkdtemp, readdir, realpath } from 'node:fs/promises'; import { platform, tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import process from 'node:process'; import { pipeline } from 'node:stream'; import { promisify } from 'node:util'; import { BadRequestException, Inject, Injectable, InternalServerErrorException, NotFoundException, StreamableFile, } from '@nestjs/common'; import { cyan, green, red, yellow } from 'bash-color'; import dayjs from 'dayjs'; import { copy, ensureDir, pathExists, readJson, remove, writeJson } from 'fs-extra/esm'; import { networkInterfaces } from 'systeminformation'; import { create, extract } from 'tar'; import { Extract } from 'unzipper'; import { ConfigService } from '../../core/config/config.service.js'; import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js'; import { Logger } from '../../core/logger/logger.service.js'; import { RE_BACKUP_FILENAME, RE_BACKUP_ID, RE_COLON } from '../../core/regex.constants.js'; import { SchedulerService } from '../../core/scheduler/scheduler.service.js'; import { PluginsService } from '../plugins/plugins.service.js'; const pump = promisify(pipeline); let BackupService = class BackupService { configService; pluginsService; schedulerService; homebridgeIpcService; logger; restoreDirectory; constructor(configService, pluginsService, schedulerService, homebridgeIpcService, logger) { this.configService = configService; this.pluginsService = pluginsService; this.schedulerService = schedulerService; this.homebridgeIpcService = homebridgeIpcService; this.logger = logger; this.scheduleInstanceBackups(); } scheduleInstanceBackups() { if (this.configService.ui.scheduledBackupDisable === true) { this.logger.debug('Scheduled backups disabled.'); return; } const scheduleRule = new this.schedulerService.RecurrenceRule(); scheduleRule.hour = Math.floor(Math.random() * 7); scheduleRule.minute = Math.floor(Math.random() * 59); scheduleRule.second = Math.floor(Math.random() * 59); this.schedulerService.scheduleJob('instance-backup', scheduleRule, () => { this.logger.log('Running scheduled instance backup...'); this.runScheduledBackupJob(); }); } async createBackup() { const instanceId = this.configService.homebridgeConfig.bridge.username.replace(RE_COLON, ''); const backupDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); const backupFileName = `homebridge-backup-${instanceId}.${Date.now().toString()}.tar.gz`; const backupPath = resolve(backupDir, backupFileName); this.logger.log(`Creating temporary backup archive at ${backupPath}.`); try { const storagePath = await realpath(this.configService.storagePath); await copy(storagePath, resolve(backupDir, 'storage'), { filter: async (filePath) => { if ([ 'instance-backups', 'nssm.exe', 'homebridge.log', 'logs', 'node_modules', 'startup.sh', '.docker.env', 'docker-compose.yml', 'pnpm-lock.yaml', 'package.json', 'package-lock.json', '.npmrc', '.npm', 'FFmpeg', 'fdk-aac', '.git', 'recordings', '.homebridge.sock', '#recycle', '@eaDir', '.venv', '.cache', ].includes(basename(filePath))) { return false; } try { const stat = await lstat(filePath); return (stat.isDirectory() || stat.isFile()); } catch (e) { return false; } }, }); const installedPlugins = await this.pluginsService.getInstalledPlugins(); await writeJson(resolve(backupDir, 'plugins.json'), installedPlugins); await writeJson(resolve(backupDir, 'info.json'), { timestamp: new Date().toISOString(), platform: platform(), uix: this.configService.package.version, node: process.version, }); await create({ portable: true, gzip: true, file: backupPath, cwd: backupDir, filter: (filePath, stat) => { if (stat.size > globalThis.backup.maxBackupFileSize) { this.logger.warn(`Backup is skipping ${filePath} because it is larger than ${globalThis.backup.maxBackupFileSizeText}.`); return false; } return true; }, }, [ 'storage', 'plugins.json', 'info.json', ]); if (statSync(backupPath).size > globalThis.backup.maxBackupSize) { this.logger.error(`Backup file exceeds maximum restore file size (${globalThis.backup.maxBackupSizeText}) ${(statSync(backupPath).size / (1024 * 1024)).toFixed(1)}MB.`); } } catch (e) { this.logger.log(`Backup failed, removing ${backupDir}.`); await remove(resolve(backupDir)); throw e; } return { instanceId, backupDir, backupPath, backupFileName, }; } async ensureScheduledBackupPath() { if (this.configService.ui.scheduledBackupPath) { if (!await pathExists(this.configService.instanceBackupPath)) { throw new Error('Custom instance backup path does not exist'); } try { await access(this.configService.instanceBackupPath, constants.W_OK | constants.R_OK); } catch (e) { throw new Error(`Custom instance backup path is not writable / readable by service: ${e.message}`); } } else { return await ensureDir(this.configService.instanceBackupPath); } } async runScheduledBackupJob() { try { await this.ensureScheduledBackupPath(); } catch (e) { this.logger.warn(`Could not run scheduled backup as ${e.message}.`); return; } try { const { backupDir, backupPath, instanceId } = await this.createBackup(); await copy(backupPath, resolve(this.configService.instanceBackupPath, `homebridge-backup-${instanceId}.${Date.now().toString()}.tar.gz`)); await remove(resolve(backupDir)); } catch (e) { this.logger.warn(`Failed to create scheduled instance backup as ${e.message}.`); } try { const backups = await this.listScheduledBackups(); for (const backup of backups) { if (dayjs().diff(dayjs(backup.timestamp), 'day') >= 7) { await remove(resolve(this.configService.instanceBackupPath, backup.fileName)); } } } catch (e) { this.logger.warn(`Failed to remove old backups as ${e.message}.`); } } async getNextBackupTime() { if (this.configService.ui.scheduledBackupDisable === true) { return { next: false, }; } else { return { next: this.schedulerService.scheduledJobs['instance-backup']?.nextInvocation() || false, }; } } async listScheduledBackups() { try { await this.ensureScheduledBackupPath(); const dirContents = await readdir(this.configService.instanceBackupPath, { withFileTypes: true }); return dirContents .filter(x => x.isFile() && x.name.match(RE_BACKUP_FILENAME)) .map((x) => { const split = x.name.split('.'); const instanceId = split[0].split('-')[2]; if (split.length === 4 && !Number.isNaN(split[1])) { return { id: `${instanceId}.${split[1]}`, instanceId: split[0].split('-')[2], timestamp: new Date(Number.parseInt(split[1], 10)), fileName: x.name, size: (statSync(`${this.configService.instanceBackupPath}/${x.name}`).size / (1024 * 1024)).toFixed(1), maxBackupSize: globalThis.backup.maxBackupSize / (1024 * 1024), maxBackupSizeText: globalThis.backup.maxBackupSizeText, }; } else { return null; } }) .filter(x => x !== null) .sort((a, b) => { if (a.id > b.id) { return -1; } else if (a.id < b.id) { return -2; } else { return 0; } }); } catch (e) { this.logger.warn(`Could not get scheduled backups as ${e.message}.`); throw new InternalServerErrorException(e.message); } } async getScheduledBackup(backupId) { if (!RE_BACKUP_ID.test(backupId)) { throw new BadRequestException('Invalid backup ID.'); } const backupPath = resolve(this.configService.instanceBackupPath, `homebridge-backup-${backupId}.tar.gz`); if (!backupPath.startsWith(this.configService.instanceBackupPath)) { throw new BadRequestException('Invalid backup ID.'); } if (!await pathExists(backupPath)) { throw new NotFoundException(); } return new StreamableFile(createReadStream(backupPath)); } async deleteScheduledBackup(backupId) { if (!RE_BACKUP_ID.test(backupId)) { throw new BadRequestException('Invalid backup ID.'); } const backupPath = resolve(this.configService.instanceBackupPath, `homebridge-backup-${backupId}.tar.gz`); if (!backupPath.startsWith(this.configService.instanceBackupPath)) { throw new BadRequestException('Invalid backup ID.'); } if (!await pathExists(backupPath)) { throw new NotFoundException(); } try { await remove(backupPath); this.logger.warn(`Scheduled backup ${backupId} deleted by request.`); } catch (e) { this.logger.warn(`Failed to delete scheduled backup by request as ${e.message}.`); throw new InternalServerErrorException(e.message); } } async restoreScheduledBackup(backupId) { if (!RE_BACKUP_ID.test(backupId)) { throw new BadRequestException('Invalid backup ID.'); } const backupPath = resolve(this.configService.instanceBackupPath, `homebridge-backup-${backupId}.tar.gz`); if (!backupPath.startsWith(this.configService.instanceBackupPath)) { throw new BadRequestException('Invalid backup ID.'); } if (!await pathExists(backupPath)) { throw new NotFoundException(); } this.restoreDirectory = undefined; const restoreDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); await pump(createReadStream(backupPath), extract({ cwd: restoreDir, })); this.restoreDirectory = restoreDir; } async downloadBackup(reply) { const { backupDir, backupPath, backupFileName } = await this.createBackup(); reply.raw.setHeader('Content-type', 'application/octet-stream'); reply.raw.setHeader('Content-disposition', `attachment; filename=${backupFileName}`); reply.raw.setHeader('File-Name', backupFileName); if (reply.request.hostname === 'localhost:8080') { reply.raw.setHeader('access-control-allow-origin', 'http://localhost:4200'); } return new StreamableFile(createReadStream(backupPath).on('close', () => remove(resolve(backupDir)))); } async createBackupInDirectory() { try { await this.ensureScheduledBackupPath(); } catch (error) { this.logger.error(`Create backup failed: ${error.message}`); throw new NotFoundException(); } try { const { backupDir, backupPath, instanceId } = await this.createBackup(); await copy(backupPath, resolve(this.configService.instanceBackupPath, `homebridge-backup-${instanceId}.${Date.now().toString()}.tar.gz`)); await remove(resolve(backupDir)); } catch (error) { this.logger.error(`Create backup failed: ${error.message}`); throw new InternalServerErrorException(error.message); } } async uploadBackupRestore(data) { this.restoreDirectory = undefined; const backupDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); await pump(data.file, extract({ cwd: backupDir, })); this.restoreDirectory = backupDir; } async removeRestoreDirectory() { if (this.restoreDirectory) { return await remove(this.restoreDirectory); } } async triggerHeadlessRestore() { if (!await pathExists(this.restoreDirectory)) { throw new BadRequestException('No backup file uploaded'); } const client = new EventEmitter(); client.on('stdout', (data) => { this.logger.log(data); }); client.on('stderr', (data) => { this.logger.log(data); }); await this.restoreFromBackup(client, true); return { status: 0 }; } async restoreFromBackup(client, autoRestart = false) { if (!this.restoreDirectory) { throw new BadRequestException(); } if (!await pathExists(resolve(this.restoreDirectory, 'info.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid Homebridge Backup Archive.'); } if (!await pathExists(resolve(this.restoreDirectory, 'plugins.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid Homebridge Backup Archive.'); } if (!await pathExists(resolve(this.restoreDirectory, 'storage'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid Homebridge Backup Archive.'); } const backupInfo = await readJson(resolve(this.restoreDirectory, 'info.json')); client.emit('stdout', cyan('Backup Archive Information\r\n')); client.emit('stdout', `Source Node.js Version: ${backupInfo.node}\r\n`); client.emit('stdout', `Source Homebridge UI Version: v${backupInfo.uix}\r\n`); client.emit('stdout', `Source Platform: ${backupInfo.platform}\r\n`); client.emit('stdout', `Created: ${backupInfo.timestamp}\r\n`); this.logger.warn('Starting backup restore...'); client.emit('stdout', cyan('\r\nRestoring backup...\r\n\r\n')); await new Promise(res => setTimeout(res, 1000)); const restoreFilter = [ join(this.restoreDirectory, 'storage', 'package.json'), join(this.restoreDirectory, 'storage', 'package-lock.json'), join(this.restoreDirectory, 'storage', '.npmrc'), join(this.restoreDirectory, 'storage', 'docker-compose.yml'), ]; const storagePath = await realpath(this.configService.storagePath); client.emit('stdout', yellow(`Restoring Homebridge storage to ${storagePath}\r\n`)); await new Promise(res => setTimeout(res, 100)); await copy(resolve(this.restoreDirectory, 'storage'), storagePath, { filter: async (filePath) => { if (restoreFilter.includes(filePath)) { client.emit('stdout', `Skipping ${basename(filePath)}\r\n`); return false; } try { const stat = await lstat(filePath); if (stat.isDirectory() || stat.isFile()) { client.emit('stdout', `Restoring ${basename(filePath)}\r\n`); return true; } else { client.emit('stdout', `Skipping ${basename(filePath)}\r\n`); return false; } } catch (e) { client.emit('stdout', `Skipping ${basename(filePath)}\r\n`); return false; } }, }); client.emit('stdout', yellow('File restore complete.\r\n')); await new Promise(res => setTimeout(res, 1000)); client.emit('stdout', cyan('\r\nRestoring plugins...\r\n')); const plugins = (await readJson(resolve(this.restoreDirectory, 'plugins.json'))) .filter((x) => ![ 'homebridge-config-ui-x', ].includes(x.name) && x.publicPackage); for (const plugin of plugins) { try { client.emit('stdout', yellow(`\r\nInstalling ${plugin.name}...\r\n`)); await this.pluginsService.managePlugin('install', { name: plugin.name, version: plugin.installedVersion }, client); } catch (e) { client.emit('stdout', red(`Failed to install ${plugin.name}.\r\n`)); } } const restoredConfig = await readJson(this.configService.configPath); if (restoredConfig.bridge) { restoredConfig.bridge.port = this.configService.homebridgeConfig.bridge.port; } if (restoredConfig.bridge.bind) { this.checkBridgeBindConfig(restoredConfig); } if (!Array.isArray(restoredConfig.platforms)) { restoredConfig.platforms = []; } const uiConfigBlock = restoredConfig.platforms.find(x => x.platform === 'config'); if (uiConfigBlock) { uiConfigBlock.port = this.configService.ui.port; } else { restoredConfig.platforms.push({ name: 'Config', port: this.configService.ui.port, platform: 'config', }); } await writeJson(this.configService.configPath, restoredConfig, { spaces: 4 }); await this.removeRestoreDirectory(); client.emit('stdout', green('\r\nRestore Complete!\r\n')); this.configService.hbServiceUiRestartRequired = true; if (autoRestart) { this.postBackupRestoreRestart(); } return { status: 0 }; } async uploadHbfxRestore(data) { this.restoreDirectory = undefined; const backupDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); this.logger.log(`Extracting .hbfx file to ${backupDir}.`); await pump(data.file, Extract({ path: backupDir, })); this.restoreDirectory = backupDir; } async restoreHbfxBackup(client) { if (!this.restoreDirectory) { throw new BadRequestException(); } if (!await pathExists(resolve(this.restoreDirectory, 'package.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid HBFX Backup Archive.'); } if (!await pathExists(resolve(this.restoreDirectory, 'etc', 'config.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid HBFX Backup Archive.'); } const backupInfo = await readJson(resolve(this.restoreDirectory, 'package.json')); client.emit('stdout', cyan('Backup Archive Information\r\n')); client.emit('stdout', `Backup Source: ${backupInfo.name}\r\n`); client.emit('stdout', `Version: v${backupInfo.version}\r\n`); this.logger.warn('Starting hbfx restore...'); client.emit('stdout', cyan('\r\nRestoring hbfx backup...\r\n\r\n')); await new Promise(res => setTimeout(res, 1000)); const storagePath = await realpath(this.configService.storagePath); client.emit('stdout', yellow(`Restoring Homebridge storage to ${storagePath}\r\n`)); await copy(resolve(this.restoreDirectory, 'etc'), resolve(storagePath), { filter: (filePath) => { if ([ 'access.json', 'dashboard.json', 'layout.json', 'config.json', ].includes(basename(filePath))) { return false; } client.emit('stdout', `Restoring ${basename(filePath)}\r\n`); return true; }, }); const sourceAccessoriesPath = resolve(this.restoreDirectory, 'etc', 'accessories'); const targetAccessoriesPath = resolve(storagePath, 'accessories'); if (await pathExists(sourceAccessoriesPath)) { await copy(sourceAccessoriesPath, targetAccessoriesPath, { filter: (filePath) => { client.emit('stdout', `Restoring ${basename(filePath)}\r\n`); return true; }, }); } const sourceConfig = await readJson(resolve(this.restoreDirectory, 'etc', 'config.json')); const pluginMap = { 'hue': 'homebridge-hue', 'chamberlain': 'homebridge-chamberlain', 'google-home': 'homebridge-gsh', 'ikea-tradfri': 'homebridge-ikea-tradfri-gateway', 'nest': 'homebridge-nest', 'ring': 'homebridge-ring', 'roborock': 'homebridge-roborock', 'shelly': 'homebridge-shelly', 'wink': 'homebridge-wink3', 'homebridge-tuya-web': '@milo526/homebridge-tuya-web', }; if (sourceConfig.plugins?.length) { for (let plugin of sourceConfig.plugins) { if (plugin in pluginMap) { plugin = pluginMap[plugin]; } try { client.emit('stdout', yellow(`\r\nInstalling ${plugin}...\r\n`)); await this.pluginsService.managePlugin('install', { name: plugin, version: 'latest' }, client); } catch (e) { client.emit('stdout', red(`Failed to install ${plugin}.\r\n`)); } } } const targetConfig = JSON.parse(JSON.stringify({ bridge: sourceConfig.bridge, accessories: sourceConfig.accessories?.map((x) => { delete x.plugin_map; return x; }) || [], platforms: sourceConfig.platforms?.map((x) => { if (x.platform === 'google-home') { x.platform = 'google-smarthome'; x.notice = 'Keep your token a secret!'; } delete x.plugin_map; return x; }) || [], })); targetConfig.bridge.name = `Homebridge ${targetConfig.bridge.username.substring(targetConfig.bridge.username.length - 5).replace(RE_COLON, '')}`; if (targetConfig.bridge.bind) { this.checkBridgeBindConfig(targetConfig); } targetConfig.platforms.push({ ...this.configService.ui, platform: 'config', }); await writeJson(this.configService.configPath, targetConfig, { spaces: 4 }); await this.removeRestoreDirectory(); client.emit('stdout', green('\r\nRestore Complete!\r\n')); this.configService.hbServiceUiRestartRequired = true; return { status: 0 }; } postBackupRestoreRestart() { setTimeout(() => { this.homebridgeIpcService.killHomebridge(); setTimeout(() => { process.kill(process.pid, 'SIGKILL'); }, 500); }, 500); return { status: 0 }; } checkBridgeBindConfig(restoredConfig) { if (restoredConfig.bridge.bind) { if (typeof restoredConfig.bridge.bind === 'string') { restoredConfig.bridge.bind = [restoredConfig.bridge.bind]; } if (!Array.isArray(restoredConfig.bridge.bind)) { delete restoredConfig.bridge.bind; return; } const interfaces = networkInterfaces(); restoredConfig.bridge.bind = restoredConfig.bridge.bind.filter(x => interfaces[x]); if (!restoredConfig.bridge.bind) { delete restoredConfig.bridge.bind; } } } }; BackupService = __decorate([ Injectable(), __param(0, Inject(ConfigService)), __param(1, Inject(PluginsService)), __param(2, Inject(SchedulerService)), __param(3, Inject(HomebridgeIpcService)), __param(4, Inject(Logger)), __metadata("design:paramtypes", [ConfigService, PluginsService, SchedulerService, HomebridgeIpcService, Logger]) ], BackupService); export { BackupService }; //# sourceMappingURL=backup.service.js.map