修复日志目录逻辑

This commit is contained in:
whyour 2025-11-09 21:30:56 +08:00
parent 4cb9f57479
commit 06aa07329f
7 changed files with 64 additions and 91 deletions

View File

@ -44,5 +44,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER, position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
isPinned: { type: DataTypes.NUMBER, field: 'is_pinned' }, isPinned: DataTypes.NUMBER,
}); });

View File

@ -62,7 +62,7 @@ export default async () => {
); );
} catch (error) {} } catch (error) {}
try { try {
await sequelize.query('alter table Envs add column is_pinned NUMBER'); await sequelize.query('alter table Envs add column isPinned NUMBER');
} catch (error) {} } catch (error) {}
Logger.info('✌️ DB loaded'); Logger.info('✌️ DB loaded');

View File

@ -27,7 +27,7 @@ import { ScheduleType } from '../interface/schedule';
@Service() @Service()
export default class CronService { export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(@Inject('logger') private logger: winston.Logger) { }
private isNodeCron(cron: Crontab) { private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron; const { schedule, extra_schedules } = cron;
@ -49,9 +49,27 @@ export default class CronService {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
} }
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); const tab = new Crontab(payload);
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab); const doc = await this.insert(tab);
if (isDemoEnv()) { if (isDemoEnv()) {
@ -82,6 +100,7 @@ export default class CronService {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload }); const tab = new Crontab({ ...doc, ...payload });
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) { if (doc.isDisabled === 1 || isDemoEnv()) {
@ -142,7 +161,7 @@ export default class CronService {
let cron; let cron;
try { try {
cron = await this.getDb({ id }); cron = await this.getDb({ id });
} catch (err) {} } catch (err) { }
if (!cron) { if (!cron) {
continue; continue;
} }
@ -476,61 +495,14 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
let { id, command, log_path, log_name } = cron; let { id, command, log_name } = cron;
// Check if log_name is an absolute path const uniqPath = log_name === '/dev/null' ? (await getUniqPath(command, `${id}`)) : log_name;
const isAbsolutePath = log_name && log_name.startsWith('/'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
let uniqPath: string; await fs.mkdir(logDirPath, { recursive: true });
let absolutePath: string; const logPath = `${uniqPath}/${logTime}.log`;
let logPath: string; const absolutePath = path.resolve(config.logPath, `${logPath}`);
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 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,
@ -610,7 +582,9 @@ export default class CronService {
if (!doc) { if (!doc) {
return ''; return '';
} }
if (doc.log_name === '/dev/null') {
return '日志设置为忽略';
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
@ -653,9 +627,7 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; command = `${TASK_PREFIX}${tab.command}`;
} }
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${ let commandVariable = `real_time=${Boolean(realTime)} log_name=${tab.log_name} no_tee=true ID=${tab.id} `;
tab.id
} `;
if (tab.task_before) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")

View File

@ -64,8 +64,7 @@ export const commonCronSchema = {
return value; return value;
} }
// For relative names, enforce strict pattern if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return helpers.error('string.pattern.base'); return helpers.error('string.pattern.base');
} }
if (value.length > 100) { if (value.length > 100) {

View File

@ -46,18 +46,22 @@ handle_log_path() {
time=$(date "+$mtime_format") time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time") log_time=$(format_log_time "$mtime_format" "$time")
log_dir_tmp="${file_param##*/}" if [[ -z $log_name ]]; then
if [[ $file_param =~ "/" ]]; then log_dir_tmp="${file_param##*/}"
if [[ $file_param == /* ]]; then if [[ $file_param =~ "/" ]]; then
log_dir_tmp_path="${file_param:1}" if [[ $file_param == /* ]]; then
else log_dir_tmp_path="${file_param:1}"
log_dir_tmp_path="${file_param}" else
log_dir_tmp_path="${file_param}"
fi
fi fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
else
log_dir="$log_name"
fi fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
log_path="$log_dir/$log_time.log" log_path="$log_dir/$log_time.log"
if [[ ${real_log_path:=} ]]; then if [[ ${real_log_path:=} ]]; then
@ -73,6 +77,11 @@ handle_log_path() {
if [[ "${real_time:=}" == "true" ]]; then if [[ "${real_time:=}" == "true" ]]; then
cmd="" cmd=""
fi fi
if [[ "${log_dir:=}" == "/dev/null" ]]; then
cmd=">> /dev/null"
log_path="/dev/null"
fi
} }
format_params() { format_params() {

View File

@ -55,7 +55,7 @@ const CronLogModal = ({
const log = data as string; const log = data as string;
setValue(log || intl.get("暂无日志")); setValue(log || intl.get("暂无日志"));
const hasNext = Boolean( const hasNext = Boolean(
log && !logEnded(log) && !log.includes("日志不存在"), log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"),
); );
if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) { if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) {
setTimeout(() => { setTimeout(() => {

View File

@ -190,22 +190,15 @@ const CronModal = ({
{ {
validator: (_, value) => { validator: (_, value) => {
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
// Allow /dev/null specifically
if (value === '/dev/null') return Promise.resolve(); if (value === '/dev/null') return Promise.resolve();
// Warn about other absolute paths (server will validate) if (value.length > 100) {
if (value.startsWith('/')) { return Promise.reject(intl.get('日志名称不能超过100个字符'));
// We can't validate the exact path on frontend, but inform user
return Promise.resolve();
} }
// For relative names, enforce strict pattern if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return Promise.reject( return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'), intl.get('日志名称只能包含字母、数字、下划线和连字符'),
); );
} }
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
return Promise.resolve(); return Promise.resolve();
}, },
}, },