fix(dependencies): quote and validate Python source installs

This commit is contained in:
whyour
2026-10-12 00:23:48 +08:00
494 changed files with 56787 additions and 5398 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitnexus
.claude
.codex
.env*
node_modules
data
audit
docs
test
src/.umi*
**/__pycache__
**/*.log
+12
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=
+396 -109
View File
@@ -2,152 +2,237 @@ 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"
- "!.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
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: "20"
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
- uses: actions/upload-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
path: static/
if-no-files-found: error
include-hidden-files: true
- name: copy to static repo - 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
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: | run: |
mkdir -p tmp bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
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
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: "20"
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 +240,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 +254,306 @@ 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 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/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
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: "20"
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)
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/arm/v7,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: "20"
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: "20"
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/arm/v7,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
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
node-version: "20"
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- 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
- name: Publish npm package with OIDC
shell: bash
run: |
set -euo pipefail
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
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 --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
+161
View File
@@ -0,0 +1,161 @@
name: CLI package
on:
pull_request:
paths:
- 'cli/**'
- 'shell/**'
- 'back/api/**'
- 'back/loaders/deps.ts'
- 'package.json'
- '.github/workflows/cli-package.yml'
push:
branches: [develop, master]
paths:
- 'cli/**'
- 'shell/**'
- 'back/api/**'
- 'back/loaders/deps.ts'
- 'package.json'
- '.github/workflows/cli-package.yml'
workflow_dispatch:
inputs:
publish:
description: Publish the verified CLI package (master stable / develop prerelease)
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
type: string
default: ''
permissions:
contents: read
concurrency:
# Keep validation runs separate from releases; never interrupt a running release.
# Master push releases share the same group as manually requested releases.
group: >-
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
cancel-in-progress: >-
${{ github.ref != 'refs/heads/master' &&
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
jobs:
package:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: ['22.12.0', '24']
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: cli/package-lock.json
- name: Install test interpreters and tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
- name: Set requested prerelease version before verification
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
working-directory: cli
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash
run: |
set -euo pipefail
if [[ "$GITHUB_REF" != refs/heads/develop ||
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
exit 1
fi
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run check:cli
- run: npm run test:cli
- name: Verify offline package installation
env:
QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package
run: node cli/scripts/verify-package.cjs
- name: Upload verified npm archive
if: matrix.node == '24'
uses: actions/upload-artifact@v6
with:
name: qinglong-cli-${{ github.sha }}
path: ${{ runner.temp }}/cli-package/*.tgz
if-no-files-found: error
retention-days: 14
publish:
needs: package
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish))) ||
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
inputs.publish && inputs.prerelease_version != ''))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
id-token: write
steps:
- uses: actions/setup-node@v6
with:
node-version: '24'
package-manager-cache: false
registry-url: https://registry.npmjs.org
- uses: actions/download-artifact@v7
with:
name: qinglong-cli-${{ github.sha }}
path: cli-package
- name: Publish verified npm archive
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(cli-package/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified CLI archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
if [[ "$version" != "$PRERELEASE_VERSION" ||
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo '::error::Archive version does not match the requested prerelease.'
exit 1
fi
tag="${BASH_REMATCH[1]}"
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
tag=latest
else
echo '::error::Refusing to publish this branch/version combination.'
exit 1
fi
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
jq -e --arg version "$version" '. == $version' version.json > /dev/null
if [[ "$tag" != latest ]]; then
echo '::error::Prerelease version already exists; choose a new version to test this build.'
exit 1
fi
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
exit 0
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
echo '::error::Could not check the published CLI version; refusing to publish.'
exit 1
fi
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
+136
View File
@@ -0,0 +1,136 @@
name: Release readiness
on:
pull_request:
paths:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- 'pnpm-lock.yaml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
regression:
uses: ./.github/workflows/validate.yml
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
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/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Record source
id: source
env:
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v7
with:
name: release-readiness-static
path: static/
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build every release architecture
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }}
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
push: false
provenance: false
sbom: false
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
- name: Load native image for runtime verification
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: linux/amd64
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
tags: qinglong-readiness:${{ matrix.image }}
load: true
push: false
provenance: false
sbom: false
- name: Verify native production runtime without external networking
run: |
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
-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.3.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: Export native Debian image for isolated dev validation
if: matrix.image == 'debian'
run: |
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian'
with:
name: release-readiness-debian-image
path: release-readiness-debian-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
+10
View File
@@ -28,3 +28,13 @@ __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
.deepseek/
.claude/
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
# Local security audit artifacts
/audit/
+22
View File
@@ -0,0 +1,22 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+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 -->
+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
+55
View File
@@ -0,0 +1,55 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import {
diagnoseClientIp,
getTrustProxyConfig,
updateTrustProxy,
} from '../shared/trustProxy';
const route = Router();
export default (app: Router) => {
app.use('/system/client-ip', route);
route.get(
'/config',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await getTrustProxyConfig() });
} catch (error) {
next(error);
}
},
);
route.put(
'/config',
celebrate({
body: Joi.object({
trustProxy: Joi.string().max(500).required(),
}),
}),
async (req: Request, res: Response) => {
try {
const data = await updateTrustProxy(req.body.trustProxy);
res.send({ code: 200, data });
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : '配置更新失败',
});
}
},
);
route.get(
'/diagnose',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await diagnoseClientIp(req) });
} catch (error) {
next(error);
}
},
);
};
+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 });
+68 -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: {
@@ -254,6 +246,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 +256,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 +276,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 +358,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 +374,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) {
@@ -400,8 +409,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 +438,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);
} }
+71 -60
View File
@@ -1,13 +1,10 @@
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 { type GrpcServerService } from './services/grpc'; import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http'; import { type HttpServerService } from './services/http';
@@ -19,24 +16,12 @@ 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) {
@@ -48,7 +33,7 @@ class Application {
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);
} }
} }
@@ -60,11 +45,11 @@ class Application {
// 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, 30000)
.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 +58,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, 30000)
.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 +121,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 +141,37 @@ 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({ // 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, contentSecurityPolicy: false,
})); }));
this.app.use(cors(config.cors)); app.use(cors(config.cors));
this.app.use(compression()); app.use(compression());
this.app.use(monitoringMiddleware); app.use(monitoringMiddleware);
return app;
} }
private setupMasterShutdown() { private setupMasterShutdown() {
@@ -169,17 +186,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 +206,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 +225,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 +240,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 +280,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 +304,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 +312,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();
});
+39
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';
@@ -48,4 +50,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;
} }
+433 -111
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
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';
@@ -10,9 +10,41 @@ 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';
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 +146,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 +217,105 @@ export function dirSort(a: IFile, b: IFile): number {
} }
} }
const FILE_SYSTEM_READ_CONCURRENCY = 32;
type FileSystemTaskRunner = <T>(task: () => Promise<T>) => Promise<T>;
function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner {
let activeCount = 0;
const queue: Array<() => void> = [];
const runNext = () => {
while (activeCount < concurrency && queue.length > 0) {
activeCount += 1;
queue.shift()?.();
}
};
return <T>(task: () => Promise<T>) =>
new Promise<T>((resolve, reject) => {
queue.push(() => {
task()
.then(resolve, reject)
.finally(() => {
activeCount -= 1;
runNext();
});
});
runNext();
});
}
async function readDirsWithRunner(
dir: string,
baseDir: string,
blacklist: string[],
sort: (a: IFile, b: IFile) => number,
runFileSystemTask: FileSystemTaskRunner,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir);
const entries = await runFileSystemTask(() =>
fs.readdir(dir, { withFileTypes: true }),
);
const items = await Promise.all(
entries.map(async (entry): Promise<IFile | undefined> => {
if (blacklist.includes(entry.name) || entry.isSymbolicLink()) {
return undefined;
}
const subPath = path.join(dir, entry.name);
const stats = await runFileSystemTask(() => fs.lstat(subPath));
if (stats.isSymbolicLink()) {
return undefined;
}
const key = path.join(relativePath, entry.name);
if (stats.isDirectory()) {
const children = await readDirsWithRunner(
subPath,
baseDir,
blacklist,
sort,
runFileSystemTask,
);
return {
title: entry.name,
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
children,
};
}
return {
title: entry.name,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
};
}),
);
return items.filter((item): item is IFile => Boolean(item)).sort(sort);
}
export async function readDirs( 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 +323,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 +490,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 +647,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 +661,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,12 +671,14 @@ 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;
} }
} }
@@ -558,9 +706,11 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
// For Python dependencies installed from GitHub or requirements files, // For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check // we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) { if (type === DependenceTypes.python3) {
if (isGitHubUrl(trimmedName) || if (
isRequirementsFile(trimmedName) || isGitHubUrl(trimmedName) ||
isPyprojectToml(trimmedName)) { isRequirementsFile(trimmedName) ||
isPyprojectToml(trimmedName)
) {
// Return a command that will always indicate not installed // Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed // This ensures GitHub URLs and requirements files are always installed
return 'echo ""'; return 'echo ""';
@@ -569,81 +719,253 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`, [DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: ` [DependenceTypes.python3]: `python3 -c ${shellQuote(
python3 -c "exec(''' `from importlib.metadata import version\nimport importlib.util as u\nname=${JSON.stringify(
name='${trimmedName}' trimmedName,
try: )}\ntry:\n print(version(name))\nexcept Exception:\n print(name if u.find_spec(name) else '')`,
from importlib.metadata import version )}`,
print(version(name)) [DependenceTypes.linux]:
except: getOsTypeSync() === 'Alpine'
import importlib.util as u ? `apk info -es ${trimmedName}`
import importlib.metadata as m : maybeSudo(`dpkg-query -s ${trimmedName}`),
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: `apk info -es ${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
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
: '';
const pip = `pip3 install --disable-pip-version-check --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(Boolean);
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}`;
}
const trimmedName = name.trim();
// Handle different installation methods for Python
if (type === DependenceTypes.python3) {
// Check if it's a GitHub URL (support both git+ and direct URLs)
if (isGitHubUrl(trimmedName)) {
return `${command} ${trimmedName}`;
}
// Check if it's a requirements file path
if (isRequirementsFile(trimmedName)) {
return `${command} -r ${trimmedName}`;
}
// Check if it's a pyproject.toml file
if (isPyprojectToml(trimmedName)) {
// For pyproject.toml, install from the directory containing it
const pathMatch = trimmedName.match(/^(.+)\/pyproject\.toml$/);
if (pathMatch) {
// Has a path prefix, use the directory
return `${command} ${pathMatch[1]}`;
} else {
// Just "pyproject.toml", install current directory
return `${command} .`;
}
}
}
return `${command} ${trimmedName}`;
} }
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(Boolean);
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return `pip3 uninstall --disable-pip-version-check --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.',
)}`;
}
return `pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y -- ${shellQuote(
name,
)}`;
}
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 getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
): Promise<string> {
const S = isInContainer() ? 'sudo ' : '';
// APT otherwise returns zero when index downloads fail with transient errors.
const aptUpdate = `${S}apt-get update --error-on=any`;
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${
mirrorDomainWithScheme || 'http://deb.debian.org'
}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${
mirrorDomainWithScheme || 'http://deb.debian.org'
}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${
mirrorDomainWithScheme || 'http://deb.debian.org'
}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${
mirrorDomainWithScheme || 'http://archive.ubuntu.com'
}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${
mirrorDomainWithScheme || 'http://archive.ubuntu.com'
}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${
mirrorDomainWithScheme || 'http://archive.ubuntu.com'
}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${
mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'
}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${
mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'
}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${
mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'
}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
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;
+7
View File
@@ -31,6 +31,8 @@ export enum AuthDataType {
} }
export interface SystemConfigInfo { export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
logRemoveFrequency?: number; logRemoveFrequency?: number;
cronConcurrency?: number; cronConcurrency?: number;
dependenceProxy?: string; dependenceProxy?: string;
@@ -39,6 +41,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 {
@@ -71,11 +75,14 @@ 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 &
+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));
}; };
+88 -31
View File
@@ -8,14 +8,40 @@ import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite'; import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import { errors } from 'celebrate';
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();
} }
@@ -135,6 +191,7 @@ export default ({ app }: { app: Application }) => {
}); });
app.use(errors()); app.use(errors());
app.use(uniqueConstraintError);
app.use( app.use(
( (
+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('跳过订阅 %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;
+8
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 {
+85 -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/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 {
@@ -2959,6 +2975,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 +3191,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 +3765,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 +3889,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 +4105,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 +4194,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;
}, },
}; };
+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 { 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('[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(
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
id,
jobs.length,
);
} }
callback(null, null); callback(null, null);
+10 -6
View File
@@ -38,6 +38,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);
@@ -247,13 +248,16 @@ export const systemNotify = async (
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();
+14 -1
View File
@@ -13,7 +13,20 @@ const delCron = (
'[schedule][取消定时任务] 任务ID: %s', '[schedule][取消定时任务] 任务ID: %s',
id, id,
); );
scheduleStacks.get(id)?.forEach(x => x.cancel()); // 防御性过滤历史调度栈中的空任务,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
'[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}`, '[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; `[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${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', '[panel][执行任务失败] 任务ID: %s, 错误: %s',
command, id,
err, result.error.message
); );
await logStreamManager.write(absolutePath, JSON.stringify(err)); }
}); this.logger.info(
'[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(
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
cronId,
asError(error).message
);
} finally {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
'[panel][关闭任务日志失败] %s',
asError(error).message
); );
// 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(
}); '[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!);
}
} }
} }
} }
+194 -41
View File
@@ -16,22 +16,27 @@ import {
getPid, getPid,
killTask, killTask,
promiseExecSuccess, promiseExecSuccess,
concurrentRun,
getInstallCommand, getInstallCommand,
getUninstallCommand, getUninstallCommand,
getGetCommand, getGetCommand,
} 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 +60,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;
@@ -107,6 +113,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 +140,69 @@ export default class DependenceService {
} }
} }
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
DependenceTypes.python3,
];
if (type !== undefined && !cacheDependenceTypes.includes(type)) {
return;
}
const docs = await DependenceModel.findAll({
where: {
status: DependenceStatus.installed,
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checks = await concurrentRun(
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
);
}
}
private async isDependenceInstalled(dependency: Dependence) {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
const [, parsedName, parsedVersion] = depName.match(symbolRegx) || [];
if (parsedVersion && parsedName) {
depName = parsedName;
depVersion = parsedVersion;
}
}
const depInfo = (
await promiseExecSuccess(getGetCommand(dependency.type, depName))
)
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
const nameMatches =
(dependency.type === DependenceTypes.nodejs &&
depInfo.split(' ')?.[0] === depName) ||
dependency.type === DependenceTypes.python3;
return Boolean(
depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)),
);
}
public installDependenceOneByOne( public installDependenceOneByOne(
docs: Dependence[], docs: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
@@ -159,8 +233,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,43 +298,102 @@ 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);
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) { if (isInstall && !force) {
const getCommand = getGetCommand(dependency.type, depName); 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 (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp( const symbolRegx = new RegExp(
@@ -261,10 +405,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 +413,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 +455,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 +465,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 +475,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 +483,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 +516,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);
await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
} 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;
+2 -4
View File
@@ -1,6 +1,6 @@
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';
interface HealthStatus { interface HealthStatus {
@@ -23,7 +23,6 @@ export class HealthService {
private startTime = Date.now(); private startTime = Date.now();
constructor( constructor(
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService, private httpServerService: HttpServerService,
) {} ) {}
@@ -56,8 +55,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 : '';
} }
} }
+58 -16
View File
@@ -8,16 +8,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 +31,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 +51,11 @@ class MetricsService {
} }
} }
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) { async measureAsync(
name: string,
fn: () => Promise<void>,
tags?: Record<string, string>,
) {
const start = performance.now(); const start = performance.now();
try { try {
await fn(); await fn();
@@ -57,28 +66,61 @@ 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(),
+125 -8
View File
@@ -3,6 +3,7 @@ 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 +35,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 = '';
@@ -90,6 +94,14 @@ export default class NotificationService {
return true; 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() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params; const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try { try {
@@ -355,7 +367,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 +383,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 +434,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 +445,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 +603,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 +615,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 +730,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 +758,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 +872,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 +917,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),
),
};
}
}
+112 -80
View File
@@ -1,6 +1,6 @@
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 +11,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 +32,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 +42,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 +68,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('[panel][开始执行任务] 任务ID: %s', others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
const observed = observeChildProcess(child, {
onStart: async () => {
await callbacks.onStart?.(child, startTime);
},
onStdout: callbacks.onLog,
onStderr: callbacks.onError,
});
observed.started.then(resolveStart, rejectStart);
result = await observed.completed;
} catch (error) {
result.error = asError(error);
rejectStart(result.error);
}
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
others.id,
result.error.message,
);
try {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
'[panel][任务错误回调失败] %s',
asError(error).message,
);
}
}
// Cleanup also runs after setup/spawn failure, and only after both pipes drain.
const endTime = dayjs();
try {
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
} catch (error) {
result.error ??= asError(error);
this.logger.error(
'[panel][任务结束回调失败] %s',
asError(error).message,
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
others.id,
result.code,
);
return { ...others, pid: cp?.pid, ...result };
},
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
// Returning a PID must not release the execution slot while the process runs.
return completionTime === 'start' ? startResult : completion;
} }
async createCronTask( async createCronTask(
@@ -146,18 +163,33 @@ export default class ScheduleService {
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(
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
_id,
schedule,
asError(error).message,
);
}
}
if (runImmediately) { if (runImmediately) {
this.runTask(command, callbacks, { this.runTask(command, callbacks, {
+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'));
}
}
+47 -29
View File
@@ -8,10 +8,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 +33,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,13 +58,11 @@ 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('生成私钥文件失败', error);
} }
@@ -56,7 +70,7 @@ export default class SshKeyService {
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('删除私钥文件失败', error);
@@ -68,18 +82,19 @@ 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, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
this.sshPath,
alias,
)}\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,7 +105,7 @@ 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(`删除ssh配置文件${alias}失败`, error);
@@ -103,6 +118,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 +128,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 +136,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('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
}
} }
} }
} }
@@ -146,12 +167,9 @@ 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, alias)}\n StrictHostKeyChecking no\n`;
this.sshPath,
alias,
)}\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);
+151 -59
View File
@@ -1,8 +1,10 @@
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 { Agent, 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';
@@ -32,11 +34,15 @@ import {
SystemModelInfo, SystemModelInfo,
} 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 {
@@ -76,8 +82,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 +92,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 +104,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 +124,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 +158,7 @@ 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 +174,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 +197,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 +223,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 +235,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 +246,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 });
@@ -353,6 +359,15 @@ export default class SystemService {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateSystemVersion', type: 'updateSystemVersion',
message: JSON.stringify(err), message: JSON.stringify(err),
status: 'failed',
});
});
cp.on('exit', (code) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: '',
status: code === 0 ? 'success' : 'failed',
}); });
}); });
@@ -360,7 +375,10 @@ export default class SystemService {
} }
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('参数错误') };
}
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,
@@ -395,9 +413,9 @@ export default class SystemService {
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('通知发送失败,请检查系统设置/通知配置') };
} }
} }
@@ -415,7 +433,7 @@ export default class SystemService {
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 +449,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,11 +459,20 @@ 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(['db', 'upload', 'config', 'scripts', 'log', 'deps',
await promiseExec( 'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile if (dataDirs.some((dir) => !allowed.has(dir))) {
} ${dataPaths.join(' ')}`, 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) {
return res.send({ code: 400, message: error.message }); return res.send({ code: 400, message: error.message });
@@ -469,6 +496,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 +511,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,13 +546,17 @@ 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() {
@@ -526,10 +580,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 +637,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('在') +
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('在') +
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 '';
}
+110
View File
@@ -0,0 +1,110 @@
import CronExpressionParser from 'cron-parser';
import { validate } from 'node-cron';
import { ScheduleType } from '../interface/schedule';
const aliases: Record<string, string> = {
'@yearly': '0 0 0 1 1 *',
'@annually': '0 0 0 1 1 *',
'@monthly': '0 0 0 1 * *',
'@weekly': '0 0 0 * * 0',
'@daily': '0 0 0 * * *',
'@midnight': '0 0 0 * * *',
'@hourly': '0 0 * * * *',
'@minutely': '0 * * * * *',
};
export interface CronSchedule {
patterns: string[];
dayCount: number;
nthDay: number;
}
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
export function parseCronSchedule(schedule: unknown): CronSchedule {
if (typeof schedule !== 'string' || !schedule.trim()) {
throw new Error('Invalid cron schedule');
}
let source = schedule.trim();
const cacheKey = source;
const cached = scheduleCache.get(cacheKey);
if (cached) return cached;
if (source.startsWith('@')) {
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
source = aliases[source];
}
let parts = source.split(/\s+/);
if (
parts.length > 6 ||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
) {
throw new Error('Unsupported cron syntax');
}
parts = [
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
...parts,
];
if (
parts.some(
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
)
) {
throw new Error('Question mark is only valid in day fields');
}
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.length;
const weekCount = expression.fields.dayOfWeek.values.length;
const patterns =
dayCount < 31 && weekCount < 8
? [
[...normalized.slice(0, 5), '*'].join(' '),
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
]
: [normalized.join(' ')];
if (!patterns.every((pattern) => validate(pattern))) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
patterns,
dayCount,
nthDay: expression.fields.dayOfWeek.nthDay,
};
Object.freeze(patterns);
Object.freeze(parsed);
if (scheduleCache.size >= 512)
scheduleCache.delete(scheduleCache.keys().next().value);
scheduleCache.set(cacheKey, parsed);
return parsed;
}
export function isValidCronSchedule(schedule: unknown): schedule is string {
try {
parseCronSchedule(schedule);
return true;
} catch {
return false;
}
}
export function getInvalidCronSchedules(cron: {
schedule?: string;
extra_schedules?: Array<{ schedule: string }>;
}): unknown[] {
if (
cron.schedule?.startsWith(ScheduleType.ONCE) ||
cron.schedule?.startsWith(ScheduleType.BOOT)
) {
return [];
}
return [
cron.schedule,
...(cron.extra_schedules || []).map((x) => x.schedule),
].filter((schedule) => !isValidCronSchedule(schedule));
}
+89
View File
@@ -0,0 +1,89 @@
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
interface SchedulerLogger {
warn(message: string, ...args: unknown[]): unknown;
error(message: string, ...args: unknown[]): unknown;
}
export interface CronJob {
start(): void;
cancel(): void;
}
// node-cron owns timers and calendar calculation; Qinglong owns execution
// concurrency and process lifetime. Missed in-process slots join that same queue.
export function createCronJob(
schedule: string,
callback: (date: Date) => unknown | Promise<unknown>,
options: { name: string; logger: SchedulerLogger; start?: boolean },
): CronJob {
const parsed = parseCronSchedule(schedule);
const tasks: ScheduledTask[] = [];
let cancelled = false;
let started = false;
const accepts = (index: number, date: Date) => {
if (tasks.length === 1) return true;
// Preserve cron-parser 4's day/weekday OR semantics, including its
// month-dependent full-day range and the global nth-week constraint.
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
return false;
if (index === 1) return !tasks[0].match(date);
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
};
const dispatch = async (index: number, date: Date, missed: boolean) => {
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
options.name,
date.toISOString(),
);
}
try {
await callback(date);
} catch (error) {
options.logger.error(
'[schedule][定时回调失败] 任务: %s, 错误: %s',
options.name,
error instanceof Error ? error.message : String(error),
);
}
};
try {
for (const [index, pattern] of parsed.patterns.entries()) {
const task = createTask(
pattern,
(context) => dispatch(index, context.date, false),
{
noOverlap: false,
missedExecutionTolerance: 5000,
},
);
tasks.push(task);
task.on('execution:missed', (context) =>
dispatch(index, context.date, true),
);
// Fail before an existing schedule is cancelled, including impossible dates.
task.getNextRuns(1);
}
} catch (error) {
tasks.forEach((task) => task.destroy());
throw error;
}
const job: CronJob = {
start() {
if (started || cancelled) return;
started = true;
tasks.forEach((task) => task.start());
},
cancel() {
if (cancelled) return;
cancelled = true;
tasks.forEach((task) => task.destroy());
},
};
if (options.start !== false) job.start();
return job;
}
+4
View File
@@ -0,0 +1,4 @@
// Keep error formatting independent of database, HTTP and gRPC dependencies.
export function errStack(error: unknown): string {
return error instanceof Error && error.stack ? error.stack : String(error);
}
+57
View File
@@ -0,0 +1,57 @@
import fs from 'fs';
import path from 'path';
function isWithin(root: string, target: string): boolean {
const relative = path.relative(root, target);
return (
relative === '' ||
(!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative))
);
}
/** Resolve existing files and not-yet-created children without following an
* existing symlink outside the root. Blacklisted directories cover descendants. */
export function resolveFileAccess(
root: string,
parts: string[],
blacklist: string[] = [],
): string {
if (parts.some((part) => typeof part !== 'string' || part.includes('\0'))) {
return '';
}
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
const isBlocked = (relative: string) =>
relative.split(path.sep).some((part) => blacklist.includes(part));
if (isBlocked(path.relative(resolvedRoot, target))) return '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
let existing = target;
const missing: string[] = [];
while (!fs.existsSync(existing)) {
// existsSync is false for a dangling symlink; never treat one as absent.
try {
fs.lstatSync(existing);
return '';
} catch (error: any) {
if (error.code !== 'ENOENT') return '';
}
if (existing === resolvedRoot) return '';
missing.unshift(path.basename(existing));
existing = path.dirname(existing);
}
const realTarget = path.resolve(fs.realpathSync(existing), ...missing);
if (
!isWithin(realRoot, realTarget) ||
isBlocked(path.relative(realRoot, realTarget))
) {
return '';
}
return target;
} catch {
return '';
}
}
+179
View File
@@ -0,0 +1,179 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
'文件无法访问': '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',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'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 ',
端: '',
登录失败: '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) {
currentLang = lang || 'zh';
shareStore.setLang(currentLang);
}
/** 系统默认语言: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),
);
}
+1
View File
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
export interface ICronFn<T> { export interface ICronFn<T> {
(): Promise<T>; (): Promise<T>;
cron?: TCron; cron?: TCron;
isCurrent?: () => boolean;
} }
export interface ISchedule { export interface ISchedule {
+11
View File
@@ -0,0 +1,11 @@
import { resolveFileAccess } from './fileAccess';
/** Check stored paths too: old database rows are not trusted input. */
export function resolveLogPath(root: string, value: string): string {
const target = resolveFileAccess(root, [value]);
if (!target)
throw Object.assign(new Error('Log path is outside the log directory'), {
status: 400,
});
return target;
}
+109
View File
@@ -0,0 +1,109 @@
import fs from 'fs/promises';
export const DEFAULT_LOG_CHUNK_BYTES = 256 * 1024;
export const MAX_LOG_CHUNK_BYTES = 1024 * 1024;
export interface LogReadOptions {
offset?: number;
limit?: number;
tail?: boolean;
}
export interface LogChunk {
content: string;
offset: number;
nextOffset: number;
total: number;
truncated: boolean;
}
function normalizeLimit(limit?: number) {
if (!Number.isFinite(limit)) return DEFAULT_LOG_CHUNK_BYTES;
return Math.min(Math.max(Math.trunc(limit!), 4), MAX_LOG_CHUNK_BYTES);
}
function isUtf8ContinuationByte(byte: number) {
return (byte & 0xc0) === 0x80;
}
function completeUtf8End(buffer: Buffer, start: number, end: number) {
if (end <= start) return start;
let sequenceStart = end - 1;
while (
sequenceStart > start &&
isUtf8ContinuationByte(buffer[sequenceStart])
) {
sequenceStart--;
}
const firstByte = buffer[sequenceStart];
const expectedLength =
firstByte < 0x80
? 1
: firstByte < 0xe0
? 2
: firstByte < 0xf0
? 3
: 4;
return end - sequenceStart < expectedLength ? sequenceStart : end;
}
export async function readLogChunk(
filePath: string,
options: LogReadOptions = {},
): Promise<LogChunk> {
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(filePath, 'r');
const { size: total } = await handle.stat();
const limit = normalizeLimit(options.limit);
const requestedOffset = Number.isFinite(options.offset)
? Math.trunc(options.offset!)
: undefined;
const requestedStart =
options.tail || requestedOffset === undefined
? Math.max(total - limit, 0)
: Math.min(Math.max(requestedOffset, 0), total);
const length = Math.min(limit + 3, total - requestedStart);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(
buffer,
0,
length,
requestedStart,
);
let leadingBytes = 0;
while (
leadingBytes < bytesRead &&
isUtf8ContinuationByte(buffer[leadingBytes])
) {
leadingBytes++;
}
const offset = requestedStart + leadingBytes;
const candidateEnd = Math.min(leadingBytes + limit, bytesRead);
const contentEnd = completeUtf8End(buffer, leadingBytes, candidateEnd);
const nextOffset = requestedStart + contentEnd;
return {
content: buffer.subarray(leadingBytes, contentEnd).toString('utf8'),
offset,
nextOffset,
total,
truncated: offset > 0 || nextOffset < total,
};
} catch (error: any) {
if (error?.code === 'ENOENT') {
return {
content: '',
offset: 0,
nextOffset: 0,
total: 0,
truncated: false,
};
}
throw error;
} finally {
await handle?.close();
}
}
+87 -73
View File
@@ -1,5 +1,8 @@
import { createWriteStream, WriteStream } from 'fs'; import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import path from 'path';
import config from '../config';
import { resolveFileAccess } from './fileAccess';
/** /**
* Manages write streams for log files to improve performance by avoiding repeated file opens * Manages write streams for log files to improve performance by avoiding repeated file opens
@@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map(); private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map(); private pendingWrites: Map<string, Promise<void>> = new Map();
/** private closingStreams = new Map<string, Promise<void>>();
* Write data to a log file using a managed stream private closedStreams = new WeakSet<WriteStream>();
* @param filePath - Absolute path to the log file private streamErrors = new Map<string, Error>();
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation constructor(private readonly logRoot = config.logPath) {
const writePromise = new Promise<void>((resolve, reject) => { super();
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
} }
/** /** Register each write synchronously, so concurrent callers cannot lose the tail. */
* Close the stream for a specific file path async write(filePath: string, data: string): Promise<void> {
* @param filePath - Absolute path to the log file if (this.closingStreams.has(filePath)) {
*/ throw new Error(`Log stream is closing: ${filePath}`);
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
} }
const previous = this.pendingWrites.get(filePath) || Promise.resolve();
const pending = previous.then(
() =>
new Promise<void>((resolve, reject) => {
const failure = this.streamErrors.get(filePath);
if (failure) return reject(failure);
let stream = this.streams.get(filePath);
if (!stream) {
// Validate only when opening: subsequent chunks reuse the same descriptor.
const root = path.resolve(this.logRoot);
const target = path.resolve(filePath);
if (
!target.startsWith(root + path.sep) ||
!resolveFileAccess(root, [target])
) {
return reject(new Error('Log path is outside the log directory'));
}
stream = createWriteStream(target, { flags: 'a' });
this.streams.set(filePath, stream);
const current = stream;
stream.once('close', () => this.closedStreams.add(current));
stream.on('error', (error) => {
this.streamErrors.set(filePath, error);
// EventEmitter's unobserved "error" event would crash the caller.
if (this.listenerCount('error') > 0)
this.emit('error', { filePath, error });
});
}
stream.write(data, 'utf8', (error) =>
error ? reject(error) : resolve(),
);
}),
);
this.pendingWrites.set(filePath, pending);
// Keep the tail until close, including failures; never reopen a failed log mid-run.
return pending;
}
const stream = this.streams.get(filePath); async closeStream(filePath: string): Promise<void> {
if (stream) { const closing = this.closingStreams.get(filePath);
return new Promise<void>((resolve) => { if (closing) return closing;
stream.end(() => { const pending = this.pendingWrites.get(filePath);
this.streams.delete(filePath); const result = (async () => {
resolve(); let failure: unknown;
}); try {
}); await pending;
} catch (error) {
failure = error;
}
const stream = this.streams.get(filePath);
try {
if (stream && !this.closedStreams.has(stream)) {
await new Promise<void>((resolve) => {
stream.once('close', resolve);
if (failure || stream.destroyed) stream.destroy();
else stream.end();
});
}
failure ||= this.streamErrors.get(filePath);
if (failure) throw failure;
} finally {
this.streams.delete(filePath);
this.pendingWrites.delete(filePath);
this.streamErrors.delete(filePath);
}
})();
this.closingStreams.set(filePath, result);
try {
await result;
} finally {
this.closingStreams.delete(filePath);
} }
} }
@@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter {
* Close all open streams * Close all open streams
*/ */
async closeAll(): Promise<void> { async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) => const paths = new Set([
...this.streams.keys(),
...this.pendingWrites.keys(),
]);
const closePromises = Array.from(paths).map((filePath) =>
this.closeStream(filePath), this.closeStream(filePath),
); );
await Promise.all(closePromises); await Promise.all(closePromises);

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