Compare commits

..

21 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] d04c621e6a Simplify baseUrl implementation using URL rewrite
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 15:07:12 +00:00
copilot-swe-agent[bot] e56bdc8e81 Apply code review suggestions: improve clarity and simplify logic
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:57:49 +00:00
copilot-swe-agent[bot] 60aab8f95d Add clarifying comments and improve code readability
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:55:32 +00:00
copilot-swe-agent[bot] 8bff4202c7 Fix path construction: use req.path directly for whitelist check
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:53:42 +00:00
copilot-swe-agent[bot] 0bae11def6 Address code review feedback: fix JWT regex and path construction
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:51:49 +00:00
copilot-swe-agent[bot] 10f19a5c1d Update websocket and frontend to support base URL
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:47:34 +00:00
copilot-swe-agent[bot] f5f9d464cb Fix whitelist check to use base-URL-aware paths
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:45:14 +00:00
copilot-swe-agent[bot] a38b876a74 Add QlBaseUrl support to backend routes
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 14:43:36 +00:00
copilot-swe-agent[bot] 2b875d634f Initial plan 2025-12-22 14:36:15 +00:00
Copilot c61d1aa828 Fix enum value 0 causing type filter to fail for NodeJS dependencies (#2869)
* Initial plan

* Fix: Prevent Python3 dependencies from appearing in NodeJs tab

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-15 18:21:14 +08:00
whyour 33fa3aca99 更新版本 2.20.0 2025-12-11 01:53:17 +08:00
whyour c772fc9527 修复脚本调试保存文件错误 2025-12-11 01:52:47 +08:00
whyour c5d2aa3aba 更新 pipeline 2025-12-10 00:34:35 +08:00
Copilot 02a05f06bd Add signature verification support for Feishu bot notifications (#2856)
* Initial plan

* Add signature verification support for Feishu bot notifications

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

* Add clarifying comments about Feishu signature algorithm

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

* Add i18n translations for larkSecret configuration field

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-27 01:10:04 +08:00
whyour 3b0f55caf4 修复任务实例默认值 2025-11-23 12:45:02 +08:00
Copilot 6a3dd4f83c Fix null log_name issue by omitting it from shell command when not set (#2849)
* Initial plan

* Fix null log_name handling in runSingle method

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

* Update cron.log_name before makeCommand to avoid passing null to shell

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

* Fix: Only pass log_name to shell when it has a value

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

* Fix uniqPath calculation in runSingle for null log_name

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

* Improve comment clarity in makeCommand

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

* Refactor: Move no_tee and ID to initial commandVariable declaration

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

* Refactor: Simplify uniqPath ternary expression

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-22 12:06:01 +08:00
whyour 177cd3de81 更新 docker 日志 2025-11-22 01:05:28 +08:00
Copilot d473c3ae88 Fix SSH global private key matching before subscription-specific keys (#2845)
* Initial plan

* Fix SSH global private key loading order by using zzz_ prefix

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

* Use tilde (~) prefix for global SSH config to ensure it loads last

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-21 01:53:58 +08:00
Copilot ee2fbe5335 Add global SSH key configuration in system settings (#2840)
* Initial plan

* Add backend support for global SSH keys

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

* Add frontend UI for global SSH keys management

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

* Add SshKeyModel to database initialization

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

* Add SSH config generation for global SSH keys

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

* Add internationalization support for SSH key management UI

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

* Simplify to single global SSH key in system settings

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-20 10:09:01 +08:00
Copilot 48abf44ceb feat: Support multiple concurrent login sessions per platform (#2816)
* Initial plan

* Implement multi-device login support - allow multiple concurrent sessions

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

* Address code review feedback - extract constants and utility functions

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

* Add validation and logging improvements based on code review

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

* Revert unnecessary file changes - keep only multi-device login feature files

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-19 00:18:29 +08:00
Copilot 03c7031a3c Fix task duplication: add single/multi-instance support with UI configuration and stop all running instances (#2837)
* Initial plan

* Stop running tasks before starting new scheduled instance

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

* Add multi-instance support and fix stop to kill all running instances

- Add allow_multiple_instances field to Crontab model (default: 0 for single instance)
- Add validation for new field in commonCronSchema
- Add getAllPids and killAllTasks utility functions
- Update stop method to kill ALL running instances of a task
- Update runCron to respect allow_multiple_instances config
- Backward compatible: defaults to single instance mode

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

* Add UI support for allow_multiple_instances configuration

- Add allow_multiple_instances field to ICrontab interface
- Add instance mode selector in task creation/edit modal
- Add translations for instance mode in Chinese and English
- Default to single instance mode for backward compatibility

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

* Add allow_multiple_instances column migration and optimize db.ts

- Add allow_multiple_instances column to Crontabs table migration
- Refactor migration code to use data-driven approach
- Replace 11 individual try-catch blocks with single loop
- Improve code maintainability and readability

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-19 00:10:27 +08:00
43 changed files with 659 additions and 1218 deletions
+19 -9
View File
@@ -129,6 +129,13 @@ jobs:
with:
cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
@@ -154,19 +161,13 @@ jobs:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
tags: |
type=schedule,pattern=nightly
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -216,6 +217,13 @@ jobs:
with:
cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
@@ -254,7 +262,9 @@ jobs:
context: .
file: ./docker/310.Dockerfile
push: true
tags: whyour/qinglong:python3.10
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
-3
View File
@@ -28,6 +28,3 @@ __pycache__
/shell/preload/notify.*
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
test_sandbox_integration.sh
data/scripts/test_*.js
data/scripts/test_*.py
-138
View File
@@ -1,138 +0,0 @@
# Security Fix Implementation Summary
## Issue
**Title**: 运行的脚本可以通过 fs 等模块修改/config/task_after.sh等文件达到监听、修改所有脚本代码
**Translation**: Scripts can modify /config/task_after.sh and other files through fs module to monitor and modify all script code
**Severity**: Critical - Allows arbitrary code injection into all scripts
## Root Cause
User scripts ran with unrestricted filesystem access, allowing them to:
1. Modify `task_after.sh` to inject code that runs after every script
2. Modify `task_before.sh` to inject code that runs before every script
3. Modify configuration files
4. Potentially compromise the entire system
## Solution
Implemented a filesystem sandbox that intercepts file operations and blocks unauthorized writes.
### Implementation Details
#### 1. Node.js Sandbox (`shell/preload/sandbox.js`)
- Wraps all fs module write methods (writeFile, appendFile, mkdir, unlink, etc.)
- Wraps fs.promises API
- Wraps fs.createWriteStream
- **Wraps child_process module** (spawn, exec, execSync, fork, etc.) to prevent subprocess bypass
- Automatically injects NODE_OPTIONS into subprocess environments
- Prevents module require bypass by wrapping Module.prototype.require
- Returns EACCES error with security message for blocked operations
#### 2. Python Sandbox (`shell/preload/sandbox.py`)
- Wraps builtins.open() for write modes ('w', 'a', 'x', '+')
- Wraps os module functions (remove, mkdir, rename, chmod, etc.)
- Wraps shutil operations (rmtree, copy, move, etc.)
- Wraps pathlib.Path methods (write_text, mkdir, unlink, etc.)
- **Wraps subprocess module** (Popen, run, call, check_call, etc.) to prevent subprocess bypass
- Automatically injects PYTHONPATH into subprocess environments
- Raises PermissionError with security message for blocked operations
#### 3. Integration
- Updated `shell/preload/sitecustomize.js` to load Node.js sandbox first
- Updated `shell/preload/sitecustomize.py` to load Python sandbox first
- Sandboxes are loaded before any user code executes
#### 4. Subprocess Protection
- Scripts cannot bypass the sandbox by spawning `node` or `python3` subprocesses
- All child processes automatically inherit the sandbox through environment variables
- Prevents common bypass attempts like `execSync('node malicious.js')`
### Protected Directories
Scripts CANNOT write to:
- `/back` - Backend application code
- `/src` - Frontend source code
- `/shell` - Shell scripts and utilities
- `/sample` - Sample configuration files
- `/node_modules` - Node.js dependencies
- `/data/config` - System configuration (task_after.sh, task_before.sh, config.sh, etc.)
- `/data/db` - Database files
### Allowed Directories
Scripts CAN write to:
- `/data/scripts` - User scripts directory
- `/data/log` - Log files
- `/data/repo` - Repository clones
- `/data/raw` - Raw data storage
- `/.tmp` - Temporary files
- `/tmp` - System temporary directory
### Configuration
- **Default**: Sandbox enabled
- **Disable**: Set `QL_DISABLE_SANDBOX=true` (not recommended)
## Testing
### Test Coverage
1. ✅ Node.js exploit blocked (exact exploit from issue)
2. ✅ Python exploit blocked
3. ✅ Allowed writes work correctly
4. ✅ Sandbox can be disabled
5. ✅ CodeQL security scan: 0 alerts
6. ✅ All filesystem operations tested (write, append, mkdir, unlink, rename, etc.)
### Verification
The exact exploit from the issue is now blocked:
```javascript
const fs = require("fs");
const path = require("path");
fs.writeFileSync(path.join(__dirname, "..", "..", 'config', 'task_after.sh'), `echo 123`);
// Returns: Error: EACCES: Security Error: Script attempted to writeFileSync protected path
```
## Security Impact
### Before Fix
- ❌ Scripts could modify any system file
- ❌ Malicious scripts could inject code into all other scripts
- ❌ System configuration could be compromised
- ❌ No isolation between scripts
### After Fix
- ✅ Scripts cannot modify system files
- ✅ Scripts cannot modify configuration files
- ✅ Each script is isolated from system files
- ✅ Legitimate operations still work
- ✅ Clear error messages for blocked operations
- ✅ Optional disable for advanced use cases
## Files Changed
1. `shell/preload/sandbox.js` - Node.js sandbox implementation (NEW)
2. `shell/preload/sandbox.py` - Python sandbox implementation (NEW)
3. `shell/preload/sitecustomize.js` - Load Node.js sandbox
4. `shell/preload/sitecustomize.py` - Load Python sandbox
5. `SECURITY.md` - Document sandbox feature
6. `README.md` - Add security features section
7. `README-en.md` - Add security features section (English)
8. `SANDBOX_TESTING.md` - Testing documentation (NEW)
9. `.gitignore` - Exclude test files
## Backwards Compatibility
- ✅ Existing scripts continue to work
- ✅ No breaking changes to API
- ✅ No changes to user workflow
- ✅ Can be disabled if needed
## Future Considerations
1. Consider adding more granular permissions
2. Consider sandboxing shell scripts (currently not needed as they run with limited scope)
3. Consider adding audit logging for blocked operations
4. Consider adding user-configurable protected/allowed paths
## Conclusion
The security vulnerability has been successfully fixed with comprehensive filesystem sandboxing. The implementation:
- Blocks the exact exploit from the issue
- Maintains backwards compatibility
- Has zero security alerts
- Is thoroughly tested
- Is well documented
- Can be disabled if needed
-12
View File
@@ -34,18 +34,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support system level notification
- Support dark mode
- Support cell phone operation
- Built-in script sandbox to prevent malicious scripts from modifying system files
## Security Features
Qinglong includes a built-in script sandbox mechanism that protects critical system files from being modified by user scripts:
- ✅ Automatically blocks write operations to configuration files (e.g., `task_after.sh`, `config.sh`)
- ✅ Protects system directories (shell, back, src, etc.) from tampering
- ✅ Supports Node.js and Python scripts
- ✅ Enabled by default, no additional configuration required
For more details, see [SECURITY.md](./SECURITY.md)
## Version
-12
View File
@@ -36,18 +36,6 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持系统级通知
- 支持暗黑模式
- 支持手机端操作
- 内置脚本沙箱,防止恶意脚本修改系统文件
## 安全特性
Qinglong 内置了脚本沙箱机制,保护系统关键文件不被用户脚本修改:
- ✅ 自动拦截对配置文件(如 `task_after.sh``config.sh`)的写入操作
- ✅ 保护系统目录(shell、back、src等)不被篡改
- ✅ 支持 Node.js 和 Python 脚本
- ✅ 默认启用,无需额外配置
详细信息请查看 [SECURITY.md](./SECURITY.md)
## 版本
-125
View File
@@ -1,125 +0,0 @@
# Filesystem Sandbox Testing
This document describes how to test the filesystem sandbox feature that protects Qinglong from malicious scripts.
## The Vulnerability (Before Fix)
The original issue demonstrated that a malicious script could modify critical system files:
```javascript
const fs = require("fs")
const path = require("path")
fs.writeFileSync(path.join(__dirname, "..", "..", 'config', 'task_after.sh'), `echo 123`)
```
This would allow the script to inject code that runs after every other script, compromising the entire system.
## The Fix
The sandbox intercepts filesystem operations and blocks writes to protected directories:
### Protected Directories
- `/back` - Backend code
- `/src` - Frontend code
- `/shell` - Shell scripts
- `/sample` - Sample files
- `/node_modules` - Dependencies
- `/data/config` - Configuration files (including task_after.sh, task_before.sh)
- `/data/db` - Database files
### Allowed Directories
- `/data/scripts` - User scripts
- `/data/log` - Logs
- `/data/repo` - Repositories
- `/data/raw` - Raw data
- `/.tmp` and `/tmp` - Temporary files
## Testing the Fix
### Quick Test
Run the exploit script to verify it's blocked:
```bash
cd /home/runner/work/qinglong/qinglong
export QL_DIR=$(pwd)
export QL_DATA_DIR=$(pwd)/data
# Try the exploit
cat > data/scripts/test_exploit.js << 'EOF'
const fs = require("fs");
const path = require("path");
try {
fs.writeFileSync(path.join(__dirname, "..", "..", 'config', 'task_after.sh'), `echo 123`);
console.log("❌ VULNERABILITY: Exploit succeeded!");
process.exit(1);
} catch (error) {
if (error.code === 'EACCES') {
console.log("✅ SECURE: Exploit blocked!");
process.exit(0);
}
throw error;
}
EOF
NODE_OPTIONS="-r ./shell/preload/sandbox.js" node data/scripts/test_exploit.js
```
Expected output: `✅ SECURE: Exploit blocked!`
### Comprehensive Testing
The repository includes comprehensive tests:
1. **Node.js Tests**: Verify that Node.js scripts cannot write to protected paths
2. **Python Tests**: Verify that Python scripts cannot write to protected paths
3. **Allowed Writes**: Verify that legitimate writes still work
4. **Disable Option**: Verify that the sandbox can be disabled when needed
## Configuration
### Enable Sandbox (Default)
The sandbox is enabled by default. No configuration needed.
### Disable Sandbox (Not Recommended)
To disable the sandbox:
```bash
export QL_DISABLE_SANDBOX=true
```
**Warning**: Disabling the sandbox removes all filesystem protections and allows scripts to modify any file, including critical system files.
## How It Works
### Node.js
- Loads `shell/preload/sandbox.js` before script execution
- Wraps `fs` module methods (writeFile, appendFile, mkdir, unlink, etc.)
- Checks paths before allowing write operations
- Returns EACCES error for protected paths
### Python
- Loads `shell/preload/sandbox.py` before script execution
- Wraps `builtins.open()` for write modes
- Wraps `os` module functions (remove, mkdir, rename, etc.)
- Wraps `shutil` operations (rmtree, copy, move, etc.)
- Wraps `pathlib.Path` methods (write_text, mkdir, unlink, etc.)
- Raises PermissionError for protected paths
## Security Impact
This fix prevents:
- ✅ Modification of task_before.sh and task_after.sh
- ✅ Modification of system scripts
- ✅ Modification of configuration files
- ✅ Injection of code into other scripts
- ✅ Compromise of the entire Qinglong installation
Scripts can still:
- ✅ Read any files (read-only access)
- ✅ Write to their own directory (/data/scripts)
- ✅ Write logs
- ✅ Write to temporary directories
- ✅ Perform all legitimate operations
-52
View File
@@ -3,55 +3,3 @@
To report a vulnerability, please open a private vulnerability report at <https://github.com/whyour/qinglong/security>.
While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible.
## Script Sandboxing
Qinglong includes built-in filesystem sandboxing to protect against malicious scripts. Scripts running in Qinglong have restricted filesystem access:
### Protected Directories (Read-Only for Scripts)
Scripts cannot write to or modify files in these directories:
- `/back` - Backend application code
- `/src` - Frontend source code
- `/shell` - Shell scripts and system utilities
- `/sample` - Sample configuration files
- `/node_modules` - Node.js dependencies
- `/data/config` - System configuration files (including `task_before.sh`, `task_after.sh`, `config.sh`, etc.)
- `/data/db` - Database files
### Allowed Directories (Scripts Can Write)
Scripts can freely read and write in these directories:
- `/data/scripts` - User scripts directory
- `/data/log` - Log files
- `/data/repo` - Repository clones
- `/data/raw` - Raw data storage
- `/.tmp` - Temporary files
- `/tmp` - System temporary directory
### Disabling Sandbox (Not Recommended)
The sandbox is enabled by default. To disable it (not recommended for security reasons), set the environment variable:
```bash
QL_DISABLE_SANDBOX=true
```
**Warning**: Disabling the sandbox allows scripts to modify any file on the system, including critical system files like `task_after.sh`, which could compromise the entire Qinglong installation.
### How It Works
The sandbox works by intercepting filesystem operations and subprocess executions in Node.js and Python scripts:
- **Node.js**:
- Wraps the `fs` module and its methods (`writeFile`, `appendFile`, `mkdir`, `rmdir`, `unlink`, etc.)
- Wraps the `child_process` module (spawn, exec, execSync, etc.) to prevent sandbox bypass via subprocesses
- Automatically injects NODE_OPTIONS into all spawned subprocesses
- **Python**:
- Wraps `builtins.open()`, `os` module functions, `shutil` operations, and `pathlib.Path` methods
- Wraps `subprocess` module functions (Popen, run, call, etc.) to prevent sandbox bypass
- Automatically injects PYTHONPATH into all spawned subprocesses
When a script attempts to write to a protected path, the operation is blocked with a `PermissionError` (Python) or `EACCES` error (Node.js).
**Subprocess Protection**: The sandbox also prevents scripts from bypassing restrictions by spawning `node` or `python3` subprocesses. All spawned subprocesses automatically inherit the sandbox, ensuring consistent protection.
+2
View File
@@ -129,6 +129,7 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -175,6 +176,7 @@ export default (app: Router) => {
path,
`${originFilename.replace(/\//g, '')}`,
);
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath);
if (fileExists) {
+18
View File
@@ -426,6 +426,24 @@ export default (app: Router) => {
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
+3 -2
View File
@@ -8,7 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config';
import { isDemoEnv } from '../config/util';
import { isDemoEnv, getToken } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
@@ -56,7 +56,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
await userService.logout(req.platform);
const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 });
} catch (e) {
return next(e);
+15
View File
@@ -64,6 +64,19 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
@@ -116,6 +129,7 @@ if (envFound.error) {
export default {
...config,
jwt: config.jwt,
baseUrl,
rootPath,
tmpPath,
dataPath,
@@ -176,4 +190,5 @@ export default {
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
+21
View File
@@ -417,6 +417,27 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
+3
View File
@@ -22,6 +22,7 @@ export class Crontab {
task_before?: string;
task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
constructor(options: Crontab) {
this.name = options.name;
@@ -47,6 +48,7 @@ export class Crontab {
this.task_before = options.task_before;
this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
}
}
@@ -87,4 +89,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
});
+1
View File
@@ -142,6 +142,7 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo {
public larkKey = '';
public larkSecret = '';
}
export class NtfyNotification extends NotificationBaseInfo {
+15 -1
View File
@@ -38,6 +38,7 @@ export interface SystemConfigInfo {
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
@@ -48,6 +49,19 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
@@ -58,7 +72,7 @@ export interface AuthInfo {
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string>;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
+32 -45
View File
@@ -19,51 +19,38 @@ export default async () => {
await CrontabViewModel.sync();
// 初始化新增字段
try {
await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column proxy VARCHAR(255)',
);
} catch (error) {}
try {
await sequelize.query('alter table CrontabViews add column type NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoAddCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoDelCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column log_name VARCHAR(255)',
);
} catch (error) { }
try {
await sequelize.query('alter table Envs add column isPinned NUMBER');
} catch (error) {}
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
];
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
Logger.info('✌️ DB loaded');
} catch (error) {
+10 -5
View File
@@ -9,11 +9,19 @@ import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback');
app.use(cors());
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
@@ -77,11 +85,8 @@ export default ({ app }: { app: Application }) => {
}
const authInfo = await shareStore.getAuthInfo();
if (authInfo && headerToken) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
+7
View File
@@ -2,6 +2,7 @@ import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
@@ -10,6 +11,7 @@ export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `ts-node-transpile-only ${join(
@@ -57,6 +59,11 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
await subscriptionService.setSshConfig();
+11 -12
View File
@@ -4,6 +4,7 @@ import { Container } from 'typedi';
import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
@@ -17,21 +18,19 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (authInfo) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) {
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('close', function () {
sockService.removeClient(conn);
});
conn.on('data', (message) => {
conn.write(message);
});
return;
}
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
}
conn.close('404');
+26 -11
View File
@@ -9,6 +9,7 @@ import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
@@ -28,7 +29,7 @@ import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { }
constructor(@Inject('logger') private logger: winston.Logger) {}
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
@@ -57,7 +58,9 @@ export default class CronService {
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name);
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
@@ -162,7 +165,7 @@ export default class CronService {
let cron;
try {
cron = await this.getDb({ id });
} catch (err) { }
} catch (err) {}
if (!cron) {
continue;
}
@@ -462,12 +465,17 @@ export default class CronService {
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
if (doc.pid) {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
}
// Kill all running instances of this task
try {
const command = this.makeCommand(doc);
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
}
}
@@ -498,7 +506,10 @@ export default class CronService {
let { id, command, log_name } = cron;
const uniqPath = log_name === '/dev/null' ? (await getUniqPath(command, `${id}`)) : log_name;
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
@@ -630,7 +641,11 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
let commandVariable = `real_time=${Boolean(realTime)} log_name=${tab.log_name} no_tee=true ID=${tab.id} `;
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''")
+1 -1
View File
@@ -107,7 +107,7 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
if (DependenceTypes[type]) {
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
if (status) {
+19 -5
View File
@@ -550,19 +550,33 @@ export default class NotificationService {
}
private async lark() {
let { larkKey } = this.params;
let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
}
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try {
const res = await httpClient.post(larkKey, {
...this.gotOption,
json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
json: body,
headers: { 'Content-Type': 'application/json' },
});
if (res.StatusCode === 0 || res.code === 0) {
+28
View File
@@ -131,4 +131,32 @@ export default class SshKeyService {
}
}
}
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
}
+21
View File
@@ -530,6 +530,27 @@ export default class SystemService {
}
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
+139 -7
View File
@@ -11,6 +11,7 @@ import {
SystemModelInfo,
LoginStatus,
AuthInfo,
TokenInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
@@ -101,12 +102,23 @@ export default class UserService {
algorithm: 'HS384',
});
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
token,
tokens: {
...tokens,
[req.platform]: token,
},
tokens: updatedTokens,
lastlogon: timestamp,
retries: 0,
lastip: ip,
@@ -180,11 +192,37 @@ export default class UserService {
}
}
public async logout(platform: string): Promise<any> {
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: '',
tokens: { ...authInfo.tokens, [platform]: '' },
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
});
}
@@ -364,6 +402,100 @@ export default class UserService {
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo();
+46
View File
@@ -0,0 +1,46 @@
import { AuthInfo, TokenInfo } from '../data/system';
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
+35
View File
@@ -2,10 +2,45 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
+6 -1
View File
@@ -64,7 +64,11 @@ export const commonCronSchema = {
return value;
}
if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
@@ -77,4 +81,5 @@ export const commonCronSchema = {
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
};
+5 -5
View File
@@ -23,11 +23,11 @@ log_with_style() {
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 已配置 DNS 解析优化 (ndots:0)"
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
log_with_style "INFO" "🚀 1. 检测配置文件..."
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
@@ -41,16 +41,16 @@ log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null
+18 -2
View File
@@ -52,6 +52,7 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg
@@ -989,11 +990,26 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) {
return new Promise((resolve) => {
const { FSKEY } = push_config;
const { FSKEY, FSSECRET } = push_config;
if (FSKEY) {
const body = { msg_type: 'text', content: { text: `${text}\n\n${desp}` } };
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (FSSECRET) {
const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${FSSECRET}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } },
json: body,
headers: {
'Content-Type': 'application/json',
},
+15
View File
@@ -49,6 +49,7 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -233,6 +234,20 @@ def feishu_bot(title: str, content: str) -> None:
url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
# Add signature if secret is provided
# Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
# and signs an empty message, which differs from typical HMAC usage
if push_config.get("FSSECRET"):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}'
hmac_code = hmac.new(
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode("utf-8")
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0:
-341
View File
@@ -1,341 +0,0 @@
const Module = require('module');
const path = require('path');
const fs = require('fs');
// Get the QL_DIR and data directory paths
const qlDir = process.env.QL_DIR || path.join(__dirname, '../../');
let dataDir = process.env.QL_DATA_DIR || path.join(qlDir, 'data');
// Remove trailing slash if present
dataDir = dataDir.replace(/\/$/, '');
// Normalize paths to avoid bypassing with relative paths or symlinks
const normalizedQlDir = fs.existsSync(qlDir) ? fs.realpathSync(qlDir) : path.resolve(qlDir);
const normalizedDataDir = fs.existsSync(dataDir) ? fs.realpathSync(dataDir) : path.resolve(dataDir);
// Protected directories - no write access allowed
const protectedPaths = [
path.join(normalizedQlDir, 'back'),
path.join(normalizedQlDir, 'src'),
path.join(normalizedQlDir, 'shell'),
path.join(normalizedQlDir, 'sample'),
path.join(normalizedQlDir, 'node_modules'),
path.join(normalizedDataDir, 'config'),
path.join(normalizedDataDir, 'db'),
];
// Allowed write directories - scripts can write here
const allowedWritePaths = [
path.join(normalizedDataDir, 'scripts'),
path.join(normalizedDataDir, 'log'),
path.join(normalizedDataDir, 'repo'),
path.join(normalizedDataDir, 'raw'),
path.join(normalizedQlDir, '.tmp'),
'/tmp',
];
// Check if sandboxing is enabled (default: true)
const sandboxEnabled = process.env.QL_DISABLE_SANDBOX !== 'true';
function isPathProtected(targetPath) {
if (!sandboxEnabled) {
return false;
}
try {
// Resolve to absolute path and follow symlinks
const resolvedPath = fs.realpathSync.native ?
fs.realpathSync.native(targetPath) :
path.resolve(targetPath);
// Check if path is in a protected directory
for (const protectedPath of protectedPaths) {
if (resolvedPath.startsWith(protectedPath + path.sep) || resolvedPath === protectedPath) {
// Check if it's in an allowed subdirectory
const isInAllowedPath = allowedWritePaths.some(allowedPath =>
resolvedPath.startsWith(allowedPath + path.sep) || resolvedPath === allowedPath
);
if (!isInAllowedPath) {
return true;
}
}
}
// Also check if trying to write outside data/scripts without being in allowed paths
const isInQlDir = resolvedPath.startsWith(normalizedQlDir + path.sep) || resolvedPath === normalizedQlDir;
const isInDataDir = resolvedPath.startsWith(normalizedDataDir + path.sep) || resolvedPath === normalizedDataDir;
if (isInQlDir || isInDataDir) {
const isInAllowedPath = allowedWritePaths.some(allowedPath =>
resolvedPath.startsWith(allowedPath + path.sep) || resolvedPath === allowedPath
);
if (!isInAllowedPath) {
return true;
}
}
return false;
} catch (err) {
// If path doesn't exist yet, check parent directory
const parentPath = path.dirname(targetPath);
if (parentPath !== targetPath) {
return isPathProtected(parentPath);
}
return false;
}
}
function createSecurityError(operation, targetPath) {
const err = new Error(
`Security Error: Script attempted to ${operation} protected path: ${targetPath}\n` +
`Scripts are only allowed to write to: ${allowedWritePaths.join(', ')}`
);
err.code = 'EACCES';
return err;
}
// Store original fs methods
const originalFS = {};
const writeOperations = [
'writeFile', 'writeFileSync',
'appendFile', 'appendFileSync',
'mkdir', 'mkdirSync',
'rmdir', 'rmdirSync',
'unlink', 'unlinkSync',
'rm', 'rmSync',
'rename', 'renameSync',
'copyFile', 'copyFileSync',
'chmod', 'chmodSync',
'chown', 'chownSync',
'link', 'linkSync',
'symlink', 'symlinkSync',
'truncate', 'truncateSync',
'utimes', 'utimesSync',
];
// Wrap fs methods
for (const method of writeOperations) {
if (fs[method]) {
originalFS[method] = fs[method];
}
}
function wrapFsMethod(method, isSync) {
return function(...args) {
const targetPath = args[0];
if (isPathProtected(targetPath)) {
const err = createSecurityError(method, targetPath);
if (isSync) {
throw err;
} else {
const callback = args[args.length - 1];
if (typeof callback === 'function') {
process.nextTick(() => callback(err));
return;
}
throw err;
}
}
// For rename/copy operations, check destination too
if ((method.startsWith('rename') || method.startsWith('copy')) && args[1]) {
if (isPathProtected(args[1])) {
const err = createSecurityError(method, args[1]);
if (isSync) {
throw err;
} else {
const callback = args[args.length - 1];
if (typeof callback === 'function') {
process.nextTick(() => callback(err));
return;
}
throw err;
}
}
}
return originalFS[method].apply(fs, args);
};
}
// Apply wrappers
if (sandboxEnabled) {
for (const method of writeOperations) {
if (fs[method]) {
const isSync = method.endsWith('Sync');
fs[method] = wrapFsMethod(method, isSync);
}
}
// Wrap createWriteStream
originalFS.createWriteStream = fs.createWriteStream;
fs.createWriteStream = function(targetPath, options) {
if (isPathProtected(targetPath)) {
throw createSecurityError('createWriteStream', targetPath);
}
return originalFS.createWriteStream.call(fs, targetPath, options);
};
// Wrap promises API if it exists
if (fs.promises) {
const promisesOriginal = {};
const promisesMethods = [
'writeFile', 'appendFile', 'mkdir', 'rmdir', 'unlink', 'rm',
'rename', 'copyFile', 'chmod', 'chown', 'link', 'symlink',
'truncate', 'utimes',
];
for (const method of promisesMethods) {
if (fs.promises[method]) {
promisesOriginal[method] = fs.promises[method];
fs.promises[method] = async function(...args) {
const targetPath = args[0];
if (isPathProtected(targetPath)) {
throw createSecurityError(method, targetPath);
}
// For rename/copy operations, check destination too
if ((method === 'rename' || method === 'copyFile') && args[1]) {
if (isPathProtected(args[1])) {
throw createSecurityError(method, args[1]);
}
}
return promisesOriginal[method].apply(fs.promises, args);
};
}
}
}
}
// Wrap child_process to prevent sandbox bypass via subprocesses
let childProcessWrapped = false;
if (sandboxEnabled) {
// We need to get child_process before wrapping Module.prototype.require
const childProcess = require('child_process');
const originalSpawn = childProcess.spawn;
const originalExec = childProcess.exec;
const originalExecSync = childProcess.execSync;
const originalExecFile = childProcess.execFile;
const originalExecFileSync = childProcess.execFileSync;
const originalFork = childProcess.fork;
// Helper to ensure NODE_OPTIONS and PYTHONPATH are set for subprocesses
function ensureSandboxEnv(options = {}) {
const env = { ...process.env, ...options.env };
// Ensure NODE_OPTIONS includes the sandbox
const sandboxPreload = path.join(__dirname, 'sandbox.js');
if (!env.NODE_OPTIONS) {
env.NODE_OPTIONS = '';
}
if (!env.NODE_OPTIONS.includes(sandboxPreload)) {
env.NODE_OPTIONS = `-r ${sandboxPreload} ${env.NODE_OPTIONS}`.trim();
}
// Ensure PYTHONPATH includes the sandbox directory
if (!env.PYTHONPATH) {
env.PYTHONPATH = '';
}
if (!env.PYTHONPATH.includes(__dirname)) {
env.PYTHONPATH = `${__dirname}:${env.PYTHONPATH}`;
}
return { ...options, env };
}
// Wrap spawn
childProcess.spawn = function(...args) {
if (args[2]) {
args[2] = ensureSandboxEnv(args[2]);
} else if (args.length >= 3) {
args[2] = ensureSandboxEnv({});
}
return originalSpawn.apply(childProcess, args);
};
// Wrap exec
childProcess.exec = function(...args) {
const callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : undefined;
const optionsIndex = callback ? args.length - 2 : args.length - 1;
if (args[optionsIndex] && typeof args[optionsIndex] === 'object') {
args[optionsIndex] = ensureSandboxEnv(args[optionsIndex]);
} else if (optionsIndex > 0) {
args.splice(optionsIndex, 0, ensureSandboxEnv({}));
}
return originalExec.apply(childProcess, args);
};
// Wrap execSync
childProcess.execSync = function(...args) {
if (args[1]) {
args[1] = ensureSandboxEnv(args[1]);
} else {
args[1] = ensureSandboxEnv({});
}
return originalExecSync.apply(childProcess, args);
};
// Wrap execFile
childProcess.execFile = function(...args) {
const callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : undefined;
const optionsIndex = callback ? args.length - 2 : args.length - 1;
if (args[optionsIndex] && typeof args[optionsIndex] === 'object') {
args[optionsIndex] = ensureSandboxEnv(args[optionsIndex]);
} else if (optionsIndex > 1) {
args.splice(optionsIndex, 0, ensureSandboxEnv({}));
}
return originalExecFile.apply(childProcess, args);
};
// Wrap execFileSync
childProcess.execFileSync = function(...args) {
if (args[2]) {
args[2] = ensureSandboxEnv(args[2]);
} else if (args.length >= 3) {
args[2] = ensureSandboxEnv({});
}
return originalExecFileSync.apply(childProcess, args);
};
// Wrap fork
childProcess.fork = function(...args) {
if (args[2]) {
args[2] = ensureSandboxEnv(args[2]);
} else if (args.length >= 3) {
args[2] = ensureSandboxEnv({});
}
return originalFork.apply(childProcess, args);
};
childProcessWrapped = true;
}
// Prevent requiring the original fs or child_process modules to bypass sandbox
const originalRequire = Module.prototype.require;
Module.prototype.require = function(id) {
const module = originalRequire.apply(this, arguments);
// Return wrapped fs module
if (id === 'fs' || id === 'node:fs') {
return fs;
}
// For child_process, we already wrapped it above, so just return it
// (no need to re-require as that would cause recursion)
return module;
};
module.exports = {
sandboxEnabled,
isPathProtected,
protectedPaths,
allowedWritePaths,
};
-408
View File
@@ -1,408 +0,0 @@
import os
import sys
import builtins
from pathlib import Path
# Get the QL_DIR and data directory paths
ql_dir = os.environ.get('QL_DIR', os.path.join(os.path.dirname(__file__), '../..'))
data_dir = os.environ.get('QL_DATA_DIR', os.path.join(ql_dir, 'data'))
# Normalize paths to avoid bypassing with relative paths or symlinks
try:
normalized_ql_dir = os.path.realpath(ql_dir)
normalized_data_dir = os.path.realpath(data_dir)
except:
normalized_ql_dir = os.path.abspath(ql_dir)
normalized_data_dir = os.path.abspath(data_dir)
# Protected directories - no write access allowed
protected_paths = [
os.path.join(normalized_ql_dir, 'back'),
os.path.join(normalized_ql_dir, 'src'),
os.path.join(normalized_ql_dir, 'shell'),
os.path.join(normalized_ql_dir, 'sample'),
os.path.join(normalized_ql_dir, 'node_modules'),
os.path.join(normalized_data_dir, 'config'),
os.path.join(normalized_data_dir, 'db'),
]
# Allowed write directories - scripts can write here
allowed_write_paths = [
os.path.join(normalized_data_dir, 'scripts'),
os.path.join(normalized_data_dir, 'log'),
os.path.join(normalized_data_dir, 'repo'),
os.path.join(normalized_data_dir, 'raw'),
os.path.join(normalized_ql_dir, '.tmp'),
'/tmp',
]
# Check if sandboxing is enabled (default: true)
sandbox_enabled = os.environ.get('QL_DISABLE_SANDBOX') != 'true'
def is_path_protected(target_path):
"""Check if a path is protected from write operations"""
if not sandbox_enabled:
return False
try:
# Resolve to absolute path and follow symlinks
resolved_path = os.path.realpath(target_path)
# Check if path is in a protected directory
for protected_path in protected_paths:
if resolved_path.startswith(protected_path + os.sep) or resolved_path == protected_path:
# Check if it's in an allowed subdirectory
is_in_allowed_path = any(
resolved_path.startswith(allowed_path + os.sep) or resolved_path == allowed_path
for allowed_path in allowed_write_paths
)
if not is_in_allowed_path:
return True
# Also check if trying to write inside ql_dir or data_dir without being in allowed paths
is_in_ql_dir = resolved_path.startswith(normalized_ql_dir + os.sep) or resolved_path == normalized_ql_dir
is_in_data_dir = resolved_path.startswith(normalized_data_dir + os.sep) or resolved_path == normalized_data_dir
if is_in_ql_dir or is_in_data_dir:
is_in_allowed_path = any(
resolved_path.startswith(allowed_path + os.sep) or resolved_path == allowed_path
for allowed_path in allowed_write_paths
)
if not is_in_allowed_path:
return True
return False
except:
# If path doesn't exist yet, check parent directory
parent_path = os.path.dirname(target_path)
if parent_path != target_path:
return is_path_protected(parent_path)
return False
def create_security_error(operation, target_path):
"""Create a security error for unauthorized file operations"""
return PermissionError(
f"Security Error: Script attempted to {operation} protected path: {target_path}\n"
f"Scripts are only allowed to write to: {', '.join(allowed_write_paths)}"
)
# Store original functions
original_open = builtins.open
original_os_remove = os.remove
original_os_unlink = os.unlink
original_os_rmdir = os.rmdir
original_os_mkdir = os.mkdir
original_os_makedirs = os.makedirs
original_os_rename = os.rename
original_os_replace = os.replace
original_os_chmod = os.chmod
original_os_chown = os.chown if hasattr(os, 'chown') else None
original_os_link = os.link if hasattr(os, 'link') else None
original_os_symlink = os.symlink if hasattr(os, 'symlink') else None
original_os_truncate = os.truncate if hasattr(os, 'truncate') else None
original_os_utime = os.utime if hasattr(os, 'utime') else None
# Wrap open() to check write operations
def sandboxed_open(file, mode='r', *args, **kwargs):
"""Wrapped open() that checks for protected paths on write operations"""
if sandbox_enabled and isinstance(mode, str) and any(m in mode for m in ['w', 'a', 'x', '+']):
if is_path_protected(file):
raise create_security_error('open for writing', file)
return original_open(file, mode, *args, **kwargs)
# Wrap os functions
def sandboxed_remove(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('remove', path)
return original_os_remove(path, *args, **kwargs)
def sandboxed_unlink(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('unlink', path)
return original_os_unlink(path, *args, **kwargs)
def sandboxed_rmdir(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('rmdir', path)
return original_os_rmdir(path, *args, **kwargs)
def sandboxed_mkdir(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('mkdir', path)
return original_os_mkdir(path, *args, **kwargs)
def sandboxed_makedirs(name, *args, **kwargs):
if sandbox_enabled and is_path_protected(name):
raise create_security_error('makedirs', name)
return original_os_makedirs(name, *args, **kwargs)
def sandboxed_rename(src, dst, *args, **kwargs):
if sandbox_enabled:
if is_path_protected(src):
raise create_security_error('rename (source)', src)
if is_path_protected(dst):
raise create_security_error('rename (destination)', dst)
return original_os_rename(src, dst, *args, **kwargs)
def sandboxed_replace(src, dst, *args, **kwargs):
if sandbox_enabled:
if is_path_protected(src):
raise create_security_error('replace (source)', src)
if is_path_protected(dst):
raise create_security_error('replace (destination)', dst)
return original_os_replace(src, dst, *args, **kwargs)
def sandboxed_chmod(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('chmod', path)
return original_os_chmod(path, *args, **kwargs)
def sandboxed_chown(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('chown', path)
return original_os_chown(path, *args, **kwargs)
def sandboxed_link(src, dst, *args, **kwargs):
if sandbox_enabled:
if is_path_protected(dst):
raise create_security_error('link', dst)
return original_os_link(src, dst, *args, **kwargs)
def sandboxed_symlink(src, dst, *args, **kwargs):
if sandbox_enabled:
if is_path_protected(dst):
raise create_security_error('symlink', dst)
return original_os_symlink(src, dst, *args, **kwargs)
def sandboxed_truncate(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('truncate', path)
return original_os_truncate(path, *args, **kwargs)
def sandboxed_utime(path, *args, **kwargs):
if sandbox_enabled and is_path_protected(path):
raise create_security_error('utime', path)
return original_os_utime(path, *args, **kwargs)
# Apply sandbox wrappers
if sandbox_enabled:
builtins.open = sandboxed_open
os.remove = sandboxed_remove
os.unlink = sandboxed_unlink
os.rmdir = sandboxed_rmdir
os.mkdir = sandboxed_mkdir
os.makedirs = sandboxed_makedirs
os.rename = sandboxed_rename
os.replace = sandboxed_replace
os.chmod = sandboxed_chmod
if original_os_chown:
os.chown = sandboxed_chown
if original_os_link:
os.link = sandboxed_link
if original_os_symlink:
os.symlink = sandboxed_symlink
if original_os_truncate:
os.truncate = sandboxed_truncate
if original_os_utime:
os.utime = sandboxed_utime
# Wrap shutil if it's imported
try:
import shutil
original_shutil_rmtree = shutil.rmtree
original_shutil_copy = shutil.copy
original_shutil_copy2 = shutil.copy2
original_shutil_copytree = shutil.copytree
original_shutil_move = shutil.move
def sandboxed_rmtree(path, *args, **kwargs):
if is_path_protected(path):
raise create_security_error('rmtree', path)
return original_shutil_rmtree(path, *args, **kwargs)
def sandboxed_copy(src, dst, *args, **kwargs):
if is_path_protected(dst):
raise create_security_error('copy', dst)
return original_shutil_copy(src, dst, *args, **kwargs)
def sandboxed_copy2(src, dst, *args, **kwargs):
if is_path_protected(dst):
raise create_security_error('copy2', dst)
return original_shutil_copy2(src, dst, *args, **kwargs)
def sandboxed_copytree(src, dst, *args, **kwargs):
if is_path_protected(dst):
raise create_security_error('copytree', dst)
return original_shutil_copytree(src, dst, *args, **kwargs)
def sandboxed_move(src, dst, *args, **kwargs):
if is_path_protected(src):
raise create_security_error('move (source)', src)
if is_path_protected(dst):
raise create_security_error('move (destination)', dst)
return original_shutil_move(src, dst, *args, **kwargs)
shutil.rmtree = sandboxed_rmtree
shutil.copy = sandboxed_copy
shutil.copy2 = sandboxed_copy2
shutil.copytree = sandboxed_copytree
shutil.move = sandboxed_move
except ImportError:
pass
# Wrap pathlib.Path if available
try:
original_path_write_text = Path.write_text
original_path_write_bytes = Path.write_bytes
original_path_touch = Path.touch
original_path_mkdir = Path.mkdir
original_path_rmdir = Path.rmdir
original_path_unlink = Path.unlink
original_path_rename = Path.rename
original_path_replace = Path.replace
original_path_chmod = Path.chmod
def sandboxed_path_write_text(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.write_text', str(self))
return original_path_write_text(self, *args, **kwargs)
def sandboxed_path_write_bytes(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.write_bytes', str(self))
return original_path_write_bytes(self, *args, **kwargs)
def sandboxed_path_touch(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.touch', str(self))
return original_path_touch(self, *args, **kwargs)
def sandboxed_path_mkdir(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.mkdir', str(self))
return original_path_mkdir(self, *args, **kwargs)
def sandboxed_path_rmdir(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.rmdir', str(self))
return original_path_rmdir(self, *args, **kwargs)
def sandboxed_path_unlink(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.unlink', str(self))
return original_path_unlink(self, *args, **kwargs)
def sandboxed_path_rename(self, target, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.rename (source)', str(self))
if is_path_protected(str(target)):
raise create_security_error('Path.rename (target)', str(target))
return original_path_rename(self, target, *args, **kwargs)
def sandboxed_path_replace(self, target, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.replace (source)', str(self))
if is_path_protected(str(target)):
raise create_security_error('Path.replace (target)', str(target))
return original_path_replace(self, target, *args, **kwargs)
def sandboxed_path_chmod(self, *args, **kwargs):
if is_path_protected(str(self)):
raise create_security_error('Path.chmod', str(self))
return original_path_chmod(self, *args, **kwargs)
Path.write_text = sandboxed_path_write_text
Path.write_bytes = sandboxed_path_write_bytes
Path.touch = sandboxed_path_touch
Path.mkdir = sandboxed_path_mkdir
Path.rmdir = sandboxed_path_rmdir
Path.unlink = sandboxed_path_unlink
Path.rename = sandboxed_path_rename
Path.replace = sandboxed_path_replace
Path.chmod = sandboxed_path_chmod
except:
pass
# Wrap subprocess to prevent sandbox bypass via subprocesses
try:
import subprocess
# Helper to ensure PYTHONPATH is set for subprocesses
def ensure_sandbox_env(env=None):
if env is None:
env = os.environ.copy()
else:
env = env.copy()
# Ensure PYTHONPATH includes the sandbox directory
sandbox_dir = os.path.dirname(__file__)
if 'PYTHONPATH' not in env:
env['PYTHONPATH'] = ''
if sandbox_dir not in env['PYTHONPATH']:
env['PYTHONPATH'] = f"{sandbox_dir}:{env['PYTHONPATH']}"
return env
# Store original functions
original_popen = subprocess.Popen
original_run = subprocess.run
original_call = subprocess.call
original_check_call = subprocess.check_call
original_check_output = subprocess.check_output
# Wrap Popen
class SandboxedPopen(subprocess.Popen):
def __init__(self, *args, **kwargs):
if 'env' in kwargs:
kwargs['env'] = ensure_sandbox_env(kwargs['env'])
else:
kwargs['env'] = ensure_sandbox_env()
original_popen.__init__(self, *args, **kwargs)
subprocess.Popen = SandboxedPopen
# Wrap run
def sandboxed_run(*args, **kwargs):
if 'env' in kwargs:
kwargs['env'] = ensure_sandbox_env(kwargs['env'])
else:
kwargs['env'] = ensure_sandbox_env()
return original_run(*args, **kwargs)
subprocess.run = sandboxed_run
# Wrap call
def sandboxed_call(*args, **kwargs):
if 'env' in kwargs:
kwargs['env'] = ensure_sandbox_env(kwargs['env'])
else:
kwargs['env'] = ensure_sandbox_env()
return original_call(*args, **kwargs)
subprocess.call = sandboxed_call
# Wrap check_call
def sandboxed_check_call(*args, **kwargs):
if 'env' in kwargs:
kwargs['env'] = ensure_sandbox_env(kwargs['env'])
else:
kwargs['env'] = ensure_sandbox_env()
return original_check_call(*args, **kwargs)
subprocess.check_call = sandboxed_check_call
# Wrap check_output
def sandboxed_check_output(*args, **kwargs):
if 'env' in kwargs:
kwargs['env'] = ensure_sandbox_env(kwargs['env'])
else:
kwargs['env'] = ensure_sandbox_env()
return original_check_output(*args, **kwargs)
subprocess.check_output = sandboxed_check_output
except ImportError:
pass
-3
View File
@@ -1,6 +1,3 @@
// Load sandbox first to protect filesystem
require('./sandbox.js');
const { execSync } = require('child_process');
const client = require('./client.js');
require(`./env.js`);
-3
View File
@@ -1,6 +1,3 @@
# Load sandbox first to protect filesystem
import sandbox
import os
import re
import subprocess
+23 -3
View File
@@ -104,7 +104,7 @@
"序号": "Number",
"备注": "Remarks",
"更新时间": "Update Time",
"创建时间": "Creation Time",
"创建时间": "Created Time",
"确认删除依赖": "Confirm to delete the dependency",
"确认重新安装": "Confirm to reinstall",
"确认取消安装": "Confirm to cancel install",
@@ -252,7 +252,7 @@
"登录日志": "Login Logs",
"其他设置": "Other Settings",
"关于": "About",
"成功": "Success",
"成功": "Successfully",
"失败": "Failure",
"登录时间": "Login Time",
"登录地址": "Login Address",
@@ -389,6 +389,7 @@
"消息接收人": "message recipient",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings",
"邮箱服务名称,比如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",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
@@ -533,5 +534,24 @@
"日志名称不能超过100个字符": "Log name cannot exceed 100 characters",
"未启用": "Not enabled",
"默认为 CPU 个数": "Default is the number of CPUs",
"Minimum is 4": "Minimum is 4"
"Minimum is 4": "Minimum is 4",
"实例模式": "Instance Mode",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously",
"请选择实例模式": "Please select instance mode",
"单实例": "Single Instance",
"多实例": "Multi-Instance",
"SSH密钥": "SSH Keys",
"别名": "Alias",
"编辑SSH密钥": "Edit SSH Key",
"创建SSH密钥": "Create SSH Key",
"更新SSH密钥成功": "SSH key updated successfully",
"创建SSH密钥成功": "SSH key created successfully",
"请输入SSH密钥别名": "Please enter SSH key alias",
"请输入SSH私钥": "Please enter SSH private key",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)",
"确认删除SSH密钥": "Confirm to delete SSH key",
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
}
+21 -1
View File
@@ -389,6 +389,7 @@
"消息接收人": "消息接收人",
"调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版",
"飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973",
"飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得",
"邮箱服务名称,比如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",
"邮箱地址": "邮箱地址",
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定",
@@ -533,5 +534,24 @@
"日志名称不能超过100个字符": "日志名称不能超过100个字符",
"未启用": "未启用",
"默认为 CPU 个数": "默认为 CPU 个数",
"最小是 4": "最小是 4"
"最小是 4": "最小是 4",
"实例模式": "实例模式",
"单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例",
"请选择实例模式": "请选择实例模式",
"单实例": "单实例",
"多实例": "多实例",
"SSH密钥": "SSH密钥",
"别名": "别名",
"编辑SSH密钥": "编辑SSH密钥",
"创建SSH密钥": "创建SSH密钥",
"更新SSH密钥成功": "更新SSH密钥成功",
"创建SSH密钥成功": "创建SSH密钥成功",
"请输入SSH密钥别名": "请输入SSH密钥别名",
"请输入SSH私钥": "请输入SSH私钥",
"请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)",
"确认删除SSH密钥": "确认删除SSH密钥",
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
}
+17 -1
View File
@@ -180,6 +180,18 @@ const CronModal = ({
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
<Form.Item
name="allow_multiple_instances"
label={intl.get('实例模式')}
tooltip={intl.get(
'单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例',
)}
>
<Select placeholder={intl.get('请选择实例模式')}>
<Select.Option value={0}>{intl.get('单实例')}</Select.Option>
<Select.Option value={1}>{intl.get('多实例')}</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="log_name"
label={intl.get('日志名称')}
@@ -194,7 +206,11 @@ const CronModal = ({
if (value.length > 100) {
return Promise.reject(intl.get('日志名称不能超过100个字符'));
}
if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) {
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return Promise.reject(
intl.get('日志名称只能包含字母、数字、下划线和连字符'),
);
+1
View File
@@ -37,6 +37,7 @@ export interface ICrontab {
nextRunTime: Date;
sub_id: number;
extra_schedules?: Array<{ schedule: string }>;
allow_multiple_instances?: 1 | 0;
}
export enum ScheduleType {
+1 -1
View File
@@ -16,7 +16,7 @@ const SaveModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const payload = { ...file, ...values, originFilename: file.title };
const payload = { ...values, originFilename: file.title, content: file.content };
request
.post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => {
+28
View File
@@ -30,6 +30,7 @@ const dataMap = {
'log-remove-frequency': 'logRemoveFrequency',
'cron-concurrency': 'cronConcurrency',
timezone: 'timezone',
'global-ssh-key': 'globalSshKey',
};
const exportModules = [
@@ -54,6 +55,7 @@ const Other = ({
logRemoveFrequency?: number | null;
cronConcurrency?: number | null;
timezone?: string | null;
globalSshKey?: string | null;
}>();
const [form] = Form.useForm();
const [exportLoading, setExportLoading] = useState(false);
@@ -308,6 +310,32 @@ const Other = ({
</Button>
</Input.Group>
</Form.Item>
<Form.Item
label={intl.get('全局SSH私钥')}
name="globalSshKey"
tooltip={intl.get('用于访问所有私有仓库的全局SSH私钥')}
>
<Input.Group compact>
<Input.TextArea
value={systemConfig?.globalSshKey || ''}
style={{ width: 264 }}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={intl.get('请输入完整的SSH私钥内容')}
onChange={(e) => {
setSystemConfig({ ...systemConfig, globalSshKey: e.target.value });
}}
/>
</Input.Group>
<Button
type="primary"
onClick={() => {
updateSystemConfig('global-ssh-key');
}}
style={{ width: 264, marginTop: 8 }}
>
{intl.get('确认')}
</Button>
</Form.Item>
<Form.Item label={intl.get('语言')} name="lang">
<Select
defaultValue={localStorage.getItem('lang') || ''}
+6
View File
@@ -395,6 +395,12 @@ export default {
),
required: true,
},
{
label: 'larkSecret',
tip: intl.get(
'飞书群组机器人加签密钥,安全设置中开启签名校验后获得',
),
},
],
email: [
{
+46 -9
View File
@@ -1,10 +1,47 @@
version: 2.19.2
changeLogLink: https://t.me/jiao_long/431
publishTime: 2025-06-27 23:59
version: 2.20.0
changeLogLink: https://t.me/jiao_long/432
publishTime: 2025-12-10 01:05
changeLog: |
1. 备份数据支持选择模块,支持清除依赖缓存
2. QLAPI 和 openapi 的 systemNotify 支持自定义通知类型和参数
3. ntfy 增加可选的认证与用户动作,感谢 https://github.com/liheji
4. 修复取消安装依赖
5. 修复环境变量过大解析报错
6. 修改服务启动方式
1. 定时任务(cron / task)相关的大量修复 & 增强
修复 cron 解析错误(修复 parse cron / 升级 cron-parser
修复集群模式下定时任务可能不执行(race condition
定时任务支持订阅筛选
定时任务支持排序调整
定时任务支持自定义日志文件或无日志
修复任务实例默认值
任务支持单实例 / 多实例模式
修复 task 命令软链可能失败问题
2. 日志系统相关的大更新
修复日志目录逻辑
修复 pm2 日志目录
优化日志写入(stream pooling
3. 环境变量(env)系统的改进与修复
修复环境变量复制到剪贴板时可能失败
添加环境变量“置顶”功能
修复 QlPort 与 QlGrpcPort 环境变量在 host network 模式下被忽略
增加全局 SSH 私钥配置
4. Docker / 非 root 用户 / Alpine 兼容性增强
新增非 root Docker 用户支持,自动初始化命令
修复 Alpine 容器 DNS 解析失败(设置 ndots:0
修复 PM2 在 ARM 路由器(Node.js 不兼容)上的启动失败
移除 nginx(可能是考虑更轻量的镜像运行)
5. API 安全与校验增强
Dependencies GET endpoint 增加校验
Script API routes 增加输入校验
修复 JWT 认证问题
Feishu 机器人通知增加签名校验
QLAPI 增加 cron task 管理功能
修复 URIError(错误 cookie 导致白屏)
6. 系统设置
新增多终端/多平台的并发登录会话支持