Compare commits

..
Author SHA1 Message Date
whyour 2207098690 build: install Debian Node archives on build platform 2026-10-25 13:12:57 +08:00
whyour bd4775857a fix: use s390x-capable Python bases for Debian Node stages 2026-10-25 12:56:57 +08:00
whyour 997bd35fc6 build: unify container Node 24 and drop Debian armv7 2026-10-25 12:46:33 +08:00
whyour b92621bdb8 build: upgrade sqlite3 to 1.1.4 (#3108) 2026-10-25 01:52:58 +08:00
da9c6a6bbb fix: skip new scheduled triggers while a single-instance task is active (#3101)
* fix: skip active single-instance scheduled executions

* fix: recover stale single-instance scheduled tasks

* test: remove obsolete single-instance replacement assertions

---------

Co-authored-by: Patrick Gu <username@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-24 23:11:36 +08:00
whyour d895fbba36 build: use one bundle validation path for all architectures (#3106) 2026-10-24 19:59:41 +08:00
whyour 6862ad4f6c build: use sqlite3 1.1.3 ARM musl prebuilds (#3105) 2026-10-24 17:40:47 +08:00
whyour b8110c52eb fix: isolate Alpine Node runtime and bound URL probes (#3104)
Pin Alpine amd64 to the verified upstream Node runtime without retaining temporary binaries in image layers. Bound fresh-process URL probes and verify serialized amd64/ARMv6 layers in CI. Keep existing Sequelize, SQLite and Debian runtime behavior unchanged.
2026-10-23 23:34:24 +08:00
whyour 99a4d0eff8 fix: batch notification audits and bump CLI to 0.1.3 2026-10-22 01:25:29 +08:00
whyour b486989399 fix: validate ARMv6 upgrades and publish rolling development bundles 2026-10-21 01:43:44 +08:00
whyour 77fe551425 fix: simplify npm packaging and align frontend Node builds 2026-10-19 18:06:06 +08:00
whyour 2669f251ac fix: preserve npm environment during package replacement 2026-10-19 15:41:42 +08:00
whyour 3f02b04dff fix: ship verified npm panel packages and test production migrations 2026-10-19 14:17:53 +08:00
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
2026-10-18 22:19:49 +08:00
whyour a36a77086a fix: validate extra schedules and localize validation errors
Reject invalid extra_schedules with HTTP 400 before create or update reaches the task service. Preserve omitted, null and empty extras, supported cron syntax, and additional metadata.

Localize missing rules, invalid string and array types, and non-object extra entries through the existing per-request translation middleware. Cover POST/PUT rejection without mutations, dynamic English/Chinese switching, and stable error paths.

Validation: 9 related regression tests passed on current develop; backend build passed. The API validation fix also passed full regression and isolated develop/debian-dev acceptance.

Refs #3099
2026-10-18 01:08:36 +08:00
whyour cb5dfec2b5 fix: preserve cron semantics during node-cron adaptation
Preserve supported expression fields instead of stringifying them, and probe individual fields only when whole-expression validation fails. Convert unsupported syntax and weekday ranges ending in 7 while preserving legacy date/weekday rules and valid OR branches.

Add execution regressions for issue #3099 and 64 calendar boundary cases in UTC and Asia/Shanghai. Ensure timezone child tests actually run under the Node test runner.

Validation: 745 tests passed, 8 skipped; final targeted regressions passed; backend compilation passed. Differential calendar checks matched 142,360 execution timestamps.

Fixes #3099
2026-10-17 20:00:33 +08:00
whyour 286088a32c feat: stage online upgrades before verified restarts
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice.

Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
2026-10-17 17:14:15 +08:00
whyour a1104ef47c fix: report page load timing after load handlers complete 2026-10-17 09:03:50 +08:00
whyour 1a152575cc fix: preserve active system log files when clearing logs 2026-10-17 07:42:10 +08:00
whyour ef348b445b fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
2026-10-17 07:07:09 +08:00
whyour 8305825ffd Merge Python dependency and Shell regression fixes
fix: Python dependency status, GNU awk compatibility and dev regressions
2026-10-15 23:41:16 +08:00
whyour e555fcbb0b fix: honor Python requirement specifiers and portable script discovery 2026-10-15 23:36:21 +08:00
whyour 83e8f6bccd Merge tested dev regression fixes for integration review 2026-10-15 23:28:56 +08:00
whyour 5801f735bc fix: improve shell compatibility and process cleanup
Preserve literal paths and arguments, use portable command options, validate startup configuration, and retain scripts when downloads fail.

Ensure task watchdogs and process groups are cleaned up across Bash versions, with compatibility and process lifecycle regression coverage.
2026-10-15 23:20:15 +08:00
whyour 53e1129a12 fix: preserve Python source state and repair dev regressions 2026-10-15 09:53:22 +08:00
whyour 13c0a4d21b fix: handle mobile INI editing and silence timeout cleanup
Map INI to the CodeMirror properties language and fall back to plain text for unsupported modes. Reap the timeout watchdog immediately under stderr redirection to prevent Bash job notifications during cleanup.

Refs #3095, #3096
2026-10-15 01:17:43 +08:00
whyour 126fe8eb67 feat(notify): audit system and JavaScript/Python script notifications 2026-10-14 10:28:43 +08:00
whyour 73c41eed22 fix(i18n): retain Python dependency and notification audit translations 2026-10-14 10:25:13 +08:00
whyour 40149b6f17 feat(deps): support Python Git URLs and requirements files 2026-10-14 10:21:19 +08:00
whyour 6532775a25 feat(python): support isolated venv environments for native deployment 2026-10-14 10:19:50 +08:00
whyour 6469792546 chore(deps): update moment and brace-expansion security patches 2026-10-14 10:18:16 +08:00
whyour 0aa521a288 fix(python): integrate venv startup with source dependency support 2026-10-13 02:24:12 +08:00
whyour 1ac04a8062 fix(python): keep source dependency installs and removals in selected venv 2026-10-13 02:21:10 +08:00
whyour e435f6e886 fix(notify): audit system and JS/Python script notification results 2026-10-13 02:20:59 +08:00
whyour db88d037db fix(python): integrate latest develop and support older venv pip 2026-10-13 02:20:59 +08:00
whyour a0c09046c6 fix(deps): keep security updates within compatible dependency lines 2026-10-13 02:17:55 +08:00
dependabot[bot] 5a9e53c771 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [moment](https://github.com/moment/moment) and [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `moment` from 2.30.1 to 2.31.0
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.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: moment
  dependency-version: 2.31.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 17:28:13 +00: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 cf236f8324 fix(dependencies): require existing sources and translate install hints 2026-10-12 01:13:53 +08:00
whyour 7334566d3c fix(notification): align audit route and WPUSH fixtures 2026-10-12 01:13:53 +08:00
whyour 4cd6c6ad2e fix(dependencies): quote and validate Python source installs 2026-10-12 00:23:48 +08:00
whyour 81a60254a2 fix(python): share safe startup and honor explicit virtual environments 2026-10-12 00:23:20 +08:00
whyour 1503f07c75 fix(notification): bound audit logs without breaking delivery 2026-10-12 00:23:20 +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 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
whyour 214e23a63d fix: 恢复 linux/s390x 镜像支持 2026-07-18 11:02:07 +08:00
whyour ddb3ab7ac3 Add configurable browser tab title for multi-instance QingLong deployments (#3041) 2026-07-10 02:07:11 +08:00
whyour da6dac8bf1 ts-node 异常,锁定 typescript 版本 2026-07-10 01:36:31 +08:00
whyour 9b17a41987 修复 dashboard 接口地址 2026-07-08 00:28:35 +08:00
whyour cdfa6c3c3c 增加启动错误日志 2026-07-04 21:36:11 +08:00
whyour 1953022b99 修复识别系统默认语言 2026-07-04 01:13:59 +08:00
whyour 38d84f9f31 修复订阅链接正则判断 2026-07-02 00:08:39 +08:00
whyour e92ddb0aed 修复添加任务有可能失败 2026-07-01 00:37:42 +08:00
Copilotandcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> a0d57c78c5 Fix "Validation failed" when editing env variables with null labels (#3026)
* Initial plan

* Fix env variable edit validation error when labels is null

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-30 23:51:21 +08:00
Martha Ramirez a942a5686f 修复删除定时任务后 crontab.list 残留导致订阅更新无法重新注册 (#3021)
* 修复删除定时任务后 crontab.list 未同步导致订阅更新无法重新注册脚本

crontab.list 作为数据库的文件镜像,其同步 setCrontab() 此前串行在尽力而为
的 gRPC addCron/delCron 调用之后:当调度 worker 重启等导致 gRPC 短暂不可用、
addCron/delCron reject 时,setCrontab() 会被跳过,crontab.list 与数据库脱节。
而 shell/update.sh 的 gen_list_repo() 将 crontab.list 作为"已有任务"真源,
于是订阅更新误判脚本已存在、跳过新增注册(前端再也看不到这些脚本)。

将 create/update/remove/disabled/enabled/autosave_crontab 中的 gRPC 调用改为
尽力而为(try/catch + 告警日志),保证 setCrontab() 总是执行;autosave_crontab
额外将 setCrontab 提前到 addCron 之前,确保启动/调度器重启时文件总是同步,
并避免 gRPC 失败导致应用启动崩溃。

调度器内存中的任务注册与 crontab.list/系统 crontab 同步是两个相互独立的关注点:
gRPC 失败时内存调度可能在调度器重启后由 autosave_crontab 重新注册,但文件同步
不应因此被阻断。修复后既有的 crontab.list 残留会在下次重启或任意增删改时自愈。

可能与 #2422(订阅更新重复添加任务)同根因。

* 过滤 delCron 中 scheduleStacks 的 null job

nodeSchedule.scheduleJob() 对无效 cron 表达式(如 "0 * /6 * * *",
空格分开了 * /6)会返回 null,原代码原样存入 scheduleStacks 数组。
delCron 取消时对 null 调 cancel() 抛出 UNKNOWN 错误,导致 gRPC
handler 中断、HTTP 端 cronService.remove() 跳过 setCrontab(),
crontab.list 残留已删任务的记录,导致订阅更新时 gen_list_repo()
误判脚本已存在、不再重新注册。

过滤 null 并对每个 cancel 调用加 try/catch 容错。
2026-06-30 23:48:51 +08:00
magicJie a1ccd9947d Add Kubernetes deployment manifests (#3025) 2026-06-30 22:24:03 +08:00
zjiecodeandzjiecode 327c3a317b Add WxPusher simple push support,Enhance user experience and reduce configuration complexity (#3023)
* 新增对WxPusher极简推送的支持,减小用户使用成本

* 新增脚本对WxPusher极简推送的支持

* 修复通知配置的枚举定义

---------

Co-authored-by: zjiecode <zjiecode@gmail.com>
2026-06-30 14:30:31 +08:00
whyour d3016431ce 定时任务显示完整运行历史 2026-06-28 15:48:58 +08:00
whyour 5fbff0e1c8 修复 openapi 鉴权提示 2026-06-26 15:31:25 +08:00
whyour 3044f63f03 修复国际化文案 2026-06-21 23:53:32 +08:00
whyour 369dd13212 修复 deps 目录依赖逻辑 2026-06-21 12:12:29 +08:00
whyour b5a5fb3be6 shell 增加国际化 2026-06-14 21:19:06 +08:00
whyour 0ee0b83207 延迟增加运行时间提示 2026-06-14 15:21:29 +08:00
whyour 5f2d5bb24a 修复 esm 依赖查询路径 2026-06-13 21:08:25 +08:00
whyour 949d956aef grpc 服务增加证书校验 2026-06-13 20:16:49 +08:00
whyour 96b4c90398 增加 sudo 命令判断 2026-06-13 00:53:41 +08:00
whyour 7d8feadc78 修复获取定时任务参数 2026-06-13 00:09:31 +08:00
whyour d1dfde3ca9 修复配置文件路径可能越权 2026-06-12 23:45:40 +08:00
whyour 6796068523 更新国际化文案 2026-06-11 23:18:45 +08:00
whyour 05f8fd3805 接口提示信息国际化 2026-06-11 02:19:04 +08:00
whyour 946731ac8d 增加运行实例 2026-06-10 01:53:10 +08:00
whyour 617cf7e5b4 更新 stop emoji 2026-06-09 00:48:03 +08:00
whyour 662adc0f9c 修复 dashboard title 2026-06-07 23:45:43 +08:00
whyour 23b41893a3 修复获取任务退出码 2026-06-07 23:28:21 +08:00
whyour 34d4526413 修复 work_dir 目录判断 2026-06-07 22:03:48 +08:00
jiandanc 0af546cd1d feat: 支持 Python venv 隔离环境,适配 Node.js 原生部署
## 背景
原方案使用系统 Python + pip --prefix 安装依赖到 data/dep_cache/python3,
系统 Python 环境被污染,且无法隔离不同项目的依赖。

## 改动内容

### 核心:Python venv 支持
- shell/start.sh: 使用 python3 -m venv 创建虚拟环境,替代 --prefix 模式
  - 从 .env 文件读取 PYTHON_VENV_DIR 配置
  - 首次启动自动创建 .venv,已存在则跳过
  - 仅将 .venv/bin 加入 PATH 优先级,不设置 PYTHONHOME/PYTHONPATH(避免破坏 venv 机制)
  - .env 不再强制覆盖,仅首次从 .env.example 复制
  - 启动时自动修复 task/ql 软链接指向当前部署目录

### 后端适配
- back/config/const.ts: 新增 PYTHON_VENV_DIR 常量
- back/config/util.ts: venv 模式下跳过 pip --prefix,直接使用 venv 的 pip3

### 开发模式支持
- shell/dev-env.sh: pnpm start 时自动 source,将 .venv/bin 加入 PATH
- package.json: start:back 加入 source dev-env.sh

### 新增文件
- shell/start-simplify.sh: 精简版启动脚本(跳过系统依赖安装,适用于已预装环境的服务器)
- README-NODE.md: Node.js 原生部署完整文档

### 配置
- .env.example: 新增 PYTHON_VENV_DIR=./.venv 配置项(默认注释状态)

## 兼容性
- Docker 模式不受影响(使用独立的 docker-entrypoint.sh,走原有 --prefix 逻辑)
- 未配置 PYTHON_VENV_DIR 时默认使用系统 Python(向后兼容)
2026-06-07 21:03:51 +08:00
whyour ad92e0c2c0 修复任务统计日志 2026-06-07 15:15:12 +08:00
whyour c14390cf41 修复 work_dir 验证 2026-06-07 13:56:06 +08:00
whyour fd6b4e4cde 定时任务增加 work_dir 设置 2026-06-07 13:19:01 +08:00
whyour 865e3035b9 开机运行任务同时开始运行 2026-06-01 23:24:10 +08:00
whyour d8d30c3619 修复 initFile 2026-06-01 19:07:15 +08:00
whyour 4e803df51e 修复内部服务 ip 地址 2026-06-01 18:28:54 +08:00
whyour e8ac195c96 增加任务统计 2026-06-01 18:20:18 +08:00
whyour c0b7527148 修复路径穿越 2026-06-01 13:49:32 +08:00
whyour ca347c5854 修复非 root 用户更新时区和 linux 配置 2026-05-31 16:48:13 +08:00
whyour 4bee3fbdf4 修复 getCronById api status 2026-05-31 15:53:24 +08:00
whyour b6537e27ef update mirror action 2026-05-31 15:04:10 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
0e5b8bef4b Fix HITOKOTO parameter boolean/string type handling (#2918)
* Initial plan

* Fix HITOKOTO parameter type mismatch in notify scripts

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

* Refactor JavaScript HITOKOTO check for consistency with Python

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>
2026-05-31 14:26:57 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e06c3571a8 Fix undefined error in serverNotify when Server酱 returns error responses (#2917)
* Initial plan

* Fix: Replace data.data.errno with data.code to prevent undefined error

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>
2026-05-31 14:25:51 +08:00
whyour c2eac8b8fd 移除未使用配置 2026-05-31 00:36:54 +08:00
jmclulu 3aab1233bb fix: correct typos in source code and locales (#3003)
- Fix Countrys -> Countries in comment
- Fix Scrolldown -> ScrollDown in variable name
- Fix completeTowFactor -> completeTwoFactor (3x)
- Fix deactiveTowFactor -> deactivateTwoFactor (3x)
- Fix activeOrDeactiveTwoFactor -> activeOrDeactivateTwoFactor
- Fix API route /two-factor/deactive -> /two-factor/deactivate
- Fix elment -> element in function param
- Fix synolog -> synology in comment
- Fix Chinese comment 制定 -> 指定
- Fix swapped BARK English translations on lines 360-361
2026-05-31 00:32:04 +08:00
whyour 2fe9470ff0 fix: gRPC extra_schedules 为空时序列化报错 not iterable 2026-05-31 00:14:22 +08:00
whyour abad29cbf9 修复非 root debian dockerfile 2026-05-30 23:55:57 +08:00
whyouranddream10201 84d730d510 统一 Alpine/Debian 分支,QL_SCHEDULER 参数化调度
* 修改获取示例文件 api path

* 增加 debian-slim 基础镜像

* 修复 debian apt 命令,支持 qinglong 命令

* 更新 npm 版本 0.7.7

* 更新 npm v0.8.4

* 修复linux依赖检测 (#2082)

* 修复拉取私有仓库

* 修复 shell check_server

* 修复 qinglong 命令

* 更新 npm 版本 v0.13.2

* 增加 debian 开发版本

* 修改切换 linux 镜像源

* 修复 qinglong 命令

* 移除 qinglong 命令 npm 默认镜像源

* 修复 workflow

* 更新 npm 版本 v0.14.5

* 增加 npx 命令

* 更新 workflow action 版本

* 更新 npm 版本 v0.16.0

* 修复 linux 镜像源

* 更新 npm 版本 v0.17.0

* 更新 npm 版本 v0.18.0

* 修改 npm 安装启动命令

* 更新 npm 版本 v0.19.9

* 修复 debian netcat 包名

* 更新 npm 版本 v0.20.4

* 安装 linux 依赖自动识别 alpine 和 debian

* 修改 apt 命令

* 更新 npm 版本 v0.21.2

* 修改 ts 文件执行依赖

* npm 启动增加 reload 逻辑

* 更新 npm 版本 v2.17.8

* 修复 qinglong 命令

* 更新 npm 版本 v2.17.9

* 更新 npm 版本 v2.17.10

* 更新 npm 版本 v2.17.11

* 修改 debian 版本为 12 bookworm

* 更新 npm 版本 v2.17.12

* 修改本地服务启动提示

* 更新 npm 版本 v2.17.13

* 写入文件增加文件锁

* 修复系统安装依赖提示

* 更新 npm 版本 v2.18.2-6

* 更新 nodejs 版本

* 更新 npm 版本 v2.18.3-3

* 修复 command 变量

* 移除自动清除 deb

* 修复 npm 启动脚本

* 修复发布 npm包依赖文件

* 修改 linux 启动文件逻辑

* 更新 npm 版本 v2.19.0-10

* 修复 apt 命令

* 更新 npm 版本 v2.19.1-0

* 更新 npm 版本 v2.19.2-2

* 增加 packageManager

* 增加用户 qinglong

* 更新 pipeline

* 移除 init_nginx

* 更新 npm 版本 v2.20.0

* 更新 npm 版本 2.20.1

* 更新 npm 版本 2.20.2

* fix: 修复非 root 用户启动

* chore: 合并 debian 和 alpine 逻辑

---------

Co-authored-by: dream10201 <xiuxiu10201@gmail.com>
2026-05-30 18:03:51 +08:00
whyour 57d58c871e fix: isolate task node dependencies 2026-05-24 01:40:07 +08:00
whyour bb6d436c19 fix: run scripts from their own directory 2026-05-24 00:15:24 +08:00
whyour 8bc0906949 feat: add environment variable labels 2026-05-23 23:21:38 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
7a8917f8e4 Fix CodeMirror "multiple instances of @codemirror/state" crash on page navigation (#2969)
* Initial plan

* Fix CodeMirror multiple instances error by pinning to single versions

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>
2026-05-23 20:48:10 +08:00
whyour 66f9457be8 ci: replace hub-mirror-action with direct git push for Gitee sync
Replace Yikun/hub-mirror-action with manual git push for Gitee mirrors. The action HTTPS API call to gitee.com timed out (60s). Use set +e with explicit notice/warning status. Also add .deepseek/ to .gitignore.
2026-05-18 01:05:46 +08:00
whyour 0c5e3b5d04 ci: update workflow actions 2026-05-18 00:12:22 +08:00
whyour 400e4770de 修复环境变量 position 数据类型可能异常 2026-05-17 23:53:43 +08:00
whyour 40d4de9017 增加 localhost 检测 2026-05-17 23:43:09 +08:00
whyour 3464c4da61 fix IPv6 connectivity 2026-05-06 01:29:01 +08:00
Max 1315578878 支持自定义接收邮箱地址 (#2973)
* 支持自定义接收邮箱地址

* 新增支持多个接收邮箱,同步到node和系统内置版本
2026-05-06 00:34:26 +08:00
TengDream a1ae08da58 fix: create root .tmp directory on init for data export (#2993)
The data export feature (system backup) writes data.tgz to
`config.tmpPath` which resolves to `<rootPath>/.tmp/`. However,
`initFile.ts` only created `<dataPath>/log/.tmp/` (used for crontab
list temp files), never the root-level `.tmp/` directory.

In Docker deployments, `shell/share.sh`'s `fix_config()` creates
`$dir_root/.tmp` during shell initialization, but local/non-Docker
deployments that start the Node service directly skip the shell init,
causing a 404 ENOENT error when attempting to export/backup data.

Add `rootTmpPath` (`<rootPath>/.tmp/`) to the directories array in
`initFile.ts` so it is created during Node service startup regardless
of deployment method.
2026-05-06 00:32:29 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
66700ebe1a Add OpeniLink notification channel (#2988)
* Initial plan

* Add OpeniLink notification channel support

Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/c80b4882-1bd7-4ffe-9180-cd3220da5986

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

* Add context_token and hub_url to OpeniLink notification channel

Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/a5e66f5a-dab8-4a65-96ca-960d35fa9d50

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>
2026-04-25 16:01:36 +08:00
copilot-swe-agent[bot]andwhyour 0d33d2321a fix: address code review feedback - improve notifyType logic and typing
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:50:47 +00:00
copilot-swe-agent[bot]andwhyour 79964f149c feat: add notification audit log feature
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:49:18 +00:00
copilot-swe-agent[bot] bcb2471768 Initial plan 2026-04-25 06:44:54 +00:00
Copilotandwhyour 07bf0c705b fix: respect QlPort env var in Docker health check (#2963)
* Initial plan

* fix: use QlPort env variable in health check with fallback to default 5700

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-03-11 20:43:56 +08:00
whyour fd516977e3 chore: upgrade nodemailer 2026-03-07 22:35:18 +08:00
whyour c39f4ef846 chore: 更新 multer,解决 cve 漏洞 2026-03-07 21:31:24 +08:00
whyour 275d8af4e2 更新版本 v2.20.2 2026-03-01 20:35:25 +08:00
whyour 544c432f49 修复 PATH 环境变量 2026-03-01 20:35:19 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
6bec52dca1 Fix /open/user/init auth bypass allowing credential reset on initialized systems (#2941)
* Initial plan

* fix: add /open/user/init paths to init guard to prevent auth bypass

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-03-01 18:02:21 +08:00
rockymelody ce599d306f 青龙面板鉴权绕过漏洞已修复 (#2935)
已实施的安全加固措施
第一层防御:启用Express严格路由(第17-18行)
app.set('case sensitive routing', true);  // 路由大小写敏感
app.set('strict routing', true);           // 严格路由匹配
第二层防御:路径标准化检查中间件(第23-37行)
app.use((req, res, next) => {
  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'
    });
  }

  next();
});
作用:主动检测并拒绝含有大小写变体的恶意请求
第三层防御:JWT中间件正则表达式修复(第59行)
// 修复前:
path: [...config.apiWhiteList, /^\/(?!api\/).*/],

// 修复后:添加大小写不敏感标志 'i'
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
作用:防御正则匹配层面的绕过
第四层防御:自定义Token中间件路径标准化(第74-87行)
// 修复前:
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {

// 修复后:统一转小写比较
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
}
作用:确保Token验证逻辑对所有路径变体生效

第五层防御:初始化接口路径检查修复(第122-123行)
// 修复前:
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {

// 修复后:
const pathLower = req.path.toLowerCase();
if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) {
2026-03-01 17:44:03 +08:00
copilot-swe-agent[bot]andwhyour fcf0ee619b Improve URL pattern matching and remove redundant checks
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:51:12 +00:00
copilot-swe-agent[bot]andwhyour f3ec352066 Address code review feedback: improve pattern matching and error handling
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:49:41 +00:00
copilot-swe-agent[bot]andwhyour 023d0f1cc4 Fix pyproject.toml path handling and add error handling
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:46:12 +00:00
copilot-swe-agent[bot]andwhyour f526d3f972 Add support for GitHub URLs and requirements files in Python dependencies
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:41:43 +00:00
copilot-swe-agent[bot] e28f746294 Initial plan 2025-12-27 08:35:03 +00:00
581 changed files with 73884 additions and 6074 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitnexus
.claude
.codex
.env*
node_modules
data
audit
docs
test
src/.umi*
**/__pycache__
**/*.log
+15
View File
@@ -1,6 +1,18 @@
GRPC_PORT=5500 GRPC_PORT=5500
BACK_PORT=5700 BACK_PORT=5700
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
# 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' LOG_LEVEL='info'
JWT_SECRET= JWT_SECRET=
@@ -9,3 +21,6 @@ JWT_EXPIRES_IN=
QINIU_AK= QINIU_AK=
QINIU_SK= QINIU_SK=
QINIU_SCOPE= QINIU_SCOPE=
# Python 虚拟环境路径,留空则使用系统 Python
# PYTHON_VENV_DIR=./.venv
+575 -109
View File
@@ -2,152 +2,272 @@ name: Build And Push Docker Image
on: on:
push: push:
paths-ignore: paths:
- "*.md" - "**"
- "!*.md"
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "cli/src/internal/maintenance/platformBundle.cjs"
- "cli/src/internal/maintenance/upgradePrestart.cjs"
- "cli/src/internal/maintenance/panelUpgrade.cjs"
- "cli/src/internal/maintenance/nodeRuntime.cjs"
- "!.github/workflows/cli-package.yml"
branches: branches:
- "master" - "master"
- "develop" - "develop"
tags: tags:
- "v*" - "v*"
workflow_dispatch: 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
# Keep master image tags and their bundle indexes in one serialized release.
# A rerun may intentionally replace a version, including from the same commit.
# Develop image builds and their shared bundle index serialize separately.
# Git-tag builds and static synchronization remain independent.
concurrency:
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.ref == 'refs/heads/develop' && !inputs.static_sync_only && 'develop') || format('{0}-{1}', github.run_id, github.run_attempt) }}
cancel-in-progress: false
jobs: jobs:
code_gitlab: validate:
runs-on: ubuntu-latest if: ${{ !inputs.static_sync_only }}
steps: uses: ./.github/workflows/validate.yml
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
with:
src: github/whyour
dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong"
force_update: true
code_gitee: code_gitlab:
if: ${{ !inputs.static_sync_only }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - name: Push to GitLab
with: run: |
src: github/whyour set +e
dst: gitee/whyour mkdir -p ~/.ssh
dst_key: ${{ secrets.GITLAB_SSH_PK }} printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
dst_token: ${{ secrets.GITEE_TOKEN }} chmod 600 ~/.ssh/id_rsa
static_list: "qinglong" ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
force_update: true git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
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"
build-static: build-static:
if: ${{ !inputs.static_sync_only }}
needs: validate
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v6
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version-file: ".nvmrc"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: build front and back - name: build front and back
run: | run: |
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm build:front pnpm build:front
pnpm build:back pnpm build:back
pnpm build:info
- name: Pack the npm panel archive from the verified build
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
path: static/
if-no-files-found: error
include-hidden-files: true
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
retention-days: 14
- name: copy to static repo - name: copy to static repo
env: env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }} GITHUB_BRANCH: ${{ github.ref_name }}
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git GITHUB_REF_TYPE: ${{ github.ref_type }}
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git STATIC_TOKEN: ${{ secrets.API_TOKEN }}
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: | run: |
mkdir -p tmp : "${STATIC_TOKEN:?Missing static repository token}"
cd ./tmp bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add . - name: publish verified upgrade download sizes
git config --local user.name 'github-actions[bot]' env:
git config --local user.email 'github-actions[bot]@users.noreply.github.com' GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" GITHUB_BRANCH: ${{ github.ref_name }}
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: |
: "${STATIC_TOKEN:?Missing static repository token}"
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
static_gitlab: static_gitlab:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: static-gitlab-${{ github.ref_name }}
cancel-in-progress: false
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: - name: Push qinglong-static to GitLab
fetch-depth: 0 env:
- uses: Yikun/hub-mirror-action@master STATIC_BRANCH: ${{ github.ref_name }}
with: STATIC_REF_TYPE: ${{ github.ref_type }}
src: github/whyour MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
dst: gitlab/whyour run: |
dst_key: ${{ secrets.GITLAB_SSH_PK }} set -euo pipefail
dst_token: ${{ secrets.GITLAB_TOKEN }} mkdir -p ~/.ssh
static_list: "qinglong-static" printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
force_update: true chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
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 needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 14
permissions:
contents: write
strategy:
fail-fast: false
matrix:
repository: [qinglong, qinglong-static]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: # Gitee Pull mirrors and GitHub push webhooks own synchronization.
fetch-depth: 0 # Verification runs alongside Docker builds and needs no Gitee credentials.
- uses: Yikun/hub-mirror-action@master - name: Wait for Gitee Pull mirror
with: if: ${{ matrix.repository == 'qinglong' }}
src: github/whyour env:
dst: gitee/whyour GH_TOKEN: ${{ github.token }}
dst_key: ${{ secrets.GITLAB_SSH_PK }} QL_MIRROR_NOTIFY_REPO: whyour/qinglong
dst_token: ${{ secrets.GITEE_TOKEN }} MIRROR_REPOSITORY: ${{ matrix.repository }}
static_list: "qinglong-static" MIRROR_REF: ${{ github.ref_name }}
force_update: true MIRROR_REF_TYPE: ${{ github.ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
QL_MIRROR_NOTIFY_ON_START: 'false'
run: |
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"
build: 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/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v4 - uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "24"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml - name: Read version from version.yaml
id: version id: version
run: | run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}') VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 run: sudo timedatectl set-timezone Asia/Shanghai
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -155,7 +275,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ github.repository }} ${{ github.repository }}
@@ -169,104 +289,450 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build and push - name: Build and push (Alpine)
id: docker_build id: image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
build-args: | build-args: |
MAINTAINER=${{ github.repository_owner }} QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }} QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
# linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: . context: .
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache cache-from: type=registry,ref=whyour/qinglong:cache-alpine
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
- name: Image digest build-debian:
run: | if: ${{ !startsWith(github.ref, 'refs/tags/') }}
echo ${{ steps.docker_build.outputs.digest }}
build310:
if: ${{ github.ref_name == 'master' }}
needs: build-static needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v4 - uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "24"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml - name: Read version from version.yaml
id: version id: version
run: | run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}') VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 run: sudo timedatectl set-timezone Asia/Shanghai
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
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
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
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"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build and push python3.10 - name: Build and push (Alpine Python 3.10)
id: docker_build_310 uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with: with:
build-args: | build-args: |
MAINTAINER=${{ github.repository_owner }} QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }} QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
# linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: . context: .
file: ./docker/310.Dockerfile file: ./docker/Dockerfile.310
push: true push: true
provenance: mode=max
sbom: true
tags: | tags: |
whyour/qinglong:python3.10 whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10 whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
- name: Image digest build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
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"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: | run: |
echo ${{ steps.docker_build_310.outputs.digest }} VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
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
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: build-debian
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
verify-npm-package:
needs: [build-static, build-npm-migration-bundle]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '24']
container: node:${{ matrix.node }}-bookworm
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
- name: Install and upgrade the exact npm archive
env:
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel-results/
if-no-files-found: error
retention-days: 14
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Set up Node for trusted publishing
uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: https://registry.npmjs.org
package-manager-cache: false
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(npm-panel/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified panel npm archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if npm 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
npm publish "$archive" --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
build-upgrade-bundles:
# Both channels publish packages from this run's immutable image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+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
+179
View File
@@ -0,0 +1,179 @@
name: Release readiness
on:
pull_request:
paths:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'docker/Dockerfile*'
- 'shell/node-runtime-probe.cjs'
- 'scripts/verify-panel-package.cjs'
- 'scripts/verify-image-node-layers.py'
- 'test/back/node-runtime-probe.test.cjs'
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
static:
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: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:front && pnpm build:back && pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: release-readiness-static
path: static/
include-hidden-files: true
if-no-files-found: error
- name: Pack the verified npm archive for isolated runtime validation
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
name: release-readiness-npm-panel
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
images:
needs: [regression, static]
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/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/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 }} \
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
- name: Load ARMv6 image for SQLite and Node regression checks
if: startsWith(matrix.image, 'alpine')
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: linux/arm/v6
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 }}-armv6
load: true
push: false
provenance: false
sbom: false
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
if: startsWith(matrix.image, 'alpine')
run: |
docker run --rm --network none --platform linux/arm/v6 \
-e QEMU_CPU=arm1176 --entrypoint node \
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
- name: Check Node binaries in every serialized image layer
if: startsWith(matrix.image, 'alpine')
run: |
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
- name: Export native image for isolated dev validation
if: matrix.image == 'debian' || matrix.image == 'alpine'
run: |
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian' || matrix.image == 'alpine'
with:
name: release-readiness-${{ matrix.image }}-image
path: release-readiness-${{ matrix.image }}-image.*
compression-level: 0
if-no-files-found: error
+31
View File
@@ -0,0 +1,31 @@
name: Validate
on:
pull_request:
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: pnpm test
- run: pnpm build:back
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
- uses: actions/upload-artifact@v6
with:
name: execution-baseline
path: execution-baseline.json
+20
View File
@@ -28,3 +28,23 @@ __pycache__
/shell/preload/notify.* /shell/preload/notify.*
/shell/preload/*-notify.json /shell/preload/*-notify.json
/shell/preload/__ql_notify__.* /shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/
.claude/
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
# Local security audit artifacts
/audit/
# Local browser tests, screenshots, reports, and temporary test output
/.playwright-cli/
/output/
/playwright-report/
/test-results/
/blob-report/
/coverage/
/.nyc_output/
+1
View File
@@ -0,0 +1 @@
22
+43
View File
@@ -0,0 +1,43 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **qinglong** (2778 symbols, 6698 relationships, 233 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness |
| `gitnexus://repo/qinglong/clusters` | All functional areas |
| `gitnexus://repo/qinglong/processes` | All execution flows |
| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+10
View File
@@ -0,0 +1,10 @@
# Node.js 原生部署与 Python venv
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。
- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。
- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。
该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。
+35
View File
@@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support system level notification - Support system level notification
- Support dark mode - Support dark mode
- Support cell phone operation - Support cell phone operation
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
## Version ## Version
@@ -48,6 +49,17 @@ docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
``` ```
When running the `debian` image as a non-root user, specify `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm ### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed. The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
@@ -68,6 +80,29 @@ npm i @whyour/qinglong
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation) [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 ## Development
```bash ```bash
+35
View File
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持系统级通知 - 支持系统级通知
- 支持暗黑模式 - 支持暗黑模式
- 支持手机端操作 - 支持手机端操作
- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板
## 版本 ## 版本
@@ -50,6 +51,17 @@ docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
``` ```
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm ### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm` npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
@@ -70,6 +82,29 @@ npm i @whyour/qinglong
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation) [查看文档](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)。
## 开发 ## 开发
```bash ```bash
+56
View File
@@ -0,0 +1,56 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
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 : t('配置更新失败'),
});
}
},
);
route.get(
'/diagnose',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await diagnoseClientIp(req) });
} catch (error) {
next(error);
}
},
);
};
+20 -20
View File
@@ -4,17 +4,18 @@ import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { join } from 'path';
import { SAMPLE_FILES } from '../config/const'; import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config'; import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { resolveFileAccess } from '../shared/fileAccess';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
app.use('/configs', route); app.use('/configs', route);
route.get( route.get(
'/sample', '/samples',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
res.send({ res.send({
@@ -71,30 +72,29 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const { name, content } = req.body; const { name, content } = req.body;
if (config.blackFileList.includes(name)) {
res.send({ code: 403, message: '文件无法访问' });
}
let path = join(config.configPath, name);
if (name.startsWith('data/scripts/')) { if (name.startsWith('data/scripts/')) {
path = join(config.rootPath, name); return res.send({ code: 403, message: t('文件无法访问') });
} }
await writeFileWithLock(path, content); const normalized = resolveFileAccess(
res.send({ code: 200, message: '保存成功' }); config.configPath,
[name],
config.blackFileList,
);
if (!normalized) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
res.send({ code: 200, message: t('保存成功') });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
}, },
); );
route.get( route.get('/:file', (req: Request, res: Response) => {
'/:file', return res.send({
async (req: Request, res: Response, next: NextFunction) => { code: 410,
try { message: t('接口已下线,请使用 /configs/detail 接口'),
const configService = Container.get(ConfigService); });
await configService.getFile(req.params.file, res); });
} catch (e) {
return next(e);
}
},
);
}; };
+87 -3
View File
@@ -5,12 +5,31 @@ import CronService from '../services/cron';
import CronViewService from '../services/cronView'; import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule'; import { commonCronSchema } from '../validation/schedule';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { t } from '../shared/i18n';
import cronClient from '../schedule/client';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
app.use('/crons', route); 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( route.get(
'/views', '/views',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -60,7 +79,7 @@ export default (app: Router) => {
try { try {
const cronViewService = Container.get(CronViewService); const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) { if (req.body.type === 1) {
return res.send({ code: 400, message: '参数错误' }); return res.send({ code: 400, message: t('参数错误') });
} else { } else {
const data = await cronViewService.update(req.body); const data = await cronViewService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
@@ -302,13 +321,35 @@ export default (app: Router) => {
params: Joi.object({ params: Joi.object({
id: Joi.number().required(), 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) => { async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.log(req.params.id); const result = await cronService.log(req.params.id, {
return res.send({ code: 200, data }); 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) { } catch (e) {
return next(e); return next(e);
} }
@@ -429,6 +470,7 @@ export default (app: Router) => {
log_path: Joi.string().optional().allow(null), log_path: Joi.string().optional().allow(null),
last_running_time: Joi.number().optional().allow(null), last_running_time: Joi.number().optional().allow(null),
last_execution_time: Joi.number().optional().allow(null), last_execution_time: Joi.number().optional().allow(null),
exit_code: Joi.number().optional().allow(null),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -446,6 +488,48 @@ export default (app: Router) => {
}, },
); );
route.get(
'/:id/instances',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.findAll({
where: {
cron_id: req.params.id,
},
order: [['started_at', 'DESC']],
raw: true,
});
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
}
},
);
route.post(
'/:id/instances/:instanceId/stop',
celebrate({
params: Joi.object({
id: Joi.number().required(),
instanceId: Joi.number().required(),
}),
}),
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId);
return res.send(data);
} catch (e) {
return next(e);
}
},
);
route.get( route.get(
'/:id/logs', '/:id/logs',
celebrate({ celebrate({
+437
View File
@@ -0,0 +1,437 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
import os from 'os';
import { isEmpty } from 'lodash';
import { t, tf } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
app.use('/dashboard', route);
route.post('/record', async (req: Request, res: Response) => {
try {
const { ref_id, code, elapsed } = req.body;
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
const today = dayjs().format('YYYY-MM-DD');
const isSuccess = code === 0 ? 1 : 0;
const isFail = code !== 0 ? 1 : 0;
const elapsedMs = (Number(elapsed) || 0) * 1000;
const existing = await CrontabStatModel.findOne({
where: { ref_id: Number(ref_id), date: today },
});
if (existing) {
await CrontabStatModel.update(
{
run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess,
fail_count: (existing.fail_count || 0) + isFail,
total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs),
},
{ where: { id: existing.id } },
);
} else {
await CrontabStatModel.create({
ref_id: Number(ref_id),
date: today,
run_count: 1,
success_count: isSuccess,
fail_count: isFail,
total_time: elapsedMs,
max_time: elapsedMs,
});
}
res.send({ code: 200 });
} catch (e) {
res.send({ code: 500 });
}
});
route.get(
'/overview',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [total, enabled, disabled, stats] = await Promise.all([
CrontabModel.count(),
CrontabModel.count({ where: { isDisabled: 0 } }),
CrontabModel.count({ where: { isDisabled: 1 } }),
CrontabStatModel.findOne({
attributes: [
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
[fn('SUM', col('total_time')), 'total_time'],
],
where: { date: today },
raw: true,
}),
]);
const row = stats as any;
const totalRuns = Number(row?.total_runs) || 0;
const totalSuccess = Number(row?.total_success) || 0;
const totalFail = Number(row?.total_fail) || 0;
const totalTime = Number(row?.total_time) || 0;
res.send({
code: 200,
data: {
total,
enabled,
disabled,
todayRuns: totalRuns,
todaySuccess: totalSuccess,
todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
},
});
} catch (e) {
next(e);
}
},
);
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) => {
try {
const days = parseInt(req.query.days as string) || 7;
const dates: string[] = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
}
const rows = (await CrontabStatModel.findAll({
attributes: [
'date',
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
],
where: {
date: { [Op.in]: dates },
},
group: ['date'],
order: [['date', 'ASC']],
raw: true,
})) as any[];
const dataMap: Record<string, any> = {};
rows.forEach((r: any) => {
dataMap[r.date] = {
total: Number(r.total_runs) || 0,
success: Number(r.total_success) || 0,
fail: Number(r.total_fail) || 0,
};
});
const data = dates.map((d) => ({
date: dayjs(d).format('MM-DD'),
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-time',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('run_count')), 'run_count'],
[fn('MAX', col('max_time')), 'max_time'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('total_time')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
maxTime: Number(r.max_time),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('run_count')), 'run_count'],
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('success_count')), 'success_count'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('run_count')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
runCount: Number(r.run_count),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
successRate:
Number(r.run_count) > 0
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
: '0',
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/runtime',
async (req: Request, res: Response, next: NextFunction) => {
try {
const runningInstances = await RunningInstanceModel.findAll({
where: {
status: InstanceStatus.running,
},
raw: true,
});
const queuedCrons = await CrontabModel.findAll({
where: {
status: 3, // queued
},
raw: true,
});
// Fetch cron names for running instances
const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)),
];
const crons =
cronIds.length > 0
? await CrontabModel.findAll({
where: { id: cronIds },
raw: true,
})
: [];
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix();
const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id);
return {
instanceId: inst.id,
id: inst.cron_id,
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
pid: inst.pid,
elapsed: inst.started_at ? now - inst.started_at : 0,
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({
where: {
isDisabled: 0,
status: 1,
last_execution_time: { [Op.lt]: dayAgo },
},
order: [['last_execution_time', 'ASC']],
limit: 5,
raw: true,
});
res.send({
code: 200,
data: {
runningCount: running.length,
queuedCount: queuedCrons.length,
running,
idleTasks: idleTasks.map((c: any) => ({
id: c.id,
name: c.name || c.command || tf('任务#%s', c.id),
lastRun: c.last_execution_time
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
: '-',
})),
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/labels',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [crons, stats] = (await Promise.all([
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
]));
const statMap: Record<number, any> = {};
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
crons.forEach((c) => {
let rawLabels = c.labels;
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
const labels: string[] = Array.isArray(rawLabels)
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
: [];
if (labels.length === 0) {
labels.push(t('未分类'));
}
const st = statMap[c.id!];
labels.forEach((label: string) => {
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
labelMap[label].count += 1;
if (st) {
labelMap[label].runs += Number(st.run_count) || 0;
labelMap[label].success += Number(st.success_count) || 0;
labelMap[label].totalTime += Number(st.total_time) || 0;
}
});
});
const data = Object.entries(labelMap)
.map(([label, v]) => ({
label,
count: v.count,
todayRuns: v.runs,
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
}))
.sort((a, b) => b.todayRuns - a.todayRuns);
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const memUsage = process.memoryUsage();
res.send({
code: 200,
data: {
platform: os.platform(),
uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(),
memFree: os.freemem(),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
cpus: os.cpus().length,
},
});
} catch (e) {
next(e);
}
},
);
};
+65 -16
View File
@@ -1,23 +1,20 @@
import { Joi, celebrate } from 'celebrate'; import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express'; import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer'; import multer from 'multer';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import { t } from '../shared/i18n';
import { safeJSONParse } from '../config/util';
import EnvService from '../services/env'; import EnvService from '../services/env';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const upload = multer({
destination: function (req, file, cb) { storage: multer.memoryStorage(),
cb(null, config.scriptPath); limits: { fileSize: 5 * 1024 * 1024, files: 1 },
},
filename: function (req, file, cb) {
cb(null, file.originalname);
},
}); });
const upload = multer({ storage: storage }); const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
.required();
export default (app: Router) => { export default (app: Router) => {
app.use('/envs', route); app.use('/envs', route);
@@ -44,6 +41,7 @@ export default (app: Router) => {
.required() .required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/), .pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''), remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}), }),
), ),
}), }),
@@ -52,7 +50,7 @@ export default (app: Router) => {
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
if (!req.body?.length) { if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' }); return res.send({ code: 400, message: t('参数不正确') });
} }
const data = await envService.create(req.body); const data = await envService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
@@ -70,6 +68,7 @@ export default (app: Router) => {
name: Joi.string().required(), name: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null), remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(), id: Joi.number().required(),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -230,6 +229,44 @@ export default (app: Router) => {
}, },
); );
route.post(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.removeLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post( route.post(
'/upload', '/upload',
upload.single('env'), upload.single('env'),
@@ -237,24 +274,36 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8'); let parseContent: unknown;
const parseContent = safeJSONParse(fileContent); try {
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
} catch {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
const data = Array.isArray(parseContent) const data = Array.isArray(parseContent)
? parseContent ? 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( const result = await envService.create(
data.map((x) => ({ data.map((x) => ({
name: x.name, name: x.name,
value: x.value, value: x.value,
remarks: x.remarks, remarks: x.remarks,
labels: x.labels,
})), })),
); );
return res.send({ code: 200, data: result }); return res.send({ code: 200, data: result });
} else { } else {
return res.send({ return res.send({
code: 400, code: 400,
message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
}); });
} }
} catch (e) { } catch (e) {
+2 -2
View File
@@ -11,8 +11,8 @@ export default (app: Router) => {
try { try {
const healthService = Container.get(HealthService); const healthService = Container.get(HealthService);
const health = await healthService.check(); const health = await healthService.check();
res.status(200).send({ res.status(health.status === 'ok' ? 200 : 503).send({
code: 200, code: health.status === 'ok' ? 200 : 503,
data: health, data: health,
}); });
} catch (err: any) { } catch (err: any) {
+6
View File
@@ -10,7 +10,10 @@ import dependence from './dependence';
import system from './system'; import system from './system';
import subscription from './subscription'; import subscription from './subscription';
import update from './update'; import update from './update';
import dashboard from './dashboard';
import health from './health'; import health from './health';
import clientIp from './clientIp';
import retention from './retention';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -25,7 +28,10 @@ export default () => {
system(app); system(app);
subscription(app); subscription(app);
update(app); update(app);
dashboard(app);
health(app); health(app);
clientIp(app);
retention(app);
return app; return app;
}; };
+44 -24
View File
@@ -3,13 +3,15 @@ import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { t } from '../shared/i18n';
import { import {
getFileContentByName,
readDirs, readDirs,
removeAnsi, removeAnsi,
rmPath, rmPath,
} from '../config/util'; } from '../config/util';
import LogService from '../services/log'; import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
const route = Router(); const route = Router();
const blacklist = ['.tmp']; const blacklist = ['.tmp'];
@@ -32,6 +34,20 @@ export default (app: Router) => {
route.get( route.get(
'/detail', '/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) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const logService = Container.get(LogService); const logService = Container.get(LogService);
@@ -42,11 +58,28 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(req.query.path as string)) { if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
const content = await getFileContentByName(finalPath); const logPath = `${req.query.path as string}/${req.query.file as string}`;
res.send({ code: 200, data: removeAnsi(content) }); const runningInstance = await RunningInstanceModel.findOne({
where: { log_path: logPath, status: InstanceStatus.running },
});
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(chunk.content),
logStatus: runningInstance ? 'running' : undefined,
offset: chunk.offset,
nextOffset: chunk.nextOffset,
total: chunk.total,
truncated: chunk.truncated,
});
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -55,24 +88,11 @@ export default (app: Router) => {
route.get( route.get(
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response) => {
try { return res.send({
const logService = Container.get(LogService); code: 410,
const finalPath = logService.checkFilePath( message: t('接口已下线,请使用 /logs/detail 接口'),
(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: '暂无权限',
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
}, },
); );
@@ -96,7 +116,7 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(path)) { if (!finalPath || blacklist.includes(path)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await rmPath(finalPath); await rmPath(finalPath);
@@ -126,7 +146,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
+2
View File
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
import OpenService from '../services/open'; import OpenService from '../services/open';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { rateLimit } from 'express-rate-limit';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -100,6 +101,7 @@ export default (app: Router) => {
route.get( route.get(
'/auth/token', '/auth/token',
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
celebrate({ celebrate({
query: { query: {
client_id: Joi.string().required(), 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);
}
},
);
};
+247 -35
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 { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
@@ -5,18 +7,68 @@ import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; 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 ScriptService from '../services/script';
import ScriptHistoryService from '../services/scriptHistory';
import { t } from '../shared/i18n';
import multer from 'multer'; import multer from 'multer';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import {
ScriptHistoryError,
HistoryUnavailableError,
} from '../shared/scriptHistory';
const route = Router(); const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
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({ const storage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
cb(null, config.scriptPath); cb(null, config.tmpPath);
}, },
filename: function (req, file, cb) { filename: function (req, file, cb) {
cb(null, file.originalname); cb(null, randomUUID());
}, },
}); });
const upload = multer({ storage: storage }); const upload = multer({ storage: storage });
@@ -44,6 +96,11 @@ export default (app: Router) => {
'package-lock.json', 'package-lock.json',
]; ];
if (req.query.path) { if (req.query.path) {
if (
!resolveFileAccess(config.scriptPath, [req.query.path as string])
) {
return res.send({ code: 403, message: t('暂无权限') });
}
result = await readDir( result = await readDir(
req.query.path as string, req.query.path as string,
config.scriptPath, config.scriptPath,
@@ -71,7 +128,8 @@ export default (app: Router) => {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
return next(e); return next(e);
} }
}); },
);
route.get( route.get(
'/detail', '/detail',
@@ -85,7 +143,7 @@ export default (app: Router) => {
try { try {
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile( const content = await scriptService.getFile(
req.query?.path as string || '', (req.query?.path as string) || '',
req.query.file as string, req.query.file as string,
); );
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
@@ -95,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( 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({ celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), ...historyQuery,
id: Joi.string().uuid().required(),
}).unknown(true), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const scriptService = Container.get(ScriptService); const service = Container.get(ScriptHistoryService);
const content = await scriptService.getFile( const data = await service.detail(
req.query?.path as string || '', (req.query.path as string) || '',
req.params.file, req.query.filename as string,
req.query.id as string,
); );
res.send({ code: 200, data: content }); res.send({ code: 200, data });
} catch (e) { } catch (error) {
return next(e); 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( 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'), upload.single('file'),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
content: 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(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''), file: Joi.string().optional().allow(''),
@@ -155,30 +283,87 @@ export default (app: Router) => {
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
if (req.file) { if (req.file) {
await fs.rename(req.file.path, join(path, filename)); const uploadPath = join(path, filename);
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.copyFile(req.file.path, uploadPath);
await fs.unlink(req.file.path);
return res.send({ code: 200 }); return res.send({ code: 200 });
} }
if (directory) { if (directory) {
await fs.mkdir(join(path, directory), { recursive: true }); const dirPath = join(path, directory);
if (!isPathAllowed(dirPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(dirPath, { recursive: true });
return res.send({ code: 200 }); return res.send({ code: 200 });
} }
if (!originFilename) { if (!originFilename) {
originFilename = filename; originFilename = filename;
} }
const originFilePath = join( const originFilePath = join(path, originFilename);
path, const filePath = join(path, filename);
`${originFilename.replace(/\//g, '')}`, if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
); return res.send({ code: 403, message: t('暂无权限') });
await fs.mkdir(path, { recursive: true }); }
const filePath = join(path, `${filename.replace(/\//g, '')}`); // 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); 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) { if (fileExists) {
await fs.copyFile( await fs.copyFile(
originFilePath, originFilePath,
@@ -191,7 +376,7 @@ export default (app: Router) => {
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return historyError(e, res, next);
} }
}, },
); );
@@ -203,6 +388,11 @@ export default (app: Router) => {
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
content: Joi.string().required().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) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -217,13 +407,17 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await writeFileWithLock(filePath, content); const service = Container.get(ScriptHistoryService);
return res.send({ code: 200 }); const data = await service.save(path || '', filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return historyError(e, res, next);
} }
}, },
); );
@@ -251,7 +445,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await rmPath(filePath); await rmPath(filePath);
@@ -284,7 +478,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
@@ -316,6 +510,9 @@ export default (app: Router) => {
} }
const { name, ext } = parse(filename); const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await writeFileWithLock(filePath, content || ''); await writeFileWithLock(filePath, content || '');
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
@@ -344,12 +541,24 @@ export default (app: Router) => {
} }
const { name, ext } = parse(filename); const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
const logPath = join(config.logPath, path, `${name}.swap`); if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = resolveFileAccess(config.logPath, [
path,
`${name}.swap`,
]);
if (!logPath) {
return res.send({ code: 403, message: t('暂无权限') });
}
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid); const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => { setTimeout(() => {
rmPath(logPath); const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
if (cleanupPath) {
void rmPath(cleanupPath);
}
}, 3000); }, 3000);
res.send(result); res.send(result);
} catch (e) { } catch (e) {
@@ -379,6 +588,9 @@ export default (app: Router) => {
} }
const filePath = join(config.scriptPath, path, filename); const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename); const newPath = join(config.scriptPath, path, newFilename);
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(filePath, newPath); await fs.rename(filePath, newPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
+19 -12
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser'; import { isValidCronSchedule } from '../shared/cronSchedule';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -58,10 +58,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
if ( if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body); const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
@@ -148,13 +145,27 @@ export default (app: Router) => {
params: Joi.object({ params: Joi.object({
id: Joi.number().required(), 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) => { async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.log(req.params.id); const result = await subscriptionService.log(req.params.id, {
return res.send({ code: 200, data }); 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) { } catch (e) {
return next(e); return next(e);
} }
@@ -190,11 +201,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
if ( if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body); const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
+92 -20
View File
@@ -6,15 +6,14 @@ import config from '../config';
import SystemService from '../services/system'; import SystemService from '../services/system';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user'; import UserService from '../services/user';
import { import { t } from '../shared/i18n';
getUniqPath, import { isDefaultAuthInfo } from '../shared/auth';
handleLogPath, import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
parseVersion, import { findCronId } from '../shared/cronCommand';
promiseExec,
} from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager'; import { logStreamManager } from '../shared/logStreamManager';
import { rateLimit } from 'express-rate-limit';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -38,14 +37,7 @@ export default (app: Router) => {
const { version, changeLog, changeLogLink, publishTime } = const { version, changeLog, changeLogLink, publishTime } =
await parseVersion(config.versionFile); await parseVersion(config.versionFile);
let isInitialized = true; const isInitialized = !isDefaultAuthInfo(authInfo);
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
res.send({ res.send({
code: 200, code: 200,
data: { data: {
@@ -213,6 +205,17 @@ export default (app: Router) => {
}, },
); );
route.get(
'/update-status',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send(await Container.get(SystemService).getUpgradeStatus());
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/reload', '/reload',
celebrate({ celebrate({
@@ -254,6 +257,7 @@ export default (app: Router) => {
route.put( route.put(
'/command-run', '/command-run',
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
command: Joi.string().required(), command: Joi.string().required(),
@@ -263,8 +267,13 @@ export default (app: Router) => {
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const command = req.body.command; 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`; const crontab = await fs
let id = await promiseExec(idStr); .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 uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`; const logPath = `${uniqPath}/${logTime}.log`;
@@ -278,8 +287,13 @@ export default (app: Router) => {
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion // Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath)); try {
res.end(); await logStreamManager.closeStream(
await handleLogPath(logPath),
);
} finally {
res.end();
}
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(message); res.write(message);
@@ -355,6 +369,11 @@ export default (app: Router) => {
query: { query: {
startTime: Joi.string().allow('').optional(), startTime: Joi.string().allow('').optional(),
endTime: Joi.string().allow('').optional(), endTime: Joi.string().allow('').optional(),
limit: Joi.number()
.integer()
.min(1)
.max(1024 * 1024)
.optional(),
t: Joi.string().optional(), t: Joi.string().optional(),
}, },
}), }),
@@ -366,6 +385,7 @@ export default (app: Router) => {
req.query as { req.query as {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
limit?: number;
}, },
); );
} catch (e) { } catch (e) {
@@ -374,6 +394,19 @@ export default (app: Router) => {
}, },
); );
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete( route.delete(
'/log', '/log',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -400,8 +433,11 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.resetAuthInfo(req.body); const result = await userService.resetAuthInfo(req.body);
res.send({ code: 200, message: '更新成功' }); if (result) {
return res.send(result);
}
res.send({ code: 200, message: t('更新成功') });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -426,6 +462,42 @@ export default (app: Router) => {
}, },
); );
route.put(
'/config/lang',
celebrate({
body: Joi.object({
lang: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLanguage(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/panel-title',
celebrate({
body: Joi.object({
panelTitle: Joi.string().max(100).allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePanelTitle(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/config/global-ssh-key', '/config/global-ssh-key',
celebrate({ celebrate({
+90 -16
View File
@@ -8,6 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { t } from '../shared/i18n';
import { isDemoEnv, getToken } from '../config/util'; import { isDemoEnv, getToken } from '../config/util';
const route = Router(); const route = Router();
@@ -21,7 +22,25 @@ const storage = multer.diskStorage({
cb(null, key + ext); 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) => { export default (app: Router) => {
app.use('/user', route); app.use('/user', route);
@@ -34,8 +53,8 @@ export default (app: Router) => {
}), }),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
username: Joi.string().required(), username: Joi.string().max(1024).required(),
password: Joi.string().required(), password: Joi.string().max(1024).required(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -69,18 +88,18 @@ export default (app: Router) => {
'/', '/',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
username: Joi.string().required(), username: Joi.string().max(1024).required(),
password: Joi.string().required(), password: Joi.string().max(1024).required(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
if (isDemoEnv()) { if (isDemoEnv()) {
return res.send({ code: 450, message: '未知错误' }); return res.send({ code: 450, message: t('未知错误') });
} }
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body); const result = await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: '更新成功' }); res.send(result);
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -140,12 +159,12 @@ export default (app: Router) => {
); );
route.put( route.put(
'/two-factor/deactive', '/two-factor/deactivate',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
const data = await userService.deactiveTwoFactor(); const data = await userService.deactivateTwoFactor();
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -155,11 +174,12 @@ export default (app: Router) => {
route.put( route.put(
'/two-factor/login', '/two-factor/login',
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
code: Joi.string().required(), code: Joi.string().required(),
username: Joi.string().required(), username: Joi.string().max(1024).required(),
password: Joi.string().required(), password: Joi.string().max(1024).required(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -188,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( route.get(
'/notification', '/notification',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -218,18 +291,19 @@ export default (app: Router) => {
route.put( route.put(
'/init', '/init',
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
username: Joi.string().required(), username: Joi.string().max(1024).required(),
password: Joi.string().required(), password: Joi.string().max(1024).required(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body); const result = await userService.initializeUser(req.body);
res.send({ code: 200, message: '更新成功' }); res.send(result);
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
+60 -64
View File
@@ -1,13 +1,11 @@
import 'reflect-metadata'; import 'reflect-metadata';
import cluster, { type Worker } from 'cluster'; import cluster, { type Worker } from 'cluster';
import compression from 'compression'; import type express from 'express';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Container } from 'typedi'; import { Container } from 'typedi';
import config from './config'; import config from './config';
import Logger from './loaders/logger'; import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring'; import { errStack } from './shared/errors';
import { initializeLanguageSync } from './shared/languageSync';
import { type GrpcServerService } from './services/grpc'; import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http'; import { type HttpServerService } from './services/http';
@@ -19,52 +17,48 @@ interface WorkerMetadata {
} }
class Application { class Application {
private app: express.Application;
private httpServerService?: HttpServerService; private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService; private grpcServerService?: GrpcServerService;
private isShuttingDown = false; private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>(); private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker; 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() { async start() {
try { try {
if (cluster.isPrimary) { if (cluster.isPrimary) {
await this.initializeDatabase(); await this.initializeDatabase();
} }
await initializeLanguageSync();
if (cluster.isPrimary) { if (cluster.isPrimary) {
this.startMasterProcess(); this.startMasterProcess();
} else { } else {
await this.startWorkerProcess(); await this.startWorkerProcess();
} }
} catch (error) { } catch (error) {
Logger.error('Failed to start application:', error); Logger.error(`Failed to start application:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
} }
private startMasterProcess() { private startMasterProcess() {
const readyTimeout =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number }).arm_version,
) === 6
? 120000
: 30000;
// Fork gRPC worker first and wait for it to be ready // Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc'); const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker // Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000) this.waitForWorkerReady(grpcWorker, readyTimeout)
.then(() => { .then(() => {
Logger.info('✌️ gRPC worker is ready, starting HTTP worker'); Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http'); this.httpWorker = this.forkWorker('http');
}) })
.catch((error) => { .catch((error) => {
Logger.error('✌️ Failed to wait for gRPC worker:', error); Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
process.exit(1); process.exit(1);
}); });
@@ -73,34 +67,39 @@ class Application {
if (metadata) { if (metadata) {
if (!this.isShuttingDown) { if (!this.isShuttingDown) {
Logger.error( Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code `${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`, }). Restarting...`,
); );
// If gRPC worker died, restart it and wait for it to be ready // If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') { 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'); const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000) this.waitForWorkerReady(newGrpcWorker, readyTimeout)
.then(() => { .then(() => {
Logger.info('✌️ gRPC worker restarted and ready'); Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker // Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) { if (this.httpWorker) {
try { try {
this.httpWorker.send('reregister-crons'); this.httpWorker.send('reregister-crons');
Logger.info('✌️ Sent reregister-crons message to HTTP worker'); Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) { } catch (error) {
Logger.error('✌️ Failed to send reregister-crons message:', error); Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
} }
} }
}) })
.catch((error) => { .catch((error) => {
Logger.error('✌️ Failed to restart gRPC worker:', error); Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
process.exit(1); process.exit(1);
}); });
} else { } else {
// For HTTP worker, just restart it // For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType); const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker; this.httpWorker = newWorker;
Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`); Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
} }
} }
@@ -131,7 +130,14 @@ class Application {
} }
private forkWorker(serviceType: string): Worker { 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, { this.workerMetadataMap.set(worker.id, {
id: worker.id, id: worker.id,
@@ -144,17 +150,13 @@ class Application {
} }
private async initializeDatabase() { private async initializeDatabase() {
const dbLoader = await import('./loaders/db'); const { runStartupProcess } = await import('./shared/startupProcess');
await dbLoader.default(); await runStartupProcess(require.resolve('./bootstrap/database'));
} }
private setupMiddlewares() { private async setupMiddlewares(): Promise<express.Application> {
this.app.use(helmet({ const { createHttpApplication } = await import('./loaders/httpApplication');
contentSecurityPolicy: false, return createHttpApplication();
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
} }
private setupMasterShutdown() { private setupMasterShutdown() {
@@ -169,17 +171,14 @@ class Application {
if (worker) { if (worker) {
const exitPromise = new Promise<void>((resolve) => { const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => { worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`); Logger.info(`Worker ${worker.process.pid} exited`);
resolve(); resolve();
}); });
try { try {
worker.send('shutdown'); worker.send('shutdown');
} catch (error) { } catch (error) {
Logger.warn( Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
} }
}); });
@@ -192,14 +191,14 @@ class Application {
Promise.all(workerPromises), Promise.all(workerPromises),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
setTimeout(() => { setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached'); Logger.warn('Worker shutdown timeout reached');
resolve(); resolve();
}, 10000); }, 10000);
}), }),
]); ]);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error('✌️ Error during worker shutdown:', error); Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
}; };
@@ -211,11 +210,11 @@ class Application {
private async startWorkerProcess() { private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE; const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) { if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType); Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
process.exit(1); process.exit(1);
} }
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`); Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
try { try {
if (serviceType === 'http') { if (serviceType === 'http') {
@@ -226,22 +225,26 @@ class Application {
process.send?.('ready'); process.send?.('ready');
} catch (error) { } catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error); Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
} }
private async startHttpService() { private async startHttpService() {
this.setupMiddlewares(); // 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
const app = await this.setupMiddlewares();
const { HttpServerService } = await import('./services/http'); const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService); this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app'); const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app }); await appLoader.default({ app });
const server = await this.httpServerService.initialize( const server = await this.httpServerService.initialize(
this.app, app,
config.port, config.port,
); );
@@ -262,17 +265,10 @@ class Application {
process.on('message', async (msg) => { process.on('message', async (msg) => {
if (msg === 'shutdown') { if (msg === 'shutdown') {
this.gracefulShutdown(serviceType); this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') { } else if (serviceType === 'http' &&
// Re-register cron jobs when gRPC worker restarts (msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
try { const { default: cronClient } = await import('./schedule/client');
Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...'); cronClient.readiness.invalidate();
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('✌️ Cron jobs re-registered successfully');
} catch (error) {
Logger.error('✌️ Failed to re-register cron jobs:', error);
}
} }
}); });
@@ -293,7 +289,7 @@ class Application {
} }
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error); Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
} }
@@ -301,6 +297,6 @@ class Application {
const app = new Application(); const app = new Application();
app.start().catch((error) => { app.start().catch((error) => {
Logger.error('🙅‍♀️ Application failed to start:', error); Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1); process.exit(1);
}); });
+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();
});
+139
View File
@@ -0,0 +1,139 @@
// A single, disposable backend process. Resolve startup dependencies, but never
// run appLoader: its initData/initTask hooks install dependencies and start user work.
import 'reflect-metadata';
import dns from 'dns';
import http from 'http';
// The first scheduler connection can outlive its short RPC deadline. Let the
// normal single-flight recovery finish within the supervised startup budget.
async function waitForUpgradeScheduler(
readiness: { recover(): Promise<boolean> },
timeoutMs: number,
retryMs = 250,
) {
const deadline = Date.now() + timeoutMs;
do {
if (await readiness.recover()) return;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryMs, remaining)),
);
} while (Date.now() < deadline);
throw new Error('Upgrade scheduler readiness failed');
}
async function probeUpgrade() {
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
throw new Error('Upgrade probe requires its isolated supervisor');
dns.setDefaultResultOrder('ipv4first');
const { default: config } = await import('../config');
const { sequelize } = await import('../data');
const { default: loadDatabase } = await import('../loaders/db');
const { Container } = await import('typedi');
const { GrpcServerService } = await import('../services/grpc');
const { HttpServerService } = await import('../services/http');
const { createHttpApplication } = await import('../loaders/httpApplication');
const { default: loadServer } = await import('../loaders/server');
const { default: loadExpress } = await import('../loaders/express');
await import('../loaders/app'); // Resolve normal startup hooks without running them.
const { default: inject } = await import('../loaders/depInjector');
const grpc = Container.get(GrpcServerService);
const server = Container.get(HttpServerService);
try {
await loadDatabase();
// Exercise an actual on-disk transaction as well as schema initialization.
await sequelize.transaction(async (transaction) => {
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
transaction,
});
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
transaction,
});
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
transaction,
});
if ((rows[0] as { value: number })?.value !== 42)
throw new Error('Upgrade database readback failed');
});
inject();
config.grpcPort = await grpc.initialize();
const { default: cronClient } = await import('../schedule/client');
cronClient.readiness.configure(async () => {});
await waitForUpgradeScheduler(
cronClient.readiness,
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number })
.arm_version,
) === 6
? 60000
: 10000,
);
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
await loadServer({ server: listener });
const address = listener.address();
if (!address || typeof address === 'string')
throw new Error('No probe HTTP port');
const health = await new Promise<any>((resolve, reject) => {
const request = http.get(
`http://127.0.0.1:${address.port}/api/health`,
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok' ||
!result.data.services?.grpc ||
!result.data.services?.http
)
throw new Error('Upgrade HTTP/gRPC health check failed');
resolve(result.data);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () =>
request.destroy(new Error('Upgrade health request timed out')),
);
request.on('error', reject);
});
process.send!({
type: 'upgrade-probe',
ok: true,
build: health.build,
memory: process.memoryUsage(),
pid: process.pid,
});
} finally {
await server.shutdown();
grpc.getServer().forceShutdown();
await sequelize.close();
}
}
process.once('disconnect', () => process.exit(1));
probeUpgrade()
.then(() => process.exit(0))
.catch((error) => {
process.send?.({
type: 'upgrade-probe',
ok: false,
code: error.code,
message: error.message,
stack: error.stack,
});
console.error(error);
process.exit(1);
});
+40
View File
@@ -1,3 +1,5 @@
import { maybeSudo } from './container';
export const LOG_END_SYMBOL = '     '; export const LOG_END_SYMBOL = '     ';
export const TASK_COMMAND = 'task'; export const TASK_COMMAND = 'task';
@@ -25,6 +27,7 @@ export const SAMPLE_FILES = [
]; ];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME; export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
export const NotificationModeStringMap = { export const NotificationModeStringMap = {
0: 'gotify', 0: 'gotify',
@@ -48,4 +51,41 @@ export const NotificationModeStringMap = {
18: 'chronocat', 18: 'chronocat',
19: 'ntfy', 19: 'ntfy',
20: 'wxPusherBot', 20: 'wxPusherBot',
21: 'wxPusherSpt',
22: 'wpush',
} as const; } as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};
+7
View File
@@ -0,0 +1,7 @@
export function isInContainer(): boolean {
return process.env.QL_CONTAINER === 'true';
}
export function maybeSudo(cmd: string): string {
return isInContainer() ? `sudo ${cmd}` : cmd;
}
+198
View File
@@ -0,0 +1,198 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs/promises';
import path from 'path';
import config from './index';
import { fileExist } from './util';
import Logger from '../loaders/logger';
export interface GrpcTlsConfig {
caCert: string;
serverCert: string;
serverKey: string;
clientCert: string;
clientKey: string;
}
const certDir = path.join(config.configPath, 'grpc');
const caKeyPath = path.join(certDir, 'ca.key');
const caCertPath = path.join(certDir, 'ca.crt');
const serverKeyPath = path.join(certDir, 'server.key');
const serverCertPath = path.join(certDir, 'server.crt');
const clientKeyPath = path.join(certDir, 'client.key');
const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null;
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...');
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 () => {
await fs.rm(workspace, { recursive: true, force: true });
};
try {
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.writeFile(caKeyPath, caKey, { mode: 0o600 });
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');
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([
'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');
Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey };
} finally {
await cleanup();
}
}
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
}
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
const exists = await Promise.all([
fileExist(caCertPath),
fileExist(serverCertPath),
fileExist(serverKeyPath),
fileExist(clientCertPath),
fileExist(clientKeyPath),
]);
if (exists.some((e) => !e)) {
return null;
}
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
fs.readFile(caCertPath, 'utf-8'),
fs.readFile(serverCertPath, 'utf-8'),
fs.readFile(serverKeyPath, 'utf-8'),
fs.readFile(clientCertPath, 'utf-8'),
fs.readFile(clientKeyPath, 'utf-8'),
]);
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
return { caCert, serverCert, serverKey, clientCert, clientKey };
}
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
if (cachedConfig) {
return cachedConfig;
}
let tlsConfig = await loadExistingCerts();
if (!tlsConfig) {
tlsConfig = await generateAllCerts();
await saveCerts(tlsConfig);
}
cachedConfig = tlsConfig;
return tlsConfig;
}
export function getGrpcCerts(): GrpcTlsConfig | null {
return cachedConfig;
}
+8 -5
View File
@@ -1,6 +1,5 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { createRandomString } from './share';
dotenv.config({ dotenv.config({
path: path.join(__dirname, '../../.env'), path: path.join(__dirname, '../../.env'),
@@ -9,6 +8,8 @@ dotenv.config({
interface Config { interface Config {
port: number; port: number;
grpcPort: number; grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string; nodeEnv: string;
isDevelopment: boolean; isDevelopment: boolean;
isProduction: boolean; isProduction: boolean;
@@ -31,6 +32,8 @@ interface Config {
const config: Config = { const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10), port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10), grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development', isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === 'production',
@@ -106,6 +109,7 @@ const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py'); const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list'); const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json'); const authConfigFile = path.join(configPath, 'auth.json');
@@ -114,8 +118,6 @@ const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh'); const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite'); const sqliteFile = path.join(samplePath, 'database.sqlite');
const authError = '错误的用户名密码,请重试';
const loginFaild = '请先登录!';
const configString = 'config sample crontab shareCode diy'; const configString = 'config sample crontab shareCode diy';
const versionFile = path.join(rootPath, 'version.yaml'); const versionFile = path.join(rootPath, 'version.yaml');
const dataTgzFile = path.join(tmpPath, 'data.tgz'); const dataTgzFile = path.join(tmpPath, 'data.tgz');
@@ -137,8 +139,6 @@ export default {
shareShellFile, shareShellFile,
dependenceProxyFile, dependenceProxyFile,
configString, configString,
loginFaild,
authError,
logPath, logPath,
extraFile, extraFile,
authConfigFile, authConfigFile,
@@ -151,6 +151,7 @@ export default {
pyEnvFile, pyEnvFile,
jsNotifyFile, jsNotifyFile,
pyNotifyFile, pyNotifyFile,
langEnvFile,
dbPath, dbPath,
uploadPath, uploadPath,
configPath, configPath,
@@ -167,6 +168,8 @@ export default {
'env.js', 'env.js',
'env.py', 'env.py',
'token.json', 'token.json',
'grpc',
'__pycache__',
], ],
writePathList: [configPath, scriptPath], writePathList: [configPath, scriptPath],
bakPath, bakPath,
+6 -4
View File
@@ -1,3 +1,5 @@
import { randomInt } from 'crypto';
export function createRandomString(min: number, max: number): string { export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [ const english = [
@@ -65,19 +67,19 @@ export function createRandomString(min: number, max: number): string {
arr.push(getOne(ENGLISH)); arr.push(getOne(ENGLISH));
arr.push(getOne(special)); 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++) { for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]); arr.push(config[randomInt(config.length)]);
} }
const newArr = []; const newArr = [];
for (let j = 0; j < len; j++) { 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[]) { function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)]; return arr[randomInt(arr.length)];
} }
return newArr.join(''); return newArr.join('');
+23 -26
View File
@@ -1,5 +1,7 @@
import { Subscription } from '../data/subscription'; import { Subscription } from '../data/subscription';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shellQuote } from '../shared/shellQuote';
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
export function formatUrl(doc: Subscription) { export function formatUrl(doc: Subscription) {
let url = doc.url; let url = doc.url;
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
if (doc.type === 'private-repo') { if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') { if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1'); host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias); url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
} else { } else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1'); host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any; const { username, password } = doc.pull_option as any;
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
} }
export function formatCommand(doc: Subscription, url?: string) { export function formatCommand(doc: Subscription, url?: string) {
let command = `SUB_ID=${doc.id} ql `; const args =
let _url = url || formatUrl(doc).url; doc.type === 'file'
const { ? ['raw', url || formatUrl(doc).url, doc.proxy || '']
type, : [
whitelist, 'repo',
blacklist, url || formatUrl(doc).url,
dependences, doc.whitelist || '',
branch, doc.blacklist || '',
extensions, doc.dependences || '',
proxy, doc.branch || '',
autoAddCron, doc.extensions || '',
autoDelCron, doc.proxy || '',
} = doc; ];
if (type === 'file') { args.push(
command += `raw "${_url}" "${proxy || ''}" "${ String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
isNil(autoAddCron) ? true : Boolean(autoAddCron) String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; );
} else { return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${ .map((arg) => shellQuote(arg!))
dependences || '' .join(' ')}`;
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
}
return command;
} }
+500 -83
View File
@@ -1,18 +1,56 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { statSync } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { exec } from 'child_process'; import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree'; import psTreeFun from 'ps-tree';
import { promisify } from 'util'; import { promisify } from 'util';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import config from './index'; import config from './index';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const'; import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence'; import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici'; import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { shellQuote } from '../shared/shellQuote';
import { resolveFileAccess } from '../shared/fileAccess';
import {
mirrorWriteCommand,
normalizeMirror,
rewriteMirrorSources,
} from '../shared/linuxMirror';
export * from './share'; export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
// 1. 环境变量覆盖
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') return 'Alpine';
if (envOs === 'debian') return 'Debian';
if (envOs === 'ubuntu') return 'Ubuntu';
// 2. 模块缓存(由 detectOS 设置)
if (osType) return osType;
// 3. 能力检测:检查包管理器二进制
try {
execSync('which apt-get', { stdio: 'ignore' });
return 'Debian';
} catch {
try {
execSync('which apk', { stdio: 'ignore' });
return 'Alpine';
} catch {
// macOS / 未知系统
}
}
return undefined;
}
export async function getFileContentByName(fileName: string) { export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName); const _exsit = await fileExist(fileName);
if (_exsit) { if (_exsit) {
@@ -114,7 +152,8 @@ export async function handleLogPath(
logPath: string, logPath: string,
data: string = '', data: string = '',
): Promise<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); const logFileExist = await fileExist(absolutePath);
if (!logFileExist) { if (!logFileExist) {
await createFile(absolutePath, data); await createFile(absolutePath, data);
@@ -184,48 +223,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( export async function readDirs(
dir: string, dir: string,
baseDir: string = '', baseDir: string = '',
blacklist: string[] = [], blacklist: string[] = [],
sort: (a: IFile, b: IFile) => number = dirSort, sort: (a: IFile, b: IFile) => number = dirSort,
): Promise<IFile[]> { ): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir); return readDirsWithRunner(
const files = await fs.readdir(dir); dir,
const result: IFile[] = []; baseDir,
blacklist,
for (const file of files) { sort,
const subPath = path.join(dir, file); createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
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);
} }
export async function readDir( export async function readDir(
@@ -233,7 +329,10 @@ export async function readDir(
baseDir: string = '', baseDir: string = '',
blacklist: string[] = [], blacklist: string[] = [],
): Promise<IFile[]> { ): Promise<IFile[]> {
const absoluteDir = path.join(baseDir, dir); const absoluteDir = path.resolve(baseDir, dir);
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
return [];
}
const relativePath = path.relative(baseDir, absoluteDir); const relativePath = path.relative(baseDir, absoluteDir);
try { try {
@@ -397,18 +496,69 @@ export function psTree(pid: number): Promise<number[]> {
}); });
} }
export async function killTask(pid: number) { export async function killTask(pid: number, waitForExit = false) {
const pids = await psTree(pid); const descendants = await psTree(pid);
const signal = (target: number, sig: NodeJS.Signals) => {
if (pids.length) {
try { try {
[pid, ...pids].reverse().forEach((x) => { process.kill(target, sig);
process.kill(x, 15); } catch (error: any) {
}); if (error.code !== 'ESRCH') throw error;
} catch (error) {} }
} else { };
process.kill(pid, 2); 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) { export async function getPid(cmd: string) {
@@ -503,11 +653,13 @@ export function safeJSONParse(value?: string) {
try { try {
return JSON.parse(value); return JSON.parse(value);
} catch (error) { } catch (error) {
Logger.error('[safeJSONParse失败]', error); Logger.error('[safeJSONParse error]', error);
return {}; return {};
} }
} }
export { errStack } from '../shared/errors';
export async function rmPath(path: string) { export async function rmPath(path: string) {
try { try {
const _exsit = await fileExist(path); const _exsit = await fileExist(path);
@@ -515,7 +667,7 @@ export async function rmPath(path: string) {
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 }); await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
} }
} catch (error) { } catch (error) {
Logger.error('[rmPath失败]', error); Logger.error('[rmPath error]', error);
} }
} }
@@ -525,68 +677,333 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
throw new Error('Invalid timezone'); throw new Error('Invalid timezone');
} }
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); await promiseExec(
await promiseExec(`echo "${timezone}" > /etc/timezone`); maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
);
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true; return true;
} catch (error) { } catch (error) {
Logger.error('[setSystemTimezone失败]', error); Logger.error('[setSystemTimezone error]', error);
return false; return false;
} }
} }
// Helper function to check if a name is a GitHub URL
function getPythonExecutable(): string {
return PYTHON_VENV_DIR
? shellQuote(path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'))
: 'python3';
}
function getPythonPip(): string {
return PYTHON_VENV_DIR ? `${getPythonExecutable()} -m pip` : 'pip3';
}
function isGitHubUrl(name: string): boolean {
// Support git+https://, git+http://, https://, and http:// URLs
// This covers GitHub URLs and other git-compatible repositories
return !!name.match(/^(git\+https?:\/\/|https?:\/\/)/i);
}
// Helper function to check if a name is a requirements file
function isRequirementsFile(name: string): boolean {
return !!name.match(/requirements.*\.(txt|in)$/i);
}
// Helper function to check if a name is a pyproject.toml file
function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function isPythonSourceDependency(name: string): boolean {
const source = name.trim();
return (
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
);
}
export function getGetCommand(type: DependenceTypes, name: string): string { export function getGetCommand(type: DependenceTypes, name: string): string {
const trimmedName = name.trim();
// For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) {
if (isPythonSourceDependency(trimmedName)) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
}
}
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`, [DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: ` [DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
python3 -c "exec(''' `from importlib.metadata import version, PackageNotFoundError
name='${name}' import importlib.util
import sys
try: try:
from importlib.metadata import version try:
print(version(name)) from packaging.requirements import Requirement
except: except ImportError:
import importlib.util as u from pip._vendor.packaging.requirements import Requirement
import importlib.metadata as m requirement = Requirement(sys.argv[1])
spec=u.find_spec(name) if requirement.marker and not requirement.marker.evaluate():
print(name if spec else '') print('not applicable')
''')"`, else:
[DependenceTypes.linux]: `apk info -es ${name}`, try:
installed = version(requirement.name)
except PackageNotFoundError:
if not requirement.specifier and not requirement.extras and not requirement.url:
if importlib.util.find_spec(requirement.name):
print(requirement.name)
else:
if requirement.specifier.contains(installed, prereleases=True):
print(installed)
except Exception:
pass`,
)} ${shellQuote(trimmedName)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
: maybeSudo(`dpkg-query -s ${trimmedName}`),
}; };
return baseCommands[type]; return baseCommands[type];
} }
export function getInstallCommand(type: DependenceTypes, name: string): string { export function getInstallCommand(type: DependenceTypes, name: string): string {
if (type === DependenceTypes.python3) {
const prefix =
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
: '';
const pip = `${getPythonPip()} install --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
}${prefix}`;
name = name.trim();
if (isRequirementsFile(name) || isPyprojectToml(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return isRequirementsFile(name)
? `${pip} -r ${shellQuote(file)}`
: `${pip} ${shellQuote(path.dirname(file))}`;
}
return `${pip} -- ${shellQuote(name)}`;
}
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g', [DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]: [DependenceTypes.linux]:
'pip3 install --disable-pip-version-check --root-user-action=ignore', getOsTypeSync() === 'Alpine'
[DependenceTypes.linux]: 'apk add --no-check-certificate', ? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
}; };
return `${baseCommands[type]} ${name.trim()}`;
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
} }
export function getUninstallCommand( export function getUninstallCommand(
type: DependenceTypes, type: DependenceTypes,
name: string, name: string,
): string { ): string {
if (type === DependenceTypes.python3) {
name = name.trim();
if (isRequirementsFile(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -r ${shellQuote(file)}`;
}
if (isPyprojectToml(name) || isGitHubUrl(name)) {
// A source URL/project can install several distributions and has no safe
// inverse. Removing its panel entry leaves installed packages intact.
return `printf '%s\\n' ${shellQuote(
'Remove installed Python distributions separately by package name.',
)}`;
}
const packageName =
name.match(
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
)?.[1] || name;
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(packageName)}`;
}
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g', [DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]: [DependenceTypes.linux]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
[DependenceTypes.linux]: 'apk del',
}; };
return `${baseCommands[type]} ${name.trim()}`; return `${baseCommands[type]} ${name.trim()}`;
} }
export function isDemoEnv() { export function isDemoEnv() {
return process.env.DeployEnv === 'demo'; return process.env.DeployEnv === 'demo';
} }
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') {
osType = 'Alpine';
return osType;
}
if (envOs === 'debian') {
osType = 'Debian';
return osType;
}
if (envOs === 'ubuntu') {
osType = 'Ubuntu';
return osType;
}
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
return osType;
}
async function _updateLinuxMirror(
osType: 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 = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
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`;
}
}
}
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> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}
+7 -2
View File
@@ -13,6 +13,7 @@ export class Crontab {
pid?: number; pid?: number;
isDisabled?: 1 | 0; isDisabled?: 1 | 0;
log_path?: string; log_path?: string;
queued_token?: string | null;
isPinned?: 1 | 0; isPinned?: 1 | 0;
labels?: string[]; labels?: string[];
last_running_time?: number; last_running_time?: number;
@@ -23,6 +24,7 @@ export class Crontab {
task_after?: string; task_after?: string;
log_name?: string; log_name?: string;
allow_multiple_instances?: 1 | 0; allow_multiple_instances?: 1 | 0;
work_dir?: string;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@@ -49,14 +51,15 @@ export class Crontab {
this.task_after = options.task_after; this.task_after = options.task_after;
this.log_name = options.log_name; this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0; this.allow_multiple_instances = options.allow_multiple_instances || 0;
this.work_dir = options.work_dir;
} }
} }
export enum CrontabStatus { export enum CrontabStatus {
'running' = 0, 'running' = 0,
'queued' = 0.5,
'idle' = 1, 'idle' = 1,
'disabled', 'disabled' = 2,
'queued' = 3,
} }
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {} export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
@@ -81,6 +84,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
isDisabled: DataTypes.NUMBER, isDisabled: DataTypes.NUMBER,
isPinned: DataTypes.NUMBER, isPinned: DataTypes.NUMBER,
log_path: DataTypes.STRING, log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
labels: DataTypes.JSON, labels: DataTypes.JSON,
last_running_time: DataTypes.NUMBER, last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER, last_execution_time: DataTypes.NUMBER,
@@ -90,4 +94,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
task_after: DataTypes.STRING, task_after: DataTypes.STRING,
log_name: DataTypes.STRING, log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER, allow_multiple_instances: DataTypes.NUMBER,
work_dir: DataTypes.STRING,
}); });
+71
View File
@@ -0,0 +1,71 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
export class CrontabStat {
id?: number;
ref_id!: number;
date!: string;
run_count?: number;
success_count?: number;
fail_count?: number;
total_time?: number;
max_time?: number;
constructor(options: CrontabStat) {
this.id = options.id;
this.ref_id = options.ref_id;
this.date = options.date;
this.run_count = options.run_count || 0;
this.success_count = options.success_count || 0;
this.fail_count = options.fail_count || 0;
this.total_time = options.total_time || 0;
this.max_time = options.max_time || 0;
}
}
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
'CrontabStat',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
ref_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
date: {
type: DataTypes.STRING,
allowNull: false,
},
run_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
success_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
fail_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
total_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
max_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
},
{
indexes: [
{ unique: true, fields: ['ref_id', 'date'] },
{ fields: ['date'] },
],
},
);
+3
View File
@@ -10,6 +10,7 @@ export class Env {
name?: string; name?: string;
remarks?: string; remarks?: string;
isPinned?: 1 | 0; isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) { constructor(options: Env) {
this.value = options.value; this.value = options.value;
@@ -23,6 +24,7 @@ export class Env {
this.name = options.name; this.name = options.name;
this.remarks = options.remarks || ''; this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0; this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
} }
} }
@@ -45,4 +47,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER, isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
}); });
+22 -1
View File
@@ -20,6 +20,8 @@ export enum NotificationMode {
'chronocat' = 'Chronocat', 'chronocat' = 'Chronocat',
'ntfy' = 'ntfy', 'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot', 'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
'wpush' = 'wpush',
} }
abstract class NotificationBaseInfo { abstract class NotificationBaseInfo {
@@ -161,6 +163,22 @@ export class WxPusherBotNotification extends NotificationBaseInfo {
public wxPusherBotUids = ''; public wxPusherBotUids = '';
} }
export class WxPusherSptNotification extends NotificationBaseInfo {
public wxPusherSptList = '';
}
export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkAppToken = '';
public openiLinkHubUrl = '';
public openiLinkContextToken = '';
}
export class WpushNotification extends NotificationBaseInfo {
public wpushApiKey = '';
public wpushChannel = '';
public wpushTopicCode = '';
}
export interface NotificationInfo export interface NotificationInfo
extends GoCqHttpBotNotification, extends GoCqHttpBotNotification,
GotifyNotification, GotifyNotification,
@@ -182,4 +200,7 @@ export interface NotificationInfo
ChronocatNotification, ChronocatNotification,
LarkNotification, LarkNotification,
NtfyNotification, NtfyNotification,
WxPusherBotNotification {} WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification,
WpushNotification {}
+1 -1
View File
@@ -24,7 +24,7 @@ export interface AppToken {
expiration: number; expiration: number;
} }
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system'; export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export interface AppInstance extends Model<App, App>, App {} export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', { export const AppModel = sequelize.define<AppInstance>('App', {
+81
View File
@@ -0,0 +1,81 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export enum InstanceStatus {
'running' = 0,
'finished' = 1,
'stopped' = 2,
'error' = 3,
}
export interface RunningInstanceAttributes {
id?: number;
cron_id: number;
pid?: number;
log_path?: string;
started_at: number;
finished_at?: number;
status: InstanceStatus;
exit_code?: number;
}
export class RunningInstance {
id?: number;
cron_id!: number;
pid?: number;
log_path?: string;
started_at!: number;
finished_at?: number;
status!: InstanceStatus;
exit_code?: number;
constructor(options: RunningInstanceAttributes) {
this.id = options.id;
this.cron_id = options.cron_id;
this.pid = options.pid;
this.log_path = options.log_path;
this.started_at = options.started_at;
this.finished_at = options.finished_at;
this.status = options.status;
this.exit_code = options.exit_code;
}
}
export interface RunningInstanceModel
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
'RunningInstance',
{
cron_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
pid: {
type: DataTypes.NUMBER,
allowNull: true,
},
log_path: {
type: DataTypes.STRING,
allowNull: true,
},
started_at: {
type: DataTypes.NUMBER,
allowNull: false,
},
finished_at: {
type: DataTypes.NUMBER,
allowNull: true,
},
status: {
type: DataTypes.NUMBER,
allowNull: false,
defaultValue: InstanceStatus.running,
},
exit_code: {
type: DataTypes.NUMBER,
allowNull: true,
},
},
);
+2
View File
@@ -2,11 +2,13 @@ export class SockMessage {
message?: string; message?: string;
type?: SockMessageType; type?: SockMessageType;
references?: number[]; references?: number[];
status?: number | string;
constructor(options: SockMessage) { constructor(options: SockMessage) {
this.type = options.type; this.type = options.type;
this.message = options.message; this.message = options.message;
this.references = options.references; this.references = options.references;
this.status = options.status;
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ export class Subscription {
pull_option?: pull_option?:
| { private_key: string } | { private_key: string }
| { username: string; password: string }; | { username: string; password: string };
pid?: number; pid?: number | null;
is_disabled?: 1 | 0; is_disabled?: 1 | 0;
log_path?: string; log_path?: string;
alias: string; alias: string;
+23
View File
@@ -28,9 +28,15 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency', 'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig', 'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig', 'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
} }
export const NotifyStatus = LoginStatus;
export type NotifyStatus = LoginStatus;
export interface SystemConfigInfo { export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
logRemoveFrequency?: number; logRemoveFrequency?: number;
cronConcurrency?: number; cronConcurrency?: number;
dependenceProxy?: string; dependenceProxy?: string;
@@ -39,6 +45,8 @@ export interface SystemConfigInfo {
linuxMirror?: string; linuxMirror?: string;
timezone?: string; timezone?: string;
globalSshKey?: string; globalSshKey?: string;
runningInstanceRetentionDays?: number;
cronStatRetentionDays?: number;
} }
export interface LoginLogInfo { export interface LoginLogInfo {
@@ -49,6 +57,17 @@ export interface LoginLogInfo {
status?: LoginStatus; status?: LoginStatus;
} }
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo { export interface TokenInfo {
value: string; value: string;
timestamp: number; timestamp: number;
@@ -71,16 +90,20 @@ export interface AuthInfo {
lastaddr: string; lastaddr: string;
platform: string; platform: string;
isTwoFactorChecking: boolean; isTwoFactorChecking: boolean;
twoFactorExpiresAt?: number;
lastTwoFactorStep?: number;
token: string; token: string;
tokens: Record<string, string | TokenInfo[]>; tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: string; avatar: string;
blockedIps?: string[];
} }
export type SystemModelInfo = SystemConfigInfo & export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> & Partial<NotificationInfo> &
LoginLogInfo & LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>; Partial<AuthInfo>;
export interface SystemInstance export interface SystemInstance
+9 -7
View File
@@ -6,23 +6,25 @@ import { Application } from 'express';
import linkDeps from './deps'; import linkDeps from './deps';
import initTask from './initTask'; import initTask from './initTask';
import initFile from './initFile'; import initFile from './initFile';
import { initializeTrustProxy } from '../shared/trustProxy';
export default async ({ app }: { app: Application }) => { export default async ({ app }: { app: Application }) => {
depInjectorLoader(); depInjectorLoader();
Logger.info('✌️ Dependency loaded'); Logger.info('[boot] Dependency loaded');
await linkDeps(); await linkDeps();
Logger.info('✌️ Link deps loaded'); Logger.info('[boot] Link deps loaded');
initFile(); await initFile();
Logger.info('✌️ Init file loaded'); Logger.info('[boot] Init file loaded');
await initData(); await initData();
Logger.info('✌️ Init data loaded'); Logger.info('[boot] Init data loaded');
initTask(); initTask();
Logger.info('✌️ Init task loaded'); Logger.info('[boot] Init task loaded');
expressLoader({ app }); expressLoader({ app });
Logger.info('✌️ Express loaded'); await initializeTrustProxy(app);
Logger.info('[boot] Express loaded');
}; };
+9 -35
View File
@@ -6,7 +6,10 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system'; import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription'; import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView'; import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data'; import { sequelize } from '../data';
import { migrateSchema } from '../shared/schemaMigrations';
export default async () => { export default async () => {
try { try {
@@ -17,43 +20,14 @@ export default async () => {
await EnvModel.sync(); await EnvModel.sync();
await SubscriptionModel.sync(); await SubscriptionModel.sync();
await CrontabViewModel.sync(); await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
// 初始化新增字段 await migrateSchema(sequelize);
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
];
for (const migration of migrations) { Logger.info('[boot] DB loaded');
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
Logger.info('✌️ DB loaded');
} catch (error) { } catch (error) {
Logger.error('✌️ DB load failed', error); Logger.error('[boot] DB load failed', error);
throw error;
} }
}; };
+46 -26
View File
@@ -1,22 +1,9 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import os from 'os'; import os from 'os';
import chokidar from 'chokidar';
import config from '../config/index'; import config from '../config/index';
import Logger from './logger'; import Logger from './logger';
async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
const source = path.join(config.rootPath, src);
try {
const stats = await fs.lstat(target);
if (!stats) {
await fs.symlink(source, target, 'dir');
}
} catch (error) { }
}
async function linkCommand() { async function linkCommand() {
const homeDir = os.homedir(); const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin'); let userBinDir = path.join(homeDir, 'bin');
@@ -30,6 +17,50 @@ async function linkCommand() {
} }
async function linkCommandToDir(commandDir: string) { 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 = [ const linkShell = [
{ {
src: 'update.sh', src: 'update.sh',
@@ -52,24 +83,13 @@ async function linkCommandToDir(commandDir: string) {
if (stats) { if (stats) {
await fs.unlink(tmpTarget); await fs.unlink(tmpTarget);
} }
} catch (error) { } } catch (error) {}
await fs.symlink(source, tmpTarget); await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target); await fs.rename(tmpTarget, target);
} }
} }
export default async (src: string = 'deps') => { export default async () => {
await linkCommand(); await linkCommand();
await linkToNodeModule(src);
const source = path.join(config.rootPath, src);
const watcher = chokidar.watch(source, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
});
watcher
.on('add', () => linkToNodeModule(src))
.on('change', () => linkToNodeModule(src));
}; };
+98 -34
View File
@@ -6,16 +6,42 @@ import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt'; import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util'; import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite'; import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import validationErrors from '../middlewares/validationErrors';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth'; import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
import { AuthInfo } from '../data/system';
import path from 'path'; 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 }) => { export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback'); // 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', resolveTrustProxy());
app.use(cors()); app.use(cors());
// 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 // Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness // This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) { if (config.baseUrl) {
@@ -23,7 +49,26 @@ export default ({ app }: { app: Application }) => {
} }
app.get(`${config.api.prefix}/env.js`, serveEnv); 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.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
@@ -36,7 +81,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret, secret: config.jwt.secret,
algorithms: ['HS384'], algorithms: ['HS384'],
}).unless({ }).unless({
path: [...config.apiWhiteList, /^\/(?!api\/).*/], path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}), }),
); );
@@ -51,31 +96,44 @@ export default ({ app }: { app: Application }) => {
}); });
app.use(async (req: Request, res, next) => { app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next(); return next();
} }
const headerToken = getToken(req); const headerToken = getToken(req);
if (req.path.startsWith('/open/')) { if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps(); const apps = await shareStore.getApps();
const doc = apps?.filter((x) => const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken), x.tokens?.find((y) => y.value === headerToken),
)?.[0]; )?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) { if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken); const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/); const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1]; const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) && if (!doc.scopes.includes(key as AppScope)) {
currentToken && const err = new UnauthorizedError('credentials_bad_scheme', {
currentToken.expiration >= Math.round(Date.now() / 1000) message: t('暂无权限'),
) { });
return next(); return next(err);
} }
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
return next();
} }
} }
const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`; const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if ( if (
!headerToken && !headerToken &&
originPath && originPath &&
@@ -85,36 +143,34 @@ export default ({ app }: { app: Application }) => {
} }
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) { if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
return next(); return next();
} }
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
? 'jwt malformed'
: 'No authorization token was found';
const err = new UnauthorizedError(errorCode, { message: errorMessage }); const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err); next(err);
}); });
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next(); return next();
} }
const authInfo = const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
let isInitialized = true; let isInitialized = !isDefaultAuthInfo(authInfo);
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
if (isInitialized) { if (isInitialized) {
return res.send({ code: 450, message: '未知错误' }); return res.send({ code: 450, message: t('未知错误') });
} else { } else {
return next(); return next();
} }
@@ -123,7 +179,14 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1')); app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes()); 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'); const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => { res.sendFile(indexPath, (err) => {
if (err) { if (err) {
@@ -134,7 +197,8 @@ export default ({ app }: { app: Application }) => {
}); });
}); });
app.use(errors()); app.use(validationErrors);
app.use(uniqueConstraintError);
app.use( app.use(
( (
+33
View File
@@ -0,0 +1,33 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): 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,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+35 -21
View File
@@ -13,11 +13,13 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system'; import SystemService from '../services/system';
import UserService from '../services/user'; import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises'; import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, safeJSONParse } from '../config/util'; import { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open'; import OpenService from '../services/open';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import Logger from './logger'; 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';
export default async () => { export default async () => {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
@@ -28,19 +30,7 @@ export default async () => {
const openService = Container.get(OpenService); const openService = Container.get(OpenService);
// 初始化增加系统配置 // 初始化增加系统配置
let systemApp = ( await openService.initializeSystemApp();
await AppModel.findOne({
where: { name: 'system' },
})
)?.get({ plain: true });
if (!systemApp) {
systemApp = await AppModel.create({
name: 'system',
scopes: ['crons', 'system'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
}
const [systemConfig] = await SystemModel.findOrCreate({ const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig }, where: { type: AuthDataType.systemConfig },
}); });
@@ -50,7 +40,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({ const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig }, where: { type: AuthDataType.authConfig },
}); });
if (!authConfig?.info) { if (!authConfig?.info || isDemoEnv()) {
let authInfo = { let authInfo = {
username: 'admin', username: 'admin',
password: 'admin', password: 'admin',
@@ -134,6 +124,16 @@ export default async () => {
// 初始化更新所有任务状态为空闲 // 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} }); await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{
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 } },
);
// 初始化时执行一次所有的 ql repo 任务 // 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({ CrontabModel.findAll({
where: { where: {
@@ -210,14 +210,28 @@ export default async () => {
} }
}); });
// 初始化语言(必须在 autosave_crontab 之前)
const lang = systemConfig.info?.lang || systemLang();
setLang(lang);
// 确保 lang_env.sh 存在
try {
const langEnvExist = await fileExist(config.langEnvFile);
if (!langEnvExist) {
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
}
} catch { }
// 初始化保存一次ck和定时任务数据 // 初始化保存一次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(); await envService.set_envs();
const authInfo = await userService.getAuthInfo(); const authInfo = await userService.getAuthInfo();
const apps = await openService.findApps();
await shareStore.updateAuthInfo(authInfo); await shareStore.updateAuthInfo(authInfo);
if (apps?.length) { await openService.refreshApps();
await shareStore.updateApps(apps);
}
}; };
+3 -1
View File
@@ -20,6 +20,7 @@ const uploadPath = path.join(dataPath, 'upload/');
const bakPath = path.join(dataPath, 'bak/'); const bakPath = path.join(dataPath, 'bak/');
const samplePath = path.join(rootPath, 'sample/'); const samplePath = path.join(rootPath, 'sample/');
const tmpPath = path.join(logPath, '.tmp/'); const tmpPath = path.join(logPath, '.tmp/');
const rootTmpPath = path.join(rootPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh'); const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
@@ -44,6 +45,7 @@ const directories = [
preloadPath, preloadPath,
logPath, logPath,
tmpPath, tmpPath,
rootTmpPath,
uploadPath, uploadPath,
sshPath, sshPath,
bakPath, bakPath,
@@ -123,5 +125,5 @@ export default async () => {
} }
} }
Logger.info('✌️ Init file down'); Logger.info('[boot] Init file down');
}; };
+13 -3
View File
@@ -6,6 +6,9 @@ import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
import { t } from '../shared/i18n';
import Logger from './logger';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
export default async () => { export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
@@ -23,9 +26,10 @@ export default async () => {
if (await fileExist(tokenFile)) { if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`; tokenCommand = `node ${tokenFile}`;
} }
tokenCommand += ' --renew';
const cron = { const cron = {
id: NaN, id: NaN,
name: '生成token', name: t('生成token'),
command: tokenCommand, command: tokenCommand,
runOrigin: 'system', runOrigin: 'system',
} as ScheduleTaskType; } as ScheduleTaskType;
@@ -44,7 +48,7 @@ export default async () => {
if (data.info.logRemoveFrequency) { if (data.info.logRemoveFrequency) {
const rmlogCron = { const rmlogCron = {
id: data.id as number, id: data.id as number,
name: '删除日志', name: t('删除日志'),
command: `ql rmlog ${data.info.logRemoveFrequency}`, command: `ql rmlog ${data.info.logRemoveFrequency}`,
runOrigin: 'system' as const, runOrigin: 'system' as const,
}; };
@@ -69,6 +73,12 @@ export default async () => {
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
const subs = await subscriptionService.list(); const subs = await subscriptionService.list();
for (const sub of subs) { 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(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
}
} }
}; };
+1 -1
View File
@@ -4,7 +4,7 @@ import Sock from './sock';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
await Sock({ server }); await Sock({ server });
Logger.info('✌️ Sock loaded'); Logger.info('[boot] Sock loaded');
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error); Logger.error('Uncaught exception:', error);
+66 -8
View File
@@ -1,4 +1,4 @@
import sockJs from 'sockjs'; import sockJs, { Connection } from 'sockjs';
import { Server } from 'http'; import { Server } from 'http';
import { Container } from 'typedi'; import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
@@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth';
import config from '../config'; import config from '../config';
export default async ({ server }: { server: Server }) => { 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 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) => { echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) { if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404'); 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 platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform)) { if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
sockService.addClient(conn); sockService.addClient(conn);
sessions.set(conn, { token: headerToken, platform });
if (!sessionTimer) {
sessionTimer = setInterval(checkSessions, 1000);
sessionTimer.unref();
}
conn.on('data', (message) => { conn.on('data', (message) => {
conn.write(message); conn.write(message);
}); });
conn.on('close', function () {
sockService.removeClient(conn);
});
return; return;
} }
+17 -49
View File
@@ -3,46 +3,34 @@ import Logger from '../loaders/logger';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import { metricsService } from '../services/metrics'; import { metricsService } from '../services/metrics';
interface RequestMetrics { const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
method: string; const HTTP_METRIC_SAMPLE_INTERVAL = 10;
path: string; let requestSampleOffset = 0;
duration: number;
statusCode: number;
timestamp: number;
platform?: string;
}
const requestMetrics: RequestMetrics[] = [];
export const monitoringMiddleware = ( export const monitoringMiddleware = (
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
return next();
}
const start = performance.now(); const start = performance.now();
const originalEnd = res.end; const originalEnd = res.end;
res.end = function (chunk?: any, encoding?: any, cb?: any) { res.end = function (chunk?: any, encoding?: any, cb?: any) {
const duration = performance.now() - start; const duration = performance.now() - start;
const metric: RequestMetrics = { const shouldSample = requestSampleOffset === 0;
method: req.method, requestSampleOffset =
path: req.path, (requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
duration, if (shouldSample) {
statusCode: res.statusCode, metricsService.record('http_request', duration, {
timestamp: Date.now(), method: req.method,
platform: req.platform, path: req.path,
}; statusCode: res.statusCode.toString(),
...(req.platform && { 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();
} }
if (duration > 1000) { if (duration > 1000) {
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
next(); 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;
+18
View File
@@ -0,0 +1,18 @@
import { errors, isCelebrateError } from 'celebrate';
import { ErrorRequestHandler } from 'express';
import { t } from '../shared/i18n';
const respond = errors();
// Schemas are compiled once at startup; resolve their messages per request so
// changing the system language also changes errors from those existing schemas.
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
if (isCelebrateError(error)) {
for (const detail of error.details.values()) {
detail.message = detail.details.map((item) => t(item.message)).join('. ');
}
}
return respond(error, req, res, next);
};
export default validationErrors;
+18
View File
@@ -151,6 +151,8 @@ enum NotificationMode {
chronocat = 18; chronocat = 18;
ntfy = 19; ntfy = 19;
wxPusherBot = 20; wxPusherBot = 20;
wxPusherSpt = 21;
wpush = 22;
} }
message NotificationInfo { message NotificationInfo {
@@ -244,6 +246,12 @@ message NotificationInfo {
optional string wxPusherBotAppToken = 66; optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67; optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68; optional string wxPusherBotUids = 68;
optional string wxPusherSptList = 70;
optional string wpushApiKey = 71;
optional string wpushChannel = 72;
optional string wpushTopicCode = 73;
} }
message SystemNotifyRequest { message SystemNotifyRequest {
@@ -252,6 +260,15 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3; optional NotificationInfo notificationInfo = 3;
} }
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api { service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -263,6 +280,7 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {} rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+259 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v6.31.1
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -43,6 +43,8 @@ export enum NotificationMode {
chronocat = 18, chronocat = 18,
ntfy = 19, ntfy = 19,
wxPusherBot = 20, wxPusherBot = 20,
wxPusherSpt = 21,
wpush = 22,
UNRECOGNIZED = -1, UNRECOGNIZED = -1,
} }
@@ -111,6 +113,12 @@ export function notificationModeFromJSON(object: any): NotificationMode {
case 20: case 20:
case "wxPusherBot": case "wxPusherBot":
return NotificationMode.wxPusherBot; return NotificationMode.wxPusherBot;
case 21:
case "wxPusherSpt":
return NotificationMode.wxPusherSpt;
case 22:
case "wpush":
return NotificationMode.wpush;
case -1: case -1:
case "UNRECOGNIZED": case "UNRECOGNIZED":
default: default:
@@ -162,6 +170,10 @@ export function notificationModeToJSON(object: NotificationMode): string {
return "ntfy"; return "ntfy";
case NotificationMode.wxPusherBot: case NotificationMode.wxPusherBot:
return "wxPusherBot"; return "wxPusherBot";
case NotificationMode.wxPusherSpt:
return "wxPusherSpt";
case NotificationMode.wpush:
return "wpush";
case NotificationMode.UNRECOGNIZED: case NotificationMode.UNRECOGNIZED:
default: default:
return "UNRECOGNIZED"; return "UNRECOGNIZED";
@@ -393,6 +405,10 @@ export interface NotificationInfo {
wxPusherBotAppToken?: string | undefined; wxPusherBotAppToken?: string | undefined;
wxPusherBotTopicIds?: string | undefined; wxPusherBotTopicIds?: string | undefined;
wxPusherBotUids?: string | undefined; wxPusherBotUids?: string | undefined;
wxPusherSptList?: string | undefined;
wpushApiKey?: string | undefined;
wpushChannel?: string | undefined;
wpushTopicCode?: string | undefined;
} }
export interface SystemNotifyRequest { export interface SystemNotifyRequest {
@@ -401,6 +417,15 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined; notificationInfo?: NotificationInfo | undefined;
} }
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem { function createBaseEnvItem(): EnvItem {
return { return {
id: undefined, id: undefined,
@@ -2959,6 +2984,10 @@ function createBaseNotificationInfo(): NotificationInfo {
wxPusherBotAppToken: undefined, wxPusherBotAppToken: undefined,
wxPusherBotTopicIds: undefined, wxPusherBotTopicIds: undefined,
wxPusherBotUids: undefined, wxPusherBotUids: undefined,
wxPusherSptList: undefined,
wpushApiKey: undefined,
wpushChannel: undefined,
wpushTopicCode: undefined,
}; };
} }
@@ -3171,6 +3200,18 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherBotUids !== undefined) { if (message.wxPusherBotUids !== undefined) {
writer.uint32(546).string(message.wxPusherBotUids); writer.uint32(546).string(message.wxPusherBotUids);
} }
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; return writer;
}, },
@@ -3733,6 +3774,38 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherBotUids = reader.string(); message.wxPusherBotUids = reader.string();
continue; continue;
} }
case 70: {
if (tag !== 562) {
break;
}
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) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@@ -3825,6 +3898,10 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
? globalThis.String(object.wxPusherBotTopicIds) ? globalThis.String(object.wxPusherBotTopicIds)
: undefined, : undefined,
wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : 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,
}; };
}, },
@@ -4037,6 +4114,18 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherBotUids !== undefined) { if (message.wxPusherBotUids !== undefined) {
obj.wxPusherBotUids = message.wxPusherBotUids; obj.wxPusherBotUids = message.wxPusherBotUids;
} }
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; return obj;
}, },
@@ -4114,6 +4203,10 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined; message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined;
message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined; message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined;
message.wxPusherBotUids = object.wxPusherBotUids ?? 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; return message;
}, },
}; };
@@ -4212,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
}, },
}; };
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService; export type ApiService = typeof ApiService;
export const ApiService = { export const ApiService = {
getEnvs: { getEnvs: {
@@ -4304,6 +4537,15 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), responseDeserialize: (value: Buffer) => Response.decode(value),
}, },
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: { getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail", path: "/com.ql.api.Api/GetCronDetail",
requestStream: false, requestStream: false,
@@ -4398,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>; updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>; getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>; systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>; getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4560,6 +4803,21 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail( getCronDetail(
request: CronDetailRequest, request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void, callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+8 -1
View File
@@ -5,6 +5,7 @@ package com.ql.cron;
service Cron { service Cron {
rpc addCron(AddCronRequest) returns (AddCronResponse); rpc addCron(AddCronRequest) returns (AddCronResponse);
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse); rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
} }
message ISchedule { string schedule = 1; } message ISchedule { string schedule = 1; }
@@ -17,10 +18,16 @@ message ICron {
string name = 5; string name = 5;
} }
message AddCronRequest { repeated ICron crons = 1; } message AddCronRequest {
repeated ICron crons = 1;
bool replace = 2;
}
message AddCronResponse {} message AddCronResponse {}
message DeleteCronRequest { repeated string ids = 1; } message DeleteCronRequest { repeated string ids = 1; }
message DeleteCronResponse {} 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. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v5.28.3
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
@@ -35,6 +35,7 @@ export interface ICron {
export interface AddCronRequest { export interface AddCronRequest {
crons: ICron[]; crons: ICron[];
replace: boolean;
} }
export interface AddCronResponse { export interface AddCronResponse {
@@ -47,6 +48,11 @@ export interface DeleteCronRequest {
export interface DeleteCronResponse { export interface DeleteCronResponse {
} }
/** Zero restores the default limit. */
export interface SetConcurrencyRequest {
concurrency: number;
}
function createBaseISchedule(): ISchedule { function createBaseISchedule(): ISchedule {
return { schedule: "" }; return { schedule: "" };
} }
@@ -232,7 +238,7 @@ export const ICron: MessageFns<ICron> = {
}; };
function createBaseAddCronRequest(): AddCronRequest { function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] }; return { crons: [], replace: false };
} }
export const AddCronRequest: MessageFns<AddCronRequest> = { export const AddCronRequest: MessageFns<AddCronRequest> = {
@@ -240,6 +246,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
for (const v of message.crons) { for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).join(); ICron.encode(v!, writer.uint32(10).fork()).join();
} }
if (message.replace !== false) {
writer.uint32(16).bool(message.replace);
}
return writer; return writer;
}, },
@@ -258,6 +267,14 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
message.crons.push(ICron.decode(reader, reader.uint32())); message.crons.push(ICron.decode(reader, reader.uint32()));
continue; continue;
} }
case 2: {
if (tag !== 16) {
break;
}
message.replace = reader.bool();
continue;
}
} }
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@@ -268,7 +285,10 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
}, },
fromJSON(object: any): 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 { toJSON(message: AddCronRequest): unknown {
@@ -276,6 +296,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
if (message.crons?.length) { if (message.crons?.length) {
obj.crons = message.crons.map((e) => ICron.toJSON(e)); obj.crons = message.crons.map((e) => ICron.toJSON(e));
} }
if (message.replace !== false) {
obj.replace = message.replace;
}
return obj; return obj;
}, },
@@ -285,6 +308,7 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest { fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
const message = createBaseAddCronRequest(); const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || []; message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
message.replace = object.replace ?? false;
return message; 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 type CronService = typeof CronService;
export const CronService = { export const CronService = {
addCron: { addCron: {
@@ -453,11 +535,21 @@ export const CronService = {
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()), responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value), 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; } as const;
export interface CronServer extends UntypedServiceImplementation { export interface CronServer extends UntypedServiceImplementation {
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>; addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>; delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
} }
export interface CronClient extends Client { export interface CronClient extends Client {
@@ -491,6 +583,21 @@ export interface CronClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void, callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall; ): 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 { 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. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.21.12 // protoc v3.17.3
// source: back/protos/health.proto // source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
+94 -35
View File
@@ -1,54 +1,113 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron'; 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 { scheduleStacks } from './data';
import { runCron } from '../shared/runCron'; import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { t, tf } from '../shared/i18n';
// Validate the entire batch before replacing any existing jobs.
const isValidCronField = (cron: string): boolean => {
return isValidCronSchedule(cron);
};
const addCron = ( const addCron = (
call: ServerUnaryCall<AddCronRequest, AddCronResponse>, call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>, callback: sendUnaryData<AddCronResponse>,
) => { ) => {
for (const item of call.request.crons) { // ===== 第一遍:预校验所有 cron 表达式 =====
const { id, schedule, command, extra_schedules, name } = item; const validationErrors: string[] = [];
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
Logger.info( for (const item of call.request.crons) {
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', const { id, schedule, extra_schedules } = item;
id,
name, if (!isValidCronField(schedule)) {
schedule, validationErrors.push(
command, tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
); );
}
if (extra_schedules?.length) { if (extra_schedules?.length) {
extra_schedules.forEach((x) => { extra_schedules.forEach((x) => {
Logger.info( if (!isValidCronField(x.schedule)) {
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', validationErrors.push(
id, tf(
name, '任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
x.schedule, String(id),
command, x.schedule,
); ),
);
}
}); });
} }
}
scheduleStacks.set(id, [ if (validationErrors.length > 0) {
nodeSchedule.scheduleJob(id, schedule, async () => { const details = validationErrors.join('\n');
Logger.info(`[schedule][准备运行任务] 命令: ${command}`); const err: any = new Error(details);
runCron(command, item); err.code = status.INVALID_ARGUMENT;
}), err.details = details;
...(extra_schedules?.length callback(err, null);
? extra_schedules.map((x) => return;
nodeSchedule.scheduleJob(id, x.schedule, async () => { }
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item); // 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(t('[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;
}
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(
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
id,
jobs.length,
);
} }
callback(null, null); callback(null, null);
+34 -7
View File
@@ -16,6 +16,7 @@ import {
MoveEnvRequest, MoveEnvRequest,
Response, Response,
SystemNotifyRequest, SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest, UpdateEnvNamesRequest,
UpdateEnvRequest, UpdateEnvRequest,
} from '../protos/api'; } from '../protos/api';
@@ -38,6 +39,7 @@ import {
RunCronsRequest, RunCronsRequest,
} from '../protos/api'; } from '../protos/api';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import { Model } from 'sequelize';
Container.set('logger', LoggerInstance); Container.set('logger', LoggerInstance);
@@ -237,7 +239,8 @@ export const systemNotify = async (
const data = await systemService.notify({ const data = await systemService.notify({
title: call.request.title, title: call.request.title,
content: call.request.content, content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo, notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
}); });
callback(null, data); callback(null, data);
} catch (e: any) { } catch (e: any) {
@@ -245,15 +248,39 @@ export const systemNotify = async (
} }
}; };
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => { const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return 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 { return {
...data, ...cron,
sub_id: data.sub_id ?? undefined, labels: cron.labels ?? [],
extra_schedules: data.extra_schedules ?? undefined, sub_id: cron.sub_id ?? undefined,
pid: data.pid ?? undefined, extra_schedules: cron.extra_schedules ?? [],
task_before: data.task_before ?? undefined, pid: cron.pid ?? undefined,
task_after: data.task_after ?? undefined, task_before: cron.task_before ?? undefined,
task_after: cron.task_after ?? undefined,
}; };
}; };
+108 -16
View File
@@ -1,4 +1,4 @@
import { credentials } from '@grpc/grpc-js'; import { credentials, status, Metadata } from '@grpc/grpc-js';
import { import {
AddCronRequest, AddCronRequest,
AddCronResponse, AddCronResponse,
@@ -7,30 +7,122 @@ import {
DeleteCronResponse, DeleteCronResponse,
} from '../protos/cron'; } from '../protos/cron';
import config from '../config'; import config from '../config';
import { getGrpcCerts } from '../config/grpcCerts';
import { HealthService } from '../protos/health';
import { SchedulerReadiness } from '../shared/schedulerReadiness';
class Client { class Client {
private client = new CronClient( readonly readiness = new SchedulerReadiness(() => this.probe());
`0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> { private async waitForReady(timeoutMs: number) {
return new Promise((resolve, reject) => { try {
this.client.addCron({ crons: request }, (err, res) => { await new Promise<void>((resolve, reject) => {
if (err) { this.client.waitForReady(Date.now() + timeoutMs, (err) =>
reject(err); err ? reject(err) : resolve()
} );
resolve(res);
}); });
} 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 {
if (!this._client) {
const tlsConfig = getGrpcCerts()!;
this._client = new CronClient(
`localhost:${config.grpcPort}`,
credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
),
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
async setConcurrency(concurrency: number): Promise<void> {
await this.waitForReady(2000);
return new Promise((resolve, reject) => {
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) => { 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) { 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); 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'; 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(); export const intervalSchedule = new ToadScheduler();
+19 -5
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron'; import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
@@ -9,11 +10,24 @@ const delCron = (
) => { ) => {
for (const id of call.request.ids) { for (const id of call.request.ids) {
if (scheduleStacks.has(id)) { if (scheduleStacks.has(id)) {
Logger.info( Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
'[schedule][取消定时任务] 任务ID: %s', // 防御性过滤历史调度栈中的空任务,
id, // 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
); // 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.forEach(x => x.cancel()); scheduleStacks
.get(id)
?.filter((x) => x != null)
.forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id); scheduleStacks.delete(id);
} }
} }
+91 -12
View File
@@ -1,28 +1,107 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; 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 { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import config from '../config'; 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 ( const check = async (
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>, call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
callback: sendUnaryData<HealthCheckResponse>, callback: sendUnaryData<HealthCheckResponse>,
) => { ) => {
switch (call.request.service) { switch (call.request.service) {
case 'cron': // Local scheduler liveness only: never call HTTP from this probe.
const res = await promiseExec( case 'scheduler':
`curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`, return callback(null, { status: 1 });
); case 'cron': {
const healthUrl = `http://localhost:${config.port}${
if (res.includes('200')) { config.baseUrl || ''
return callback(null, { status: 1 }); }/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( const systemLog = await getRecentSystemLog();
`tail -n 300 ~/.pm2/logs/qinglong-error.log`, 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( 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: default:
return callback(null, { status: 1 }); 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);
}
}
+13 -17
View File
@@ -1,9 +1,10 @@
import { Service, Inject } from 'typedi'; import { Service } from 'typedi';
import path, { join } from 'path';
import config from '../config'; import config from '../config';
import { getFileContentByName } from '../config/util'; import { getFileContentByName } from '../config/util';
import { t } from '../shared/i18n';
import { Response } from 'express'; import { Response } from 'express';
import { request } from 'undici'; import { request } from 'undici';
import { resolveFileAccess } from '../shared/fileAccess';
@Service() @Service()
export default class ConfigService { export default class ConfigService {
@@ -11,19 +12,16 @@ export default class ConfigService {
public async getFile(filePath: string, res: Response) { public async getFile(filePath: string, res: Response) {
let content = ''; let content = '';
const avaliablePath = [config.rootPath, config.configPath].map((x) => if (!filePath || filePath.startsWith('data/scripts/')) {
path.resolve(x, filePath), return res.send({ code: 403, message: t('文件无法访问') });
}
const resolved = resolveFileAccess(
config.configPath,
[filePath],
config.blackFileList,
); );
if (!resolved) {
if ( return res.send({ code: 403, message: t('文件无法访问') });
config.blackFileList.includes(filePath) ||
avaliablePath.every(
(x) =>
!x.startsWith(config.scriptPath) && !x.startsWith(config.configPath),
) ||
!filePath
) {
return res.send({ code: 403, message: '文件无法访问' });
} }
if (filePath.startsWith('sample/')) { if (filePath.startsWith('sample/')) {
@@ -31,10 +29,8 @@ export default class ConfigService {
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
); );
content = await res.body.text(); content = await res.body.text();
} else if (filePath.startsWith('data/scripts/')) {
content = await getFileContentByName(join(config.rootPath, filePath));
} else { } else {
content = await getFileContentByName(join(config.configPath, filePath)); content = await getFileContentByName(resolved);
} }
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
+564 -193
View File
@@ -1,15 +1,24 @@
import { randomUUID } from 'crypto';
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
import {
withSchedulerMutation,
schedulerRegistrationError,
} from '../shared/schedulerMutationLock';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import { resolveLogPath } from '../shared/logPath';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
killTask, killTask,
killAllTasks,
getUniqPath, getUniqPath,
safeJSONParse, safeJSONParse,
isDemoEnv, isDemoEnv,
@@ -24,8 +33,13 @@ import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager'; 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() @Service()
export default class CronService { export default class CronService {
@@ -33,10 +47,40 @@ export default class CronService {
private isNodeCron(cron: Crontab) { private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron; const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) { const fields = schedule?.trim().split(/\s+/) || [];
return true; // 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' {
const env = process.env.QL_SCHEDULER;
if (env === 'system') return 'system';
if (env === 'node') return 'node';
try {
execSync('which crond', { stdio: 'ignore' });
return 'system';
} catch {
return 'node';
} }
return false; }
private shouldUseCronClient(cron: Crontab): boolean {
if (this.schedulerMode === 'node') {
return !this.isSpecialSchedule(cron.schedule);
}
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
} }
private isOnceSchedule(schedule?: string) { private isOnceSchedule(schedule?: string) {
@@ -71,29 +115,45 @@ export default class CronService {
} }
public async create(payload: Crontab): Promise<Crontab> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); return withSchedulerMutation(async () => {
tab.saved = false; const tab = new Crontab(payload);
tab.log_name = await this.getLogName(tab); tab.saved = false;
const doc = await this.insert(tab); tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (isDemoEnv()) { 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 schedulerRegistrationError(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
}
await this.setCrontab();
return doc; return doc;
} });
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
}
await this.setCrontab();
return doc;
} }
public async insert(payload: Crontab): Promise<Crontab> { public async insert(payload: Crontab): Promise<Crontab> {
@@ -101,34 +161,68 @@ export default class CronService {
} }
public async update(payload: Partial<Crontab>): Promise<Crontab> { public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id }); return withSchedulerMutation(async () => {
const tab = new Crontab({ ...doc, ...payload }); const doc = await this.getDb({ id: payload.id });
tab.saved = false; const tab = new Crontab({ ...doc, ...payload });
tab.log_name = await this.getLogName(tab); tab.saved = false;
const newDoc = await this.updateDb(tab); tab.log_name = await this.getLogName(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
return await this.updateDb(tab);
}
if (doc.isDisabled === 1 || isDemoEnv()) { // Keep the DB snapshot unchanged if deletion has an uncertain outcome.
return newDoc; // Recovery uses that snapshot after this mutation releases its lock.
}
if (this.isNodeCron(doc)) {
await cronClient.delCron([String(doc.id)]); await cronClient.delCron([String(doc.id)]);
} const newDoc = await this.updateDb(tab);
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) { if (this.shouldUseCronClient(newDoc)) {
await cronClient.addCron([ try {
{ await cronClient.addCron([
name: doc.name || '', {
id: String(newDoc.id), name: doc.name || '',
schedule: newDoc.schedule!, id: String(newDoc.id),
command: this.makeCommand(newDoc), schedule: newDoc.schedule!,
extra_schedules: newDoc.extra_schedules || [], 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(); await this.setCrontab();
return newDoc; return newDoc;
});
} }
public async updateDb(payload: Crontab): Promise<Crontab> { public async updateDb(payload: Crontab): Promise<Crontab> {
@@ -143,6 +237,7 @@ export default class CronService {
log_path, log_path,
last_running_time = 0, last_running_time = 0,
last_execution_time = 0, last_execution_time = 0,
exit_code,
}: { }: {
ids: number[]; ids: number[];
status: CrontabStatus; status: CrontabStatus;
@@ -150,7 +245,11 @@ export default class CronService {
log_path: string; log_path: string;
last_running_time: number; last_running_time: number;
last_execution_time: number; last_execution_time: number;
exit_code?: number;
}) { }) {
if (log_path && log_path !== '/dev/null') {
resolveLogPath(config.logPath, log_path);
}
let options: any = { let options: any = {
status, status,
pid, pid,
@@ -172,6 +271,40 @@ export default class CronService {
if (status === CrontabStatus.idle && log_path !== cron.log_path) { if (status === CrontabStatus.idle && log_path !== cron.log_path) {
options = omit(options, ['status', 'log_path', 'pid']); options = omit(options, ['status', 'log_path', 'pid']);
} }
// Manage RunningInstance records for status transitions from shell scripts
if (status === CrontabStatus.running) {
// Create a new running instance record
await RunningInstanceModel.create({
cron_id: id,
pid: pid || undefined,
log_path: log_path || undefined,
started_at: last_execution_time || dayjs().unix(),
status: InstanceStatus.running,
});
} else if (status === CrontabStatus.idle) {
// Mark the matching running instance as finished
const finishedAt = dayjs().unix();
const instanceStatus =
exit_code !== undefined && exit_code !== null && exit_code !== 0
? InstanceStatus.error
: InstanceStatus.finished;
await RunningInstanceModel.update(
{
finished_at: finishedAt,
status: instanceStatus,
exit_code: exit_code ?? undefined,
},
{
where: {
cron_id: id,
pid: pid || undefined,
status: InstanceStatus.running,
},
},
);
}
await CrontabModel.update( await CrontabModel.update(
{ ...pickBy(options, (v) => v === 0 || !!v) }, { ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } }, { where: { id } },
@@ -180,9 +313,11 @@ export default class CronService {
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } }); return withSchedulerMutation(async () => {
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.setCrontab(); await CrontabModel.destroy({ where: { id: ids } });
await this.setCrontab();
});
} }
public async pin(ids: number[]) { public async pin(ids: number[]) {
@@ -350,7 +485,7 @@ export default class CronService {
} }
private formatFilterQuery(query: any, filterQuery: any) { private formatFilterQuery(query: any, filterQuery: any) {
if (filterQuery) { if (!isEmpty(filterQuery)) {
if (!query[Op.and]) { if (!query[Op.and]) {
query[Op.and] = []; query[Op.and] = [];
} }
@@ -453,175 +588,385 @@ export default class CronService {
} }
public async run(ids: number[]) { public async run(ids: number[]) {
const queuedToken = randomUUID();
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.queued }, { status: CrontabStatus.queued, queued_token: queuedToken },
{ where: { id: ids } }, { where: { id: ids } },
); );
ids.forEach((id) => { ids.forEach((id) => {
this.runSingle(id); this.runSingle(id, queuedToken);
}); });
} }
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } }); 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) { for (const doc of docs) {
// Kill all running instances of this task if (doc.status === CrontabStatus.queued) {
try { const [cancelled] = await CrontabModel.update(
if (doc.pid) { { status: CrontabStatus.idle, pid: null, queued_token: null } as any,
await killTask(doc.pid); {
} where: {
const command = doc.command.replace(/\s+/g, ' ').trim(); id: doc.id,
await killAllTasks(command); status: CrontabStatus.queued,
this.logger.info( [Op.and]: [
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, 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) { } catch (error) {
this.logger.error( this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`, t('[panel][停止任务失败] PID: %s, 错误: %s'),
pid,
asError(error).message
); );
} }
} }
const stoppedIds = stoppingInstances
await CrontabModel.update( .filter((instance) => instance.pid && stopped.has(instance.pid))
{ status: CrontabStatus.idle, pid: undefined }, .map((instance) => instance.id!);
{ where: { id: ids } }, 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 }),
],
},
}
);
}
} }
private async runSingle(cronId: number): Promise<number | void> { public async stopInstance(instanceId: number) {
return taskLimit.manualRunWithCronLimit(() => { const instance = await RunningInstanceModel.findOne({
return new Promise(async (resolve: any) => { where: { id: instanceId, status: InstanceStatus.running },
const cron = await this.getDb({ id: cronId }); });
const params = { if (!instance) {
name: cron.name, return { code: 400, message: t('实例不存在或已停止') };
command: cron.command, }
schedule: cron.schedule, if (instance.pid) {
extra_schedules: cron.extra_schedules, try {
}; await killTask(instance.pid);
if (cron.status !== CrontabStatus.queued) { } catch (error) {
resolve(params); this.logger.error(
return; t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
}
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
}
}
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
{ where: { id: instanceId } },
);
let { id, command, log_name } = cron; // Check if there are still other running instances for this cron
const otherRunning = await RunningInstanceModel.count({
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
});
if (otherRunning === 0) {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: instance.cron_id } },
);
}
return { code: 200, message: t('实例已停止') };
}
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 });
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 = const uniqPath =
log_name === '/dev/null' || !log_name log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`) ? await getUniqPath(command, `${id}`)
: log_name; : log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); logPath = `${uniqPath}/${logTime}.log`;
await fs.mkdir(logDirPath, { recursive: true }); absolutePath = resolveFileAccess(config.logPath, [logPath]);
const logPath = `${uniqPath}/${logTime}.log`; if (!absolutePath)
const absolutePath = path.resolve(config.logPath, `${logPath}`); throw new Error('Log path is outside the log directory');
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
const outputPath = absolutePath;
const cp = spawn( const cp = spawn(
`real_log_path=${logPath} no_delay=true ${this.makeCommand( `real_log_path=${logPath} no_delay=true ${this.makeCommand(
cron, cron,
true, true
)}`, )}`,
{ shell: '/bin/bash' }, { shell: '/bin/bash' }
); );
// Install observers before the first await: very short children may already exit.
await CrontabModel.update( const { completed } = observeChildProcess(cp, {
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath }, onStart: async () => {
{ where: { id } }, try {
); const [count] = await CrontabModel.update(
cp.stdout.on('data', async (data) => { {
await logStreamManager.write(absolutePath, data.toString()); 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) => { const result = await completed;
this.logger.info( if (result.error) {
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await logStreamManager.write(absolutePath, data.toString());
});
cp.on('error', async (err) => {
this.logger.error( this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j', t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
command, id,
err, result.error.message
); );
await logStreamManager.write(absolutePath, JSON.stringify(err)); }
}); this.logger.info(
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
cp.on('exit', async (code) => { id,
this.logger.info( result.code
'[panel][执行任务结束] 参数: %s, 退出码: %j', );
JSON.stringify(params), return { ...cron, pid: cp.pid, ...result } as any;
code, } catch (error) {
this.logger.error(
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
cronId,
asError(error).message
);
} finally {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
t('[panel][关闭任务日志失败] %s'),
asError(error).message
); );
// Close the stream after task completion }
await logStreamManager.closeStream(absolutePath); try {
// Do not overwrite a newer run's state or its script-reported exit code.
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{ where: { id } }, {
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 }]
: []),
],
},
}
); );
resolve({ ...params, pid: cp.pid, code }); } catch (error) {
}); this.logger.error(
}); t('[panel][清理任务状态失败] %s'),
asError(error).message
);
}
}
}); });
} }
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); return withSchedulerMutation(async () => {
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.setCrontab(); await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await this.setCrontab();
});
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); return withSchedulerMutation(async () => {
const docs = await CrontabModel.findAll({ where: { id: ids } }); await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const sixCron = docs const docs = await CrontabModel.findAll({ where: { id: ids } });
.filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule)) const crons = docs
.map((doc) => ({ .filter((x) => this.shouldUseCronClient(x))
name: doc.name || '', .map((doc) => ({
id: String(doc.id), name: doc.name || '',
schedule: doc.schedule!, id: String(doc.id),
command: this.makeCommand(doc), schedule: doc.schedule!,
extra_schedules: doc.extra_schedules || [], command: this.makeCommand(doc),
})); extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) { if (isDemoEnv()) {
return; return;
} }
await cronClient.addCron(sixCron);
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) { 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 }); const doc = await this.getDb({ id });
if (!doc) { if (!doc) {
return ''; return {
content: '',
status: 'empty',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
} }
if (doc.log_name === '/dev/null') { if (doc.log_name === '/dev/null') {
return '日志设置为忽略'; 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)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
return await getFileContentByName(`${absolutePath}`); const chunk = await readLogChunk(`${absolutePath}`, options);
const isRunning =
typeof doc.status === 'number' &&
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
return {
...chunk,
status: isRunning ? 'running' : 'completed',
};
} else { } else {
return typeof doc.status === 'number' && const status =
typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status) [CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? '运行中...' ? 'running'
: '日志不存在...'; : 'notFound';
return {
content: status === 'running' ? t('运行中...') : t('日志不存在...'),
status,
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
} }
} }
public async logs(id: number) { public async logs(id: number) {
const doc = await this.getDb({ id }); const doc = await this.getDb({ id });
if (!doc || !doc.log_path) { if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
return []; return [];
} }
const relativeDir = path.dirname(`${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 dirExist = await fileExist(dir); const dirExist = await fileExist(dir);
if (dirExist) { if (dirExist) {
let files = await fs.readdir(dir); let files = await fs.readdir(dir);
@@ -661,6 +1006,9 @@ export default class CronService {
.replace(/;? *\n/g, ';') .replace(/;? *\n/g, ';')
.trim()}' `; .trim()}' `;
} }
if (tab.work_dir) {
commandVariable += `work_dir='${tab.work_dir.replace(/'/g, "'\\''")}' `;
}
const crontab_job_string = `${commandVariable}${command}`; const crontab_job_string = `${commandVariable}${command}`;
return crontab_job_string; return crontab_job_string;
@@ -690,11 +1038,13 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string); await writeFileWithLock(config.crontabFile, crontab_string);
try { if (this.schedulerMode === 'system') {
execSync(`crontab ${config.crontabFile}`); try {
} catch (error: any) { execSync(`crontab ${config.crontabFile}`);
const errorMsg = error.message || String(error); } catch (error: any) {
this.logger.error('[crontab] Failed to update system crontab:', errorMsg); const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
} }
await CrontabModel.update({ saved: true }, { where: {} }); await CrontabModel.update({ saved: true }, { where: {} });
@@ -720,7 +1070,7 @@ export default class CronService {
if ( if (
command && command &&
schedule && schedule &&
CronExpressionParser.parse(schedule).hasNext() isValidCronSchedule(schedule)
) { ) {
const name = namePrefix + '_' + index; const name = namePrefix + '_' + index;
@@ -739,29 +1089,56 @@ export default class CronService {
}); });
} }
public async autosave_crontab() { public async autosave_crontab(requireScheduler = false) {
const tabs = await this.crontabs(); return withSchedulerMutation(async () => {
const regularCrons = tabs.data const tabs = await this.crontabs();
.filter( // A bad persisted rule must not block startup or all other schedules.
(x) => // Keep the DB row editable; omit it from both runtime scheduler snapshots.
x.isDisabled !== 1 && tabs.data = tabs.data.filter((doc) => {
this.isNodeCron(x) && if (doc.isDisabled === 1) return true;
!this.isSpecialSchedule(x.schedule), const invalidSchedules = getInvalidCronSchedules(doc);
) if (invalidSchedules.length === 0) return true;
.map((doc) => ({ this.logger.warn(
name: doc.name || '', '[crontab] Skipping task with invalid schedule (id=%s, name=%s): %s',
id: String(doc.id), doc.id,
schedule: doc.schedule!, doc.name || '',
command: this.makeCommand(doc), JSON.stringify(invalidSchedules),
extra_schedules: doc.extra_schedules || [], );
})); 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()) { if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, ''); await writeFileWithLock(config.crontabFile, '');
return; return;
} }
await cronClient.addCron(regularCrons);
this.setCrontab(tabs); // 先同步 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() { public async bootTask() {
@@ -770,13 +1147,7 @@ export default class CronService {
(x) => !x.isDisabled && this.isBootSchedule(x.schedule), (x) => !x.isDisabled && this.isBootSchedule(x.schedule),
); );
if (bootTasks.length > 0) { if (bootTasks.length > 0) {
await CrontabModel.update( await this.run(bootTasks.map((task) => task.id!));
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
await this.runSingle(task.id!);
}
} }
} }
} }
+225 -49
View File
@@ -16,22 +16,28 @@ import {
getPid, getPid,
killTask, killTask,
promiseExecSuccess, promiseExecSuccess,
concurrentRun,
getInstallCommand, getInstallCommand,
getUninstallCommand, getUninstallCommand,
getGetCommand, getGetCommand,
isPythonSourceDependency,
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
@Service() @Service()
export default class DependenceService { export default class DependenceService {
constructor( constructor(
@Inject('logger') private logger: winston.Logger, @Inject('logger') private logger: winston.Logger,
private sockService: SockService, private sockService: SockService,
) { } ) {}
public async create(payloads: Dependence[]): Promise<Dependence[]> { public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => { const tabs = payloads.map((x) => {
getInstallCommand(x.type, x.name);
const tab = new Dependence({ ...x, status: DependenceStatus.queued }); const tab = new Dependence({ ...x, status: DependenceStatus.queued });
return tab; return tab;
}); });
@@ -55,6 +61,7 @@ export default class DependenceService {
...other, ...other,
status: DependenceStatus.queued, status: DependenceStatus.queued,
}); });
getInstallCommand(tab.type, tab.name);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]); this.installDependenceOneByOne([newDoc]);
return newDoc; return newDoc;
@@ -70,21 +77,25 @@ export default class DependenceService {
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
} }
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter( const installedDeps = docs.filter(
(x) => x.status === DependenceStatus.installed, (x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
); );
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!)); await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) { if (installedDeps.length) {
await DependenceModel.update( await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: ids } }, { where: { id: installedDeps.map((x) => x.id!) } },
); );
this.installDependenceOneByOne(docs, false, force); this.installDependenceOneByOne(installedDeps, false, force);
} }
return docs; return docs;
} }
@@ -107,6 +118,11 @@ export default class DependenceService {
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = query; let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) { if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type]; condition.type = DependenceTypes[type];
} }
@@ -129,6 +145,78 @@ 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
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.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 (
dependency.type !== DependenceTypes.python3 &&
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( public installDependenceOneByOne(
docs: Dependence[], docs: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
@@ -159,8 +247,19 @@ export default class DependenceService {
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
const depInstallCommand = getInstallCommand(doc.type, doc.name); let depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name); let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const isLinuxDependence = doc.type === DependenceTypes.linux;
if (isLinuxDependence) {
const osType = await detectOS();
if (!osType) {
continue;
}
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
}
const pids = await Promise.all([ const pids = await Promise.all([
getPid(depInstallCommand), getPid(depInstallCommand),
getPid(depUnInstallCommand), getPid(depUnInstallCommand),
@@ -213,45 +312,113 @@ export default class DependenceService {
force: boolean = false, force: boolean = false,
) { ) {
return taskLimit.runDependeny(dependency, () => { return taskLimit.runDependeny(dependency, () => {
let validatedCommand: string;
try {
validatedCommand = isInstall
? getInstallCommand(dependency.type, dependency.name.trim())
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.update(
{
status: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed,
log: [String(error)],
},
{ where: { id: dependency.id } },
).then(() => null);
}
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) { if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null); return resolve(null);
} }
taskLimit.removeQueuedDependency(dependency);
const depIds = [dependency.id!]; const depIds = [dependency.id!];
let depName = dependency.name.trim();
const actionText = isInstall ? t('安装') : t('删除');
const socketMessageType = isInstall
? 'installDependence'
: 'uninstallDependence';
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence = dependency.type === DependenceTypes.python3;
const osType = await detectOS();
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) {
if (!osType) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: depIds } },
);
const startTime = dayjs();
const message = tf(
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒',
actionText,
depName,
startTime.format('YYYY-MM-DD HH:mm:ss'),
actionText,
startTime.format('YYYY-MM-DD HH:mm:ss'),
String(startTime.diff(startTime, 'second')),
);
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
status: DependenceStatus.installFailed,
});
this.updateLog(depIds, message);
return resolve(null);
}
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
}
const status = isInstall const status = isInstall
? DependenceStatus.installing ? DependenceStatus.installing
: DependenceStatus.removing; : DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } }); await DependenceModel.update({ status }, { where: { id: depIds } });
const socketMessageType = isInstall let command = validatedCommand;
? 'installDependence' if (isLinuxDependence) {
: 'uninstallDependence'; command = isInstall
let depName = dependency.name.trim(); ? `${linuxCommand.install} ${depName.trim()}`
const command = isInstall : `${linuxCommand.uninstall} ${depName.trim()}`;
? getInstallCommand(dependency.type, depName) }
: getUninstallCommand(dependency.type, depName);
const actionText = isInstall ? '安装' : '删除';
const startTime = dayjs(); const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( const message = tf(
'YYYY-MM-DD HH:mm:ss', '开始%s依赖 %s,开始时间 %s\n\n',
)}\n\n`; actionText,
depName,
startTime.format('YYYY-MM-DD HH:mm:ss'),
);
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message, message,
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, message); this.updateLog(depIds, message);
// 判断是否已经安装过依赖 // 判断是否已经安装过依赖
if (isInstall && !force) { // pip must resolve optional dependencies even if the base distribution
const getCommand = getGetCommand(dependency.type, depName); // is already present; its version alone cannot prove extras are ready.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type]; const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`;
}
let depVersion = ''; let depVersion = '';
if (depName.includes(depVersionStr)) { if (!isPythonDependence && depName.includes(depVersionStr)) {
const symbolRegx = new RegExp( const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
); );
@@ -261,10 +428,6 @@ export default class DependenceService {
depVersion = _depVersion; depVersion = _depVersion;
} }
} }
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence =
dependency.type === DependenceTypes.python3;
const depInfo = (await promiseExecSuccess(getCommand)) const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ') .replace(/\s{2,}/, ' ')
.replace(/\s+$/, ''); .replace(/\s+$/, '');
@@ -273,18 +436,24 @@ export default class DependenceService {
depInfo && depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) || ((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence && (isLinuxDependence &&
depInfo.toLocaleLowerCase().includes('installed')) || linuxCommand.check(depInfo.toLocaleLowerCase())) ||
isPythonDependence) && isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion)) (!depVersion || depInfo.includes(depVersion))
) { ) {
const endTime = dayjs(); const endTime = dayjs();
const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format( const _message = tf(
'YYYY-MM-DD HH:mm:ss', '检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒',
)},耗时 ${endTime.diff(startTime, 'second')} 秒`; depName,
depInfo,
actionText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message: _message, message: _message,
references: depIds, references: depIds,
status: DependenceStatus.installed,
}); });
this.updateLog(depIds, _message); this.updateLog(depIds, _message);
await DependenceModel.update( await DependenceModel.update(
@@ -309,6 +478,7 @@ export default class DependenceService {
type: socketMessageType, type: socketMessageType,
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
@@ -318,6 +488,7 @@ export default class DependenceService {
type: socketMessageType, type: socketMessageType,
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
@@ -327,6 +498,7 @@ export default class DependenceService {
type: socketMessageType, type: socketMessageType,
message: JSON.stringify(err), message: JSON.stringify(err),
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, JSON.stringify(err)); this.updateLog(depIds, JSON.stringify(err));
}); });
@@ -334,28 +506,32 @@ export default class DependenceService {
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
const endTime = dayjs(); const endTime = dayjs();
const isSucceed = code === 0; const isSucceed = code === 0;
const resultText = isSucceed ? '成功' : '失败'; const resultText = isSucceed ? t('成功') : t('失败');
const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format( const message =
'YYYY-MM-DD HH:mm:ss', '\n' +
)},耗时 ${endTime.diff(startTime, 'second')} 秒`; tf(
'依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed
? isInstall
? DependenceStatus.installed
: DependenceStatus.removed
: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message, message,
references: depIds, references: depIds,
status: exitStatus,
}); });
this.updateLog(depIds, message); this.updateLog(depIds, message);
let status: number;
if (isSucceed) {
status = isInstall
? DependenceStatus.installed
: DependenceStatus.removed;
} else {
status = isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
}
const docs = await DependenceModel.findAll({ where: { id: depIds } }); const docs = await DependenceModel.findAll({ where: { id: depIds } });
const _docIds = docs const _docIds = docs
.filter((x) => x.status !== DependenceStatus.cancelled) .filter((x) => x.status !== DependenceStatus.cancelled)
@@ -363,7 +539,7 @@ export default class DependenceService {
if (_docIds.length > 0) { if (_docIds.length > 0) {
await DependenceModel.update( await DependenceModel.update(
{ status }, { status: exitStatus },
{ where: { id: _docIds } }, { where: { id: _docIds } },
); );
} }
+44 -7
View File
@@ -27,7 +27,7 @@ export default class EnvService {
envs.length > 0 && envs.length > 0 &&
typeof envs[envs.length - 1].position === 'number' typeof envs[envs.length - 1].position === 'number'
) { ) {
position = envs[envs.length - 1].position!; position = this.getPrecisionPosition(envs[envs.length - 1].position!);
} }
const tabs = payloads.map((x) => { const tabs = payloads.map((x) => {
position = position - stepPosition; position = position - stepPosition;
@@ -100,7 +100,7 @@ export default class EnvService {
} }
private async checkPosition(position: number, edge: number = 0) { private async checkPosition(position: number, edge: number = 0) {
const precisionPosition = parseFloat(position.toPrecision(16)); const precisionPosition = this.getPrecisionPosition(position);
if ( if (
precisionPosition < minPosition || precisionPosition < minPosition ||
precisionPosition > maxPosition || precisionPosition > maxPosition ||
@@ -116,7 +116,7 @@ export default class EnvService {
} }
private getPrecisionPosition(position: number): number { private getPrecisionPosition(position: number): number {
return parseFloat(position.toPrecision(16)); return Math.trunc(parseFloat(position.toPrecision(16)));
} }
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> { public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
@@ -199,6 +199,44 @@ export default class EnvService {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } }); await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
} }
public async addLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async removeLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{
labels: (env.labels || []).filter(
(label: string) => !labels.includes(label),
),
},
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async set_envs() { public async set_envs() {
const envs = await this.envs('', { const envs = await this.envs('', {
name: { [Op.not]: null }, name: { [Op.not]: null },
@@ -224,10 +262,9 @@ export default class EnvService {
.map((x) => x.value) .map((x) => x.value)
.join('&') .join('&')
.replace(/\\/g, '\\\\')}`; .replace(/\\/g, '\\\\')}`;
js_env_string += `process.env.${key}=\`${_env_value.replace( js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
/\`/g, group.map((x) => x.value).join('&'),
'\\`', )};\n`;
)}\`;\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace( py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g, /\'/g,
"\\'", "\\'",
+41 -11
View File
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api'; import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron'; import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron'; import { delCron } from '../schedule/delCron';
import { setConcurrency } from '../schedule/setConcurrency';
import { check } from '../schedule/health'; import { check } from '../schedule/health';
import * as Api from '../schedule/api'; import * as Api from '../schedule/api';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
@@ -11,30 +12,59 @@ import { promisify } from 'util';
import config from '../config'; import config from '../config';
import { metricsService } from './metrics'; import { metricsService } from './metrics';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { initGrpcCerts } from '../config/grpcCerts';
@Service() @Service()
export class GrpcServerService { export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 }); private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
private formatGrpcAddress(host: string, port: number): string {
if (host === '::') {
return `[::]:${port}`;
}
return `${host}:${port}`;
}
async initialize() { async initialize() {
try { try {
this.server.addService(HealthService, { check }); this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron }); this.server.addService(CronService, { addCron, delCron, setConcurrency });
this.server.addService(ApiService, Api); this.server.addService(ApiService, Api);
const grpcPort = config.grpcPort; const tlsConfig = await initGrpcCerts();
const bindAsync = promisify(this.server.bindAsync).bind(this.server); const credentials = ServerCredentials.createSsl(
await bindAsync( Buffer.from(tlsConfig.caCert),
`0.0.0.0:${grpcPort}`, [{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }],
ServerCredentials.createInsecure(), true,
); );
Logger.debug(`✌️ gRPC service started successfully`);
metricsService.record('grpc_service_start', 1, { const grpcPort = config.grpcPort;
port: grpcPort.toString(), const hostsToTry = [
}); config.bindHostGrpc,
...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
return grpcPort; let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
const boundPort = await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return boundPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
}
}
Logger.error('Failed to start gRPC service on all hosts');
throw lastError || new Error('Failed to start gRPC service');
} catch (err) { } catch (err) {
Logger.error('Failed to start gRPC service:', err); Logger.error('Failed to start gRPC service:', err);
throw err; throw err;
+6 -7
View File
@@ -1,9 +1,11 @@
import { Service } from 'typedi'; import { Service } from 'typedi';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { GrpcServerService } from './grpc'; import cronClient from '../schedule/client';
import { HttpServerService } from './http'; import { HttpServerService } from './http';
import { runtimeBuild } from '../shared/runtimeBuild';
interface HealthStatus { interface HealthStatus {
build: typeof runtimeBuild;
status: 'ok' | 'error'; status: 'ok' | 'error';
services: { services: {
http: boolean; http: boolean;
@@ -22,13 +24,11 @@ interface HealthStatus {
export class HealthService { export class HealthService {
private startTime = Date.now(); private startTime = Date.now();
constructor( constructor(private httpServerService: HttpServerService) {}
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> { async check(): Promise<HealthStatus> {
const status: HealthStatus = { const status: HealthStatus = {
build: runtimeBuild,
status: 'ok', status: 'ok',
services: { services: {
http: true, http: true,
@@ -56,8 +56,7 @@ export class HealthService {
} }
try { try {
const grpcServer = this.grpcServerService.getServer(); if (!(await cronClient.readiness.check())) {
if (!grpcServer) {
status.services.grpc = false; status.services.grpc = false;
status.status = 'error'; status.status = 'error';
} }
+42 -16
View File
@@ -3,31 +3,57 @@ import Logger from '../loaders/logger';
import { metricsService } from './metrics'; import { metricsService } from './metrics';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { Server } from 'http'; import { Server } from 'http';
import config from '../config';
@Service() @Service()
export class HttpServerService { export class HttpServerService {
private server?: Server = undefined; private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) { async initialize(expressApp: express.Application, port: number) {
try { const hostsToTry = [
return new Promise((resolve, reject) => { config.bindHost,
this.server = expressApp.listen(port, '0.0.0.0', () => { ...(config.bindHost === '::' ? ['0.0.0.0'] : [])
Logger.debug(`✌️ HTTP service started successfully`); ];
metricsService.record('http_service_start', 1, {
port: port.toString(),
});
resolve(this.server);
});
this.server?.on('error', (err: Error) => { let lastError: Error | null = null;
Logger.error('Failed to start HTTP service:', err);
reject(err); for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
}); });
}); this.server = server;
} catch (err) { return server;
Logger.error('Failed to start HTTP service:', err); } catch (err) {
throw err; lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
}
} }
Logger.error('Failed to start HTTP service on all hosts');
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
// 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);
});
server.on('error', (err: Error) => {
server.close();
reject(err);
});
});
} }
async shutdown() { async shutdown() {
+2 -3
View File
@@ -1,4 +1,4 @@
import path from 'path'; import { resolveFileAccess } from '../shared/fileAccess';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
@@ -8,7 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) { public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName); return resolveFileAccess(config.logPath, [filePath || '', fileName]);
return finalPath.startsWith(config.logPath) ? finalPath : '';
} }
} }
+60 -17
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
@@ -8,16 +9,19 @@ interface Metric {
tags?: Record<string, string>; tags?: Record<string, string>;
} }
class MetricsService { export const MAX_METRIC_SAMPLES = 1000;
private metrics: Metric[] = []; 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 static instance: MetricsService;
private constructor() { private constructor() {
// 定期清理旧数据 // 定期清理旧数据
setInterval(() => { const cleanupTimer = setInterval(() => this.removeExpiredMetrics(), 60000);
const oneHourAgo = Date.now() - 3600000; cleanupTimer.unref();
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
}, 60000);
} }
static getInstance(): MetricsService { static getInstance(): MetricsService {
@@ -28,12 +32,14 @@ class MetricsService {
} }
record(name: string, value: number, tags?: Record<string, string>) { record(name: string, value: number, tags?: Record<string, string>) {
this.metrics.push({ this.metrics[this.nextMetricIndex] = {
name, name,
value, value,
timestamp: Date.now(), timestamp: Date.now(),
tags, 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>) { measure(name: string, fn: () => void, tags?: Record<string, string>) {
@@ -46,7 +52,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(); const start = performance.now();
try { try {
await fn(); await fn();
@@ -57,34 +67,67 @@ class MetricsService {
} }
getMetrics(name?: string, tags?: Record<string, string>) { getMetrics(name?: string, tags?: Record<string, string>) {
let filtered = this.metrics; this.removeExpiredMetrics();
let filtered = this.getMetricSnapshot();
if (name) { if (name) {
filtered = filtered.filter(m => m.name === name); filtered = filtered.filter((m) => m.name === name);
} }
if (tags) { if (tags) {
filtered = filtered.filter(m => { filtered = filtered.filter((m) => {
if (!m.tags) return false; 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 { return {
count: filtered.length, count: filtered.length,
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length, average: values.length
min: Math.min(...filtered.map(m => m.value)), ? values.reduce((acc, value) => acc + value, 0) / values.length
max: Math.max(...filtered.map(m => m.value)), : 0,
min: values.length ? Math.min(...values) : 0,
max: values.length ? Math.max(...values) : 0,
metrics: filtered, 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() { report() {
const report = { const report = {
timestamp: Date.now(), timestamp: Date.now(),
metrics: this.getMetrics(), metrics: this.getMetrics(),
}; };
Logger.info('性能指标报告:', report); Logger.info(t('性能指标报告:'), report);
return report; return report;
} }
} }
+164 -31
View File
@@ -1,8 +1,10 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto'; import crypto from 'crypto';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { parseBody, parseHeaders } from '../config/util'; import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import { t } from '../shared/i18n';
import UserService from './user'; import UserService from './user';
import { httpClient } from '../config/http'; import { httpClient } from '../config/http';
import { ProxyAgent } from 'undici'; import { ProxyAgent } from 'undici';
@@ -34,6 +36,9 @@ export default class NotificationService {
['chronocat', this.chronocat], ['chronocat', this.chronocat],
['ntfy', this.ntfy], ['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot], ['wxPusherBot', this.wxPusherBot],
['wxPusherSpt', this.wxPusherSpt],
['openiLink', this.openiLink],
['wpush', this.wpush],
]); ]);
private title = ''; private title = '';
@@ -51,27 +56,33 @@ export default class NotificationService {
content: string, content: string,
notificationInfo?: NotificationInfo, notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode(); let type: string | undefined;
if (notificationInfo?.type) { let success = false;
type = notificationInfo?.type; try {
} const saved = await this.userService.getNotificationMode();
if (type) { type = notificationInfo?.type || saved.type;
this.title = title; if (type) {
this.content = content; this.title = title;
let params = rest; this.content = content;
if (notificationInfo) { const { type: _, ...overrides } = notificationInfo || {};
const { type: _, ...others } = notificationInfo; const { type: _saved, ...rest } = saved;
params = { ...rest, ...others }; this.params = { ...rest, ...overrides };
} try {
this.params = params; success = !!(await this.modeMap.get(type)?.call(this));
const notificationModeAction = this.modeMap.get(type); } catch (error) {
try { console.error(error);
return await notificationModeAction?.call(this); }
} catch (error: any) {
console.error(error);
} }
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
} }
return false;
} }
public async testNotify( public async testNotify(
@@ -80,14 +91,31 @@ export default class NotificationService {
content: string, content: string,
) { ) {
const { type, ...rest } = info; const { type, ...rest } = info;
if (type) { if (!type) return true;
let success = false;
try {
this.title = title; this.title = title;
this.content = content; this.content = content;
this.params = rest; this.params = rest;
const notificationModeAction = this.modeMap.get(type); success = !!(await this.modeMap.get(type)?.call(this));
return await notificationModeAction?.call(this); return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
} }
return true; }
private parseMailRecipients(value?: string) {
const recipients = (value || '')
.split(/[;;]/)
.map((item) => item.trim())
.filter(Boolean);
return recipients.length > 0 ? recipients : undefined;
} }
private async gotify() { private async gotify() {
@@ -355,7 +383,7 @@ export default class NotificationService {
{ {
title: `${this.title}`, title: `${this.title}`,
thumb_media_id, thumb_media_id,
author: `智能助手`, author: t('智能助手'),
content_source_url: ``, content_source_url: ``,
content: `${this.content.replace(/\n/g, '<br/>')}`, content: `${this.content.replace(/\n/g, '<br/>')}`,
digest: `${this.content}`, digest: `${this.content}`,
@@ -371,7 +399,7 @@ export default class NotificationService {
title: `${this.title}`, title: `${this.title}`,
description: `${this.content}`, description: `${this.content}`,
url: 'https://github.com/whyour/qinglong', url: 'https://github.com/whyour/qinglong',
btntxt: '更多', btntxt: t('更多'),
}, },
}; };
break; break;
@@ -422,7 +450,7 @@ export default class NotificationService {
roomName: `${aibotkName}`, roomName: `${aibotkName}`,
message: { message: {
type: 1, type: 1,
content: `【青龙快讯】\n\n${this.title}\n${this.content}`, content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`,
}, },
}; };
break; break;
@@ -433,7 +461,7 @@ export default class NotificationService {
name: `${aibotkName}`, name: `${aibotkName}`,
message: { message: {
type: 1, type: 1,
content: `【青龙快讯】\n\n${this.title}\n${this.content}`, content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`,
}, },
}; };
break; break;
@@ -591,6 +619,7 @@ export default class NotificationService {
private async email() { private async email() {
const { emailPass, emailService, emailUser, emailTo } = this.params; const { emailPass, emailService, emailUser, emailTo } = this.params;
const recipients = this.parseMailRecipients(emailTo) || emailUser;
try { try {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -602,8 +631,8 @@ export default class NotificationService {
}); });
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`, from: `"${t('青龙快讯')}" <${emailUser}>`,
to: emailTo ? emailTo.split(';') : emailUser, to: recipients,
subject: `${this.title}`, subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`, html: `${this.content.replace(/\n/g, '<br/>')}`,
}); });
@@ -717,7 +746,7 @@ export default class NotificationService {
// topic_ids 和 uids 至少要有一个 // topic_ids 和 uids 至少要有一个
if (!topicIds.length && !uids.length) { if (!topicIds.length && !uids.length) {
throw new Error('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'); throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'));
} }
const url = `https://wxpusher.zjiecode.com/api/send/message`; const url = `https://wxpusher.zjiecode.com/api/send/message`;
@@ -745,6 +774,52 @@ export default class NotificationService {
} }
} }
private async wxPusherSpt() {
const { wxPusherSptList } = this.params;
// 处理 SPT,将逗号分隔的字符串转为数组
const spts = wxPusherSptList
? wxPusherSptList
.split(',')
.map((spt) => spt.trim())
.filter((spt) => spt)
: [];
if (!spts.length) {
throw new Error(t('wxPusher SPT 不能为空'));
}
if (spts.length > 10) {
throw new Error(t('wxPusher SPT 最多支持 10 个'));
}
const url = `https://wxpusher.zjiecode.com/api/send/message/simple-push`;
const json: any = {
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
summary: this.title,
contentType: 2,
};
// 单个 SPT 用 spt,多个用 sptList
if (spts.length === 1) {
json.spt = spts[0];
} else {
json.sptList = spts;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json,
});
if (res.code === 1000) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async chronocat() { private async chronocat() {
const { chronocatURL, chronocatQQ, chronocatToken } = this.params; const { chronocatURL, chronocatQQ, chronocatToken } = this.params;
try { try {
@@ -813,7 +888,7 @@ export default class NotificationService {
} = this.params; } = this.params;
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) { if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
throw new Error('Url 或者 Body 中必须包含 $title'); throw new Error(t('Url 或者 Body 中必须包含 $title'));
} }
const headers = parseHeaders(webhookHeaders); const headers = parseHeaders(webhookHeaders);
@@ -858,4 +933,62 @@ export default class NotificationService {
} }
return {}; return {};
} }
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl =
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
content: `${this.title}\n\n${this.content}`,
};
if (openiLinkContextToken) {
body.context_token = openiLinkContextToken;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${openiLinkAppToken}`,
},
});
if (res.ok) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
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);
}
}
} }
+118 -35
View File
@@ -5,27 +5,78 @@ import { App, AppModel } from '../data/open';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize'; import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store'; 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() @Service()
export default class OpenService { export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {} 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> { public async findApps(): Promise<App[] | null> {
const docs = await this.find({}); const docs = await this.find({});
return docs; return docs;
} }
public async create(payload: App): Promise<App> { public async create(payload: App): Promise<App> {
const tab = { ...payload }; return this.withAppsLock(async () => {
tab.client_id = createRandomString(12, 12); const tab = { ...payload };
tab.client_secret = createRandomString(24, 24); tab.client_id = createRandomString(12, 12);
const doc = await this.insert(tab); tab.client_secret = createRandomString(24, 24);
const apps = await this.find({}); const doc = await this.insert(tab);
await shareStore.updateApps(apps); const apps = await this.find({});
return { ...doc, tokens: [] }; 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 }); const doc = await AppModel.create(payload, { returning: true });
return doc.get({ plain: true }); return doc.get({ plain: true });
} }
@@ -40,10 +91,12 @@ export default class OpenService {
} }
private async updateDb(payload: Partial<App>): Promise<App> { private async updateDb(payload: Partial<App>): Promise<App> {
await AppModel.update(payload, { where: { id: payload.id } }); return this.withAppsLock(async () => {
const apps = await this.find({}); await AppModel.update(payload, { where: { id: payload.id } });
await shareStore.updateApps(apps); const apps = await this.find({});
return apps?.find((x) => x.id === payload.id) as App; await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
});
} }
public async getDb(query: Record<string, any>): Promise<App> { public async getDb(query: Record<string, any>): Promise<App> {
@@ -55,9 +108,11 @@ export default class OpenService {
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await AppModel.destroy({ where: { id: ids } }); return this.withAppsLock(async () => {
const apps = await this.find({}); await AppModel.destroy({ where: { id: ids } });
await shareStore.updateApps(apps); const apps = await this.find({});
await shareStore.updateApps(apps);
});
} }
public async resetSecret(id: number): Promise<App> { public async resetSecret(id: number): Promise<App> {
@@ -111,7 +166,15 @@ export default class OpenService {
return docs.map((x) => x.get({ plain: true })); 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_id,
client_secret, client_secret,
}: { }: {
@@ -151,31 +214,51 @@ export default class OpenService {
}, },
}; };
} else { } else {
return { code: 400, message: 'client_id 或 client_seret 有误' }; return { code: 400, message: t('client_id 或 client_seret 有误') };
} }
} }
public async generateSystemToken(): Promise<{ public async generateSystemToken(renew = false): Promise<{
value: string; value: string;
expiration: number; expiration: number;
}> { }> {
const apps = await shareStore.getApps(); return this.withAppsLock(async () => {
const systemApp = apps?.find((x) => x.name === 'system'); // Read after acquiring the lock; a concurrent issuer may have renewed.
if (!systemApp) { const systemApp = await this.getDb({ name: 'system' });
throw new Error('system app not found'); const now = Math.round(Date.now() / 1000);
} let appToken = systemApp.tokens
const now = Math.round(Date.now() / 1000); ?.filter((token) => token.expiration > now)
const currentToken = systemApp.tokens?.find((x) => x.expiration > now); .sort((left, right) => right.expiration - left.expiration)[0];
if (currentToken) { if (renew || !appToken) {
return currentToken; const { data } = await this.issueToken({
} client_id: systemApp.client_id,
const { data } = await this.authToken({ client_secret: systemApp.client_secret,
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),
),
};
}
}
+118 -85
View File
@@ -1,6 +1,7 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import nodeSchedule from 'node-schedule'; import { createCronJob, CronJob } from '../shared/cronScheduler';
import { ChildProcessWithoutNullStreams } from 'child_process'; import { ChildProcessWithoutNullStreams } from 'child_process';
import { import {
ToadScheduler, ToadScheduler,
@@ -11,6 +12,11 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import {
observeChildProcess,
asError,
ProcessResult,
} from '../shared/childProcess';
export interface ScheduleTaskType { export interface ScheduleTaskType {
id?: number; id?: number;
@@ -27,7 +33,7 @@ export interface TaskCallbacks {
startTime: dayjs.Dayjs, startTime: dayjs.Dayjs,
) => Promise<void>; ) => Promise<void>;
onEnd?: ( onEnd?: (
cp: ChildProcessWithoutNullStreams, cp: ChildProcessWithoutNullStreams | undefined,
endTime: dayjs.Dayjs, endTime: dayjs.Dayjs,
diff: number, diff: number,
) => Promise<void>; ) => Promise<void>;
@@ -37,7 +43,7 @@ export interface TaskCallbacks {
@Service() @Service()
export default class ScheduleService { export default class ScheduleService {
private scheduleStacks = new Map<string, nodeSchedule.Job>(); private scheduleStacks = new Map<string, CronJob>();
private intervalSchedule = new ToadScheduler(); private intervalSchedule = new ToadScheduler();
@@ -63,73 +69,85 @@ export default class ScheduleService {
) { ) {
const { runOrigin, ...others } = params; const { runOrigin, ...others } = params;
return taskLimit[this.taskLimitMap[runOrigin]](others, () => { let resolveStart!: (pid: number | undefined) => void;
return new Promise(async (resolve, reject) => { let rejectStart!: (error: Error) => void;
this.logger.info( const startResult = new Promise<number | undefined>((resolve, reject) => {
`[panel][开始执行任务] 参数: ${JSON.stringify({ resolveStart = resolve;
...others, rejectStart = reject;
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));
}
});
}); });
// 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(t('[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(
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
others.id,
result.error.message,
);
try {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
t('[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(
t('[panel][任务结束回调失败] %s'),
asError(error).message,
);
}
this.logger.info(
t('[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(t('[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( async createCronTask(
@@ -139,25 +157,40 @@ export default class ScheduleService {
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s', t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
_id, _id,
schedule, schedule,
name, name,
command, command,
); );
this.scheduleStacks.set( if (schedule) {
_id, try {
nodeSchedule.scheduleJob(_id, schedule, async () => { const job = createCronJob(
this.runTask(command, callbacks, {
name,
schedule, schedule,
command, () =>
id: _id, this.runTask(command, callbacks, {
runOrigin, 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(
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
_id,
schedule,
asError(error).message,
);
}
}
if (runImmediately) { if (runImmediately) {
this.runTask(command, callbacks, { this.runTask(command, callbacks, {
@@ -172,7 +205,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) { async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务] 任务名: %s', name); this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
if (this.scheduleStacks.has(_id)) { if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel(); this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id); this.scheduleStacks.delete(_id);
@@ -187,7 +220,7 @@ export default class ScheduleService {
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s', t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
_id, _id,
name, name,
command, command,
@@ -204,7 +237,7 @@ export default class ScheduleService {
}, },
(err) => { (err) => {
this.logger.error( this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j', t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
command, command,
err, err,
); );
@@ -232,7 +265,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) { async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[panel][取消interval任务] 任务ID: %s, 任务名: %s', t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
_id, _id,
name, name,
); );
+5 -5
View File
@@ -1,3 +1,4 @@
import { resolveFileAccess } from '../shared/fileAccess';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import path, { join } from 'path'; import path, { join } from 'path';
@@ -57,16 +58,15 @@ export default class ScriptService {
taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-')); taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-'));
pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number; pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number;
} }
try { if (pid) {
await killTask(pid); await killTask(pid, true);
} catch (error) {} }
return { code: 200 }; return { code: 200 };
} }
public checkFilePath(filePath: string, fileName: string) { public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName); return resolveFileAccess(config.scriptPath, [filePath || '', fileName]);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
} }
public async getFile(filePath: string, fileName: string) { 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'));
}
}
+55 -26
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import fs from 'fs/promises'; import fs from 'fs/promises';
@@ -8,10 +9,22 @@ import { formatUrl } from '../config/subscription';
import config from '../config'; import config from '../config';
import { fileExist, rmPath } from '../config/util'; import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import {
getSubscriptionSshAlias,
resolveSubscriptionPath,
} from '../shared/subscriptionPath';
@Service() @Service()
export default class SshKeyService { 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 sshPath = config.sshdPath;
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
@@ -21,6 +34,10 @@ export default class SshKeyService {
} }
private async initSshConfigFile() { private async initSshConfigFile() {
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
recursive: true,
mode: 0o700,
});
let config = ''; let config = '';
const _exist = await fileExist(this.sshConfigFilePath); const _exist = await fileExist(this.sshConfigFilePath);
if (_exist) { if (_exist) {
@@ -42,24 +59,22 @@ export default class SshKeyService {
key: string, key: string,
): Promise<void> { ): Promise<void> {
try { try {
await writeFileWithLock( const filePath = resolveSubscriptionPath(this.sshPath, alias);
path.join(this.sshPath, alias), try {
`${key}${os.EOL}`, await rmPath(filePath);
{ } catch {}
mode: '400', await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
},
);
} catch (error) { } catch (error) {
this.logger.error('生成私钥文件失败', error); this.logger.error(t('生成私钥文件失败'), error);
} }
} }
private async removePrivateKeyFile(alias: string): Promise<void> { private async removePrivateKeyFile(alias: string): Promise<void> {
try { try {
const filePath = path.join(this.sshPath, alias); const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath); await rmPath(filePath);
} catch (error) { } catch (error) {
this.logger.error('删除私钥文件失败', error); this.logger.error(t('删除私钥文件失败'), error);
} }
} }
@@ -68,18 +83,26 @@ export default class SshKeyService {
host: string, host: string,
proxy?: 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') { if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
} }
const proxyStr = proxy const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: ''; : '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
this.sshPath, this.sshPath,
alias, alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`; )}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock( await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`, `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config, config,
{ {
encoding: 'utf8', encoding: 'utf8',
@@ -90,10 +113,10 @@ export default class SshKeyService {
private async removeSshConfig(alias: string) { private async removeSshConfig(alias: string) {
try { try {
const filePath = path.join(this.sshPath, `${alias}.config`); const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath); await rmPath(filePath);
} catch (error) { } catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error); this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
} }
} }
@@ -103,6 +126,7 @@ export default class SshKeyService {
host: string, host: string,
proxy?: string, proxy?: string,
): Promise<void> { ): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.generatePrivateKeyFile(alias, key); await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy); await this.generateSingleSshConfig(alias, host, proxy);
} }
@@ -112,6 +136,7 @@ export default class SshKeyService {
host: string, host: string,
proxy?: string, proxy?: string,
): Promise<void> { ): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.removePrivateKeyFile(alias); await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias); await this.removeSshConfig(alias);
} }
@@ -119,15 +144,19 @@ export default class SshKeyService {
public async setSshConfig(docs: Subscription[]) { public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) { for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc; try {
const { host } = formatUrl(doc); const alias = getSubscriptionSshAlias(doc.alias);
await this.removePrivateKeyFile(alias); const { host } = formatUrl(doc);
await this.removeSshConfig(alias); await this.removePrivateKeyFile(alias);
await this.generatePrivateKeyFile( await this.removeSshConfig(alias);
alias, await this.generatePrivateKeyFile(
(doc.pull_option as any).private_key, alias,
); (doc.pull_option as any).private_key,
await this.generateSingleSshConfig(alias, host, proxy); );
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
}
} }
} }
} }
@@ -146,12 +175,12 @@ export default class SshKeyService {
private async generateGlobalSshConfig(alias: string) { private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available // Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join( const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
this.sshPath, this.sshPath,
alias, alias,
)}\n StrictHostKeyChecking no\n`; )}\n StrictHostKeyChecking no\n`;
await writeFileWithLock( await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`, `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config, config,
{ {
encoding: 'utf8', encoding: 'utf8',
+177 -82
View File
@@ -20,18 +20,28 @@ import {
} from '../config/util'; } from '../config/util';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize'; import { FindOptions, Op } from 'sequelize';
import path, { join } from 'path'; import path from 'path';
import ScheduleService, { TaskCallbacks } from './schedule'; import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler'; import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock'; import SockService from './sock';
import { t, tf } from '../shared/i18n';
import SshKeyService from './sshKey'; import SshKeyService from './sshKey';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const'; import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription'; import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron'; 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 CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager'; import { logStreamManager } from '../shared/logStreamManager';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -86,9 +96,11 @@ export default class SubscriptionService {
needCreate = true, needCreate = true,
runImmediately = false, runImmediately = false,
) { ) {
const { url } = formatUrl(doc); // Cancelling by ID must also work for invalid historical subscriptions.
if (needCreate) {
doc.command = formatCommand(doc, url as string); const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
}
if (doc.schedule_type === 'crontab') { if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any); this.scheduleService.cancelCronTask(doc as any);
@@ -130,14 +142,14 @@ export default class SubscriptionService {
); );
const absolutePath = await handleLogPath( const absolutePath = await handleLogPath(
logPath as string, logPath as string,
`## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`, tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
); );
// 执行sub_before // 执行sub_before
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { if (doc.sub_before) {
await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`); await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
beforeStr = await promiseExec(doc.sub_before); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -157,44 +169,54 @@ export default class SubscriptionService {
); );
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
const sub = await this.getDb({ id: doc.id }); let absolutePath: string | undefined;
const absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
try { try {
if (sub.sub_after) { const sub = await this.getDb({ id: doc.id });
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`); absolutePath = await handleLogPath(sub.log_path as string);
afterStr = await promiseExec(sub.sub_after);
// 执行 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## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${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: '订阅执行完成',
references: [doc.id as number],
});
}, },
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
@@ -210,11 +232,14 @@ export default class SubscriptionService {
} }
public async create(payload: Subscription): Promise<Subscription> { public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload); assertSubscriptionAlias(payload.alias);
const doc = await this.insert(tab); return withSubscriptionMutation(async () => {
await this.handleTask(doc.get({ plain: true })); const tab = new Subscription(payload);
await this.setSshConfig(); const doc = await this.insert(tab);
return doc; await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig();
return doc;
});
} }
public async insert(payload: Subscription): Promise<SubscriptionInstance> { public async insert(payload: Subscription): Promise<SubscriptionInstance> {
@@ -222,12 +247,27 @@ export default class SubscriptionService {
} }
public async update(payload: Subscription): Promise<Subscription> { public async update(payload: Subscription): Promise<Subscription> {
const doc = await this.getDb({ id: payload.id }); assertSubscriptionAlias(payload.alias);
const tab = new Subscription({ ...doc, ...payload }); return withSubscriptionMutation(async () => {
const newDoc = await this.updateDb(tab); const doc = await this.getDb({ id: payload.id });
await this.handleTask(newDoc, !newDoc.is_disabled); const tab = new Subscription({ ...doc, ...payload });
await this.setSshConfig(); const newDoc = await this.updateDb(tab);
return newDoc; 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> { public async updateDb(payload: Subscription): Promise<Subscription> {
@@ -250,6 +290,7 @@ export default class SubscriptionService {
last_running_time: number; last_running_time: number;
last_execution_time: number; last_execution_time: number;
}) { }) {
if (log_path) resolveLogPath(config.logPath, log_path);
const options: any = { const options: any = {
status, status,
pid, pid,
@@ -267,25 +308,71 @@ export default class SubscriptionService {
} }
public async remove(ids: number[], query: { force?: boolean }) { public async remove(ids: number[], query: { force?: boolean }) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); return withSubscriptionMutation(async () => {
for (const doc of docs) { const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.handleTask(doc.get({ plain: true }), false); const storagePaths: string[] = [];
} const retainedPaths: string[] = [];
await SubscriptionModel.destroy({ where: { id: ids } }); const collectPaths = (
await this.setSshConfig(); doc: Subscription,
targets: string[],
if (query?.force === true) { deleting = false,
const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); ) => {
if (crons?.length) { for (const names of getSubscriptionStorageCandidates(doc)) {
await this.crontabService.remove(crons.map((x) => x.id!)); // 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) { for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias); await this.handleTask(doc.get({ plain: true }), false);
const repoPath = join(config.repoPath, doc.alias);
await rmPath(filePath);
await rmPath(repoPath);
} }
} 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( public async getDb(
@@ -310,20 +397,22 @@ export default class SubscriptionService {
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
let failure: unknown;
for (const doc of docs) { for (const doc of docs) {
if (doc.pid) { try {
try { if (doc.pid) {
await killTask(doc.pid); await killTask(doc.pid, true);
} catch (error) {
this.logger.error(error);
} }
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;
} }
} }
if (failure) throw failure;
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: ids } },
);
} }
private async runSingle(subscriptionId: number) { private async runSingle(subscriptionId: number) {
@@ -361,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 }); const doc = await this.getDb({ id });
if (!doc || !doc.log_path) { 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); const absolutePath = await handleLogPath(doc.log_path as string);
return await getFileContentByName(absolutePath); return await readLogChunk(absolutePath, options);
} }
public async logs(id: number) { public async logs(id: number) {
@@ -379,7 +474,7 @@ export default class SubscriptionService {
if (doc.log_path) { if (doc.log_path) {
const relativeDir = path.dirname(`${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); const _exist = await fileExist(dir);
if (_exist) { if (_exist) {
let files = await fs.readdir(dir); let files = await fs.readdir(dir);
+258 -107
View File
@@ -1,8 +1,11 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express'; import { Response } from 'express';
import fs from 'fs'; import fs from 'fs';
import { Agent, request } from 'undici'; import { request } from 'undici';
import sum from 'lodash/sum';
import path from 'path'; import path from 'path';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
@@ -30,13 +33,19 @@ import {
SystemInstance, SystemInstance,
SystemModel, SystemModel,
SystemModelInfo, SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system'; } from '../data/system';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
import NotificationService from './notify'; import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule'; import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock'; import SockService from './sock';
import os from 'os'; import os from 'os';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { t, setLang } from '../shared/i18n';
import { updateLinuxMirrorFile } from '../config/util';
@Service() @Service()
export default class SystemService { export default class SystemService {
@@ -47,7 +56,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger, @Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
private sockService: SockService, private sockService: SockService,
) { } ) {}
public async getSystemConfig() { public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig }); const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -76,8 +85,8 @@ export default class SystemService {
const code = Math.random().toString().slice(-6); const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify( const isSuccess = await this.notificationService.testNotify(
notificationInfo, notificationInfo,
'青龙', t('青龙'),
`【蛟龙】测试通知 https://t.me/jiao_long`, t('【蛟龙】测试通知 https://t.me/jiao_long'),
); );
if (isSuccess) { if (isSuccess) {
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@@ -86,7 +95,7 @@ export default class SystemService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
@@ -98,7 +107,7 @@ export default class SystemService {
}); });
const cron = { const cron = {
id: result.id as number, id: result.id as number,
name: '删除日志', name: t('删除日志'),
command: `ql rmlog ${info.logRemoveFrequency}`, command: `ql rmlog ${info.logRemoveFrequency}`,
runOrigin: 'system' as const, runOrigin: 'system' as const,
}; };
@@ -118,15 +127,29 @@ export default class SystemService {
} }
public async updateCronConcurrency(info: SystemModelInfo) { public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const limit = info.cronConcurrency ?? 0;
await this.updateAuthDb({ if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
...oDoc, throw Object.assign(new Error(t('参数错误')), { status: 400 });
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
} }
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) { public async updateDependenceProxy(info: SystemModelInfo) {
@@ -138,7 +161,9 @@ export default class SystemService {
if (info.dependenceProxy) { if (info.dependenceProxy) {
await fs.promises.writeFile( await fs.promises.writeFile(
config.dependenceProxyFile, 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 { } else {
await fs.promises.rm(config.dependenceProxyFile); await fs.promises.rm(config.dependenceProxyFile);
@@ -154,7 +179,7 @@ export default class SystemService {
}); });
let cmd = 'pnpm config delete registry'; let cmd = 'pnpm config delete registry';
if (info.nodeMirror) { if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`; cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
} }
let command = `cd && ${cmd}`; let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({ const docs = await DependenceModel.findAll({
@@ -177,6 +202,7 @@ export default class SystemService {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateNodeMirror', type: 'updateNodeMirror',
message: 'update node mirror end', message: 'update node mirror end',
status: 'completed',
}); });
}, },
onError: async (message: string) => { onError: async (message: string) => {
@@ -202,7 +228,7 @@ export default class SystemService {
}); });
let cmd = 'pip config unset global.index-url'; let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) { 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); await promiseExec(cmd);
return { code: 200, data: info }; return { code: 200, data: info };
@@ -214,33 +240,10 @@ export default class SystemService {
onEnd?: () => void, onEnd?: () => void,
) { ) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
let targetDomain = 'https://dl-cdn.alpinelinux.org';
if (os.platform() !== 'linux') { if (os.platform() !== 'linux') {
return; return;
} }
const content = await fs.promises.readFile('/etc/apk/repositories', { const command = await updateLinuxMirrorFile(info.linuxMirror || '');
encoding: 'utf-8',
});
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
if (domainMatch) {
defaultDomain = domainMatch[1];
}
if (info.linuxMirror) {
targetDomain = info.linuxMirror;
}
const command = `sed -i 's/${defaultDomain.replace(
/\//g,
'\\/',
)}/${targetDomain.replace(
/\//g,
'\\/',
)}/g' /etc/apk/repositories && apk update -f`;
this.scheduleService.runTask( this.scheduleService.runTask(
command, command,
{ {
@@ -248,12 +251,20 @@ export default class SystemService {
res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end(); res?.end();
}, },
onEnd: async () => { onEnd: async (cp) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateLinuxMirror', type: 'updateLinuxMirror',
message: 'update linux mirror end', message: 'update linux mirror end',
status: 'completed',
}); });
onEnd?.(); onEnd?.();
// 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 },
});
}
}, },
onError: async (message: string) => { onError: async (message: string) => {
this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
@@ -271,26 +282,24 @@ export default class SystemService {
} }
public async checkUpdate() { public async checkUpdate() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try { try {
const currentVersionContent = await parseVersion(config.versionFile); const currentVersionContent = await parseVersion(config.versionFile);
let lastVersionContent; const { body, statusCode } = await request(
try { `${config.lastVersionFile}?t=${Date.now()}`,
const { body } = await request( { signal: controller.signal },
`${config.lastVersionFile}?t=${Date.now()}`, );
{ const text = await body.text();
dispatcher: new Agent({ if (statusCode !== 200) throw new Error('Version request failed');
keepAliveTimeout: 30000, const lastVersionContent = parseContentVersion(text);
keepAliveMaxTimeout: 30000, if (
}), !lastVersionContent ||
}, typeof lastVersionContent.version !== 'string' ||
); !/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
const text = await body.text(); ) {
lastVersionContent = parseContentVersion(text); throw new Error('Invalid version response');
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
} }
return { return {
@@ -308,8 +317,10 @@ export default class SystemService {
} catch (error: any) { } catch (error: any) {
return { return {
code: 400, code: 400,
message: error.message, message: t('检查更新失败,请检查网络后重试'),
}; };
} finally {
clearTimeout(timeout);
} }
} }
@@ -333,34 +344,52 @@ export default class SystemService {
} }
public async updateSystem() { public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' }); return this.startUpgradeJob('download');
}
cp.stdout.on('data', (data) => { private upgradeJobs() {
this.sockService.sendMessage({ return require(path.join(
type: 'updateSystemVersion', config.rootPath,
message: data.toString(), 'cli/src/internal/maintenance/panelUpgrade.cjs',
}); ));
}); }
cp.stderr.on('data', (data) => { public async getUpgradeStatus() {
this.sockService.sendMessage({ return {
type: 'updateSystemVersion', code: 200,
message: data.toString(), data: await this.upgradeJobs().getStatus(config.tmpPath),
}); };
}); }
cp.on('error', (err) => { private async startUpgradeJob(action: 'download' | 'apply') {
this.sockService.sendMessage({ try {
type: 'updateSystemVersion', const data = await this.upgradeJobs().startJob(
message: JSON.stringify(err), {
}); root: config.rootPath,
}); tmp: config.tmpPath,
env: {
return { code: 200 }; ...process.env,
dir_data: config.dataPath,
dir_config: config.configPath,
QlPort: String(config.port),
QlBaseUrl: config.baseUrl,
},
},
action,
);
return { code: 200, data };
} catch (error: any) {
this.logger.error('[upgrade] %s', error.message);
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
}
} }
public async reloadSystem(target?: 'system' | 'data') { 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('参数错误') };
}
if (target === 'system') return this.startUpgradeJob('apply');
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, { const cp = spawn(cmd, {
shell: '/bin/bash', shell: '/bin/bash',
detached: true, detached: true,
@@ -389,33 +418,55 @@ export default class SystemService {
if (notificationInfo && typeString) { if (notificationInfo && typeString) {
notificationInfo.type = typeString; notificationInfo.type = typeString;
} }
const isSuccess = await this.notificationService.notify( const isSuccess = await this.notificationService.notify(
title, title,
content, content,
notificationInfo, notificationInfo,
); );
if (isSuccess) { if (isSuccess) {
return { code: 200, message: '通知发送成功' }; return { code: 200, message: t('通知发送成功') };
} else { } else {
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
} }
} }
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) { public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
order: [['id', 'DESC']],
limit: 200,
});
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{ command, logPath }: { command: string; logPath?: string },
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) { if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`; command = `${TASK_COMMAND} ${command}`;
} }
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '' const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, { this.scheduleService.runTask(
command, `${logPathPrefix} real_time=true ${command}`,
id: command.replace(/ /g, '-'), callback,
runOrigin: 'system', {
}); command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
},
);
} }
public async stop({ command, pid }: { command: string; pid: number }) { public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) { if (!pid && !command) {
return { code: 400, message: '参数错误' }; return { code: 400, message: t('参数错误') };
} }
if (pid) { if (pid) {
@@ -431,7 +482,7 @@ export default class SystemService {
await killTask(_pid); await killTask(_pid);
return { code: 200 }; return { code: 200 };
} else { } else {
return { code: 400, message: '任务未找到' }; return { code: 400, message: t('任务未找到') };
} }
} }
@@ -441,10 +492,35 @@ export default class SystemService {
if (type && type.length) { if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
} }
const dataPaths = dataDirs.map((dir) => `data/${dir}`); const allowed = new Set([
await promiseExec( 'db',
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile 'upload',
} ${dataPaths.join(' ')}`, '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); res.download(config.dataTgzFile);
} catch (error: any) { } catch (error: any) {
@@ -469,6 +545,7 @@ export default class SystemService {
query: { query: {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
limit?: number;
}, },
) { ) {
const startTime = dayjs(query.startTime || undefined) const startTime = dayjs(query.startTime || undefined)
@@ -483,8 +560,30 @@ export default class SystemService {
.filter((x) => x.title.endsWith('.log')) .filter((x) => x.title.endsWith('.log'))
.filter((x) => x.createTime >= startTime && x.createTime <= endTime); .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({ 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) { (function sendFiles(res, fileNames) {
if (fileNames.length === 0) { if (fileNames.length === 0) {
@@ -496,20 +595,34 @@ export default class SystemService {
if (currentLog) { if (currentLog) {
const currentFileStream = fs.createReadStream( const currentFileStream = fs.createReadStream(
path.join(config.systemLogPath, currentLog.title), path.join(config.systemLogPath, currentLog.title),
{
start: currentLog.start,
end: currentLog.start + currentLog.length - 1,
},
); );
currentFileStream.on('end', () => { currentFileStream.on('end', () => {
sendFiles(res, fileNames); sendFiles(res, fileNames);
}); });
currentFileStream.pipe(res, { end: false }); currentFileStream.pipe(res, { end: false });
} }
})(res, logs); })(res, selected);
} }
public async deleteSystemLog() { public async deleteSystemLog() {
const result = await readDirs(config.systemLogPath, config.systemLogPath); const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log')); const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) { for (const log of logs) {
await rmPath(path.join(config.systemLogPath, log.title)); // All workers keep append handles open; unlinking strands future writes
// in a deleted inode until rotation. Preserve the file while clearing it.
const file = await fs.promises.open(
path.join(config.systemLogPath, log.title),
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
);
try {
await file.truncate(0);
} finally {
await file.close();
}
} }
} }
@@ -526,10 +639,40 @@ export default class SystemService {
if (success) { if (success) {
return { code: 200, data: info }; return { code: 200, data: info };
} else { } else {
return { code: 400, message: '设置时区失败' }; return { code: 400, message: t('设置时区失败') };
} }
} }
public async updateLanguage(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const lang = info.lang || 'zh';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, lang },
});
// Write to standalone lang_env.sh, sourced by shell scripts
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG=${shellQuote(lang)}\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
}
setLang(lang);
return { code: 200, data: { lang } };
}
public async updatePanelTitle(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const panelTitle = info.panelTitle?.trim() || '';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, panelTitle },
});
return { code: 200, data: { panelTitle } };
}
public async updateGlobalSshKey(info: SystemModelInfo) { public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@@ -553,12 +696,20 @@ export default class SystemService {
public async cleanDependence(type: 'node' | 'python3') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: t('参数错误') };
} }
try { const finalPath = path.join(config.dependenceCachePath, type);
const finalPath = path.join(config.dependenceCachePath, type); await fs.promises.rm(finalPath, { recursive: true, force: true });
await fs.promises.rm(finalPath, { recursive: true }); await DependenceModel.update(
} catch (error) { } { status: DependenceStatus.installFailed },
{
where: {
type:
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
status: DependenceStatus.installed,
},
},
);
return { code: 200 }; return { code: 200 };
} }
} }
+236 -53
View File
@@ -20,14 +20,24 @@ import ScheduleService from './schedule';
import SockService from './sock'; import SockService from './sock';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import IP2Region from 'ip2region'; import IP2Region from 'ip2region';
import requestIp from 'request-ip';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store'; 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() @Service()
export default class UserService { export default class UserService {
private loginThrottle = new LoginThrottle();
@Inject((type) => NotificationService) @Inject((type) => NotificationService)
private notificationService!: NotificationService; private notificationService!: NotificationService;
@@ -37,7 +47,15 @@ export default class UserService {
private sockService: SockService, private sockService: SockService,
) {} ) {}
@serializeAuthMutation
public async login( public async login(
payloads: { username: string; password: string },
req: Request,
): Promise<any> {
return this.authenticate(payloads, req);
}
private async authenticate(
payloads: { payloads: {
username: string; username: string;
password: string; password: string;
@@ -47,7 +65,18 @@ export default class UserService {
): Promise<any> { ): Promise<any> {
let { username, password } = payloads; let { username, password } = payloads;
const content = await this.getAuthInfo(); const content = await this.getAuthInfo();
if (isDefaultAuthInfo(content)) {
return { code: 450, message: t('请先初始化') };
}
const timestamp = Date.now(); 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 { let {
username: cUsername, username: cUsername,
password: cPassword, password: cPassword,
@@ -58,27 +87,43 @@ export default class UserService {
twoFactorActivated, twoFactorActivated,
tokens = {}, tokens = {},
platform, platform,
blockedIps = [],
} = content; } = content;
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) { if (
const waitTime = Math.ceil( ip &&
(retriesTime - (timestamp - lastlogon)) / 1000, 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 { return {
code: 410, code: 410,
message: `失败次数过多,请${waitTime}秒后重试`, message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime, data: waitTime,
}; };
} }
if ( const passwordMatches =
username === cUsername && username === cUsername && (await verifyPassword(password, cPassword));
password === cPassword &&
twoFactorActivated && if (passwordMatches && twoFactorActivated && needTwoFactor) {
needTwoFactor
) {
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
isTwoFactorChecking: true, isTwoFactorChecking: true,
twoFactorExpiresAt: timestamp + 5 * 60 * 1000,
}); });
return { return {
code: 420, code: 420,
@@ -86,15 +131,8 @@ export default class UserService {
}; };
} }
const ip = requestIp.getClientIp(req) || ''; if (passwordMatches) {
const query = new IP2Region(); this.loginThrottle.reset(ip);
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) {
const data = createRandomString(50, 100); const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d'; const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, { let token = jwt.sign({ data }, config.jwt.secret, {
@@ -117,6 +155,9 @@ export default class UserService {
); );
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
password: isPasswordHash(cPassword)
? cPassword
: await hashPassword(password),
token, token,
tokens: updatedTokens, tokens: updatedTokens,
lastlogon: timestamp, lastlogon: timestamp,
@@ -125,12 +166,22 @@ export default class UserService {
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
isTwoFactorChecking: false, isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
}); });
this.notificationService.notify( this.notificationService.notify(
'登录通知', t('登录通知'),
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ t('你于') +
req.platform dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
}端 登录成功,ip地址 ${ip}`, t('在') +
t(address) +
' ' +
req.platform +
t('端') +
' ' +
t('登录成功') +
t(',ip地址') +
' ' +
ip,
); );
await this.insertDb({ await this.insertDb({
type: AuthDataType.loginLog, type: AuthDataType.loginLog,
@@ -155,18 +206,28 @@ export default class UserService {
}, },
}; };
} else { } else {
const failures = this.loginThrottle.fail(ip, timestamp);
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
retries: retries + 1, retries: failures,
lastlogon: timestamp, lastlogon: timestamp,
lastip: ip, lastip: ip,
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
}); });
this.notificationService.notify( this.notificationService.notify(
'登录通知', t('登录通知'),
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ t('你于') +
req.platform dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
}端 登录失败,ip地址 ${ip}`, t('在') +
t(address) +
' ' +
req.platform +
t('端') +
' ' +
t('登录失败') +
t(',ip地址') +
' ' +
ip,
); );
await this.insertDb({ await this.insertDb({
type: AuthDataType.loginLog, type: AuthDataType.loginLog,
@@ -179,19 +240,20 @@ export default class UserService {
}, },
}); });
this.getLoginLog(); this.getLoginLog();
if (retries > 2) { if (failures > 3) {
const waitTime = Math.round(Math.pow(3, retries + 1)); const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
return { return {
code: 410, code: 410,
message: `失败次数过多,请${waitTime}秒后重试`, message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime, data: waitTime,
}; };
} else { } else {
return { code: 400, message: config.authError }; return { code: 400, message: t('错误的用户名密码,请重试') };
} }
} }
} }
@serializeAuthMutation
public async logout(platform: string, tokenValue: string): Promise<any> { public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) { if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token'); this.logger.warn('Invalid logout parameters - empty platform or token');
@@ -245,11 +307,67 @@ export default class UserService {
return []; 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> { private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true }); const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc; 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({ public async updateUsernameAndPassword({
username, username,
password, password,
@@ -258,27 +376,40 @@ export default class UserService {
password: string; password: string;
}) { }) {
if (password === 'admin') { if (password === 'admin') {
return { code: 400, message: '密码不能设置为admin' }; return { code: 400, message: t('密码不能设置为admin') };
} }
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password }); await this.updateAuthInfo(authInfo, {
return { code: 200, message: '更新成功' }; username,
password: await hashPassword(password),
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
return { code: 200, message: t('更新成功') };
} }
@serializeAuthMutation
public async updateAvatar(avatar: string) { public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar }); await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: '更新成功' }; return { code: 200, data: avatar, message: t('更新成功') };
} }
@serializeAuthMutation
public async initTwoFactor() { public async initTwoFactor() {
const secret = authenticator.generateSecret(); const secret = authenticator.generateSecret();
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
if (authInfo.twoFactorActivated) {
throw new Error(t('请先关闭两步验证'));
}
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth }; return { secret, url: otpauth };
} }
@serializeAuthMutation
public async activeTwoFactor(code: string) { public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const isValid = authenticator.verify({ const isValid = authenticator.verify({
@@ -286,11 +417,18 @@ export default class UserService {
secret: authInfo.twoFactorSecret, secret: authInfo.twoFactorSecret,
}); });
if (isValid) { if (isValid) {
await this.updateAuthInfo(authInfo, { twoFactorActivated: true }); await this.updateAuthInfo(authInfo, {
twoFactorActivated: true,
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
});
} }
return isValid; return isValid;
} }
@serializeAuthMutation
public async twoFactorLogin( public async twoFactorLogin(
{ {
username, username,
@@ -301,17 +439,30 @@ export default class UserService {
) { ) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo; const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) { const now = Date.now();
return { code: 450, message: '未知错误' }; const source = getClientIp(req);
if (this.loginThrottle.retryAfter(source, now) > 0) {
return { code: 410, message: t('失败次数过多,请稍后重试') };
} }
const isValid = authenticator.verify({ if (
token: code, !isTwoFactorChecking ||
secret: twoFactorSecret, !authInfo.twoFactorActivated ||
}); !authInfo.twoFactorExpiresAt ||
authInfo.twoFactorExpiresAt <= now
) {
return { code: 450, message: t('未知错误') };
}
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) { if (isValid) {
return this.login({ username, password }, req, false); await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step });
return this.authenticate({ username, password }, req, false);
} else { } else {
const ip = requestIp.getClientIp(req) || ''; const ip = getClientIp(req);
const query = new IP2Region(); const query = new IP2Region();
const ipAddress = query.search(ip); const ipAddress = query.search(ip);
let address = ''; let address = '';
@@ -322,19 +473,26 @@ export default class UserService {
.join(' '); .join(' ');
} }
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
retries: this.loginThrottle.fail(source, now),
lastlogon: now,
lastip: ip, lastip: ip,
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
}); });
return { code: 430, message: '验证失败' }; return { code: 430, message: t('验证失败') };
} }
} }
public async deactiveTwoFactor() { @serializeAuthMutation
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
twoFactorActivated: false, twoFactorActivated: false,
twoFactorSecret: '', twoFactorSecret: '',
token: '',
tokens: {},
isTwoFactorChecking: false,
twoFactorExpiresAt: 0,
}); });
return true; return true;
} }
@@ -355,6 +513,9 @@ export default class UserService {
type: AuthDataType.authConfig, type: AuthDataType.authConfig,
info: result, info: result,
}); });
if (info.tokens && Object.keys(info.tokens).length === 0) {
this.sockService.getClients().forEach((conn) => conn.close('401'));
}
} }
public async getNotificationMode(): Promise<NotificationInfo> { public async getNotificationMode(): Promise<NotificationInfo> {
@@ -388,8 +549,8 @@ export default class UserService {
const code = Math.random().toString().slice(-6); const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify( const isSuccess = await this.notificationService.testNotify(
notificationInfo, notificationInfo,
'青龙', t('青龙'),
`【蛟龙】测试通知 https://t.me/jiao_long`, t('【蛟龙】测试通知 https://t.me/jiao_long'),
); );
if (isSuccess) { if (isSuccess) {
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@@ -398,7 +559,7 @@ export default class UserService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
@@ -496,8 +657,12 @@ export default class UserService {
return undefined; return undefined;
} }
@serializeAuthMutation
public async resetAuthInfo(info: Partial<AuthInfo>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info; const { retries, twoFactorActivated, password, username } = info;
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const payload = pickBy( const payload = pickBy(
{ {
@@ -509,6 +674,24 @@ export default class UserService {
(x) => !isNil(x), (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); await this.updateAuthInfo(authInfo, payload);
if (retries === 0 || password !== undefined || username !== undefined) {
this.loginThrottle.clear();
}
} }
} }
+21 -1
View File
@@ -1,4 +1,9 @@
import { AuthInfo, TokenInfo } from '../data/system'; import { AuthInfo, TokenInfo } from '../data/system';
import jwt from 'jsonwebtoken';
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
return authInfo.username === 'admin' && authInfo.password === 'admin';
}
/** /**
* Validates if a token exists in the authentication info. * Validates if a token exists in the authentication info.
@@ -13,11 +18,21 @@ export function isValidToken(
authInfo: AuthInfo | null | undefined, authInfo: AuthInfo | null | undefined,
headerToken: string, headerToken: string,
platform: string, platform: string,
secret: string,
): boolean { ): boolean {
if (!authInfo || !headerToken) { if (!authInfo || !headerToken) {
return false; 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; const { token = '', tokens = {} } = authInfo;
// Check legacy token field // Check legacy token field
@@ -38,7 +53,12 @@ export function isValidToken(
return headerToken === platformTokens; return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) { } else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects // 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 // 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 '';
}
+167
View File
@@ -0,0 +1,167 @@
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>();
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// 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');
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
// step does not land on 7. node-cron accepts those ranges with different days.
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
normalized[5] = expression.fields.dayOfWeek.values.join(',');
}
// Most rules are already supported; only inspect individual fields on failure.
if (!validate(normalized.join(' '))) {
const fields = [
expression.fields.second,
expression.fields.minute,
expression.fields.hour,
expression.fields.dayOfMonth,
expression.fields.month,
expression.fields.dayOfWeek,
];
normalized = normalized.map((value, index) => {
// Preserve supported lists, ranges and names. Expand only fields node-cron
// cannot consume, such as numeric/name-start steps; never compress them.
const probe = ['0', '0', '0', '*', '*', '*'];
probe[index] = value;
if (validate(probe.join(' '))) return value;
const values = fields[index].values;
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
if (values.length === 8) return '*';
// # applies to a single weekday; normalize its numeric spelling only.
return values.length === 1
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
: value;
}
return values.join(',');
});
}
const months = expression.fields.month.values;
// cron-parser 4 clipped dates outside a single selected month's maximum
// before deciding whether the day field covered the whole month.
const dayCount = expression.fields.dayOfMonth.values.filter(
(day) =>
months.length !== 1 ||
typeof day !== 'number' ||
day <= daysInMonth[months[0] - 1],
).length;
const weekCount = expression.fields.dayOfWeek.values.length;
// Preserve the legacy count-based wildcard rules, including repeated Sunday
// and L tokens: an ignored day field must not remain an AND constraint.
const weekdayPattern = [
...normalized.slice(0, 3),
'*',
...normalized.slice(4),
].join(' ');
const patterns =
weekCount === 8
? [[...normalized.slice(0, 5), '*'].join(' ')]
: dayCount < 31
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
: [weekdayPattern];
const valid = patterns.map((pattern) => validate(pattern));
if (patterns.length === 2 && !valid[0] && valid[1]) {
// A date branch can be impossible for all chosen months while the weekday
// branch still runs. Drop it only if its syntax is valid with any month.
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
patterns.shift();
valid.shift();
}
}
if (!valid.every(Boolean)) {
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));
}
+90
View File
@@ -0,0 +1,90 @@
import { t } from './i18n';
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(
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
options.name,
date.toISOString(),
);
}
try {
await callback(date);
} catch (error) {
options.logger.error(
t('[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 '';
}
}
+311
View File
@@ -0,0 +1,311 @@
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'升级任务启动失败,请检查日志后重试':
'Failed to start the update. Check the logs and try again.',
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
'[schedule][Stopping running task] Task ID: %s, PID: %s',
'接口已下线,请使用 /configs/detail 接口':
'This endpoint has been retired. Use /configs/detail instead.',
'接口已下线,请使用 /logs/detail 接口':
'This endpoint has been retired. Use /logs/detail instead.',
'接口已下线,请使用 /scripts/detail 接口':
'This endpoint has been retired. Use /scripts/detail instead.',
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
请先初始化: 'Please initialize the panel first',
请先关闭两步验证: 'Please disable two-factor authentication first',
'失败次数过多,请稍后重试':
'Too many failed attempts. Please try again later.',
配置更新失败: 'Failed to update configuration',
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
脚本不存在: 'Script does not exist',
请选择脚本文件: 'Please select a script file',
'历史版本读取失败,请检查历史文件':
'Failed to read version history. Check the history file.',
历史版本不存在或已清理:
'This history version does not exist or has been removed',
'文件已变化,请刷新历史版本后重试':
'The file has changed. Refresh version history and try again.',
'历史版本容量不足,无法安全保存':
'Version history storage is full. The file cannot be saved safely.',
'跳过订阅 %s 的无效定时配置: %s':
'Skipping invalid schedule for subscription %s: %s',
'[schedule][准备运行任务] 命令: %s':
'[schedule][Preparing task] Command: %s',
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
'[schedule][取消定时任务] 任务ID: %s':
'[schedule][Cancelling scheduled task] Task ID: %s',
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
'[panel][停止任务失败] PID: %s, 错误: %s':
'[panel][Failed to stop task] PID: %s, error: %s',
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
'[panel][Task failed] Task ID: %s, error: %s',
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
'[panel][Task finished] Task ID: %s, exit code: %j',
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
'[panel][Failed to create task] Task ID: %s, error: %s',
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
'性能指标报告:': 'Performance metrics:',
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
'[panel][任务结束回调失败] %s':
'[panel][Task completion callback failed] %s',
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
'[panel][取消定时任务] 任务名: %s':
'[panel][Cancelling scheduled task] Name: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
'[panel][Task failed] Command: %s, error: %j',
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
'[panel][Cancelling interval task] Task ID: %s, name: %s',
生成私钥文件失败: 'Failed to generate private key file',
删除私钥文件失败: 'Failed to delete private key file',
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
'跳过订阅 %s 的无效 SSH 配置: %s':
'Skipping invalid SSH configuration for subscription %s: %s',
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
'[schedule][Running late task] Task: %s, scheduled time: %s',
'[schedule][定时回调失败] 任务: %s, 错误: %s':
'[schedule][Scheduled callback failed] Task: %s, error: %s',
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task queued] Running: %s, waiting: %s',
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Processing task] Running: %s, waiting: %s',
'[schedule][任务处理成功] 参数 %s':
'[schedule][Task succeeded] Arguments: %s',
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task finished] Running: %s, waiting: %s',
'[schedule][任务重复运行] 通知失败 %s':
'[schedule][Duplicate task] Notification failed: %s',
'[schedule][任务重复运行] 参数 %s':
'[schedule][Duplicate task] Arguments: %s',
'[schedule][停止已运行任务] 任务ID: %s':
'[schedule][Stopping running task] Task ID: %s',
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to check running task] Task ID: %s, error: %s',
'[schedule][开始执行任务] 参数 %s':
'[schedule][Starting task] Arguments: %s',
'[schedule][任务标准错误] 命令: %s, 信息: %s':
'[schedule][Task stderr] Command: %s, message: %s',
'[schedule][执行任务失败] 命令: %s, 错误: %s':
'[schedule][Task failed] Command: %s, error: %s',
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
'[schedule][Task finished] Task ID: %s, exit code: %j',
'[schedule][创建任务失败] 命令: %s, 错误: %s':
'[schedule][Failed to create task] Command: %s, error: %s',
'检查更新失败,请检查网络后重试':
'Failed to check for updates. Check your network and try again.',
'资源已存在,请检查重复的名称或值':
'Resource already exists; check for duplicate names or values',
暂无权限: 'Access denied',
参数错误: 'Invalid parameter',
参数不正确: 'Invalid parameter',
文件无法访问: 'File not accessible',
文件不存在: 'File not found',
路径不存在: 'Path does not exist',
路径不正确: 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置':
'Notification failed, check system settings',
通知发送成功: 'Notification sent successfully',
设置时区失败: 'Failed to set timezone',
任务未找到: 'Task not found',
密码不能设置为admin: 'Password cannot be admin',
更新成功: 'Update successful',
未知错误: 'Unknown error',
验证失败: 'Verification failed',
实例不存在或已停止: 'Instance does not exist or stopped',
实例已停止: 'Instance stopped',
确认停止实例: 'Confirm to stop instance',
确认停止运行实例: 'Confirm to stop running instance',
确认停止: 'Confirm to stop',
确认停止定时任务: 'Confirm to stop scheduled task',
确认删除: 'Confirm to delete',
确认删除定时任务: 'Confirm to delete scheduled task',
确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
确认运行: 'Confirm to run',
确认运行定时任务: 'Confirm to run scheduled task',
确认保存: 'Confirm to save',
确认保存文件: 'Confirm to save file',
确认重启: 'Confirm restart',
确认启用: 'Confirm to enable',
确认禁用: 'Confirm to disable',
确认: 'Confirm',
删除成功: 'Deleted successfully',
操作成功: 'Operation successful',
参数不完整: 'Incomplete parameters',
默认路径不支持删除: 'Default path cannot be deleted',
必须在日志目录下: 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
系统将在: 'System will',
秒后自动刷新: 'refresh in seconds',
'生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式':
'Each entry must have name and value, see export format',
不支持当前依赖类型: 'Unsupported dependency type',
依赖已存在: 'Dependency already exists',
依赖不存在: 'Dependency does not exist',
该脚本正在运行中: 'Script is running',
该脚本未在运行中: 'Script is not running',
文件内容为空: 'File content is empty',
文件名不能为空: 'File name cannot be empty',
标签不能为空: 'Label cannot be empty',
名称不能为空: 'Name cannot be empty',
名称不能为保留关键字: 'Name cannot be reserved keyword',
名称已存在: 'Name already exists',
密码错误: 'Incorrect password',
用户不存在: 'User does not exist',
请输入用户名和密码: 'Please enter username and password',
无权访问: 'Access denied',
登录成功: 'Login successful',
退出成功: 'Logout successful',
'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token',
两步骤验证已开启: '2FA enabled',
两步骤验证已关闭: '2FA disabled',
验证码错误: 'Invalid verification code',
验证码已过期: 'Verification code expired',
请先开启两步骤验证: 'Please enable 2FA first',
两步骤验证密钥不能为空: '2FA secret cannot be empty',
用户已存在: 'User already exists',
用户名不能为admin: 'Username cannot be admin',
不能删除自己: 'Cannot delete yourself',
不能禁用自己: 'Cannot disable yourself',
文件路径无效: 'Invalid file path',
保存成功: 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
订阅执行完成: 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null',
请先登录: 'Please login first',
'运行中...': 'Running...',
'日志不存在...': 'Log does not exist...',
未分类: 'Uncategorized',
任务重复运行: 'Duplicate task execution',
日志设置为忽略: 'Log set to ignore',
定时规则不能为空: 'Schedule rule cannot be empty',
无效的定时规则: 'Invalid schedule rule',
定时规则必须是字符串: 'Schedule rule must be a string',
额外定时规则必须是对象: 'Extra schedule must be an object',
额外定时规则必须是数组: 'Extra schedules must be an array',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'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 ',
在: ' in ',
端: ' client',
内网IP: 'Private IP',
登录失败: 'login failed',
',ip地址': ', IP: ',
'任务#%s': 'Task#%s',
安装: 'Install',
删除: 'Uninstall',
成功: 'Succeeded',
失败: 'Failed',
'失败次数过多,请%s秒后重试':
'Too many failed attempts, please retry in %s seconds',
智能助手: 'Smart Assistant',
更多: 'More',
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒':
'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds',
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒':
'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds',
'开始%s依赖 %s,开始时间 %s\n\n':
'Start %s dependency %s, start time %s\n\n',
'依赖%s%s,结束时间 %s,耗时 %s 秒':
'Dependency %s%s, end time %s, elapsed %s seconds',
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置':
'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings',
青龙: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long',
生成token: 'Generate token',
删除日志: 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒':
'## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚':
'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back',
'任务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"',
},
};
let currentLang: string = 'zh';
export function setLang(lang: string, persist = true) {
currentLang = lang || 'zh';
if (!persist) return;
// Translation is also used by standalone scheduler and CLI helpers. Load
// persistent storage only when changing the language, not when reading it.
const { shareStore } = require('./store') as typeof import('./store');
const language = currentLang;
return shareStore.setLang(language).then(() => {
if (currentLang === language && process.connected && process.send) {
process.send({ type: 'language-changed', lang: language }, () => {});
}
});
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
export function systemLang(): string {
const prefix = Intl.DateTimeFormat().resolvedOptions().locale.split('-')[0];
return prefix === 'en' ? 'en' : 'zh';
}
export function t(key: string, lang?: string): string {
const effectiveLang = lang || currentLang;
if (effectiveLang === 'en' && messages.en[key]) {
return messages.en[key];
}
return key;
}
export function tf(key: string, ...args: (string | number)[]): string {
return args.reduce<string>(
(str, arg) => str.replace(/%s|%d/, String(arg)),
t(key),
);
}

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