mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 09:28:35 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df52094f2 | ||
|
|
be2580d0e8 | ||
|
|
5f6049d80a | ||
|
|
0e975d1d6d | ||
|
|
134cd8337c | ||
|
|
88200f2805 | ||
|
|
decdab093f | ||
|
|
dfa73af104 | ||
|
|
f71fd5ebd6 | ||
|
|
afe9b5702b | ||
|
|
02e02491c4 | ||
|
|
276dfc2382 | ||
|
|
31f78e4d1f | ||
|
|
83e4490b57 | ||
|
|
2ac4f07fca | ||
|
|
6618240c61 |
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,3 +35,6 @@ __pycache__
|
||||
|
||||
# local Kubernetes overlays
|
||||
/deploy/kubernetes/overlays/local/
|
||||
|
||||
# Local security audit artifacts
|
||||
/audit/
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 接口'),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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 ',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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: '',
|
||||
|
||||
+2
-2
@@ -65,7 +65,7 @@
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.0",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.39.16"
|
||||
}
|
||||
@@ -109,7 +109,7 @@
|
||||
"request-ip": "3.3.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"sockjs": "^0.3.24",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.0",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||
"toad-scheduler": "^3.0.1",
|
||||
"typedi": "^0.10.0",
|
||||
"undici": "^7.9.0",
|
||||
|
||||
Generated
+9
-9
@@ -1,7 +1,7 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
overrides:
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.0
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.2
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.16
|
||||
|
||||
@@ -116,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: npm:@whyour/sqlite3@1.1.0
|
||||
version: /@whyour/sqlite3@1.1.0
|
||||
specifier: npm:@whyour/sqlite3@1.1.2
|
||||
version: /@whyour/sqlite3@1.1.2
|
||||
toad-scheduler:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
@@ -3190,7 +3190,7 @@ packages:
|
||||
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
||||
engines: {node: '>= 18'}
|
||||
dependencies:
|
||||
sqlite3: /@whyour/sqlite3@1.1.0
|
||||
sqlite3: /@whyour/sqlite3@1.1.2
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -5614,8 +5614,8 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@whyour/sqlite3@1.1.0:
|
||||
resolution: {integrity: sha512-5g9xsjkv9sUepCvBBKmev6JSKSKjp0ebpW25uSsuV5kurIYrd7AnkbffNMNIswxwsQJSLPewVEX5ROsQfETfnA==}
|
||||
/@whyour/sqlite3@1.1.2:
|
||||
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
|
||||
requiresBuild: true
|
||||
peerDependenciesMeta:
|
||||
node-gyp:
|
||||
@@ -13939,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:
|
||||
@@ -13984,7 +13984,7 @@ packages:
|
||||
retry-as-promised: 7.0.4
|
||||
semver: 7.6.3
|
||||
sequelize-pool: 7.1.0
|
||||
sqlite3: /@whyour/sqlite3@1.1.0
|
||||
sqlite3: /@whyour/sqlite3@1.1.2
|
||||
toposort-class: 1.0.1
|
||||
uuid: 8.3.2
|
||||
validator: 13.12.0
|
||||
|
||||
+58
-18
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-2
@@ -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
@@ -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
@@ -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
@@ -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 @@
|
||||
"已完成": "已完成",
|
||||
"已停止": "已停止",
|
||||
"退出码": "退出码",
|
||||
"结束": "结束"
|
||||
"结束": "结束",
|
||||
"失败次数": "失败次数",
|
||||
"最新日志": "最新日志",
|
||||
"加载失败": "加载失败",
|
||||
"重试": "重试"
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -28,6 +28,7 @@ const CronModal = ({
|
||||
const method = cron?.id ? 'put' : 'post';
|
||||
const payload = {
|
||||
...values,
|
||||
labels: values.labels || [],
|
||||
schedule:
|
||||
scheduleType !== ScheduleType.Normal
|
||||
? scheduleTypeMap[scheduleType]
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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('依赖设置'),
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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} 接口`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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`],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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, '');
|
||||
});
|
||||
@@ -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)'],
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user