定时任务支持自定义日志文件或者 /dev/null (#2823)

* Initial plan

* Add log_name field to enable custom log folder naming

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add database migration for log_name column

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Add security validation to prevent path traversal attacks

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Apply prettier formatting to modified files

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Support absolute paths like /dev/null for log redirection

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

* Restrict absolute paths to log directory except /dev/null

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
Copilot 2025-11-09 19:32:40 +08:00 committed by GitHub
parent 0e28e1b6c4
commit c369514741
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 160 additions and 12 deletions

View File

@ -21,6 +21,7 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
task_before?: string; task_before?: string;
task_after?: string; task_after?: string;
log_name?: string;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@ -45,6 +46,7 @@ export class Crontab {
this.extra_schedules = options.extra_schedules; this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before; this.task_before = options.task_before;
this.task_after = options.task_after; this.task_after = options.task_after;
this.log_name = options.log_name;
} }
} }
@ -55,7 +57,7 @@ export enum CrontabStatus {
'disabled', 'disabled',
} }
export interface CronInstance extends Model<Crontab, Crontab>, Crontab { } export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export const CrontabModel = sequelize.define<CronInstance>('Crontab', { export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: { name: {
unique: 'compositeIndex', unique: 'compositeIndex',
@ -84,4 +86,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON, extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING, task_before: DataTypes.STRING,
task_after: DataTypes.STRING, task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
}); });

View File

@ -56,6 +56,11 @@ export default async () => {
try { try {
await sequelize.query('alter table Crontabs add column task_after TEXT'); await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {} } catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column log_name VARCHAR(255)',
);
} catch (error) {}
Logger.info('✌️ DB loaded'); Logger.info('✌️ DB loaded');
} catch (error) { } catch (error) {

View File

@ -476,15 +476,61 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
let { id, command, log_path } = cron; let { id, command, log_path, log_name } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); // Check if log_name is an absolute path
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); const isAbsolutePath = log_name && log_name.startsWith('/');
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
await fs.mkdir(logDirPath, { recursive: true }); let uniqPath: string;
let absolutePath: string;
let logPath: string;
if (isAbsolutePath) {
// Special case: /dev/null is allowed as-is to discard logs
if (log_name === '/dev/null') {
uniqPath = log_name;
absolutePath = log_name;
logPath = log_name;
} else {
// For other absolute paths, ensure they are within the safe log directory
const normalizedLogName = path.normalize(log_name!);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedLogName.startsWith(normalizedLogPath)) {
this.logger.error(
`[panel][日志路径安全检查失败] 绝对路径必须在日志目录内: ${log_name}`,
);
// Fallback to auto-generated path for security
const fallbackUniqPath = await getUniqPath(command, `${id}`);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${fallbackUniqPath}`);
if (log_path?.split('/')?.every((x) => x !== fallbackUniqPath)) {
await fs.mkdir(logDirPath, { recursive: true });
}
logPath = `${fallbackUniqPath}/${logTime}.log`;
absolutePath = path.resolve(config.logPath, `${logPath}`);
uniqPath = fallbackUniqPath;
} else {
// Absolute path is safe, use it
uniqPath = log_name!;
absolutePath = log_name!;
logPath = log_name!;
}
}
} else {
// Sanitize log_name to prevent path traversal for relative paths
const sanitizedLogName = log_name
? log_name.replace(/[\/\\\.]/g, '_').replace(/^_+|_+$/g, '')
: '';
uniqPath = sanitizedLogName || (await getUniqPath(command, `${id}`));
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
await fs.mkdir(logDirPath, { recursive: true });
}
logPath = `${uniqPath}/${logTime}.log`;
absolutePath = path.resolve(config.logPath, `${logPath}`);
} }
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn( const cp = spawn(
`real_log_path=${logPath} no_delay=true ${this.makeCommand( `real_log_path=${logPath} no_delay=true ${this.makeCommand(
cron, cron,

View File

@ -1,6 +1,8 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => { const validateSchedule = (value: string, helpers: any) => {
if ( if (
@ -37,4 +39,43 @@ export const commonCronSchema = {
extra_schedules: Joi.array().optional().allow(null), extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null), task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
// For relative names, enforce strict pattern
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
}; };

View File

@ -521,5 +521,14 @@
"远程仓库缓存": "Remote repository cache", "远程仓库缓存": "Remote repository cache",
"SSH 文件缓存": "SSH file cache", "SSH 文件缓存": "SSH file cache",
"清除依赖缓存": "Clean dependency cache", "清除依赖缓存": "Clean dependency cache",
"清除成功": "Clean successful" "清除成功": "Clean successful",
"日志名称": "Log Name",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory",
"请输入自定义日志文件夹名称": "Please enter a custom log folder name",
"请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path",
"请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens",
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters"
} }

View File

@ -521,5 +521,14 @@
"远程仓库缓存": "远程仓库缓存", "远程仓库缓存": "远程仓库缓存",
"SSH 文件缓存": "SSH 文件缓存", "SSH 文件缓存": "SSH 文件缓存",
"清除依赖缓存": "清除依赖缓存", "清除依赖缓存": "清除依赖缓存",
"清除成功": "清除成功" "清除成功": "清除成功",
"日志名称": "日志名称",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内",
"请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称",
"请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径",
"请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null",
"日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符",
"日志名称不能超过100个字符": "日志名称不能超过100个字符"
} }

View File

@ -180,6 +180,42 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}> <Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup /> <EditableTagGroup />
</Form.Item> </Form.Item>
<Form.Item
name="log_name"
label={intl.get('日志名称')}
tooltip={intl.get(
'自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内',
)}
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
// Allow /dev/null specifically
if (value === '/dev/null') return Promise.resolve();
// Warn about other absolute paths (server will validate)
if (value.startsWith('/')) {
// We can't validate the exact path on frontend, but inform user
return Promise.resolve();
}
// For relative names, enforce strict pattern
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
);
}
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
return Promise.resolve();
},
},
]}
>
<Input
placeholder={intl.get('请输入自定义日志文件夹名称或 /dev/null')}
maxLength={200}
/>
</Form.Item>
<Form.Item <Form.Item
name="task_before" name="task_before"
label={intl.get('执行前')} label={intl.get('执行前')}
@ -312,4 +348,3 @@ const CronLabelModal = ({
}; };
export { CronLabelModal, CronModal as default }; export { CronLabelModal, CronModal as default };