Compare commits

...
Author SHA1 Message Date
whyour 4df52094f2 fix: harden authentication and file access security 2026-09-05 18:19:01 +08:00
whyour be2580d0e8 feat: show today's failed tasks from dashboard
Add a failure list with task names, commands, accumulated failure counts and latest log links. Keep recovered and deleted tasks visible in today's statistics.

Fixes #3065
2026-09-05 17:03:31 +08:00
whyour 5f6049d80a fix: secure file routes and dependency management 2026-08-29 19:10:30 +08:00
whyour 0e975d1d6d fix: show cron validation field errors 2026-08-22 01:20:04 +08:00
whyour 134cd8337c fix: read ql runtime logs from syslog 2026-08-20 09:41:42 +08:00
whyour 88200f2805 feat: add ql service log command 2026-08-20 00:56:59 +08:00
whyour decdab093f perf: speed up large file tree listings 2026-08-19 01:05:18 +08:00
whyour dfa73af104 fix: preserve container startup diagnostics 2026-08-18 00:40:40 +08:00
whyour f71fd5ebd6 fix: propagate initialization errors and reduce request metrics 2026-08-17 08:55:53 +08:00
whyour afe9b5702b chore: remove internal performance triage report 2026-08-16 22:41:46 +08:00
whyour 02e02491c4 fix: avoid duplicate container stdout logging 2026-08-16 17:03:15 +08:00
whyour 276dfc2382 perf: bound runtime memory and storage usage 2026-08-16 16:29:06 +08:00
whyour 31f78e4d1f feat: add client IP diagnostics and blocking 2026-08-16 13:57:33 +08:00
whyour 83e4490b57 fix: pin Python base for multi-arch builds 2026-08-11 00:12:47 +08:00
whyour 2ac4f07fca fix: address dashboard scope and preview interactions 2026-08-10 23:40:53 +08:00
Kobi Hikri 6618240c61 ci: attach provenance and SBOM attestations to the published images (#3048)
* ci: attach provenance and SBOM attestations to the published images

* ci: restore trailing newline at end of file
2026-08-01 20:11:55 +08:00
whyour 0bb41ebe96 新版本 v2.21.0 2026-07-25 13:28:44 +08:00
whyour d67f33df39 fix: 防止 task_before 污染 Node.js 依赖路径 2026-07-24 22:33:48 +08:00
whyour 24d539fc05 fix: 使用 npm 包安装 sqlite3 2026-07-19 00:40:40 +08:00
98 changed files with 4946 additions and 481 deletions
+7
View File
@@ -6,6 +6,13 @@ BACK_PORT=5700
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。
# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表:
# QL_TRUST_PROXY=1
# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32
# 仅在面板端口绝对无法绕过代理直接访问时使用 true。
# QL_TRUST_PROXY=loopback
LOG_LEVEL='info'
JWT_SECRET=
+8
View File
@@ -216,6 +216,8 @@ jobs:
context: .
file: ./docker/Dockerfile
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
@@ -295,6 +297,8 @@ jobs:
context: .
file: ./docker/Dockerfile.debian
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
@@ -359,6 +363,8 @@ jobs:
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
@@ -425,6 +431,8 @@ jobs:
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
+3
View File
@@ -35,3 +35,6 @@ __pycache__
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
# Local security audit artifacts
/audit/
+55
View File
@@ -0,0 +1,55 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import {
diagnoseClientIp,
getTrustProxyConfig,
updateTrustProxy,
} from '../shared/trustProxy';
const route = Router();
export default (app: Router) => {
app.use('/system/client-ip', route);
route.get(
'/config',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await getTrustProxyConfig() });
} catch (error) {
next(error);
}
},
);
route.put(
'/config',
celebrate({
body: Joi.object({
trustProxy: Joi.string().max(500).required(),
}),
}),
async (req: Request, res: Response) => {
try {
const data = await updateTrustProxy(req.body.trustProxy);
res.send({ code: 200, data });
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : '配置更新失败',
});
}
},
);
route.get(
'/diagnose',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await diagnoseClientIp(req) });
} catch (error) {
next(error);
}
},
);
};
+13 -19
View File
@@ -9,6 +9,7 @@ import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils';
import { resolveFileAccess } from '../shared/fileAccess';
const route = Router();
export default (app: Router) => {
@@ -78,14 +79,12 @@ export default (app: Router) => {
basePath = join(config.rootPath, 'data/scripts');
}
const cleanName = name.replace(/^data\/scripts\//, '');
const resolvedPath = join(basePath, cleanName);
const normalized = join(resolvedPath);
// Verify the resolved path stays within allowed directory
if (!normalized.startsWith(basePath)) {
return res.send({ code: 403, message: t('文件路径无效') });
}
// Check blacklist on actual filename (not user input)
if (config.blackFileList.includes(basename(normalized))) {
const normalized = resolveFileAccess(
basePath,
[cleanName],
config.blackFileList,
);
if (!normalized) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
@@ -96,15 +95,10 @@ export default (app: Router) => {
},
);
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const configService = Container.get(ConfigService);
await configService.getFile(req.params.file, res);
} catch (e) {
return next(e);
}
},
);
route.get('/:file', (req: Request, res: Response) => {
return res.send({
code: 410,
message: t('接口已下线,请使用 /configs/detail 接口'),
});
});
};
+24 -2
View File
@@ -307,13 +307,35 @@ export default (app: Router) => {
params: Joi.object({
id: Joi.number().required(),
}),
query: Joi.object({
offset: Joi.number().integer().min(0).optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
tail: Joi.boolean().optional(),
t: Joi.string().optional(),
}).unknown(true),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const result = await cronService.log(req.params.id);
return res.send({ code: 200, data: result.content, logStatus: result.status });
const result = await cronService.log(req.params.id, {
offset: req.query.offset as unknown as number,
limit: req.query.limit as unknown as number,
tail: req.query.tail as unknown as boolean,
});
return res.send({
code: 200,
data: result.content,
logStatus: result.status,
offset: result.offset,
nextOffset: result.nextOffset,
total: result.total,
truncated: result.truncated,
});
} catch (e) {
return next(e);
}
+37
View File
@@ -107,6 +107,43 @@ export default (app: Router) => {
},
);
route.get(
'/failures',
async (req: Request, res: Response, next: NextFunction) => {
try {
const rows = (await CrontabStatModel.findAll({
attributes: ['ref_id', [fn('SUM', col('fail_count')), 'fail_count']],
where: { date: dayjs().format('YYYY-MM-DD'), fail_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('fail_count')), 'DESC'], ['ref_id', 'ASC']],
raw: true,
})) as any[];
const crons = rows.length > 0 ? await CrontabModel.findAll({
attributes: ['id', 'name', 'command'],
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
raw: true,
}) : [];
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
res.send({
code: 200,
data: rows.map((row) => {
const id = Number(row.ref_id);
const cron = cronMap.get(id);
return {
id,
name: cron?.name || cron?.command || tf('任务#%s', id),
command: cron?.command || '',
failCount: Number(row.fail_count),
deleted: !cron,
};
}),
});
} catch (e) {
next(e);
}
},
);
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
+4
View File
@@ -12,6 +12,8 @@ import subscription from './subscription';
import update from './update';
import dashboard from './dashboard';
import health from './health';
import clientIp from './clientIp';
import retention from './retention';
export default () => {
const app = Router();
@@ -28,6 +30,8 @@ export default () => {
update(app);
dashboard(app);
health(app);
clientIp(app);
retention(app);
return app;
};
+30 -21
View File
@@ -5,13 +5,13 @@ import { Logger } from 'winston';
import config from '../config';
import { t } from '../shared/i18n';
import {
getFileContentByName,
readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
const route = Router();
const blacklist = ['.tmp'];
@@ -34,6 +34,20 @@ export default (app: Router) => {
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().allow('').optional(),
file: Joi.string().required(),
offset: Joi.number().integer().min(0).optional(),
limit: Joi.number()
.integer()
.min(1)
.max(MAX_LOG_CHUNK_BYTES)
.optional(),
tail: Joi.boolean().optional(),
t: Joi.string().optional(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
@@ -52,11 +66,19 @@ export default (app: Router) => {
where: { log_path: logPath, status: InstanceStatus.running },
});
const content = await getFileContentByName(finalPath);
const chunk = await readLogChunk(finalPath, {
offset: req.query.offset as unknown as number,
limit: req.query.limit as unknown as number,
tail: req.query.tail as unknown as boolean,
});
res.send({
code: 200,
data: removeAnsi(content),
data: removeAnsi(chunk.content),
logStatus: runningInstance ? 'running' : undefined,
offset: chunk.offset,
nextOffset: chunk.nextOffset,
total: chunk.total,
truncated: chunk.truncated,
});
} catch (e) {
return next(e);
@@ -66,24 +88,11 @@ export default (app: Router) => {
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.params.file as string) || '',
);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
(req: Request, res: Response) => {
return res.send({
code: 410,
message: t('接口已下线,请使用 /logs/detail 接口'),
});
},
);
+78
View File
@@ -0,0 +1,78 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { Container } from 'typedi';
import RetentionService from '../services/retention';
import { MAX_RETENTION_DAYS } from '../shared/retention';
const route = Router();
const policySchema = {
runningInstanceRetentionDays: Joi.number()
.integer()
.min(0)
.max(MAX_RETENTION_DAYS)
.required(),
cronStatRetentionDays: Joi.number()
.integer()
.min(0)
.max(MAX_RETENTION_DAYS)
.required(),
};
const cleanupSchema = {
...policySchema,
dependenceCacheTypes: Joi.array()
.items(Joi.string().valid('node', 'python3'))
.unique()
.default([]),
compactDatabase: Joi.boolean().default(false),
};
export default (app: Router) => {
app.use('/system/storage-retention', route);
route.put(
'/config',
celebrate({ body: Joi.object(policySchema) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(RetentionService);
const data = await service.updatePolicy(req.body);
res.send({ code: 200, data });
} catch (error) {
next(error);
}
},
);
route.post(
'/preview',
celebrate({ body: Joi.object(cleanupSchema) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(RetentionService);
const data = await service.preview(req.body);
res.send({ code: 200, data });
} catch (error) {
next(error);
}
},
);
route.post(
'/cleanup',
celebrate({
body: Joi.object({
...cleanupSchema,
confirmation: Joi.string().valid('CLEAN').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(RetentionService);
const data = await service.cleanup(req.body);
res.send({ code: 200, data });
} catch (error) {
next(error);
}
},
);
};
+33 -29
View File
@@ -1,3 +1,5 @@
import { randomUUID } from 'crypto';
import { resolveFileAccess } from '../shared/fileAccess';
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
@@ -14,15 +16,17 @@ const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((x) => resolved.startsWith(x));
return config.writePathList.some((x) =>
Boolean(resolveFileAccess(x, [resolved], config.blackFileList)),
);
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.scriptPath);
cb(null, config.tmpPath);
},
filename: function (req, file, cb) {
cb(null, file.originalname);
cb(null, randomUUID());
},
});
const upload = multer({ storage: storage });
@@ -50,6 +54,15 @@ export default (app: Router) => {
'package-lock.json',
];
if (req.query.path) {
if (
!resolveFileAccess(
config.scriptPath,
[req.query.path as string],
config.blackFileList,
)
) {
return res.send({ code: 403, message: t('暂无权限') });
}
result = await readDir(
req.query.path as string,
config.scriptPath,
@@ -77,7 +90,8 @@ export default (app: Router) => {
logger.error('🔥 error: %o', e);
return next(e);
}
});
},
);
route.get(
'/detail',
@@ -91,7 +105,7 @@ export default (app: Router) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
(req.query?.path as string) || '',
req.query.file as string,
);
res.send({ code: 200, data: content });
@@ -101,32 +115,21 @@ export default (app: Router) => {
},
);
route.get(
'/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
req.params.file,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.get('/:file', (req: Request, res: Response) => {
return res.send({
code: 410,
message: t('接口已下线,请使用 /scripts/detail 接口'),
});
});
route.post(
'/',
(req: Request, res: Response, next: NextFunction) => {
res.on('finish', () => {
if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined);
});
next();
},
upload.single('file'),
celebrate({
body: Joi.object({
@@ -170,7 +173,8 @@ export default (app: Router) => {
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(req.file.path, uploadPath);
await fs.copyFile(req.file.path, uploadPath);
await fs.unlink(req.file.path);
return res.send({ code: 200 });
}
+16 -2
View File
@@ -148,13 +148,27 @@ export default (app: Router) => {
params: Joi.object({
id: Joi.number().required(),
}),
query: Joi.object({
offset: Joi.number().integer().min(0).optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
tail: Joi.boolean().optional(),
t: Joi.string().optional(),
}).unknown(true),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.log(req.params.id);
return res.send({ code: 200, data });
const result = await subscriptionService.log(req.params.id, {
offset: req.query.offset as unknown as number,
limit: req.query.limit as unknown as number,
tail: req.query.tail as unknown as boolean,
});
return res.send({ code: 200, data: result.content, ...result });
} catch (e) {
return next(e);
}
+6
View File
@@ -350,6 +350,11 @@ export default (app: Router) => {
query: {
startTime: Joi.string().allow('').optional(),
endTime: Joi.string().allow('').optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
t: Joi.string().optional(),
},
}),
@@ -361,6 +366,7 @@ export default (app: Router) => {
req.query as {
startTime?: string;
endTime?: string;
limit?: number;
},
);
} catch (e) {
+86 -13
View File
@@ -22,7 +22,25 @@ const storage = multer.diskStorage({
cb(null, key + ext);
},
});
const upload = multer({ storage: storage });
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const imageTypes: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
};
if (imageTypes[ext] !== file.mimetype) {
return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片')));
}
cb(null, true);
},
});
export default (app: Router) => {
app.use('/user', route);
@@ -35,8 +53,8 @@ export default (app: Router) => {
}),
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -70,8 +88,8 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -80,8 +98,8 @@ export default (app: Router) => {
return res.send({ code: 450, message: t('未知错误') });
}
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: t('更新成功') });
const result = await userService.updateUsernameAndPassword(req.body);
res.send(result);
} catch (e) {
return next(e);
}
@@ -156,11 +174,12 @@ export default (app: Router) => {
route.put(
'/two-factor/login',
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
celebrate({
body: Joi.object({
code: Joi.string().required(),
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -189,6 +208,59 @@ export default (app: Router) => {
},
);
route.get(
'/ip-blacklist',
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.getIpBlacklist();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/ip-blacklist',
celebrate({
body: Joi.object({
ip: Joi.string()
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
.required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.blockIp(req.body.ip);
res.send({ code: 200, data, message: t('已加入 IP 黑名单') });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/ip-blacklist',
celebrate({
body: Joi.object({
ip: Joi.string()
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
.required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.unblockIp(req.body.ip);
res.send({ code: 200, data, message: t('已移出 IP 黑名单') });
} catch (e) {
return next(e);
}
},
);
route.get(
'/notification',
async (req: Request, res: Response, next: NextFunction) => {
@@ -219,18 +291,19 @@ export default (app: Router) => {
route.put(
'/init',
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: t('更新成功') });
const result = await userService.initializeUser(req.body);
res.send(result);
} catch (e) {
return next(e);
}
+7 -5
View File
@@ -1,3 +1,5 @@
import { randomInt } from 'crypto';
export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [
@@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string {
arr.push(getOne(ENGLISH));
arr.push(getOne(special));
const len = min + Math.floor(Math.random() * (max - min + 1));
const len = min + randomInt(max - min + 1);
for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]);
arr.push(config[randomInt(config.length)]);
}
const newArr = [];
for (let j = 0; j < len; j++) {
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
newArr.push(arr.splice(randomInt(arr.length), 1)[0]);
}
function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
return arr[randomInt(arr.length)];
}
return newArr.join('');
}
}
+93 -36
View File
@@ -214,48 +214,105 @@ export function dirSort(a: IFile, b: IFile): number {
}
}
const FILE_SYSTEM_READ_CONCURRENCY = 32;
type FileSystemTaskRunner = <T>(task: () => Promise<T>) => Promise<T>;
function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner {
let activeCount = 0;
const queue: Array<() => void> = [];
const runNext = () => {
while (activeCount < concurrency && queue.length > 0) {
activeCount += 1;
queue.shift()?.();
}
};
return <T>(task: () => Promise<T>) =>
new Promise<T>((resolve, reject) => {
queue.push(() => {
task()
.then(resolve, reject)
.finally(() => {
activeCount -= 1;
runNext();
});
});
runNext();
});
}
async function readDirsWithRunner(
dir: string,
baseDir: string,
blacklist: string[],
sort: (a: IFile, b: IFile) => number,
runFileSystemTask: FileSystemTaskRunner,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir);
const entries = await runFileSystemTask(() =>
fs.readdir(dir, { withFileTypes: true }),
);
const items = await Promise.all(
entries.map(async (entry): Promise<IFile | undefined> => {
if (blacklist.includes(entry.name) || entry.isSymbolicLink()) {
return undefined;
}
const subPath = path.join(dir, entry.name);
const stats = await runFileSystemTask(() => fs.lstat(subPath));
if (stats.isSymbolicLink()) {
return undefined;
}
const key = path.join(relativePath, entry.name);
if (stats.isDirectory()) {
const children = await readDirsWithRunner(
subPath,
baseDir,
blacklist,
sort,
runFileSystemTask,
);
return {
title: entry.name,
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
children,
};
}
return {
title: entry.name,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
};
}),
);
return items.filter((item): item is IFile => Boolean(item)).sort(sort);
}
export async function readDirs(
dir: string,
baseDir: string = '',
blacklist: string[] = [],
sort: (a: IFile, b: IFile) => number = dirSort,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir);
const files = await fs.readdir(dir);
const result: IFile[] = [];
for (const file of files) {
const subPath = path.join(dir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
const children = await readDirs(subPath, baseDir, blacklist, sort);
result.push({
title: file,
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
children: children.sort(sort),
});
} else {
result.push({
title: file,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
return result.sort(sort);
return readDirsWithRunner(
dir,
baseDir,
blacklist,
sort,
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
);
}
export async function readDir(
+5
View File
@@ -41,6 +41,8 @@ export interface SystemConfigInfo {
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
runningInstanceRetentionDays?: number;
cronStatRetentionDays?: number;
}
export interface LoginLogInfo {
@@ -73,11 +75,14 @@ export interface AuthInfo {
lastaddr: string;
platform: string;
isTwoFactorChecking: boolean;
twoFactorExpiresAt?: number;
lastTwoFactorStep?: number;
token: string;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
blockedIps?: string[];
}
export type SystemModelInfo = SystemConfigInfo &
+2
View File
@@ -6,6 +6,7 @@ import { Application } from 'express';
import linkDeps from './deps';
import initTask from './initTask';
import initFile from './initFile';
import { initializeTrustProxy } from '../shared/trustProxy';
export default async ({ app }: { app: Application }) => {
depInjectorLoader();
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
Logger.info('[boot] Init task loaded');
expressLoader({ app });
await initializeTrustProxy(app);
Logger.info('[boot] Express loaded');
};
+46 -25
View File
@@ -14,30 +14,32 @@ import { AuthInfo } from '../data/system';
import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
import protectedPathCase from '../middlewares/protectedPathCase';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim();
if (!setting) {
return 'loopback';
}
if (setting === 'true' || setting === 'false') {
return setting === 'true';
}
if (/^\d+$/.test(setting)) {
return Number(setting);
}
return setting;
}
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.set('trust proxy', resolveTrustProxy());
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Security: Reject case variations under protected API namespaces before
// authentication checks can interpret the request differently from routing.
app.use(protectedPathCase);
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
@@ -46,7 +48,26 @@ export default ({ app }: { app: Application }) => {
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use(
`${config.api.prefix}/static`,
express.static(config.uploadPath, {
setHeaders: (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
},
}),
);
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
['/user/login', '/user/init', '/user/two-factor/login'].map(
(route) => `${prefix}${route}`,
),
);
app.use(
credentialPaths,
bodyParser.json({ limit: '16kb' }),
bodyParser.urlencoded({ limit: '16kb', extended: false }),
);
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
@@ -97,7 +118,10 @@ export default ({ app }: { app: Application }) => {
return next(err);
}
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
@@ -118,14 +142,12 @@ export default ({ app }: { app: Application }) => {
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? t('Token 已失效')
: t('请先登录');
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
@@ -142,8 +164,7 @@ export default ({ app }: { app: Application }) => {
) {
return next();
}
const authInfo =
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
+20 -2
View File
@@ -8,26 +8,44 @@ import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const echo = sockJs.createServer({
prefix: `${config.baseUrl}/api/ws`,
log: () => {},
});
const sockService = Container.get(SockService);
echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404');
return;
}
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform)) {
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
sockService.addClient(conn);
const checkSession = setInterval(async () => {
try {
const current = await shareStore.getAuthInfo();
if (
!isValidToken(current, headerToken, platform, config.jwt.secret)
) {
conn.close('401');
}
} catch {
conn.close('401');
}
}, 1000);
checkSession.unref();
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
clearInterval(checkSession);
sockService.removeClient(conn);
});
+17 -49
View File
@@ -3,46 +3,34 @@ import Logger from '../loaders/logger';
import { performance } from 'perf_hooks';
import { metricsService } from '../services/metrics';
interface RequestMetrics {
method: string;
path: string;
duration: number;
statusCode: number;
timestamp: number;
platform?: string;
}
const requestMetrics: RequestMetrics[] = [];
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
let requestSampleOffset = 0;
export const monitoringMiddleware = (
req: Request,
res: Response,
next: NextFunction,
) => {
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
return next();
}
const start = performance.now();
const originalEnd = res.end;
res.end = function (chunk?: any, encoding?: any, cb?: any) {
const duration = performance.now() - start;
const metric: RequestMetrics = {
method: req.method,
path: req.path,
duration,
statusCode: res.statusCode,
timestamp: Date.now(),
platform: req.platform,
};
requestMetrics.push(metric);
metricsService.record('http_request', duration, {
method: req.method,
path: req.path,
statusCode: res.statusCode.toString(),
...(req.platform && { platform: req.platform }),
});
if (requestMetrics.length > 1000) {
requestMetrics.shift();
const shouldSample = requestSampleOffset === 0;
requestSampleOffset =
(requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
if (shouldSample) {
metricsService.record('http_request', duration, {
method: req.method,
path: req.path,
statusCode: res.statusCode.toString(),
...(req.platform && { platform: req.platform }),
});
}
if (duration > 1000) {
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
next();
};
export const getMetrics = () => {
return {
totalRequests: requestMetrics.length,
averageDuration:
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
requestMetrics.length,
requestsByMethod: requestMetrics.reduce((acc, curr) => {
acc[curr.method] = (acc[curr.method] || 0) + 1;
return acc;
}, {} as Record<string, number>),
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
if (curr.platform) {
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
}
return acc;
}, {} as Record<string, number>),
recentRequests: requestMetrics.slice(-10),
};
};
+23
View File
@@ -0,0 +1,23 @@
import type { NextFunction, Request, Response } from 'express';
export default function protectedPathCase(
req: Request,
res: Response,
next: NextFunction,
) {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
if (
originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') ||
normalizedPath.startsWith('/open/'))
) {
return res.status(400).json({
code: 400,
message: 'Invalid path format',
});
}
return next();
}
+88 -12
View File
@@ -1,28 +1,104 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import fs from 'fs/promises';
import path from 'path';
import { request } from 'undici';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import config from '../config';
import { promiseExec } from '../config/util';
function formatError(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const detailedError = error as Error & {
cause?: unknown;
code?: string;
errors?: unknown[];
};
const details = [error.message, detailedError.code];
if (Array.isArray(detailedError.errors)) {
details.push(...detailedError.errors.map(formatError));
} else if (detailedError.cause) {
details.push(formatError(detailedError.cause));
}
return [...new Set(details.filter(Boolean))].join(': ') || error.name;
}
async function getRecentSystemLog(lineLimit = 300): Promise<string> {
try {
const entries = await fs.readdir(config.systemLogPath);
const latestLog = entries
.filter((entry) => entry.endsWith('.log'))
.sort()
.at(-1);
if (!latestLog) {
return `No system log found in ${config.systemLogPath}`;
}
const content = await fs.readFile(
path.join(config.systemLogPath, latestLog),
'utf8',
);
return content.split('\n').slice(-lineLimit).join('\n').trim();
} catch (error) {
return `Unable to read system log from ${config.systemLogPath}: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
const check = async (
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
callback: sendUnaryData<HealthCheckResponse>,
) => {
switch (call.request.service) {
case 'cron':
const res = await promiseExec(
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
);
if (res.includes('200')) {
return callback(null, { status: 1 });
case 'cron': {
const healthUrl = `http://localhost:${config.port}${
config.baseUrl || ''
}/api/health`;
let failure = '';
try {
const response = await request(healthUrl, {
method: 'GET',
headersTimeout: 5000,
bodyTimeout: 5000,
});
const body = (await response.body.json()) as {
code?: number;
data?: { status?: string };
};
if (
response.statusCode >= 200 &&
response.statusCode < 300 &&
body.code === 200 &&
body.data?.status === 'ok'
) {
return callback(null, { status: 1 });
}
failure = `HTTP ${response.statusCode}: ${JSON.stringify(body)}`;
} catch (error) {
failure = formatError(error);
}
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
const systemLog = await getRecentSystemLog();
const containerHint =
process.env.QL_CONTAINER === 'true'
? 'PM2 file logging is disabled in containers. Check `docker logs <container>` for early startup errors.'
: 'Check `pm2 logs qinglong --lines 300` for early startup errors.';
return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
new Error(
[
`HTTP health check failed: ${healthUrl}`,
failure,
containerHint,
`Recent system log (${config.systemLogPath}):`,
systemLog,
]
.filter(Boolean)
.join('\n'),
),
);
}
default:
return callback(null, { status: 1 });
+10 -20
View File
@@ -1,10 +1,10 @@
import { Service, Inject } from 'typedi';
import path, { join } from 'path';
import { Service } from 'typedi';
import config from '../config';
import { getFileContentByName } from '../config/util';
import { t } from '../shared/i18n';
import { Response } from 'express';
import { request } from 'undici';
import { resolveFileAccess } from '../shared/fileAccess';
@Service()
export default class ConfigService {
@@ -15,21 +15,13 @@ export default class ConfigService {
if (!filePath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const normalized = path.normalize(filePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const resolvedRoot = path.resolve(config.rootPath, normalized);
const resolvedConfig = path.resolve(config.configPath, normalized);
const isValidPath =
resolvedRoot.startsWith(config.scriptPath) ||
resolvedRoot.startsWith(config.configPath) ||
resolvedConfig.startsWith(config.scriptPath) ||
resolvedConfig.startsWith(config.configPath);
if (!isValidPath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (config.blackFileList.includes(path.basename(normalized))) {
const scriptFile = filePath.startsWith('data/scripts/');
const resolved = resolveFileAccess(
scriptFile ? config.scriptPath : config.configPath,
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
config.blackFileList,
);
if (!resolved) {
return res.send({ code: 403, message: t('文件无法访问') });
}
@@ -38,10 +30,8 @@ export default class ConfigService {
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
);
content = await res.body.text();
} else if (filePath.startsWith('data/scripts/')) {
content = await getFileContentByName(join(config.rootPath, filePath));
} else {
content = await getFileContentByName(join(config.configPath, filePath));
content = await getFileContentByName(resolved);
}
res.send({ code: 200, data: content });
+45 -8
View File
@@ -32,6 +32,7 @@ import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
import { isEmpty } from 'lodash';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
@Service()
export default class CronService {
@@ -766,27 +767,63 @@ export default class CronService {
await this.setCrontab();
}
public async log(id: number): Promise<{ content: string; status: string }> {
public async log(
id: number,
options: LogReadOptions = {},
): Promise<{
content: string;
status: string;
offset: number;
nextOffset: number;
total: number;
truncated: boolean;
}> {
const doc = await this.getDb({ id });
if (!doc) {
return { content: '', status: 'empty' };
return {
content: '',
status: 'empty',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
if (doc.log_name === '/dev/null') {
return { content: t('日志设置为忽略'), status: 'ignored' };
return {
content: t('日志设置为忽略'),
status: 'ignored',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
const content = await getFileContentByName(`${absolutePath}`);
const chunk = await readLogChunk(`${absolutePath}`, options);
const isRunning =
typeof doc.status === 'number' &&
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
return { content, status: isRunning ? 'running' : 'completed' };
return {
...chunk,
status: isRunning ? 'running' : 'completed',
};
} else {
return typeof doc.status === 'number' &&
const status =
typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? { content: t('运行中...'), status: 'running' }
: { content: t('日志不存在...'), status: 'notFound' };
? 'running'
: 'notFound';
return {
content: status === 'running' ? t('运行中...') : t('日志不存在...'),
status,
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
}
+69
View File
@@ -16,6 +16,7 @@ import {
getPid,
killTask,
promiseExecSuccess,
concurrentRun,
getInstallCommand,
getUninstallCommand,
getGetCommand,
@@ -110,6 +111,11 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
@@ -132,6 +138,69 @@ export default class DependenceService {
}
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
DependenceTypes.python3,
];
if (type !== undefined && !cacheDependenceTypes.includes(type)) {
return;
}
const docs = await DependenceModel.findAll({
where: {
status: DependenceStatus.installed,
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checks = await concurrentRun(
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
);
}
}
private async isDependenceInstalled(dependency: Dependence) {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
const [, parsedName, parsedVersion] = depName.match(symbolRegx) || [];
if (parsedVersion && parsedName) {
depName = parsedName;
depVersion = parsedVersion;
}
}
const depInfo = (
await promiseExecSuccess(getGetCommand(dependency.type, depName))
)
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
const nameMatches =
(dependency.type === DependenceTypes.nodejs &&
depInfo.split(' ')?.[0] === depName) ||
dependency.type === DependenceTypes.python3;
return Boolean(
depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)),
);
}
public installDependenceOneByOne(
docs: Dependence[],
isInstall: boolean = true,
+1 -1
View File
@@ -40,7 +40,7 @@ export class GrpcServerService {
const grpcPort = config.grpcPort;
const hostsToTry = [
config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
+1 -1
View File
@@ -12,7 +12,7 @@ export class HttpServerService {
async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [
config.bindHost,
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
...(config.bindHost === '::' ? ['0.0.0.0'] : [])
];
let lastError: Error | null = null;
+6 -2
View File
@@ -1,3 +1,4 @@
import { resolveFileAccess } from '../shared/fileAccess';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
@@ -8,7 +9,10 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
return resolveFileAccess(
config.logPath,
[filePath || '', fileName],
config.blackFileList,
);
}
}
+61 -19
View File
@@ -8,16 +8,19 @@ interface Metric {
tags?: Record<string, string>;
}
class MetricsService {
private metrics: Metric[] = [];
export const MAX_METRIC_SAMPLES = 1000;
const METRIC_RETENTION_MS = 60 * 60 * 1000;
export class MetricsService {
private metrics: Array<Metric | undefined> = new Array(MAX_METRIC_SAMPLES);
private metricCount = 0;
private nextMetricIndex = 0;
private static instance: MetricsService;
private constructor() {
// 定期清理旧数据
setInterval(() => {
const oneHourAgo = Date.now() - 3600000;
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
}, 60000);
const cleanupTimer = setInterval(() => this.removeExpiredMetrics(), 60000);
cleanupTimer.unref();
}
static getInstance(): MetricsService {
@@ -28,12 +31,14 @@ class MetricsService {
}
record(name: string, value: number, tags?: Record<string, string>) {
this.metrics.push({
this.metrics[this.nextMetricIndex] = {
name,
value,
timestamp: Date.now(),
tags,
});
};
this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES;
this.metricCount = Math.min(this.metricCount + 1, MAX_METRIC_SAMPLES);
}
measure(name: string, fn: () => void, tags?: Record<string, string>) {
@@ -46,7 +51,11 @@ class MetricsService {
}
}
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
async measureAsync(
name: string,
fn: () => Promise<void>,
tags?: Record<string, string>,
) {
const start = performance.now();
try {
await fn();
@@ -57,28 +66,61 @@ class MetricsService {
}
getMetrics(name?: string, tags?: Record<string, string>) {
let filtered = this.metrics;
this.removeExpiredMetrics();
let filtered = this.getMetricSnapshot();
if (name) {
filtered = filtered.filter(m => m.name === name);
filtered = filtered.filter((m) => m.name === name);
}
if (tags) {
filtered = filtered.filter(m => {
filtered = filtered.filter((m) => {
if (!m.tags) return false;
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
return Object.entries(tags).every(
([key, value]) => m.tags![key] === value,
);
});
}
const values = filtered.map((metric) => metric.value);
return {
count: filtered.length,
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
min: Math.min(...filtered.map(m => m.value)),
max: Math.max(...filtered.map(m => m.value)),
average: values.length
? values.reduce((acc, value) => acc + value, 0) / values.length
: 0,
min: values.length ? Math.min(...values) : 0,
max: values.length ? Math.max(...values) : 0,
metrics: filtered,
};
}
private getMetricSnapshot(): Metric[] {
if (this.metricCount < MAX_METRIC_SAMPLES) {
return this.metrics.slice(0, this.metricCount) as Metric[];
}
return [
...this.metrics.slice(this.nextMetricIndex),
...this.metrics.slice(0, this.nextMetricIndex),
] as Metric[];
}
private removeExpiredMetrics() {
const oldestTimestamp = Date.now() - METRIC_RETENTION_MS;
const retained = this.getMetricSnapshot().filter(
(metric) => metric.timestamp > oldestTimestamp,
);
if (retained.length === this.metricCount) return;
this.metrics = new Array(MAX_METRIC_SAMPLES);
this.metricCount = 0;
this.nextMetricIndex = 0;
for (const metric of retained) {
this.metrics[this.nextMetricIndex] = metric;
this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES;
this.metricCount++;
}
}
report() {
const report = {
timestamp: Date.now(),
@@ -89,4 +131,4 @@ class MetricsService {
}
}
export const metricsService = MetricsService.getInstance();
export const metricsService = MetricsService.getInstance();
+155
View File
@@ -0,0 +1,155 @@
import fs from 'fs/promises';
import path from 'path';
import dayjs from 'dayjs';
import { Op } from 'sequelize';
import { Service } from 'typedi';
import config from '../config';
import { CrontabStatModel } from '../data/cronStats';
import { sequelize } from '../data';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { AuthDataType, SystemModel } from '../data/system';
import {
DependenceCacheType,
getDirectorySize,
isDependenceCacheType,
normalizeRetentionPolicy,
RetentionPolicy,
} from '../shared/retention';
export interface StorageCleanupRequest extends RetentionPolicy {
dependenceCacheTypes?: DependenceCacheType[];
compactDatabase?: boolean;
}
function runningInstanceWhere(days: number) {
const cutoff = dayjs().subtract(days, 'day').unix();
return {
status: { [Op.ne]: InstanceStatus.running },
[Op.or]: [
{ finished_at: { [Op.lt]: cutoff } },
{
finished_at: { [Op.is]: null },
started_at: { [Op.lt]: cutoff },
},
],
};
}
function cronStatWhere(days: number) {
return {
date: { [Op.lt]: dayjs().subtract(days, 'day').format('YYYY-MM-DD') },
};
}
@Service()
export default class RetentionService {
public async updatePolicy(policy: Partial<RetentionPolicy>) {
const normalized = normalizeRetentionPolicy(policy);
const systemConfig = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (!systemConfig) {
throw new Error('System config not found');
}
await SystemModel.update(
{
info: {
...systemConfig.info,
...normalized,
},
},
{ where: { id: systemConfig.id } },
);
return normalized;
}
public async preview(request: StorageCleanupRequest) {
const policy = normalizeRetentionPolicy(request);
const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter(
isDependenceCacheType,
);
const [runningInstances, cronStats, dependenceCaches] = await Promise.all([
policy.runningInstanceRetentionDays > 0
? RunningInstanceModel.count({
where: runningInstanceWhere(policy.runningInstanceRetentionDays),
})
: 0,
policy.cronStatRetentionDays > 0
? CrontabStatModel.count({
where: cronStatWhere(policy.cronStatRetentionDays),
})
: 0,
Promise.all(
dependenceCacheTypes.map(async (type) => ({
type,
bytes: await getDirectorySize(
path.join(config.dependenceCachePath, type),
),
})),
),
]);
return {
policy,
runningInstances,
cronStats,
dependenceCaches,
dependenceCacheBytes: dependenceCaches.reduce(
(total, cache) => total + cache.bytes,
0,
),
compactDatabase: Boolean(request.compactDatabase),
};
}
public async cleanup(request: StorageCleanupRequest) {
const preview = await this.preview(request);
const deleted = await sequelize.transaction(async (transaction) => {
const runningInstances =
preview.policy.runningInstanceRetentionDays > 0
? await RunningInstanceModel.destroy({
where: runningInstanceWhere(
preview.policy.runningInstanceRetentionDays,
),
transaction,
})
: 0;
const cronStats =
preview.policy.cronStatRetentionDays > 0
? await CrontabStatModel.destroy({
where: cronStatWhere(preview.policy.cronStatRetentionDays),
transaction,
})
: 0;
return { runningInstances, cronStats };
});
const dependenceCaches = [];
for (const cache of preview.dependenceCaches) {
await fs.rm(path.join(config.dependenceCachePath, cache.type), {
recursive: true,
force: true,
});
dependenceCaches.push(cache);
}
if (
request.compactDatabase &&
(deleted.runningInstances || deleted.cronStats)
) {
await sequelize.query('VACUUM');
}
return {
deleted: {
...deleted,
dependenceCaches,
dependenceCacheBytes: preview.dependenceCacheBytes,
},
compactedDatabase: Boolean(
request.compactDatabase &&
(deleted.runningInstances || deleted.cronStats),
),
};
}
}
+6 -2
View File
@@ -1,3 +1,4 @@
import { resolveFileAccess } from '../shared/fileAccess';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import path, { join } from 'path';
@@ -65,8 +66,11 @@ export default class ScriptService {
}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
return resolveFileAccess(
config.scriptPath,
[filePath || '', fileName],
config.blackFileList,
);
}
public async getFile(filePath: string, fileName: string) {
+10 -3
View File
@@ -33,6 +33,7 @@ import { CrontabModel } from '../data/cron';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
@Service()
export default class SubscriptionService {
@@ -366,14 +367,20 @@ export default class SubscriptionService {
}
}
public async log(id: number) {
public async log(id: number, options: LogReadOptions = {}) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
return '';
return {
content: '',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
const absolutePath = await handleLogPath(doc.log_path as string);
return await getFileContentByName(absolutePath);
return await readLogChunk(absolutePath, options);
}
public async logs(id: number) {
+41 -7
View File
@@ -2,7 +2,6 @@ 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';
@@ -467,6 +466,7 @@ export default class SystemService {
query: {
startTime?: string;
endTime?: string;
limit?: number;
},
) {
const startTime = dayjs(query.startTime || undefined)
@@ -481,8 +481,30 @@ export default class SystemService {
.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': sum(logs.map((x) => x.size)),
'Content-Length': contentLength,
'X-QL-Log-Total': total,
'X-QL-Log-Truncated': total > contentLength ? 'true' : 'false',
});
(function sendFiles(res, fileNames) {
if (fileNames.length === 0) {
@@ -494,13 +516,17 @@ export default class SystemService {
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, logs);
})(res, selected);
}
public async deleteSystemLog() {
@@ -583,10 +609,18 @@ export default class SystemService {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
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 };
}
}
+183 -25
View File
@@ -20,12 +20,19 @@ import ScheduleService from './schedule';
import SockService from './sock';
import dayjs from 'dayjs';
import IP2Region from 'ip2region';
import requestIp from 'request-ip';
import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store';
import { t, tf } from '../shared/i18n';
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
import { isDefaultAuthInfo } from '../shared/auth';
import {
hashPassword,
isPasswordHash,
verifyPassword,
} from '../shared/password';
import { serializeAuthMutation } from '../shared/authMutation';
@Service()
export default class UserService {
@@ -38,7 +45,15 @@ export default class UserService {
private sockService: SockService,
) {}
@serializeAuthMutation
public async login(
payloads: { username: string; password: string },
req: Request,
): Promise<any> {
return this.authenticate(payloads, req);
}
private async authenticate(
payloads: {
username: string;
password: string;
@@ -48,7 +63,18 @@ export default class UserService {
): Promise<any> {
let { username, password } = payloads;
const content = await this.getAuthInfo();
if (isDefaultAuthInfo(content)) {
return { code: 450, message: t('请先初始化') };
}
const timestamp = Date.now();
const ip = getClientIp(req);
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
let {
username: cUsername,
password: cPassword,
@@ -59,7 +85,27 @@ export default class UserService {
twoFactorActivated,
tokens = {},
platform,
blockedIps = [],
} = content;
if (
ip &&
blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip)
) {
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.fail,
},
});
this.getLoginLog();
return { code: 403, message: t('该 IP 已被列入黑名单') };
}
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
@@ -72,14 +118,13 @@ export default class UserService {
};
}
if (
username === cUsername &&
password === cPassword &&
twoFactorActivated &&
needTwoFactor
) {
const passwordMatches =
username === cUsername && (await verifyPassword(password, cPassword));
if (passwordMatches && twoFactorActivated && needTwoFactor) {
await this.updateAuthInfo(content, {
isTwoFactorChecking: true,
twoFactorExpiresAt: timestamp + 5 * 60 * 1000,
});
return {
code: 420,
@@ -87,15 +132,7 @@ export default class UserService {
};
}
const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) {
if (passwordMatches) {
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
@@ -118,6 +155,9 @@ export default class UserService {
);
await this.updateAuthInfo(content, {
password: isPasswordHash(cPassword)
? cPassword
: await hashPassword(password),
token,
tokens: updatedTokens,
lastlogon: timestamp,
@@ -126,6 +166,7 @@ export default class UserService {
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
this.notificationService.notify(
t('登录通知'),
@@ -211,6 +252,7 @@ export default class UserService {
}
}
@serializeAuthMutation
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
@@ -264,11 +306,67 @@ export default class UserService {
return [];
}
public async getIpBlacklist(): Promise<string[]> {
const authInfo = await this.getAuthInfo();
return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter(
Boolean,
);
}
@serializeAuthMutation
public async blockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
const blockedIps = uniq([
...(authInfo.blockedIps || []).map(normalizeClientIp),
normalizeClientIp(ip),
]).filter(Boolean);
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
@serializeAuthMutation
public async unblockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
const normalizedIp = normalizeClientIp(ip);
const blockedIps = (authInfo.blockedIps || [])
.map(normalizeClientIp)
.filter((blockedIp) => blockedIp && blockedIp !== normalizedIp);
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc;
}
@serializeAuthMutation
public async initializeUser({
username,
password,
}: {
username: string;
password: string;
}) {
const authInfo = await this.getAuthInfo();
if (!isDefaultAuthInfo(authInfo)) {
return { code: 450, message: t('未知错误') };
}
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
await this.updateAuthInfo(authInfo, {
username,
password: await hashPassword(password),
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return { code: 200, message: t('更新成功') };
}
@serializeAuthMutation
public async updateUsernameAndPassword({
username,
password,
@@ -280,24 +378,37 @@ export default class UserService {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password });
await this.updateAuthInfo(authInfo, {
username,
password: await hashPassword(password),
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return { code: 200, message: t('更新成功') };
}
@serializeAuthMutation
public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: t('更新成功') };
}
@serializeAuthMutation
public async initTwoFactor() {
const secret = authenticator.generateSecret();
const authInfo = await this.getAuthInfo();
if (authInfo.twoFactorActivated) {
throw new Error(t('请先关闭两步验证'));
}
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth };
}
@serializeAuthMutation
public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo();
const isValid = authenticator.verify({
@@ -305,11 +416,18 @@ export default class UserService {
secret: authInfo.twoFactorSecret,
});
if (isValid) {
await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
await this.updateAuthInfo(authInfo, {
twoFactorActivated: true,
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
}
return isValid;
}
@serializeAuthMutation
public async twoFactorLogin(
{
username,
@@ -320,17 +438,30 @@ export default class UserService {
) {
const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) {
const now = Date.now();
const retries = authInfo.retries || 0;
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) {
return { code: 410, message: t('失败次数过多,请稍后重试') };
}
if (
!isTwoFactorChecking ||
!authInfo.twoFactorActivated ||
!authInfo.twoFactorExpiresAt ||
authInfo.twoFactorExpiresAt <= now
) {
return { code: 450, message: t('未知错误') };
}
const isValid = authenticator.verify({
token: code,
secret: twoFactorSecret,
});
const step = Math.floor(now / 30000);
const isValid =
username === authInfo.username &&
(await verifyPassword(password, authInfo.password)) &&
authInfo.lastTwoFactorStep !== step &&
authenticator.verify({ token: code, secret: twoFactorSecret });
if (isValid) {
return this.login({ username, password }, req, false);
await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step });
return this.authenticate({ username, password }, req, false);
} else {
const ip = requestIp.getClientIp(req) || '';
const ip = getClientIp(req);
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
@@ -341,6 +472,9 @@ export default class UserService {
.join(' ');
}
await this.updateAuthInfo(authInfo, {
retries: retries + 1,
lastlogon: now,
isTwoFactorChecking: retries + 1 < 5,
lastip: ip,
lastaddr: address,
platform: req.platform,
@@ -349,11 +483,16 @@ export default class UserService {
}
}
@serializeAuthMutation
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, {
twoFactorActivated: false,
twoFactorSecret: '',
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return true;
}
@@ -374,6 +513,9 @@ export default class UserService {
type: AuthDataType.authConfig,
info: result,
});
if (info.tokens && Object.keys(info.tokens).length === 0) {
this.sockService.getClients().forEach((conn) => conn.close('401'));
}
}
public async getNotificationMode(): Promise<NotificationInfo> {
@@ -515,6 +657,7 @@ export default class UserService {
return undefined;
}
@serializeAuthMutation
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
if (password === 'admin') {
@@ -531,6 +674,21 @@ export default class UserService {
(x) => !isNil(x),
);
if (password !== undefined) {
payload.password = await hashPassword(password);
}
if (
password !== undefined ||
username !== undefined ||
twoFactorActivated !== undefined
) {
Object.assign(payload, {
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
}
await this.updateAuthInfo(authInfo, payload);
}
}
+18 -6
View File
@@ -1,11 +1,8 @@
import { AuthInfo, TokenInfo } from '../data/system';
import jwt from 'jsonwebtoken';
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
return (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
);
return authInfo.username === 'admin' && authInfo.password === 'admin';
}
/**
@@ -21,11 +18,21 @@ export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
secret: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
try {
const claims = jwt.verify(headerToken, secret, { algorithms: ['HS384'] });
if (typeof claims === 'string' || typeof claims.exp !== 'number') {
return false;
}
} catch {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
@@ -46,7 +53,12 @@ export function isValidToken(
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
return platformTokens.some(
(t: TokenInfo) =>
t &&
t.value === headerToken &&
(t.expiration === undefined || t.expiration > Date.now() / 1000),
);
}
// Unexpected type - log warning and reject
+17
View File
@@ -0,0 +1,17 @@
// All account mutations in the HTTP service share one queue. In particular,
// a login that read old credentials must finish before a password reset revokes
// its session, and two initialization requests must not both claim the account.
let pending: Promise<unknown> = Promise.resolve();
export function serializeAuthMutation(
_target: object,
_key: string,
descriptor: PropertyDescriptor,
) {
const method = descriptor.value;
descriptor.value = function (this: unknown, ...args: unknown[]) {
const result = pending.then(() => method.apply(this, args));
pending = result.catch(() => undefined);
return result;
};
}
+29
View File
@@ -0,0 +1,29 @@
import { Request } from 'express';
const IPV4_MAPPED_PREFIX = '::ffff:';
export function normalizeClientIp(value?: string): string {
let ip = (value || '').trim().toLowerCase();
if (!ip) {
return '';
}
if (ip.startsWith('[') && ip.endsWith(']')) {
ip = ip.slice(1, -1);
}
const zoneIndex = ip.indexOf('%');
if (zoneIndex !== -1) {
ip = ip.slice(0, zoneIndex);
}
if (ip.startsWith(IPV4_MAPPED_PREFIX)) {
return ip.slice(IPV4_MAPPED_PREFIX.length);
}
return ip;
}
export function getClientIp(req: Request): string {
return normalizeClientIp(req.ip || req.socket.remoteAddress);
}
+57
View File
@@ -0,0 +1,57 @@
import fs from 'fs';
import path from 'path';
function isWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === '' ||
(!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative))
);
}
/** Resolve existing files and not-yet-created children without following an
* existing symlink outside the root. Blacklisted directories cover descendants. */
export function resolveFileAccess(
root: string,
parts: string[],
blacklist: string[] = [],
): string {
if (parts.some((part) => typeof part !== 'string' || part.includes('\0'))) {
return '';
}
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
const isBlocked = (relative: string) =>
relative.split(path.sep).some((part) => blacklist.includes(part));
if (isBlocked(path.relative(resolvedRoot, target))) return '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
let existing = target;
const missing: string[] = [];
while (!fs.existsSync(existing)) {
// existsSync is false for a dangling symlink; never treat one as absent.
try {
fs.lstatSync(existing);
return '';
} catch (error: any) {
if (error.code !== 'ENOENT') return '';
}
if (existing === resolvedRoot) return '';
missing.unshift(path.basename(existing));
existing = path.dirname(existing);
}
const realTarget = path.resolve(fs.realpathSync(existing), ...missing);
if (
!isWithin(realRoot, realTarget) ||
isBlocked(path.relative(realRoot, realTarget))
) {
return '';
}
return target;
} catch {
return '';
}
}
+3
View File
@@ -98,6 +98,9 @@ const messages: Record<string, Record<string, string>> = {
'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked',
'已加入 IP 黑名单': 'IP address added to the blacklist',
'已移出 IP 黑名单': 'IP address removed from the blacklist',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
+109
View File
@@ -0,0 +1,109 @@
import fs from 'fs/promises';
export const DEFAULT_LOG_CHUNK_BYTES = 256 * 1024;
export const MAX_LOG_CHUNK_BYTES = 1024 * 1024;
export interface LogReadOptions {
offset?: number;
limit?: number;
tail?: boolean;
}
export interface LogChunk {
content: string;
offset: number;
nextOffset: number;
total: number;
truncated: boolean;
}
function normalizeLimit(limit?: number) {
if (!Number.isFinite(limit)) return DEFAULT_LOG_CHUNK_BYTES;
return Math.min(Math.max(Math.trunc(limit!), 4), MAX_LOG_CHUNK_BYTES);
}
function isUtf8ContinuationByte(byte: number) {
return (byte & 0xc0) === 0x80;
}
function completeUtf8End(buffer: Buffer, start: number, end: number) {
if (end <= start) return start;
let sequenceStart = end - 1;
while (
sequenceStart > start &&
isUtf8ContinuationByte(buffer[sequenceStart])
) {
sequenceStart--;
}
const firstByte = buffer[sequenceStart];
const expectedLength =
firstByte < 0x80
? 1
: firstByte < 0xe0
? 2
: firstByte < 0xf0
? 3
: 4;
return end - sequenceStart < expectedLength ? sequenceStart : end;
}
export async function readLogChunk(
filePath: string,
options: LogReadOptions = {},
): Promise<LogChunk> {
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(filePath, 'r');
const { size: total } = await handle.stat();
const limit = normalizeLimit(options.limit);
const requestedOffset = Number.isFinite(options.offset)
? Math.trunc(options.offset!)
: undefined;
const requestedStart =
options.tail || requestedOffset === undefined
? Math.max(total - limit, 0)
: Math.min(Math.max(requestedOffset, 0), total);
const length = Math.min(limit + 3, total - requestedStart);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(
buffer,
0,
length,
requestedStart,
);
let leadingBytes = 0;
while (
leadingBytes < bytesRead &&
isUtf8ContinuationByte(buffer[leadingBytes])
) {
leadingBytes++;
}
const offset = requestedStart + leadingBytes;
const candidateEnd = Math.min(leadingBytes + limit, bytesRead);
const contentEnd = completeUtf8End(buffer, leadingBytes, candidateEnd);
const nextOffset = requestedStart + contentEnd;
return {
content: buffer.subarray(leadingBytes, contentEnd).toString('utf8'),
offset,
nextOffset,
total,
truncated: offset > 0 || nextOffset < total,
};
} catch (error: any) {
if (error?.code === 'ENOENT') {
return {
content: '',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
throw error;
} finally {
await handle?.close();
}
}
+30
View File
@@ -0,0 +1,30 @@
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
import { promisify } from 'util';
const deriveKey = promisify(scrypt);
const HASH_PREFIX = 'scrypt$';
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const key = (await deriveKey(password, salt, 64)) as Buffer;
return `${HASH_PREFIX}${salt}$${key.toString('hex')}`;
}
export function isPasswordHash(password: string): boolean {
return /^scrypt\$[a-f0-9]{32}\$[a-f0-9]{128}$/.test(password);
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
if (!isPasswordHash(stored)) {
// Existing installations migrate after a successful password check.
const input = Buffer.from(password);
const expected = Buffer.from(stored);
return input.length === expected.length && timingSafeEqual(input, expected);
}
const [, salt, hash] = stored.split('$');
const key = (await deriveKey(password, salt, 64)) as Buffer;
return timingSafeEqual(key, Buffer.from(hash, 'hex'));
}
+64
View File
@@ -0,0 +1,64 @@
import fs from 'fs/promises';
import { Dirent } from 'fs';
import path from 'path';
export const MAX_RETENTION_DAYS = 3650;
export const DEPENDENCE_CACHE_TYPES = ['node', 'python3'] as const;
export type DependenceCacheType = (typeof DEPENDENCE_CACHE_TYPES)[number];
export interface RetentionPolicy {
runningInstanceRetentionDays: number;
cronStatRetentionDays: number;
}
export function normalizeRetentionDays(value: unknown) {
const days = Number(value);
if (!Number.isFinite(days)) return 0;
return Math.min(Math.max(Math.trunc(days), 0), MAX_RETENTION_DAYS);
}
export function normalizeRetentionPolicy(
policy: Partial<RetentionPolicy>,
): RetentionPolicy {
return {
runningInstanceRetentionDays: normalizeRetentionDays(
policy.runningInstanceRetentionDays,
),
cronStatRetentionDays: normalizeRetentionDays(policy.cronStatRetentionDays),
};
}
export function isDependenceCacheType(
value: string,
): value is DependenceCacheType {
return DEPENDENCE_CACHE_TYPES.includes(value as DependenceCacheType);
}
export async function getDirectorySize(rootPath: string): Promise<number> {
const pending = [rootPath];
let total = 0;
while (pending.length > 0) {
const currentPath = pending.pop()!;
let entries: Dirent[];
try {
entries = await fs.readdir(currentPath, { withFileTypes: true });
} catch (error: any) {
if (error?.code === 'ENOENT') continue;
throw error;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const entryPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
pending.push(entryPath);
} else if (entry.isFile()) {
total += (await fs.stat(entryPath)).size;
}
}
}
return total;
}
+142
View File
@@ -0,0 +1,142 @@
import express, { Application, Request } from 'express';
import { AuthDataType, SystemModel } from '../data/system';
import { normalizeClientIp } from './clientIp';
const DEFAULT_TRUST_PROXY = 'loopback';
type TrustProxyValue = boolean | number | string;
let activeApp: Application | undefined;
function getEnvironmentSetting(): string {
return process.env.QL_TRUST_PROXY?.trim() || '';
}
function normalizeSetting(value?: string): string {
return value?.trim() || DEFAULT_TRUST_PROXY;
}
export function resolveTrustProxy(value?: string): TrustProxyValue {
const setting = normalizeSetting(value);
if (setting === 'true' || setting === 'false') {
return setting === 'true';
}
if (/^\d+$/.test(setting)) {
return Number(setting);
}
return setting;
}
function validateTrustProxy(value: string): string {
const setting = normalizeSetting(value);
if (setting.length > 500 || /[\r\n]/.test(setting)) {
throw new Error('trust proxy 配置格式无效');
}
if (/^\d+$/.test(setting) && Number(setting) > 20) {
throw new Error('代理层数不能超过 20');
}
const probe = express();
probe.set('trust proxy', resolveTrustProxy(setting));
return setting;
}
async function getStoredSetting(): Promise<string> {
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
const info = (doc?.get('info') || {}) as Record<string, unknown>;
return typeof info.trustProxy === 'string' ? info.trustProxy : '';
}
export async function getTrustProxyConfig() {
const environmentSetting = getEnvironmentSetting();
const storedSetting = await getStoredSetting();
const trustProxy = normalizeSetting(environmentSetting || storedSetting);
return {
trustProxy,
source: environmentSetting
? 'environment'
: storedSetting
? 'system'
: 'default',
editable: !environmentSetting,
};
}
export async function initializeTrustProxy(app: Application) {
activeApp = app;
const { trustProxy } = await getTrustProxyConfig();
app.set('trust proxy', resolveTrustProxy(trustProxy));
}
export async function updateTrustProxy(value: string) {
if (getEnvironmentSetting()) {
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
}
const trustProxy = validateTrustProxy(value);
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (!doc) {
throw new Error('系统配置不存在');
}
const plain = doc.get({ plain: true });
await SystemModel.update(
{ info: { ...(plain.info || {}), trustProxy } as any },
{ where: { id: plain.id } },
);
activeApp?.set('trust proxy', resolveTrustProxy(trustProxy));
return getTrustProxyConfig();
}
function parseForwardedFor(value: string | string[] | undefined): string[] {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values
.flatMap((item) => item.split(','))
.map((item) => normalizeClientIp(item))
.filter(Boolean);
}
export async function diagnoseClientIp(req: Request) {
const remoteAddress = normalizeClientIp(req.socket.remoteAddress);
const forwardedFor = parseForwardedFor(req.headers['x-forwarded-for']);
const hopsFromApp = [remoteAddress, ...forwardedFor.slice().reverse()].filter(
Boolean,
);
const trust = req.app.get('trust proxy fn') as
| ((ip: string, hop: number) => boolean)
| undefined;
let selectedIndex = Math.max(hopsFromApp.length - 1, 0);
for (let index = 0; index < hopsFromApp.length - 1; index += 1) {
if (!trust?.(hopsFromApp[index], index)) {
selectedIndex = index;
break;
}
}
const hops = hopsFromApp.map((ip, index) => ({
ip,
hop: index,
status:
index < selectedIndex
? 'trusted'
: index === selectedIndex
? 'client'
: 'not_checked',
}));
return {
...(await getTrustProxyConfig()),
remoteAddress,
forwardedFor,
expressIps: req.ips.map(normalizeClientIp),
clientIp: normalizeClientIp(req.ip || req.socket.remoteAddress),
hops,
};
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const commonCronSchema = {
name: Joi.string().optional(),
command: Joi.string().required(),
schedule: scheduleSchema,
labels: Joi.array().optional(),
labels: Joi.array().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
+4 -2
View File
@@ -2,7 +2,7 @@
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
FROM node:20-bookworm-slim AS nodebuilder
FROM python:3.11-slim-bookworm AS builder
FROM python:3.11.14-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
@@ -14,7 +14,7 @@ RUN set -x && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.11-slim-bookworm
FROM python:3.11.14-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
@@ -70,8 +70,10 @@ RUN set -x && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
npm cache clean --force && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /var/lib/apt/lists/* && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
+2
View File
@@ -69,8 +69,10 @@ RUN set -x && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
npm cache clean --force && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /var/lib/apt/lists/* && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
+7 -1
View File
@@ -1,3 +1,5 @@
const isContainer = process.env.QL_CONTAINER === 'true';
module.exports = {
apps: [
{
@@ -7,7 +9,11 @@ module.exports = {
wait_ready: true,
listen_timeout: 5000,
source_map_support: true,
time: true,
time: !isContainer,
out_file: isContainer ? '/dev/null' : undefined,
// Do not persist PM2 logs in containers, but keep early startup errors
// visible through `docker logs` before Winston is initialized.
error_file: isContainer ? '/proc/1/fd/2' : undefined,
script: 'static/build/app.js',
env: {
http_proxy: '',
+4 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.20.2-3",
"version": "2.21.0-16",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
@@ -21,9 +21,7 @@
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true",
"test": "umi-test",
"test:coverage": "umi-test --coverage"
"postinstall": "max setup 2>/dev/null || true"
},
"gitHooks": {
"pre-commit": "lint-staged"
@@ -67,7 +65,7 @@
}
},
"overrides": {
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0",
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16"
}
@@ -111,7 +109,7 @@
"request-ip": "3.3.0",
"sequelize": "^6.37.5",
"sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0",
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.9.0",
+39 -41
View File
@@ -1,11 +1,7 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.1.0
sqlite3: npm:@whyour/sqlite3@1.1.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
@@ -120,13 +116,13 @@ dependencies:
version: 3.3.0
sequelize:
specifier: ^6.37.5
version: 6.37.5(@whyour/sqlite3@1.1.0)
version: 6.37.5(@whyour/sqlite3@1.1.2)
sockjs:
specifier: ^0.3.24
version: 0.3.24
sqlite3:
specifier: git+https://github.com/whyour/node-sqlite3.git#v1.1.0
version: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce
specifier: npm:@whyour/sqlite3@1.1.2
version: /@whyour/sqlite3@1.1.2
toad-scheduler:
specifier: ^3.0.1
version: 3.0.1
@@ -1630,8 +1626,8 @@ packages:
resolution: {integrity: sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
dependencies:
'@codemirror/language': 6.10.6
@@ -3194,7 +3190,7 @@ packages:
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
engines: {node: '>= 18'}
dependencies:
sqlite3: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce
sqlite3: /@whyour/sqlite3@1.1.2
transitivePeerDependencies:
- encoding
- supports-color
@@ -3869,8 +3865,8 @@ packages:
peerDependencies:
'@codemirror/autocomplete': ^6.0.0
'@codemirror/language': ^6.0.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
'@lezer/highlight': ^1.0.0
'@lezer/lr': ^1.0.0
@@ -3889,8 +3885,8 @@ packages:
peerDependencies:
'@codemirror/autocomplete': ^6.0.0
'@codemirror/language': ^6.0.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
'@lezer/highlight': ^1.0.0
'@lezer/lr': ^1.0.0
@@ -3921,8 +3917,8 @@ packages:
'@codemirror/lang-html': ^6.2.0
'@codemirror/lang-javascript': ^6.1.1
'@codemirror/language': ^6.0.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
'@lezer/highlight': ^1.0.0
'@lezer/javascript': ^1.2.0
@@ -4825,8 +4821,8 @@ packages:
'@codemirror/language': '>=6.0.0'
'@codemirror/lint': '>=6.0.0'
'@codemirror/search': '>=6.0.0'
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@codemirror/state': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/commands': 6.7.1
@@ -4885,9 +4881,9 @@ packages:
resolution: {integrity: sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==}
peerDependencies:
'@babel/runtime': '>=7.11.0'
'@codemirror/state': 6.5.4
'@codemirror/state': '>=6.0.0'
'@codemirror/theme-one-dark': '>=6.0.0'
'@codemirror/view': 6.39.16
'@codemirror/view': '>=6.0.0'
codemirror: '>=6.0.0'
react: '>=16.8.0 || 18'
react-dom: '>=16.8.0 || 18'
@@ -5618,6 +5614,23 @@ packages:
- supports-color
dev: true
/@whyour/sqlite3@1.1.2:
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
requiresBuild: true
peerDependenciesMeta:
node-gyp:
optional: true
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
node-addon-api: 4.3.0
tar: 6.2.1
optionalDependencies:
node-gyp: 12.4.0
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
requiresBuild: true
@@ -13926,7 +13939,7 @@ packages:
engines: {node: '>= 10.0.0'}
dev: false
/sequelize@6.37.5(@whyour/sqlite3@1.1.0):
/sequelize@6.37.5(@whyour/sqlite3@1.1.2):
resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==}
engines: {node: '>=10.0.0'}
peerDependencies:
@@ -13971,7 +13984,7 @@ packages:
retry-as-promised: 7.0.4
semver: 7.6.3
sequelize-pool: 7.1.0
sqlite3: github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce
sqlite3: /@whyour/sqlite3@1.1.2
toposort-class: 1.0.1
uuid: 8.3.2
validator: 13.12.0
@@ -15810,21 +15823,6 @@ packages:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
dev: true
github.com/whyour/node-sqlite3/4d29681eee3557890502d3859ec0fd6b6d13ccce:
resolution: {tarball: https://codeload.github.com/whyour/node-sqlite3/tar.gz/4d29681eee3557890502d3859ec0fd6b6d13ccce}
name: '@whyour/sqlite3'
version: 1.1.0
requiresBuild: true
peerDependenciesMeta:
node-gyp:
optional: true
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
node-addon-api: 4.3.0
tar: 6.2.1
optionalDependencies:
node-gyp: 12.4.0
transitivePeerDependencies:
- encoding
- supports-color
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+58 -18
View File
@@ -23,37 +23,75 @@ copy_dep() {
}
pm2_log() {
t '---> pm2日志'
local panelOut="/root/.pm2/logs/qinglong-out.log"
local panelError="/root/.pm2/logs/qinglong-error.log"
tail -n 300 "$panelOut"
tail -n 300 "$panelError"
t '---> 服务诊断信息'
pm2 status || true
local systemLogDir="$dir_data/syslog"
local latestSystemLog
latestSystemLog=$(find "$systemLogDir" -maxdepth 1 -type f -name '*.log' 2>/dev/null | sort | tail -n 1)
if [[ -n "$latestSystemLog" ]]; then
t '---> 最近的系统日志: %s' "$latestSystemLog"
tail -n 300 "$latestSystemLog"
else
t '---> 未找到系统日志: %s' "$systemLogDir"
fi
if [[ "$QL_CONTAINER" == "true" ]]; then
t '---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>'
return
fi
local pm2Home="${PM2_HOME:-$HOME/.pm2}"
local panelOut="$pm2Home/logs/qinglong-out.log"
local panelError="$pm2Home/logs/qinglong-error.log"
[[ -f "$panelOut" ]] && tail -n 300 "$panelOut"
[[ -f "$panelError" ]] && tail -n 300 "$panelError"
}
check_ql() {
local api=$(curl -s --noproxy "*" "http://localhost:${ql_port}")
local basePath="${ql_base_url%/}"
local api
local attempt
for ((attempt = 1; attempt <= 10; attempt++)); do
api=$(curl -s --max-time 2 --noproxy "*" "http://localhost:${ql_port}${basePath}/")
[[ $api =~ "<div id=\"root\"></div>" ]] && break
sleep 1
done
t '\n=====> 检测面板'
echo -e "\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then
t '=====> 面板服务启动正常\n'
else
t '=====> 面板服务启动异常,请检查上方诊断信息\n'
return 1
fi
}
check_pm2() {
pm2_log
local currentTimeStamp=$(date +%s)
local api=$(
curl -s --noproxy "*" "http://localhost:${ql_port}/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H "Referer: http://localhost:${ql_port}/crontab" \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed
)
local basePath="${ql_base_url%/}"
local api
local attempt
for ((attempt = 1; attempt <= 10; attempt++)); do
api=$(
curl -s --max-time 2 --noproxy "*" "http://localhost:${ql_port}${basePath}/api/health?t=$currentTimeStamp" \
-H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H "Referer: http://localhost:${ql_port}/crontab" \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed
)
[[ $api == *'"code":200'* && $api == *'"status":"ok"'* ]] && break
sleep 1
done
t '\n=====> 检测后台'
echo -e "\n\n$api\n"
if [[ $api =~ "{\"code\"" ]]; then
if [[ $api == *'"code":200'* && $api == *'"status":"ok"'* ]]; then
t '=====> 后台服务启动正常\n'
else
pm2_log
t '=====> 后台服务启动异常,请检查上方诊断信息\n'
return 1
fi
}
@@ -63,10 +101,12 @@ main() {
reset_env
copy_dep
check_ql
check_pm2
reload_pm2
local checkStatus=0
check_ql || checkStatus=1
check_pm2 || checkStatus=1
t '\n=====> 检测结束\n'
return $checkStatus
}
main
+6
View File
@@ -82,10 +82,16 @@ declare -gA LANG_MESSAGES=(
['---> 复制一份 %s 为 %s\n']='---> Copying %s to %s\n'
['---> 通知文件复制完成\n']='---> Notification files copied\n'
['---> pm2日志']='---> pm2 log'
['---> 服务诊断信息']='---> Service diagnostics'
['---> 最近的系统日志: %s']='---> Recent system log: %s'
['---> 未找到系统日志: %s']='---> No system log found: %s'
['---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>']='---> PM2 logs are not persisted in containers; run docker logs --tail 300 <container> for early startup errors'
['\n=====> 检测面板']='\n=====> Checking panel'
['=====> 面板服务启动正常\n']='=====> Panel service running normally\n'
['=====> 面板服务启动异常,请检查上方诊断信息\n']='=====> Panel failed to start; check the diagnostics above\n'
['\n=====> 检测后台']='\n=====> Checking backend'
['=====> 后台服务启动正常\n']='=====> Backend service running normally\n'
['=====> 后台服务启动异常,请检查上方诊断信息\n']='=====> Backend failed to start; check the diagnostics above\n'
['=====> 开始检测']='=====> Starting check'
['\n=====> 检测结束\n']='\n=====> Check complete\n'
# rmlog.sh
+6
View File
@@ -82,10 +82,16 @@ declare -gA LANG_MESSAGES=(
['---> 复制一份 %s 为 %s\n']='---> 复制一份 %s 为 %s\n'
['---> 通知文件复制完成\n']='---> 通知文件复制完成\n'
['---> pm2日志']='---> pm2日志'
['---> 服务诊断信息']='---> 服务诊断信息'
['---> 最近的系统日志: %s']='---> 最近的系统日志: %s'
['---> 未找到系统日志: %s']='---> 未找到系统日志: %s'
['---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>']='---> 容器内 PM2 日志不落盘;早期启动错误请执行 docker logs --tail 300 <容器名>'
['\n=====> 检测面板']='\n=====> 检测面板'
['=====> 面板服务启动正常\n']='=====> 面板服务启动正常\n'
['=====> 面板服务启动异常,请检查上方诊断信息\n']='=====> 面板服务启动异常,请检查上方诊断信息\n'
['\n=====> 检测后台']='\n=====> 检测后台'
['=====> 后台服务启动正常\n']='=====> 后台服务启动正常\n'
['=====> 后台服务启动异常,请检查上方诊断信息\n']='=====> 后台服务启动异常,请检查上方诊断信息\n'
['=====> 开始检测']='=====> 开始检测'
['\n=====> 检测结束\n']='\n=====> 检测结束\n'
# rmlog.sh
+5 -1
View File
@@ -98,7 +98,11 @@ function run() {
const newEnvObject = JSON.parse(envStr);
if (typeof newEnvObject === 'object' && newEnvObject !== null) {
for (const key in newEnvObject) {
if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) {
if (
Object.prototype.hasOwnProperty.call(newEnvObject, key) &&
key !== 'NODE_PATH' &&
key !== 'QL_NODE_GLOBAL_PATH'
) {
process.env[key] = newEnvObject[key];
}
}
+7 -2
View File
@@ -305,8 +305,13 @@ reload_pm2() {
# Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true
# Start node directly in the background
nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 &
# Start node directly in the background. In containers, keep application
# output on the container streams instead of duplicating Winston logs on disk.
if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else
nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 &
fi
local node_pid=$!
t '已使用 Node.js 直接启动服务 (PID: %s)' "$node_pid"
+65 -4
View File
@@ -211,6 +211,60 @@ run_extra_shell() {
fi
}
## 查看青龙服务日志
show_service_logs() {
local lines="15"
local no_stream="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--lines)
if [[ ! "${2:-}" =~ ^[0-9]+$ ]]; then
echo "ql log: --lines requires a non-negative integer" >&2
return 2
fi
lines="$2"
shift 2
;;
--lines=*)
lines="${1#--lines=}"
if [[ ! "$lines" =~ ^[0-9]+$ ]]; then
echo "ql log: --lines requires a non-negative integer" >&2
return 2
fi
shift
;;
--nostream)
no_stream="true"
shift
;;
*)
echo "ql log: unknown option: $1" >&2
return 2
;;
esac
done
local system_log_dir="$dir_data/syslog"
local latest_system_log
latest_system_log=$(ls -1t "$system_log_dir"/*.log* 2>/dev/null | head -n 1)
if [[ "$lines" != "0" ]] && [[ -f "$latest_system_log" ]]; then
tail -n "$lines" "$latest_system_log"
fi
if [[ "$no_stream" == "true" ]]; then
if [[ -z "$latest_system_log" ]]; then
echo "ql log: no system log found in $system_log_dir" >&2
return 1
fi
return 0
fi
# Winston writes the active runtime log by date. Follow the filename so
# size-based rotations are handled without involving the PM2 log files.
local current_system_log="$system_log_dir/$(date +%F).log"
tail -n 0 --retry -F "$current_system_log"
}
## 脚本用法
usage() {
t "$cmd_update 命令使用方法:"
@@ -221,10 +275,11 @@ usage() {
echo -e "5. $cmd_update rmlog <days> # 删除旧日志"
echo -e "6. $cmd_update bot # 启动tg-bot"
echo -e "7. $cmd_update check # 检测青龙环境并修复"
echo -e "8. $cmd_update resetlet # 重置登录错误次数"
echo -e "9. $cmd_update resettfa # 禁用两步登录"
echo -e "10. $cmd_update resetpwd # 修改登录密码"
echo -e "11. $cmd_update resetname # 修改登录用户名"
echo -e "8. $cmd_update log [--lines <n>] [--nostream] # 查看青龙运行日志"
echo -e "9. $cmd_update resetlet # 重置登录错误次数"
echo -e "10. $cmd_update resettfa # 禁用两步登录"
echo -e "11. $cmd_update resetpwd # 修改登录密码"
echo -e "12. $cmd_update resetname # 修改登录用户名"
}
reload_qinglong() {
@@ -471,6 +526,12 @@ main() {
local p8="${8}"
local p9="${9}"
local p10="${10}"
if [[ "$p1" == "log" ]]; then
show_service_logs "${@:2}"
return $?
fi
local log_dir="${p1}"
make_dir "$dir_log/$log_dir"
local log_time=$(date "+%Y-%m-%d-%H-%M-%S")
+54 -2
View File
@@ -2,7 +2,7 @@
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. Execute 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <container_name>' on the host machine",
"2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container",
"24小时未运行": "Idle 24h+",
"3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. If the problem persists, execute 'pm2 logs' inside the container and copy the results",
"3. 如果无法解决,容器内执行 ql log,拷贝执行结果": "3. If the problem persists, execute 'ql log' inside the container and copy the results",
"6位数字": "6-digit number",
"BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK push icon, custom push icon (requires iOS 15 or above to display)",
"BARK推送消息的分组,默认为qinglong": "BARK push message grouping, default is qinglong",
@@ -366,6 +366,23 @@
"添加标签成功": "Tags added successfully",
"清空日志": "Clear Logs",
"清除依赖缓存": "Clean dependency cache",
"历史数据保留与手动清理": "History retention and manual cleanup",
"保留天数为0时禁用对应清理,预览不会删除数据": "Set retention to 0 to disable that cleanup. Preview never deletes data.",
"任务统计": "Task statistics",
"清除 Node 依赖缓存": "Clean Node dependency cache",
"清除 Python 依赖缓存": "Clean Python dependency cache",
"清理后压缩数据库": "Compact database after cleanup",
"保存设置": "Save settings",
"预览清理": "Preview cleanup",
"确认清理存储数据": "Confirm storage cleanup",
"确认清理": "Clean now",
"将删除历史运行实例": "Historical running instances to delete",
"将删除任务统计": "Task statistics to delete",
"将清除依赖缓存": "Dependency caches to clear",
"清理依赖缓存后相关依赖需要重新安装": "Dependencies must be reinstalled after their cache is cleared.",
"数据库压缩期间可能暂时阻塞请求": "Database compaction may briefly block requests.",
"此操作不可恢复,请确认已完成必要备份": "This action cannot be undone. Confirm that required backups are complete.",
"清理完成": "Cleanup complete",
"清除成功": "Clean successful",
"源文件": "Source File",
"激活成功": "Activation successful",
@@ -596,6 +613,37 @@
"验证码": "Verification Code",
"验证码为6位数字": "Verification code is a 6-digit number",
"黑名单": "Blacklist",
"客户端 IP": "Client IP",
"可信代理配置说明": "Trusted proxy configuration",
"系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。": "QingLong checks the proxy chain from the nearest hop to the farthest and uses the first untrusted address as the client IP.",
"代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。": "Hop count is safe only when every request path has the same length. In production, prefer fixed proxy IPs or dedicated network CIDRs. Do not use true directly.",
"当前由环境变量 QL_TRUST_PROXY 管理": "Managed by QL_TRUST_PROXY",
"请修改容器环境变量并重启,系统设置不能覆盖环境变量。": "Change the container environment variable and restart. System settings cannot override it.",
"Trust Proxy 自定义设置": "Custom trust proxy setting",
"直接访问": "Direct access",
"一层代理": "One proxy",
"固定多层": "Fixed hops",
"指定地址或网段": "IP addresses or CIDRs",
"层": "hops",
"保存配置": "Save",
"当前生效值": "Effective value",
"默认配置": "Default",
"环境变量": "Environment variable",
"客户端 IP 诊断": "Client IP diagnostics",
"重新诊断": "Run again",
"Socket 地址": "Socket address",
"最终客户端 IP": "Final client IP",
"距离青龙": "Distance from QingLong",
"跳": "hop(s)",
"判定": "Decision",
"可信代理": "Trusted proxy",
"最终客户端": "Final client",
"未检查": "Not checked",
"请输入可信代理地址或网段": "Enter trusted proxy addresses or CIDRs",
"加入黑名单": "Block IP",
"移出黑名单": "Unblock IP",
"确认加入 IP 黑名单": "Block this IP address?",
"确认移出 IP 黑名单": "Unblock this IP address?",
"默认为 CPU 个数": "Default is the number of CPUs",
",保存后不可恢复": ", it can't be recovered after saving.",
",删除后不可恢复": ", it can't be recovered after deletion",
@@ -609,5 +657,9 @@
"已完成": "Completed",
"已停止": "Stopped",
"退出码": "Exit Code",
"结束": "End"
"结束": "End",
"失败次数": "Failure count",
"最新日志": "Latest log",
"加载失败": "Failed to load",
"重试": "Retry"
}
+54 -2
View File
@@ -2,7 +2,7 @@
"1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>",
"2. 容器内执行 ql check、ql update": "2. 容器内执行 ql check、ql update",
"24小时未运行": "24小时未运行",
"3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果",
"3. 如果无法解决,容器内执行 ql log,拷贝执行结果": "3. 如果无法解决,容器内执行 ql log,拷贝执行结果",
"6位数字": "6位数字",
"BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)",
"BARK推送消息的分组,默认为qinglong": "BARK推送消息的分组,默认为qinglong",
@@ -366,6 +366,23 @@
"添加标签成功": "添加标签成功",
"清空日志": "清空日志",
"清除依赖缓存": "清除依赖缓存",
"历史数据保留与手动清理": "历史数据保留与手动清理",
"保留天数为0时禁用对应清理,预览不会删除数据": "保留天数为0时禁用对应清理,预览不会删除数据",
"任务统计": "任务统计",
"清除 Node 依赖缓存": "清除 Node 依赖缓存",
"清除 Python 依赖缓存": "清除 Python 依赖缓存",
"清理后压缩数据库": "清理后压缩数据库",
"保存设置": "保存设置",
"预览清理": "预览清理",
"确认清理存储数据": "确认清理存储数据",
"确认清理": "确认清理",
"将删除历史运行实例": "将删除历史运行实例",
"将删除任务统计": "将删除任务统计",
"将清除依赖缓存": "将清除依赖缓存",
"清理依赖缓存后相关依赖需要重新安装": "清理依赖缓存后相关依赖需要重新安装",
"数据库压缩期间可能暂时阻塞请求": "数据库压缩期间可能暂时阻塞请求",
"此操作不可恢复,请确认已完成必要备份": "此操作不可恢复,请确认已完成必要备份",
"清理完成": "清理完成",
"清除成功": "清除成功",
"源文件": "源文件",
"激活成功": "激活成功",
@@ -596,6 +613,37 @@
"验证码": "验证码",
"验证码为6位数字": "验证码为6位数字",
"黑名单": "黑名单",
"客户端 IP": "客户端 IP",
"可信代理配置说明": "可信代理配置说明",
"系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。": "系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。",
"代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。": "代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。",
"当前由环境变量 QL_TRUST_PROXY 管理": "当前由环境变量 QL_TRUST_PROXY 管理",
"请修改容器环境变量并重启,系统设置不能覆盖环境变量。": "请修改容器环境变量并重启,系统设置不能覆盖环境变量。",
"Trust Proxy 自定义设置": "Trust Proxy 自定义设置",
"直接访问": "直接访问",
"一层代理": "一层代理",
"固定多层": "固定多层",
"指定地址或网段": "指定地址或网段",
"层": "层",
"保存配置": "保存配置",
"当前生效值": "当前生效值",
"默认配置": "默认配置",
"环境变量": "环境变量",
"客户端 IP 诊断": "客户端 IP 诊断",
"重新诊断": "重新诊断",
"Socket 地址": "Socket 地址",
"最终客户端 IP": "最终客户端 IP",
"距离青龙": "距离青龙",
"跳": "跳",
"判定": "判定",
"可信代理": "可信代理",
"最终客户端": "最终客户端",
"未检查": "未检查",
"请输入可信代理地址或网段": "请输入可信代理地址或网段",
"加入黑名单": "加入黑名单",
"移出黑名单": "移出黑名单",
"确认加入 IP 黑名单": "确认将该 IP 加入黑名单?",
"确认移出 IP 黑名单": "确认将该 IP 移出黑名单?",
"默认为 CPU 个数": "默认为 CPU 个数",
",保存后不可恢复": ",保存后不可恢复",
",删除后不可恢复": ",删除后不可恢复",
@@ -609,5 +657,9 @@
"已完成": "已完成",
"已停止": "已停止",
"退出码": "退出码",
"结束": "结束"
"结束": "结束",
"失败次数": "失败次数",
"最新日志": "最新日志",
"加载失败": "加载失败",
"重试": "重试"
}
+2
View File
@@ -130,6 +130,8 @@ const CronDetailModal = ({
{(item) => (
<List.Item
className="log-item"
onClick={() => item.log_path && viewInstanceLog(item)}
style={{ cursor: item.log_path ? 'pointer' : 'default' }}
actions={[
item.log_path && (
<Tooltip title={intl.get('查看日志')} key="log">
+48 -23
View File
@@ -16,11 +16,12 @@ import {
CheckCircleOutlined,
} from "@ant-design/icons";
import { PageLoading } from "@ant-design/pro-layout";
import { logEnded } from "@/utils";
import { CrontabStatus } from "./type";
import Ansi from "ansi-to-react";
const { Countdown } = Statistic;
const LOG_CHUNK_BYTES = 256 * 1024;
const MAX_LOG_VIEW_CHARS = 1024 * 1024;
const CronLogModal = ({
cron,
@@ -38,35 +39,49 @@ const CronLogModal = ({
const [executing, setExecuting] = useState<any>(true);
const [isPhone, setIsPhone] = useState(false);
const scrollInfoRef = useRef({ value: 0, down: true });
const logOffsetRef = useRef<number>();
const valueRef = useRef(value);
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>();
const uniqPath = logUrl ? logUrl : String(cron?.id);
const getCronLog = (isFirst?: boolean) => {
if (isFirst) {
setLoading(true);
}
const baseUrl = logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`;
const separator = baseUrl.includes("?") ? "&" : "?";
const offset = isFirst ? undefined : logOffsetRef.current;
const pagination = `${separator}limit=${LOG_CHUNK_BYTES}${
isFirst ? "&tail=true" : offset !== undefined ? `&offset=${offset}` : ""
}`;
request
.get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`)
.then(({ code, data, logStatus }) => {
if (
code === 200 &&
localStorage.getItem("logCron") === uniqPath &&
data !== value
) {
const log = (data as string) || intl.get("暂无日志");
setValue(log);
const hasNext = logStatus === 'running';
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
setTimeout(() => {
autoScroll();
});
}
setExecuting(hasNext);
if (hasNext) {
setTimeout(() => {
autoScroll();
getCronLog();
}, 2000);
}
.get(`${baseUrl}${pagination}`)
.then(({ code, data, logStatus, nextOffset }) => {
if (code !== 200 || localStorage.getItem("logCron") !== uniqPath) {
return;
}
const hasNext = logStatus === "running";
const chunk = (data as string) || "";
let log = isFirst ? chunk : `${valueRef.current}${chunk}`;
if (!log && !hasNext) {
log = intl.get("暂无日志");
}
if (log.length > MAX_LOG_VIEW_CHARS) {
log = log.slice(-MAX_LOG_VIEW_CHARS);
}
valueRef.current = log;
setValue(log);
if (typeof nextOffset === "number") {
logOffsetRef.current = nextOffset;
}
setExecuting(hasNext);
if (chunk || !hasNext) {
autoScroll();
}
if (hasNext) {
pollTimerRef.current = setTimeout(() => getCronLog(), 2000);
}
})
.finally(() => {
@@ -89,6 +104,9 @@ const CronLogModal = ({
};
const cancel = () => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
}
localStorage.removeItem("logCron");
handleCancel();
};
@@ -117,12 +135,19 @@ const CronLogModal = ({
useEffect(() => {
if (cron && cron.id) {
logOffsetRef.current = undefined;
getCronLog(true);
}
return () => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
}
};
}, [cron]);
useEffect(() => {
if (data) {
valueRef.current = data;
setValue(data);
}
}, [data]);
+1
View File
@@ -28,6 +28,7 @@ const CronModal = ({
const method = cron?.id ? 'put' : 'post';
const payload = {
...values,
labels: values.labels || [],
schedule:
scheduleType !== ScheduleType.Normal
? scheduleTypeMap[scheduleType]
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Modal, Table, Tag } from 'antd';
import intl from 'react-intl-universal';
import { request } from '@/utils/http';
import config from '@/utils/config';
import CronLogModal from '../crontab/logModal';
interface FailedTask {
id: number;
name: string;
command: string;
failCount: number;
deleted: boolean;
}
export default function FailureModal({ onCancel }: { onCancel: () => void }) {
const [tasks, setTasks] = useState<FailedTask[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [reload, setReload] = useState(0);
const [logCron, setLogCron] = useState<FailedTask | null>(null);
useEffect(() => {
let active = true;
setLoading(true);
setFailed(false);
request
.get(`${config.apiPrefix}dashboard/failures`)
.then((response) => {
if (!active) return;
if (response.code !== 200)
throw new Error('Failed to load dashboard failures');
setTasks(response.data);
})
.catch(() => {
if (active) setFailed(true);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [reload]);
return (
<>
<Modal
title={intl.get('今日失败')}
open
onCancel={onCancel}
footer={null}
width={900}
>
{failed ? (
<Alert
type="error"
showIcon
message={intl.get('加载失败')}
action={
<Button onClick={() => setReload((value) => value + 1)}>
{intl.get('重试')}
</Button>
}
/>
) : (
<Table<FailedTask>
loading={loading}
dataSource={tasks}
rowKey="id"
size="small"
scroll={{ x: 650 }}
pagination={{ pageSize: 10, showSizeChanger: false }}
columns={[
{
title: intl.get('定时任务'),
dataIndex: 'name',
ellipsis: true,
render: (name, task) => (
<>
{name} {task.deleted && <Tag>{intl.get('已删除')}</Tag>}
</>
),
},
{ title: intl.get('命令'), dataIndex: 'command', ellipsis: true },
{
title: intl.get('失败次数'),
dataIndex: 'failCount',
width: 100,
},
{
title: intl.get('日志'),
width: 120,
render: (_, task) => (
<Button
type="link"
size="small"
disabled={task.deleted}
onClick={() => {
localStorage.setItem('logCron', String(task.id));
setLogCron(task);
}}
>
{intl.get('最新日志')}
</Button>
),
},
]}
/>
)}
</Modal>
{logCron && (
<CronLogModal cron={logCron} handleCancel={() => setLogCron(null)} />
)}
</>
);
}
+13 -1
View File
@@ -16,6 +16,7 @@ import { SharedContext } from '@/layouts';
import { request } from '@/utils/http';
import config from '@/utils/config';
import CronLogModal from '../crontab/logModal';
import FailureModal from './failureModal';
interface Overview {
total: number;
@@ -93,6 +94,7 @@ const Dashboard = () => {
const [labels, setLabels] = useState<any[]>([]);
const [logCron, setLogCron] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [showFailures, setShowFailures] = useState(false);
const fetchData = useCallback(async () => {
try {
@@ -193,7 +195,16 @@ const Dashboard = () => {
<Card size="small"><Statistic title={intl.get('今日成功')} value={overview?.todaySuccess || 0} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('今日失败')} value={overview?.todayFail || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<CloseCircleOutlined />} /></Card>
<Card size="small" hoverable role="button" tabIndex={0}
aria-label={intl.get('今日失败')}
onClick={() => setShowFailures(true)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setShowFailures(true);
}
}}
><Statistic title={intl.get('今日失败')} value={overview?.todayFail || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<CloseCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('平均耗时')} value={overview?.avgTime ? `${(overview.avgTime / 1000).toFixed(1)}s` : '-'} prefix={<ClockCircleOutlined />} /></Card>
@@ -350,6 +361,7 @@ const Dashboard = () => {
</Card>
</Col>
</Row>
{showFailures && <FailureModal onCancel={() => setShowFailures(false)} />}
{logCron && (
<CronLogModal
cron={logCron}
+1 -1
View File
@@ -87,7 +87,7 @@ const Error = () => {
<div>{intl.get('2. 容器内执行 ql check、ql update')}</div>
<div>
{intl.get(
'3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果',
'3. 如果无法解决,容器内执行 ql log,拷贝执行结果',
)}
<Typography.Link href="https://github.com/whyour/qinglong/issues/new?assignees=&labels=&template=bug_report.yml">
{intl.get('提交 issue')}
+1 -1
View File
@@ -59,7 +59,7 @@ const Log = () => {
.get(
`${config.apiPrefix}logs/detail?file=${node.title}&path=${
node.parent || ''
}`,
}&tail=true&limit=${1024 * 1024}`,
)
.then(({ code, data }) => {
if (code === 200) {
+292
View File
@@ -0,0 +1,292 @@
import React, { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import {
Alert,
Button,
Descriptions,
Input,
InputNumber,
Radio,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import config from '@/utils/config';
import { request } from '@/utils/http';
const { Paragraph, Text } = Typography;
type TrustProxySource = 'default' | 'system' | 'environment';
type TrustProxyMode = 'direct' | 'single' | 'hops' | 'custom';
interface TrustProxyConfig {
trustProxy: string;
source: TrustProxySource;
editable: boolean;
}
interface ClientIpDiagnostic extends TrustProxyConfig {
remoteAddress: string;
forwardedFor: string[];
expressIps: string[];
clientIp: string;
hops: Array<{
ip: string;
hop: number;
status: 'trusted' | 'client' | 'not_checked';
}>;
}
function parseSetting(setting: string) {
if (setting === 'false' || setting === '0') {
return { mode: 'direct' as const, hops: 2, custom: '' };
}
if (setting === '1') {
return { mode: 'single' as const, hops: 2, custom: '' };
}
if (/^\d+$/.test(setting)) {
return { mode: 'hops' as const, hops: Number(setting), custom: '' };
}
return { mode: 'custom' as const, hops: 2, custom: setting };
}
const ClientIp = () => {
const [configInfo, setConfigInfo] = useState<TrustProxyConfig>();
const [diagnostic, setDiagnostic] = useState<ClientIpDiagnostic>();
const [mode, setMode] = useState<TrustProxyMode>('direct');
const [hops, setHops] = useState(2);
const [custom, setCustom] = useState('');
const [saving, setSaving] = useState(false);
const [diagnosing, setDiagnosing] = useState(false);
const applyConfig = (data: TrustProxyConfig) => {
setConfigInfo(data);
const parsed = parseSetting(data.trustProxy);
setMode(parsed.mode);
setHops(parsed.hops);
setCustom(parsed.custom);
};
const getConfig = async () => {
const response = await request.get(
`${config.apiPrefix}system/client-ip/config`,
);
if (response.code === 200) {
applyConfig(response.data);
}
};
const diagnose = async () => {
setDiagnosing(true);
try {
const response = await request.get(
`${config.apiPrefix}system/client-ip/diagnose`,
);
if (response.code === 200) {
setDiagnostic(response.data);
}
} finally {
setDiagnosing(false);
}
};
const getSetting = () => {
if (mode === 'direct') return 'false';
if (mode === 'single') return '1';
if (mode === 'hops') return String(hops);
return custom.trim();
};
const save = async () => {
const trustProxy = getSetting();
if (!trustProxy) {
message.error(intl.get('请输入可信代理地址或网段'));
return;
}
setSaving(true);
try {
const response = await request.put(
`${config.apiPrefix}system/client-ip/config`,
{ trustProxy },
);
if (response.code === 200) {
applyConfig(response.data);
message.success(intl.get('更新成功'));
await diagnose();
}
} finally {
setSaving(false);
}
};
useEffect(() => {
getConfig();
diagnose();
}, []);
const sourceMap: Record<TrustProxySource, string> = {
default: intl.get('默认配置'),
system: intl.get('系统设置'),
environment: intl.get('环境变量'),
};
const statusMap = {
trusted: { color: 'green', text: intl.get('可信代理') },
client: { color: 'blue', text: intl.get('最终客户端') },
not_checked: { color: 'default', text: intl.get('未检查') },
};
return (
<div style={{ maxWidth: 960, padding: '12px 0 32px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={intl.get('可信代理配置说明')}
description={
<div>
<Paragraph>
{intl.get(
'系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。',
)}
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
{intl.get(
'代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。',
)}
</Paragraph>
</div>
}
/>
{configInfo?.source === 'environment' && (
<Alert
type="warning"
showIcon
message={intl.get('当前由环境变量 QL_TRUST_PROXY 管理')}
description={intl.get(
'请修改容器环境变量并重启,系统设置不能覆盖环境变量。',
)}
/>
)}
<div>
<Paragraph strong>{intl.get('Trust Proxy 自定义设置')}</Paragraph>
<Radio.Group
value={mode}
onChange={(event) => setMode(event.target.value)}
disabled={!configInfo?.editable}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="direct">{intl.get('直接访问')}</Radio.Button>
<Radio.Button value="single">{intl.get('一层代理')}</Radio.Button>
<Radio.Button value="hops">{intl.get('固定多层')}</Radio.Button>
<Radio.Button value="custom">
{intl.get('指定地址或网段')}
</Radio.Button>
</Radio.Group>
<div style={{ marginTop: 12 }}>
{mode === 'hops' && (
<InputNumber
min={2}
max={20}
value={hops}
onChange={(value) => setHops(value || 2)}
addonAfter={intl.get('层')}
disabled={!configInfo?.editable}
/>
)}
{mode === 'custom' && (
<Input
style={{ maxWidth: 620 }}
value={custom}
onChange={(event) => setCustom(event.target.value)}
disabled={!configInfo?.editable}
placeholder="loopback,172.18.0.0/16,10.20.0.8/32"
/>
)}
</div>
<Space style={{ marginTop: 12 }} wrap>
<Button
type="primary"
onClick={save}
loading={saving}
disabled={!configInfo?.editable}
>
{intl.get('保存配置')}
</Button>
<Text type="secondary">
{intl.get('当前生效值')}:
<Text copyable>{configInfo?.trustProxy || '-'}</Text>
{configInfo && `(${sourceMap[configInfo.source]})`}
</Text>
</Space>
</div>
<div>
<Space style={{ marginBottom: 12 }}>
<Paragraph strong style={{ marginBottom: 0 }}>
{intl.get('客户端 IP 诊断')}
</Paragraph>
<Button onClick={diagnose} loading={diagnosing}>
{intl.get('重新诊断')}
</Button>
</Space>
{diagnostic && (
<>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label={intl.get('Socket 地址')}>
<Text copyable>{diagnostic.remoteAddress || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="X-Forwarded-For">
<Text copyable>
{diagnostic.forwardedFor.join(', ') || '-'}
</Text>
</Descriptions.Item>
<Descriptions.Item label={intl.get('最终客户端 IP')}>
<Text strong copyable>
{diagnostic.clientIp || '-'}
</Text>
</Descriptions.Item>
</Descriptions>
<Table
style={{ marginTop: 12 }}
size="small"
pagination={false}
rowKey={(record) => `${record.hop}-${record.ip}`}
dataSource={diagnostic.hops}
columns={[
{
title: intl.get('距离青龙'),
dataIndex: 'hop',
width: 120,
render: (value) => `${value} ${intl.get('跳')}`,
},
{ title: 'IP', dataIndex: 'ip' },
{
title: intl.get('判定'),
dataIndex: 'status',
width: 140,
render: (value: keyof typeof statusMap) => (
<Tag color={statusMap[value].color}>
{statusMap[value].text}
</Tag>
),
},
]}
/>
</>
)}
</div>
</Space>
</div>
);
};
export default ClientIp;
+6 -1
View File
@@ -35,6 +35,7 @@ import './index.less';
import useResizeObserver from '@react-hook/resize-observer';
import SystemLog from './systemLog';
import Dependence from './dependence';
import ClientIp from './clientIp';
const { Text } = Typography;
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
@@ -50,7 +51,6 @@ const Setting = () => {
reloadTheme,
systemInfo,
} = useOutletContext<SharedContext>();
console.log('user',user)
const columns = [
{
title: intl.get('名称'),
@@ -345,6 +345,11 @@ const Setting = () => {
label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />,
},
{
key: 'client-ip',
label: intl.get('客户端 IP'),
children: <ClientIp />,
},
{
key: 'dependence',
label: intl.get('依赖设置'),
+93 -54
View File
@@ -1,12 +1,10 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Typography, Table, Tag, Button, Spin, message } from 'antd';
import { Table, Tag, Button, Popconfirm, message } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import dayjs from 'dayjs';
const { Text, Link } = Typography;
enum LoginStatus {
'成功',
'失败',
@@ -17,70 +15,111 @@ enum LoginStatusColor {
'error',
}
const columns = [
{
title: intl.get('序号'),
width: 50,
render: (text: string, record: any, index: number) => {
return index + 1;
const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
const [blockedIps, setBlockedIps] = useState<string[]>([]);
const getIpBlacklist = () => {
request
.get(`${config.apiPrefix}user/ip-blacklist`)
.then(({ code, data }) => {
if (code === 200) {
setBlockedIps(data || []);
}
});
};
useEffect(() => {
getIpBlacklist();
}, []);
const updateIpBlacklist = async (ip: string, blocked: boolean) => {
const response = blocked
? await request.delete(`${config.apiPrefix}user/ip-blacklist`, {
data: { ip },
})
: await request.put(`${config.apiPrefix}user/ip-blacklist`, { ip });
if (response.code === 200) {
setBlockedIps(response.data || []);
message.success(response.message);
}
};
const columns = [
{
title: intl.get('序号'),
width: 50,
render: (text: string, record: any, index: number) => index + 1,
},
},
{
title: intl.get('登录时间'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 120,
render: (text: string, record: any) => {
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
{
title: intl.get('登录时间'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 120,
render: (text: string, record: any) =>
dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'),
},
},
{
title: intl.get('登录地址'),
dataIndex: 'address',
width: 120,
key: 'address',
},
{
title: intl.get('登录IP'),
dataIndex: 'ip',
width: 100,
key: 'ip',
},
{
title: intl.get('登录设备'),
dataIndex: 'platform',
key: 'platform',
width: 80,
},
{
title: intl.get('登录状态'),
dataIndex: 'status',
key: 'status',
width: 80,
render: (text: string, record: any) => {
return (
{
title: intl.get('登录地址'),
dataIndex: 'address',
width: 120,
key: 'address',
},
{
title: intl.get('登录IP'),
dataIndex: 'ip',
width: 100,
key: 'ip',
},
{
title: intl.get('登录设备'),
dataIndex: 'platform',
key: 'platform',
width: 80,
},
{
title: intl.get('登录状态'),
dataIndex: 'status',
key: 'status',
width: 80,
render: (text: string, record: any) => (
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(LoginStatus[record.status])}
</Tag>
);
),
},
},
];
{
title: intl.get('操作'),
key: 'action',
width: 100,
render: (text: string, record: any) => {
if (!record.ip || record.status !== 1) {
return null;
}
const blocked = blockedIps.includes(record.ip);
const label = blocked ? '移出黑名单' : '加入黑名单';
return (
<Popconfirm
title={intl.get(
blocked ? '确认移出 IP 黑名单' : '确认加入 IP 黑名单',
)}
onConfirm={() => updateIpBlacklist(record.ip, blocked)}
>
<Button type="link" danger={!blocked} size="small">
{intl.get(label)}
</Button>
</Popconfirm>
);
},
},
];
const LoginLog = ({
data,
height,
}: {
data: Array<object>;
height: number;
}) => {
return (
<>
<Table
columns={columns}
pagination={false}
dataSource={data}
rowKey="id"
rowKey={(record) => `${record.ip}-${record.timestamp}`}
size="middle"
scroll={{ x: 1000, y: height }}
/>
+157
View File
@@ -62,6 +62,8 @@ const Other = ({
cronConcurrency?: number | null;
timezone?: string | null;
globalSshKey?: string | null;
runningInstanceRetentionDays?: number | null;
cronStatRetentionDays?: number | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
@@ -69,6 +71,11 @@ const Other = ({
const showDownloadProgress = useProgress(intl.get('下载'));
const [visible, setVisible] = useState(false);
const [selectedModules, setSelectedModules] = useState<string[]>(['base']);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [dependenceCacheTypes, setDependenceCacheTypes] = useState<string[]>(
[],
);
const [compactDatabase, setCompactDatabase] = useState(false);
const {
enable: enableDarkMode,
@@ -136,6 +143,93 @@ const Other = ({
});
};
const retentionPayload = () => ({
runningInstanceRetentionDays:
systemConfig?.runningInstanceRetentionDays || 0,
cronStatRetentionDays: systemConfig?.cronStatRetentionDays || 0,
});
const saveRetentionPolicy = () => {
request
.put(
`${config.apiPrefix}system/storage-retention/config`,
retentionPayload(),
)
.then(({ code }) => {
if (code === 200) {
message.success(intl.get('更新成功'));
}
});
};
const formatBytes = (bytes: number) => {
if (!bytes) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
const unit = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${units[unit]}`;
};
const previewStorageCleanup = () => {
setCleanupLoading(true);
const payload = {
...retentionPayload(),
dependenceCacheTypes,
compactDatabase,
};
request
.post(`${config.apiPrefix}system/storage-retention/preview`, payload)
.then(({ code, data }) => {
if (code !== 200) return;
Modal.confirm({
width: 560,
centered: true,
title: intl.get('确认清理存储数据'),
okText: intl.get('确认清理'),
cancelText: intl.get('取消'),
okButtonProps: { danger: true },
content: (
<div>
<p>
{intl.get('将删除历史运行实例')}: {data.runningInstances}
</p>
<p>
{intl.get('将删除任务统计')}: {data.cronStats}
</p>
<p>
{intl.get('将清除依赖缓存')}: {data.dependenceCaches.length} (
{formatBytes(data.dependenceCacheBytes)})
</p>
{data.dependenceCaches.length > 0 && (
<p>{intl.get('清理依赖缓存后相关依赖需要重新安装')}</p>
)}
{compactDatabase && (
<p>{intl.get('数据库压缩期间可能暂时阻塞请求')}</p>
)}
<p>{intl.get('此操作不可恢复,请确认已完成必要备份')}</p>
</div>
),
onOk: () => {
setCleanupLoading(true);
return request
.post(`${config.apiPrefix}system/storage-retention/cleanup`, {
...payload,
confirmation: 'CLEAN',
})
.then(({ code }) => {
if (code === 200) {
message.success(intl.get('清理完成'));
}
})
.finally(() => setCleanupLoading(false));
},
});
})
.finally(() => setCleanupLoading(false));
};
const exportData = () => {
setExportLoading(true);
request
@@ -302,6 +396,69 @@ const Other = ({
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('历史数据保留与手动清理')}
tooltip={intl.get('保留天数为0时禁用对应清理,预览不会删除数据')}
>
<div style={{ marginBottom: 8 }}>
<InputNumber
addonBefore={intl.get('运行实例')}
addonAfter={intl.get('天')}
min={0}
max={3650}
value={systemConfig?.runningInstanceRetentionDays || 0}
onChange={(value) => {
setSystemConfig({
...systemConfig,
runningInstanceRetentionDays: value,
});
}}
/>
</div>
<div style={{ marginBottom: 8 }}>
<InputNumber
addonBefore={intl.get('任务统计')}
addonAfter={intl.get('天')}
min={0}
max={3650}
value={systemConfig?.cronStatRetentionDays || 0}
onChange={(value) => {
setSystemConfig({
...systemConfig,
cronStatRetentionDays: value,
});
}}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Checkbox.Group
value={dependenceCacheTypes}
options={[
{ label: intl.get('清除 Node 依赖缓存'), value: 'node' },
{ label: intl.get('清除 Python 依赖缓存'), value: 'python3' },
]}
onChange={(value) => setDependenceCacheTypes(value as string[])}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Checkbox
checked={compactDatabase}
onChange={(event) => setCompactDatabase(event.target.checked)}
>
{intl.get('清理后压缩数据库')}
</Checkbox>
</div>
<Button onClick={saveRetentionPolicy} style={{ marginRight: 8 }}>
{intl.get('保存设置')}
</Button>
<Button
danger
loading={cleanupLoading}
onClick={previewStorageCleanup}
>
{intl.get('预览清理')}
</Button>
</Form.Item>
<Form.Item label={intl.get('定时任务并发数')} name="frequency">
<Input.Group compact>
<InputNumber
+3 -1
View File
@@ -29,7 +29,9 @@ const SystemLog = ({ height, theme }: any) => {
const { loading, refresh } = useRequest(
() => {
return request.get<Blob>(
`${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${range[1]}`,
`${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${
range[1]
}&limit=${1024 * 1024}`,
{
responseType: 'blob',
},
+5
View File
@@ -48,6 +48,10 @@ export default {
name: intl.get('系统信息'),
value: 'system',
},
{
name: intl.get('仪表盘'),
value: 'dashboard',
},
],
scopesMap: {
crons: intl.get('定时任务'),
@@ -58,6 +62,7 @@ export default {
logs: intl.get('日志管理'),
dependencies: intl.get('依赖管理'),
system: intl.get('系统信息'),
dashboard: intl.get('仪表盘'),
},
notificationModes: [
{ value: 'gotify', label: 'Gotify' },
+6 -6
View File
@@ -9,8 +9,9 @@ import axios, {
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { getErrorDetails, ValidationErrorResponse } from './httpError';
export interface IResponseData {
export interface IResponseData extends ValidationErrorResponse {
code?: number;
data?: any;
message?: string;
@@ -43,6 +44,7 @@ const errorHandler = function (
const msg = error.response.data
? error.response.data.message || error.message
: error.response.statusText;
const errorDetails = getErrorDetails(error.response.data);
const responseStatus = error.response.status;
if ([502, 504].includes(responseStatus)) {
history.push('/error');
@@ -60,12 +62,10 @@ const errorHandler = function (
msg &&
notification.error({
message: msg,
description: error.response?.data?.errors ? (
description: errorDetails.length ? (
<>
{error.response?.data?.errors?.map((item: any) => (
<div>
{item.message} ({item.value})
</div>
{errorDetails.map((detail, index) => (
<div key={`${index}-${detail}`}>{detail}</div>
))}
</>
) : undefined,
+30
View File
@@ -0,0 +1,30 @@
export interface ValidationErrorResponse {
errors?: Array<{ message?: string; value?: unknown }>;
validation?: Record<
string,
{ source?: string; keys?: string[]; message?: string }
>;
}
export const getErrorDetails = (data?: ValidationErrorResponse) => {
const details =
data?.errors?.map((item) =>
item.value === undefined
? item.message
: `${item.message} (${String(item.value)})`,
) || [];
Object.values(data?.validation || {}).forEach((validation) => {
if (validation.keys?.length) {
validation.keys.forEach((key) => {
details.push(
validation.message ? `${key}: ${validation.message}` : key,
);
});
} else if (validation.message) {
details.push(validation.message);
}
});
return details.filter((detail): detail is string => Boolean(detail));
};
+6
View File
@@ -10,6 +10,12 @@ interface FileTypeConfig {
// 文件类型分类配置(只包含特殊文件类型)
const fileTypeConfigs: Record<string, FileTypeConfig> = {
config: {
// Keep common text configuration files previewable even when a Monaco
// build has not registered its optional INI language contribution yet.
extensions: ['.ini'],
},
// 前端特殊文件
frontend: {
extensions: [
+74
View File
@@ -0,0 +1,74 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const jwt = require('jsonwebtoken');
const load = require('../helpers/load-security-module.cjs');
const { isValidToken } = load(
path.join(__dirname, '../../back/shared/auth.ts'),
);
const { hashPassword, verifyPassword } = load(
path.join(__dirname, '../../back/shared/password.ts'),
);
const secret = 'security-test-secret';
const token = (payload = {}, options = {}) =>
jwt.sign(payload, secret, {
algorithm: 'HS384',
expiresIn: '1h',
...options,
});
const info = (value) => ({ token: value, tokens: {} });
test('valid legacy and platform sessions retain compatibility', () => {
const value = token();
for (const auth of [
info(value),
{ tokens: { desktop: value } },
{ tokens: { desktop: [{ value }] } },
]) {
assert.equal(isValidToken(auth, value, 'desktop', secret), true);
}
assert.equal(
isValidToken({ tokens: { mobile: value } }, value, 'desktop', secret),
false,
);
});
test('session membership never bypasses JWT signature, expiry, algorithm or nbf', () => {
const values = [
token({}, { expiresIn: -1 }),
token({}, { algorithm: 'HS256' }),
token({ nbf: Math.floor(Date.now() / 1000) + 60 }),
jwt.sign({}, 'wrong-secret', { algorithm: 'HS384', expiresIn: '1h' }),
jwt.sign({}, secret, { algorithm: 'HS384' }),
'not-a-token',
];
for (const value of values)
assert.equal(isValidToken(info(value), value, 'desktop', secret), false);
assert.equal(isValidToken({ tokens: {} }, token(), 'desktop', secret), false);
assert.equal(isValidToken(null, token(), 'desktop', secret), false);
assert.equal(isValidToken(info(''), '', 'desktop', secret), false);
});
test('platform token metadata can shorten but cannot extend JWT expiration', () => {
const value = token();
assert.equal(
isValidToken(
{ tokens: { desktop: [{ value, expiration: 1 }] } },
value,
'desktop',
secret,
),
false,
);
});
test('passwords are salted and legacy plaintext can migrate without changing the password', async () => {
const first = await hashPassword('owner-password');
const second = await hashPassword('owner-password');
assert.notEqual(first, second);
assert.equal(await verifyPassword('owner-password', first), true);
assert.equal(await verifyPassword('wrong-password', first), false);
assert.equal(await verifyPassword(first, first), false);
assert.equal(await verifyPassword('old-password', 'old-password'), true);
assert.equal(await verifyPassword('wrong', 'old-password'), false);
});
+45
View File
@@ -0,0 +1,45 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const configPath = require.resolve('../../back/config');
require.cache[configPath] = {
id: configPath,
filename: configPath,
loaded: true,
exports: { __esModule: true, default: { logPath: '/ql/data/log' } },
children: [],
paths: [],
};
const { commonCronSchema } = require('../../back/validation/schedule');
const { Joi } = require('celebrate');
const schema = Joi.object(commonCronSchema);
test('cron validation accepts leading-zero schedules and legacy null labels', () => {
const result = schema.validate({
name: 'legacy cron',
command: 'task legacy.js',
schedule: '01 7 * * *',
labels: null,
});
assert.equal(result.error, undefined);
});
test('cron validation identifies invalid fields', () => {
const result = schema.validate(
{
name: 'invalid cron',
command: 'task invalid.js',
schedule: '01 7 * * *',
allow_multiple_instances: '',
},
{ abortEarly: false },
);
assert.deepEqual(
[...new Set(result.error.details.map((detail) => detail.path.join('.')))],
['allow_multiple_instances'],
);
});
+126
View File
@@ -0,0 +1,126 @@
require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { Sequelize, DataTypes } = require('sequelize');
const dayjs = require('dayjs');
const express = require('express');
test('today failures includes recovered and deleted tasks, excluding previous days and successes', async (t) => {
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => db.close());
const crons = db.define('Cron', {
name: DataTypes.STRING,
command: DataTypes.STRING,
});
const stats = db.define('Stat', {
ref_id: DataTypes.INTEGER,
date: DataTypes.STRING,
fail_count: DataTypes.INTEGER,
success_count: DataTypes.INTEGER,
});
const replacements = {
'../../back/data/cron': { CrontabModel: crons },
'../../back/data/cronStats': { CrontabStatModel: stats },
'../../back/data/runningInstance': {},
'../../back/shared/i18n': { tf: (format, id) => format.replace('%s', id) },
};
for (const [modulePath, exports] of Object.entries(replacements)) {
const resolved = require.resolve(modulePath);
const original = require.cache[resolved];
require.cache[resolved] = {
id: resolved,
filename: resolved,
loaded: true,
exports,
};
t.after(() => {
if (original) require.cache[resolved] = original;
else delete require.cache[resolved];
});
}
await db.sync();
await crons.bulkCreate([
{ id: 1, name: 'Recovered task', command: 'task recovered.js' },
{ id: 2, name: '', command: 'task unnamed.js' },
{ id: 3, name: 'Successful task', command: 'task success.js' },
]);
const today = dayjs().format('YYYY-MM-DD');
await stats.bulkCreate([
{ ref_id: 1, date: today, fail_count: 2, success_count: 1 },
{ ref_id: 2, date: today, fail_count: 1, success_count: 0 },
{ ref_id: 3, date: today, fail_count: 0, success_count: 4 },
{ ref_id: 4, date: today, fail_count: 3, success_count: 0 },
{
ref_id: 3,
date: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
fail_count: 10,
},
]);
const router = express.Router();
require('../../back/api/dashboard').default(router);
const dashboard = router.stack.find(
(layer) => layer.name === 'router',
).handle;
const handler = dashboard.stack.find(
(layer) => layer.route?.path === '/failures',
).route.stack[0].handle;
let response;
await handler(
{},
{
send: (value) => {
response = value;
},
},
(error) => {
throw error;
},
);
assert.deepEqual(response, {
code: 200,
data: [
{ id: 4, name: '任务#4', command: '', failCount: 3, deleted: true },
{
id: 1,
name: 'Recovered task',
command: 'task recovered.js',
failCount: 2,
deleted: false,
},
{
id: 2,
name: 'task unnamed.js',
command: 'task unnamed.js',
failCount: 1,
deleted: false,
},
],
});
await stats.destroy({ where: {} });
await handler(
{},
{
send: (value) => {
response = value;
},
},
(error) => {
throw error;
},
);
assert.deepEqual(response, { code: 200, data: [] });
await stats.drop();
let forwarded;
await handler(
{},
{ send: () => assert.fail('must forward database errors') },
(error) => {
forwarded = error;
},
);
assert.ok(forwarded instanceof Error);
});
+125
View File
@@ -0,0 +1,125 @@
const assert = require('node:assert/strict');
const test = require('node:test');
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
moduleStubs.set(resolved, require.cache[resolved]);
require.cache[resolved] = {
id: resolved,
filename: resolved,
loaded: true,
exports,
children: [],
paths: [],
};
};
const DependenceStatus = {
installing: 0,
installed: 1,
installFailed: 2,
};
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
const docs = [
{ id: 1, name: 'missing-package', type: 0, status: 1 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
];
const updates = [];
const DependenceModel = {
findAll: async ({ where }) => {
if (where.status === DependenceStatus.installed) {
return docs;
}
return docs.map((doc) => ({
...doc,
status: updates.some((update) => update.ids.includes(doc.id))
? DependenceStatus.installFailed
: doc.status,
}));
},
update: async ({ status }, { where }) => {
updates.push({ status, ids: where.id });
},
};
stubModule('../../back/config', {
__esModule: true,
default: {},
});
stubModule('../../back/data/dependence', {
Dependence: class Dependence {},
DependenceModel,
DependenceStatus,
DependenceTypes,
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
});
stubModule('../../back/config/util', {
concurrentRun: async (tasks) => Promise.all(tasks.map((task) => task())),
detectOS: async () => 'Alpine',
fileExist: async () => false,
getGetCommand: (_type, name) => `check:${name}`,
getInstallCommand: () => '',
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
promiseExecSuccess: async (command) =>
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
});
stubModule('../../back/config/const', {
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
});
stubModule('../../back/shared/pLimit', {
__esModule: true,
default: {},
});
stubModule('../../back/shared/i18n', {
t: (message) => message,
tf: (message) => message,
});
stubModule('../../back/services/sock', {
__esModule: true,
default: class SockService {},
});
const servicePath = require.resolve('../../back/services/dependence');
const originalService = require.cache[servicePath];
delete require.cache[servicePath];
t.after(() => {
if (originalService) {
require.cache[servicePath] = originalService;
} else {
delete require.cache[servicePath];
}
for (const [resolved, original] of moduleStubs) {
if (original) {
require.cache[resolved] = original;
} else {
delete require.cache[resolved];
}
}
});
const DependenceService = require('../../back/services/dependence').default;
const service = new DependenceService({}, {});
const result = await service.dependencies({
searchValue: '',
type: 'nodejs',
status: '',
});
assert.deepEqual(updates, [
{ status: DependenceStatus.installFailed, ids: [1] },
]);
assert.deepEqual(
result.map(({ id, status }) => ({ id, status })),
[
{ id: 1, status: DependenceStatus.installFailed },
{ id: 2, status: DependenceStatus.installed },
],
);
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
assert.equal(updates.length, 1);
});
+82
View File
@@ -0,0 +1,82 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const express = require('express');
function mockModule(modulePath, exports) {
const filename = require.resolve(modulePath);
require.cache[filename] = {
id: filename,
filename,
loaded: true,
exports,
children: [],
paths: [],
};
}
mockModule('../../back/config', {
__esModule: true,
default: {
bakPath: '/tmp',
blackFileList: [],
configPath: '/tmp',
logPath: '/tmp',
logs: { level: 'info' },
rootPath: '/tmp',
scriptPath: '/tmp',
systemLogPath: '/tmp',
writePathList: ['/tmp'],
},
});
mockModule('../../back/shared/i18n', {
t: (message) => message,
});
mockModule('../../back/config/util', {
fileExist: async () => false,
readDir: async () => [],
readDirs: async () => [],
removeAnsi: (content) => content,
rmPath: async () => {},
});
mockModule('../../back/shared/utils', {
writeFileWithLock: async () => {},
});
for (const service of ['config', 'script', 'log']) {
mockModule(`../../back/services/${service}`, {
__esModule: true,
default: class {},
});
}
mockModule('../../back/data/runningInstance', {
InstanceStatus: { running: 'running' },
RunningInstanceModel: { findOne: async () => null },
});
const deprecatedRoutes = [
['config', '/configs/detail'],
['script', '/scripts/detail'],
['log', '/logs/detail'],
];
for (const [moduleName, replacement] of deprecatedRoutes) {
test(`${moduleName} filename route points callers to its detail API`, () => {
const app = express.Router();
require(`../../back/api/${moduleName}`).default(app);
const router = app.stack.find((layer) => layer.name === 'router').handle;
const deprecatedRoute = router.stack.find(
(layer) => layer.route?.path === '/:file',
);
const handler = deprecatedRoute.route.stack.at(-1).handle;
let responseBody;
handler(
{ params: { file: 'Example.js' }, query: {} },
{ send: (body) => (responseBody = body) },
);
assert.deepEqual(responseBody, {
code: 410,
message: `接口已下线,请使用 ${replacement} 接口`,
});
});
}
+28
View File
@@ -0,0 +1,28 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const configPath = require.resolve('../../ecosystem.config');
function loadConfig(containerValue) {
if (containerValue === undefined) {
delete process.env.QL_CONTAINER;
} else {
process.env.QL_CONTAINER = containerValue;
}
delete require.cache[configPath];
return require(configPath).apps[0];
}
test('container logging discards stdout and forwards startup errors without persistent copies', () => {
const app = loadConfig('true');
assert.equal(app.out_file, '/dev/null');
assert.equal(app.error_file, '/proc/1/fd/2');
assert.equal(app.time, false);
});
test('non-container installs keep the PM2 logging defaults', () => {
const app = loadConfig(undefined);
assert.equal(app.out_file, undefined);
assert.equal(app.error_file, undefined);
assert.equal(app.time, true);
});
+47
View File
@@ -0,0 +1,47 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
const { resolveFileAccess } = load(
path.join(__dirname, '../../back/shared/fileAccess.ts'),
);
test('file access enforces directory boundaries, blacklist descendants and symlinks', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-file-security-'));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const root = path.join(tmp, 'config');
fs.mkdirSync(path.join(root, 'grpc'), { recursive: true });
fs.mkdirSync(path.join(tmp, 'config-other'));
fs.writeFileSync(path.join(root, 'normal.txt'), 'normal');
fs.writeFileSync(path.join(root, 'grpc', 'client.key'), 'test-only');
fs.symlinkSync(path.join(tmp, 'config-other'), path.join(root, 'outside'));
fs.symlinkSync(path.join(root, 'grpc'), path.join(root, 'alias'));
fs.symlinkSync(path.join(root, 'normal.txt'), path.join(root, 'safe-link'));
fs.symlinkSync(path.join(tmp, 'missing'), path.join(root, 'dangling'));
for (const input of [
'../config-other/secret',
'/etc/passwd',
'grpc/client.key',
'alias/client.key',
'outside/new.txt',
'dangling',
'auth.json',
'nested/auth.json',
'',
]) {
assert.equal(
resolveFileAccess(root, [input], ['grpc', 'auth.json']),
'',
input,
);
}
for (const input of ['normal.txt', 'safe-link', 'new-dir/new.txt']) {
assert.equal(
resolveFileAccess(root, [input], ['grpc']),
path.join(root, input),
input,
);
}
});
+57
View File
@@ -0,0 +1,57 @@
require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { afterEach, test } = require('node:test');
const { readDirs } = require('../../back/config/util');
const temporaryDirectories = [];
async function temporaryRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-file-tree-'));
temporaryDirectories.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => fs.rm(directory, { recursive: true, force: true })),
);
});
test('readDirs preserves the recursive tree contract and ignores blocked entries', async () => {
const root = await temporaryRoot();
await fs.mkdir(path.join(root, 'folder'));
await fs.mkdir(path.join(root, 'node_modules'));
await fs.writeFile(path.join(root, 'z.js'), 'z');
await fs.writeFile(path.join(root, 'folder', 'a.js'), 'alpha');
await fs.writeFile(path.join(root, 'node_modules', 'hidden.js'), 'hidden');
await fs.symlink(path.join(root, 'folder'), path.join(root, 'linked-folder'));
const result = await readDirs(root, root, ['node_modules'], (a, b) => {
if (a.type === b.type) return a.title.localeCompare(b.title);
return a.type === 'directory' ? -1 : 1;
});
assert.deepEqual(
result.map(({ title, type, key, parent }) => ({
title,
type,
key,
parent,
})),
[
{ title: 'folder', type: 'directory', key: 'folder', parent: '' },
{ title: 'z.js', type: 'file', key: 'z.js', parent: '' },
],
);
assert.equal(result[0].children.length, 1);
assert.equal(result[0].children[0].title, 'a.js');
assert.equal(result[0].children[0].key, path.join('folder', 'a.js'));
assert.equal(result[0].children[0].parent, 'folder');
assert.equal(result[0].children[0].size, 5);
});
+213
View File
@@ -0,0 +1,213 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const express = require('express');
const jwt = require('jsonwebtoken');
const load = require('../helpers/load-security-module.cjs');
test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-'));
for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp'])
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
const secret = 'http-security-test';
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
const auth = {
username: 'owner',
password: 'configured',
token: valid,
tokens: { desktop: [{ value: expired }] },
};
const configSource = fs.readFileSync(
path.join(__dirname, '../../back/config/index.ts'),
'utf8',
);
const whitelistSource = configSource
.slice(configSource.indexOf('apiWhiteList:'))
.match(/apiWhiteList:\s*\[([\s\S]*?)\]/)[1];
const config = {
api: { prefix: '/api' },
apiWhiteList: [...whitelistSource.matchAll(/['"]([^'"]+)['"]/g)].map(
(x) => x[1],
),
jwt: { secret },
rootPath: tmp,
configPath: path.join(tmp, 'config/'),
scriptPath: path.join(tmp, 'scripts/'),
uploadPath: path.join(tmp, 'upload'),
tmpPath: path.join(tmp, 'tmp'),
blackFileList: ['auth.json', 'grpc'],
baseUrl: '/panel',
};
const apps = [
{
scopes: ['configs'],
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs'],
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
},
];
let initialized = 0;
const User = class {};
const user = {
initializeUser: async () => {
initialized++;
return { code: 200 };
},
getAuthInfo: async () => auth,
};
const mocks = {
'../config': config,
'../config/util': {
getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''),
getPlatform: () => 'desktop',
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
},
'../shared/i18n': { t: (x) => x },
'../shared/store': {
shareStore: { getAuthInfo: async () => auth, getApps: async () => apps },
},
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../services/user': User,
'../data/open': {},
'../data/system': {},
'../shared/utils': {
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
},
};
const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
...mocks,
typedi: { Service: () => (x) => x },
}).default;
const configService = new Config();
mocks['../services/config'] = Config;
mocks.typedi = {
Container: { get: (x) => (x === User ? user : configService) },
};
mocks['../api'] = () => {
const router = express.Router();
load(path.join(__dirname, '../../back/api/user.ts'), mocks).default(router);
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
router,
);
router.get('/envs', (_req, res) => res.json({ code: 200 }));
return router;
};
const app = express();
load(path.join(__dirname, '../../back/loaders/express.ts'), mocks).default({
app,
});
const server = app.listen(0, '127.0.0.1');
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmp, { recursive: true, force: true });
});
await new Promise((resolve, reject) => {
server.on('listening', resolve);
server.on('error', reject);
});
const request = async (url, token, method = 'GET', body) => {
const response = await fetch(
`http://127.0.0.1:${server.address().port}${url}`,
{
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'Content-Type': 'application/json',
},
...(body ? { body: JSON.stringify(body) } : {}),
},
);
return { status: response.status, body: await response.json() };
};
for (const url of [
'/api/user/init',
'/open/user/init',
'/panel/api/user/init',
'/panel/open/user/init',
]) {
const response = await request(url, undefined, 'PUT', {
username: 'attacker',
password: 'changed',
});
assert.equal(response.body.code, 450, url);
}
assert.equal(initialized, 0);
assert.equal(
(
await request('/api/user/login', undefined, 'POST', {
username: 'x',
password: 'x'.repeat(20000),
})
).status,
413,
);
fs.writeFileSync(
path.join(tmp, 'upload/legacy.html'),
'<script>window.test=1</script>',
);
const legacyUpload = await fetch(
`http://127.0.0.1:${server.address().port}/api/static/legacy.html`,
);
assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff');
assert.equal(
legacyUpload.headers.get('content-security-policy'),
"sandbox; default-src 'none'",
);
for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) {
assert.equal(
(await request(`${prefix}/envs`, expired)).status,
401,
prefix,
);
assert.equal(
(await request(`${prefix}/envs`, valid)).body.code,
200,
prefix,
);
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
}
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
401,
);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'config-app')).body
.data,
'normal',
);
assert.equal(
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
.body.code,
403,
);
assert.equal(
(
await request('/open/configs/save', 'config-app', 'POST', {
name: 'grpc/client.key',
content: 'changed',
})
).body.code,
403,
);
assert.equal(
fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'),
'SENTINEL',
);
assert.equal(
(
await request('/Api/user/init', undefined, 'PUT', {
username: 'x',
password: 'y',
})
).status,
400,
);
});
+67
View File
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
const logger = { debug() {}, warn() {}, error() {} };
for (const host of ['127.0.0.1', '::1', '192.0.2.1', '::']) {
test(`HTTP binding ${host} never broadens an explicit private address`, async () => {
const Http = load(path.join(__dirname, '../../back/services/http.ts'), {
'../config': { bindHost: host },
'../loaders/logger': logger,
'./metrics': { metricsService: { record() {} } },
typedi: { Service: () => (x) => x },
}).HttpServerService;
const instance = new Http();
const attempted = [];
instance.tryListen = async (_app, _port, address) => {
attempted.push(address);
throw new Error('unavailable');
};
await assert.rejects(instance.initialize({}, 5700));
assert.deepEqual(attempted, host === '::' ? ['::', '0.0.0.0'] : [host]);
});
test(`gRPC binding ${host} never broadens an explicit private address`, async () => {
const attempted = [];
const Grpc = load(path.join(__dirname, '../../back/services/grpc.ts'), {
'../config': { bindHostGrpc: host, grpcPort: 5500 },
'../loaders/logger': logger,
'./metrics': { metricsService: { record() {} } },
typedi: { Service: () => (x) => x },
'@grpc/grpc-js': {
Server: class {
addService() {}
bindAsync(address, _credentials, cb) {
attempted.push(address);
cb(new Error('unavailable'));
}
},
ServerCredentials: {
createSsl(_ca, _certs, requireClientCert) {
assert.equal(requireClientCert, true);
return {};
},
},
},
'../protos/cron': { CronService: {} },
'../protos/health': { HealthService: {} },
'../protos/api': { ApiService: {} },
'../schedule/addCron': {},
'../schedule/delCron': {},
'../schedule/health': {},
'../schedule/api': {},
'../config/grpcCerts': {
initGrpcCerts: async () => ({
caCert: 'test',
serverCert: 'test',
serverKey: 'test',
}),
},
}).GrpcServerService;
await assert.rejects(new Grpc().initialize());
assert.deepEqual(
attempted,
host === '::' ? ['[::]:5500', '0.0.0.0:5500'] : [`${host}:5500`],
);
});
}
+72
View File
@@ -0,0 +1,72 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
DEFAULT_LOG_CHUNK_BYTES,
MAX_LOG_CHUNK_BYTES,
readLogChunk,
} = require('../../back/shared/logReader');
test('log reader defaults to a bounded tail and supports incremental reads', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-'));
const file = path.join(directory, 'task.log');
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const initial = 'a'.repeat(DEFAULT_LOG_CHUNK_BYTES * 2);
await fs.writeFile(file, initial);
const tail = await readLogChunk(file);
assert.equal(tail.content.length, DEFAULT_LOG_CHUNK_BYTES);
assert.equal(tail.offset, DEFAULT_LOG_CHUNK_BYTES);
assert.equal(tail.nextOffset, initial.length);
assert.equal(tail.truncated, true);
await fs.appendFile(file, 'next');
const incremental = await readLogChunk(file, { offset: tail.nextOffset });
assert.equal(incremental.content, 'next');
assert.equal(incremental.nextOffset, initial.length + 4);
});
test('log reader enforces the maximum chunk size', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-'));
const file = path.join(directory, 'large.log');
t.after(() => fs.rm(directory, { recursive: true, force: true }));
await fs.writeFile(file, 'x'.repeat(MAX_LOG_CHUNK_BYTES + 1024));
const chunk = await readLogChunk(file, { limit: Number.MAX_SAFE_INTEGER });
assert.equal(Buffer.byteLength(chunk.content), MAX_LOG_CHUNK_BYTES);
});
test('log reader preserves UTF-8 characters across byte boundaries', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-'));
const file = path.join(directory, 'utf8.log');
t.after(() => fs.rm(directory, { recursive: true, force: true }));
await fs.writeFile(file, 'a'.repeat(5) + '中文日志');
const first = await readLogChunk(file, { offset: 0, limit: 7 });
const second = await readLogChunk(file, {
offset: first.nextOffset,
limit: 7,
});
const third = await readLogChunk(file, {
offset: second.nextOffset,
limit: 7,
});
assert.equal(first.content + second.content + third.content, 'aaaaa中文日志');
assert.equal((first.content + second.content + third.content).includes('�'), false);
});
test('missing logs return an empty chunk', async () => {
const chunk = await readLogChunk('/path/that/does/not/exist.log');
assert.deepEqual(chunk, {
content: '',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
});
});
+34
View File
@@ -0,0 +1,34 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
MAX_METRIC_SAMPLES,
metricsService,
} = require('../../back/services/metrics');
test('metrics keep only the newest bounded samples', () => {
const sampleCount = MAX_METRIC_SAMPLES * 50;
for (let index = 0; index < sampleCount; index++) {
metricsService.record('http_request', index, {
path: `/health/${index}`,
});
}
const result = metricsService.getMetrics('http_request');
assert.equal(result.count, MAX_METRIC_SAMPLES);
assert.equal(result.metrics[0].value, sampleCount - MAX_METRIC_SAMPLES);
assert.equal(result.metrics.at(-1).value, sampleCount - 1);
});
test('empty metric queries return finite aggregates', () => {
const result = metricsService.getMetrics('missing_metric');
assert.deepEqual(
{
count: result.count,
average: result.average,
min: result.min,
max: result.max,
},
{ count: 0, average: 0, min: 0, max: 0 },
);
});
+73
View File
@@ -0,0 +1,73 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { monitoringMiddleware } = require('../../back/middlewares/monitoring');
const { metricsService } = require('../../back/services/metrics');
function createResponse() {
return {
statusCode: 200,
ended: false,
end() {
this.ended = true;
},
};
}
test('health probes bypass request metric retention with or without base URL', () => {
const before = metricsService.getMetrics('http_request', {
path: '/api/health',
}).count;
let nextCalled = false;
for (const path of ['/api/health', '/ql/api/health']) {
const response = createResponse();
monitoringMiddleware({ method: 'GET', path }, response, () => {
nextCalled = true;
});
response.end();
assert.equal(response.ended, true);
}
const after = metricsService.getMetrics('http_request', {
path: '/api/health',
}).count;
assert.equal(nextCalled, true);
assert.equal(after, before);
});
test('non-health requests keep bounded service metrics', () => {
const path = '/api/monitoring-test';
const before = metricsService.getMetrics('http_request', { path }).count;
const response = createResponse();
monitoringMiddleware(
{ method: 'GET', path, platform: 'desktop' },
response,
() => {},
);
response.end();
const after = metricsService.getMetrics('http_request', { path }).count;
assert.equal(response.ended, true);
assert.equal(after, before + 1);
});
test('ordinary request metrics are sampled instead of retained per request', () => {
const path = '/api/monitoring-sampling-test';
const before = metricsService.getMetrics('http_request', { path }).count;
for (let index = 0; index < 30; index += 1) {
const response = createResponse();
monitoringMiddleware(
{ method: 'GET', path, platform: 'desktop' },
response,
() => {},
);
response.end();
}
const recorded =
metricsService.getMetrics('http_request', { path }).count - before;
assert.ok(recorded >= 2 && recorded <= 3);
});
+58
View File
@@ -0,0 +1,58 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const protectedPathCase = require(
'../../back/middlewares/protectedPathCase',
).default;
function runMiddleware(path) {
let status;
let body;
let nextCalled = false;
const response = {
status(value) {
status = value;
return this;
},
json(value) {
body = value;
return this;
},
};
protectedPathCase({ path }, response, () => {
nextCalled = true;
});
return { status, body, nextCalled };
}
test('rejects case variations under protected API namespaces', () => {
for (const path of [
'/Api/configs/detail',
'/api/Configs/detail',
'/OPEN/scripts/detail',
'/open/scripts/Detail',
]) {
assert.deepEqual(runMiddleware(path), {
status: 400,
body: { code: 400, message: 'Invalid path format' },
nextCalled: false,
});
}
});
test('allows normalized protected paths and unrelated paths', () => {
for (const path of [
'/api/configs/detail',
'/open/scripts/detail',
'/OpenApi/status',
'/assets/AppBundle.js',
]) {
assert.deepEqual(runMiddleware(path), {
status: undefined,
body: undefined,
nextCalled: true,
});
}
});
+128
View File
@@ -0,0 +1,128 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { after, test } = require('node:test');
const root = path.resolve(__dirname, '../..');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-log-command-'));
after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
function writeExecutable(filePath, content) {
fs.writeFileSync(filePath, content, { mode: 0o755 });
}
function prepareFixture(name) {
const testRoot = path.join(fixtureRoot, name);
const shellDir = path.join(testRoot, 'shell');
const binDir = path.join(testRoot, 'bin');
fs.mkdirSync(shellDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
fs.copyFileSync(
path.join(root, 'shell/update.sh'),
path.join(shellDir, 'update.sh'),
);
fs.writeFileSync(
path.join(shellDir, 'share.sh'),
[
'dir_data="${QL_DATA_DIR:-$QL_DIR/data}"',
'load_ql_envs() { :; }',
'import_config() { :; }',
'',
].join('\n'),
);
fs.writeFileSync(path.join(shellDir, 'api.sh'), '');
fs.writeFileSync(path.join(shellDir, 'env.sh'), '');
writeExecutable(
path.join(binDir, 'pm2'),
'#!/usr/bin/env bash\necho "pm2 must not be called" >&2\nexit 99\n',
);
return { binDir, shellDir, testRoot };
}
test('ql log reads the requested system log history without PM2', () => {
const { binDir, shellDir, testRoot } = prepareFixture('host');
const systemLogDir = path.join(testRoot, 'data/syslog');
fs.mkdirSync(systemLogDir, { recursive: true });
fs.writeFileSync(
path.join(systemLogDir, '2026-08-20.log'),
'system-one\nsystem-two\nsystem-three\n',
);
const result = spawnSync(
'/bin/bash',
[path.join(shellDir, 'update.sh'), 'log', '--lines', '2', '--nostream'],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
QL_CONTAINER: 'false',
QL_DIR: testRoot,
},
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'system-two\nsystem-three\n');
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
});
test('ql log follows the current Winston system log without PM2', () => {
const { binDir, shellDir, testRoot } = prepareFixture('container');
const systemLogDir = path.join(testRoot, 'data/syslog');
const currentDate = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim();
const currentLog = path.join(systemLogDir, `${currentDate}.log`);
fs.mkdirSync(systemLogDir, { recursive: true });
fs.writeFileSync(currentLog, 'system-one\n');
writeExecutable(
path.join(binDir, 'tail'),
'#!/usr/bin/env bash\nprintf "tail"\nprintf " <%s>" "$@"\nprintf "\\n"\n',
);
const result = spawnSync(
'/bin/bash',
[path.join(shellDir, 'update.sh'), 'log', '--lines', '0'],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
QL_CONTAINER: 'true',
QL_DIR: testRoot,
},
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`);
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
});
test('ql log reports when no persisted system log exists', () => {
const { binDir, shellDir, testRoot } = prepareFixture('container-nostream');
const result = spawnSync(
'/bin/bash',
[path.join(shellDir, 'update.sh'), 'log', '--nostream'],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
QL_CONTAINER: 'true',
QL_DIR: testRoot,
},
},
);
assert.equal(result.status, 0);
assert.equal(result.stdout, '');
assert.match(result.stderr, /no system log found/);
});
+131
View File
@@ -0,0 +1,131 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const { Op } = require('sequelize');
const {
getDirectorySize,
normalizeRetentionPolicy,
} = require('../../back/shared/retention');
test('retention policy defaults to disabled and clamps invalid values', () => {
assert.deepEqual(normalizeRetentionPolicy({}), {
runningInstanceRetentionDays: 0,
cronStatRetentionDays: 0,
});
assert.deepEqual(
normalizeRetentionPolicy({
runningInstanceRetentionDays: -10,
cronStatRetentionDays: 99999,
}),
{
runningInstanceRetentionDays: 0,
cronStatRetentionDays: 3650,
},
);
});
test('directory preview counts files recursively and ignores symlinks', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-'));
const nested = path.join(directory, 'nested');
t.after(() => fs.rm(directory, { recursive: true, force: true }));
await fs.mkdir(nested);
await fs.writeFile(path.join(directory, 'one'), Buffer.alloc(7));
await fs.writeFile(path.join(nested, 'two'), Buffer.alloc(11));
await fs.symlink(path.join(nested, 'two'), path.join(directory, 'link'));
assert.equal(await getDirectorySize(directory), 18);
assert.equal(await getDirectorySize(path.join(directory, 'missing')), 0);
});
test('cleanup previews first, protects running instances, and uses explicit options', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-'));
const nodeCache = path.join(directory, 'node');
await fs.mkdir(nodeCache);
await fs.writeFile(path.join(nodeCache, 'cache'), Buffer.alloc(13));
const config = require('../../back/config').default;
const originalCachePath = config.dependenceCachePath;
const RunningInstanceModel = {};
const CrontabStatModel = {};
const InstanceStatus = { running: 0 };
const sequelize = {};
const stubModule = (modulePath, exports) => {
require.cache[require.resolve(modulePath)] = {
id: require.resolve(modulePath),
filename: require.resolve(modulePath),
loaded: true,
exports,
children: [],
paths: [],
};
};
stubModule('../../back/data', { sequelize });
stubModule('../../back/data/cronStats', { CrontabStatModel });
stubModule('../../back/data/runningInstance', {
InstanceStatus,
RunningInstanceModel,
});
stubModule('../../back/data/system', {
AuthDataType: { systemConfig: 'systemConfig' },
SystemModel: {},
});
delete require.cache[require.resolve('../../back/services/retention')];
const RetentionService = require('../../back/services/retention').default;
t.after(async () => {
config.dependenceCachePath = originalCachePath;
await fs.rm(directory, { recursive: true, force: true });
});
let instanceWhere;
let vacuumed = false;
let instanceCountCalls = 0;
let statCountCalls = 0;
config.dependenceCachePath = directory;
RunningInstanceModel.count = async ({ where }) => {
instanceCountCalls++;
instanceWhere = where;
return 2;
};
RunningInstanceModel.destroy = async ({ where }) => {
instanceWhere = where;
return 2;
};
CrontabStatModel.count = async () => {
statCountCalls++;
return 3;
};
CrontabStatModel.destroy = async () => 3;
sequelize.transaction = async (callback) => callback({});
sequelize.query = async (query) => {
vacuumed = query === 'VACUUM';
return [];
};
const service = new RetentionService();
const disabledPreview = await service.preview({
runningInstanceRetentionDays: 0,
cronStatRetentionDays: 0,
});
assert.equal(disabledPreview.runningInstances, 0);
assert.equal(disabledPreview.cronStats, 0);
assert.equal(instanceCountCalls, 0);
assert.equal(statCountCalls, 0);
const result = await service.cleanup({
runningInstanceRetentionDays: 30,
cronStatRetentionDays: 90,
dependenceCacheTypes: ['node'],
compactDatabase: true,
});
assert.equal(instanceWhere.status[Op.ne], InstanceStatus.running);
assert.equal(result.deleted.runningInstances, 2);
assert.equal(result.deleted.cronStats, 3);
assert.equal(result.deleted.dependenceCacheBytes, 13);
assert.equal(vacuumed, true);
await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' });
});
+86
View File
@@ -0,0 +1,86 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const config = require('../../back/config').default;
const { check } = require('../../back/schedule/health');
function runCheck(service = 'cron') {
return new Promise((resolve) => {
check({ request: { service } }, (error, response) => {
resolve({ error, response });
});
});
}
test('schedule health check uses the HTTP health endpoint and system logs', async (t) => {
const originalBaseUrl = config.baseUrl;
const originalPort = config.port;
const originalSystemLogPath = config.systemLogPath;
const originalContainer = process.env.QL_CONTAINER;
t.after(() => {
config.baseUrl = originalBaseUrl;
config.port = originalPort;
config.systemLogPath = originalSystemLogPath;
if (originalContainer === undefined) {
delete process.env.QL_CONTAINER;
} else {
process.env.QL_CONTAINER = originalContainer;
}
});
await t.test(
'returns serving for a healthy prefixed HTTP service',
async () => {
let requestedUrl = '';
const server = http.createServer((request, response) => {
requestedUrl = request.url;
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ code: 200, data: { status: 'ok' } }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
config.baseUrl = '/ql';
config.port = server.address().port;
const result = await runCheck();
assert.equal(result.error, null);
assert.deepEqual(result.response, { status: 1 });
assert.equal(requestedUrl, '/ql/api/health');
},
);
await t.test(
'reports recent system logs when HTTP startup fails',
async () => {
const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-health-'));
t.after(() => fs.rm(logDir, { recursive: true, force: true }));
const lines = Array.from(
{ length: 305 },
(_, index) => `line-${index + 1}`,
);
await fs.writeFile(path.join(logDir, '2026-08-16.log'), 'older-log');
await fs.writeFile(path.join(logDir, '2026-08-17.log'), lines.join('\n'));
config.systemLogPath = logDir;
config.port = 1;
process.env.QL_CONTAINER = 'true';
const result = await runCheck();
assert.equal(result.response, undefined);
assert.match(result.error.message, /ECONNREFUSED|connect/);
assert.match(
result.error.message,
/http:\/\/localhost:1\/ql\/api\/health/,
);
assert.match(result.error.message, /docker logs <container>/);
assert.match(result.error.message, /line-305/);
assert.doesNotMatch(result.error.message, /line-1\n/);
assert.doesNotMatch(result.error.message, /qinglong-error\.log/);
},
);
});
+72
View File
@@ -0,0 +1,72 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const { EventEmitter } = require('node:events');
const jwt = require('jsonwebtoken');
const load = require('../helpers/load-security-module.cjs');
test('WebSocket connections reject expired tokens and close when sessions are revoked', async () => {
const secret = 'sock-test';
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
let auth = { token: valid, tokens: { desktop: [{ value: expired }] } };
let onConnection;
const clients = new Set();
const Sock = class {};
const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), {
sockjs: {
createServer: () => ({
on: (_event, fn) => {
onConnection = fn;
},
installHandlers() {},
}),
},
typedi: {
Container: {
get: () => ({
addClient: (c) => clients.add(c),
removeClient: (c) => clients.delete(c),
}),
},
},
'../services/sock': Sock,
'../config': { jwt: { secret }, baseUrl: '' },
'../config/util': { getPlatform: () => 'desktop' },
'../shared/store': { shareStore: { getAuthInfo: async () => auth } },
}).default;
await loader({ server: {} });
const connection = (token) => {
const c = new EventEmitter();
Object.assign(c, {
headers: {},
pathname: '/api/ws/a/b/websocket',
url: `/api/ws/a/b/websocket?token=${token}`,
close() {
this.closed = true;
this.emit('close');
},
write() {},
});
return c;
};
const rejected = connection(expired);
await onConnection(rejected);
assert.equal(rejected.closed, true);
assert.equal(clients.size, 0);
const accepted = connection(valid);
await onConnection(accepted);
assert.equal(clients.size, 1);
auth = { token: '', tokens: {} };
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
accepted.close();
reject(new Error('revoked socket remained open'));
}, 3000);
accepted.once('close', () => {
clearTimeout(timeout);
resolve();
});
});
assert.equal(clients.size, 0);
});
+126
View File
@@ -0,0 +1,126 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
test('clearing a dependency cache marks installed entries for reinstall', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-dependence-'));
const nodeCache = path.join(directory, 'node');
await fs.mkdir(nodeCache);
await fs.writeFile(path.join(nodeCache, 'package'), 'cached');
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
moduleStubs.set(resolved, require.cache[resolved]);
require.cache[resolved] = {
id: resolved,
filename: resolved,
loaded: true,
exports,
children: [],
paths: [],
};
};
const updates = [];
const DependenceStatus = { installed: 1, installFailed: 2 };
const DependenceTypes = { nodejs: 0, python3: 1 };
stubModule('../../back/config', {
__esModule: true,
default: { dependenceCachePath: directory },
});
stubModule('../../back/config/const', {
NotificationModeStringMap: {},
TASK_COMMAND: 'task',
});
stubModule('../../back/config/util', {
getPid: async () => 0,
killTask: async () => {},
parseContentVersion: () => '',
parseVersion: () => '',
promiseExec: async () => '',
readDirs: async () => [],
rmPath: async () => {},
setSystemTimezone: async () => true,
updateLinuxMirrorFile: async () => {},
});
stubModule('../../back/data/dependence', {
DependenceModel: {
update: async (values, options) => updates.push({ values, options }),
},
DependenceStatus,
DependenceTypes,
});
stubModule('../../back/data/notify', {});
stubModule('../../back/data/system', {
AuthDataType: {},
SystemModel: {},
});
stubModule('../../back/shared/pLimit', {
__esModule: true,
default: {},
});
stubModule('../../back/shared/i18n', {
setLang: () => {},
t: (message) => message,
});
stubModule('../../back/services/notify', {
__esModule: true,
default: class NotificationService {},
});
stubModule('../../back/services/schedule', {
__esModule: true,
default: class ScheduleService {},
});
stubModule('../../back/services/sock', {
__esModule: true,
default: class SockService {},
});
const servicePath = require.resolve('../../back/services/system');
const originalService = require.cache[servicePath];
delete require.cache[servicePath];
t.after(() => {
if (originalService) {
require.cache[servicePath] = originalService;
} else {
delete require.cache[servicePath];
}
for (const [resolved, original] of moduleStubs) {
if (original) {
require.cache[resolved] = original;
} else {
delete require.cache[resolved];
}
}
});
const SystemService = require('../../back/services/system').default;
const service = new SystemService({}, {}, {});
assert.deepEqual(await service.cleanDependence('node'), { code: 200 });
assert.deepEqual(await service.cleanDependence('python3'), { code: 200 });
await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' });
assert.deepEqual(updates, [
{
values: { status: DependenceStatus.installFailed },
options: {
where: {
type: DependenceTypes.nodejs,
status: DependenceStatus.installed,
},
},
},
{
values: { status: DependenceStatus.installFailed },
options: {
where: {
type: DependenceTypes.python3,
status: DependenceStatus.installed,
},
},
},
]);
});
+99
View File
@@ -0,0 +1,99 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const express = require('express');
const { Container } = require('typedi');
test('initialization returns the username and password validation result', async (t) => {
const originalGet = Container.get;
const userServicePath = require.resolve('../../back/services/user');
const originalUserService = require.cache[userServicePath];
const i18nPath = require.resolve('../../back/shared/i18n');
const originalI18n = require.cache[i18nPath];
const utilPath = require.resolve('../../back/config/util');
const originalUtil = require.cache[utilPath];
const authPath = require.resolve('../../back/shared/auth');
const originalAuth = require.cache[authPath];
require.cache[userServicePath] = {
id: userServicePath,
filename: userServicePath,
loaded: true,
exports: { __esModule: true, default: class UserService {} },
children: [],
paths: [],
};
require.cache[utilPath] = {
id: utilPath,
filename: utilPath,
loaded: true,
exports: { getToken: () => '', isDemoEnv: () => false },
children: [],
paths: [],
};
require.cache[authPath] = {
id: authPath,
filename: authPath,
loaded: true,
exports: { isDefaultAuthInfo: () => false },
children: [],
paths: [],
};
require.cache[i18nPath] = {
id: i18nPath,
filename: i18nPath,
loaded: true,
exports: { t: (message) => message },
children: [],
paths: [],
};
Container.get = () => ({
initializeUser: async () => ({
code: 400,
message: 'password rejected',
}),
});
t.after(() => {
Container.get = originalGet;
if (originalUserService) {
require.cache[userServicePath] = originalUserService;
} else {
delete require.cache[userServicePath];
}
if (originalI18n) {
require.cache[i18nPath] = originalI18n;
} else {
delete require.cache[i18nPath];
}
if (originalUtil) {
require.cache[utilPath] = originalUtil;
} else {
delete require.cache[utilPath];
}
if (originalAuth) {
require.cache[authPath] = originalAuth;
} else {
delete require.cache[authPath];
}
});
const app = express.Router();
require('../../back/api/user').default(app);
const userRouter = app.stack.find((layer) => layer.name === 'router').handle;
const initRoute = userRouter.stack.find(
(layer) => layer.route?.path === '/init',
);
const handler = initRoute.route.stack.at(-1).handle;
let responseBody;
await handler(
{ body: { username: 'admin', password: 'admin' } },
{ send: (body) => (responseBody = body) },
(error) => {
throw error;
},
);
assert.deepEqual(responseBody, {
code: 400,
message: 'password rejected',
});
});
+248
View File
@@ -0,0 +1,248 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const jwt = require('jsonwebtoken');
const { authenticator } = require('@otplib/preset-default');
const load = require('../helpers/load-security-module.cjs');
const { verifyPassword, isPasswordHash } = load(
path.join(__dirname, '../../back/shared/password.ts'),
);
const req = {
platform: 'desktop',
headers: {},
socket: { remoteAddress: '127.0.0.1' },
};
function fixture(initial) {
let auth = structuredClone(initial);
let closed = 0;
const model = {
findOne: async () => ({ id: 1 }),
update: async () => [1, [{}]],
create: async (x) => x,
findAll: async () => [],
destroy: async () => {},
};
const mocks = {
typedi: { Service: () => (x) => x, Inject: () => () => {} },
'../config': { jwt: { secret: 'test-secret' }, maxTokensPerPlatform: 10 },
'../data/system': {
AuthDataType: { authConfig: 'authConfig', loginLog: 'loginLog' },
LoginStatus: { fail: 0, success: 1 },
SystemModel: model,
},
'../config/util': {
createRandomString: () =>
require('node:crypto').randomBytes(32).toString('hex'),
},
'./notify': class {},
'./schedule': class {},
'./sock': class {},
'../shared/store': {
shareStore: {
getAuthInfo: async () => auth,
updateAuthInfo: async (x) => {
auth = x;
},
},
},
'../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) },
'../shared/clientIp': {
getClientIp: () => '127.0.0.1',
normalizeClientIp: (x) => x,
},
ip2region: class {
search() {
return null;
}
},
};
const User = load(
path.join(__dirname, '../../back/services/user.ts'),
mocks,
).default;
const user = new User(
{ warn() {}, info() {} },
{},
{ getClients: () => [{ close: () => closed++ }] },
);
user.notificationService = { notify() {} };
return {
user,
get auth() {
return auth;
},
get closed() {
return closed;
},
};
}
const initialized = () => ({
username: 'owner',
password: 'old-password',
token: 'stolen-token',
tokens: { desktop: [{ value: 'stolen-token' }] },
retries: 0,
lastlogon: 0,
});
test('initialization checks state inside a serialized mutation', async () => {
const f = fixture({ username: 'admin', password: 'admin' });
assert.equal(
(await f.user.login({ username: 'admin', password: 'admin' }, req)).code,
450,
);
assert.deepEqual(f.auth, { username: 'admin', password: 'admin' });
const results = await Promise.all([
f.user.initializeUser({ username: 'owner', password: 'first-password' }),
f.user.initializeUser({
username: 'attacker',
password: 'second-password',
}),
]);
assert.deepEqual(
results.map((x) => x.code),
[200, 450],
);
assert.equal(f.auth.username, 'owner');
assert.equal(await verifyPassword('first-password', f.auth.password), true);
assert.equal(
(
await f.user.initializeUser({
username: 'attacker',
password: 'third-password',
})
).code,
450,
);
});
test('password change revokes sessions and closes connected clients', async () => {
const f = fixture(initialized());
await f.user.updateUsernameAndPassword({
username: 'owner',
password: 'new-password',
});
assert.equal(f.auth.token, '');
assert.deepEqual(f.auth.tokens, {});
assert.equal(f.closed, 1);
assert.equal(await verifyPassword('new-password', f.auth.password), true);
assert.equal(
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
.code,
400,
);
const login = await f.user.login(
{ username: 'owner', password: 'new-password' },
req,
);
assert.equal(login.code, 200);
jwt.verify(login.data.token, 'test-secret', { algorithms: ['HS384'] });
});
test('a concurrent old-password login cannot restore a session after reset', async () => {
const f = fixture(initialized());
const results = await Promise.all([
f.user.login({ username: 'owner', password: 'old-password' }, req),
f.user.resetAuthInfo({ password: 'new-password' }),
]);
assert.equal(results[0].code, 200);
assert.equal(f.auth.token, '');
assert.deepEqual(f.auth.tokens, {});
assert.equal(await verifyPassword('new-password', f.auth.password), true);
});
test('legacy plaintext migrates after a successful login', async () => {
const f = fixture(initialized());
assert.equal(
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
.code,
200,
);
assert.equal(isPasswordHash(f.auth.password), true);
assert.equal(await verifyPassword('old-password', f.auth.password), true);
});
test('TOTP failures are counted serially and further attempts are throttled', async () => {
const secret = authenticator.generateSecret();
const f = fixture({
...initialized(),
twoFactorActivated: true,
twoFactorSecret: secret,
});
assert.equal(
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
.code,
420,
);
const badCode =
authenticator.generate(secret) === '000000' ? '111111' : '000000';
const results = await Promise.all(
Array.from({ length: 120 }, () =>
f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: badCode },
req,
),
),
);
assert.equal(f.auth.retries, 3);
assert.equal(results.filter((x) => x.code === 430).length, 3);
assert.equal(results.filter((x) => x.code === 410).length, 117);
});
test('TOTP challenge expires and valid codes cannot be reused in the same step', async () => {
const secret = authenticator.generateSecret();
const f = fixture({
...initialized(),
twoFactorActivated: true,
twoFactorSecret: secret,
});
await f.user.login({ username: 'owner', password: 'old-password' }, req);
f.auth.twoFactorExpiresAt = Date.now() - 1;
const payload = {
username: 'owner',
password: 'old-password',
code: authenticator.generate(secret),
};
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 450);
await f.user.login({ username: 'owner', password: 'old-password' }, req);
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 200);
await f.user.login({ username: 'owner', password: 'old-password' }, req);
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 430);
});
test('active two-factor secret cannot be silently replaced and disabling revokes sessions', async () => {
const f = fixture({
...initialized(),
twoFactorActivated: true,
twoFactorSecret: authenticator.generateSecret(),
});
await assert.rejects(f.user.initTwoFactor());
await f.user.deactivateTwoFactor();
assert.equal(f.auth.token, '');
assert.deepEqual(f.auth.tokens, {});
assert.equal(f.auth.twoFactorSecret, '');
});
test('default credentials with historical metadata still require initialization', async () => {
const f = fixture({
username: 'admin',
password: 'admin',
retries: 1,
token: 'old-default-session',
});
assert.equal(
(await f.user.login({ username: 'admin', password: 'admin' }, req)).code,
450,
);
assert.equal(
(
await f.user.initializeUser({
username: 'owner',
password: 'new-password',
})
).code,
200,
);
assert.equal(f.auth.token, '');
});
+29
View File
@@ -0,0 +1,29 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { getErrorDetails } = require('../../src/utils/httpError');
test('validation errors include the failing field names', () => {
const details = getErrorDetails({
message: 'Validation failed',
validation: {
body: {
source: 'body',
keys: ['labels', 'allow_multiple_instances'],
message: 'request body contains invalid values',
},
},
});
assert.deepEqual(details, [
'labels: request body contains invalid values',
'allow_multiple_instances: request body contains invalid values',
]);
});
test('existing API error details remain visible', () => {
assert.deepEqual(
getErrorDetails({ errors: [{ message: 'duplicate value', value: 'foo' }] }),
['duplicate value (foo)'],
);
});
+42
View File
@@ -0,0 +1,42 @@
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const ts = require('typescript');
module.exports = function loadSecurityModule(
file,
mocks = {},
cache = new Map(),
) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const module = { exports: {} };
cache.set(file, module);
const localRequire = createRequire(file);
const requireModule = (name) => {
if (Object.hasOwn(mocks, name)) return mocks[name];
if (name.startsWith('.')) {
const target = path.resolve(path.dirname(file), name);
if (fs.existsSync(`${target}.ts`)) {
return loadSecurityModule(`${target}.ts`, mocks, cache);
}
}
return localRequire(name);
};
const { outputText } = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
experimentalDecorators: true,
esModuleInterop: true,
},
});
// Use the host realm: express-unless checks RegExp with instanceof.
new Function('require', 'module', 'exports', '__dirname', outputText)(
requireModule,
module,
module.exports,
path.dirname(file),
);
return module.exports;
};
+33 -4
View File
@@ -1,6 +1,35 @@
version: 2.20.2
changeLogLink: https://t.me/jiao_long/434
publishTime: 2026-03-01 1800
version: 2.21.0
changeLogLink: https://t.me/jiao_long/436
publishTime: 2026-06-18 2300
changeLog: |
1. 修复 path 安全漏洞(重要)
1. shell 增加国际化支持
2. 接口提示信息国际化,更新国际化文案
3. 统一 Alpine/Debian 分支,QL_SCHEDULER 参数化调度
4. grpc 服务增加证书校验
5. 定时任务增加 work_dir 设置
6. 增加任务统计功能
7. 新增 OpeniLink 通知渠道
8. 支持自定义接收邮箱地址
9. 增加运行实例功能
10. 增加 sudo 命令判断
11. 延迟增加运行时间提示
12. 开机运行任务支持同时开始运行
13. 增加 localhost 检测
14. 增加环境变量标签功能
15. 修复路径穿越安全漏洞
16. 修复配置文件路径可能越权
17. 修复 work_dir 验证和目录判断
18. 修复多个 API 问题(getCronById、定时任务参数、任务退出码等)
19. 修复 ESM 依赖查询路径
20. 修复 CodeMirror 页面切换多实例崩溃
21. 修复 IPv6 网络连接问题
22. 修复内部服务 IP 地址
23. 修复 gRPC extra_schedules 为空时序列化报错
24. 修复 HITOKOTO 参数布尔/字符串类型处理
25. 修复 Server酱 返回错误时 undefined 异常
26. 修复环境变量 position 数据类型异常
27. 修复 Docker 健康检查 QlPort 环境变量读取
28. 修复任务统计日志
29. 升级 multer 解决 CVE 漏洞
30. 升级 nodemailer