Compare commits

...
2 Commits
Author SHA1 Message Date
whyour 41910b5236 fix(dependencies): avoid repeated checks when opening dependency management (#3122)
* fix(dependencies): cache status reads and refresh asynchronously

* fix(cli): expose asynchronous dependency refresh operations
2026-11-03 23:08:16 +08:00
whyour 442ab2c616 fix(security): restrict task environment controls and app scopes (#3121)
* fix(security): restrict task environment controls and app scopes

* fix(security): preserve legacy scopes and add read-only access

* feat(security): add read-only scopes for remaining resources

* fix(security): reject apps scope grants in app API

* fix(security): protect system app and task file boundaries

* fix: block case-variant secret paths and task runner env overrides
2026-11-03 23:04:43 +08:00
38 changed files with 1500 additions and 88 deletions
+68 -6
View File
@@ -11,12 +11,11 @@ export default (app: Router) => {
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -31,6 +30,69 @@ export default (app: Router) => {
},
);
route.post(
'/refresh',
celebrate({
body: Joi.object({
type: Joi.string().valid('nodejs', 'python3').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const { job, accepted } = dependenceService.startRefresh(req.body.type);
if (!accepted) {
return res.status(409).send({
code: 409,
message: 'Another dependency refresh is already running',
});
}
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/active',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
res.set('Cache-Control', 'no-store');
return res.send({
code: 200,
data: dependenceService.getActiveRefreshJob() || null,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/:id',
celebrate({
params: Joi.object({ id: Joi.string().uuid().required() }),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const job = dependenceService.getRefreshJob(req.params.id);
if (!job) {
return res.status(404).send({
code: 404,
message: 'Dependency refresh job not found',
});
}
res.set('Cache-Control', 'no-store');
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
+2 -2
View File
@@ -27,7 +27,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow('').disallow('system'),
scopes: Joi.array().items(Joi.string().required()),
scopes: Joi.array().items(Joi.string().required().invalid('apps')),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -47,7 +47,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow(''),
scopes: Joi.array().items(Joi.string()),
scopes: Joi.array().items(Joi.string().invalid('apps')),
id: Joi.number().required(),
}),
}),
+19 -1
View File
@@ -24,7 +24,25 @@ export interface AppToken {
expiration: number;
}
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export type AppScope =
| 'crons'
| 'crons:read'
| 'subscriptions'
| 'subscriptions:read'
| 'envs'
| 'envs:read'
| 'configs'
| 'configs:read'
| 'scripts'
| 'scripts:read'
| 'logs'
| 'logs:read'
| 'dependencies'
| 'dependencies:read'
| 'dashboard'
| 'dashboard:read'
| 'system'
| 'apps';
export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', {
+37 -1
View File
@@ -112,7 +112,43 @@ export default ({ app }: { app: Application }) => {
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const isReadRequest =
(['GET', 'HEAD'].includes(req.method) &&
!(
key === 'crons' && /^\/open\/crons\/import\/?$/.test(pathLower)
)) ||
(['scripts', 'logs'].includes(key || '') &&
req.method === 'POST' &&
/^\/open\/(?:scripts|logs)\/download\/?$/.test(pathLower));
const hasNamespaceScope =
doc.scopes.includes(key as AppScope) ||
([
'crons',
'subscriptions',
'envs',
'configs',
'scripts',
'logs',
'dependencies',
'dashboard',
].includes(key || '') &&
doc.scopes.includes(`${key}:read` as AppScope) &&
isReadRequest);
if (!hasNamespaceScope) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
// The built-in system app still needs its local CLI maintenance API.
const appRouteAllowed =
key !== 'system' ||
doc.name === 'system' ||
((req.method === 'GET' || req.method === 'HEAD') &&
(pathLower === '/open/system' || pathLower === '/open/system/')) ||
(req.method === 'PUT' && pathLower === '/open/system/notify');
if (!appRouteAllowed) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
+3 -2
View File
@@ -222,6 +222,9 @@ export default async () => {
}
} catch { }
// Replace legacy task environment files before recovered tasks can run.
await envService.set_envs();
// 初始化保存一次ck和定时任务数据
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
@@ -229,8 +232,6 @@ export default async () => {
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
await shareStore.updateAuthInfo(authInfo);
await openService.refreshApps();
+76 -19
View File
@@ -27,9 +27,20 @@ import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
import { randomUUID } from 'crypto';
type RefreshType = 'nodejs' | 'python3';
type RefreshJob = {
id: string;
type: RefreshType;
state: 'running' | 'completed' | 'failed';
};
@Service()
export default class DependenceService {
private activeRefreshJob?: RefreshJob;
private refreshJobs = new Map<string, RefreshJob>();
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
@@ -118,11 +129,6 @@ 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];
}
@@ -145,6 +151,52 @@ export default class DependenceService {
}
}
public startRefresh(type: RefreshType): {
job: RefreshJob;
accepted: boolean;
} {
if (this.activeRefreshJob) {
return {
job: this.activeRefreshJob,
accepted: this.activeRefreshJob.type === type,
};
}
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
this.activeRefreshJob = job;
this.refreshJobs.set(job.id, job);
while (this.refreshJobs.size > 20) {
const oldestId = this.refreshJobs.keys().next().value;
this.refreshJobs.delete(oldestId);
}
void this.refreshInstalledStatuses(DependenceTypes[type])
.then(() => {
job.state = 'completed';
})
.catch((error) => {
this.logger.error('Dependency refresh failed: %o', error);
job.state = 'failed';
})
.finally(() => {
if (this.activeRefreshJob === job) {
this.activeRefreshJob = undefined;
}
});
return { job, accepted: true };
}
public getRefreshJob(id: string): RefreshJob | undefined {
return this.refreshJobs.get(id);
}
public getActiveRefreshJob(): RefreshJob | undefined {
return this.activeRefreshJob?.state === 'running'
? this.activeRefreshJob
: undefined;
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
@@ -160,26 +212,31 @@ export default class DependenceService {
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checkableDocs = docs.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
);
const checks = await concurrentRun(
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
for (const [index, installed] of (checks || []).entries()) {
if (installed) continue;
const doc = checkableDocs[index];
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
{
where: {
id: doc.id,
status: DependenceStatus.installed,
name: doc.name,
type: doc.type,
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
} as any,
},
);
}
}
+22 -3
View File
@@ -14,12 +14,17 @@ import {
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
import {
assertAllowedTaskEnvName,
isAllowedTaskEnvName,
} from '../shared/taskEnvName';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> {
payloads.forEach((payload) => assertAllowedTaskEnvName(payload.name));
const envs = await this.envs();
let position = initPosition;
if (
@@ -52,6 +57,7 @@ export default class EnvService {
public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload });
assertAllowedTaskEnvName(tab.name);
const newDoc = await this.updateDb(tab);
await this.set_envs();
return newDoc;
@@ -151,7 +157,10 @@ export default class EnvService {
['position', 'DESC'],
['createdAt', 'ASC'],
]);
return result;
return result.map((env) => ({
...env,
securityBlocked: !isAllowedTaskEnvName(env.name),
}));
} catch (error) {
throw error;
}
@@ -182,11 +191,14 @@ export default class EnvService {
}
public async enabled(ids: number[]) {
const docs = await EnvModel.findAll({ where: { id: ids } });
docs.forEach((doc) => assertAllowedTaskEnvName(doc.name));
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
assertAllowedTaskEnvName(name);
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}
@@ -242,6 +254,13 @@ export default class EnvService {
name: { [Op.not]: null },
status: EnvStatus.normal,
});
const invalid = envs.filter((env) => !isAllowedTaskEnvName(env.name));
if (invalid.length > 0) {
this.logger.warn(
'Skipped reserved environment variables in task files: %s',
JSON.stringify(invalid.map((env) => ({ id: env.id, name: env.name }))),
);
}
const groups = groupBy(envs, 'name');
let env_string = '';
let js_env_string = '';
@@ -250,8 +269,8 @@ export default class EnvService {
if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key];
// 忽略不符合bash要求的环境变量名称
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
// Ignore invalid and reserved names, including legacy database rows.
if (isAllowedTaskEnvName(key)) {
let value = group
.map((x) => x.value)
.join('&')
+1 -1
View File
@@ -8,6 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
return resolveFileAccess(config.logPath, [filePath || '', fileName], ['.tmp']);
}
}
+10
View File
@@ -92,6 +92,10 @@ export default class OpenService {
private async updateDb(payload: Partial<App>): Promise<App> {
return this.withAppsLock(async () => {
const target = await AppModel.findOne({ where: { id: payload.id } });
if (target?.name === 'system' || payload.name === 'system') {
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
}
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
@@ -109,6 +113,12 @@ export default class OpenService {
public async remove(ids: number[]) {
return this.withAppsLock(async () => {
const systemApp = await AppModel.findOne({
where: { id: ids, name: 'system' },
});
if (systemApp) {
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
}
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
+4 -1
View File
@@ -24,8 +24,11 @@ export function resolveFileAccess(
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
// File systems such as default APFS can resolve a differently cased spelling
// of the same entry. Check every protected name without case sensitivity.
const blockedNames = new Set(blacklist.map((part) => part.toLowerCase()));
const isBlocked = (relative: string) =>
relative.split(path.sep).some((part) => blacklist.includes(part));
relative.split(path.sep).some((part) => blockedNames.has(part.toLowerCase()));
if (isBlocked(path.relative(resolvedRoot, target))) return '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
+1
View File
@@ -23,6 +23,7 @@ const messages: Record<string, Record<string, string>> = {
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
环境变量名称不可使用: 'Environment variable name is reserved or invalid',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
+79
View File
@@ -0,0 +1,79 @@
import { t } from './i18n';
// These names control the task runner or the interpreters it starts. Values
// managed through the environment API must remain task data, not executable
// configuration.
const reservedNames = new Set([
'TASK_BEFORE',
'TASK_AFTER',
'WORK_DIR',
'TASK_SHELL_PARAMS',
'SCRIPT_PARAMS',
'WHICH_PROGRAM',
'ISJSORPYTHONFILE',
'EXPORTED_VARIABLES',
'FOUND_DOUBLE_DASH',
'LOG_DIR',
'LOG_PATH',
'ID',
'BEGIN_TIMESTAMP',
'TIME_FORMAT',
'MTIME_FORMAT',
'IS_MACOS',
'RANDOMDELAY',
'RANDOMDELAYFILEEXTENSIONS',
'RANDOMDELAYIGNOREDMINUTES',
'COMMAND_TIMEOUT_TIME',
'SET_U_ON',
'_TASK_EXIT_CODE',
'PATH',
'HOME',
'SHELL',
'ENV',
'BASH_ENV',
'BASHOPTS',
'SHELLOPTS',
'IFS',
'CDPATH',
'GLOBIGNORE',
'PROMPT_COMMAND',
'PS4',
'NODE_OPTIONS',
'NODE_PATH',
'PREV_NODE_OPTIONS',
'PREV_NODE_PATH',
'PREV_PYTHONPATH',
'GIT_SSH',
'GIT_SSH_COMMAND',
'GIT_ASKPASS',
'GIT_EXEC_PATH',
'GIT_TEMPLATE_DIR',
'SSH_ASKPASS',
]);
export function isAllowedTaskEnvName(name: unknown): name is string {
if (typeof name !== 'string' || !/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(name)) {
return false;
}
const upper = name.toUpperCase();
return (
!reservedNames.has(upper) &&
!upper.startsWith('BASH_') &&
!upper.startsWith('QL') &&
!upper.startsWith('FILE_') &&
!upper.startsWith('DIR_') &&
!upper.startsWith('LD_') &&
!upper.startsWith('DYLD_') &&
!upper.startsWith('PYTHON') &&
!upper.startsWith('GIT_CONFIG_')
);
}
export function assertAllowedTaskEnvName(name: unknown): asserts name is string {
if (!isAllowedTaskEnvName(name)) {
throw Object.assign(new Error(t('环境变量名称不可使用')), {
status: 400,
});
}
}
+2 -2
View File
@@ -48,7 +48,7 @@ The CLI also supports task/subscription CRUD, application and secret management,
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
@@ -68,7 +68,7 @@ Protected requests use `Authorization: Bearer <token>`. Two credential sources a
### Application login
Create a dedicated panel application with the required scopes, such as crons, subscriptions or envs. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
Create a dedicated panel application with the required scopes. Existing scopes retain their original permissions. For read-only access, use `crons:read`, `subscriptions:read`, `envs:read`, `configs:read`, `scripts:read`, `dependencies:read`, `logs:read`, or `dashboard:read`. Script and log downloads use POST but are available to their read-only scopes. `GET /crons/import` writes tasks and is excluded from `crons:read`. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
```sh
ql login --url https://ql.example.com
+2 -2
View File
@@ -48,7 +48,7 @@ ql auth logout --json
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
@@ -68,7 +68,7 @@ ql api request PUT /open/crons/run --data '[12,13]' --json
### 应用凭据登录
在面板创建专用应用,按需授予 crons、subscriptions、envs 等权限。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
在面板创建专用应用,按需授予 crons、subscriptions 等权限。旧 scope 保留原有权限;仅读取时可选用 `crons:read`、`subscriptions:read`、`envs:read`、`configs:read`、`scripts:read`、`dependencies:read`、`logs:read`、`dashboard:read`。脚本和日志下载虽使用 POST,也允许对应只读权限;`GET /crons/import` 会写入任务,不属于只读操作。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
```sh
ql login --url https://ql.example.com
+18 -5
View File
@@ -1,16 +1,18 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / Authentication and permissions
常规使用 `ql login` 的应用凭据。scope 是 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。应用管理需要 apps;user、health、update 等路由同样接受服务端权限校验。面板 UI 当前没有列出全部这些 scope,不能把路由存在理解成现有应用已获授权。
常规使用 `ql login` 的应用凭据。scope 通常对应 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。旧 scope 保留原有权限;上述资源除 system 外可选用对应的 `:read` scope。`crons:read` 不包含会写入任务的 `GET /crons/import`;`scripts:read`、`logs:read` 包含使用 POST 的下载,不包含写入、删除或执行。`dashboard:read` 不允许 POST 统计上报。外部应用的 `system` scope 仅允许读取系统状态和发送通知。应用管理需要 apps;`GET /apps` 会返回 Client Secret,因此不提供 `apps:read`。user、health、update 等路由同样接受服务端权限校验。路由存在不代表现有应用已获授权。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用 apps 权限的应用或授权面板会话;QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用已有 apps 权限的应用或授权面板会话;后端创建和更新应用时拒绝授予 apps 权限。QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts apps-scoped credentials or an authorized owner session. Direct tokens are not saved/refreshed and never fall back to application configuration.
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts existing apps-scoped credentials or an authorized owner session; the backend rejects granting apps scope when creating or updating an application. Direct tokens are not saved/refreshed and never fall back to application configuration.
Existing scopes retain their original access. Use the resource's `:read` scope for read-only access to crons, subscriptions, envs, configs, scripts, dependencies, logs, or dashboard. `crons:read` excludes the mutating `GET /crons/import`; `scripts:read` and `logs:read` include downloads (POST), but exclude writes, deletion, and execution. `dashboard:read` excludes statistics reporting (POST). External `system` applications may only read system status and send notifications. `GET /apps` returns Client Secrets, so there is no `apps:read` scope.
`auth status --scope <scope>` tests a representative read; update has no read endpoint and is not offered by status. This does not prove permission for every write. `app list/create/update/reset-secret` 默认隐藏 client_secret/tokens;只有明确需要凭据时才加 --show-secrets,并避免把输出记录到共享日志。其他资源/通用请求可能含环境值、配置、脚本、订阅凭据和会话,按敏感数据处理。
@@ -49,16 +51,24 @@ ql script get --query '{"file":"demo.js","path":""}' --json
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
# Requires the full configs scope or a panel session token.
ql config save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
ql dependency create --data '[{"name":"example-package","type":0}]' --json
ql dependency refresh --data '{"type":"python3"}' --json
ql dependency refresh-active --json
ql dependency refresh-status <JOB_UUID> --json
ql system data-export --data '{}' --output ./panel.tgz --json
ql system data-import --file ./panel.tgz --json
ql api request PUT /open/crons/run --data '[12,13]' --json
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
```
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
@@ -151,6 +161,9 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
+19
View File
@@ -5,6 +5,7 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -334,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency refresh',
method: 'POST',
path: 'dependencies/refresh',
body: 'json',
},
{
name: 'dependency refresh-active',
method: 'GET',
path: 'dependencies/refresh/active',
},
{
name: 'dependency refresh-status',
method: 'GET',
path: 'dependencies/refresh/:id',
params: ['id'],
uuidParams: ['id'],
},
{
name: 'dependency create',
method: 'POST',
+8
View File
@@ -107,6 +107,14 @@ export async function auth(
{
crons: 'crons?page=1&size=1',
configs: 'configs/files',
'envs:read': 'envs',
'configs:read': 'configs/files',
'scripts:read': 'scripts',
'crons:read': 'crons?page=1&size=1',
'subscriptions:read': 'subscriptions',
'dependencies:read': 'dependencies',
'logs:read': 'logs',
'dashboard:read': 'dashboard/overview',
dashboard: 'dashboard/overview',
} as Record<string, string>
)[scope] ?? scope,
+15 -3
View File
@@ -49,12 +49,19 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
}
}
const UUID_PATTERN =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
'^' +
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
).test(path),
);
if (!match)
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':')) integer(path.split('/')[index]!);
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
return match;
}
@@ -107,7 +115,11 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const value = integer(command.positionals[index]!);
const positional = command.positionals[index]!;
const isUuid = op.uuidParams?.includes(name);
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
const value = isUuid ? positional : integer(positional);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+8
View File
@@ -26,6 +26,14 @@ export const commands: readonly CommandSpec[] = [
default: 'crons',
choices: [
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
'envs:read',
'configs:read',
'scripts:read',
'crons:read',
'subscriptions:read',
'dependencies:read',
'logs:read',
'dashboard:read',
].filter((scope) => !['auth', 'update'].includes(scope)),
},
},
+4 -4
View File
@@ -28,12 +28,12 @@ const fs = require('node:fs/promises');
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
return JSON.parse(result.stdout).data;
};
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard', '--show-secrets'], true);
assert.ok(app.client_id && app.client_secret);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
cli(['auth', 'status', '--scope', 'apps']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
cli(['auth', 'status', '--scope', 'crons']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard'], true);
assert.ok(cli(['app', 'list'], true).every(item => !('client_secret' in item) && !('tokens' in item)));
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
+22
View File
@@ -81,6 +81,13 @@ async function fixture(t) {
code: 200,
data: { data: [{ id: 12, name: 'example' }], total: 1 },
});
else if (
['/panel/open/envs', '/panel/open/configs/files', '/panel/open/scripts',
'/panel/open/subscriptions', '/panel/open/dependencies',
'/panel/open/logs', '/panel/open/dashboard/overview']
.includes(url.pathname)
)
json({ code: 200, data: [] });
else if (
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
url.pathname,
@@ -155,6 +162,21 @@ test('compiled entry: login aliases, private storage, status, refresh and local
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
assert.doesNotMatch(status.out, /test-secret|test-token/);
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
for (const [scope, pathname] of [
['envs:read', '/panel/open/envs'],
['configs:read', '/panel/open/configs/files'],
['scripts:read', '/panel/open/scripts'],
['crons:read', '/panel/open/crons'],
['subscriptions:read', '/panel/open/subscriptions'],
['dependencies:read', '/panel/open/dependencies'],
['logs:read', '/panel/open/logs'],
['dashboard:read', '/panel/open/dashboard/overview'],
]) {
const checked = await f.invoke(['auth', 'status', '--scope', scope, '--json']);
assert.equal(checked.code, 0, checked.err);
assert.equal(JSON.parse(checked.out).data.scopeChecked, scope);
assert.equal(f.requests.at(-1).url.pathname, pathname);
}
const config = JSON.parse(fs.readFileSync(f.file));
config.expiration = 1;
fs.writeFileSync(f.file, JSON.stringify(config));
+24 -2
View File
@@ -83,6 +83,27 @@ test('script history commands send exact query parameters and restore payloads',
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('dependency refresh commands send the dependency type and address background jobs', async t => {
const f = await fixture(t);
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
assert.equal(f.requests[0].method, 'POST');
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
success(await f.run(['dependency', 'refresh-status', id]));
assert.equal(f.requests[1].method, 'GET');
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
success(await f.run(['dependency', 'refresh-active']));
assert.equal(f.requests[2].method, 'GET');
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
@@ -132,7 +153,8 @@ test('all added named operations reach their registered method/path, including u
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
for (const op of openOperations.filter(op => !op.existing)) {
const args = op.name.split(' ');
for (const _ of op.params ?? []) args.push('7');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -144,7 +166,7 @@ test('all added named operations reach their registered method/path, including u
success(await f.run(args));
const last = f.requests.at(-1);
assert.equal(last.method, op.method, op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
}
+15
View File
@@ -1,5 +1,9 @@
{
"选中条数": "Selected {count} items",
"重新检查依赖": "Recheck dependencies",
"依赖检查失败": "Dependency check failed",
"正在检查依赖": "Checking {type} dependencies",
"依赖检查状态已中断": "Dependency check status was lost. Please retry.",
"登录失败次数": "Failed {count} time(s)",
"间隔定时规则": "Every {value} {unit}",
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
@@ -112,6 +116,7 @@
"代理端口": "Proxy Port",
"以后再说": "Later",
"仪表盘": "Dashboard",
"仪表盘(只读)": "Dashboard (read only)",
"任务": "Task",
"任务数": "Tasks",
"任务未运行": "Task not running",
@@ -122,6 +127,7 @@
"使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key",
"依赖文件": "Dependency Files",
"依赖管理": "Dependency Management",
"依赖管理(只读)": "Dependencies (read only)",
"依赖类型": "Dependency Type",
"依赖缓存": "Dependency cache",
"依赖设置": "Dependence Settings",
@@ -188,6 +194,8 @@
"可用,你使用的版本为": "available, the version you are using is",
"同时删除关联任务和脚本": "Delete associated tasks and scripts as well",
"名称": "Name",
"名称受限": "Restricted name",
"名称受限,未注入任务": "Restricted name; not injected into tasks",
"名称不能为保留关键字": "The name cannot be a reserved keyword",
"吗": "?",
"否": "No",
@@ -228,6 +236,7 @@
"完成设置": "Finish Setup",
"定时": "Schedule",
"定时任务": "Scheduled Tasks",
"定时任务(只读)": "Scheduled Tasks (read only)",
"定时任务并发数": "Concurrent Scheduled Tasks",
"定时类型": "Schedule Type",
"定时规则": "Schedule Rule",
@@ -331,6 +340,7 @@
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators",
"日志文件": "Log files",
"日志管理": "Log Management",
"日志管理(只读)": "Logs (read only)",
"时": "hour(s)",
"时区": "Timezone",
"是": "Yes",
@@ -411,6 +421,7 @@
"版本": "Version",
"状态": "Status",
"环境变量": "Environment variable",
"环境变量(只读)": "Environment variables (read only)",
"生成数据中...": "Generating data...",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/",
@@ -494,6 +505,7 @@
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.",
"脚本文件": "Script files",
"脚本管理": "Script Management",
"脚本(只读)": "Scripts (read only)",
"自动删除任务": "Automatically Delete Tasks",
"自动拆分": "Auto Split",
"自动添加任务": "Automatically Add Tasks",
@@ -508,11 +520,13 @@
"视图管理": "View Management",
"订阅": "Subscription",
"订阅管理": "Subscription Management",
"订阅管理(只读)": "Subscriptions (read only)",
"设置": "Settings",
"访问密钥": "Access Key",
"语言": "Language",
"请": "Please",
"请先按如下方式修复:": "Please fix it as follows:",
"请先修改环境变量名称": "Rename the environment variable first",
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.",
"请求头Content-Type": "Request Header Content-Type",
"请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)",
@@ -606,6 +620,7 @@
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱认证地址": "Email address for SMTP authentication",
"配置文件": "Configuration Files",
"配置文件(只读)": "Configuration files (read only)",
"重启": "Restart",
"重命名": "Rename",
"重新下载": "Redownload",
+15
View File
@@ -1,5 +1,9 @@
{
"选中条数": "已选择 {count} 项",
"重新检查依赖": "重新检查依赖",
"依赖检查失败": "依赖检查失败",
"正在检查依赖": "正在检查 {type} 依赖",
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
"登录失败次数": "失败{count}次",
"间隔定时规则": "每{value}{unit}",
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
@@ -111,6 +115,7 @@
"代理端口": "代理端口",
"以后再说": "以后再说",
"仪表盘": "仪表盘",
"仪表盘(只读)": "仪表盘(只读)",
"任务": "任务",
"任务数": "任务数",
"任务未运行": "任务未运行",
@@ -121,6 +126,7 @@
"使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥",
"依赖文件": "依赖文件",
"依赖管理": "依赖管理",
"依赖管理(只读)": "依赖管理(只读)",
"依赖类型": "依赖类型",
"依赖缓存": "依赖缓存",
"依赖设置": "依赖设置",
@@ -187,6 +193,8 @@
"可用,你使用的版本为": "可用,你使用的版本为",
"同时删除关联任务和脚本": "同时删除关联任务和脚本",
"名称": "名称",
"名称受限": "名称受限",
"名称受限,未注入任务": "名称受限,未注入任务",
"名称不能为保留关键字": "名称不能为保留关键字",
"吗": "吗",
"否": "否",
@@ -227,6 +235,7 @@
"完成设置": "完成设置",
"定时": "定时",
"定时任务": "定时任务",
"定时任务(只读)": "定时任务(只读)",
"定时任务并发数": "定时任务并发数",
"定时类型": "定时类型",
"定时规则": "定时规则",
@@ -330,6 +339,7 @@
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符",
"日志文件": "日志文件",
"日志管理": "日志管理",
"日志管理(只读)": "日志管理(只读)",
"时": "时",
"时区": "时区",
"是": "是",
@@ -411,6 +421,7 @@
"版本": "版本",
"状态": "状态",
"环境变量": "环境变量",
"环境变量(只读)": "环境变量(只读)",
"生成数据中...": "生成数据中...",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/",
@@ -494,6 +505,7 @@
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径",
"脚本文件": "脚本文件",
"脚本管理": "脚本管理",
"脚本(只读)": "脚本(只读)",
"自动删除任务": "自动删除任务",
"自动拆分": "自动拆分",
"自动添加任务": "自动添加任务",
@@ -508,11 +520,13 @@
"视图管理": "视图管理",
"订阅": "订阅",
"订阅管理": "订阅管理",
"订阅管理(只读)": "订阅管理(只读)",
"设置": "设置",
"访问密钥": "访问密钥",
"语言": "语言",
"请": "请",
"请先按如下方式修复:": "请先按如下方式修复:",
"请先修改环境变量名称": "请先修改环境变量名称",
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置",
"请求头Content-Type": "请求头Content-Type",
"请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割",
@@ -606,6 +620,7 @@
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
"邮箱认证地址": "邮箱认证地址",
"配置文件": "配置文件",
"配置文件(只读)": "配置文件(只读)",
"重启": "重启",
"重命名": "重命名",
"重新下载": "重新下载",
+175 -10
View File
@@ -87,6 +87,7 @@ const StatusMap: Record<number, { icon: React.ReactNode; color: string }> = {
const Dependence = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const columns: any = [
{
title: intl.get('序号'),
@@ -106,6 +107,7 @@ const Dependence = () => {
key: 'status',
width: 120,
dataIndex: 'status',
filteredValue: statusFilter.length ? statusFilter : null,
filters: [
{
text: intl.get('队列中'),
@@ -233,6 +235,9 @@ const Dependence = () => {
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [refreshingType, setRefreshingType] = useState<string | null>(null);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedDependence, setEditedDependence] = useState();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
@@ -240,9 +245,26 @@ const Dependence = () => {
const [logDependence, setLogDependence] = useState<any>();
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [type, setType] = useState('nodejs');
const listQuery = useRef({ type, searchText, statusFilter });
listQuery.current = { type, searchText, statusFilter };
const tableRef = useRef<HTMLDivElement>(null);
const latestListRequest = useRef(0);
const refreshTimer = useRef<ReturnType<typeof setTimeout>>();
const activeRefreshId = useRef<string | null>(null);
const refreshRequestVersion = useRef(0);
const alive = useRef(true);
const [height, setHeight] = useState<number>(0);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
latestListRequest.current += 1;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
};
}, []);
useResizeObserver(tableRef, (entry) => {
const _height =
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
@@ -256,23 +278,146 @@ const Dependence = () => {
});
const getDependencies = (status?: number[]) => {
const query = listQuery.current;
const requestId = ++latestListRequest.current;
setLoading(true);
request
.get(
`${
config.apiPrefix
}dependencies?searchValue=${searchText}&type=${type}&status=${
status || ''
}`,
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
query.type
}&status=${status ?? query.statusFilter}`,
)
.then(({ code, data }) => {
if (code === 200) {
if (
alive.current &&
requestId === latestListRequest.current &&
code === 200
) {
setValue(data);
}
})
.finally(() => setLoading(false));
.finally(() => {
if (alive.current && requestId === latestListRequest.current) {
setLoading(false);
}
});
};
const scheduleRefresh = (callback: () => void, delay: number) => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(callback, delay);
};
const finishRefresh = (id: string, completed: boolean) => {
if (activeRefreshId.current !== id) return;
refreshRequestVersion.current += 1;
activeRefreshId.current = null;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (!completed) {
message.error(intl.get('依赖检查失败'));
}
};
const attachRefresh = (job: { id: string; type: string }) => {
if (!alive.current) return;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
activeRefreshId.current = job.id;
setRefreshing(true);
setRefreshingType(job.type);
scheduleRefresh(() => pollRefresh(job.id), 0);
};
const recoverRefresh = (
reloadWhenIdle = false,
retryOnError = false,
notifyWhenIdle = false,
) => {
const version = ++refreshRequestVersion.current;
request
.get(`${config.apiPrefix}dependencies/refresh/active`)
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200 && data) {
attachRefresh(data);
} else if (reloadWhenIdle) {
activeRefreshId.current = null;
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (notifyWhenIdle) message.warning(intl.get('依赖检查状态已中断'));
}
})
.catch(() => {
if (
alive.current &&
version === refreshRequestVersion.current &&
retryOnError
) {
scheduleRefresh(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(reloadWhenIdle, true, notifyWhenIdle);
}
}, 5000);
}
});
};
const pollRefresh = (id: string) => {
request
.get(`${config.apiPrefix}dependencies/refresh/${id}`)
.then(({ code, data }) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (code !== 200) {
scheduleRefresh(() => pollRefresh(id), 5000);
return;
}
if (data.state === 'running') {
scheduleRefresh(() => pollRefresh(id), 2000);
} else {
finishRefresh(id, data.state === 'completed');
}
})
.catch((error) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (error?.response?.status === 404) {
activeRefreshId.current = null;
recoverRefresh(true, true, true);
} else {
scheduleRefresh(() => pollRefresh(id), 5000);
}
});
};
const startRefresh = () => {
if (activeRefreshId.current) return;
const version = ++refreshRequestVersion.current;
setRefreshing(true);
setRefreshingType(type);
request
.post(`${config.apiPrefix}dependencies/refresh`, { type })
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200) {
attachRefresh(data);
} else {
recoverRefresh(true, true);
}
})
.catch(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(true, true);
}
});
};
useEffect(() => {
recoverRefresh(false, true);
}, []);
const addDependence = () => {
setEditedDependence(null as any);
setIsModalVisible(true);
@@ -450,12 +595,16 @@ const Dependence = () => {
};
const onSearch = (value: string) => {
setSearchText(value.trim());
const trimmed = value.trim();
if (trimmed !== searchText) {
latestListRequest.current += 1;
setSearchText(trimmed);
}
};
useEffect(() => {
getDependencies();
}, [searchText, type]);
}, [searchText, type, refreshGeneration]);
useEffect(() => {
if (logDependence) {
@@ -506,6 +655,7 @@ const Dependence = () => {
}, []);
const onTabChange = (activeKey: string) => {
latestListRequest.current += 1;
setSelectedRowIds([]);
setType(activeKey);
};
@@ -551,7 +701,9 @@ const Dependence = () => {
scroll={{ x: 768, y: height }}
loading={loading}
onChange={(pagination, filters) => {
getDependencies(filters?.status as number[]);
const status = (filters?.status as number[]) || [];
setStatusFilter(status);
getDependencies(status);
}}
/>
</DndProvider>
@@ -563,6 +715,19 @@ const Dependence = () => {
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
title={intl.get('依赖管理')}
extra={[
<Button
key="refresh"
icon={<SyncOutlined />}
loading={refreshing}
disabled={type === 'linux' || loading}
onClick={startRefresh}
>
{refreshingType
? intl.get('正在检查依赖', {
type: refreshingType === 'nodejs' ? 'Node' : 'Python',
})
: intl.get('重新检查依赖')}
</Button>,
<Search
placeholder={intl.get('请输入名称')}
style={{ width: 'auto' }}
+24 -2
View File
@@ -174,7 +174,7 @@ const Env = () => {
title: intl.get('状态'),
key: 'status',
dataIndex: 'status',
width: 100,
width: 170,
filters: [
{
text: intl.get('已启用'),
@@ -192,6 +192,13 @@ const Env = () => {
<Tag color={StatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(Status[record.status])}
</Tag>
{record.securityBlocked && (
<Tooltip title={intl.get('名称受限,未注入任务')}>
<Tag color="warning" style={{ marginRight: 0 }}>
{intl.get('名称受限')}
</Tag>
</Tooltip>
)}
</Space>
);
},
@@ -202,6 +209,8 @@ const Env = () => {
width: 160,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
const isBlockedEnable =
record.status === Status.已禁用 && record.securityBlocked;
return (
<Space size="middle">
<Tooltip title={isPc ? intl.get('编辑') : ''}>
@@ -212,7 +221,9 @@ const Env = () => {
<Tooltip
title={
isPc
? record.status === Status.已禁用
? isBlockedEnable
? intl.get('请先修改环境变量名称')
: record.status === Status.已禁用
? intl.get('启用')
: intl.get('禁用')
: ''
@@ -278,6 +289,10 @@ const Env = () => {
};
const enabledOrDisabledEnv = (record: any, index: number) => {
if (record.status === Status.已禁用 && record.securityBlocked) {
message.error(intl.get('请先修改环境变量名称'));
return;
}
Modal.confirm({
title: intl.get('确认操作', {
operation:
@@ -538,6 +553,13 @@ const Env = () => {
};
const operateEnvs = (operationStatus: number) => {
if (
operationStatus === 0 &&
value.some((env) => selectedRowIds.includes(env.id) && env.securityBlocked)
) {
message.error(intl.get('请先修改环境变量名称'));
return;
}
Modal.confirm({
title: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
+40
View File
@@ -20,30 +20,58 @@ export default {
name: intl.get('定时任务'),
value: 'crons',
},
{
name: intl.get('定时任务(只读)'),
value: 'crons:read',
},
{
name: intl.get('环境变量'),
value: 'envs',
},
{
name: intl.get('环境变量(只读)'),
value: 'envs:read',
},
{
name: intl.get('订阅管理'),
value: 'subscriptions',
},
{
name: intl.get('订阅管理(只读)'),
value: 'subscriptions:read',
},
{
name: intl.get('配置文件'),
value: 'configs',
},
{
name: intl.get('配置文件(只读)'),
value: 'configs:read',
},
{
name: intl.get('脚本管理'),
value: 'scripts',
},
{
name: intl.get('脚本(只读)'),
value: 'scripts:read',
},
{
name: intl.get('日志管理'),
value: 'logs',
},
{
name: intl.get('日志管理(只读)'),
value: 'logs:read',
},
{
name: intl.get('依赖管理'),
value: 'dependencies',
},
{
name: intl.get('依赖管理(只读)'),
value: 'dependencies:read',
},
{
name: intl.get('系统信息'),
value: 'system',
@@ -52,17 +80,29 @@ export default {
name: intl.get('仪表盘'),
value: 'dashboard',
},
{
name: intl.get('仪表盘(只读)'),
value: 'dashboard:read',
},
],
scopesMap: {
crons: intl.get('定时任务'),
'crons:read': intl.get('定时任务(只读)'),
envs: intl.get('环境变量'),
'envs:read': intl.get('环境变量(只读)'),
subscriptions: intl.get('订阅管理'),
'subscriptions:read': intl.get('订阅管理(只读)'),
configs: intl.get('配置文件'),
'configs:read': intl.get('配置文件(只读)'),
scripts: intl.get('脚本管理'),
'scripts:read': intl.get('脚本(只读)'),
logs: intl.get('日志管理'),
'logs:read': intl.get('日志管理(只读)'),
dependencies: intl.get('依赖管理'),
'dependencies:read': intl.get('依赖管理(只读)'),
system: intl.get('系统信息'),
dashboard: intl.get('仪表盘'),
'dashboard:read': intl.get('仪表盘(只读)'),
},
notificationModes: [
{ value: 'gotify', label: 'Gotify' },
@@ -43,6 +43,104 @@ test('environment values remain literal when the generated JavaScript loads', as
);
});
test('environment API rejects execution controls and legacy rows stay out of task files', async () => {
const output = {};
let writes = 0;
const warnings = [];
const legacy = [
{ id: 1, name: 'task_before', value: 'touch /tmp/untrusted' },
{ id: 2, name: 'NODE_OPTIONS', value: '--require /tmp/untrusted.js' },
{ id: 3, name: 'PREV_NODE_OPTIONS', value: '--import=data:text/javascript,bad' },
{ id: 4, name: 'GIT_SSH_COMMAND', value: 'sh -c bad' },
{ id: 5, name: 'PATH', value: '/tmp/untrusted' },
{ id: 6, name: 'file_task_before', value: '/tmp/untrusted.sh' },
{ id: 7, name: 'SAFE_VALUE', value: 'kept' },
{ id: 8, name: 'bad\nname', value: 'untrusted' },
{ id: 9, name: 'work_dir', value: '/tmp/untrusted' },
{ id: 10, name: 'task_shell_params', value: 'other.sh' },
{ id: 11, name: 'which_program', value: 'bash' },
{ id: 12, name: 'script_params', value: 'other.sh' },
{ id: 13, name: 'LOG_DIR', value: '../../config' },
{ id: 14, name: 'EXPORTED_VARIABLES', value: 'PATH' },
{ id: 15, name: 'begin_timestamp', value: 'a[$(printf BAD)]' },
{ id: 16, name: 'is_macos', value: 'a[$(printf BAD)]' },
{ id: 17, name: 'RandomDelayIgnoredMinutes', value: 'a[$(printf BAD)]' },
{ id: 18, name: 'ID', value: '999' },
{ id: 19, name: 'BASH_XTRACEFD', value: '2' },
];
const Env = load(path.resolve('back/services/env.ts'), {
typedi: decorators,
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
'../data/env': {
Env: class { constructor(value) { Object.assign(this, value); } },
EnvModel: {
update: async (value, query) => {
writes++;
},
findAll: async () => [legacy[0]],
},
EnvStatus: { normal: 0, disabled: 1 },
},
'../data': { sequelize: { literal: (value) => value } },
'../shared/utils': {
writeFileWithLock: async (file, value) => { output[file] = value; },
},
}).default;
const service = new Env({
...logger,
warn: (...args) => warnings.push(args),
});
service.envs = async () => legacy;
service.insert = async () => { writes++; return []; };
service.getDb = async () => ({ id: 1, name: 'SAFE_VALUE', value: 'kept' });
service.updateDb = async () => { writes++; return {}; };
for (const name of [
'task_before', 'PREV_NODE_OPTIONS', 'GIT_SSH_COMMAND', 'work_dir',
'task_shell_params', 'which_program', 'script_params', 'LOG_DIR',
'EXPORTED_VARIABLES',
'begin_timestamp', 'is_macos', 'RandomDelayIgnoredMinutes', 'ID',
'BASH_XTRACEFD',
]) {
await assert.rejects(
service.create([
{ name: 'SAFE_VALUE', value: 'ok' },
{ name, value: 'untrusted' },
]),
/环境变量名称不可使用/,
);
}
await assert.rejects(
service.update({ id: 1, name: 'BASH_ENV', value: '/tmp/untrusted.sh' }),
/环境变量名称不可使用/,
);
await assert.rejects(
service.updateNames({ ids: [1], name: 'PYTHONPATH' }),
/环境变量名称不可使用/,
);
assert.equal(writes, 0);
await service.set_envs();
assert.equal(writes, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0][1], /GIT_SSH_COMMAND/);
assert.equal(warnings[0][1].includes('\n'), false);
await assert.rejects(service.enabled([1]), /环境变量名称不可使用/);
assert.equal(writes, 0);
delete service.envs;
service.find = async () => legacy;
const listed = await service.envs();
assert.equal(listed[0].securityBlocked, true);
assert.equal(listed[6].securityBlocked, false);
for (const contents of Object.values(output)) {
assert.doesNotMatch(
contents,
/task_before|NODE_OPTIONS|GIT_SSH_COMMAND|PATH|work_dir|task_shell_params|which_program|script_params|LOG_DIR|EXPORTED_VARIABLES|begin_timestamp|is_macos|RandomDelayIgnoredMinutes|ID|BASH_XTRACEFD/,
);
assert.match(contents, /SAFE_VALUE/);
}
});
test('subscription shell parameters round trip without expansion or command substitution', () => {
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
const payload =
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const express = require('express');
const { errors } = require('celebrate');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
test('application API rejects granting apps scope on create and update', async (t) => {
const calls = [];
class OpenService {}
const service = {
create: async (body) => {
calls.push(['create', body]);
return body;
},
update: async (body) => {
calls.push(['update', body]);
return body;
},
};
const route = load(path.join(__dirname, '../../back/api/open.ts'), {
'../services/open': OpenService,
typedi: { Container: { get: () => service } },
}).default;
const app = express();
app.use(express.json());
for (const prefix of ['/api', '/open']) {
const router = express.Router();
route(router);
app.use(prefix, router);
}
app.use(errors());
const server = app.listen(0, '127.0.0.1');
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
const request = async (prefix, method, body) => {
const response = await fetch(
`http://127.0.0.1:${server.address().port}${prefix}/apps`,
{
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
);
return response.status;
};
for (const prefix of ['/api', '/open']) {
assert.equal(
await request(prefix, 'POST', { name: 'new', scopes: ['crons', 'apps'] }),
400,
);
assert.equal(
await request(prefix, 'PUT', { id: 1, scopes: ['apps'] }),
400,
);
assert.equal(
await request(prefix, 'POST', { name: 'new', scopes: ['crons:read'] }),
200,
);
assert.equal(
await request(prefix, 'PUT', { id: 1, scopes: ['crons'] }),
200,
);
}
assert.equal(calls.length, 4);
});
+93 -15
View File
@@ -1,7 +1,7 @@
const assert = require('node:assert/strict');
const test = require('node:test');
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
test('dependency listing uses stored status until explicitly refreshed', async (t) => {
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
@@ -23,24 +23,47 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
};
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 },
{
id: 1,
name: 'missing-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
{
id: 2,
name: 'present-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
];
const updates = [];
const probes = [];
const DependenceModel = {
findAll: async ({ where }) => {
if (where.status === DependenceStatus.installed) {
return docs;
return docs
.filter((doc) => doc.status === DependenceStatus.installed)
.map((doc) => ({ ...doc }));
}
return docs.map((doc) => ({
...doc,
status: updates.some((update) => update.ids.includes(doc.id))
? DependenceStatus.installFailed
: doc.status,
}));
return docs.map((doc) => ({ ...doc }));
},
update: async ({ status }, { where }) => {
updates.push({ status, ids: where.id });
const changed = docs.filter(
(doc) =>
doc.id === where.id &&
doc.status === where.status &&
doc.name === where.name &&
doc.type === where.type &&
doc.updatedAt.getTime() === where.updatedAt.getTime(),
);
for (const doc of changed) {
doc.status = status;
doc.updatedAt = new Date(doc.updatedAt.getTime() + 1000);
}
if (changed.length)
updates.push({ status, ids: changed.map((d) => d.id) });
},
};
@@ -65,8 +88,12 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
promiseExecSuccess: async (command) =>
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
promiseExecSuccess: async (command) => {
probes.push(command);
return command === 'check:present-package'
? 'present-package 1.0.0\n'
: '';
},
});
stubModule('../../back/config/const', {
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
@@ -104,15 +131,29 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
const DependenceService = require('../../back/services/dependence').default;
const service = new DependenceService({}, {});
const result = await service.dependencies({
const query = {
searchValue: '',
type: 'nodejs',
status: '',
});
};
const cached = await service.dependencies(query);
assert.deepEqual(updates, []);
assert.deepEqual(probes, []);
assert.deepEqual(
cached.map(({ status }) => status),
[1, 1],
);
const { job } = service.startRefresh('nodejs');
assert.equal(job.state, 'running');
await new Promise(setImmediate);
assert.equal(service.getRefreshJob(job.id).state, 'completed');
const result = await service.dependencies(query);
assert.deepEqual(updates, [
{ status: DependenceStatus.installFailed, ids: [1] },
]);
assert.deepEqual(probes, ['check:missing-package', 'check:present-package']);
assert.deepEqual(
result.map(({ id, status }) => ({ id, status })),
[
@@ -123,4 +164,41 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
assert.equal(updates.length, 1);
assert.equal(probes.length, 2);
let finishProbe;
service.isDependenceInstalled = async () => {
await new Promise((resolve) => {
finishProbe = resolve;
});
return false;
};
const racing = service.startRefresh('nodejs');
await new Promise(setImmediate);
docs[1].updatedAt = new Date(docs[1].updatedAt.getTime() + 1000);
finishProbe();
await new Promise(setImmediate);
assert.equal(racing.job.state, 'completed');
assert.equal(docs[1].status, DependenceStatus.installed);
assert.equal(updates.length, 1);
let finishRefresh;
service.refreshInstalledStatuses = () =>
new Promise((resolve) => {
finishRefresh = resolve;
});
const first = service.startRefresh('nodejs');
assert.equal(first.accepted, true);
assert.equal(service.getActiveRefreshJob().id, first.job.id);
assert.equal(service.startRefresh('nodejs').job.id, first.job.id);
assert.equal(service.startRefresh('python3').accepted, false);
finishRefresh();
await new Promise(setImmediate);
assert.equal(first.job.state, 'completed');
assert.equal(service.getActiveRefreshJob(), undefined);
const second = service.startRefresh('python3');
assert.equal(second.accepted, true);
finishRefresh();
await new Promise(setImmediate);
assert.equal(second.job.state, 'completed');
});
+62
View File
@@ -0,0 +1,62 @@
require('reflect-metadata');
const assert = require('node:assert/strict');
const test = require('node:test');
const { randomUUID } = require('node:crypto');
const express = require('express');
const { errors } = require('celebrate');
const { Container } = require('typedi');
const load = require('../helpers/load-security-module.cjs');
test('dependency refresh API starts one job and exposes its status', async (t) => {
const job = { id: randomUUID(), type: 'nodejs', state: 'running' };
class DependenceService {
startRefresh(type) {
return { job, accepted: type === job.type };
}
getRefreshJob(id) {
return id === job.id ? job : undefined;
}
getActiveRefreshJob() {
return job.state === 'running' ? job : undefined;
}
}
Container.set(DependenceService, new DependenceService());
t.after(() => Container.remove(DependenceService));
const api = load('back/api/dependence.ts', {
'../services/dependence': { __esModule: true, default: DependenceService },
});
const app = express();
app.use(express.json());
api.default(app);
app.use(errors());
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}/dependencies/refresh`;
const start = (type) =>
fetch(base, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type }),
});
const started = await start('nodejs');
assert.equal(started.status, 200);
assert.deepEqual((await started.json()).data, job);
assert.equal((await start('python3')).status, 409);
assert.equal((await start('linux')).status, 400);
const active = await fetch(`${base}/active`);
assert.equal(active.headers.get('cache-control'), 'no-store');
assert.deepEqual((await active.json()).data, job);
const running = await fetch(`${base}/${job.id}`);
assert.equal(running.headers.get('cache-control'), 'no-store');
assert.equal((await running.json()).data.state, 'running');
job.state = 'completed';
assert.equal((await (await fetch(`${base}/active`)).json()).data, null);
assert.equal(
(await (await fetch(`${base}/${job.id}`)).json()).data.state,
'completed',
);
assert.equal((await fetch(`${base}/${randomUUID()}`)).status, 404);
});
+20 -3
View File
@@ -35,8 +35,15 @@ function serviceFor(docs) {
(!where.id || where.id.includes(d.id)),
),
update: async (values, { where }) => {
const ids = Array.isArray(where.id) ? where.id : [where.id];
docs
.filter((d) => where.id.includes(d.id))
.filter(
(d) =>
ids.includes(d.id) &&
(where.status === undefined || d.status === where.status) &&
(where.updatedAt === undefined ||
d.updatedAt === where.updatedAt),
)
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
@@ -63,7 +70,7 @@ function serviceFor(docs) {
return { service, probes, removed, uninstalls };
}
test('listing preserves Python source results and still detects missing named packages', async () => {
test('explicit refresh preserves Python sources and detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
@@ -71,7 +78,13 @@ test('listing preserves Python source results and still detects missing named pa
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
].map((name, i) => ({
id: i + 1,
name,
type: 1,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
}));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
@@ -80,6 +93,10 @@ test('listing preserves Python source results and still detects missing named pa
status: '',
});
}
assert.deepEqual(probes, []);
const { job } = service.startRefresh('python3');
await new Promise(setImmediate);
assert.equal(job.state, 'completed');
assert.deepEqual(
docs.map((d) => d.status),
[1, 1, 1, 1, 1, 2],
+20
View File
@@ -115,7 +115,9 @@ for (const customData of [false, true]) {
);
for (const name of [
'token.json',
'TOKEN.JSON',
'grpc/client.key',
'Grpc/client.key',
'secret-link',
'data/scripts/secret-link',
'data/scripts/../config/token.json',
@@ -176,5 +178,23 @@ test('log APIs allow configuration-like names while retaining directory boundari
])
assert.equal((await invoke(method, route, values)).code, 403, filename);
}
const hidden = path.join(config.logPath, '.tmp', 'own_user.list');
fs.mkdirSync(path.dirname(hidden), { recursive: true });
fs.writeFileSync(hidden, 'hidden task command');
for (const values of [
{ path: '.tmp', file: 'own_user.list', filename: 'own_user.list' },
{ path: '.TMP', file: 'own_user.list', filename: 'own_user.list' },
{ path: '', file: '.tmp/own_user.list', filename: '.tmp/own_user.list' },
{ path: '', file: '.Tmp/own_user.list', filename: '.Tmp/own_user.list' },
]) {
for (const [method, route] of [
['get', '/detail'],
['post', '/download'],
['delete', '/'],
]) {
assert.equal((await invoke(method, route, values)).code, 403);
}
}
assert.equal(fs.readFileSync(hidden, 'utf8'), 'hidden task command');
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
});
+3
View File
@@ -24,11 +24,14 @@ test('file access enforces directory boundaries, blacklist descendants and symli
'../config-other/secret',
'/etc/passwd',
'grpc/client.key',
'GRPC/client.key',
'alias/client.key',
'outside/new.txt',
'dangling',
'auth.json',
'AUTH.JSON',
'nested/auth.json',
'nested/Auth.Json',
'',
]) {
assert.equal(
+250 -2
View File
@@ -13,6 +13,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
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');
fs.writeFileSync(path.join(tmp, 'config/config.sh'), 'original');
const secret = 'http-security-test';
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
@@ -51,6 +52,10 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['configs'],
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['configs:read'],
tokens: [{ value: 'config-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['configs', 'scripts'],
tokens: [
@@ -61,13 +66,55 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['scripts'],
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['scripts:read'],
tokens: [{ value: 'script-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs'],
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs:read'],
tokens: [{ value: 'env-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
name: 'external-system',
scopes: ['system'],
tokens: [{ value: 'system-app', expiration: Date.now() / 1000 + 3600 }],
},
{
name: 'system',
scopes: ['system'],
tokens: [{ value: 'internal-system', expiration: Date.now() / 1000 + 3600 }],
},
];
for (const scope of ['crons', 'subscriptions', 'dependencies', 'logs', 'dashboard']) {
for (const suffix of ['', ':read']) {
apps.push({
scopes: [`${scope}${suffix}`],
tokens: [{
value: `${scope}${suffix}-app`,
expiration: Date.now() / 1000 + 3600,
}],
});
}
}
let resetCalls = 0;
let commandCalls = 0;
let envWriteCalls = 0;
let scopedMutationCalls = 0;
let initialized = 0;
const User = class {};
const Script = class {
getFile(folder, filename) {
return fs.promises.readFile(path.join(config.scriptPath, folder, filename), 'utf8');
}
checkFilePath(folder, filename) {
return path.join(config.scriptPath, folder || '', filename);
}
};
const script = new Script();
const user = {
initializeUser: async () => {
initialized++;
@@ -90,7 +137,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
},
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../services/user': User,
'../services/script': class {},
'../services/script': Script,
'../data/open': {},
'../data/system': {},
typedi: { Service: () => (x) => x },
@@ -111,7 +158,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
mocks.typedi = {
...mocks.typedi,
Container: {
get: (x) => (x === User ? user : x === History ? history : configService),
get: (x) =>
x === User ? user : x === History ? history : x === Script ? script : configService,
},
};
mocks['../api'] = () => {
@@ -126,6 +174,41 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
historyCache,
).default(router);
router.get('/envs', (_req, res) => res.json({ code: 200 }));
router.post('/envs', (_req, res) => {
envWriteCalls++;
res.json({ code: 200 });
});
for (const route of [
'/crons', '/subscriptions', '/dependencies', '/logs', '/dashboard/overview',
]) {
router.get(route, (_req, res) => res.json({ code: 200 }));
}
for (const [method, route] of [
['put', '/crons/run'],
['put', '/subscriptions/run'],
['post', '/dependencies'],
['delete', '/logs'],
['post', '/dashboard/record'],
['get', '/crons/import'],
]) {
router[method](route, (_req, res) => {
scopedMutationCalls++;
res.json({ code: 200 });
});
}
router.post('/logs/download', (_req, res) =>
res.json({ code: 200, data: 'downloaded' }),
);
router.get('/system', (_req, res) => res.json({ code: 200 }));
router.put('/system/notify', (_req, res) => res.json({ code: 200 }));
router.put('/system/auth/reset', (_req, res) => {
resetCalls++;
res.json({ code: 200 });
});
router.put('/system/command-run', (_req, res) => {
commandCalls++;
res.json({ code: 200 });
});
return router;
};
const app = express();
@@ -203,6 +286,68 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
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/envs', 'env-read-app')).body.code, 200);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/envs`, 'env-read-app', 'POST', [
{ name: 'SAFE_VALUE', value: 'kept' },
])).status,
401,
);
}
assert.equal(envWriteCalls, 0);
assert.equal(
(await request('/open/envs', 'env-app', 'POST', [
{ name: 'SAFE_VALUE', value: 'kept' },
])).body.code,
200,
);
assert.equal(envWriteCalls, 1);
for (const [scope, readPath, method, writePath] of [
['crons', '/crons', 'PUT', '/crons/run'],
['subscriptions', '/subscriptions', 'PUT', '/subscriptions/run'],
['dependencies', '/dependencies', 'POST', '/dependencies'],
['logs', '/logs', 'DELETE', '/logs'],
['dashboard', '/dashboard/overview', 'POST', '/dashboard/record'],
]) {
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}${readPath}`, `${scope}:read-app`)).body.code,
200,
);
assert.equal(
(await request(`${prefix}${writePath}`, `${scope}:read-app`, method))
.status,
401,
);
}
assert.equal(
(await request(`/open${writePath}`, `${scope}-app`, method)).body.code,
200,
);
}
assert.equal(scopedMutationCalls, 5);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/crons/import`, 'crons:read-app')).status,
401,
);
assert.equal(
(await request(`${prefix}/logs/download`, 'logs:read-app', 'POST'))
.body.data,
'downloaded',
);
}
const importedWithHead = await fetch(
`http://127.0.0.1:${server.address().port}/open/crons/import`,
{ method: 'HEAD', headers: { Authorization: 'Bearer crons:read-app' } },
);
assert.equal(importedWithHead.status, 401);
assert.equal(
(await request('/open/crons/import', 'crons-app')).body.code,
200,
);
assert.equal(scopedMutationCalls, 6);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
401,
@@ -212,6 +357,58 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
.data,
'normal',
);
assert.equal(
(await request('/open/configs/detail?path=normal.txt', 'config-read-app'))
.body.data,
'normal',
);
for (const prefix of ['/open', '/panel/open']) {
for (const name of ['normal.txt', 'config.sh', 'task_before.sh']) {
assert.equal(
(await request(`${prefix}/configs/save`, 'config-read-app', 'POST', {
name,
content: 'untrusted',
})).status,
401,
);
}
}
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'original');
assert.equal((await request('/open/system', 'system-app')).body.code, 200);
assert.equal(
(await request('/open/system/notify', 'system-app', 'PUT', {
title: 'title',
content: 'content',
})).body.code,
200,
);
for (const prefix of ['/open', '/panel/open']) {
assert.equal(
(await request(`${prefix}/system/auth/reset`, 'system-app', 'PUT', {
password: 'changed',
})).status,
401,
);
assert.equal(
(await request(`${prefix}/system/command-run`, 'system-app', 'PUT', {
command: 'id',
})).status,
401,
);
}
assert.equal(resetCalls, 0);
assert.equal(commandCalls, 0);
assert.equal(
(await request('/open/system/auth/reset', 'internal-system', 'PUT', {
retries: 0,
})).body.code,
200,
);
assert.equal(
(await request('/api/system/auth/reset', valid, 'PUT', { retries: 0 })).body.code,
200,
);
assert.equal(resetCalls, 2);
assert.equal(
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
.body.code,
@@ -277,6 +474,14 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).body.code,
200,
);
assert.equal(
(await request('/api/configs/save', valid, 'POST', {
name: 'config.sh',
content: 'owner config',
})).body.code,
200,
);
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'owner config');
assert.equal(
(
await request('/api/configs/save', valid, 'POST', {
@@ -296,6 +501,15 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-read-app', 'POST', {
filename: 'test.js',
content: 'unauthorized',
})
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-app', 'POST', {
@@ -310,4 +524,38 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
'authorized script',
);
}
assert.equal(
(await request('/open/scripts/detail?file=test.js', 'script-read-app')).body
.data,
'authorized script',
);
const downloaded = await fetch(
`http://127.0.0.1:${server.address().port}/open/scripts/download`,
{
method: 'POST',
headers: {
Authorization: 'Bearer script-read-app',
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: 'test.js' }),
},
);
assert.equal(downloaded.status, 200);
assert.equal(await downloaded.text(), 'authorized script');
for (const method of ['PUT', 'DELETE']) {
assert.equal(
(await request('/open/scripts', 'script-read-app', method, {
filename: 'test.js', content: 'unauthorized',
})).status,
401,
);
}
for (const path of ['run', 'stop', 'rename', 'history/restore']) {
assert.equal(
(await request(`/open/scripts/${path}`, 'script-read-app', 'PUT', {
filename: 'test.js', content: 'unauthorized',
})).status,
401,
);
}
});
+10 -2
View File
@@ -24,8 +24,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
{ id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 },
]);
const systemConfig = { info: { lang: 'en-US' } };
const startupEvents = [];
const service = {
set_envs: async () => {},
set_envs: async () => { startupEvents.push('environment'); },
getAuthInfo: async () => ({}),
initializeSystemApp: async () => {},
refreshApps: async () => {},
@@ -70,7 +71,10 @@ test('startup closes interrupted instances without inventing exit codes or rewri
'../shared/i18n': { setLang() {} },
'./logger': {},
'../schedule/client': {
readiness: { configure() {}, recover: async () => {} },
readiness: {
configure() {},
recover: async () => { startupEvents.push('scheduler'); },
},
},
}).default;
// Dependency installation is unrelated to startup reconciliation.
@@ -82,6 +86,7 @@ test('startup closes interrupted instances without inventing exit codes or rewri
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(startupEvents, ['environment', 'scheduler']);
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
assert.equal(rows[0].status, 2);
assert.ok(
@@ -109,6 +114,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(startupEvents, [
'environment', 'scheduler', 'environment', 'scheduler',
]);
assert.deepEqual(
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
rows,
+16
View File
@@ -397,6 +397,22 @@ for (const mutation of ['resetSecret', 'remove', 'update', 'create']) {
});
}
test('application mutations cannot change or expose the built-in system app', async (t) => {
const state = appsFixture(t);
for (const change of [
() => state.service.resetSecret(1),
() => state.service.update({ id: 1, name: 'system', scopes: ['crons'] }),
() => state.service.update({ id: 2, name: 'system', scopes: ['crons'] }),
() => state.service.remove([1, 2]),
]) {
await assert.rejects(change(), (error) => error.status === 403);
}
assert.equal(state.apps().length, 2);
assert.equal(state.apps()[0].client_secret, 'system-secret');
assert.equal(state.apps()[0].tokens[0].value, 'system-token');
assert.deepEqual(state.cached(), state.apps());
});
test('system initialization preserves active tokens while migrating dashboard scope', async (t) => {
const state = appsFixture(t);
await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } });
+146
View File
@@ -0,0 +1,146 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const source = ts.createSourceFile(
'index.tsx',
fs.readFileSync(
path.join(__dirname, '../../src/pages/dependence/index.tsx'),
'utf8',
),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = new Set([
'scheduleRefresh',
'finishRefresh',
'attachRefresh',
'recoverRefresh',
'pollRefresh',
'startRefresh',
]);
const declarations = [];
function visit(node) {
if (ts.isVariableDeclaration(node) && names.has(node.name.getText(source))) {
declarations.push(`var ${node.getText(source)};`);
}
ts.forEachChild(node, visit);
}
visit(source);
function fixture() {
const requests = [];
const timers = new Map();
let timerId = 0;
const state = { refreshing: false, type: null, reloads: 0 };
const request = (url) => {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
requests.push({ url, resolve, reject });
return promise;
};
const context = {
config: { apiPrefix: '/api/' },
type: 'python3',
alive: { current: true },
activeRefreshId: { current: null },
refreshRequestVersion: { current: 0 },
refreshTimer: {},
request: { get: request, post: request },
setRefreshing: (value) => {
state.refreshing = value;
},
setRefreshingType: (value) => {
state.type = value;
},
setRefreshGeneration: () => {
state.reloads += 1;
},
message: { error() {}, warning() {} },
intl: { get: (value) => value },
setTimeout: (fn, delay) => {
timers.set(++timerId, { fn, delay });
return timerId;
},
clearTimeout: (id) => timers.delete(id),
};
vm.createContext(context);
vm.runInContext(
ts.transpile(declarations.join('\n'), { target: ts.ScriptTarget.ES2020 }),
context,
);
return { context, requests, timers, state };
}
const settle = () => new Promise(setImmediate);
const newJob = { id: 'new-job', type: 'python3' };
test('late recovery cannot replace a newly attached job or clear it as idle', async () => {
for (const data of [{ id: 'old-job', type: 'nodejs' }, null]) {
const f = fixture();
f.context.recoverRefresh(true, true);
f.context.attachRefresh(newJob);
f.requests[0].resolve({ code: 200, data });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
assert.equal(f.state.refreshing, true);
assert.equal(f.state.reloads, 0);
assert.equal([...f.timers.values()][0].delay, 0);
}
});
test('late recovery failure cannot replace the new job poll timer', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.attachRefresh(newJob);
f.requests[0].reject(new Error('offline'));
await settle();
assert.equal(f.timers.size, 1);
assert.equal([...f.timers.values()][0].delay, 0);
});
test('a queued recovery retry is invalidated when a new job starts', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.requests[0].reject(new Error('offline'));
await settle();
const retry = [...f.timers.values()][0].fn;
f.context.startRefresh();
retry();
assert.equal(f.requests.length, 2);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('starting a job invalidates recovery before the start response arrives', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.startRefresh();
f.requests[0].resolve({ code: 200, data: { id: 'old-job', type: 'nodejs' } });
await settle();
assert.equal(f.context.activeRefreshId.current, null);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('current recovery still attaches and polls the running job', async () => {
const f = fixture();
f.context.recoverRefresh();
f.requests[0].resolve({ code: 200, data: newJob });
await settle();
[...f.timers.values()][0].fn();
assert.equal(f.requests[1].url, '/api/dependencies/refresh/new-job');
f.requests[1].resolve({ code: 200, data: { ...newJob, state: 'completed' } });
await settle();
assert.equal(f.state.refreshing, false);
assert.equal(f.state.reloads, 1);
});