import { spawn } from 'cross-spawn'; import { Response } from 'express'; import fs from 'fs'; import { Agent, request } from 'undici'; import sum from 'lodash/sum'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; import config from '../config'; import { NotificationModeStringMap, TASK_COMMAND } from '../config/const'; import { getPid, killTask, parseContentVersion, parseVersion, promiseExec, readDirs, rmPath, setSystemTimezone, } from '../config/util'; import { DependenceModel, DependenceStatus, DependenceTypes, } from '../data/dependence'; import { NotificationInfo } from '../data/notify'; import { AuthDataType, SystemInfo, SystemInstance, SystemModel, SystemModelInfo, } from '../data/system'; import taskLimit from '../shared/pLimit'; import NotificationService from './notify'; import ScheduleService, { TaskCallbacks } from './schedule'; import SockService from './sock'; import os from 'os'; import dayjs from 'dayjs'; @Service() export default class SystemService { @Inject((type) => NotificationService) private notificationService!: NotificationService; constructor( @Inject('logger') private logger: winston.Logger, private scheduleService: ScheduleService, private sockService: SockService, ) { } public async getSystemConfig() { const doc = await this.getDb({ type: AuthDataType.systemConfig }); return { ...doc, info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' }, }; } private async updateAuthDb(payload: SystemInfo): Promise { const { id, ...others } = payload; await SystemModel.update(others, { where: { id } }); const doc = await this.getDb({ id }); return doc; } public async getDb(query: any): Promise { const doc = await SystemModel.findOne({ where: query }); if (!doc) { throw new Error(`System ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async updateNotificationMode(notificationInfo: NotificationInfo) { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, '青龙', `【蛟龙】测试通知 https://t.me/jiao_long`, ); if (isSuccess) { const result = await this.updateAuthDb({ type: AuthDataType.notification, info: { ...notificationInfo }, }); return { code: 200, data: { ...result, code } }; } else { return { code: 400, message: '通知发送失败,请检查参数' }; } } public async updateLogRemoveFrequency(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); const cron = { id: result.id as number, name: '删除日志', command: `ql rmlog ${info.logRemoveFrequency}`, runOrigin: 'system' as const, }; if (oDoc.info?.logRemoveFrequency) { await this.scheduleService.cancelIntervalTask(cron); } if (info.logRemoveFrequency && info.logRemoveFrequency > 0) { this.scheduleService.createIntervalTask( cron, { days: info.logRemoveFrequency, }, true, ); } return { code: 200, data: info }; } public async updateCronConcurrency(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); if (info.cronConcurrency) { await taskLimit.setCustomLimit(info.cronConcurrency); } return { code: 200, data: info }; } public async updateDependenceProxy(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); if (info.dependenceProxy) { await fs.promises.writeFile( config.dependenceProxyFile, `export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`, ); } else { await fs.promises.rm(config.dependenceProxyFile); } return { code: 200, data: info }; } public async updateNodeMirror(info: SystemModelInfo, res?: Response) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); let cmd = 'pnpm config delete registry'; if (info.nodeMirror) { cmd = `pnpm config set registry ${info.nodeMirror}`; } let command = `cd && ${cmd}`; const docs = await DependenceModel.findAll({ where: { type: DependenceTypes.nodejs, status: DependenceStatus.installed, }, }); if (docs.length > 0) { command += ` && pnpm i -g`; } this.scheduleService.runTask( command, { onStart: async (cp) => { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, onEnd: async () => { this.sockService.sendMessage({ type: 'updateNodeMirror', message: 'update node mirror end', }); }, onError: async (message: string) => { this.sockService.sendMessage({ type: 'updateNodeMirror', message }); }, onLog: async (message: string) => { this.sockService.sendMessage({ type: 'updateNodeMirror', message }); }, }, { command, id: 'update-node-mirror', runOrigin: 'system', }, ); } public async updatePythonMirror(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); let cmd = 'pip config unset global.index-url'; if (info.pythonMirror) { cmd = `pip3 config set global.index-url ${info.pythonMirror}`; } await promiseExec(cmd); return { code: 200, data: info }; } public async updateLinuxMirror( info: SystemModelInfo, res?: Response, onEnd?: () => void, ) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); if (os.platform() !== 'linux') { return; } let command = ''; // Check if this is a Debian-based system (including Armbian) // Check for both sources.list and debian_version for more reliable detection const hasAptSourcesList = await fs.promises.access('/etc/apt/sources.list') .then(() => true) .catch(() => false); const hasDebianVersion = await fs.promises.access('/etc/debian_version') .then(() => true) .catch(() => false); const isDebianBased = hasAptSourcesList || hasDebianVersion; if (isDebianBased) { // Handle Debian/Ubuntu/Armbian systems let defaultDomain = ''; let targetDomain = info.linuxMirror || ''; try { // Read the current sources.list const content = await fs.promises.readFile('/etc/apt/sources.list', { encoding: 'utf-8', }); // Match the first deb line to extract the current mirror // Note: This assumes all mirrors in sources.list use the same base URL // If multiple different mirrors are configured, only the first one will be replaced const debMatch = content.match(/^deb\s+(https?:\/\/[^\s]+)/m); if (debMatch) { defaultDomain = debMatch[1]; } if (defaultDomain && targetDomain) { // Sanitize and escape special characters for sed // Escape backslashes first, then other special characters const escapedDefault = defaultDomain .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/\//g, '\\/') // Escape forward slashes .replace(/\./g, '\\.'); // Escape dots const escapedTarget = targetDomain .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/\//g, '\\/'); // Escape forward slashes // Replace mirror URL in main sources.list command = `sed -i 's/${escapedDefault}/${escapedTarget}/g' /etc/apt/sources.list`; // Also update sources.list.d if it exists command += ` && if [ -d /etc/apt/sources.list.d ]; then find /etc/apt/sources.list.d -type f \\( -name "*.list" -o -name "*.sources" \\) -exec sed -i 's/${escapedDefault}/${escapedTarget}/g' {} \\;; fi`; // Update package lists command += ` && apt-get update`; } else if (!defaultDomain && targetDomain) { // Cannot detect current mirror, log warning this.logger.warn('Unable to detect current mirror from /etc/apt/sources.list. Mirror update skipped.'); this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'Warning: Unable to detect current mirror. Please manually configure /etc/apt/sources.list', }); } } catch (error) { this.logger.error('Failed to read /etc/apt/sources.list', error); } } else { // Handle Alpine Linux systems let defaultDomain = 'https://dl-cdn.alpinelinux.org'; let targetDomain = 'https://dl-cdn.alpinelinux.org'; try { const content = await fs.promises.readFile('/etc/apk/repositories', { encoding: 'utf-8', }); const domainMatch = content.match(/(http.*)\/alpine\/.*/); if (domainMatch) { defaultDomain = domainMatch[1]; } if (info.linuxMirror) { targetDomain = info.linuxMirror; } // Sanitize and escape special characters for sed // Escape backslashes first, then other special characters command = `sed -i 's/${defaultDomain .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/\//g, '\\/') // Escape forward slashes .replace(/\./g, '\\.')}/${targetDomain .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/\//g, '\\/')}/g' /etc/apk/repositories && apk update -f`; } catch (error) { this.logger.error('Failed to read /etc/apk/repositories', error); } } if (!command) { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'No supported package manager found or mirror not configured', }); return; } this.scheduleService.runTask( command, { onStart: async (cp) => { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, onEnd: async () => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'update linux mirror end', }); onEnd?.(); }, onError: async (message: string) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, onLog: async (message: string) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, }, { command, id: 'update-linux-mirror', runOrigin: 'system', }, ); } public async checkUpdate() { try { const currentVersionContent = await parseVersion(config.versionFile); let lastVersionContent; try { const { body } = await request( `${config.lastVersionFile}?t=${Date.now()}`, { dispatcher: new Agent({ keepAliveTimeout: 30000, keepAliveMaxTimeout: 30000, }), }, ); const text = await body.text(); lastVersionContent = parseContentVersion(text); } catch (error) { } if (!lastVersionContent) { lastVersionContent = currentVersionContent; } return { code: 200, data: { hasNewVersion: this.checkHasNewVersion( currentVersionContent.version, lastVersionContent.version, ), lastVersion: lastVersionContent.version, lastLog: lastVersionContent.changeLog, lastLogLink: lastVersionContent.changeLogLink, }, }; } catch (error: any) { return { code: 400, message: error.message, }; } } private checkHasNewVersion(curVersion: string, lastVersion: string) { const curArr = curVersion.split('.').map((x) => parseInt(x, 10)); const lastArr = lastVersion.split('.').map((x) => parseInt(x, 10)); if (curArr[0] < lastArr[0]) { return true; } if (curArr[0] === lastArr[0] && curArr[1] < lastArr[1]) { return true; } if ( curArr[0] === lastArr[0] && curArr[1] === lastArr[1] && curArr[2] < lastArr[2] ) { return true; } return false; } public async updateSystem() { const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' }); cp.stdout.on('data', (data) => { this.sockService.sendMessage({ type: 'updateSystemVersion', message: data.toString(), }); }); cp.stderr.on('data', (data) => { this.sockService.sendMessage({ type: 'updateSystemVersion', message: data.toString(), }); }); cp.on('error', (err) => { this.sockService.sendMessage({ type: 'updateSystemVersion', message: JSON.stringify(err), }); }); return { code: 200 }; } public async reloadSystem(target?: 'system' | 'data') { const cmd = `real_time=true ql reload ${target || ''}`; const cp = spawn(cmd, { shell: '/bin/bash', detached: true, stdio: 'ignore', }); cp.unref(); setTimeout(() => { process.exit(0); }); return { code: 200 }; } public async notify({ title, content, notificationInfo, }: { title: string; content: string; notificationInfo?: NotificationInfo; }) { const typeString = typeof notificationInfo?.type === 'number' ? NotificationModeStringMap[notificationInfo.type] : undefined; if (notificationInfo && typeString) { notificationInfo.type = typeString; } const isSuccess = await this.notificationService.notify( title, content, notificationInfo, ); if (isSuccess) { return { code: 200, message: '通知发送成功' }; } else { return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; } } public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) { if (!command.startsWith(TASK_COMMAND)) { command = `${TASK_COMMAND} ${command}`; } const logPathPrefix = logPath ? `real_log_path=${logPath}` : '' this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, { command, id: command.replace(/ /g, '-'), runOrigin: 'system', }); } public async stop({ command, pid }: { command: string; pid: number }) { if (!pid && !command) { return { code: 400, message: '参数错误' }; } if (pid) { await killTask(pid); return { code: 200 }; } if (!command.startsWith(TASK_COMMAND)) { command = `${TASK_COMMAND} ${command}`; } const _pid = await getPid(command); if (_pid) { await killTask(_pid); return { code: 200 }; } else { return { code: 400, message: '任务未找到' }; } } public async exportData(res: Response, type?: string[]) { try { let dataDirs = ['db', 'upload']; if (type && type.length) { dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); } const dataPaths = dataDirs.map((dir) => `data/${dir}`); await promiseExec( `cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile } ${dataPaths.join(' ')}`, ); res.download(config.dataTgzFile); } catch (error: any) { return res.send({ code: 400, message: error.message }); } } public async importData() { try { await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`); const res = await promiseExec( `cd ${config.tmpPath} && tar -zxvf ${config.dataTgzFile}`, ); return { code: 200, data: res }; } catch (error: any) { return { code: 400, message: error.message }; } } public async getSystemLog( res: Response, query: { startTime?: string; endTime?: string; }, ) { const startTime = dayjs(query.startTime || undefined) .startOf('d') .valueOf(); const endTime = dayjs(query.endTime || undefined) .endOf('d') .valueOf(); const result = await readDirs(config.systemLogPath, config.systemLogPath); const logs = result .reverse() .filter((x) => x.title.endsWith('.log')) .filter((x) => x.createTime >= startTime && x.createTime <= endTime); res.set({ 'Content-Length': sum(logs.map((x) => x.size)), }); (function sendFiles(res, fileNames) { if (fileNames.length === 0) { res.end(); return; } const currentLog = fileNames.shift(); if (currentLog) { const currentFileStream = fs.createReadStream( path.join(config.systemLogPath, currentLog.title), ); currentFileStream.on('end', () => { sendFiles(res, fileNames); }); currentFileStream.pipe(res, { end: false }); } })(res, logs); } public async deleteSystemLog() { const result = await readDirs(config.systemLogPath, config.systemLogPath); const logs = result.reverse().filter((x) => x.title.endsWith('.log')); for (const log of logs) { await rmPath(path.join(config.systemLogPath, log.title)); } } public async updateTimezone(info: SystemModelInfo) { if (!info.timezone) { info.timezone = 'Asia/Shanghai'; } const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); const success = await setSystemTimezone(info.timezone); if (success) { return { code: 200, data: info }; } else { return { code: 400, message: '设置时区失败' }; } } public async updateGlobalSshKey(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); // Apply the global SSH key const SshKeyService = require('./sshKey').default; const Container = require('typedi').Container; const sshKeyService = Container.get(SshKeyService); if (info.globalSshKey) { await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global'); } else { await sshKeyService.removeGlobalSSHKey('global'); } return { code: 200, data: result }; } public async cleanDependence(type: 'node' | 'python3') { if (!type || !['node', 'python3'].includes(type)) { return { code: 400, message: '参数错误' }; } try { const finalPath = path.join(config.dependenceCachePath, type); await fs.promises.rm(finalPath, { recursive: true }); } catch (error) { } return { code: 200 }; } }