Compare commits

...
Author SHA1 Message Date
whyour ac8567e537 fix(runtime): resolve develop merge and restore frozen lockfile compatibility 2026-10-13 02:20:59 +08:00
whyour be877cfddc fix: 修复完整镜像源地址及 CRLF 任务导入 (#3093)
* fix: address six verified legacy issue regressions

* fix: limit issue patch to mirror URLs and CRLF imports

* fix: preserve APT formats and third-party repositories
2026-10-13 01:11:35 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
fdeffebba8 Fix catch-all route serving HTML for API requests (#2885)
* Initial plan

* Fix env.js loading error by preventing catch-all from matching API routes

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>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-13 01:07:50 +08:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>whyour
3c61e933a1 chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates (#3075)
* chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates

Bumps the npm_and_yarn group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [body-parser](https://github.com/expressjs/body-parser) | `1.20.3` | `1.20.6` |
| [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) | `3.0.3` | `3.0.7` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.1.0` | `4.3.2` |
| [multer](https://github.com/expressjs/multer) | `2.3.0` | `2.4.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.1` | `10.0.9` |
| [undici](https://github.com/nodejs/undici) | `7.9.0` | `7.29.1` |
| [axios](https://github.com/axios/axios) | `1.7.8` | `1.20.0` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.12` | `1.1.21` |



Updates `body-parser` from 1.20.3 to 1.20.6
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.3...1.20.6)

Updates `http-proxy-middleware` from 3.0.3 to 3.0.7
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v3.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v3.0.3...v3.0.7)

Updates `js-yaml` from 4.1.0 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.3.2)

Updates `multer` from 2.3.0 to 2.4.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.3.0...v2.4.0)

Updates `nodemailer` from 8.0.1 to 10.0.9
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v10.0.9)

Updates `undici` from 7.9.0 to 7.29.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.9.0...v7.29.1)

Updates `axios` from 1.7.8 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.8...v1.20.0)

Updates `brace-expansion` from 1.1.12 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.21)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:development
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: direct:production
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
- dependency-name: http-proxy-middleware
  dependency-version: 3.0.7
  dependency-type: direct:production
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: direct:production
- dependency-name: multer
  dependency-version: 2.3.0
  dependency-type: direct:production
