mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
Synchronize scheduler concurrency across API and gRPC processes and restore persisted limits during recovery. Serialize subscription mutations and preserve shared storage while cleaning Shell and CLI namespaces, historical references, and filesystem-specific naming candidates. Handle CLI text responses correctly and include script history in backups. Add regression coverage for concurrency, cleanup ownership, long URLs, Unicode paths, and history exports.
658 lines
19 KiB
TypeScript
658 lines
19 KiB
TypeScript
import { spawn } from 'cross-spawn';
|
|
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { shellQuote } from '../shared/shellQuote';
|
|
import { Response } from 'express';
|
|
import fs from 'fs';
|
|
import { Agent, request } from 'undici';
|
|
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 cronClient from '../schedule/client';
|
|
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
|
|
import NotificationService from './notify';
|
|
import ScheduleService, { TaskCallbacks } from './schedule';
|
|
import SockService from './sock';
|
|
import os from 'os';
|
|
import dayjs from 'dayjs';
|
|
import { t, setLang } from '../shared/i18n';
|
|
import { updateLinuxMirrorFile } from '../config/util';
|
|
|
|
@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<SystemInfo> {
|
|
const { id, ...others } = payload;
|
|
await SystemModel.update(others, { where: { id } });
|
|
const doc = await this.getDb({ id });
|
|
return doc;
|
|
}
|
|
|
|
public async getDb(query: any): Promise<SystemInfo> {
|
|
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,
|
|
t('青龙'),
|
|
t('【蛟龙】测试通知 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: t('通知发送失败,请检查参数') };
|
|
}
|
|
}
|
|
|
|
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: t('删除日志'),
|
|
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 limit = info.cronConcurrency ?? 0;
|
|
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
|
throw Object.assign(new Error(t('参数错误')), { status: 400 });
|
|
}
|
|
return withSchedulerMutation(async () => {
|
|
const oDoc = await this.getSystemConfig();
|
|
await this.updateAuthDb({
|
|
...oDoc,
|
|
info: { ...oDoc.info, ...info, cronConcurrency: limit },
|
|
});
|
|
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
|
await cronClient.setConcurrency(limit);
|
|
return { code: 200, data: info };
|
|
});
|
|
}
|
|
|
|
public async restoreCronConcurrency() {
|
|
await withSchedulerMutation(async () => {
|
|
const doc = await this.getSystemConfig();
|
|
const limit = doc.info?.cronConcurrency ?? 0;
|
|
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
|
await cronClient.setConcurrency(limit);
|
|
});
|
|
}
|
|
|
|
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=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(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 ${shellQuote(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',
|
|
status: 'completed',
|
|
});
|
|
},
|
|
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 ${shellQuote(info.pythonMirror)}`;
|
|
}
|
|
await promiseExec(cmd);
|
|
return { code: 200, data: info };
|
|
}
|
|
|
|
public async updateLinuxMirror(
|
|
info: SystemModelInfo,
|
|
res?: Response,
|
|
onEnd?: () => void,
|
|
) {
|
|
const oDoc = await this.getSystemConfig();
|
|
if (os.platform() !== 'linux') {
|
|
return;
|
|
}
|
|
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
|
let hasError = false;
|
|
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',
|
|
status: 'completed',
|
|
});
|
|
onEnd?.();
|
|
if (!hasError) {
|
|
await this.updateAuthDb({
|
|
...oDoc,
|
|
info: { ...oDoc.info, ...info },
|
|
});
|
|
}
|
|
},
|
|
onError: async (message: string) => {
|
|
hasError = true;
|
|
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),
|
|
status: 'failed',
|
|
});
|
|
});
|
|
|
|
cp.on('exit', (code) => {
|
|
this.sockService.sendMessage({
|
|
type: 'updateSystemVersion',
|
|
message: '',
|
|
status: code === 0 ? 'success' : 'failed',
|
|
});
|
|
});
|
|
|
|
return { code: 200 };
|
|
}
|
|
|
|
public async reloadSystem(target?: 'system' | 'data') {
|
|
if (target && !['system', 'data'].includes(target)) {
|
|
return { code: 400, message: t('参数错误') };
|
|
}
|
|
const cmd = `real_time=true ql reload ${shellQuote(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: t('通知发送成功') };
|
|
} else {
|
|
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
|
|
}
|
|
}
|
|
|
|
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: t('参数错误') };
|
|
}
|
|
|
|
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: t('任务未找到') };
|
|
}
|
|
}
|
|
|
|
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 allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
|
|
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
|
|
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
|
return res.status(400).send({ code: 400, message: t('参数错误') });
|
|
}
|
|
if (dataDirs.includes('scripts')) dataDirs.push('script-history');
|
|
// History is created lazily; old/fresh installations must still export.
|
|
if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) {
|
|
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
|
}
|
|
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
|
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
|
|
cwd: path.dirname(config.dataPath),
|
|
});
|
|
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;
|
|
limit?: number;
|
|
},
|
|
) {
|
|
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);
|
|
|
|
const limit = Math.min(
|
|
Math.max(Number(query.limit) || 1024 * 1024, 1),
|
|
1024 * 1024,
|
|
);
|
|
const total = logs.reduce((size, log) => size + (log.size || 0), 0);
|
|
let remaining = limit;
|
|
const selected: Array<
|
|
(typeof logs)[number] & { start: number; length: number }
|
|
> = [];
|
|
for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) {
|
|
const log = logs[index];
|
|
const size = log.size || 0;
|
|
const length = Math.min(size, remaining);
|
|
if (length > 0) {
|
|
selected.unshift({ ...log, start: size - length, length });
|
|
remaining -= length;
|
|
}
|
|
}
|
|
|
|
const contentLength = selected.reduce((size, log) => size + log.length, 0);
|
|
res.set({
|
|
'Content-Length': contentLength,
|
|
'X-QL-Log-Total': total,
|
|
'X-QL-Log-Truncated': total > contentLength ? 'true' : 'false',
|
|
});
|
|
(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),
|
|
{
|
|
start: currentLog.start,
|
|
end: currentLog.start + currentLog.length - 1,
|
|
},
|
|
);
|
|
currentFileStream.on('end', () => {
|
|
sendFiles(res, fileNames);
|
|
});
|
|
currentFileStream.pipe(res, { end: false });
|
|
}
|
|
})(res, selected);
|
|
}
|
|
|
|
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: t('设置时区失败') };
|
|
}
|
|
}
|
|
|
|
public async updateLanguage(info: SystemModelInfo) {
|
|
const oDoc = await this.getSystemConfig();
|
|
const lang = info.lang || 'zh';
|
|
await this.updateAuthDb({
|
|
...oDoc,
|
|
info: { ...oDoc.info, lang },
|
|
});
|
|
// Write to standalone lang_env.sh, sourced by shell scripts
|
|
try {
|
|
await fs.promises.writeFile(
|
|
config.langEnvFile,
|
|
`export QL_LANG=${shellQuote(lang)}\n`,
|
|
);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to write lang_env.sh: ${error}`);
|
|
}
|
|
setLang(lang);
|
|
return { code: 200, data: { lang } };
|
|
}
|
|
|
|
public async updatePanelTitle(info: SystemModelInfo) {
|
|
const oDoc = await this.getSystemConfig();
|
|
const panelTitle = info.panelTitle?.trim() || '';
|
|
await this.updateAuthDb({
|
|
...oDoc,
|
|
info: { ...oDoc.info, panelTitle },
|
|
});
|
|
return { code: 200, data: { panelTitle } };
|
|
}
|
|
|
|
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: t('参数错误') };
|
|
}
|
|
const finalPath = path.join(config.dependenceCachePath, type);
|
|
await fs.promises.rm(finalPath, { recursive: true, force: true });
|
|
await DependenceModel.update(
|
|
{ status: DependenceStatus.installFailed },
|
|
{
|
|
where: {
|
|
type:
|
|
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
|
|
status: DependenceStatus.installed,
|
|
},
|
|
},
|
|
);
|
|
return { code: 200 };
|
|
}
|
|
}
|