homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
607 lines • 27 kB
JavaScript
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