- dependency-name: nodemailer
  dependency-version: 9.1.1
  dependency-type: direct:production
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(ci): verify the updated multer runtime version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-13 01:06:29 +08:00
whyour 59979ee061 fix(build): pin frontend compilation to Node 22 on newer hosts 2026-10-12 01:34:21 +08:00
whyour 449a8a471c chore(runtime): reconcile future migration with current develop 2026-10-12 01:13:53 +08:00
whyour 70636900b2 chore: finalize 2.23.0 publication metadata 2026-10-11 12:27:51 +08:00
whyour 331f4f9138 Prepare Qinglong 2.23.0 and CLI 0.1.2 release (#3090)
* fix: patch reachable release-blocking dependencies

* docs: keep release risk notes in PR description

* chore: prepare Qinglong 2.23.0 and CLI 0.1.2 release metadata
2026-10-11 12:24:50 +08:00
whyour b06cb72334 fix: apply minimal production dependency security patches 2026-10-11 02:09:20 +08:00
whyour 89c6355635 fix: renew system tokens and serialize authentication cache updates 2026-10-11 01:56:50 +08:00
whyour 4b4e485305 ci: probe legacy pnpm without unsupported config options 2026-10-10 06:10:58 +08:00
whyour 2f359c6bf0 fix: freeze release runtime tools and npm consumer artifacts 2026-10-10 06:02:39 +08:00
whyour a70ec66fe3 fix: prepare supported runtime images for 2.23.0 2026-10-10 03:17:52 +08:00
whyour 5038d2d9a0 fix: patch production dependencies and add release validation 2026-10-10 01:16:07 +08:00
whyour 68ffa106f8 fix: resolve Debian regression findings (#3087)
* fix: resolve Debian regression findings

* fix: reject failed APT index refreshes and simplify SSH home lookup
2026-10-09 01:41:29 +08:00
whyour 9d5f58ef99 ci: verify Gitee Pull mirrors with resilient GitHub notifications
Use native Gitee Pull mirrors for code and static artifacts, and verify branch and tag SHAs alongside image builds. Trigger stale mirrors through small GitHub REST ref updates, retry transient errors with backoff, and keep polling when a notification fails.
2026-10-08 01:16:43 +08:00
whyour ef1c54a620 fix: 补齐前端缺失的中英文翻译 2026-10-07 20:35:59 +08:00
whyour 0218404d9f fix: 修复页面升级、ql update 与静态资源版本同步 (#3086)
* fix: validate panel upgrades and synchronize versioned static builds

* fix: add static-only mirror backfill workflow

* fix: avoid lifecycle self-kill and stop cancelled upgrade groups

* fix: build images when the shared upgrade engine changes

* fix: dispatch upgrade lifecycle through a fixed script

* fix: reclaim upgrade locks safely and normalize health probes

* fix: match backend base URL normalization exactly
2026-10-07 17:43:03 +08:00
whyour 24f7d1a46f fix: resolve dev panel and CLI regressions
Synchronize scheduler concurrency across API and gRPC processes and restore persisted limits during recovery.

Serialize subscription mutations and preserve shared storage while cleaning Shell and CLI namespaces, historical references, and filesystem-specific naming candidates.

Handle CLI text responses correctly and include script history in backups. Add regression coverage for concurrency, cleanup ownership, long URLs, Unicode paths, and history exports.
2026-10-07 12:35:10 +08:00
whyour fa8910b3d6 feat: add script version history (#3085)
* feat: add script version history

* fix: validate canonical save-as paths before backup

* fix: stack script history footer on small screens
2026-10-06 22:00:11 +08:00
whyour 2cab112391 fix: 加固安全输入边界并修复订阅兼容性 (#3084)
* fix: harden scoped API inputs and isolate login throttling

* fix: close script path gaps and harden security boundaries

* fix: preserve subscription aliases and avoid extension eval

* fix: allow cancelling invalid historical subscriptions

* fix: make script parent validation explicit for CodeQL

* fix: limit command execution and clarify trusted path branches

* fix: separate diff tool script access from config APIs

* fix: clean up obsolete subscription SSH credentials
2026-10-06 18:21:54 +08:00
whyour 2ab9c702e6 ci: skip Docker builds for CLI-only changes 2026-10-01 12:25:16 +08:00
whyour 6dd98a8565 fix(ci): isolate CLI release concurrency from validation 2026-10-01 12:17:26 +08:00
whyour 06c8d8b112 fix: report resource conflicts and accurate CLI API errors 2026-10-01 11:26:40 +08:00
whyour a5406bedca ci: allow verified CLI prereleases from develop 2026-10-01 00:26:42 +08:00
whyour 3d2b98a442 fix: scope file access restrictions and honor custom data directories 2026-09-30 21:21:35 +08:00
whyour 93d7ec69dc fix: scope config file blacklist to config directory (#3082) 2026-09-30 20:31:20 +08:00
whyour a303e33b4e fix: invalidate queued cron revisions and enforce task process timeouts 2026-09-29 23:37:00 +08:00
whyour 44129ca088 fix: align CLI shell completion and cancellation semantics 2026-09-29 01:06:47 +08:00
whyour def4917447 fix: finalize exited shell tasks and preserve command environments 2026-09-29 00:52:33 +08:00
whyour bc0f35e3f7 fix: reduce task startup overhead and lazy-load notifications (#3080) 2026-09-28 23:41:31 +08:00
whyour f168efcdae refactor: migrate cron scheduling to node-cron (#3079)
* refactor: migrate cron scheduling to node-cron

* feat: support annually midnight and minutely cron macros

* fix: harden cron recovery and system scheduler compatibility
2026-09-27 22:18:39 +08:00
whyour bf63425805 fix: isolate invalid cron rules during scheduler recovery 2026-09-27 16:45:09 +08:00
whyour a8310e7d51 fix: restore legacy cron schedules and shell diagnostics (#3078) 2026-09-27 16:16:22 +08:00
whyour a67d44cde5 fix: pin Debian Python 3.10 base for full release matrix 2026-09-26 01:40:39 +08:00
whyour 6c487018c2 fix: drain completed task logs and prepare 2.22.0 release (#3076) 2026-09-26 01:17:28 +08:00
whyour 801a71d740 feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* feat(cli): add unified Commander CLI for QingLong 2.x

* fix(cli): publish via npm and address security review feedback

* ci(cli): package npm artifacts and remove evaluation collateral

* test(cli): use a fixed shell fixture for log retention

* refactor(cli): separate remote npm client from panel tools

* feat(cli): cover active panel OpenAPI resources

* docs(cli): unify authentication and skill guidance

* refactor(cli): isolate internal commands and generate Commander help

* refactor(cli): organize remote and internal modules by responsibility

* ci(cli): publish verified npm archives from master

* fix(cli): publish under the whyour npm scope

* ci: use npm trusted publishing for both packages

* docs: introduce the published CLI on the project homepage

* fix(cli): preserve server log truncation and correct login hints

* fix(cli): accept dashboard record request bodies

* fix(cli): preserve stdin for local task execution

* fix(cli): resolve task executables after changing directory

* fix(cli): preserve shell function tasks and sanitize test failures

* fix(cli): preserve shell hook state and resolve workdir after hooks

* fix(cli): preserve cleanup across shared shell task timeouts

* fix(cli): isolate shell control descriptors and reap timed-out descendants
2026-09-25 23:24:41 +08:00
whyour f051135fc4 fix: declare nullable subscription process id 2026-09-24 20:37:13 +08:00
whyour 2d6c2206b3 fix: verify task termination and preserve startup configuration 2026-09-22 22:23:18 +08:00
whyour 6acb665369 fix: prevent expired-token login refresh loop 2026-09-19 13:23:28 +08:00
whyour d9833c17d5 perf: reduce resident dependencies and duplicate WebSocket authentication (#3071)
* perf: load HTTP dependencies only in the HTTP worker

* docs: record 30-minute idle resource comparison

* perf: isolate database initialization from the cluster primary

* docs: record isolated database startup resource comparison

* perf: avoid an extra shell child in Docker health probes

* perf: load notification dependencies only when needed

* perf: retain queue cleanup after notification loading comparison

* fix: stop page polling after unmount and avoid duplicate fetches

* perf: share WebSocket session polling across connections

* docs: measure real WebSocket container CPU and memory

* perf: reuse JWT validation within each WebSocket session check

* perf: bound login IP lookup allocations with an address cache

* refactor: limit resource optimization PR to three core changes

* docs: benchmark the three core optimizations against develop

* chore: remove documentation and benchmark artifacts from PR
2026-09-18 20:22:18 +08:00
whyour a60ec5587a feat: show today's successful tasks from dashboard (#3072) 2026-09-18 02:16:54 +08:00
whyour 4eb27427f8 fix: 修复 develop 调度锁、运行恢复与 Alpine 缓存并发 (#3070)
* fix: repair develop scheduler locks and runtime recovery

* test: wait for child reaping after verified termination

* fix: abort cron mutations when scheduler deletion fails
2026-09-13 13:44:44 +08:00
whyour 041a437453 fix: support WPUSH notifications over gRPC 2026-09-13 01:08:14 +08:00
Alone88 841740f19a feat: add WPUSH notification provider (#3066)
Add WPUSH as a notification channel for Qinglong panel settings and
sample notify scripts (Python/JS), using POST https://api.wpush.cn/api/v1/send
with success code === 0. Supports optional channel and topic_code.
2026-09-13 00:50:56 +08:00
whyour a94e665054 fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)
* fix: harden task lifecycle and scheduler readiness

* fix: confine log writes to the configured log directory

* fix: verify complete build artifacts and untracked inputs

* fix: reconcile scheduler state and make stop win startup races

* fix: isolate cron generations and serialize scheduler recovery
2026-09-13 00:32:41 +08:00
whyour d62d8f3025 fix: support Chinese cron log names (#3068) 2026-09-12 03:03:25 +08:00
whyour 4df52094f2 fix: harden authentication and file access security 2026-09-05 18:19:01 +08:00
whyour be2580d0e8 feat: show today's failed tasks from dashboard
Add a failure list with task names, commands, accumulated failure counts and latest log links. Keep recovered and deleted tasks visible in today's statistics.

Fixes #3065
2026-09-05 17:03:31 +08:00
whyour 5f6049d80a fix: secure file routes and dependency management 2026-08-29 19:10:30 +08:00
whyour 0e975d1d6d fix: show cron validation field errors 2026-08-22 01:20:04 +08:00
whyour 134cd8337c fix: read ql runtime logs from syslog 2026-08-20 09:41:42 +08:00
whyour 88200f2805 feat: add ql service log command 2026-08-20 00:56:59 +08:00
whyour decdab093f perf: speed up large file tree listings 2026-08-19 01:05:18 +08:00
whyour dfa73af104 fix: preserve container startup diagnostics 2026-08-18 00:40:40 +08:00
whyour f71fd5ebd6 fix: propagate initialization errors and reduce request metrics 2026-08-17 08:55:53 +08:00
whyour afe9b5702b chore: remove internal performance triage report 2026-08-16 22:41:46 +08:00
whyour 02e02491c4 fix: avoid duplicate container stdout logging 2026-08-16 17:03:15 +08:00
whyour 276dfc2382 perf: bound runtime memory and storage usage 2026-08-16 16:29:06 +08:00
whyour 31f78e4d1f feat: add client IP diagnostics and blocking 2026-08-16 13:57:33 +08:00
whyour 83e4490b57 fix: pin Python base for multi-arch builds 2026-08-11 00:12:47 +08:00
whyour 2ac4f07fca fix: address dashboard scope and preview interactions 2026-08-10 23:40:53 +08:00
Kobi Hikri 6618240c61 ci: attach provenance and SBOM attestations to the published images (#3048)
* ci: attach provenance and SBOM attestations to the published images

* ci: restore trailing newline at end of file
2026-08-01 20:11:55 +08:00
whyour 0bb41ebe96 新版本 v2.21.0 2026-07-25 13:28:44 +08:00
whyour d67f33df39 fix: 防止 task_before 污染 Node.js 依赖路径 2026-07-24 22:33:48 +08:00
whyour 24d539fc05 fix: 使用 npm 包安装 sqlite3 2026-07-19 00:40:40 +08:00
495 changed files with 92189 additions and 10880 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitnexus
.claude
.codex
.env*
node_modules
data
audit
docs
test
src/.umi*
**/__pycache__
**/*.log
+7
View File
@@ -6,6 +6,13 @@ BACK_PORT=5700
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。
# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表:
# QL_TRUST_PROXY=1
# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32
# 仅在面板端口绝对无法绕过代理直接访问时使用 true。
# QL_TRUST_PROXY=loopback
LOG_LEVEL='info'
JWT_SECRET=
+283 -96
View File
@@ -2,20 +2,48 @@ name: Build And Push Docker Image
on:
push:
paths-ignore:
- "*.md"
paths:
- "**"
- "!*.md"
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
- "develop"
tags:
- "v*"
workflow_dispatch:
inputs:
static_sync_only:
description: Synchronize an existing static snapshot to GitLab and verify Gitee
type: boolean
default: false
static_ref:
description: Static branch or version name, for example v2.22.0
type: string
default: ''
static_ref_type:
description: Synchronize a branch, or a version branch and Git tag
type: choice
options: [branch, tag]
default: branch
permissions:
contents: read
jobs:
validate:
if: ${{ !inputs.static_sync_only }}
uses: ./.github/workflows/validate.yml
runtime-tools:
if: ${{ !inputs.static_sync_only }}
uses: ./.github/workflows/runtime-tools.yml
code_gitlab:
if: ${{ !inputs.static_sync_only }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -32,46 +60,19 @@ jobs:
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
code_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
build-static:
if: ${{ !inputs.static_sync_only }}
needs: [validate, runtime-tools]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -81,66 +82,133 @@ jobs:
pnpm build:front
pnpm build:back
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: static/
- run: pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
path: static/
if-no-files-found: error
include-hidden-files: true
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
run: |
mkdir -p tmp
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
npm-package:
if: ${{ !inputs.static_sync_only }}
needs: build-static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: qinglong-static-${{ github.sha }}
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
concurrency:
group: static-gitlab-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Push qinglong-static to GitLab
env:
STATIC_BRANCH: ${{ github.ref_name }}
STATIC_REF_TYPE: ${{ github.ref_type }}
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
set +e
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
static_gitee:
verify-gitee-mirrors:
needs: build-static
runs-on: ubuntu-latest
timeout-minutes: 14
permissions:
contents: write
strategy:
fail-fast: false
matrix:
repository: [qinglong, qinglong-static]
steps:
- name: Push qinglong-static to Gitee
- uses: actions/checkout@v6
# Gitee Pull mirrors and GitHub push webhooks own synchronization.
# Verification runs alongside Docker builds and needs no Gitee credentials.
- name: Wait for Gitee Pull mirror
if: ${{ matrix.repository == 'qinglong' }}
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GH_TOKEN: ${{ github.token }}
QL_MIRROR_NOTIFY_REPO: whyour/qinglong
MIRROR_REPOSITORY: ${{ matrix.repository }}
MIRROR_REF: ${{ github.ref_name }}
MIRROR_REF_TYPE: ${{ github.ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
QL_MIRROR_NOTIFY_ON_START: 'false'
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE"
- name: Notify and wait for Gitee static mirror
if: ${{ matrix.repository == 'qinglong-static' }}
env:
GH_TOKEN: ${{ secrets.API_TOKEN }}
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
MIRROR_REF: ${{ github.ref_name }}
MIRROR_REF_TYPE: ${{ github.ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
run: |
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee static push succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong-static","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
sync-existing-static:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
mirror: [gitlab]
concurrency:
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Synchronize existing static snapshot
env:
STATIC_REF: ${{ inputs.static_ref }}
STATIC_REF_TYPE: ${{ inputs.static_ref_type }}
MIRROR_HOST: ${{ matrix.mirror }}.com
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE"
verify-existing-static-gitee:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
runs-on: ubuntu-latest
timeout-minutes: 14
steps:
- uses: actions/checkout@v6
- name: Wait for existing Gitee static snapshot
env:
GH_TOKEN: ${{ secrets.API_TOKEN }}
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
MIRROR_REF: ${{ inputs.static_ref }}
MIRROR_REF_TYPE: ${{ inputs.static_ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
run: |
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
@@ -151,11 +219,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -208,7 +281,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -216,6 +289,8 @@ jobs:
context: .
file: ./docker/Dockerfile
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
@@ -230,11 +305,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -286,7 +366,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -295,6 +375,8 @@ jobs:
context: .
file: ./docker/Dockerfile.debian
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
@@ -309,11 +391,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -351,7 +438,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -359,6 +446,8 @@ jobs:
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
@@ -374,11 +463,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -416,7 +510,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -425,6 +519,8 @@ jobs:
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
@@ -432,26 +528,117 @@ jobs:
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
publish:
if: ${{ github.ref_name == 'master' }}
needs: [build-alpine, build-debian]
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, npm-package]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
env:
SOURCE_SHA: ${{ github.sha }}
VERIFIED_TARBALL_SHA256: ${{ needs.npm-package.outputs.tarball-sha256 }}
VERIFIED_PACKAGE_VERSION: ${{ needs.npm-package.outputs.package-version }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/checkout@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v3
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
cache: "pnpm"
- name: build front and back
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ needs.npm-package.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-publish/
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: ${{ runner.temp }}/npm-publisher-runtime-tools/
- name: Verify the gate-approved archive and its source receipts
shell: bash
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
const fileHash = (file) => hash(fs.readFileSync(file));
const directory = path.join(process.env.RUNNER_TEMP, 'main-npm-publish');
const summary = JSON.parse(fs.readFileSync(path.join(directory, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
assert.match(process.env.VERIFIED_TARBALL_SHA256 || '', /^[a-f0-9]{64}$/);
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.deepEqual(summary.sourceMetadataChanges, []); assert(summary.helperProvenance.length > 0);
assert(summary.helperProvenance.every((item) => item.matchesGitSource));
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.packageVersion, process.env.VERIFIED_PACKAGE_VERSION);
assert.equal(summary.productionManifestSHA256, fileHash('package.json')); assert.equal(summary.productionLockSHA256, fileHash('pnpm-lock.yaml'));
const source = spawnSync('git', ['archive', process.env.SOURCE_SHA], { maxBuffer: 128 * 1024 * 1024 });
assert.equal(source.status, 0); assert.equal(hash(source.stdout), summary.sourceArchiveSHA256);
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.runtimeTools.builder.platform, 'linux'); assert.equal(summary.runtimeTools.builder.releaseBuild, true);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename); assert(summary.tarball.filename.endsWith('.tgz'));
assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith('.tgz')), [summary.tarball.filename]);
const tarball = path.join(directory, summary.tarball.filename);
assert.equal(summary.tarball.sha256, process.env.VERIFIED_TARBALL_SHA256);
assert.equal(fileHash(tarball), process.env.VERIFIED_TARBALL_SHA256); assert.equal(fs.statSync(tarball).size, summary.tarball.bytes);
const tar = (args) => {
const result = spawnSync('tar', args, { maxBuffer: 128 * 1024 * 1024 });
assert.equal(result.status, 0, 'Cannot read the approved archive'); return result.stdout;
};
const entries = tar(['-tzf', tarball]).toString().split('\n').filter(Boolean);
assert.equal(entries.length, new Set(entries).size);
for (const name of entries) assert(name.startsWith('package/') && !name.split('/').includes('..') && !name.endsWith('.node') && !name.split('/').some((part) => ['.npmrc', '.git', '.pnpm', '_cacache'].includes(part)), 'Forbidden archive item ' + name);
const read = (relative) => tar(['-xOf', tarball, 'package/' + relative]);
const receipt = JSON.parse(read('qinglong-npm-dependency-proof.json'));
const { tarball: ignoredTarball, ...producerReceipt } = summary; assert.deepEqual(receipt, producerReceipt);
assert.deepEqual(entries.map((name) => name.slice('package/'.length)).sort(), receipt.publishedFilePaths);
const published = JSON.parse(read('package.json')); assert.equal(published.name, pkg.name); assert.equal(published.version, pkg.version);
assert.equal(hash(read('package.json')), receipt.publishedRootManifestSHA256);
const buildInfo = read('static/build-info.json'), build = JSON.parse(buildInfo);
assert.equal(hash(buildInfo), summary.ciBuildInfoSHA256); assert.equal(build.sourceCommit, process.env.SOURCE_SHA); assert.equal(build.dirty, false);
assert.equal(build.lockfileSha256, summary.productionLockSHA256);
assert.deepEqual(entries.filter((name) => name.startsWith('package/static/')).map((name) => name.slice('package/static/'.length)).sort(), [...Object.keys(build.files), 'build-info.json'].sort());
const toolsRoot = path.join(process.env.RUNNER_TEMP, 'npm-publisher-runtime-tools');
for (const [name, expected] of [['runtime-tools.tgz', summary.runtimeTools.archiveSHA256], ['runtime-tools-proof.json', summary.runtimeTools.proofSHA256]]) {
assert.equal(fileHash(path.join(toolsRoot, name)), expected); assert.equal(hash(read('static/' + name)), expected); assert.equal(build.files[name], expected);
}
const tools = JSON.parse(fs.readFileSync(path.join(toolsRoot, 'runtime-tools-proof.json')));
assert.equal(tools.sourceCommit, process.env.SOURCE_SHA); assert.equal(tools.archive.sha256, summary.runtimeTools.archiveSHA256);
assert.equal(tools.builder.platform, 'linux'); assert.equal(tools.builder.releaseBuild, true);
assert.equal(tools.sourceAttestation.verified, true); assert.equal(tools.sourceAttestation.head, process.env.SOURCE_SHA); assert(tools.inputs.length > 0);
for (const input of tools.inputs) {
assert(input.path && !path.isAbsolute(input.path) && !input.path.split(/[\\/]/).includes('..'));
assert.equal(fileHash(input.path), input.sha256, 'Tool source input changed: ' + input.path);
}
fs.appendFileSync(process.env.GITHUB_ENV, 'QINGLONG_PUBLISH_TARBALL=' + tarball + '\n');
console.log(JSON.stringify({ sourceCommit: summary.sourceCommit, packageVersion: summary.packageVersion, tarballSHA256: summary.tarball.sha256, cleanSource: true, runtimeToolsIncluded: true }));
NODE
- name: Install the same fixed npm CLI for trusted publishing
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
node scripts/install-runtime-tools.cjs \
--archive "$RUNNER_TEMP/npm-publisher-runtime-tools/runtime-tools.tgz" \
--proof "$RUNNER_TEMP/npm-publisher-runtime-tools/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-publisher-tools"
- name: Publish the verified npm archive with OIDC
shell: bash
run: |
set -euo pipefail
npm_cli="$RUNNER_TEMP/npm-publisher-tools/lib/node_modules/npm/bin/npm-cli.js"
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npm-publish-public.npmrc"
export NPM_CONFIG_GLOBALCONFIG=/dev/null
export NPM_CONFIG_CACHE="$RUNNER_TEMP/npm-publish-cache"
printf 'registry=https://registry.npmjs.org/\n' > "$NPM_CONFIG_USERCONFIG"
test "$(node "$npm_cli" --version)" = '12.2.0'
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
if node "$npm_cli" view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
node "$npm_cli" publish "$QINGLONG_PUBLISH_TARBALL" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
+161
View File
@@ -0,0 +1,161 @@
name: CLI package
on:
pull_request:
paths:
- 'cli/**'
- 'shell/**'
- 'back/api/**'
- 'back/loaders/deps.ts'
- 'package.json'
- '.github/workflows/cli-package.yml'
push:
branches: [develop, master]
paths:
- 'cli/**'
- 'shell/**'
- 'back/api/**'
- 'back/loaders/deps.ts'
- 'package.json'
- '.github/workflows/cli-package.yml'
workflow_dispatch:
inputs:
publish:
description: Publish the verified CLI package (master stable / develop prerelease)
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
type: string
default: ''
permissions:
contents: read
concurrency:
# Keep validation runs separate from releases; never interrupt a running release.
# Master push releases share the same group as manually requested releases.
group: >-
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
cancel-in-progress: >-
${{ github.ref != 'refs/heads/master' &&
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
jobs:
package:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: ['22.12.0', '24']
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: cli/package-lock.json
- name: Install test interpreters and tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
- name: Set requested prerelease version before verification
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
working-directory: cli
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash
run: |
set -euo pipefail
if [[ "$GITHUB_REF" != refs/heads/develop ||
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
exit 1
fi
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run check:cli
- run: npm run test:cli
- name: Verify offline package installation
env:
QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package
run: node cli/scripts/verify-package.cjs
- name: Upload verified npm archive
if: matrix.node == '24'
uses: actions/upload-artifact@v6
with:
name: qinglong-cli-${{ github.sha }}
path: ${{ runner.temp }}/cli-package/*.tgz
if-no-files-found: error
retention-days: 14
publish:
needs: package
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish))) ||
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
inputs.publish && inputs.prerelease_version != ''))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
id-token: write
steps:
- uses: actions/setup-node@v6
with:
node-version: '24'
package-manager-cache: false
registry-url: https://registry.npmjs.org
- uses: actions/download-artifact@v7
with:
name: qinglong-cli-${{ github.sha }}
path: cli-package
- name: Publish verified npm archive
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(cli-package/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified CLI archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
if [[ "$version" != "$PRERELEASE_VERSION" ||
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo '::error::Archive version does not match the requested prerelease.'
exit 1
fi
tag="${BASH_REMATCH[1]}"
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
tag=latest
else
echo '::error::Refusing to publish this branch/version combination.'
exit 1
fi
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
jq -e --arg version "$version" '. == $version' version.json > /dev/null
if [[ "$tag" != latest ]]; then
echo '::error::Prerelease version already exists; choose a new version to test this build.'
exit 1
fi
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
exit 0
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
echo '::error::Could not check the published CLI version; refusing to publish.'
exit 1
fi
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
+312
View File
@@ -0,0 +1,312 @@
name: Verify main npm archive
on:
workflow_call:
inputs:
static-artifact:
description: Complete static artifact including the same-source runtime tools
required: true
type: string
outputs:
artifact-name:
description: Main npm archive and producer summary, after every consumer passes
value: ${{ jobs.verified.outputs.artifact-name }}
tarball-sha256:
description: SHA256 of the exact archive consumed by all six cases
value: ${{ jobs.verified.outputs.tarball-sha256 }}
package-version:
description: Source package version verified against the archive
value: ${{ jobs.verified.outputs.package-version }}
permissions:
contents: read
env:
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
archive:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
artifact-name: ${{ steps.archive.outputs.artifact-name }}
tarball-sha256: ${{ steps.archive.outputs.tarball-sha256 }}
package-version: ${{ steps.archive.outputs.package-version }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.static-artifact }}
path: static/
- name: Verify tool artifact source before executing the producer tools
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const build = JSON.parse(fs.readFileSync('static/build-info.json'));
const tools = JSON.parse(fs.readFileSync('static/runtime-tools-proof.json'));
assert.equal(build.sourceCommit, process.env.SOURCE_SHA); assert.equal(build.dirty, false);
assert.equal(build.lockfileSha256, hash('pnpm-lock.yaml'));
assert.equal(tools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(tools.builder.platform, 'linux'); assert.equal(tools.builder.releaseBuild, true);
assert.equal(tools.sourceAttestation.verified, true); assert.equal(tools.sourceAttestation.head, process.env.SOURCE_SHA);
for (const name of ['runtime-tools.tgz', 'runtime-tools-proof.json']) assert.equal(hash(`static/${name}`), build.files[name]);
assert.equal(hash('static/runtime-tools.tgz'), tools.archive.sha256);
for (const input of tools.inputs) {
assert(input.path && !input.path.startsWith('/') && !input.path.split(/[\\/]/).includes('..'));
assert.equal(hash(input.path), input.sha256, `Runtime source input changed: ${input.path}`);
}
NODE
- name: Install fixed producer tools into an isolated prefix
run: |
node scripts/install-runtime-tools.cjs \
--archive "$GITHUB_WORKSPACE/static/runtime-tools.tgz" \
--proof "$GITHUB_WORKSPACE/static/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-producer-tools"
- name: Export the single frozen production graph
run: |
node scripts/build-npm-package.cjs \
--repo "$GITHUB_WORKSPACE" \
--source-ref "$SOURCE_SHA" \
--artifact-dir "$GITHUB_WORKSPACE/static" \
--pnpm-cli "$RUNNER_TEMP/npm-producer-tools/lib/node_modules/qinglong-runtime-tools/node_modules/pnpm/bin/pnpm.cjs" \
--npm-cli "$RUNNER_TEMP/npm-producer-tools/lib/node_modules/npm/bin/npm-cli.js" \
--output "$RUNNER_TEMP/qinglong-npm-export"
- name: Verify and retain the exact consumer archive
id: archive
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const root = path.join(process.env.RUNNER_TEMP, 'qinglong-npm-export');
const summary = JSON.parse(fs.readFileSync(path.join(root, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.deepEqual(summary.sourceMetadataChanges, []); assert(summary.helperProvenance.every((item) => item.matchesGitSource));
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.productionManifestSHA256, hash('package.json')); assert.equal(summary.productionLockSHA256, hash('pnpm-lock.yaml'));
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.runtimeTools.builder.releaseBuild, true);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename);
const tarball = path.join(root, 'artifacts', summary.tarball.filename);
assert.equal(hash(tarball), summary.tarball.sha256); assert.equal(fs.statSync(tarball).size, summary.tarball.bytes);
const delivery = path.join(process.env.RUNNER_TEMP, 'main-npm-delivery'); fs.mkdirSync(delivery);
fs.copyFileSync(tarball, path.join(delivery, summary.tarball.filename));
fs.copyFileSync(path.join(root, 'package-build-summary.json'), path.join(delivery, 'package-build-summary.json'));
fs.appendFileSync(process.env.GITHUB_OUTPUT, `artifact-name=qinglong-main-npm-${process.env.SOURCE_SHA}\ntarball-sha256=${summary.tarball.sha256}\npackage-version=${pkg.version}\n`);
NODE
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.archive.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-delivery/
compression-level: 0
if-no-files-found: error
- name: Preserve producer diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-main-npm-producer-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/qinglong-npm-export/logs/
if-no-files-found: ignore
consumer:
needs: archive
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
client:
- {npm: '10', mode: local}
- {npm: '12', mode: local}
- {npm: '12', mode: global}
sqlite: [prebuilt, source]
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ needs.archive.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-input/
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/runtime-tools-input/
- name: Verify archive and client tools against the producer source
env:
VERIFIED_TARBALL_SHA256: ${{ needs.archive.outputs.tarball-sha256 }}
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const directory = path.join(process.env.RUNNER_TEMP, 'main-npm-input');
const summary = JSON.parse(fs.readFileSync(path.join(directory, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.productionManifestSHA256, hash('package.json')); assert.equal(summary.productionLockSHA256, hash('pnpm-lock.yaml'));
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.tarball.sha256, process.env.VERIFIED_TARBALL_SHA256);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename);
assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith('.tgz')), [summary.tarball.filename]);
assert.equal(hash(path.join(directory, summary.tarball.filename)), process.env.VERIFIED_TARBALL_SHA256);
const tools = path.join(process.env.RUNNER_TEMP, 'runtime-tools-input');
assert.equal(hash(path.join(tools, 'runtime-tools.tgz')), summary.runtimeTools.archiveSHA256);
assert.equal(hash(path.join(tools, 'runtime-tools-proof.json')), summary.runtimeTools.proofSHA256);
NODE
- name: Install native compiler prerequisites
if: matrix.sqlite == 'source'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential python3
- name: Select the isolated npm client
env:
CLIENT_NPM: ${{ matrix.client.npm }}
shell: bash
run: |
set -euo pipefail
npm10_cli="$(node -p "require('node:path').join(require('node:path').dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')")"
if [[ "$(node "$npm10_cli" --version)" != 10.* ]]; then
echo '::error::The pinned Node22 client must supply npm10.'
exit 1
fi
node scripts/install-runtime-tools.cjs \
--archive "$RUNNER_TEMP/runtime-tools-input/runtime-tools.tgz" \
--proof "$RUNNER_TEMP/runtime-tools-input/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-consumer-tools"
if [[ "$CLIENT_NPM" == 10 ]]; then
npm_cli="$npm10_cli"
else
npm_cli="$RUNNER_TEMP/npm-consumer-tools/lib/node_modules/npm/bin/npm-cli.js"
fi
echo "QINGLONG_CONSUMER_NPM_CLI=$npm_cli" >> "$GITHUB_ENV"
- name: Consume the same archive and verify the selected SQLite installation
env:
CONSUMER_MODE: ${{ matrix.client.mode }}
SQLITE_INSTALL: ${{ matrix.sqlite }}
shell: bash
run: |
set -euo pipefail
extra_args=()
if [[ "$CONSUMER_MODE" == global ]]; then extra_args+=(--global); fi
if [[ "$SQLITE_INSTALL" == source ]]; then extra_args+=(--source-build); else extra_args+=(--require-prebuilt); fi
tarball="$(node -e 'const fs=require("node:fs"),p=require("node:path"); const d=process.argv[1]; const s=JSON.parse(fs.readFileSync(p.join(d,"package-build-summary.json"))); process.stdout.write(p.join(d,s.tarball.filename))' "$RUNNER_TEMP/main-npm-input")"
node scripts/verify-npm-package.cjs \
--tarball "$tarball" \
--build-summary "$RUNNER_TEMP/main-npm-input/package-build-summary.json" \
--npm-cli "$QINGLONG_CONSUMER_NPM_CLI" \
--output "$RUNNER_TEMP/qinglong-npm-consumer" \
"${extra_args[@]}"
- name: Preserve actual consumer evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-main-npm-consumer-${{ matrix.client.npm }}-${{ matrix.client.mode }}-${{ matrix.sqlite }}-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/qinglong-npm-consumer/*.log
${{ runner.temp }}/qinglong-npm-consumer/npm-consumer-validation.json
if-no-files-found: ignore
source-fallback:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.static-artifact }}
path: static/
- name: Install native compiler prerequisites
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential python3
- name: Prepare source using an older manager and the same verified static tools
env:
npm_config_build_from_source: 'true'
shell: bash
run: |
set -euo pipefail
mkdir "$RUNNER_TEMP/source-tool-fallback"
git archive "$SOURCE_SHA" | tar -x -C "$RUNNER_TEMP/source-tool-fallback"
npm10_cli="$(node -p "require('node:path').join(require('node:path').dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')")"
printf 'registry=https://registry.npmjs.org/\n' > "$RUNNER_TEMP/legacy-pnpm-public.npmrc"
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/legacy-pnpm-public.npmrc"
export npm_config_userconfig="$NPM_CONFIG_USERCONFIG"
export NPM_CONFIG_GLOBALCONFIG=/dev/null npm_config_globalconfig=/dev/null
export NPM_CONFIG_CACHE="$RUNNER_TEMP/legacy-pnpm-cache"
export PNPM_HOME="$RUNNER_TEMP/legacy-pnpm-home"
export XDG_CACHE_HOME="$RUNNER_TEMP/legacy-pnpm-xdg-cache" XDG_STATE_HOME="$RUNNER_TEMP/legacy-pnpm-xdg-state"
node "$npm10_cli" install --global pnpm@8.3.1 \
--prefix "$RUNNER_TEMP/legacy-pnpm" --ignore-scripts --no-audit --no-fund \
--userconfig "$RUNNER_TEMP/legacy-pnpm-public.npmrc" --globalconfig /dev/null \
--cache "$RUNNER_TEMP/legacy-pnpm-cache" --registry=https://registry.npmjs.org \
2>&1 | tee "$RUNNER_TEMP/legacy-pnpm-install.log"
legacy_cli="$RUNNER_TEMP/legacy-pnpm/lib/node_modules/pnpm/bin/pnpm.cjs"
node "$legacy_cli" --version | tee "$RUNNER_TEMP/legacy-pnpm-version.log"
test "$(cat "$RUNNER_TEMP/legacy-pnpm-version.log")" = '8.3.1'
node -e 'const fs=require("node:fs"),os=require("node:os"); fs.writeFileSync(process.argv[1],JSON.stringify(fs.readdirSync(os.tmpdir()).filter(n=>n.startsWith("qinglong-source-tools-")).sort()))' "$RUNNER_TEMP/source-tool-prefixes-before.json"
node scripts/install-panel-dependencies.cjs \
--root "$RUNNER_TEMP/source-tool-fallback" \
--pnpm-cli "$legacy_cli" \
--static-dir "$GITHUB_WORKSPACE/static" \
--source-commit "$SOURCE_SHA" \
2>&1 | tee "$RUNNER_TEMP/source-tool-fallback.log"
grep -q 'SOLINK_MODULE' "$RUNNER_TEMP/source-tool-fallback.log"
grep -q 'node-gyp@12.4.0' "$RUNNER_TEMP/source-tool-fallback.log"
grep -q '"prefixScope":"fresh temporary only"' "$RUNNER_TEMP/source-tool-fallback.log"
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), os = require('node:os'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const root = path.join(process.env.RUNNER_TEMP, 'source-tool-fallback');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(hash(path.join(root, 'pnpm-lock.yaml')), hash('pnpm-lock.yaml'));
const before = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'source-tool-prefixes-before.json')));
assert.deepEqual(fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('qinglong-source-tools-')).sort(), before);
const requireSource = createRequire(path.join(root, 'package.json')); requireSource('sqlite3');
const binding = Object.keys(require.cache).find((file) => file.endsWith('.node'));
assert(binding); assert.equal(fs.readFileSync(binding).subarray(0, 4).toString('hex'), '7f454c46');
fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'source-tool-native-proof.json'), JSON.stringify({ sourceCommit: process.env.SOURCE_SHA, productionLockSHA256: hash('pnpm-lock.yaml'), legacyManagerVersion: '8.3.1', legacyManagerCliSHA256: hash(path.join(process.env.RUNNER_TEMP, 'legacy-pnpm/lib/node_modules/pnpm/bin/pnpm.cjs')), binding: path.relative(root, binding), bindingSHA256: hash(binding), temporaryPrefixRemoved: true }, null, 2));
NODE
node "$RUNNER_TEMP/source-tool-fallback/scripts/verify-production-dependencies.cjs" \
2>&1 | tee "$RUNNER_TEMP/source-tool-fallback-protocols.log"
- name: Preserve source migration evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-source-tool-fallback-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/legacy-pnpm-install.log
${{ runner.temp }}/legacy-pnpm-version.log
${{ runner.temp }}/source-tool-fallback.log
${{ runner.temp }}/source-tool-fallback-protocols.log
${{ runner.temp }}/source-tool-native-proof.json
if-no-files-found: ignore
verified:
needs: [archive, consumer, source-fallback]
runs-on: ubuntu-latest
outputs:
artifact-name: ${{ needs.archive.outputs.artifact-name }}
tarball-sha256: ${{ needs.archive.outputs.tarball-sha256 }}
package-version: ${{ needs.archive.outputs.package-version }}
steps:
- run: echo 'The exact main npm archive passed six Linux consumers and the older-manager source migration.'
+194
View File
@@ -0,0 +1,194 @@
name: Release readiness
on:
pull_request:
paths:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- 'pnpm-lock.yaml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
regression:
uses: ./.github/workflows/validate.yml
runtime-tools:
uses: ./.github/workflows/runtime-tools.yml
static:
needs: runtime-tools
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm audit --prod --audit-level high
- run: pnpm build:front && pnpm build:back
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: static/
- run: pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: release-readiness-static
path: static/
include-hidden-files: true
if-no-files-found: error
sqlite-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: pnpm
- run: pnpm install --prod --frozen-lockfile
- name: Verify precompiled SQLite and production protocols
run: node scripts/verify-production-dependencies.cjs
- name: Rebuild SQLite from source
env:
npm_config_build_from_source: 'true'
PYTHON: /usr/bin/python3
run: |
export npm_config_node_gyp="$(node -p 'require("node:module").createRequire(require.resolve("sqlite3/package.json")).resolve("node-gyp/bin/node-gyp.js")')"
pnpm rebuild @whyour/sqlite3
- name: Verify source-built SQLite and production protocols
run: node scripts/verify-production-dependencies.cjs
npm-package:
needs: static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: release-readiness-static
images:
needs: [regression, static, sqlite-install]
runs-on: ubuntu-22.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- image: alpine
file: docker/Dockerfile
cache: alpine
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Record source
id: source
env:
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v7
with:
name: release-readiness-static
path: static/
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build every release architecture
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }}
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
push: false
provenance: false
sbom: false
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
- name: Load native image for runtime verification
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: linux/amd64
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
tags: qinglong-readiness:${{ matrix.image }}
load: true
push: false
provenance: false
sbom: false
- name: Verify native production runtime without external networking
run: |
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
--eval 'console.log(JSON.stringify({node: process.version, arch: process.arch, platform: process.platform}))'
docker run --rm --network none --entrypoint python3 \
qinglong-readiness:${{ matrix.image }} --version
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
scripts/verify-system-runtime.cjs
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
scripts/verify-production-dependencies.cjs
- name: Archive verified native Debian image for isolated dev validation
if: matrix.image == 'debian'
shell: bash
run: |
docker image inspect qinglong-readiness:debian \
--format '{"id":{{json .Id}},"revision":{{json (index .Config.Labels "org.opencontainers.image.revision")}},"architecture":{{json .Architecture}},"os":{{json .Os}}}' \
> release-readiness-debian-image.json
docker save qinglong-readiness:debian | gzip > release-readiness-debian-image.tar.gz
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian'
with:
name: release-readiness-debian-image
path: |
release-readiness-debian-image.tar.gz
release-readiness-debian-image.sha256
release-readiness-debian-image.json
compression-level: 0
retention-days: 3
if-no-files-found: error
+40
View File
@@ -0,0 +1,40 @@
name: Build verified runtime tools
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
tools:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- name: Build the pinned universal Linux tool archive
env:
QL_TOOLS_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash scripts/build-runtime-tools.sh "$RUNNER_TEMP/qinglong-runtime-tools"
- uses: actions/upload-artifact@v6
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/qinglong-runtime-tools/runtime-tools.tgz
${{ runner.temp }}/qinglong-runtime-tools/runtime-tools-proof.json
compression-level: 0
if-no-files-found: error
- name: Preserve build diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-runtime-tools-diagnostics-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/qinglong-runtime-tools/logs/
if-no-files-found: ignore
+36
View File
@@ -0,0 +1,36 @@
name: Validate
on:
pull_request:
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
regression:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: pnpm test
- run: pnpm build:back
- run: pnpm build:front
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
- uses: actions/upload-artifact@v6
with:
name: execution-baseline-node-${{ matrix.node }}
path: execution-baseline.json
+3
View File
@@ -35,3 +35,6 @@ __pycache__
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
# Local security audit artifacts
/audit/
+2 -1
View File
@@ -1 +1,2 @@
strict-peer-dependencies=false
strict-peer-dependencies=false
strict-dep-builds=true
+34 -4
View File
@@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support system level notification
- Support dark mode
- Support cell phone operation
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
## Version
@@ -61,10 +62,14 @@ docker run -d \
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
The npm deployment supports `debian/ubuntu/alpine` with Node.js 22.x >=22.22.2 or 24.x >=24.15.0, npm, Python 3 and pip. The 2.23.0 archive includes frozen production dependencies and verified runtime tools; a separate pnpm installation is unnecessary. After this version is published, use the command below. With npm 12, approve this exact package lifecycle in your project's `package.json` first:
```json
{"allowScripts":{"@whyour/qinglong@2.23.0":true}}
```
```bash
npm i @whyour/qinglong
npm i @whyour/qinglong@2.23.0
```
## Deployment
@@ -79,15 +84,40 @@ npm i @whyour/qinglong
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Remote CLI and Agent Skill
Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents.
Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`:
```bash
npm install -g @whyour/qinglong-cli
ql login --url https://ql.example.com
ql auth status --json
ql task list --json
ql subscription list --json
ql --help
```
Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help.
The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md).
[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md)
The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md).
## Development
Use Node.js 22.x starting at 22.22.2, or Node.js 24.x starting at 24.15.0.
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
npm install -g pnpm@10.34.6
pnpm install --frozen-lockfile
pnpm start
```
+34 -4
View File
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持系统级通知
- 支持暗黑模式
- 支持手机端操作
- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板
## 版本
@@ -63,10 +64,14 @@ docker run -d \
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 Node.js 22.x >=22.22.2 或 24.x >=24.15.0,以及 npm、Python 3 和 pip。2.23.0 归档携带冻结生产依赖和受控工具,不需要单独安装 pnpm。该版本发布后使用以下命令;npm 12 的项目安装需要在当前项目 `package.json` 中精确批准安装脚本:
```json
{"allowScripts":{"@whyour/qinglong@2.23.0":true}}
```
```bash
npm i @whyour/qinglong
npm i @whyour/qinglong@2.23.0
```
## 部署
@@ -81,15 +86,40 @@ npm i @whyour/qinglong
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 远程 CLI 与 Agent Skill
使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。
需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令:
```bash
npm install -g @whyour/qinglong-cli
ql login --url https://ql.example.com
ql auth status --json
ql task list --json
ql subscription list --json
ql --help
```
在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。
npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。
[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md)
包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。
## 开发
使用 Node.js 22.22.2 及以上的 22.x,或 Node.js 24.x >=24.15.0。
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
npm install -g pnpm@10.34.6
pnpm install --frozen-lockfile
pnpm start
```
+55
View File
@@ -0,0 +1,55 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import {
diagnoseClientIp,
getTrustProxyConfig,
updateTrustProxy,
} from '../shared/trustProxy';
const route = Router();
export default (app: Router) => {
app.use('/system/client-ip', route);
route.get(
'/config',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await getTrustProxyConfig() });
} catch (error) {
next(error);
}
},
);
route.put(
'/config',
celebrate({
body: Joi.object({
trustProxy: Joi.string().max(500).required(),
}),
}),
async (req: Request, res: Response) => {
try {
const data = await updateTrustProxy(req.body.trustProxy);
res.send({ code: 200, data });
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : '配置更新失败',
});
}
},
);
route.get(
'/diagnose',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await diagnoseClientIp(req) });
} catch (error) {
next(error);
}
},
);
};
+14 -24
View File
@@ -4,11 +4,11 @@ import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import { join, basename } from 'path';
import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils';
import { resolveFileAccess } from '../shared/fileAccess';
const route = Router();
export default (app: Router) => {
@@ -72,20 +72,15 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const { name, content } = req.body;
// Resolve path first to prevent traversal attacks
let basePath = config.configPath;
if (name.startsWith('data/scripts/')) {
basePath = join(config.rootPath, 'data/scripts');
return res.send({ code: 403, message: t('文件无法访问') });
}
const cleanName = name.replace(/^data\/scripts\//, '');
const resolvedPath = join(basePath, cleanName);
const normalized = join(resolvedPath);
// Verify the resolved path stays within allowed directory
if (!normalized.startsWith(basePath)) {
return res.send({ code: 403, message: t('文件路径无效') });
}
// Check blacklist on actual filename (not user input)
if (config.blackFileList.includes(basename(normalized))) {
const normalized = resolveFileAccess(
config.configPath,
[name],
config.blackFileList,
);
if (!normalized) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
@@ -96,15 +91,10 @@ export default (app: Router) => {
},
);
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const configService = Container.get(ConfigService);
await configService.getFile(req.params.file, res);
} catch (e) {
return next(e);
}
},
);
route.get('/:file', (req: Request, res: Response) => {
return res.send({
code: 410,
message: t('接口已下线,请使用 /configs/detail 接口'),
});
});
};
+38 -2
View File
@@ -10,12 +10,26 @@ import {
InstanceStatus,
} from '../data/runningInstance';
import { t } from '../shared/i18n';
import cronClient from '../schedule/client';
const route = Router();
export default (app: Router) => {
app.use('/crons', route);
route.use(async (req, res, next) => {
// Keep stop/status callbacks available even when the scheduler is down.
if (['POST', 'PUT', 'DELETE'].includes(req.method) &&
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) {
try {
await cronClient.readiness.ensureReady();
} catch (error) {
return next(error);
}
}
return next();
});
route.get(
'/views',
async (req: Request, res: Response, next: NextFunction) => {
@@ -307,13 +321,35 @@ export default (app: Router) => {
params: Joi.object({
id: Joi.number().required(),
}),
query: Joi.object({
offset: Joi.number().integer().min(0).optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
tail: Joi.boolean().optional(),
t: Joi.string().optional(),
}).unknown(true),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const result = await cronService.log(req.params.id);
return res.send({ code: 200, data: result.content, logStatus: result.status });
const result = await cronService.log(req.params.id, {
offset: req.query.offset as unknown as number,
limit: req.query.limit as unknown as number,
tail: req.query.tail as unknown as boolean,
});
return res.send({
code: 200,
data: result.content,
logStatus: result.status,
offset: result.offset,
nextOffset: result.nextOffset,
total: result.total,
truncated: result.truncated,
});
} catch (e) {
return next(e);
}
+40
View File
@@ -107,6 +107,46 @@ export default (app: Router) => {
},
);
for (const isSuccess of [false, true]) {
route.get(
isSuccess ? '/successes' : '/failures',
async (req: Request, res: Response, next: NextFunction) => {
try {
const countColumn = isSuccess ? 'success_count' : 'fail_count';
const rows = (await CrontabStatModel.findAll({
attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']],
where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']],
raw: true,
})) as any[];
const crons = rows.length > 0 ? await CrontabModel.findAll({
attributes: ['id', 'name', 'command'],
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
raw: true,
}) : [];
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
res.send({
code: 200,
data: rows.map((row) => {
const id = Number(row.ref_id);
const cron = cronMap.get(id);
return {
id,
name: cron?.name || cron?.command || tf('任务#%s', id),
command: cron?.command || '',
[isSuccess ? 'successCount' : 'failCount']: Number(row.result_count),
deleted: !cron,
};
}),
});
} catch (e) {
next(e);
}
},
);
}
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
+17 -14
View File
@@ -1,24 +1,16 @@
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
import { t } from '../shared/i18n';
import EnvService from '../services/env';
const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.scriptPath);
},
filename: function (req, file, cb) {
cb(null, file.originalname);
},
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
const upload = multer({ storage: storage });
const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
@@ -282,12 +274,23 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
const parseContent = safeJSONParse(fileContent);
let parseContent: unknown;
try {
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
} catch {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
const data = Array.isArray(parseContent)
? parseContent
: [parseContent];
if (data.every((x) => x.name && x.value)) {
if (
data.length > 0 &&
data.every((x) =>
x && typeof x.name === 'string' &&
/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) &&
typeof x.value === 'string' && x.value.length > 0,
)
) {
const result = await envService.create(
data.map((x) => ({
name: x.name,
+2 -2
View File
@@ -11,8 +11,8 @@ export default (app: Router) => {
try {
const healthService = Container.get(HealthService);
const health = await healthService.check();
res.status(200).send({
code: 200,
res.status(health.status === 'ok' ? 200 : 503).send({
code: health.status === 'ok' ? 200 : 503,
data: health,
});
} catch (err: any) {
+4
View File
@@ -12,6 +12,8 @@ import subscription from './subscription';
import update from './update';
import dashboard from './dashboard';
import health from './health';
import clientIp from './clientIp';
import retention from './retention';
export default () => {
const app = Router();
@@ -28,6 +30,8 @@ export default () => {
update(app);
dashboard(app);
health(app);
clientIp(app);
retention(app);
return app;
};
+30 -21
View File
@@ -5,13 +5,13 @@ import { Logger } from 'winston';
import config from '../config';
import { t } from '../shared/i18n';
import {
getFileContentByName,
readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
const route = Router();
const blacklist = ['.tmp'];
@@ -34,6 +34,20 @@ export default (app: Router) => {
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().allow('').optional(),
file: Joi.string().required(),
offset: Joi.number().integer().min(0).optional(),
limit: Joi.number()
.integer()
.min(1)
.max(MAX_LOG_CHUNK_BYTES)
.optional(),
tail: Joi.boolean().optional(),
t: Joi.string().optional(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
@@ -52,11 +66,19 @@ export default (app: Router) => {
where: { log_path: logPath, status: InstanceStatus.running },
});
const content = await getFileContentByName(finalPath);
const chunk = await readLogChunk(finalPath, {
offset: req.query.offset as unknown as number,
limit: req.query.limit as unknown as number,
tail: req.query.tail as unknown as boolean,
});
res.send({
code: 200,
data: removeAnsi(content),
data: removeAnsi(chunk.content),
logStatus: runningInstance ? 'running' : undefined,
offset: chunk.offset,
nextOffset: chunk.nextOffset,
total: chunk.total,
truncated: chunk.truncated,
});
} catch (e) {
return next(e);
@@ -66,24 +88,11 @@ export default (app: Router) => {
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.params.file as string) || '',
);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
(req: Request, res: Response) => {
return res.send({
code: 410,
message: t('接口已下线,请使用 /logs/detail 接口'),
});
},
);
+2
View File
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
import OpenService from '../services/open';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import { rateLimit } from 'express-rate-limit';
const route = Router();
export default (app: Router) => {
@@ -100,6 +101,7 @@ export default (app: Router) => {
route.get(
'/auth/token',
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
celebrate({
query: {
client_id: Joi.string().required(),
+78
View File
@@ -0,0 +1,78 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { Container } from 'typedi';
import RetentionService from '../services/retention';
import { MAX_RETENTION_DAYS } from '../shared/retention';
const route = Router();
const policySchema = {
runningInstanceRetentionDays: Joi.number()
.integer()
.min(0)
.max(MAX_RETENTION_DAYS)
.required(),
cronStatRetentionDays: Joi.number()
.integer()
.min(0)
.max(MAX_RETENTION_DAYS)
.required(),
};
const cleanupSchema = {
...policySchema,
dependenceCacheTypes: Joi.array()
.items(Joi.string().valid('node', 'python3'))
.unique()
.default([]),
compactDatabase: Joi.boolean().default(false),
};
export default (app: Router) => {
app.use('/system/storage-retention', route);
route.put(
'/config',
celebrate({ body: Joi.object(policySchema) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(RetentionService);
const data = await service.updatePolicy(req.body);
res.send({ code: 200, data });
} catch (error) {
next(error);
}
},
);
route.post(
'/preview',
celebrate({ body: Joi.object(cleanupSchema) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(RetentionService);
const data = await service.preview(req.body);
res.send({ code: 200, data });
} catch (error) {
next(error);
}
},
);
route.post(
'/cleanup',
celebrate({
body: Joi.object({
...cleanupSchema,
confirmation: Joi.string().valid('CLEAN').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(RetentionService);
const data = await service.cleanup(req.body);
res.send({ code: 200, data });
} catch (error) {
next(error);
}
},
);
};
+215 -26
View File
@@ -1,3 +1,5 @@
import { randomUUID } from 'crypto';
import { resolveFileAccess } from '../shared/fileAccess';
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
@@ -5,24 +7,68 @@ import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import path, { join, parse } from 'path';
import path, { dirname, join, parse, resolve, sep } from 'path';
import ScriptService from '../services/script';
import ScriptHistoryService from '../services/scriptHistory';
import { t } from '../shared/i18n';
import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
import {
ScriptHistoryError,
HistoryUnavailableError,
} from '../shared/scriptHistory';
const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((x) => resolved.startsWith(x));
return config.writePathList.some((x) =>
Boolean(
resolveFileAccess(
x,
[resolved],
// Panel configuration secrets must not restrict user script filenames.
path.resolve(x) === path.resolve(config.configPath)
? config.blackFileList
: [],
),
),
);
}
async function existingWritableRealPath(targetPath: string): Promise<string> {
if (!isPathAllowed(targetPath)) {
throw new ScriptHistoryError(403, '暂无权限');
}
const resolved = resolve(targetPath);
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const rootPrefix = root.endsWith(sep) ? root : root + sep;
// Keep an explicit normalized boundary check at the filesystem operation.
if (!resolved.startsWith(rootPrefix)) continue;
const realPath = await fs.realpath(resolved);
const realRoot = await fs.realpath(root);
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
if (
!realPath.startsWith(realRootPrefix) ||
!resolveFileAccess(
realRoot,
[realPath],
root === resolve(config.configPath) ? config.blackFileList : [],
)
) {
throw new ScriptHistoryError(403, '暂无权限');
}
return realPath;
}
throw new ScriptHistoryError(403, '暂无权限');
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.scriptPath);
cb(null, config.tmpPath);
},
filename: function (req, file, cb) {
cb(null, file.originalname);
cb(null, randomUUID());
},
});
const upload = multer({ storage: storage });
@@ -50,6 +96,11 @@ export default (app: Router) => {
'package-lock.json',
];
if (req.query.path) {
if (
!resolveFileAccess(config.scriptPath, [req.query.path as string])
) {
return res.send({ code: 403, message: t('暂无权限') });
}
result = await readDir(
req.query.path as string,
config.scriptPath,
@@ -77,7 +128,8 @@ export default (app: Router) => {
logger.error('🔥 error: %o', e);
return next(e);
}
});
},
);
route.get(
'/detail',
@@ -91,7 +143,7 @@ export default (app: Router) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
(req.query?.path as string) || '',
req.query.file as string,
);
res.send({ code: 200, data: content });
@@ -101,38 +153,108 @@ export default (app: Router) => {
},
);
const historyError = (error: unknown, res: Response, next: NextFunction) => {
if (error instanceof ScriptHistoryError) {
return res.status(error.status).send({
code: error.status,
message: t(error.message),
...(error instanceof HistoryUnavailableError
? { historyUnavailable: true, currentHash: error.currentHash }
: {}),
});
}
return next(error);
};
const historyQuery = {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
};
route.get(
'/:file',
'/history',
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(ScriptHistoryService);
const data = await service.list(
(req.query.path as string) || '',
req.query.filename as string,
);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.get(
'/history/detail',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
...historyQuery,
id: Joi.string().uuid().required(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
req.params.file,
const service = Container.get(ScriptHistoryService);
const data = await service.detail(
(req.query.path as string) || '',
req.query.filename as string,
req.query.id as string,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.put(
'/history/restore',
celebrate({
body: Joi.object({
...historyQuery,
id: Joi.string().uuid().required(),
expectedHash: Joi.string().hex().length(64).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { path = '', filename, id, expectedHash } = req.body;
const service = Container.get(ScriptHistoryService);
const data = await service.restore(path, filename, id, expectedHash);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.get('/:file', (req: Request, res: Response) => {
return res.send({
code: 410,
message: t('接口已下线,请使用 /scripts/detail 接口'),
});
});
route.post(
'/',
(req: Request, res: Response, next: NextFunction) => {
res.on('finish', () => {
if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined);
});
next();
},
upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
skipHistory: Joi.boolean().optional(),
expectedHash: Joi.string()
.hex()
.length(64)
.when('skipHistory', { is: true, then: Joi.required() }),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
@@ -170,7 +292,8 @@ export default (app: Router) => {
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(req.file.path, uploadPath);
await fs.copyFile(req.file.path, uploadPath);
await fs.unlink(req.file.path);
return res.send({ code: 200 });
}
@@ -191,8 +314,56 @@ export default (app: Router) => {
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(path, { recursive: true });
// Check the canonical parent directly at the directory creation boundary.
const parentPath = resolve(dirname(filePath));
let parentCreated = false;
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const rootPrefix = root.endsWith(sep) ? root : root + sep;
if (parentPath === root) {
await fs.mkdir(root, { recursive: true });
} else if (parentPath.startsWith(rootPrefix)) {
await fs.mkdir(parentPath, { recursive: true });
} else {
continue;
}
parentCreated = true;
break;
}
if (!parentCreated) {
return res.send({ code: 403, message: t('暂无权限') });
}
const fileExists = await fileExist(filePath);
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
let removeSource = filename !== originFilename;
let backupSource = '';
if (removeSource) {
const [originRealPath, targetRealPath] = await Promise.all([
existingWritableRealPath(originFilePath),
existingWritableRealPath(filePath),
]);
// Aliases of one script are an in-place save. Removing the source
// would also remove the destination behind a target symlink.
removeSource = originRealPath !== targetRealPath;
backupSource = originRealPath;
}
// Save-as removes the source after committing the destination. Keep
// its original content too: destination history only protects the
// file being overwritten, not the source being deleted.
if (removeSource) {
await fs.copyFile(
backupSource,
join(config.bakPath, originFilename.replace(/\//g, '')),
);
}
const service = Container.get(ScriptHistoryService);
const data = await service.save(path, filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
if (removeSource) await rmPath(originFilePath);
return res.send({ code: 200, data });
}
if (fileExists) {
await fs.copyFile(
originFilePath,
@@ -205,7 +376,7 @@ export default (app: Router) => {
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
return historyError(e, res, next);
}
},
);
@@ -217,6 +388,11 @@ export default (app: Router) => {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().required().allow(''),
skipHistory: Joi.boolean().optional(),
expectedHash: Joi.string()
.hex()
.length(64)
.when('skipHistory', { is: true, then: Joi.required() }),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -234,10 +410,14 @@ export default (app: Router) => {
message: t('暂无权限'),
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
const service = Container.get(ScriptHistoryService);
const data = await service.save(path || '', filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
return res.send({ code: 200, data });
} catch (e) {
return next(e);
return historyError(e, res, next);
}
},
);
@@ -364,12 +544,21 @@ export default (app: Router) => {
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = join(config.logPath, path, `${name}.swap`);
const logPath = resolveFileAccess(config.logPath, [
path,
`${name}.swap`,
]);
if (!logPath) {
return res.send({ code: 403, message: t('暂无权限') });
}
const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => {
rmPath(logPath);
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
if (cleanupPath) {
void rmPath(cleanupPath);
}
}, 3000);
res.send(result);
} catch (e) {
+19 -12
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import { isValidCronSchedule } from '../shared/cronSchedule';
const route = Router();
export default (app: Router) => {
@@ -58,10 +58,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data });
@@ -148,13 +145,27 @@ export default (app: Router) => {
params: Joi.object({
id: Joi.number().required(),
}),
query: Joi.object({
offset: Joi.number().integer().min(0).optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
tail: Joi.boolean().optional(),
t: Joi.string().optional(),
}).unknown(true),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.log(req.params.id);
return res.send({ code: 200, data });
const result = await subscriptionService.log(req.params.id, {
offset: req.query.offset as unknown as number,
limit: req.query.limit as unknown as number,
tail: req.query.tail as unknown as boolean,
});
return res.send({ code: 200, data: result.content, ...result });
} catch (e) {
return next(e);
}
@@ -190,11 +201,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data });
+24 -10
View File
@@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user';
import { t } from '../shared/i18n';
import { isDefaultAuthInfo } from '../shared/auth';
import {
getUniqPath,
handleLogPath,
parseVersion,
promiseExec,
} from '../config/util';
import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
import { findCronId } from '../shared/cronCommand';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
import { rateLimit } from 'express-rate-limit';
const route = Router();
const storage = multer.diskStorage({
@@ -249,6 +246,7 @@ export default (app: Router) => {
route.put(
'/command-run',
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
celebrate({
body: Joi.object({
command: Joi.string().required(),
@@ -258,8 +256,13 @@ export default (app: Router) => {
try {
const systemService = Container.get(SystemService);
const command = req.body.command;
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
let id = await promiseExec(idStr);
const crontab = await fs
.readFile(config.crontabFile, 'utf8')
.catch((e) => {
if (e.code === 'ENOENT') return '';
throw e;
});
const id = findCronId(crontab, command);
const uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`;
@@ -273,8 +276,13 @@ export default (app: Router) => {
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
try {
await logStreamManager.closeStream(
await handleLogPath(logPath),
);
} finally {
res.end();
}
},
onError: async (message: string) => {
res.write(message);
@@ -350,6 +358,11 @@ export default (app: Router) => {
query: {
startTime: Joi.string().allow('').optional(),
endTime: Joi.string().allow('').optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
t: Joi.string().optional(),
},
}),
@@ -361,6 +374,7 @@ export default (app: Router) => {
req.query as {
startTime?: string;
endTime?: string;
limit?: number;
},
);
} catch (e) {
+86 -13
View File
@@ -22,7 +22,25 @@ const storage = multer.diskStorage({
cb(null, key + ext);
},
});
const upload = multer({ storage: storage });
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const imageTypes: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
};
if (imageTypes[ext] !== file.mimetype) {
return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片')));
}
cb(null, true);
},
});
export default (app: Router) => {
app.use('/user', route);
@@ -35,8 +53,8 @@ export default (app: Router) => {
}),
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -70,8 +88,8 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -80,8 +98,8 @@ export default (app: Router) => {
return res.send({ code: 450, message: t('未知错误') });
}
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: t('更新成功') });
const result = await userService.updateUsernameAndPassword(req.body);
res.send(result);
} catch (e) {
return next(e);
}
@@ -156,11 +174,12 @@ export default (app: Router) => {
route.put(
'/two-factor/login',
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
celebrate({
body: Joi.object({
code: Joi.string().required(),
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -189,6 +208,59 @@ export default (app: Router) => {
},
);
route.get(
'/ip-blacklist',
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.getIpBlacklist();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/ip-blacklist',
celebrate({
body: Joi.object({
ip: Joi.string()
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
.required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.blockIp(req.body.ip);
res.send({ code: 200, data, message: t('已加入 IP 黑名单') });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/ip-blacklist',
celebrate({
body: Joi.object({
ip: Joi.string()
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
.required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.unblockIp(req.body.ip);
res.send({ code: 200, data, message: t('已移出 IP 黑名单') });
} catch (e) {
return next(e);
}
},
);
route.get(
'/notification',
async (req: Request, res: Response, next: NextFunction) => {
@@ -219,18 +291,19 @@ export default (app: Router) => {
route.put(
'/init',
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
username: Joi.string().max(1024).required(),
password: Joi.string().max(1024).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: t('更新成功') });
const result = await userService.initializeUser(req.body);
res.send(result);
} catch (e) {
return next(e);
}
+49 -40
View File
@@ -1,14 +1,10 @@
import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import type express from 'express';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring';
import { errStack } from './config/util';
import { errStack } from './shared/errors';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
@@ -20,24 +16,12 @@ interface WorkerMetadata {
}
class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() {
this.app = express();
// 创建一个全局中间件,删除查询参数中的t
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.t) {
delete req.query.t;
}
next();
});
}
async start() {
try {
if (cluster.isPrimary) {
@@ -79,6 +63,11 @@ class Application {
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
try {
this.httpWorker?.send('scheduler-unavailable');
} catch (error) {
Logger.warn('Unable to notify HTTP worker of scheduler exit');
}
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
@@ -132,7 +121,14 @@ class Application {
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
// PM2's fork launcher is inherited by our own cluster workers. Their APM
// messages go to this primary, not PM2, and duplicate its sampling work.
// Keep primary monitoring and allow restoring the inherited worker APM.
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
workerEnv.pmx = 'false';
}
const worker = cluster.fork(workerEnv);
this.workerMetadataMap.set(worker.id, {
id: worker.id,
@@ -145,17 +141,37 @@ class Application {
}
private async initializeDatabase() {
const dbLoader = await import('./loaders/db');
await dbLoader.default();
const { runStartupProcess } = await import('./shared/startupProcess');
await runStartupProcess(require.resolve('./bootstrap/database'));
}
private setupMiddlewares() {
this.app.use(helmet({
private async setupMiddlewares(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('./middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
private setupMasterShutdown() {
@@ -234,16 +250,16 @@ class Application {
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
this.setupMiddlewares();
const app = await this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
await appLoader.default({ app });
const server = await this.httpServerService.initialize(
this.app,
app,
config.port,
);
@@ -264,17 +280,10 @@ class Application {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('[boot] Cron jobs re-registered successfully');
} catch (error) {
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
}
} else if (serviceType === 'http' &&
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
const { default: cronClient } = await import('./schedule/client');
cronClient.readiness.invalidate();
}
});
+26
View File
@@ -0,0 +1,26 @@
import { errStack } from '../shared/errors';
// Also terminate if the primary disappears without forwarding a signal.
const onParentDisconnect = () => process.exit(1);
process.once('disconnect', onParentDisconnect);
async function initializeDatabase() {
const { sequelize } = await import('../data');
try {
const { default: loadDatabase } = await import('../loaders/db');
await loadDatabase();
} finally {
await sequelize.close();
}
}
initializeDatabase()
.catch((error) => {
console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
process.exitCode = 1;
})
.finally(() => {
process.removeListener('disconnect', onParentDisconnect);
// Let pending log writes drain and exit naturally after closing SQLite.
if (process.connected) process.disconnect();
});
+1
View File
@@ -51,6 +51,7 @@ export const NotificationModeStringMap = {
19: 'ntfy',
20: 'wxPusherBot',
21: 'wxPusherSpt',
22: 'wpush',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
+93 -45
View File
@@ -1,6 +1,5 @@
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import * as fs from 'fs/promises';
import * as os from 'os';
import path from 'path';
import config from './index';
import { fileExist } from './util';
@@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null;
function run(cmd: string, execOpts?: Record<string, unknown>): string {
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
return (execSync(cmd, opts) as string).trim();
}
async function tmpFile(prefix: string): Promise<string> {
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
await fs.mkdir(dir, { recursive: true });
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
function run(args: string[]): string {
return execFileSync('openssl', args, {
stdio: 'pipe',
timeout: 30000,
encoding: 'utf-8',
}).trim();
}
async function generateAllCerts(): Promise<GrpcTlsConfig> {
Logger.info('[boot] Generating gRPC mTLS certificates...');
const caKeyTmp = await tmpFile('ca_key');
const caCertTmp = await tmpFile('ca_cert');
const serverKeyTmp = await tmpFile('server_key');
const serverCsrTmp = await tmpFile('server_csr');
const serverExtTmp = await tmpFile('server_ext');
const clientKeyTmp = await tmpFile('client_key');
const clientCsrTmp = await tmpFile('client_csr');
const clientExtTmp = await tmpFile('client_ext');
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
const caKeyTmp = path.join(workspace, 'ca.key');
const caCertTmp = path.join(workspace, 'ca.crt');
const serverKeyTmp = path.join(workspace, 'server.key');
const serverCsrTmp = path.join(workspace, 'server.csr');
const serverExtTmp = path.join(workspace, 'server.ext');
const clientKeyTmp = path.join(workspace, 'client.key');
const clientCsrTmp = path.join(workspace, 'client.csr');
const clientExtTmp = path.join(workspace, 'client.ext');
const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
await fs.rm(workspace, { recursive: true, force: true });
};
try {
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
run(['genrsa', '-out', caKeyTmp, '2048']);
run([
'req',
'-new',
'-x509',
'-days',
'3650',
'-key',
caKeyTmp,
'-out',
caCertTmp,
'-subj',
'/CN=qinglong-ca/O=qinglong/C=CN',
]);
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
const caCert = await fs.readFile(caCertTmp, 'utf-8');
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
// 2. 服务端
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
const serverCert = run(
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
run(['genrsa', '-out', serverKeyTmp, '2048']);
run([
'req',
'-new',
'-key',
serverKeyTmp,
'-out',
serverCsrTmp,
'-subj',
'/CN=grpc-server',
]);
await fs.writeFile(
serverExtTmp,
'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n',
);
const serverCert = run([
'x509',
'-req',
'-days',
'3650',
'-in',
serverCsrTmp,
'-CA',
caCertTmp,
'-CAkey',
caKeyTmp,
'-CAserial',
path.join(workspace, 'ca.srl'),
'-CAcreateserial',
'-extfile',
serverExtTmp,
]);
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
// 3. 客户端
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
run(['genrsa', '-out', clientKeyTmp, '2048']);
run([
'req',
'-new',
'-key',
clientKeyTmp,
'-out',
clientCsrTmp,
'-subj',
'/CN=grpc-client',
]);
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
const clientCert = run(
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
);
const clientCert = run([
'x509',
'-req',
'-days',
'3650',
'-in',
clientCsrTmp,
'-CA',
caCertTmp,
'-CAkey',
caKeyTmp,
'-CAserial',
path.join(workspace, 'ca.srl'),
'-CAcreateserial',
'-extfile',
clientExtTmp,
]);
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
await cleanup();
Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey };
} catch (e) {
} finally {
await cleanup();
throw e;
}
}
+7 -5
View File
@@ -1,3 +1,5 @@
import { randomInt } from 'crypto';
export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [
@@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string {
arr.push(getOne(ENGLISH));
arr.push(getOne(special));
const len = min + Math.floor(Math.random() * (max - min + 1));
const len = min + randomInt(max - min + 1);
for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]);
arr.push(config[randomInt(config.length)]);
}
const newArr = [];
for (let j = 0; j < len; j++) {
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
newArr.push(arr.splice(randomInt(arr.length), 1)[0]);
}
function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
return arr[randomInt(arr.length)];
}
return newArr.join('');
}
}
+23 -26
View File
@@ -1,5 +1,7 @@
import { Subscription } from '../data/subscription';
import isNil from 'lodash/isNil';
import { shellQuote } from '../shared/shellQuote';
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
export function formatUrl(doc: Subscription) {
let url = doc.url;
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias);
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
} else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any;
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
}
export function formatCommand(doc: Subscription, url?: string) {
let command = `SUB_ID=${doc.id} ql `;
let _url = url || formatUrl(doc).url;
const {
type,
whitelist,
blacklist,
dependences,
branch,
extensions,
proxy,
autoAddCron,
autoDelCron,
} = doc;
if (type === 'file') {
command += `raw "${_url}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
} else {
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
dependences || ''
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
}
return command;
const args =
doc.type === 'file'
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
: [
'repo',
url || formatUrl(doc).url,
doc.whitelist || '',
doc.blacklist || '',
doc.dependences || '',
doc.branch || '',
doc.extensions || '',
doc.proxy || '',
];
args.push(
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
);
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
.map((arg) => shellQuote(arg!))
.join(' ')}`;
}
+220 -115
View File
@@ -12,6 +12,8 @@ import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { resolveFileAccess } from '../shared/fileAccess';
import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror';
export * from './share';
@@ -144,7 +146,8 @@ export async function handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const absolutePath = resolveFileAccess(config.logPath, [logPath]);
if (!absolutePath) throw new Error('Log path is outside the log directory');
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
@@ -214,48 +217,105 @@ export function dirSort(a: IFile, b: IFile): number {
}
}
const FILE_SYSTEM_READ_CONCURRENCY = 32;
type FileSystemTaskRunner = <T>(task: () => Promise<T>) => Promise<T>;
function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner {
let activeCount = 0;
const queue: Array<() => void> = [];
const runNext = () => {
while (activeCount < concurrency && queue.length > 0) {
activeCount += 1;
queue.shift()?.();
}
};
return <T>(task: () => Promise<T>) =>
new Promise<T>((resolve, reject) => {
queue.push(() => {
task()
.then(resolve, reject)
.finally(() => {
activeCount -= 1;
runNext();
});
});
runNext();
});
}
async function readDirsWithRunner(
dir: string,
baseDir: string,
blacklist: string[],
sort: (a: IFile, b: IFile) => number,
runFileSystemTask: FileSystemTaskRunner,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir);
const entries = await runFileSystemTask(() =>
fs.readdir(dir, { withFileTypes: true }),
);
const items = await Promise.all(
entries.map(async (entry): Promise<IFile | undefined> => {
if (blacklist.includes(entry.name) || entry.isSymbolicLink()) {
return undefined;
}
const subPath = path.join(dir, entry.name);
const stats = await runFileSystemTask(() => fs.lstat(subPath));
if (stats.isSymbolicLink()) {
return undefined;
}
const key = path.join(relativePath, entry.name);
if (stats.isDirectory()) {
const children = await readDirsWithRunner(
subPath,
baseDir,
blacklist,
sort,
runFileSystemTask,
);
return {
title: entry.name,
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
children,
};
}
return {
title: entry.name,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
};
}),
);
return items.filter((item): item is IFile => Boolean(item)).sort(sort);
}
export async function readDirs(
dir: string,
baseDir: string = '',
blacklist: string[] = [],
sort: (a: IFile, b: IFile) => number = dirSort,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir);
const files = await fs.readdir(dir);
const result: IFile[] = [];
for (const file of files) {
const subPath = path.join(dir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
const children = await readDirs(subPath, baseDir, blacklist, sort);
result.push({
title: file,
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
children: children.sort(sort),
});
} else {
result.push({
title: file,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
return result.sort(sort);
return readDirsWithRunner(
dir,
baseDir,
blacklist,
sort,
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
);
}
export async function readDir(
@@ -430,18 +490,69 @@ export function psTree(pid: number): Promise<number[]> {
});
}
export async function killTask(pid: number) {
const pids = await psTree(pid);
if (pids.length) {
export async function killTask(pid: number, waitForExit = false) {
const descendants = await psTree(pid);
const signal = (target: number, sig: NodeJS.Signals) => {
try {
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) { }
} else {
process.kill(pid, 2);
process.kill(target, sig);
} catch (error: any) {
if (error.code !== 'ESRCH') throw error;
}
};
if (!waitForExit) {
if (descendants.length) {
// A child may exit after psTree; keep signalling the remaining tree.
for (const target of [pid, ...descendants].reverse()) {
signal(target, 'SIGTERM');
}
} else signal(pid, 'SIGINT');
return;
}
const pids = [...descendants.reverse(), pid];
for (const target of pids) signal(target, 'SIGTERM');
const alive = async (target: number) => {
try {
process.kill(target, 0);
} catch (error: any) {
if (error.code === 'ESRCH') return false;
throw error;
}
if (process.platform === 'linux') {
try {
const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8');
// The command field may contain spaces and parentheses. Zombies have
// exited even while their parent has not reaped the PID yet.
const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0];
if (['Z', 'X', 'x'].includes(state)) return false;
} catch {
// /proc may be unavailable, or the process may have just exited.
// Retain the portable signal probe rather than assuming it is dead.
try {
process.kill(target, 0);
} catch (error: any) {
if (error.code === 'ESRCH') return false;
throw error;
}
}
}
return true;
};
const wait = async () => {
const deadline = Date.now() + 1000;
let remaining = pids;
while (true) {
const states = await Promise.all(remaining.map(alive));
remaining = remaining.filter((_, index) => states[index]);
if (!remaining.length || Date.now() >= deadline) return remaining;
await new Promise((resolve) => setTimeout(resolve, 20));
}
};
let remaining = await wait();
if (!remaining.length) return;
for (const target of remaining) signal(target, 'SIGKILL');
remaining = await wait();
if (remaining.length)
throw new Error(`Task processes did not exit: ${remaining.join(', ')}`);
}
export async function getPid(cmd: string) {
@@ -541,11 +652,7 @@ export function safeJSONParse(value?: string) {
}
}
export function errStack(error: unknown): string {
return error instanceof Error && error.stack
? error.stack
: String(error);
}
export { errStack } from '../shared/errors';
export async function rmPath(path: string) {
try {
@@ -700,75 +807,73 @@ export async function detectOS(): Promise<
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
mirror: string,
): Promise<string> {
const defaults: Record<string, string> = {
Debian: 'http://deb.debian.org/debian',
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
};
if (!defaults[osType])
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
osType === 'Alpine'
? '/etc/apk/repositories'
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
let content = '';
try {
content = await fs.readFile(file, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') throw error;
if (osType !== 'Alpine') {
try {
file = '/etc/apt/sources.list';
content = await fs.readFile(file, 'utf8');
} catch (legacyError: any) {
if (legacyError.code !== 'ENOENT') throw legacyError;
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
if (!content.trim()) {
const release = await fs.readFile('/etc/os-release', 'utf8');
if (osType === 'Alpine') {
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
if (!version)
throw new Error('Cannot determine Alpine repository version');
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
} else {
// The fallback may have read an empty legacy .list. Deb822 stanzas must
// be written to a .sources file, leaving the legacy file untouched.
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
if (!codename)
throw new Error('Cannot determine APT repository codename');
const ubuntu = osType === 'Ubuntu';
content = `Types: deb\nURIs: ${
defaults[osType]
}\nSuites: ${codename} ${codename}-updates${
ubuntu ? ` ${codename}-backports` : ''
}\nComponents: ${
ubuntu ? 'main restricted universe multiverse' : 'main'
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
if (!ubuntu)
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
}
}
return mirrorWriteCommand(
file,
rewriteMirrorSources(content, target),
S,
update,
);
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
+2
View File
@@ -13,6 +13,7 @@ export class Crontab {
pid?: number;
isDisabled?: 1 | 0;
log_path?: string;
queued_token?: string | null;
isPinned?: 1 | 0;
labels?: string[];
last_running_time?: number;
@@ -83,6 +84,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
isDisabled: DataTypes.NUMBER,
isPinned: DataTypes.NUMBER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
labels: DataTypes.JSON,
last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER,
+9 -1
View File
@@ -21,6 +21,7 @@ export enum NotificationMode {
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
'wpush' = 'wpush',
}
abstract class NotificationBaseInfo {
@@ -172,6 +173,12 @@ export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkContextToken = '';
}
export class WpushNotification extends NotificationBaseInfo {
public wpushApiKey = '';
public wpushChannel = '';
public wpushTopicCode = '';
}
export interface NotificationInfo
extends GoCqHttpBotNotification,
GotifyNotification,
@@ -195,4 +202,5 @@ export interface NotificationInfo
NtfyNotification,
WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification {}
OpeniLinkNotification,
WpushNotification {}
+1 -1
View File
@@ -20,7 +20,7 @@ export class Subscription {
pull_option?:
| { private_key: string }
| { username: string; password: string };
pid?: number;
pid?: number | null;
is_disabled?: 1 | 0;
log_path?: string;
alias: string;
+5
View File
@@ -41,6 +41,8 @@ export interface SystemConfigInfo {
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
runningInstanceRetentionDays?: number;
cronStatRetentionDays?: number;
}
export interface LoginLogInfo {
@@ -73,11 +75,14 @@ export interface AuthInfo {
lastaddr: string;
platform: string;
isTwoFactorChecking: boolean;
twoFactorExpiresAt?: number;
lastTwoFactorStep?: number;
token: string;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
blockedIps?: string[];
}
export type SystemModelInfo = SystemConfigInfo &
+2
View File
@@ -6,6 +6,7 @@ import { Application } from 'express';
import linkDeps from './deps';
import initTask from './initTask';
import initFile from './initFile';
import { initializeTrustProxy } from '../shared/trustProxy';
export default async ({ app }: { app: Application }) => {
depInjectorLoader();
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
Logger.info('[boot] Init task loaded');
expressLoader({ app });
await initializeTrustProxy(app);
Logger.info('[boot] Express loaded');
};
+3 -35
View File
@@ -9,6 +9,7 @@ import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
import { migrateSchema } from '../shared/schemaMigrations';
export default async () => {
try {
@@ -22,44 +23,11 @@ export default async () => {
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
// 初始化新增字段
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: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
{ table: 'Envs', column: 'labels', type: 'JSON' },
];
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
}
}
await migrateSchema(sequelize);
Logger.info('[boot] DB loaded');
} catch (error) {
Logger.error('[boot] DB load failed', error);
throw error;
}
};
+45 -1
View File
@@ -17,6 +17,50 @@ async function linkCommand() {
}
async function linkCommandToDir(commandDir: string) {
const cliRoot = process.env.QL_CLI_ROOT;
if (cliRoot) {
if (!path.isAbsolute(cliRoot)) {
throw new Error('QL_CLI_ROOT must be an absolute CLI installation path');
}
const { installCliEntrypoints } = require(path.join(
cliRoot,
'dist/local/entrypoints.js',
));
await installCliEntrypoints(commandDir, cliRoot);
const { installCronEntrypoint } = require(path.join(
cliRoot,
'dist/local/cronEntrypoint.js',
));
await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, {
...process.env,
QL_DIR: config.rootPath,
QL_DATA_DIR: config.dataPath,
});
// Container-wide legacy links may precede ~/bin. Ensure panel children use
// the explicitly selected entries without changing system-wide links.
process.env.PATH = [
commandDir,
...(process.env.PATH?.split(path.delimiter) ?? []).filter(
(entry) => entry !== commandDir,
),
].join(path.delimiter);
return;
}
const cronBridge = path.join(commandDir, 'crontab');
try {
if (
(await fs.lstat(cronBridge)).isFile() &&
(await fs.readFile(cronBridge, 'utf8')).includes(
'// QingLong CLI crontab bridge',
)
) {
await fs.unlink(cronBridge);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const linkShell = [
{
src: 'update.sh',
@@ -39,7 +83,7 @@ async function linkCommandToDir(commandDir: string) {
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
} catch (error) {}
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
+56 -26
View File
@@ -14,30 +14,33 @@ import { AuthInfo } from '../data/system';
import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
import protectedPathCase from '../middlewares/protectedPathCase';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim();
if (!setting) {
return 'loopback';
}
if (setting === 'true' || setting === 'false') {
return setting === 'true';
}
if (/^\d+$/.test(setting)) {
return Number(setting);
}
return setting;
}
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.set('trust proxy', resolveTrustProxy());
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Security: Reject case variations under protected API namespaces before
// authentication checks can interpret the request differently from routing.
app.use(protectedPathCase);
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
@@ -46,7 +49,26 @@ export default ({ app }: { app: Application }) => {
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use(
`${config.api.prefix}/static`,
express.static(config.uploadPath, {
setHeaders: (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
},
}),
);
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
['/user/login', '/user/init', '/user/two-factor/login'].map(
(route) => `${prefix}${route}`,
),
);
app.use(
credentialPaths,
bodyParser.json({ limit: '16kb' }),
bodyParser.urlencoded({ limit: '16kb', extended: false }),
);
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
@@ -97,7 +119,10 @@ export default ({ app }: { app: Application }) => {
return next(err);
}
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
@@ -118,14 +143,12 @@ export default ({ app }: { app: Application }) => {
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? t('Token 已失效')
: t('请先登录');
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
@@ -142,8 +165,7 @@ export default ({ app }: { app: Application }) => {
) {
return next();
}
const authInfo =
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
@@ -157,7 +179,14 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (_, res, next) => {
app.get('*', (req, res, next) => {
// Don't serve index.html for API routes
if (
req.path === config.api.prefix ||
req.path.startsWith(`${config.api.prefix}/`)
) {
return res.status(404).json({ code: 404, message: 'Not Found' });
}
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
@@ -169,6 +198,7 @@ export default ({ app }: { app: Application }) => {
});
app.use(errors());
app.use(uniqueConstraintError);
app.use(
(
+14 -26
View File
@@ -13,11 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
import cronClient from '../schedule/client';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n';
@@ -30,24 +30,7 @@ export default async () => {
const openService = Container.get(OpenService);
// 初始化增加系统配置
let systemApp = (
await AppModel.findOne({
where: { name: 'system' },
})
)?.get({ plain: true });
if (!systemApp) {
systemApp = await AppModel.create({
name: 'system',
scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!systemApp.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...systemApp.scopes, 'dashboard'] },
{ where: { name: 'system' } },
);
}
await openService.initializeSystemApp();
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
});
@@ -143,7 +126,11 @@ export default async () => {
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{
status: InstanceStatus.stopped,
finished_at: Math.floor(Date.now() / 1000),
// The previous process's exit code cannot be recovered after a restart.
},
{ where: { status: InstanceStatus.running } },
);
@@ -236,14 +223,15 @@ export default async () => {
} catch { }
// 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab();
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
await systemService.restoreCronConcurrency();
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
const apps = await openService.findApps();
await shareStore.updateAuthInfo(authInfo);
if (apps?.length) {
await shareStore.updateApps(apps);
}
await openService.refreshApps();
};
+10 -1
View File
@@ -7,6 +7,8 @@ import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
import { t } from '../shared/i18n';
import Logger from './logger';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
export default async () => {
const systemService = Container.get(SystemService);
@@ -24,6 +26,7 @@ export default async () => {
if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`;
}
tokenCommand += ' --renew';
const cron = {
id: NaN,
name: t('生成token'),
@@ -70,6 +73,12 @@ export default async () => {
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
try {
const doc = sub.get({ plain: true });
assertSubscriptionAlias(doc.alias);
await subscriptionService.handleTask(doc, !sub.is_disabled);
} catch (error) {
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
}
}
};
+66 -8
View File
@@ -1,4 +1,4 @@
import sockJs from 'sockjs';
import sockJs, { Connection } from 'sockjs';
import { Server } from 'http';
import { Container } from 'typedi';
import SockService from '../services/sock';
@@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const echo = sockJs.createServer({
prefix: `${config.baseUrl}/api/ws`,
log: () => {},
});
const sockService = Container.get(SockService);
const sessions = new Map<Connection, { token: string; platform: string }>();
let sessionTimer: ReturnType<typeof setInterval> | undefined;
let checking = false;
const checkSessions = async () => {
if (checking || sessions.size === 0) return;
checking = true;
// Connections accepted during this read must not use an older snapshot.
const batch = [...sessions];
try {
const current = await shareStore.getAuthInfo();
// Reuse validation only within this synchronous check of one auth snapshot.
const validated = new Map<string, Map<string, boolean>>();
for (const [conn, { token, platform }] of batch) {
if (!sessions.has(conn)) continue;
let platforms = validated.get(token);
if (!platforms) {
platforms = new Map();
validated.set(token, platforms);
}
let valid = platforms.get(platform);
if (valid === undefined) {
valid = isValidToken(current, token, platform, config.jwt.secret);
platforms.set(platform, valid);
}
if (!valid) conn.close('401');
}
} catch {
for (const [conn] of batch) {
if (sessions.has(conn)) conn.close('401');
}
} finally {
checking = false;
}
};
echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404');
return;
}
const authInfo = await shareStore.getAuthInfo();
let closed = false;
conn.on('close', () => {
closed = true;
sessions.delete(conn);
sockService.removeClient(conn);
if (sessions.size === 0 && sessionTimer) {
clearInterval(sessionTimer);
sessionTimer = undefined;
}
});
let authInfo;
try {
authInfo = await shareStore.getAuthInfo();
} catch {
if (!closed) conn.close('401');
return;
}
if (closed) return;
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform)) {
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
sockService.addClient(conn);
sessions.set(conn, { token: headerToken, platform });
if (!sessionTimer) {
sessionTimer = setInterval(checkSessions, 1000);
sessionTimer.unref();
}
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
}
+17 -49
View File
@@ -3,46 +3,34 @@ import Logger from '../loaders/logger';
import { performance } from 'perf_hooks';
import { metricsService } from '../services/metrics';
interface RequestMetrics {
method: string;
path: string;
duration: number;
statusCode: number;
timestamp: number;
platform?: string;
}
const requestMetrics: RequestMetrics[] = [];
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
let requestSampleOffset = 0;
export const monitoringMiddleware = (
req: Request,
res: Response,
next: NextFunction,
) => {
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
return next();
}
const start = performance.now();
const originalEnd = res.end;
res.end = function (chunk?: any, encoding?: any, cb?: any) {
const duration = performance.now() - start;
const metric: RequestMetrics = {
method: req.method,
path: req.path,
duration,
statusCode: res.statusCode,
timestamp: Date.now(),
platform: req.platform,
};
requestMetrics.push(metric);
metricsService.record('http_request', duration, {
method: req.method,
path: req.path,
statusCode: res.statusCode.toString(),
...(req.platform && { platform: req.platform }),
});
if (requestMetrics.length > 1000) {
requestMetrics.shift();
const shouldSample = requestSampleOffset === 0;
requestSampleOffset =
(requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
if (shouldSample) {
metricsService.record('http_request', duration, {
method: req.method,
path: req.path,
statusCode: res.statusCode.toString(),
...(req.platform && { platform: req.platform }),
});
}
if (duration > 1000) {
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
next();
};
export const getMetrics = () => {
return {
totalRequests: requestMetrics.length,
averageDuration:
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
requestMetrics.length,
requestsByMethod: requestMetrics.reduce((acc, curr) => {
acc[curr.method] = (acc[curr.method] || 0) + 1;
return acc;
}, {} as Record<string, number>),
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
if (curr.platform) {
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
}
return acc;
}, {} as Record<string, number>),
recentRequests: requestMetrics.slice(-10),
};
};
+23
View File
@@ -0,0 +1,23 @@
import type { NextFunction, Request, Response } from 'express';
export default function protectedPathCase(
req: Request,
res: Response,
next: NextFunction,
) {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
if (
originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') ||
normalizedPath.startsWith('/open/'))
) {
return res.status(400).json({
code: 400,
message: 'Invalid path format',
});
}
return next();
}
+14
View File
@@ -0,0 +1,14 @@
import { ErrorRequestHandler } from 'express';
import { UniqueConstraintError } from 'sequelize';
import { t } from '../shared/i18n';
// Handle the database constraint itself so concurrent writes are covered too.
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
if (!(err instanceof UniqueConstraintError)) return next(err);
// Constraint details can contain environment values or application credentials.
res
.status(409)
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
};
export default uniqueConstraintError;
+6 -1
View File
@@ -152,6 +152,7 @@ enum NotificationMode {
ntfy = 19;
wxPusherBot = 20;
wxPusherSpt = 21;
wpush = 22;
}
message NotificationInfo {
@@ -247,6 +248,10 @@ message NotificationInfo {
optional string wxPusherBotUids = 68;
optional string wxPusherSptList = 70;
optional string wpushApiKey = 71;
optional string wpushChannel = 72;
optional string wpushTopicCode = 73;
}
message SystemNotifyRequest {
@@ -275,4 +280,4 @@ service Api {
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
}
+61 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/api.proto
/* eslint-disable */
@@ -44,6 +44,7 @@ export enum NotificationMode {
ntfy = 19,
wxPusherBot = 20,
wxPusherSpt = 21,
wpush = 22,
UNRECOGNIZED = -1,
}
@@ -115,6 +116,9 @@ export function notificationModeFromJSON(object: any): NotificationMode {
case 21:
case "wxPusherSpt":
return NotificationMode.wxPusherSpt;
case 22:
case "wpush":
return NotificationMode.wpush;
case -1:
case "UNRECOGNIZED":
default:
@@ -168,6 +172,8 @@ export function notificationModeToJSON(object: NotificationMode): string {
return "wxPusherBot";
case NotificationMode.wxPusherSpt:
return "wxPusherSpt";
case NotificationMode.wpush:
return "wpush";
case NotificationMode.UNRECOGNIZED:
default:
return "UNRECOGNIZED";
@@ -400,6 +406,9 @@ export interface NotificationInfo {
wxPusherBotTopicIds?: string | undefined;
wxPusherBotUids?: string | undefined;
wxPusherSptList?: string | undefined;
wpushApiKey?: string | undefined;
wpushChannel?: string | undefined;
wpushTopicCode?: string | undefined;
}
export interface SystemNotifyRequest {
@@ -2967,6 +2976,9 @@ function createBaseNotificationInfo(): NotificationInfo {
wxPusherBotTopicIds: undefined,
wxPusherBotUids: undefined,
wxPusherSptList: undefined,
wpushApiKey: undefined,
wpushChannel: undefined,
wpushTopicCode: undefined,
};
}
@@ -3182,6 +3194,15 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherSptList !== undefined) {
writer.uint32(562).string(message.wxPusherSptList);
}
if (message.wpushApiKey !== undefined) {
writer.uint32(570).string(message.wpushApiKey);
}
if (message.wpushChannel !== undefined) {
writer.uint32(578).string(message.wpushChannel);
}
if (message.wpushTopicCode !== undefined) {
writer.uint32(586).string(message.wpushTopicCode);
}
return writer;
},
@@ -3752,6 +3773,30 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherSptList = reader.string();
continue;
}
case 71: {
if (tag !== 570) {
break;
}
message.wpushApiKey = reader.string();
continue;
}
case 72: {
if (tag !== 578) {
break;
}
message.wpushChannel = reader.string();
continue;
}
case 73: {
if (tag !== 586) {
break;
}
message.wpushTopicCode = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -3845,6 +3890,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
: undefined,
wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined,
wxPusherSptList: isSet(object.wxPusherSptList) ? globalThis.String(object.wxPusherSptList) : undefined,
wpushApiKey: isSet(object.wpushApiKey) ? globalThis.String(object.wpushApiKey) : undefined,
wpushChannel: isSet(object.wpushChannel) ? globalThis.String(object.wpushChannel) : undefined,
wpushTopicCode: isSet(object.wpushTopicCode) ? globalThis.String(object.wpushTopicCode) : undefined,
};
},
@@ -4060,6 +4108,15 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherSptList !== undefined) {
obj.wxPusherSptList = message.wxPusherSptList;
}
if (message.wpushApiKey !== undefined) {
obj.wpushApiKey = message.wpushApiKey;
}
if (message.wpushChannel !== undefined) {
obj.wpushChannel = message.wpushChannel;
}
if (message.wpushTopicCode !== undefined) {
obj.wpushTopicCode = message.wpushTopicCode;
}
return obj;
},
@@ -4138,6 +4195,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined;
message.wxPusherBotUids = object.wxPusherBotUids ?? undefined;
message.wxPusherSptList = object.wxPusherSptList ?? undefined;
message.wpushApiKey = object.wpushApiKey ?? undefined;
message.wpushChannel = object.wpushChannel ?? undefined;
message.wpushTopicCode = object.wpushTopicCode ?? undefined;
return message;
},
};
+8 -1
View File
@@ -5,6 +5,7 @@ package com.ql.cron;
service Cron {
rpc addCron(AddCronRequest) returns (AddCronResponse);
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
}
message ISchedule { string schedule = 1; }
@@ -17,10 +18,16 @@ message ICron {
string name = 5;
}
message AddCronRequest { repeated ICron crons = 1; }
message AddCronRequest {
repeated ICron crons = 1;
bool replace = 2;
}
message AddCronResponse {}
message DeleteCronRequest { repeated string ids = 1; }
message DeleteCronResponse {}
// Zero restores the default limit.
message SetConcurrencyRequest { int32 concurrency = 1; }
+110 -3
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v5.28.3
// source: back/protos/cron.proto
/* eslint-disable */
@@ -35,6 +35,7 @@ export interface ICron {
export interface AddCronRequest {
crons: ICron[];
replace: boolean;
}
export interface AddCronResponse {
@@ -47,6 +48,11 @@ export interface DeleteCronRequest {
export interface DeleteCronResponse {
}
/** Zero restores the default limit. */
export interface SetConcurrencyRequest {
concurrency: number;
}
function createBaseISchedule(): ISchedule {
return { schedule: "" };
}
@@ -232,7 +238,7 @@ export const ICron: MessageFns<ICron> = {
};
function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] };
return { crons: [], replace: false };
}
export const AddCronRequest: MessageFns<AddCronRequest> = {
@@ -240,6 +246,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).join();
}
if (message.replace !== false) {
writer.uint32(16).bool(message.replace);
}
return writer;
},
@@ -258,6 +267,14 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
message.crons.push(ICron.decode(reader, reader.uint32()));
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.replace = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -268,7 +285,10 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
},
fromJSON(object: any): AddCronRequest {
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
return {
crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [],
replace: isSet(object.replace) ? globalThis.Boolean(object.replace) : false,
};
},
toJSON(message: AddCronRequest): unknown {
@@ -276,6 +296,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
if (message.crons?.length) {
obj.crons = message.crons.map((e) => ICron.toJSON(e));
}
if (message.replace !== false) {
obj.replace = message.replace;
}
return obj;
},
@@ -285,6 +308,7 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
message.replace = object.replace ?? false;
return message;
},
};
@@ -433,6 +457,64 @@ export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
},
};
function createBaseSetConcurrencyRequest(): SetConcurrencyRequest {
return { concurrency: 0 };
}
export const SetConcurrencyRequest: MessageFns<SetConcurrencyRequest> = {
encode(message: SetConcurrencyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.concurrency !== 0) {
writer.uint32(8).int32(message.concurrency);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): SetConcurrencyRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseSetConcurrencyRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.concurrency = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): SetConcurrencyRequest {
return { concurrency: isSet(object.concurrency) ? globalThis.Number(object.concurrency) : 0 };
},
toJSON(message: SetConcurrencyRequest): unknown {
const obj: any = {};
if (message.concurrency !== 0) {
obj.concurrency = Math.round(message.concurrency);
}
return obj;
},
create<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(base?: I): SetConcurrencyRequest {
return SetConcurrencyRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(object: I): SetConcurrencyRequest {
const message = createBaseSetConcurrencyRequest();
message.concurrency = object.concurrency ?? 0;
return message;
},
};
export type CronService = typeof CronService;
export const CronService = {
addCron: {
@@ -453,11 +535,21 @@ export const CronService = {
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
},
setConcurrency: {
path: "/com.ql.cron.Cron/setConcurrency",
requestStream: false,
responseStream: false,
requestSerialize: (value: SetConcurrencyRequest) => Buffer.from(SetConcurrencyRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => SetConcurrencyRequest.decode(value),
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
},
} as const;
export interface CronServer extends UntypedServiceImplementation {
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
}
export interface CronClient extends Client {
@@ -491,6 +583,21 @@ export interface CronClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
setConcurrency(
request: SetConcurrencyRequest,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
setConcurrency(
request: SetConcurrencyRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
setConcurrency(
request: SetConcurrencyRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
}
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/health.proto
/* eslint-disable */
+59 -84
View File
@@ -1,28 +1,15 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule';
import { createCronJob, CronJob } from '../shared/cronScheduler';
import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
/**
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
* 提前拦截避免走 scheduleJob 后才发现无效。
*/
// Validate the entire batch before replacing any existing jobs.
const isValidCronField = (cron: string): boolean => {
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
// node-schedule 会对这种字段返回 null
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
return false;
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
// cron-parser 接受但 node-schedule 拒绝,提前拦截
if (/\?/.test(cron)) {
return false;
}
return true;
return isValidCronSchedule(cron);
};
const addCron = (
@@ -37,11 +24,7 @@ const addCron = (
if (!isValidCronField(schedule)) {
validationErrors.push(
tf(
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
schedule,
),
tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
);
}
@@ -50,7 +33,7 @@ const addCron = (
if (!isValidCronField(x.schedule)) {
validationErrors.push(
tf(
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
String(id),
x.schedule,
),
@@ -69,70 +52,62 @@ const addCron = (
return;
}
// ===== 第二遍:注册所有任务 =====
for (const item of call.request.crons) {
const { id, schedule, command, extra_schedules, name } = item;
// 取消该 id 已有的旧任务
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
schedule,
command,
);
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
// Prepare stopped jobs first: construction errors must preserve the old snapshot.
const prepared = new Map<string, CronJob[]>();
try {
for (const item of call.request.crons) {
const jobs: CronJob[] = [];
prepared.get(item.id)?.forEach((job) => job.cancel());
prepared.set(item.id, jobs);
for (const schedule of [
item.schedule,
...(item.extra_schedules || []).map((x) => x.schedule),
]) {
jobs.push(
createCronJob(
schedule,
async () => {
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
await runCron(
item.command,
item,
() => scheduleStacks.get(item.id) === jobs,
);
},
{
name: `${item.id}: ${item.name || ''}`,
logger: Logger,
start: false,
},
),
);
});
}
}
} catch (error) {
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
const err: any = new Error(
error instanceof Error ? error.message : String(error),
);
err.code = status.INVALID_ARGUMENT;
err.details = err.message;
callback(err, null);
return;
}
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!mainJob) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
schedule,
);
}
const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
if (call.request.replace) {
for (const jobs of scheduleStacks.values())
jobs.forEach((job) => job?.cancel());
scheduleStacks.clear();
}
for (const [id, jobs] of prepared) {
scheduleStacks.get(id)?.forEach((job) => job.cancel());
scheduleStacks.set(id, jobs);
jobs.forEach((job) => job.start());
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
id,
jobs.length,
);
}
callback(null, null);
+10 -6
View File
@@ -38,6 +38,7 @@ import {
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
import { Model } from 'sequelize';
Container.set('logger', LoggerInstance);
@@ -247,13 +248,16 @@ export const systemNotify = async (
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? [],
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
...cron,
labels: cron.labels ?? [],
sub_id: cron.sub_id ?? undefined,
extra_schedules: cron.extra_schedules ?? [],
pid: cron.pid ?? undefined,
task_before: cron.task_before ?? undefined,
task_after: cron.task_after ?? undefined,
};
};
+90 -11
View File
@@ -1,4 +1,4 @@
import { credentials } from '@grpc/grpc-js';
import { credentials, status, Metadata } from '@grpc/grpc-js';
import {
AddCronRequest,
AddCronResponse,
@@ -9,7 +9,41 @@ import {
import config from '../config';
import { getGrpcCerts } from '../config/grpcCerts';
import { HealthService } from '../protos/health';
import { SchedulerReadiness } from '../shared/schedulerReadiness';
class Client {
readonly readiness = new SchedulerReadiness(() => this.probe());
private async waitForReady(timeoutMs: number) {
try {
await new Promise<void>((resolve, reject) => {
this.client.waitForReady(Date.now() + timeoutMs, (err) =>
err ? reject(err) : resolve()
);
});
} catch (error) {
this.readiness.invalidate();
throw Object.assign(
error instanceof Error ? error : new Error(String(error)),
{ status: 503 }
);
}
}
private async probe(): Promise<void> {
await this.waitForReady(1000);
await new Promise<void>((resolve, reject) => {
this.client.makeUnaryRequest(
HealthService.check.path,
HealthService.check.requestSerialize,
HealthService.check.responseDeserialize,
{ service: 'scheduler' },
{ deadline: Date.now() + 1000 },
(err, res) => err ? reject(err) : res?.status === 1 ? resolve() : reject(new Error('Scheduler unavailable')),
);
});
}
private _client: CronClient | null = null;
private get client(): CronClient {
@@ -28,22 +62,67 @@ class Client {
return this._client;
}
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
async setConcurrency(concurrency: number): Promise<void> {
await this.waitForReady(2000);
return new Promise((resolve, reject) => {
this.client.addCron({ crons: request }, (err, res) => {
if (err) {
reject(err);
}
resolve(res);
});
this.client.setConcurrency(
{ concurrency }, new Metadata(), { deadline: Date.now() + 5000 },
(err) => {
if (err) {
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
this.readiness.invalidate();
Object.assign(err, { status: 503 });
}
return reject(err);
}
resolve();
},
);
});
}
delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
async addCron(
request: AddCronRequest['crons'],
replace = false
): Promise<AddCronResponse> {
await this.waitForReady(2000);
// Include every rule in a recovery snapshot, but bound a stuck worker.
const ruleCount = request.reduce(
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
0,
);
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
return new Promise((resolve, reject) => {
this.client.delCron({ ids: request }, (err, res) => {
this.client.addCron(
{ crons: request, replace },
new Metadata(),
{ deadline: Date.now() + registrationTimeoutMs },
(err, res) => {
if (err) {
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
// A timed-out write may already have reached the scheduler.
// Reconcile its state from the DB instead of replaying the RPC.
this.readiness.invalidate();
Object.assign(err, { status: 503 });
}
return reject(err);
}
resolve(res);
}
);
});
}
async delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
await this.waitForReady(2000);
return new Promise((resolve, reject) => {
this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => {
if (err) {
reject(err);
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
this.readiness.invalidate();
Object.assign(err, { status: 503 });
}
return reject(err);
}
resolve(res);
});
+2 -2
View File
@@ -1,6 +1,6 @@
import nodeSchedule from 'node-schedule';
import { CronJob } from '../shared/cronScheduler';
import { ToadScheduler } from 'toad-scheduler';
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
export const scheduleStacks = new Map<string, CronJob[]>();
export const intervalSchedule = new ToadScheduler();
+1 -1
View File
@@ -13,7 +13,7 @@ const delCron = (
'[schedule][取消定时任务] 任务ID: %s',
id,
);
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 防御性过滤历史调度栈中的空任务,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
+91 -12
View File
@@ -1,28 +1,107 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import fs from 'fs/promises';
import path from 'path';
import { request } from 'undici';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import config from '../config';
import { promiseExec } from '../config/util';
function formatError(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const detailedError = error as Error & {
cause?: unknown;
code?: string;
errors?: unknown[];
};
const details = [error.message, detailedError.code];
if (Array.isArray(detailedError.errors)) {
details.push(...detailedError.errors.map(formatError));
} else if (detailedError.cause) {
details.push(formatError(detailedError.cause));
}
return [...new Set(details.filter(Boolean))].join(': ') || error.name;
}
async function getRecentSystemLog(lineLimit = 300): Promise<string> {
try {
const entries = await fs.readdir(config.systemLogPath);
const latestLog = entries
.filter((entry) => entry.endsWith('.log'))
.sort()
.at(-1);
if (!latestLog) {
return `No system log found in ${config.systemLogPath}`;
}
const content = await fs.readFile(
path.join(config.systemLogPath, latestLog),
'utf8',
);
return content.split('\n').slice(-lineLimit).join('\n').trim();
} catch (error) {
return `Unable to read system log from ${config.systemLogPath}: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
const check = async (
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
callback: sendUnaryData<HealthCheckResponse>,
) => {
switch (call.request.service) {
case 'cron':
const res = await promiseExec(
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
);
if (res.includes('200')) {
return callback(null, { status: 1 });
// Local scheduler liveness only: never call HTTP from this probe.
case 'scheduler':
return callback(null, { status: 1 });
case 'cron': {
const healthUrl = `http://localhost:${config.port}${
config.baseUrl || ''
}/api/health`;
let failure = '';
try {
const response = await request(healthUrl, {
method: 'GET',
headersTimeout: 5000,
bodyTimeout: 5000,
});
const body = (await response.body.json()) as {
code?: number;
data?: { status?: string };
};
if (
response.statusCode >= 200 &&
response.statusCode < 300 &&
body.code === 200 &&
body.data?.status === 'ok'
) {
return callback(null, { status: 1 });
}
failure = `HTTP ${response.statusCode}: ${JSON.stringify(body)}`;
} catch (error) {
failure = formatError(error);
}
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
const systemLog = await getRecentSystemLog();
const containerHint =
process.env.QL_CONTAINER === 'true'
? 'PM2 file logging is disabled in containers. Check `docker logs <container>` for early startup errors.'
: 'Check `pm2 logs qinglong --lines 300` for early startup errors.';
return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
new Error(
[
`HTTP health check failed: ${healthUrl}`,
failure,
containerHint,
`Recent system log (${config.systemLogPath}):`,
systemLog,
]
.filter(Boolean)
.join('\n'),
),
);
}
default:
return callback(null, { status: 1 });
+26
View File
@@ -0,0 +1,26 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import os from 'os';
import { AddCronResponse, SetConcurrencyRequest } from '../protos/cron';
import taskLimit from '../shared/pLimit';
export async function setConcurrency(
call: ServerUnaryCall<SetConcurrencyRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>,
) {
const limit = call.request.concurrency;
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
callback(
Object.assign(new Error('Invalid concurrency'), {
code: status.INVALID_ARGUMENT,
}),
null,
);
return;
}
try {
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
callback(null, {});
} catch (error) {
callback(error as Error, null);
}
}
+10 -21
View File
@@ -1,10 +1,10 @@
import { Service, Inject } from 'typedi';
import path, { join } from 'path';
import { Service } from 'typedi';
import config from '../config';
import { getFileContentByName } from '../config/util';
import { t } from '../shared/i18n';
import { Response } from 'express';
import { request } from 'undici';
import { resolveFileAccess } from '../shared/fileAccess';
@Service()
export default class ConfigService {
@@ -12,24 +12,15 @@ export default class ConfigService {
public async getFile(filePath: string, res: Response) {
let content = '';
if (!filePath) {
if (!filePath || filePath.startsWith('data/scripts/')) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const normalized = path.normalize(filePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const resolvedRoot = path.resolve(config.rootPath, normalized);
const resolvedConfig = path.resolve(config.configPath, normalized);
const isValidPath =
resolvedRoot.startsWith(config.scriptPath) ||
resolvedRoot.startsWith(config.configPath) ||
resolvedConfig.startsWith(config.scriptPath) ||
resolvedConfig.startsWith(config.configPath);
if (!isValidPath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (config.blackFileList.includes(path.basename(normalized))) {
const resolved = resolveFileAccess(
config.configPath,
[filePath],
config.blackFileList,
);
if (!resolved) {
return res.send({ code: 403, message: t('文件无法访问') });
}
@@ -38,10 +29,8 @@ export default class ConfigService {
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
);
content = await res.body.text();
} else if (filePath.startsWith('data/scripts/')) {
content = await getFileContentByName(join(config.rootPath, filePath));
} else {
content = await getFileContentByName(join(config.configPath, filePath));
content = await getFileContentByName(resolved);
}
res.send({ code: 200, data: content });
+457 -277
View File
@@ -1,6 +1,13 @@
import { randomUUID } from 'crypto';
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
import {
withSchedulerMutation,
schedulerRegistrationError,
} from '../shared/schedulerMutationLock';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { resolveLogPath } from '../shared/logPath';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
@@ -8,12 +15,10 @@ import {
} from '../data/runningInstance';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
@@ -31,7 +36,10 @@ import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
import { observeChildProcess, asError } from '../shared/childProcess';
import { isEmpty } from 'lodash';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
import { resolveFileAccess } from '../shared/fileAccess';
@Service()
export default class CronService {
@@ -39,10 +47,21 @@ export default class CronService {
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
return true;
}
return false;
const fields = schedule?.trim().split(/\s+/) || [];
// System crontab only receives portable numeric five-field expressions.
// Extended syntax, macros and legacy shorthand use the Node scheduler.
return (
fields.length !== 5 ||
/[^\d\s*,/\-]/.test(schedule || '') ||
Boolean(extra_schedules?.length) ||
// BusyBox treats N/step as a single value and doesn't support Sunday=7.
fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) ||
fields[4].includes('7') ||
// BusyBox steps DOM from zero; its full-range day/week wildcard
// handling also differs from the legacy Node parser's OR semantics.
fields[2].includes('/') ||
(fields[2] !== '*' && fields[4] !== '*')
);
}
private get schedulerMode(): 'system' | 'node' {
@@ -96,42 +115,45 @@ export default class CronService {
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
return withSchedulerMutation(async () => {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (isDemoEnv()) {
return doc;
}
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// (DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
);
if (isDemoEnv()) {
return doc;
}
}
await this.setCrontab();
return doc;
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// (DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw schedulerRegistrationError(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
}
await this.setCrontab();
return doc;
});
}
public async insert(payload: Crontab): Promise<Crontab> {
@@ -139,69 +161,68 @@ export default class CronService {
}
public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc;
}
try {
await cronClient.delCron([String(newDoc.id)]);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
if (this.shouldUseCronClient(newDoc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(doc, { where: { id: doc.id } });
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
);
return withSchedulerMutation(async () => {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
return await this.updateDb(tab);
}
}
await this.setCrontab();
return newDoc;
// Keep the DB snapshot unchanged if deletion has an uncertain outcome.
// Recovery uses that snapshot after this mutation releases its lock.
await cronClient.delCron([String(doc.id)]);
const newDoc = await this.updateDb(tab);
if (this.shouldUseCronClient(newDoc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(omit(doc, ['queued_token']), {
where: { id: doc.id },
});
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw schedulerRegistrationError(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
}
await this.setCrontab();
return newDoc;
});
}
public async updateDb(payload: Crontab): Promise<Crontab> {
@@ -226,6 +247,9 @@ export default class CronService {
last_execution_time: number;
exit_code?: number;
}) {
if (log_path && log_path !== '/dev/null') {
resolveLogPath(config.logPath, log_path);
}
let options: any = {
status,
pid,
@@ -289,16 +313,11 @@ export default class CronService {
}
public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } });
try {
return withSchedulerMutation(async () => {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
await CrontabModel.destroy({ where: { id: ids } });
await this.setCrontab();
});
}
public async pin(ids: number[]) {
@@ -569,46 +588,99 @@ export default class CronService {
}
public async run(ids: number[]) {
const queuedToken = randomUUID();
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ status: CrontabStatus.queued, queued_token: queuedToken },
{ where: { id: ids } },
);
ids.forEach((id) => {
this.runSingle(id);
this.runSingle(id, queuedToken);
});
}
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
// Cancel the queued snapshot first, so a late spawn cannot claim it.
for (const doc of docs) {
// Kill all running instances of this task
try {
if (doc.pid) {
await killTask(doc.pid);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
if (doc.status === CrontabStatus.queued) {
const [cancelled] = await CrontabModel.update(
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{
where: {
id: doc.id,
status: CrontabStatus.queued,
[Op.and]: [
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
where(colFn('queued_token'), {
[Op.eq]: doc.queued_token ?? null,
}),
],
},
}
);
// A concurrent claim may have won; capture its PID before signalling.
if (!cancelled) await doc.reload();
}
}
const stoppingInstances = await RunningInstanceModel.findAll({
attributes: ['id', 'pid', 'cron_id'],
where: { cron_id: ids, status: InstanceStatus.running },
});
const targets = new Set<number>(
[
...stoppingInstances.map((instance) => instance.pid),
...docs
.filter((doc) => doc.status === CrontabStatus.running)
.map((doc) => doc.pid),
].filter((pid): pid is number => typeof pid === 'number' && pid > 0)
);
const stopped = new Set<number>();
for (const pid of targets) {
try {
await killTask(pid, true);
stopped.add(pid);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
'[panel][停止任务失败] PID: %s, 错误: %s',
pid,
asError(error).message
);
}
}
// Mark all running instances as stopped
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: finishedAt },
{ where: { cron_id: ids, status: InstanceStatus.running } },
);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: ids } },
);
const stoppedIds = stoppingInstances
.filter((instance) => instance.pid && stopped.has(instance.pid))
.map((instance) => instance.id!);
if (stoppedIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: stoppedIds } }
);
}
for (const doc of docs) {
if (
doc.status !== CrontabStatus.running ||
(doc.pid && !stopped.has(doc.pid))
)
continue;
const remaining = await RunningInstanceModel.count({
where: { cron_id: doc.id, status: InstanceStatus.running },
});
if (remaining) continue;
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{
where: {
id: doc.id,
status: CrontabStatus.running,
[Op.and]: [
where(colFn('queued_token'), { [Op.eq]: doc.queued_token ?? null }),
where(colFn('pid'), { [Op.eq]: doc.pid ?? null }),
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
],
},
}
);
}
}
public async stopInstance(instanceId: number) {
@@ -645,159 +717,256 @@ export default class CronService {
return { code: 200, message: t('实例已停止') };
}
private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => {
private async runSingle(
cronId: number,
expectedToken?: string,
): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(async () => {
let absolutePath: string | undefined;
let logPath: string | undefined;
let queuedLogPath: string | null | undefined;
let queuedToken: string | null = null;
let claimed = false;
try {
const cron = await this.getDb({ id: cronId });
const params = {
name: cron.name,
command: cron.command,
schedule: cron.schedule,
extra_schedules: cron.extra_schedules,
};
if (cron.status !== CrontabStatus.queued) {
resolve(params);
return;
}
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
);
let { id, command, log_name } = cron;
if (
cron.status !== CrontabStatus.queued ||
(expectedToken !== undefined && cron.queued_token !== expectedToken)
) return;
queuedToken = cron.queued_token ?? null;
queuedLogPath = cron.log_path ?? null;
const { id, command, log_name } = cron;
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 });
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
logPath = `${uniqPath}/${logTime}.log`;
absolutePath = resolveFileAccess(config.logPath, [logPath]);
if (!absolutePath)
throw new Error('Log path is outside the log directory');
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
const outputPath = absolutePath;
const cp = spawn(
`real_log_path=${logPath} no_delay=true ${this.makeCommand(
cron,
true,
true
)}`,
{ shell: '/bin/bash' },
{ shell: '/bin/bash' }
);
await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
{ where: { id } },
);
cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString());
// Install observers before the first await: very short children may already exit.
const { completed } = observeChildProcess(cp, {
onStart: async () => {
try {
const [count] = await CrontabModel.update(
{
status: CrontabStatus.running,
pid: cp.pid,
log_path: logPath,
},
{
where: {
id,
status: CrontabStatus.queued,
[Op.and]: [
where(colFn('queued_token'), { [Op.eq]: queuedToken }),
where(colFn('log_path'), { [Op.eq]: queuedLogPath }),
],
},
}
);
if (count !== 1)
throw new Error(
'Task was stopped or superseded before startup'
);
claimed = true;
} catch (error) {
if (cp.pid) await killTask(cp.pid, true);
throw error;
}
},
onStdout: (message) => logStreamManager.write(outputPath, message),
onStderr: (message) => logStreamManager.write(outputPath, message),
});
cp.stderr.on('data', async (data) => {
this.logger.info(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await logStreamManager.write(absolutePath, data.toString());
});
cp.on('error', async (err) => {
const result = await completed;
if (result.error) {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
id,
result.error.message
);
await logStreamManager.write(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code) => {
this.logger.info(
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify(params),
code,
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
id,
result.code
);
return { ...cron, pid: cp.pid, ...result } as any;
} catch (error) {
this.logger.error(
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
cronId,
asError(error).message
);
} finally {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
'[panel][关闭任务日志失败] %s',
asError(error).message
);
await logStreamManager.closeStream(absolutePath);
resolve({ ...params, pid: cp.pid, code });
});
});
}
try {
// Do not overwrite a newer run's state or its script-reported exit code.
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{
where: {
id: cronId,
[Op.and]: where(colFn('queued_token'), { [Op.eq]: queuedToken }),
[Op.or]: [
...(queuedLogPath !== undefined && !claimed
? [
{
status: CrontabStatus.queued,
[Op.and]: where(colFn('log_path'), {
[Op.eq]: queuedLogPath,
}),
},
]
: []),
...(claimed && logPath
? [{ log_path: logPath, status: CrontabStatus.running }]
: []),
],
},
}
);
} catch (error) {
this.logger.error(
'[panel][清理任务状态失败] %s',
asError(error).message
);
}
}
});
}
public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
try {
return withSchedulerMutation(async () => {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await this.setCrontab();
});
}
public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
return withSchedulerMutation(async () => {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) {
return;
}
if (isDemoEnv()) {
return;
}
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
await this.setCrontab();
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw schedulerRegistrationError(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
await this.setCrontab();
});
}
public async log(id: number): Promise<{ content: string; status: string }> {
public async log(
id: number,
options: LogReadOptions = {},
): Promise<{
content: string;
status: string;
offset: number;
nextOffset: number;
total: number;
truncated: boolean;
}> {
const doc = await this.getDb({ id });
if (!doc) {
return { content: '', status: 'empty' };
return {
content: '',
status: 'empty',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
if (doc.log_name === '/dev/null') {
return { content: t('日志设置为忽略'), status: 'ignored' };
return {
content: t('日志设置为忽略'),
status: 'ignored',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const absolutePath = doc.log_path
? resolveLogPath(config.logPath, doc.log_path)
: '';
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
const content = await getFileContentByName(`${absolutePath}`);
const chunk = await readLogChunk(`${absolutePath}`, options);
const isRunning =
typeof doc.status === 'number' &&
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
return { content, status: isRunning ? 'running' : 'completed' };
return {
...chunk,
status: isRunning ? 'running' : 'completed',
};
} else {
return typeof doc.status === 'number' &&
const status =
typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? { content: t('运行中...'), status: 'running' }
: { content: t('日志不存在...'), status: 'notFound' };
? 'running'
: 'notFound';
return {
content: status === 'running' ? t('运行中...') : t('日志不存在...'),
status,
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
}
public async logs(id: number) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
return [];
}
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
const dirExist = await fileExist(dir);
if (dirExist) {
let files = await fs.readdir(dir);
@@ -901,7 +1070,7 @@ export default class CronService {
if (
command &&
schedule &&
CronExpressionParser.parse(schedule).hasNext()
isValidCronSchedule(schedule)
) {
const name = namePrefix + '_' + index;
@@ -920,39 +1089,56 @@ export default class CronService {
});
}
public async autosave_crontab() {
const tabs = await this.crontabs();
const regularCrons = tabs.data
.filter(
(x) =>
x.isDisabled !== 1 &&
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
public async autosave_crontab(requireScheduler = false) {
return withSchedulerMutation(async () => {
const tabs = await this.crontabs();
// A bad persisted rule must not block startup or all other schedules.
// Keep the DB row editable; omit it from both runtime scheduler snapshots.
tabs.data = tabs.data.filter((doc) => {
if (doc.isDisabled === 1) return true;
const invalidSchedules = getInvalidCronSchedules(doc);
if (invalidSchedules.length === 0) return true;
this.logger.warn(
'[crontab] Skipping task with invalid schedule (id=%s, name=%s): %s',
doc.id,
doc.name || '',
JSON.stringify(invalidSchedules),
);
return false;
});
const regularCrons = tabs.data
.filter(
(x) =>
x.isDisabled !== 1 &&
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler:',
error?.message || error,
);
}
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons, requireScheduler);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler: %s',
error?.message || error,
);
if (requireScheduler) throw error;
}
});
}
public async bootTask() {
@@ -961,13 +1147,7 @@ export default class CronService {
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
);
if (bootTasks.length > 0) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
this.runSingle(task.id!);
}
await this.run(bootTasks.map((task) => task.id!));
}
}
}
+69
View File
@@ -16,6 +16,7 @@ import {
getPid,
killTask,
promiseExecSuccess,
concurrentRun,
getInstallCommand,
getUninstallCommand,
getGetCommand,
@@ -110,6 +111,11 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
@@ -132,6 +138,69 @@ export default class DependenceService {
}
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
DependenceTypes.python3,
];
if (type !== undefined && !cacheDependenceTypes.includes(type)) {
return;
}
const docs = await DependenceModel.findAll({
where: {
status: DependenceStatus.installed,
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checks = await concurrentRun(
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
);
}
}
private async isDependenceInstalled(dependency: Dependence) {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
const [, parsedName, parsedVersion] = depName.match(symbolRegx) || [];
if (parsedVersion && parsedName) {
depName = parsedName;
depVersion = parsedVersion;
}
}
const depInfo = (
await promiseExecSuccess(getGetCommand(dependency.type, depName))
)
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
const nameMatches =
(dependency.type === DependenceTypes.nodejs &&
depInfo.split(' ')?.[0] === depName) ||
dependency.type === DependenceTypes.python3;
return Boolean(
depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)),
);
}
public installDependenceOneByOne(
docs: Dependence[],
isInstall: boolean = true,
+3 -4
View File
@@ -262,10 +262,9 @@ export default class EnvService {
.map((x) => x.value)
.join('&')
.replace(/\\/g, '\\\\')}`;
js_env_string += `process.env.${key}=\`${_env_value.replace(
/\`/g,
'\\`',
)}\`;\n`;
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
group.map((x) => x.value).join('&'),
)};\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g,
"\\'",
+3 -2
View File
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron';
import { setConcurrency } from '../schedule/setConcurrency';
import { check } from '../schedule/health';
import * as Api from '../schedule/api';
import Logger from '../loaders/logger';
@@ -27,7 +28,7 @@ export class GrpcServerService {
async initialize() {
try {
this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron });
this.server.addService(CronService, { addCron, delCron, setConcurrency });
this.server.addService(ApiService, Api);
const tlsConfig = await initGrpcCerts();
@@ -40,7 +41,7 @@ export class GrpcServerService {
const grpcPort = config.grpcPort;
const hostsToTry = [
config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
+2 -4
View File
@@ -1,6 +1,6 @@
import { Service } from 'typedi';
import Logger from '../loaders/logger';
import { GrpcServerService } from './grpc';
import cronClient from '../schedule/client';
import { HttpServerService } from './http';
interface HealthStatus {
@@ -23,7 +23,6 @@ export class HealthService {
private startTime = Date.now();
constructor(
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService,
) {}
@@ -56,8 +55,7 @@ export class HealthService {
}
try {
const grpcServer = this.grpcServerService.getServer();
if (!grpcServer) {
if (!(await cronClient.readiness.check())) {
status.services.grpc = false;
status.status = 'error';
}
+8 -2
View File
@@ -12,7 +12,7 @@ export class HttpServerService {
async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [
config.bindHost,
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
...(config.bindHost === '::' ? ['0.0.0.0'] : [])
];
let lastError: Error | null = null;
@@ -39,7 +39,13 @@ export class HttpServerService {
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
// There is one HTTP worker; accepting here avoids primary IPC handoff
// for every connection. Restore shared listening for custom clusters.
const server = expressApp.listen({
port,
host,
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
}, () => {
resolve(server);
});
+2 -3
View File
@@ -1,4 +1,4 @@
import path from 'path';
import { resolveFileAccess } from '../shared/fileAccess';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@@ -8,7 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
}
}
+61 -19
View File
@@ -8,16 +8,19 @@ interface Metric {
tags?: Record<string, string>;
}
class MetricsService {
private metrics: Metric[] = [];
export const MAX_METRIC_SAMPLES = 1000;
const METRIC_RETENTION_MS = 60 * 60 * 1000;
export class MetricsService {
private metrics: Array<Metric | undefined> = new Array(MAX_METRIC_SAMPLES);
private metricCount = 0;
private nextMetricIndex = 0;
private static instance: MetricsService;
private constructor() {
// 定期清理旧数据
setInterval(() => {
const oneHourAgo = Date.now() - 3600000;
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
}, 60000);
const cleanupTimer = setInterval(() => this.removeExpiredMetrics(), 60000);
cleanupTimer.unref();
}
static getInstance(): MetricsService {
@@ -28,12 +31,14 @@ class MetricsService {
}
record(name: string, value: number, tags?: Record<string, string>) {
this.metrics.push({
this.metrics[this.nextMetricIndex] = {
name,
value,
timestamp: Date.now(),
tags,
});
};
this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES;
this.metricCount = Math.min(this.metricCount + 1, MAX_METRIC_SAMPLES);
}
measure(name: string, fn: () => void, tags?: Record<string, string>) {
@@ -46,7 +51,11 @@ class MetricsService {
}
}
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
async measureAsync(
name: string,
fn: () => Promise<void>,
tags?: Record<string, string>,
) {
const start = performance.now();
try {
await fn();
@@ -57,28 +66,61 @@ class MetricsService {
}
getMetrics(name?: string, tags?: Record<string, string>) {
let filtered = this.metrics;
this.removeExpiredMetrics();
let filtered = this.getMetricSnapshot();
if (name) {
filtered = filtered.filter(m => m.name === name);
filtered = filtered.filter((m) => m.name === name);
}
if (tags) {
filtered = filtered.filter(m => {
filtered = filtered.filter((m) => {
if (!m.tags) return false;
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
return Object.entries(tags).every(
([key, value]) => m.tags![key] === value,
);
});
}
const values = filtered.map((metric) => metric.value);
return {
count: filtered.length,
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
min: Math.min(...filtered.map(m => m.value)),
max: Math.max(...filtered.map(m => m.value)),
average: values.length
? values.reduce((acc, value) => acc + value, 0) / values.length
: 0,
min: values.length ? Math.min(...values) : 0,
max: values.length ? Math.max(...values) : 0,
metrics: filtered,
};
}
private getMetricSnapshot(): Metric[] {
if (this.metricCount < MAX_METRIC_SAMPLES) {
return this.metrics.slice(0, this.metricCount) as Metric[];
}
return [
...this.metrics.slice(this.nextMetricIndex),
...this.metrics.slice(0, this.nextMetricIndex),
] as Metric[];
}
private removeExpiredMetrics() {
const oldestTimestamp = Date.now() - METRIC_RETENTION_MS;
const retained = this.getMetricSnapshot().filter(
(metric) => metric.timestamp > oldestTimestamp,
);
if (retained.length === this.metricCount) return;
this.metrics = new Array(MAX_METRIC_SAMPLES);
this.metricCount = 0;
this.nextMetricIndex = 0;
for (const metric of retained) {
this.metrics[this.nextMetricIndex] = metric;
this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES;
this.metricCount++;
}
}
report() {
const report = {
timestamp: Date.now(),
@@ -89,4 +131,4 @@ class MetricsService {
}
}
export const metricsService = MetricsService.getInstance();
export const metricsService = MetricsService.getInstance();
+28
View File
@@ -37,6 +37,7 @@ export default class NotificationService {
['wxPusherBot', this.wxPusherBot],
['wxPusherSpt', this.wxPusherSpt],
['openiLink', this.openiLink],
['wpush', this.wpush],
]);
private title = '';
@@ -947,4 +948,31 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error);
}
}
private async wpush() {
const { wpushApiKey, wpushChannel, wpushTopicCode } = this.params;
const url = 'https://api.wpush.cn/api/v1/send';
const json: Record<string, string> = {
apikey: `${wpushApiKey}`,
title: `${this.title}`,
content: `${this.content}`,
channel: `${wpushChannel || 'wechat'}`,
};
if (wpushTopicCode) {
json.topic_code = `${wpushTopicCode}`;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json,
});
if (res.code === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
}
+116 -34
View File
@@ -6,27 +6,77 @@ import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store';
import { t } from '../shared/i18n';
import { lock } from 'proper-lockfile';
import path from 'path';
import os from 'os';
import { writeFile, rename, rm, stat, chmod } from 'fs/promises';
import config from '../config';
@Service()
export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {}
// Every database mutation and full authentication-cache publication shares
// this cross-process lock. Lock a separate marker, not token.json itself.
private async withAppsLock<T>(operation: () => Promise<T>): Promise<T> {
const target = path.join(config.configPath, '.apps-state');
const release = await lock(target, {
realpath: false,
stale: 10_000,
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 },
});
try {
return await operation();
} finally {
await release();
}
}
public async initializeSystemApp(): Promise<void> {
await this.withAppsLock(async () => {
const doc = await AppModel.findOne({ where: { name: 'system' } });
const app = doc?.get({ plain: true });
if (!app) {
await this.insert({
name: 'system',
scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!app.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...app.scopes, 'dashboard'] },
{ where: { id: app.id } },
);
}
await shareStore.updateApps(await this.find({}));
});
}
public async refreshApps(): Promise<void> {
await this.withAppsLock(async () => {
await shareStore.updateApps(await this.find({}));
});
}
public async findApps(): Promise<App[] | null> {
const docs = await this.find({});
return docs;
}
public async create(payload: App): Promise<App> {
const tab = { ...payload };
tab.client_id = createRandomString(12, 12);
tab.client_secret = createRandomString(24, 24);
const doc = await this.insert(tab);
const apps = await this.find({});
await shareStore.updateApps(apps);
return { ...doc, tokens: [] };
return this.withAppsLock(async () => {
const tab = { ...payload };
tab.client_id = createRandomString(12, 12);
tab.client_secret = createRandomString(24, 24);
const doc = await this.insert(tab);
const apps = await this.find({});
await shareStore.updateApps(apps);
return { ...doc, tokens: [] };
});
}
public async insert(payload: App): Promise<App> {
private async insert(payload: App): Promise<App> {
const doc = await AppModel.create(payload, { returning: true });
return doc.get({ plain: true });
}
@@ -41,10 +91,12 @@ export default class OpenService {
}
private async updateDb(payload: Partial<App>): Promise<App> {
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
return this.withAppsLock(async () => {
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
});
}
public async getDb(query: Record<string, any>): Promise<App> {
@@ -56,9 +108,11 @@ export default class OpenService {
}
public async remove(ids: number[]) {
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
return this.withAppsLock(async () => {
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
});
}
public async resetSecret(id: number): Promise<App> {
@@ -112,7 +166,15 @@ export default class OpenService {
return docs.map((x) => x.get({ plain: true }));
}
public async authToken({
public async authToken(credentials: {
client_id: string;
client_secret: string;
}): Promise<any> {
return this.withAppsLock(() => this.issueToken(credentials));
}
// Caller holds withAppsLock through database update and cache publication.
private async issueToken({
client_id,
client_secret,
}: {
@@ -156,27 +218,47 @@ export default class OpenService {
}
}
public async generateSystemToken(): Promise<{
public async generateSystemToken(renew = false): Promise<{
value: string;
expiration: number;
}> {
const apps = await shareStore.getApps();
const systemApp = apps?.find((x) => x.name === 'system');
if (!systemApp) {
throw new Error('system app not found');
}
const now = Math.round(Date.now() / 1000);
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
if (currentToken) {
return currentToken;
}
const { data } = await this.authToken({
client_id: systemApp.client_id,
client_secret: systemApp.client_secret,
return this.withAppsLock(async () => {
// Read after acquiring the lock; a concurrent issuer may have renewed.
const systemApp = await this.getDb({ name: 'system' });
const now = Math.round(Date.now() / 1000);
let appToken = systemApp.tokens
?.filter((token) => token.expiration > now)
.sort((left, right) => right.expiration - left.expiration)[0];
if (renew || !appToken) {
const { data } = await this.issueToken({
client_id: systemApp.client_id,
client_secret: systemApp.client_secret,
});
appToken = { value: data.token, expiration: data.expiration };
} else {
// Recover a prior database commit whose cache publication failed.
await shareStore.updateApps(await this.find({}));
}
const tokenFile = path.join(config.configPath, 'token.json');
const temporaryFile = `${tokenFile}.${process.pid}.tmp`;
const mode = await stat(tokenFile).then(
(file) => file.mode & 0o777,
(error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return 0o600;
throw error;
},
);
try {
await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, {
mode,
});
await chmod(temporaryFile, mode);
// Shell readers see a complete old or new document, never a partial one.
await rename(temporaryFile, tokenFile);
} finally {
await rm(temporaryFile, { force: true });
}
return appToken;
});
return {
...data,
value: data.token,
};
}
}
+155
View File
@@ -0,0 +1,155 @@
import fs from 'fs/promises';
import path from 'path';
import dayjs from 'dayjs';
import { Op } from 'sequelize';
import { Service } from 'typedi';
import config from '../config';
import { CrontabStatModel } from '../data/cronStats';
import { sequelize } from '../data';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { AuthDataType, SystemModel } from '../data/system';
import {
DependenceCacheType,
getDirectorySize,
isDependenceCacheType,
normalizeRetentionPolicy,
RetentionPolicy,
} from '../shared/retention';
export interface StorageCleanupRequest extends RetentionPolicy {
dependenceCacheTypes?: DependenceCacheType[];
compactDatabase?: boolean;
}
function runningInstanceWhere(days: number) {
const cutoff = dayjs().subtract(days, 'day').unix();
return {
status: { [Op.ne]: InstanceStatus.running },
[Op.or]: [
{ finished_at: { [Op.lt]: cutoff } },
{
finished_at: { [Op.is]: null },
started_at: { [Op.lt]: cutoff },
},
],
};
}
function cronStatWhere(days: number) {
return {
date: { [Op.lt]: dayjs().subtract(days, 'day').format('YYYY-MM-DD') },
};
}
@Service()
export default class RetentionService {
public async updatePolicy(policy: Partial<RetentionPolicy>) {
const normalized = normalizeRetentionPolicy(policy);
const systemConfig = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (!systemConfig) {
throw new Error('System config not found');
}
await SystemModel.update(
{
info: {
...systemConfig.info,
...normalized,
},
},
{ where: { id: systemConfig.id } },
);
return normalized;
}
public async preview(request: StorageCleanupRequest) {
const policy = normalizeRetentionPolicy(request);
const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter(
isDependenceCacheType,
);
const [runningInstances, cronStats, dependenceCaches] = await Promise.all([
policy.runningInstanceRetentionDays > 0
? RunningInstanceModel.count({
where: runningInstanceWhere(policy.runningInstanceRetentionDays),
})
: 0,
policy.cronStatRetentionDays > 0
? CrontabStatModel.count({
where: cronStatWhere(policy.cronStatRetentionDays),
})
: 0,
Promise.all(
dependenceCacheTypes.map(async (type) => ({
type,
bytes: await getDirectorySize(
path.join(config.dependenceCachePath, type),
),
})),
),
]);
return {
policy,
runningInstances,
cronStats,
dependenceCaches,
dependenceCacheBytes: dependenceCaches.reduce(
(total, cache) => total + cache.bytes,
0,
),
compactDatabase: Boolean(request.compactDatabase),
};
}
public async cleanup(request: StorageCleanupRequest) {
const preview = await this.preview(request);
const deleted = await sequelize.transaction(async (transaction) => {
const runningInstances =
preview.policy.runningInstanceRetentionDays > 0
? await RunningInstanceModel.destroy({
where: runningInstanceWhere(
preview.policy.runningInstanceRetentionDays,
),
transaction,
})
: 0;
const cronStats =
preview.policy.cronStatRetentionDays > 0
? await CrontabStatModel.destroy({
where: cronStatWhere(preview.policy.cronStatRetentionDays),
transaction,
})
: 0;
return { runningInstances, cronStats };
});
const dependenceCaches = [];
for (const cache of preview.dependenceCaches) {
await fs.rm(path.join(config.dependenceCachePath, cache.type), {
recursive: true,
force: true,
});
dependenceCaches.push(cache);
}
if (
request.compactDatabase &&
(deleted.runningInstances || deleted.cronStats)
) {
await sequelize.query('VACUUM');
}
return {
deleted: {
...deleted,
dependenceCaches,
dependenceCacheBytes: preview.dependenceCacheBytes,
},
compactedDatabase: Boolean(
request.compactDatabase &&
(deleted.runningInstances || deleted.cronStats),
),
};
}
}
+112 -80
View File
@@ -1,6 +1,6 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import nodeSchedule from 'node-schedule';
import { createCronJob, CronJob } from '../shared/cronScheduler';
import { ChildProcessWithoutNullStreams } from 'child_process';
import {
ToadScheduler,
@@ -11,6 +11,11 @@ import {
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
import {
observeChildProcess,
asError,
ProcessResult,
} from '../shared/childProcess';
export interface ScheduleTaskType {
id?: number;
@@ -27,7 +32,7 @@ export interface TaskCallbacks {
startTime: dayjs.Dayjs,
) => Promise<void>;
onEnd?: (
cp: ChildProcessWithoutNullStreams,
cp: ChildProcessWithoutNullStreams | undefined,
endTime: dayjs.Dayjs,
diff: number,
) => Promise<void>;
@@ -37,7 +42,7 @@ export interface TaskCallbacks {
@Service()
export default class ScheduleService {
private scheduleStacks = new Map<string, nodeSchedule.Job>();
private scheduleStacks = new Map<string, CronJob>();
private intervalSchedule = new ToadScheduler();
@@ -63,73 +68,85 @@ export default class ScheduleService {
) {
const { runOrigin, ...others } = params;
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
return new Promise(async (resolve, reject) => {
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify({
...others,
command,
})}`,
);
try {
const startTime = dayjs();
await callbacks.onBefore?.(startTime);
const cp = spawn(command, { shell: '/bin/bash' });
callbacks.onStart?.(cp, startTime);
completionTime === 'start' && resolve(cp.pid);
cp.stdout.on('data', async (data) => {
await callbacks.onLog?.(data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.info(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await callbacks.onError?.(data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
await callbacks.onError?.(JSON.stringify(err));
});
cp.on('exit', async (code) => {
this.logger.info(
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...others,
command,
}),
code,
);
const endTime = dayjs();
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
resolve({ ...others, pid: cp.pid, code });
});
} catch (error) {
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
error,
);
await callbacks.onError?.(JSON.stringify(error));
}
});
let resolveStart!: (pid: number | undefined) => void;
let rejectStart!: (error: Error) => void;
const startResult = new Promise<number | undefined>((resolve, reject) => {
resolveStart = resolve;
rejectStart = reject;
});
// Most scheduled callers only observe completion (or intentionally detach).
void startResult.catch(() => {});
const completion = taskLimit[this.taskLimitMap[runOrigin]](
others,
async () => {
const startTime = dayjs();
let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null };
try {
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
const observed = observeChildProcess(child, {
onStart: async () => {
await callbacks.onStart?.(child, startTime);
},
onStdout: callbacks.onLog,
onStderr: callbacks.onError,
});
observed.started.then(resolveStart, rejectStart);
result = await observed.completed;
} catch (error) {
result.error = asError(error);
rejectStart(result.error);
}
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
others.id,
result.error.message,
);
try {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
'[panel][任务错误回调失败] %s',
asError(error).message,
);
}
}
// Cleanup also runs after setup/spawn failure, and only after both pipes drain.
const endTime = dayjs();
try {
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
} catch (error) {
result.error ??= asError(error);
this.logger.error(
'[panel][任务结束回调失败] %s',
asError(error).message,
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
others.id,
result.code,
);
return { ...others, pid: cp?.pid, ...result };
},
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
// Returning a PID must not release the execution slot while the process runs.
return completionTime === 'start' ? startResult : completion;
}
async createCronTask(
@@ -146,18 +163,33 @@ export default class ScheduleService {
command,
);
this.scheduleStacks.set(
_id,
nodeSchedule.scheduleJob(_id, schedule, async () => {
this.runTask(command, callbacks, {
name,
if (schedule) {
try {
const job = createCronJob(
schedule,
command,
id: _id,
runOrigin,
});
}),
);
() =>
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
}),
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
);
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.set(_id, job);
job.start();
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
_id,
schedule,
asError(error).message,
);
}
}
if (runImmediately) {
this.runTask(command, callbacks, {
+5 -5
View File
@@ -1,3 +1,4 @@
import { resolveFileAccess } from '../shared/fileAccess';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import path, { join } from 'path';
@@ -57,16 +58,15 @@ export default class ScriptService {
taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-'));
pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number;
}
try {
await killTask(pid);
} catch (error) {}
if (pid) {
await killTask(pid, true);
}
return { code: 200 };
}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
return resolveFileAccess(config.scriptPath, [filePath || '', fileName]);
}
public async getFile(filePath: string, fileName: string) {
+12
View File
@@ -0,0 +1,12 @@
import path from 'path';
import { Service } from 'typedi';
import config from '../config';
import { ScriptHistory } from '../shared/scriptHistory';
/** Panel configuration and lifecycle; the storage core stays independently testable. */
@Service()
export default class ScriptHistoryService extends ScriptHistory {
constructor() {
super(config.scriptPath, path.join(config.dataPath, 'script-history'));
}
}
+43 -23
View File
@@ -8,10 +8,22 @@ import { formatUrl } from '../config/subscription';
import config from '../config';
import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
import {
getSubscriptionSshAlias,
resolveSubscriptionPath,
} from '../shared/subscriptionPath';
@Service()
export default class SshKeyService {
private homedir = os.homedir();
// OpenSSH uses the effective user's passwd entry, not the HOME override.
private get homedir(): string {
try {
return os.userInfo().homedir;
} catch {
// Custom container UIDs may have no passwd entry. Keep startup working.
return os.homedir();
}
}
private sshPath = config.sshdPath;
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
@@ -21,6 +33,10 @@ export default class SshKeyService {
}
private async initSshConfigFile() {
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
recursive: true,
mode: 0o700,
});
let config = '';
const _exist = await fileExist(this.sshConfigFilePath);
if (_exist) {
@@ -42,7 +58,7 @@ export default class SshKeyService {
key: string,
): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
const filePath = resolveSubscriptionPath(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
@@ -54,7 +70,7 @@ export default class SshKeyService {
private async removePrivateKeyFile(alias: string): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
@@ -66,18 +82,19 @@ export default class SshKeyService {
host: string,
proxy?: string,
) {
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
}
if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
}
const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`;
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
{
encoding: 'utf8',
@@ -88,7 +105,7 @@ export default class SshKeyService {
private async removeSshConfig(alias: string) {
try {
const filePath = path.join(this.sshPath, `${alias}.config`);
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
@@ -101,6 +118,7 @@ export default class SshKeyService {
host: string,
proxy?: string,
): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy);
}
@@ -110,6 +128,7 @@ export default class SshKeyService {
host: string,
proxy?: string,
): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
}
@@ -117,15 +136,19 @@ export default class SshKeyService {
public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc;
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, proxy);
try {
const alias = getSubscriptionSshAlias(doc.alias);
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
}
}
}
}
@@ -144,12 +167,9 @@ export default class SshKeyService {
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`;
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
{
encoding: 'utf8',
+174 -84
View File
@@ -20,7 +20,7 @@ import {
} from '../config/util';
import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize';
import path, { join } from 'path';
import path from 'path';
import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock';
@@ -30,9 +30,18 @@ import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
import { resolveLogPath } from '../shared/logPath';
import {
getSubscriptionStorageCandidates,
isSubscriptionStorageName,
resolveSubscriptionStoragePath,
} from '../shared/subscriptionStorage';
import { withSubscriptionMutation } from '../shared/subscriptionMutationLock';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
@Service()
export default class SubscriptionService {
@@ -87,9 +96,11 @@ export default class SubscriptionService {
needCreate = true,
runImmediately = false,
) {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
// Cancelling by ID must also work for invalid historical subscriptions.
if (needCreate) {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
}
if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any);
@@ -158,48 +169,54 @@ export default class SubscriptionService {
);
},
onEnd: async (cp, endTime, diff) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
let absolutePath: string | undefined;
try {
if (sub.sub_after) {
await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
afterStr = await promiseExec(sub.sub_after);
const sub = await this.getDb({ id: doc.id });
absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
try {
if (sub.sub_after) {
await logStreamManager.write(
absolutePath,
`\n\n## ${t('执行after命令...')}\n\n`,
);
afterStr = await promiseExec(sub.sub_after);
}
} catch (error: any) {
afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
absolutePath,
'\n' +
tf(
'## 执行结束... %s 耗时 %s 秒',
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(diff),
) +
LOG_END_SYMBOL,
);
} finally {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} finally {
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id } },
);
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: t('订阅执行完成'),
references: [doc.id as number],
});
}
} catch (error: any) {
afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
absolutePath,
'\n' +
tf(
'## 执行结束... %s 耗时 %s 秒',
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(diff),
) +
LOG_END_SYMBOL,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
);
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: t('订阅执行完成'),
references: [doc.id as number],
});
},
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
@@ -215,11 +232,14 @@ export default class SubscriptionService {
}
public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig();
return doc;
assertSubscriptionAlias(payload.alias);
return withSubscriptionMutation(async () => {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig();
return doc;
});
}
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
@@ -227,12 +247,27 @@ export default class SubscriptionService {
}
public async update(payload: Subscription): Promise<Subscription> {
const doc = await this.getDb({ id: payload.id });
const tab = new Subscription({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.handleTask(newDoc, !newDoc.is_disabled);
await this.setSshConfig();
return newDoc;
assertSubscriptionAlias(payload.alias);
return withSubscriptionMutation(async () => {
const doc = await this.getDb({ id: payload.id });
const tab = new Subscription({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.handleTask(newDoc, !newDoc.is_disabled);
await this.removeSshConfigForSubscription(doc);
await this.setSshConfig();
return newDoc;
});
}
private async removeSshConfigForSubscription(doc: Subscription) {
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
// Invalid historical rows must remain deletable without deriving unsafe paths.
try {
assertSubscriptionAlias(doc.alias);
} catch {
return;
}
await this.sshKeyService.removeSSHKey(doc.alias, '');
}
public async updateDb(payload: Subscription): Promise<Subscription> {
@@ -255,6 +290,7 @@ export default class SubscriptionService {
last_running_time: number;
last_execution_time: number;
}) {
if (log_path) resolveLogPath(config.logPath, log_path);
const options: any = {
status,
pid,
@@ -272,25 +308,71 @@ export default class SubscriptionService {
}
public async remove(ids: number[], query: { force?: boolean }) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false);
}
await SubscriptionModel.destroy({ where: { id: ids } });
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
return withSubscriptionMutation(async () => {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
const storagePaths: string[] = [];
const retainedPaths: string[] = [];
const collectPaths = (
doc: Subscription,
targets: string[],
deleting = false,
) => {
for (const names of getSubscriptionStorageCandidates(doc)) {
// A URL accepted by one engine can produce an invalid name in another.
// Validate each candidate independently, retaining the safe candidate.
if (!Object.values(names).every(isSubscriptionStorageName)) continue;
const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve;
const scriptPath = resolve(config.scriptPath, names.script);
if (scriptPath) targets.push(scriptPath);
if (names.repo) {
const repoPath = resolve(config.repoPath, names.repo);
if (repoPath) targets.push(repoPath);
}
if (names.raw) {
const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw);
if (rawPath) targets.push(rawPath);
}
}
};
// Validate all deletion targets before changing rows or removing any files.
if (query?.force === true) {
for (const doc of docs) {
assertSubscriptionAlias(doc.alias);
resolveSubscriptionStoragePath(config.scriptPath, doc.alias);
resolveSubscriptionStoragePath(config.repoPath, doc.alias);
collectPaths(doc, storagePaths, true);
}
const remaining = await SubscriptionModel.findAll({ where: {} });
for (const doc of remaining) {
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
}
}
for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias);
const repoPath = join(config.repoPath, doc.alias);
await rmPath(filePath);
await rmPath(repoPath);
await this.handleTask(doc.get({ plain: true }), false);
}
}
await SubscriptionModel.destroy({ where: { id: ids } });
for (const doc of docs) {
await this.removeSshConfigForSubscription(doc);
}
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
}
for (const storagePath of new Set(storagePaths)) {
// rm is recursive: an ancestor must also be retained if a surviving
// subscription uses a nested branch, and vice versa.
const shared = retainedPaths.some((retained) =>
retained === storagePath ||
retained.startsWith(`${storagePath}${path.sep}`) ||
storagePath.startsWith(`${retained}${path.sep}`),
);
if (!shared) await rmPath(storagePath);
}
}
});
}
public async getDb(
@@ -315,20 +397,22 @@ export default class SubscriptionService {
public async stop(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
let failure: unknown;
for (const doc of docs) {
if (doc.pid) {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
try {
if (doc.pid) {
await killTask(doc.pid, true);
}
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id, pid: doc.pid ?? null } },
);
} catch (error) {
this.logger.error(error);
failure ??= error;
}
}
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: ids } },
);
if (failure) throw failure;
}
private async runSingle(subscriptionId: number) {
@@ -366,14 +450,20 @@ export default class SubscriptionService {
}
}
public async log(id: number) {
public async log(id: number, options: LogReadOptions = {}) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
return '';
return {
content: '',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
const absolutePath = await handleLogPath(doc.log_path as string);
return await getFileContentByName(absolutePath);
return await readLogChunk(absolutePath, options);
}
public async logs(id: number) {
@@ -384,7 +474,7 @@ export default class SubscriptionService {
if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
const _exist = await fileExist(dir);
if (_exist) {
let files = await fs.readdir(dir);
+93 -29
View File
@@ -1,8 +1,10 @@
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
import sum from 'lodash/sum';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
@@ -32,6 +34,8 @@ import {
SystemModelInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock';
@@ -120,15 +124,29 @@ export default class SystemService {
}
public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
const limit = info.cronConcurrency ?? 0;
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
throw Object.assign(new Error(t('参数错误')), { status: 400 });
}
return { code: 200, data: info };
return withSchedulerMutation(async () => {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info, cronConcurrency: limit },
});
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
await cronClient.setConcurrency(limit);
return { code: 200, data: info };
});
}
public async restoreCronConcurrency() {
await withSchedulerMutation(async () => {
const doc = await this.getSystemConfig();
const limit = doc.info?.cronConcurrency ?? 0;
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
await cronClient.setConcurrency(limit);
});
}
public async updateDependenceProxy(info: SystemModelInfo) {
@@ -140,7 +158,7 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -156,7 +174,7 @@ export default class SystemService {
});
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
@@ -205,7 +223,7 @@ export default class SystemService {
});
let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) {
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`;
}
await promiseExec(cmd);
return { code: 200, data: info };
@@ -221,7 +239,6 @@ export default class SystemService {
return;
}
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
let hasError = false;
this.scheduleService.runTask(
command,
{
@@ -229,14 +246,15 @@ export default class SystemService {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
onEnd: async (cp) => {
this.sockService.sendMessage({
type: 'updateLinuxMirror',
message: 'update linux mirror end',
status: 'completed',
});
onEnd?.();
if (!hasError) {
// Tools such as sudo and apt may write warnings to stderr on success.
if (cp?.exitCode === 0 && cp.signalCode === null) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
@@ -244,7 +262,6 @@ export default class SystemService {
}
},
onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
onLog: async (message: string) => {
@@ -358,7 +375,10 @@ export default class SystemService {
}
public async reloadSystem(target?: 'system' | 'data') {
const cmd = `real_time=true ql reload ${target || ''}`;
if (target && !['system', 'data'].includes(target)) {
return { code: 400, message: t('参数错误') };
}
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
detached: true,
@@ -439,11 +459,20 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
if (dataDirs.includes('scripts')) dataDirs.push('script-history');
// History is created lazily; old/fresh installations must still export.
if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) {
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
@@ -467,6 +496,7 @@ export default class SystemService {
query: {
startTime?: string;
endTime?: string;
limit?: number;
},
) {
const startTime = dayjs(query.startTime || undefined)
@@ -481,8 +511,30 @@ export default class SystemService {
.filter((x) => x.title.endsWith('.log'))
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
const limit = Math.min(
Math.max(Number(query.limit) || 1024 * 1024, 1),
1024 * 1024,
);
const total = logs.reduce((size, log) => size + (log.size || 0), 0);
let remaining = limit;
const selected: Array<
(typeof logs)[number] & { start: number; length: number }
> = [];
for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) {
const log = logs[index];
const size = log.size || 0;
const length = Math.min(size, remaining);
if (length > 0) {
selected.unshift({ ...log, start: size - length, length });
remaining -= length;
}
}
const contentLength = selected.reduce((size, log) => size + log.length, 0);
res.set({
'Content-Length': sum(logs.map((x) => x.size)),
'Content-Length': contentLength,
'X-QL-Log-Total': total,
'X-QL-Log-Truncated': total > contentLength ? 'true' : 'false',
});
(function sendFiles(res, fileNames) {
if (fileNames.length === 0) {
@@ -494,13 +546,17 @@ export default class SystemService {
if (currentLog) {
const currentFileStream = fs.createReadStream(
path.join(config.systemLogPath, currentLog.title),
{
start: currentLog.start,
end: currentLog.start + currentLog.length - 1,
},
);
currentFileStream.on('end', () => {
sendFiles(res, fileNames);
});
currentFileStream.pipe(res, { end: false });
}
})(res, logs);
})(res, selected);
}
public async deleteSystemLog() {
@@ -539,7 +595,7 @@ export default class SystemService {
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG='${lang}'\n`,
`export QL_LANG=${shellQuote(lang)}\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
@@ -583,10 +639,18 @@ export default class SystemService {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true, force: true });
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{
where: {
type:
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
status: DependenceStatus.installed,
},
},
);
return { code: 200 };
}
}
+194 -33
View File
@@ -20,15 +20,24 @@ import ScheduleService from './schedule';
import SockService from './sock';
import dayjs from 'dayjs';
import IP2Region from 'ip2region';
import requestIp from 'request-ip';
import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store';
import { t, tf } from '../shared/i18n';
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
import { isDefaultAuthInfo } from '../shared/auth';
import {
hashPassword,
isPasswordHash,
verifyPassword,
} from '../shared/password';
import { serializeAuthMutation } from '../shared/authMutation';
import { LoginThrottle } from '../shared/loginThrottle';
@Service()
export default class UserService {
private loginThrottle = new LoginThrottle();
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
@@ -38,7 +47,15 @@ export default class UserService {
private sockService: SockService,
) {}
@serializeAuthMutation
public async login(
payloads: { username: string; password: string },
req: Request,
): Promise<any> {
return this.authenticate(payloads, req);
}
private async authenticate(
payloads: {
username: string;
password: string;
@@ -48,7 +65,18 @@ export default class UserService {
): Promise<any> {
let { username, password } = payloads;
const content = await this.getAuthInfo();
if (isDefaultAuthInfo(content)) {
return { code: 450, message: t('请先初始化') };
}
const timestamp = Date.now();
const ip = getClientIp(req);
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
let {
username: cUsername,
password: cPassword,
@@ -59,12 +87,29 @@ export default class UserService {
twoFactorActivated,
tokens = {},
platform,
blockedIps = [],
} = content;
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
if (
ip &&
blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip)
) {
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.fail,
},
});
this.getLoginLog();
return { code: 403, message: t('该 IP 已被列入黑名单') };
}
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
if (waitTime > 0) {
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -72,14 +117,13 @@ export default class UserService {
};
}
if (
username === cUsername &&
password === cPassword &&
twoFactorActivated &&
needTwoFactor
) {
const passwordMatches =
username === cUsername && (await verifyPassword(password, cPassword));
if (passwordMatches && twoFactorActivated && needTwoFactor) {
await this.updateAuthInfo(content, {
isTwoFactorChecking: true,
twoFactorExpiresAt: timestamp + 5 * 60 * 1000,
});
return {
code: 420,
@@ -87,15 +131,8 @@ export default class UserService {
};
}
const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) {
if (passwordMatches) {
this.loginThrottle.reset(ip);
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
@@ -118,6 +155,9 @@ export default class UserService {
);
await this.updateAuthInfo(content, {
password: isPasswordHash(cPassword)
? cPassword
: await hashPassword(password),
token,
tokens: updatedTokens,
lastlogon: timestamp,
@@ -126,6 +166,7 @@ export default class UserService {
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
this.notificationService.notify(
t('登录通知'),
@@ -165,8 +206,9 @@ export default class UserService {
},
};
} else {
const failures = this.loginThrottle.fail(ip, timestamp);
await this.updateAuthInfo(content, {
retries: retries + 1,
retries: failures,
lastlogon: timestamp,
lastip: ip,
lastaddr: address,
@@ -198,8 +240,8 @@ export default class UserService {
},
});
this.getLoginLog();
if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1));
if (failures > 3) {
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -211,6 +253,7 @@ export default class UserService {
}
}
@serializeAuthMutation
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
@@ -264,11 +307,67 @@ export default class UserService {
return [];
}
public async getIpBlacklist(): Promise<string[]> {
const authInfo = await this.getAuthInfo();
return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter(
Boolean,
);
}
@serializeAuthMutation
public async blockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
const blockedIps = uniq([
...(authInfo.blockedIps || []).map(normalizeClientIp),
normalizeClientIp(ip),
]).filter(Boolean);
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
@serializeAuthMutation
public async unblockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
const normalizedIp = normalizeClientIp(ip);
const blockedIps = (authInfo.blockedIps || [])
.map(normalizeClientIp)
.filter((blockedIp) => blockedIp && blockedIp !== normalizedIp);
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc;
}
@serializeAuthMutation
public async initializeUser({
username,
password,
}: {
username: string;
password: string;
}) {
const authInfo = await this.getAuthInfo();
if (!isDefaultAuthInfo(authInfo)) {
return { code: 450, message: t('未知错误') };
}
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
await this.updateAuthInfo(authInfo, {
username,
password: await hashPassword(password),
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return { code: 200, message: t('更新成功') };
}
@serializeAuthMutation
public async updateUsernameAndPassword({
username,
password,
@@ -280,24 +379,37 @@ export default class UserService {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password });
await this.updateAuthInfo(authInfo, {
username,
password: await hashPassword(password),
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return { code: 200, message: t('更新成功') };
}
@serializeAuthMutation
public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: t('更新成功') };
}
@serializeAuthMutation
public async initTwoFactor() {
const secret = authenticator.generateSecret();
const authInfo = await this.getAuthInfo();
if (authInfo.twoFactorActivated) {
throw new Error(t('请先关闭两步验证'));
}
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth };
}
@serializeAuthMutation
public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo();
const isValid = authenticator.verify({
@@ -305,11 +417,18 @@ export default class UserService {
secret: authInfo.twoFactorSecret,
});
if (isValid) {
await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
await this.updateAuthInfo(authInfo, {
twoFactorActivated: true,
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
}
return isValid;
}
@serializeAuthMutation
public async twoFactorLogin(
{
username,
@@ -320,17 +439,30 @@ export default class UserService {
) {
const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) {
const now = Date.now();
const source = getClientIp(req);
if (this.loginThrottle.retryAfter(source, now) > 0) {
return { code: 410, message: t('失败次数过多,请稍后重试') };
}
if (
!isTwoFactorChecking ||
!authInfo.twoFactorActivated ||
!authInfo.twoFactorExpiresAt ||
authInfo.twoFactorExpiresAt <= now
) {
return { code: 450, message: t('未知错误') };
}
const isValid = authenticator.verify({
token: code,
secret: twoFactorSecret,
});
const step = Math.floor(now / 30000);
const isValid =
username === authInfo.username &&
(await verifyPassword(password, authInfo.password)) &&
authInfo.lastTwoFactorStep !== step &&
authenticator.verify({ token: code, secret: twoFactorSecret });
if (isValid) {
return this.login({ username, password }, req, false);
await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step });
return this.authenticate({ username, password }, req, false);
} else {
const ip = requestIp.getClientIp(req) || '';
const ip = getClientIp(req);
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
@@ -341,6 +473,8 @@ export default class UserService {
.join(' ');
}
await this.updateAuthInfo(authInfo, {
retries: this.loginThrottle.fail(source, now),
lastlogon: now,
lastip: ip,
lastaddr: address,
platform: req.platform,
@@ -349,11 +483,16 @@ export default class UserService {
}
}
@serializeAuthMutation
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, {
twoFactorActivated: false,
twoFactorSecret: '',
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return true;
}
@@ -374,6 +513,9 @@ export default class UserService {
type: AuthDataType.authConfig,
info: result,
});
if (info.tokens && Object.keys(info.tokens).length === 0) {
this.sockService.getClients().forEach((conn) => conn.close('401'));
}
}
public async getNotificationMode(): Promise<NotificationInfo> {
@@ -515,6 +657,7 @@ export default class UserService {
return undefined;
}
@serializeAuthMutation
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
if (password === 'admin') {
@@ -531,6 +674,24 @@ export default class UserService {
(x) => !isNil(x),
);
if (password !== undefined) {
payload.password = await hashPassword(password);
}
if (
password !== undefined ||
username !== undefined ||
twoFactorActivated !== undefined
) {
Object.assign(payload, {
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
}
await this.updateAuthInfo(authInfo, payload);
if (retries === 0 || password !== undefined || username !== undefined) {
this.loginThrottle.clear();
}
}
}
+18 -6
View File
@@ -1,11 +1,8 @@
import { AuthInfo, TokenInfo } from '../data/system';
import jwt from 'jsonwebtoken';
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
return (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
);
return authInfo.username === 'admin' && authInfo.password === 'admin';
}
/**
@@ -21,11 +18,21 @@ export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
secret: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
try {
const claims = jwt.verify(headerToken, secret, { algorithms: ['HS384'] });
if (typeof claims === 'string' || typeof claims.exp !== 'number') {
return false;
}
} catch {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
@@ -46,7 +53,12 @@ export function isValidToken(
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
return platformTokens.some(
(t: TokenInfo) =>
t &&
t.value === headerToken &&
(t.expiration === undefined || t.expiration > Date.now() / 1000),
);
}
// Unexpected type - log warning and reject
+17
View File
@@ -0,0 +1,17 @@
// All account mutations in the HTTP service share one queue. In particular,
// a login that read old credentials must finish before a password reset revokes
// its session, and two initialization requests must not both claim the account.
let pending: Promise<unknown> = Promise.resolve();
export function serializeAuthMutation(
_target: object,
_key: string,
descriptor: PropertyDescriptor,
) {
const method = descriptor.value;
descriptor.value = function (this: unknown, ...args: unknown[]) {
const result = pending.then(() => method.apply(this, args));
pending = result.catch(() => undefined);
return result;
};
}
+78
View File
@@ -0,0 +1,78 @@
import { ChildProcessWithoutNullStreams } from 'child_process';
import { Readable } from 'stream';
export interface ProcessResult {
code: number | null;
signal: NodeJS.Signals | null;
error?: Error;
}
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
/** Attach immediately after spawn, before awaiting database or user callbacks. */
export function observeChildProcess(
child: ChildProcessWithoutNullStreams,
callbacks: {
onStart?: () => Promise<void>;
onStdout?: (message: string) => Promise<void>;
onStderr?: (message: string) => Promise<void>;
} = {},
) {
let failure: Error | undefined;
const recordError = (error: unknown) => {
failure ??= asError(error);
};
const spawned = new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
// Keep the listener through close: errors can occur after a successful spawn.
child.on('error', (error) => {
recordError(error);
reject(error);
});
});
const closed = new Promise<ProcessResult>((resolve) => {
child.once('close', (code, signal) => resolve({ code, signal }));
});
const started = spawned.then(async () => {
await callbacks.onStart?.();
return child.pid;
});
// The caller can ask only for completion, without an unhandled start rejection.
const ready = started.catch(recordError);
const consume = async (
stream: Readable,
callback?: (message: string) => Promise<void>,
) => {
// StringDecoder in Readable preserves UTF-8 characters split across chunks.
stream.setEncoding('utf8');
let callbackFailed = false;
try {
for await (const chunk of stream) {
await ready;
if (!callbackFailed && callback) {
try {
await callback(String(chunk));
} catch (error) {
recordError(error);
callbackFailed = true;
}
}
// Even if a log sink fails, drain the pipe so the child can finish.
}
} catch (error) {
recordError(error);
}
};
const output = Promise.all([
consume(child.stdout, callbacks.onStdout),
consume(child.stderr, callbacks.onStderr),
]);
const completed = Promise.all([closed, ready, output]).then(([result]) => ({
...result,
error: failure,
}));
return { started, completed };
}
+29
View File
@@ -0,0 +1,29 @@
import { Request } from 'express';
const IPV4_MAPPED_PREFIX = '::ffff:';
export function normalizeClientIp(value?: string): string {
let ip = (value || '').trim().toLowerCase();
if (!ip) {
return '';
}
if (ip.startsWith('[') && ip.endsWith(']')) {
ip = ip.slice(1, -1);
}
const zoneIndex = ip.indexOf('%');
if (zoneIndex !== -1) {
ip = ip.slice(0, zoneIndex);
}
if (ip.startsWith(IPV4_MAPPED_PREFIX)) {
return ip.slice(IPV4_MAPPED_PREFIX.length);
}
return ip;
}
export function getClientIp(req: Request): string {
return normalizeClientIp(req.ip || req.socket.remoteAddress);
}
+11
View File
@@ -0,0 +1,11 @@
// Match command text literally: looking up a task must never execute its input.
export function findCronId(crontab: string, command: string): string {
if (!command) return '';
for (const line of crontab.split('\n')) {
const id = /\bID=(\d+)\s/.exec(line);
if (id && line.includes(command, id.index + id[0].length)) {
return id[1];
}
}
return '';
}
+110
View File
@@ -0,0 +1,110 @@
import CronExpressionParser from 'cron-parser';
import { validate } from 'node-cron';
import { ScheduleType } from '../interface/schedule';
const aliases: Record<string, string> = {
'@yearly': '0 0 0 1 1 *',
'@annually': '0 0 0 1 1 *',
'@monthly': '0 0 0 1 * *',
'@weekly': '0 0 0 * * 0',
'@daily': '0 0 0 * * *',
'@midnight': '0 0 0 * * *',
'@hourly': '0 0 * * * *',
'@minutely': '0 * * * * *',
};
export interface CronSchedule {
patterns: string[];
dayCount: number;
nthDay: number;
}
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
export function parseCronSchedule(schedule: unknown): CronSchedule {
if (typeof schedule !== 'string' || !schedule.trim()) {
throw new Error('Invalid cron schedule');
}
let source = schedule.trim();
const cacheKey = source;
const cached = scheduleCache.get(cacheKey);
if (cached) return cached;
if (source.startsWith('@')) {
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
source = aliases[source];
}
let parts = source.split(/\s+/);
if (
parts.length > 6 ||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
) {
throw new Error('Unsupported cron syntax');
}
parts = [
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
...parts,
];
if (
parts.some(
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
)
) {
throw new Error('Question mark is only valid in day fields');
}
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.length;
const weekCount = expression.fields.dayOfWeek.values.length;
const patterns =
dayCount < 31 && weekCount < 8
? [
[...normalized.slice(0, 5), '*'].join(' '),
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
]
: [normalized.join(' ')];
if (!patterns.every((pattern) => validate(pattern))) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
patterns,
dayCount,
nthDay: expression.fields.dayOfWeek.nthDay,
};
Object.freeze(patterns);
Object.freeze(parsed);
if (scheduleCache.size >= 512)
scheduleCache.delete(scheduleCache.keys().next().value);
scheduleCache.set(cacheKey, parsed);
return parsed;
}
export function isValidCronSchedule(schedule: unknown): schedule is string {
try {
parseCronSchedule(schedule);
return true;
} catch {
return false;
}
}
export function getInvalidCronSchedules(cron: {
schedule?: string;
extra_schedules?: Array<{ schedule: string }>;
}): unknown[] {
if (
cron.schedule?.startsWith(ScheduleType.ONCE) ||
cron.schedule?.startsWith(ScheduleType.BOOT)
) {
return [];
}
return [
cron.schedule,
...(cron.extra_schedules || []).map((x) => x.schedule),
].filter((schedule) => !isValidCronSchedule(schedule));
}
+89
View File
@@ -0,0 +1,89 @@
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
interface SchedulerLogger {
warn(message: string, ...args: unknown[]): unknown;
error(message: string, ...args: unknown[]): unknown;
}
export interface CronJob {
start(): void;
cancel(): void;
}
// node-cron owns timers and calendar calculation; Qinglong owns execution
// concurrency and process lifetime. Missed in-process slots join that same queue.
export function createCronJob(
schedule: string,
callback: (date: Date) => unknown | Promise<unknown>,
options: { name: string; logger: SchedulerLogger; start?: boolean },
): CronJob {
const parsed = parseCronSchedule(schedule);
const tasks: ScheduledTask[] = [];
let cancelled = false;
let started = false;
const accepts = (index: number, date: Date) => {
if (tasks.length === 1) return true;
// Preserve cron-parser 4's day/weekday OR semantics, including its
// month-dependent full-day range and the global nth-week constraint.
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
return false;
if (index === 1) return !tasks[0].match(date);
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
};
const dispatch = async (index: number, date: Date, missed: boolean) => {
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
options.name,
date.toISOString(),
);
}
try {
await callback(date);
} catch (error) {
options.logger.error(
'[schedule][定时回调失败] 任务: %s, 错误: %s',
options.name,
error instanceof Error ? error.message : String(error),
);
}
};
try {
for (const [index, pattern] of parsed.patterns.entries()) {
const task = createTask(
pattern,
(context) => dispatch(index, context.date, false),
{
noOverlap: false,
missedExecutionTolerance: 5000,
},
);
tasks.push(task);
task.on('execution:missed', (context) =>
dispatch(index, context.date, true),
);
// Fail before an existing schedule is cancelled, including impossible dates.
task.getNextRuns(1);
}
} catch (error) {
tasks.forEach((task) => task.destroy());
throw error;
}
const job: CronJob = {
start() {
if (started || cancelled) return;
started = true;
tasks.forEach((task) => task.start());
},
cancel() {
if (cancelled) return;
cancelled = true;
tasks.forEach((task) => task.destroy());
},
};
if (options.start !== false) job.start();
return job;
}
+4
View File
@@ -0,0 +1,4 @@
// Keep error formatting independent of database, HTTP and gRPC dependencies.
export function errStack(error: unknown): string {
return error instanceof Error && error.stack ? error.stack : String(error);
}
+57
View File
@@ -0,0 +1,57 @@
import fs from 'fs';
import path from 'path';
function isWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === '' ||
(!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative))
);
}
/** Resolve existing files and not-yet-created children without following an
* existing symlink outside the root. Blacklisted directories cover descendants. */
export function resolveFileAccess(
root: string,
parts: string[],
blacklist: string[] = [],
): string {
if (parts.some((part) => typeof part !== 'string' || part.includes('\0'))) {
return '';
}
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
const isBlocked = (relative: string) =>
relative.split(path.sep).some((part) => blacklist.includes(part));
if (isBlocked(path.relative(resolvedRoot, target))) return '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
let existing = target;
const missing: string[] = [];
while (!fs.existsSync(existing)) {
// existsSync is false for a dangling symlink; never treat one as absent.
try {
fs.lstatSync(existing);
return '';
} catch (error: any) {
if (error.code !== 'ENOENT') return '';
}
if (existing === resolvedRoot) return '';
missing.unshift(path.basename(existing));
existing = path.dirname(existing);
}
const realTarget = path.resolve(fs.realpathSync(existing), ...missing);
if (
!isWithin(realRoot, realTarget) ||
isBlocked(path.relative(realRoot, realTarget))
) {
return '';
}
return target;
} catch {
return '';
}
}
+10 -6
View File
@@ -3,6 +3,7 @@ import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
@@ -94,10 +95,13 @@ const messages: Record<string, Record<string, string>> = {
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
'日志名称只能包含字母、数字、下划线和连字符':
'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked',
'已加入 IP 黑名单': 'IP address added to the blacklist',
'已移出 IP 黑名单': 'IP address removed from the blacklist',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
@@ -139,10 +143,10 @@ const messages: Record<string, Record<string, string>> = {
'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back',
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s: 无效的 cron 表达式 "%s"':
'Task ID %s: invalid cron expression "%s"',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"':
'Task ID %s (extra_schedule): invalid cron expression "%s"',
},
};
+1
View File
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
export interface ICronFn<T> {
(): Promise<T>;
cron?: TCron;
isCurrent?: () => boolean;
}
export interface ISchedule {
+121
View File
@@ -0,0 +1,121 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
+11
View File
@@ -0,0 +1,11 @@
import { resolveFileAccess } from './fileAccess';
/** Check stored paths too: old database rows are not trusted input. */
export function resolveLogPath(root: string, value: string): string {
const target = resolveFileAccess(root, [value]);
if (!target)
throw Object.assign(new Error('Log path is outside the log directory'), {
status: 400,
});
return target;
}
+109
View File
@@ -0,0 +1,109 @@
import fs from 'fs/promises';
export const DEFAULT_LOG_CHUNK_BYTES = 256 * 1024;
export const MAX_LOG_CHUNK_BYTES = 1024 * 1024;
export interface LogReadOptions {
offset?: number;
limit?: number;
tail?: boolean;
}
export interface LogChunk {
content: string;
offset: number;
nextOffset: number;
total: number;
truncated: boolean;
}
function normalizeLimit(limit?: number) {
if (!Number.isFinite(limit)) return DEFAULT_LOG_CHUNK_BYTES;
return Math.min(Math.max(Math.trunc(limit!), 4), MAX_LOG_CHUNK_BYTES);
}
function isUtf8ContinuationByte(byte: number) {
return (byte & 0xc0) === 0x80;
}
function completeUtf8End(buffer: Buffer, start: number, end: number) {
if (end <= start) return start;
let sequenceStart = end - 1;
while (
sequenceStart > start &&
isUtf8ContinuationByte(buffer[sequenceStart])
) {
sequenceStart--;
}
const firstByte = buffer[sequenceStart];
const expectedLength =
firstByte < 0x80
? 1
: firstByte < 0xe0
? 2
: firstByte < 0xf0
? 3
: 4;
return end - sequenceStart < expectedLength ? sequenceStart : end;
}
export async function readLogChunk(
filePath: string,
options: LogReadOptions = {},
): Promise<LogChunk> {
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(filePath, 'r');
const { size: total } = await handle.stat();
const limit = normalizeLimit(options.limit);
const requestedOffset = Number.isFinite(options.offset)
? Math.trunc(options.offset!)
: undefined;
const requestedStart =
options.tail || requestedOffset === undefined
? Math.max(total - limit, 0)
: Math.min(Math.max(requestedOffset, 0), total);
const length = Math.min(limit + 3, total - requestedStart);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(
buffer,
0,
length,
requestedStart,
);
let leadingBytes = 0;
while (
leadingBytes < bytesRead &&
isUtf8ContinuationByte(buffer[leadingBytes])
) {
leadingBytes++;
}
const offset = requestedStart + leadingBytes;
const candidateEnd = Math.min(leadingBytes + limit, bytesRead);
const contentEnd = completeUtf8End(buffer, leadingBytes, candidateEnd);
const nextOffset = requestedStart + contentEnd;
return {
content: buffer.subarray(leadingBytes, contentEnd).toString('utf8'),
offset,
nextOffset,
total,
truncated: offset > 0 || nextOffset < total,
};
} catch (error: any) {
if (error?.code === 'ENOENT') {
return {
content: '',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
throw error;
} finally {
await handle?.close();
}
}
+87 -73
View File
@@ -1,5 +1,8 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
import path from 'path';
import config from '../config';
import { resolveFileAccess } from './fileAccess';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
@@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
private closingStreams = new Map<string, Promise<void>>();
private closedStreams = new WeakSet<WriteStream>();
private streamErrors = new Map<string, Error>();
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
constructor(private readonly logRoot = config.logPath) {
super();
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
/** Register each write synchronously, so concurrent callers cannot lose the tail. */
async write(filePath: string, data: string): Promise<void> {
if (this.closingStreams.has(filePath)) {
throw new Error(`Log stream is closing: ${filePath}`);
}
const previous = this.pendingWrites.get(filePath) || Promise.resolve();
const pending = previous.then(
() =>
new Promise<void>((resolve, reject) => {
const failure = this.streamErrors.get(filePath);
if (failure) return reject(failure);
let stream = this.streams.get(filePath);
if (!stream) {
// Validate only when opening: subsequent chunks reuse the same descriptor.
const root = path.resolve(this.logRoot);
const target = path.resolve(filePath);
if (
!target.startsWith(root + path.sep) ||
!resolveFileAccess(root, [target])
) {
return reject(new Error('Log path is outside the log directory'));
}
stream = createWriteStream(target, { flags: 'a' });
this.streams.set(filePath, stream);
const current = stream;
stream.once('close', () => this.closedStreams.add(current));
stream.on('error', (error) => {
this.streamErrors.set(filePath, error);
// EventEmitter's unobserved "error" event would crash the caller.
if (this.listenerCount('error') > 0)
this.emit('error', { filePath, error });
});
}
stream.write(data, 'utf8', (error) =>
error ? reject(error) : resolve(),
);
}),
);
this.pendingWrites.set(filePath, pending);
// Keep the tail until close, including failures; never reopen a failed log mid-run.
return pending;
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
async closeStream(filePath: string): Promise<void> {
const closing = this.closingStreams.get(filePath);
if (closing) return closing;
const pending = this.pendingWrites.get(filePath);
const result = (async () => {
let failure: unknown;
try {
await pending;
} catch (error) {
failure = error;
}
const stream = this.streams.get(filePath);
try {
if (stream && !this.closedStreams.has(stream)) {
await new Promise<void>((resolve) => {
stream.once('close', resolve);
if (failure || stream.destroyed) stream.destroy();
else stream.end();
});
}
failure ||= this.streamErrors.get(filePath);
if (failure) throw failure;
} finally {
this.streams.delete(filePath);
this.pendingWrites.delete(filePath);
this.streamErrors.delete(filePath);
}
})();
this.closingStreams.set(filePath, result);
try {
await result;
} finally {
this.closingStreams.delete(filePath);
}
}
@@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter {
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
const paths = new Set([
...this.streams.keys(),
...this.pendingWrites.keys(),
]);
const closePromises = Array.from(paths).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
+47
View File
@@ -0,0 +1,47 @@
const RETRY_TTL = 15 * 60 * 1000;
const MAX_SOURCES = 10000;
const MAX_RETRY_SECONDS = 5 * 60;
/** Source-specific, bounded backoff; persisted account history is not a lock. */
export class LoginThrottle {
private sources = new Map<
string,
{ failures: number; lastFailure: number }
>();
private get(source: string, now: number) {
const state = this.sources.get(source);
if (state && now - state.lastFailure >= RETRY_TTL) {
this.sources.delete(source);
return undefined;
}
return state;
}
retryAfter(source: string, now: number): number {
const state = this.get(source, now);
if (!state || state.failures < 3) return 0;
const seconds = Math.min(3 ** state.failures, MAX_RETRY_SECONDS);
return Math.max(
0,
Math.ceil((seconds * 1000 - (now - state.lastFailure)) / 1000),
);
}
fail(source: string, now: number): number {
const failures = Math.min((this.get(source, now)?.failures || 0) + 1, 6);
this.sources.delete(source);
if (this.sources.size >= MAX_SOURCES) {
this.sources.delete(this.sources.keys().next().value!);
}
this.sources.set(source, { failures, lastFailure: now });
return failures;
}
reset(source: string) {
this.sources.delete(source);
}
clear() {
this.sources.clear();
}
}
+16 -9
View File
@@ -50,11 +50,9 @@ class TaskLimit {
Buffer.from(tlsConfig.clientCert),
)
: credentials.createInsecure();
this._client = new ApiClient(
`localhost:${config.grpcPort}`,
creds,
{ 'grpc.enable_http_proxy': 0 },
);
this._client = new ApiClient(`localhost:${config.grpcPort}`, creds, {
'grpc.enable_http_proxy': 0,
});
}
return this._client;
}
@@ -113,12 +111,15 @@ class TaskLimit {
}
}
public removeQueuedCron(id: string) {
public removeQueuedCron(id: string, completed?: ICronFn<any>) {
if (this.queuedCrons.has(id)) {
const runs = this.queuedCrons.get(id);
if (runs && runs.length > 0) {
runs.pop();
this.queuedCrons.set(id, runs);
const remaining = completed
? runs.filter((run) => run !== completed)
: runs.slice(0, -1);
if (remaining.length) this.queuedCrons.set(id, remaining);
else this.queuedCrons.delete(id);
}
}
}
@@ -143,9 +144,15 @@ class TaskLimit {
cron: TCron,
fn: ICronFn<T>,
options?: Partial<QueueAddOptions>,
isCurrent: () => boolean = () => true,
): Promise<T | void> {
if (!isCurrent()) return;
fn.cron = cron;
let runs = this.queuedCrons.get(cron.id);
fn.isCurrent = isCurrent;
// Invalidated snapshots must not consume the new revision's repeat budget.
const runs = this.queuedCrons
.get(cron.id)
?.filter((run) => run.isCurrent?.() !== false);
const result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) {
+30
View File
@@ -0,0 +1,30 @@
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
import { promisify } from 'util';
const deriveKey = promisify(scrypt);
const HASH_PREFIX = 'scrypt$';
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const key = (await deriveKey(password, salt, 64)) as Buffer;
return `${HASH_PREFIX}${salt}$${key.toString('hex')}`;
}
export function isPasswordHash(password: string): boolean {
return /^scrypt\$[a-f0-9]{32}\$[a-f0-9]{128}$/.test(password);
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
if (!isPasswordHash(stored)) {
// Existing installations migrate after a successful password check.
const input = Buffer.from(password);
const expected = Buffer.from(stored);
return input.length === expected.length && timingSafeEqual(input, expected);
}
const [, salt, hash] = stored.split('$');
const key = (await deriveKey(password, salt, 64)) as Buffer;
return timingSafeEqual(key, Buffer.from(hash, 'hex'));
}
+64
View File
@@ -0,0 +1,64 @@
import fs from 'fs/promises';
import { Dirent } from 'fs';
import path from 'path';
export const MAX_RETENTION_DAYS = 3650;
export const DEPENDENCE_CACHE_TYPES = ['node', 'python3'] as const;
export type DependenceCacheType = (typeof DEPENDENCE_CACHE_TYPES)[number];
export interface RetentionPolicy {
runningInstanceRetentionDays: number;
cronStatRetentionDays: number;
}
export function normalizeRetentionDays(value: unknown) {
const days = Number(value);
if (!Number.isFinite(days)) return 0;
return Math.min(Math.max(Math.trunc(days), 0), MAX_RETENTION_DAYS);
}
export function normalizeRetentionPolicy(
policy: Partial<RetentionPolicy>,
): RetentionPolicy {
return {
runningInstanceRetentionDays: normalizeRetentionDays(
policy.runningInstanceRetentionDays,
),
cronStatRetentionDays: normalizeRetentionDays(policy.cronStatRetentionDays),
};
}
export function isDependenceCacheType(
value: string,
): value is DependenceCacheType {
return DEPENDENCE_CACHE_TYPES.includes(value as DependenceCacheType);
}
export async function getDirectorySize(rootPath: string): Promise<number> {
const pending = [rootPath];
let total = 0;
while (pending.length > 0) {
const currentPath = pending.pop()!;
let entries: Dirent[];
try {
entries = await fs.readdir(currentPath, { withFileTypes: true });
} catch (error: any) {
if (error?.code === 'ENOENT') continue;
throw error;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const entryPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
pending.push(entryPath);
} else if (entry.isFile()) {
total += (await fs.stat(entryPath)).size;
}
}
}
return total;
}
+53 -34
View File
@@ -4,21 +4,27 @@ import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
export function runCron(
cmd: string,
cron: ICron,
isCurrent: () => boolean = () => true,
): Promise<number | void> {
const execute = async () => {
try {
if (!isCurrent()) return;
// 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) },
});
// A mutation may commit while this queued run is reading the database.
if (!isCurrent() || !existingCron || existingCron.isDisabled) return;
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
@@ -33,12 +39,17 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
{
where: {
cron_id: Number(cron.id),
status: InstanceStatus.running,
},
},
);
// Update the status to idle after killing
await CrontabModel.update(
@@ -50,8 +61,11 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
throw error;
}
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
@@ -60,33 +74,38 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
);
const cp = spawn(cmd, { shell: '/bin/bash' });
cp.stderr.on('data', (data) => {
Logger.info(
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
cmd,
data.toString(),
);
const { completed } = observeChildProcess(cp, {
onStderr: async (message) => {
Logger.info(
'[schedule][任务标准错误] 命令: %s, 信息: %s',
cmd,
message,
);
},
});
cp.on('error', (err) => {
const result = await completed;
if (result.error) {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误信息: %j',
'[schedule][执行任务失败] 命令: %s, 错误: %s',
cmd,
err,
result.error.message,
);
});
cp.on('exit', async (code) => {
taskLimit.removeQueuedCron(cron.id);
Logger.info(
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...cron,
command: cmd,
}),
code,
);
resolve({ ...cron, command: cmd, pid: cp.pid, code });
});
});
});
}
Logger.info(
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
cron.id,
result.code,
);
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误: %s',
cmd,
asError(error).message,
);
} finally {
taskLimit.removeQueuedCron(cron.id, execute);
}
};
return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent);
}
+40
View File
@@ -0,0 +1,40 @@
import lockfile from 'proper-lockfile';
import config from '../config';
// HTTP and gRPC both mutate cron definitions. Hold one shared lock from the
// initial DB read/write through scheduler registration (including rollback).
// Recovery takes the same lock before reading its replacement snapshot.
export async function withSchedulerMutation<T>(
operation: () => Promise<T>,
): Promise<T> {
let release: () => Promise<void>;
try {
// proper-lockfile indexes held locks by target, not lockfilePath. Keep
// this identity separate from nested writeFileWithLock(crontabFile).
release = await lockfile.lock(`${config.crontabFile}.scheduler`, {
realpath: false,
lockfilePath: `${config.crontabFile}.scheduler.lock`,
stale: 30000,
update: 10000,
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
});
} catch (cause) {
throw Object.assign(
new Error('Scheduler configuration is busy', { cause }),
{
status: 503,
},
);
}
try {
return await operation();
} finally {
await release();
}
}
export function schedulerRegistrationError(message: string, cause: any): Error {
return Object.assign(new Error(message, { cause }), {
status: cause?.status === 503 ? 503 : 500,
});
}
+77
View File
@@ -0,0 +1,77 @@
// Recovery is single-flight and retried only while unavailable, never at idle.
export class SchedulerReadiness {
private ready = false;
private generation = 0;
private restore?: () => Promise<void>;
private pending?: Promise<boolean>;
private retry?: NodeJS.Timeout;
constructor(private probe: () => Promise<void>, private retryMs = 1000) {}
configure(restore: () => Promise<void>) {
this.restore = restore;
}
invalidate() {
this.ready = false;
this.generation++;
void this.recover();
}
recover(): Promise<boolean> {
if (this.pending) return this.pending;
if (!this.restore) return Promise.resolve(false);
clearTimeout(this.retry);
const generation = this.generation;
this.ready = false;
this.pending = (async () => {
try {
await this.probe();
await this.restore!();
await this.probe();
this.ready = generation === this.generation;
} catch {
this.ready = false;
}
return this.ready;
})().finally(() => {
this.pending = undefined;
if (!this.ready) {
this.retry = setTimeout(() => void this.recover(), this.retryMs);
this.retry.unref();
}
});
return this.pending;
}
async check(): Promise<boolean> {
if (!this.ready) return false;
const generation = this.generation;
try {
await this.probe();
return this.ready && generation === this.generation;
} catch {
this.invalidate();
return false;
}
}
async ensureReady(timeoutMs = 2000): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
const available = await Promise.race([
(async () => (await this.check()) || (await this.recover()))(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
if (!available) {
throw Object.assign(new Error('Scheduler is recovering; try again later'), {
status: 503,
});
}
} finally {
clearTimeout(timer);
}
}
}

Some files were not shown because too many files have changed in this diff Show More