diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1f28245d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitnexus +.claude +.codex +.env* +node_modules +data +audit +docs +test +src/.umi* +**/__pycache__ +**/*.log diff --git a/.env.example b/.env.example index a149b653..5bc5eb04 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ BACK_PORT=5700 # BIND_HOST=0.0.0.0 # BIND_HOST_GRPC=0.0.0.0 +# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。 +# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表: +# QL_TRUST_PROXY=1 +# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32 +# 仅在面板端口绝对无法绕过代理直接访问时使用 true。 +# QL_TRUST_PROXY=loopback + LOG_LEVEL='info' JWT_SECRET= diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index f7964132..75941c8c 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -2,20 +2,44 @@ name: Build And Push Docker Image on: push: - paths-ignore: - - "*.md" + paths: + - "**" + - "!*.md" + # CLI is verified separately; its shared Bash upgrade engine ships in images. + - "!cli/**" + - "cli/src/internal/maintenance/upgradeArtifacts.cjs" + - "!.github/workflows/cli-package.yml" branches: - "master" - "develop" tags: - "v*" workflow_dispatch: + inputs: + static_sync_only: + description: Synchronize an existing static snapshot to GitLab and verify Gitee + type: boolean + default: false + static_ref: + description: Static branch or version name, for example v2.22.0 + type: string + default: '' + static_ref_type: + description: Synchronize a branch, or a version branch and Git tag + type: choice + options: [branch, tag] + default: branch permissions: contents: read jobs: + validate: + if: ${{ !inputs.static_sync_only }} + uses: ./.github/workflows/validate.yml + code_gitlab: + if: ${{ !inputs.static_sync_only }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -32,37 +56,9 @@ jobs: git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed" git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed" - code_gitee: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Push to Gitee - env: - GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} - run: | - set +e - mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null - git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null - - if git push --force --all gitee 2>&1; then - echo "::notice::Gitee push --all succeeded" - else - echo "::warning::Push failed, trying to create repo via API..." - curl -sS --connect-timeout 30 --max-time 60 \ - -X POST "https://gitee.com/api/v5/user/repos" \ - -H "Content-Type: application/json" \ - -d '{"name":"qinglong","private":"false"}' \ - "?access_token=$GITEE_TOKEN" 2>/dev/null - git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry" - fi - git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed" - build-static: + if: ${{ !inputs.static_sync_only }} + needs: validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -72,6 +68,7 @@ jobs: - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -80,67 +77,122 @@ jobs: pnpm install --frozen-lockfile pnpm build:front 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 env: GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_BRANCH: ${{ github.ref_name }} + GITHUB_REF_TYPE: ${{ github.ref_type }} run: | - mkdir -p tmp - cd ./tmp - cp -rf ../static/* ./ - git init -b ${GITHUB_BRANCH} && git add . - git config --local user.name 'github-actions[bot]' - git config --local user.email 'github-actions[bot]@users.noreply.github.com' - git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" - git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} + bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE" static_gitlab: needs: build-static runs-on: ubuntu-latest + concurrency: + group: static-gitlab-${{ github.ref_name }} + cancel-in-progress: false steps: + - uses: actions/checkout@v6 - name: Push qinglong-static to GitLab + env: + STATIC_BRANCH: ${{ github.ref_name }} + STATIC_REF_TYPE: ${{ github.ref_type }} + MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }} run: | - set +e + set -euo pipefail mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null - git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror - cd static-mirror - git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null - git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed" - git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed" + bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE" - static_gitee: + verify-gitee-mirrors: needs: build-static runs-on: ubuntu-latest + timeout-minutes: 14 + permissions: + contents: write + strategy: + fail-fast: false + matrix: + repository: [qinglong, qinglong-static] steps: - - name: Push qinglong-static to Gitee + - uses: actions/checkout@v6 + # Gitee Pull mirrors and GitHub push webhooks own synchronization. + # Verification runs alongside Docker builds and needs no Gitee credentials. + - name: Wait for Gitee Pull mirror + if: ${{ matrix.repository == 'qinglong' }} env: - GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GH_TOKEN: ${{ github.token }} + QL_MIRROR_NOTIFY_REPO: whyour/qinglong + MIRROR_REPOSITORY: ${{ matrix.repository }} + MIRROR_REF: ${{ github.ref_name }} + MIRROR_REF_TYPE: ${{ github.ref_type }} + QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }} + QL_MIRROR_NOTIFY_ON_START: 'false' run: | - set +e - mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null - git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror - cd static-mirror - git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null + bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE" + - name: Notify and wait for Gitee static mirror + if: ${{ matrix.repository == 'qinglong-static' }} + env: + GH_TOKEN: ${{ secrets.API_TOKEN }} + QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static + MIRROR_REF: ${{ github.ref_name }} + MIRROR_REF_TYPE: ${{ github.ref_type }} + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true' + run: | + bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE" - if git push --force --all gitee 2>&1; then - echo "::notice::Gitee static push succeeded" - else - echo "::warning::Push failed, trying to create repo via API..." - curl -sS --connect-timeout 30 --max-time 60 \ - -X POST "https://gitee.com/api/v5/user/repos" \ - -H "Content-Type: application/json" \ - -d '{"name":"qinglong-static","private":"false"}' \ - "?access_token=$GITEE_TOKEN" 2>/dev/null - git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry" - fi - git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed" + sync-existing-static: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mirror: [gitlab] + concurrency: + group: static-${{ matrix.mirror }}-${{ inputs.static_ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - name: Synchronize existing static snapshot + env: + STATIC_REF: ${{ inputs.static_ref }} + STATIC_REF_TYPE: ${{ inputs.static_ref_type }} + MIRROR_HOST: ${{ matrix.mirror }}.com + MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null + bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE" + + verify-existing-static-gitee: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }} + runs-on: ubuntu-latest + timeout-minutes: 14 + steps: + - uses: actions/checkout@v6 + - name: Wait for existing Gitee static snapshot + env: + GH_TOKEN: ${{ secrets.API_TOKEN }} + QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static + MIRROR_REF: ${{ inputs.static_ref }} + MIRROR_REF_TYPE: ${{ inputs.static_ref_type }} + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true' + run: | + bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE" build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} @@ -151,11 +203,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -192,7 +249,7 @@ jobs: latest=false tags: | type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} - type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=semver,pattern={{version}} @@ -208,14 +265,16 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 context: . file: ./docker/Dockerfile push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=whyour/qinglong:cache-alpine @@ -230,11 +289,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -286,14 +350,17 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host + # 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 @@ -308,11 +375,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -350,14 +422,16 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 + platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 context: . file: ./docker/Dockerfile.310 push: true + provenance: mode=max + sbom: true tags: | whyour/qinglong:python3.10 whyour/qinglong:${{ steps.version.outputs.version }}-python3.10 @@ -373,11 +447,16 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: qinglong-static-${{ github.sha }} + path: static/ - uses: pnpm/action-setup@v6 with: version: "8.3.1" - uses: actions/setup-node@v6 with: + node-version: "20" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -415,14 +494,17 @@ jobs: uses: docker/build-push-action@v7 with: build-args: | - MAINTAINER=${{ github.repository_owner }} + QL_MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host + # 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 @@ -430,9 +512,12 @@ jobs: cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max publish: - if: ${{ github.ref_name == 'master' }} - needs: [build-alpine, build-debian] + if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }} + needs: [build-alpine, build-debian, build-alpine310, build-debian310] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 @@ -441,6 +526,7 @@ jobs: - uses: actions/setup-node@v3 with: + node-version: "20" cache: "pnpm" - name: build front and back @@ -449,7 +535,25 @@ jobs: pnpm build:front pnpm build:back - - name: publish npm package + - 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: | - echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc - npm publish + 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 diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml new file mode 100644 index 00000000..ecf2060f --- /dev/null +++ b/.github/workflows/cli-package.yml @@ -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 diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml new file mode 100644 index 00000000..fcb6784c --- /dev/null +++ b/.github/workflows/release-readiness.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..e6cfeb9f --- /dev/null +++ b/.github/workflows/validate.yml @@ -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 diff --git a/.gitignore b/.gitignore index c9fc9200..2428ab44 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,13 @@ __pycache__ /shell/preload/notify.* /shell/preload/*-notify.json /shell/preload/__ql_notify__.* +/shell/preload/lang_env.sh .deepseek/ +.claude/ + +# local Kubernetes overlays +/deploy/kubernetes/overlays/local/ + +# Local security audit artifacts +/audit/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3fdc476b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ + +# 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` | + + diff --git a/README-NODE.md b/README-NODE.md index a255479b..0671dddc 100644 --- a/README-NODE.md +++ b/README-NODE.md @@ -1,296 +1,10 @@ -# QingLong - Node.js 原生部署指南 +# Node.js 原生部署与 Python venv -本文档说明如何在 Linux 服务器上以 Node.js 原生方式编译、部署和运行青龙面板,使用 Python venv 隔离环境。 +设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。 +如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。 -## 一、环境要求 +- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。 +- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。 +- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。 -### 本地构建环境(macOS / Linux) - -- Node.js 20.x -- pnpm 8.3.1 -- Python 3.x - -### 服务器运行环境 - -- Node.js 16+ -- Python 3 + python3-venv -- nginx -- jq、curl、git -- pm2(全局安装) - -## 二、本地编译 - -```bash -# 克隆项目 -git clone https://github.com/whyour/qinglong.git -cd qinglong - -# 安装依赖 -npm i -g pnpm@8.3.1 -pnpm install - -# 创建本地 venv(可选,仅本地开发需要) -python3 -m venv .venv - -# 构建前端 -pnpm build:front - -# 构建后端 -pnpm build:back -``` - -构建产物: - -``` -static/ -├── build/ ← 后端编译产物(tsc) -│ └── app.js ← pm2 入口 -└── dist/ ← 前端构建产物(umi) - ├── index.html - └── ... -``` - -## 三、打包上传 - -### 打包(不含 node_modules,约 12MB) - -```bash -COPYFILE_DISABLE=1 tar czf qinglong-deploy.tar.gz \ - --exclude='node_modules' \ - static/ shell/ sample/ back/protos/ \ - package.json pnpm-lock.yaml ecosystem.config.js .env.example version.yaml -``` - -### 上传到服务器 - -```bash -scp qinglong-deploy.tar.gz user@server:~/ -``` - -## 四、服务器部署 - -### 4.1 解压并安装依赖 - -```bash -mkdir -p ~/qinglong -tar xzf qinglong-deploy.tar.gz -C ~/qinglong -cd ~/qinglong - -# 安装 pnpm(如果没有) -npm i -g pnpm@8.3.1 - -# 安装生产依赖 -pnpm install --prod -``` - -### 4.2 首次启动 - -```bash -export QL_DIR=$(pwd) -export QL_DATA_DIR="${QL_DIR}/data" -bash shell/start-simplify.sh -``` - -首次启动会自动: - -1. 读取 `.env` 配置(不存在则从 `.env.example` 复制) -2. 创建 Python venv(`${QL_DIR}/.venv`) -3. 安装 `requests` 到 venv -4. 修复 `task` / `ql` 命令软链接 -5. 启动 nginx + pm2 - -访问 `http://服务器IP:5700` 即可。 - -### 4.3 配置 systemctl 开机自启 - -```bash -sudo tee /etc/systemd/system/qinglong.service << 'EOF' -[Unit] -Description=QingLong Panel -After=network.target - -[Service] -Type=forking -WorkingDirectory=/home/jiandanc/qinglong -Environment="QL_DIR=/home/jiandanc/qinglong" -Environment="QL_DATA_DIR=/home/jiandanc/qinglong/data" -ExecStart=/bin/bash /home/jiandanc/qinglong/shell/start-simplify.sh -StandardOutput=journal -StandardError=journal -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target -EOF - -# 重载配置 -sudo systemctl daemon-reload - -# 启动服务 -sudo systemctl start qinglong - -# 设置开机自启 -sudo systemctl enable qinglong - -# 查看状态 -sudo systemctl status qinglong - -# 查看日志 -sudo journalctl -u qinglong -f -``` - -> **注意**:`WorkingDirectory`、`QL_DIR`、`QL_DATA_DIR` 需要替换为你的实际部署路径。 - -## 五、目录结构 - -部署后的完整目录结构: - -``` -~/qinglong/ ← QL_DIR -├── .venv/ ← Python 虚拟环境(自动创建) -│ ├── bin/ -│ │ ├── python3 -│ │ └── pip3 -│ └── lib/ -├── data/ ← QL_DATA_DIR(运行数据) -│ ├── config/ ← 配置文件 -│ │ ├── config.sh ← 用户配置 -│ │ └── crontab.list ← 定时任务列表 -│ ├── scripts/ ← 用户脚本 -│ ├── log/ ← 运行日志 -│ ├── db/ ← 数据库 -│ ├── deps/ ← 依赖缓存 -│ └── dep_cache/ -│ └── node/ ← Node 依赖缓存 -├── static/ -│ ├── build/ ← 后端编译产物 -│ └── dist/ ← 前端构建产物 -├── shell/ -│ ├── start.sh ← 完整启动脚本(含系统依赖安装) -│ ├── start-simplify.sh ← 精简启动脚本(跳过系统依赖安装) -│ ├── task.sh ← 定时任务执行脚本 -│ └── update.sh ← 更新脚本(ql 命令) -├── sample/ ← 配置模板 -├── back/protos/ ← gRPC proto 文件 -├── ecosystem.config.js ← pm2 配置 -├── package.json -└── .env ← 环境变量(Node.js 读取) -``` - -## 六、Python venv 说明 - -### 工作原理 - -启动脚本将 `.venv/bin` 加入 `PATH` 最前面,使所有 `python3` / `pip3` 调用自动指向 venv,无需修改 `task.sh` 等原有脚本。 - -### 环境变量解析优先级 - -``` -1. export PYTHON_VENV_DIR=/path ← 手动 export(最高优先级) -2. .env 中 PYTHON_VENV_DIR=./.venv ← 启动脚本从 .env 读取 -3. ${QL_DIR}/.venv ← 默认值(兜底) -``` - -### 不设置的变量 - -以下变量**不应设置**,否则会破坏 venv 的包解析机制: - -- `PYTHONHOME` — 会覆盖 Python 的 prefix 解析 -- `PYTHONUSERBASE` — venv 不需要 -- `PYTHONPATH` — venv 通过 `pyvenv.cfg` 自动管理 - -### 验证 venv 是否生效 - -```bash -# 查看 Python 路径 -which python3 -# 预期: ~/qinglong/.venv/bin/python3 - -# 验证是否在 venv 中 -python3 -c "import sys; print('✅ venv' if sys.prefix != sys.base_prefix else '❌ 系统')" -``` - -## 七、本地开发模式 - -```bash -cd qinglong - -# 创建 venv -python3 -m venv .venv - -# 安装依赖 -pnpm install - -# 启动(前端 8000 端口,后端 5700 端口) -pnpm start -``` - -开发模式下 `pnpm start` 会自动 source `shell/dev-env.sh` 设置 venv 环境。 - -## 八、常用运维命令 - -```bash -# 查看服务状态 -sudo systemctl status qinglong - -# 重启服务 -sudo systemctl restart qinglong - -# 查看日志 -sudo journalctl -u qinglong -f - -# 查看 pm2 状态 -sudo pm2 list - -# 查看 pm2 日志 -sudo pm2 logs - -# 手动运行任务 -cd ~/qinglong -QL_DIR=$(pwd) QL_DATA_DIR="$(pwd)/data" bash shell/task.sh <脚本名> now - -# 更新 qinglong(重新编译后替换 static/ 目录,重启服务) -sudo systemctl restart qinglong -``` - -## 九、常见问题 - -### Q: 网页运行任务一直显示"运行中" - -检查 `task` 软链接是否指向正确目录: - -```bash -ls -la /usr/local/bin/task -# 应该指向 ~/qinglong/shell/task.sh,不是 npm 全局路径 -``` - -修复: - -```bash -sudo ln -sf ~/qinglong/shell/task.sh /usr/local/bin/task -sudo pm2 restart qinglong -``` - -### Q: 安装 Python 依赖报权限错误 - -```bash -sudo chown -R $(whoami) ~/qinglong/.venv -``` - -### Q: sudo 运行后普通用户无权限 - -```bash -sudo chown -R $(whoami) ~/qinglong/data -``` - -### Q: systemctl 日志为空 - -确保 service 文件中包含: - -```ini -StandardOutput=journal -StandardError=journal -``` - -然后 `sudo systemctl daemon-reload && sudo systemctl restart qinglong`。 +该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。 diff --git a/README-en.md b/README-en.md index 4570d6d5..581661c9 100644 --- a/README-en.md +++ b/README-en.md @@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript - Support system level notification - Support dark mode - Support cell phone operation +- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill) ## Version @@ -79,6 +80,29 @@ npm i @whyour/qinglong [View Documentation](https://qinglong.online/guide/user-guide/basic-explanation) +## Remote CLI and Agent Skill + +Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents. + +Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`: + +```bash +npm install -g @whyour/qinglong-cli +ql login --url https://ql.example.com +ql auth status --json +ql task list --json +ql subscription list --json +ql --help +``` + +Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help. + +The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md). + +[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md) + +The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md). + ## Development ```bash diff --git a/README.md b/README.md index 9b7cdd55..b6d34f94 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript - 支持系统级通知 - 支持暗黑模式 - 支持手机端操作 +- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板 ## 版本 @@ -81,6 +82,29 @@ npm i @whyour/qinglong [查看文档](https://qinglong.online/guide/user-guide/basic-explanation) +## 远程 CLI 与 Agent Skill + +使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。 + +需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令: + +```bash +npm install -g @whyour/qinglong-cli +ql login --url https://ql.example.com +ql auth status --json +ql task list --json +ql subscription list --json +ql --help +``` + +在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。 + +npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。 + +[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md) + +包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。 + ## 开发 ```bash diff --git a/back/api/clientIp.ts b/back/api/clientIp.ts new file mode 100644 index 00000000..172c8e9b --- /dev/null +++ b/back/api/clientIp.ts @@ -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); + } + }, + ); +}; diff --git a/back/api/config.ts b/back/api/config.ts index 3fe3f463..c7f6d3ad 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -4,10 +4,11 @@ import { Logger } from 'winston'; import config from '../config'; import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; -import { join } from 'path'; import { SAMPLE_FILES } from '../config/const'; +import { t } from '../shared/i18n'; import ConfigService from '../services/config'; import { writeFileWithLock } from '../shared/utils'; +import { resolveFileAccess } from '../shared/fileAccess'; const route = Router(); export default (app: Router) => { @@ -71,36 +72,29 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { 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/')) { - path = join(config.rootPath, name); + return res.send({ code: 403, message: t('文件无法访问') }); } - if ( - !path.startsWith(config.configPath) && - !path.startsWith(config.scriptPath) - ) { - return res.send({ code: 403, message: '文件路径无效' }); + const normalized = resolveFileAccess( + config.configPath, + [name], + config.blackFileList, + ); + if (!normalized) { + return res.send({ code: 403, message: t('文件无法访问') }); } - await writeFileWithLock(path, content); - res.send({ code: 200, message: '保存成功' }); + await writeFileWithLock(normalized, content); + res.send({ code: 200, message: t('保存成功') }); } catch (e) { return next(e); } }, ); - route.get( - '/:file', - async (req: Request, res: Response, next: NextFunction) => { - try { - const configService = Container.get(ConfigService); - await configService.getFile(req.params.file, res); - } catch (e) { - return next(e); - } - }, - ); + route.get('/:file', (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /configs/detail 接口'), + }); + }); }; diff --git a/back/api/cron.ts b/back/api/cron.ts index 1bc99062..a13114ee 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -5,12 +5,31 @@ import CronService from '../services/cron'; import CronViewService from '../services/cronView'; import { celebrate, Joi } from 'celebrate'; import { commonCronSchema } from '../validation/schedule'; +import { + RunningInstanceModel, + InstanceStatus, +} from '../data/runningInstance'; +import { t } from '../shared/i18n'; +import cronClient from '../schedule/client'; const route = Router(); export default (app: Router) => { app.use('/crons', route); + route.use(async (req, res, next) => { + // Keep stop/status callbacks available even when the scheduler is down. + if (['POST', 'PUT', 'DELETE'].includes(req.method) && + ['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) { + try { + await cronClient.readiness.ensureReady(); + } catch (error) { + return next(error); + } + } + return next(); + }); + route.get( '/views', async (req: Request, res: Response, next: NextFunction) => { @@ -60,7 +79,7 @@ export default (app: Router) => { try { const cronViewService = Container.get(CronViewService); if (req.body.type === 1) { - return res.send({ code: 400, message: '参数错误' }); + return res.send({ code: 400, message: t('参数错误') }); } else { const data = await cronViewService.update(req.body); return res.send({ code: 200, data }); @@ -302,13 +321,35 @@ export default (app: Router) => { params: Joi.object({ id: Joi.number().required(), }), + query: Joi.object({ + offset: Joi.number().integer().min(0).optional(), + limit: Joi.number() + .integer() + .min(1) + .max(1024 * 1024) + .optional(), + tail: Joi.boolean().optional(), + t: Joi.string().optional(), + }).unknown(true), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.log(req.params.id); - return res.send({ code: 200, data }); + const result = await cronService.log(req.params.id, { + offset: req.query.offset as unknown as number, + limit: req.query.limit as unknown as number, + tail: req.query.tail as unknown as boolean, + }); + return res.send({ + code: 200, + data: result.content, + logStatus: result.status, + offset: result.offset, + nextOffset: result.nextOffset, + total: result.total, + truncated: result.truncated, + }); } catch (e) { return next(e); } @@ -429,6 +470,7 @@ export default (app: Router) => { log_path: Joi.string().optional().allow(null), last_running_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) => { @@ -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( '/:id/logs', celebrate({ diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 2e23daa5..8abaf17a 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -3,8 +3,14 @@ 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(); @@ -101,6 +107,46 @@ export default (app: Router) => { }, ); + for (const isSuccess of [false, true]) { + route.get( + isSuccess ? '/successes' : '/failures', + async (req: Request, res: Response, next: NextFunction) => { + try { + const countColumn = isSuccess ? 'success_count' : 'fail_count'; + const rows = (await CrontabStatModel.findAll({ + attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']], + where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']], + raw: true, + })) as any[]; + const crons = rows.length > 0 ? await CrontabModel.findAll({ + attributes: ['id', 'name', 'command'], + where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } }, + raw: true, + }) : []; + const cronMap = new Map(crons.map((cron) => [cron.id, cron])); + res.send({ + code: 200, + data: rows.map((row) => { + const id = Number(row.ref_id); + const cron = cronMap.get(id); + return { + id, + name: cron?.name || cron?.command || tf('任务#%s', id), + command: cron?.command || '', + [isSuccess ? 'successCount' : 'failCount']: Number(row.result_count), + deleted: !cron, + }; + }), + }); + } catch (e) { + next(e); + } + }, + ); + } + route.get( '/trend', async (req: Request, res: Response, next: NextFunction) => { @@ -177,7 +223,7 @@ export default (app: Router) => { const data = rows.map((r: any, i) => ({ rank: i + 1, - name: nameMap[Number(r.ref_id)] || `任务#${r.ref_id}`, + 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), })); @@ -219,7 +265,7 @@ export default (app: Router) => { const data = rows.map((r: any, i) => ({ rank: i + 1, - name: nameMap[Number(r.ref_id)] || `任务#${r.ref_id}`, + 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: @@ -239,9 +285,9 @@ export default (app: Router) => { '/runtime', async (req: Request, res: Response, next: NextFunction) => { try { - const runningCrons = await CrontabModel.findAll({ + const runningInstances = await RunningInstanceModel.findAll({ where: { - status: 0, // running + status: InstanceStatus.running, }, raw: true, }); @@ -253,15 +299,31 @@ export default (app: Router) => { raw: true, }); - const running = runningCrons.map((c: any) => ({ - id: c.id, - name: c.name || c.command || `任务#${c.id}`, - pid: c.pid, - elapsed: c.last_execution_time - ? Math.floor((Date.now() / 1000) - c.last_execution_time) - : 0, - logPath: c.log_path, - })); + // 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({ @@ -283,7 +345,7 @@ export default (app: Router) => { running, idleTasks: idleTasks.map((c: any) => ({ id: c.id, - name: c.name || c.command || `任务#${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') : '-', @@ -304,17 +366,22 @@ export default (app: Router) => { const [crons, stats] = (await Promise.all([ CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }), CrontabStatModel.findAll({ where: { date: today }, raw: true }), - ])) as any[]; + ])); const statMap: Record = {}; stats.forEach((s: any) => { statMap[s.ref_id] = s; }); const labelMap: Record = {}; - crons.forEach((c: any) => { + crons.forEach((c) => { let rawLabels = c.labels; if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels); - const labels: string[] = Array.isArray(rawLabels) && rawLabels.length > 0 ? rawLabels : ['未分类']; - const st = statMap[c.id]; + 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; @@ -352,7 +419,7 @@ export default (app: Router) => { code: 200, data: { platform: os.platform(), - uptime: Math.floor(os.uptime()), + uptime: Math.floor(process.uptime()), memTotal: os.totalmem(), memFree: os.freemem(), memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1), diff --git a/back/api/env.ts b/back/api/env.ts index e58cd3ae..469088df 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -1,23 +1,16 @@ import { Joi, celebrate } from 'celebrate'; import { NextFunction, Request, Response, Router } from 'express'; -import fs from 'fs'; import multer from 'multer'; import { Container } from 'typedi'; import { Logger } from 'winston'; -import config from '../config'; -import { safeJSONParse } from '../config/util'; +import { t } from '../shared/i18n'; import EnvService from '../services/env'; const route = Router(); -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, config.scriptPath); - }, - filename: function (req, file, cb) { - cb(null, file.originalname); - }, +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024, files: 1 }, }); -const upload = multer({ storage: storage }); const labelSchema = Joi.array() .items(Joi.string().trim().required()) .min(1) @@ -48,7 +41,7 @@ export default (app: Router) => { .required() .pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/), remarks: Joi.string().optional().allow(''), - labels: Joi.array().items(Joi.string().trim()).optional(), + labels: Joi.array().items(Joi.string().trim()).optional().allow(null), }), ), }), @@ -57,7 +50,7 @@ export default (app: Router) => { try { const envService = Container.get(EnvService); if (!req.body?.length) { - return res.send({ code: 400, message: '参数不正确' }); + return res.send({ code: 400, message: t('参数不正确') }); } const data = await envService.create(req.body); return res.send({ code: 200, data }); @@ -75,7 +68,7 @@ export default (app: Router) => { name: Joi.string().required(), remarks: Joi.string().optional().allow('').allow(null), id: Joi.number().required(), - labels: Joi.array().items(Joi.string().trim()).optional(), + labels: Joi.array().items(Joi.string().trim()).optional().allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -281,12 +274,23 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); - const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8'); - const parseContent = safeJSONParse(fileContent); + let parseContent: unknown; + try { + parseContent = JSON.parse(req.file?.buffer.toString('utf8') || ''); + } catch { + return res.status(400).send({ code: 400, message: t('参数错误') }); + } const data = Array.isArray(parseContent) ? parseContent : [parseContent]; - if (data.every((x) => x.name && x.value)) { + if ( + data.length > 0 && + data.every((x) => + x && typeof x.name === 'string' && + /^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) && + typeof x.value === 'string' && x.value.length > 0, + ) + ) { const result = await envService.create( data.map((x) => ({ name: x.name, @@ -299,7 +303,7 @@ export default (app: Router) => { } else { return res.send({ code: 400, - message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', + message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'), }); } } catch (e) { diff --git a/back/api/health.ts b/back/api/health.ts index bb961fa1..738e8ec1 100644 --- a/back/api/health.ts +++ b/back/api/health.ts @@ -11,8 +11,8 @@ export default (app: Router) => { try { const healthService = Container.get(HealthService); const health = await healthService.check(); - res.status(200).send({ - code: 200, + res.status(health.status === 'ok' ? 200 : 503).send({ + code: health.status === 'ok' ? 200 : 503, data: health, }); } catch (err: any) { diff --git a/back/api/index.ts b/back/api/index.ts index 443c8e5c..9d1b7411 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -12,6 +12,8 @@ import subscription from './subscription'; import update from './update'; import dashboard from './dashboard'; import health from './health'; +import clientIp from './clientIp'; +import retention from './retention'; export default () => { const app = Router(); @@ -28,6 +30,8 @@ export default () => { update(app); dashboard(app); health(app); + clientIp(app); + retention(app); return app; }; diff --git a/back/api/log.ts b/back/api/log.ts index 020d572e..1820f4cc 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -3,13 +3,15 @@ import { NextFunction, Request, Response, Router } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; +import { t } from '../shared/i18n'; import { - getFileContentByName, readDirs, removeAnsi, rmPath, } from '../config/util'; import LogService from '../services/log'; +import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; +import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader'; const route = Router(); const blacklist = ['.tmp']; @@ -32,6 +34,20 @@ export default (app: Router) => { route.get( '/detail', + celebrate({ + query: Joi.object({ + path: Joi.string().allow('').optional(), + file: Joi.string().required(), + offset: Joi.number().integer().min(0).optional(), + limit: Joi.number() + .integer() + .min(1) + .max(MAX_LOG_CHUNK_BYTES) + .optional(), + tail: Joi.boolean().optional(), + t: Joi.string().optional(), + }).unknown(true), + }), async (req: Request, res: Response, next: NextFunction) => { try { const logService = Container.get(LogService); @@ -42,11 +58,28 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } - const content = await getFileContentByName(finalPath); - res.send({ code: 200, data: removeAnsi(content) }); + const logPath = `${req.query.path as string}/${req.query.file as string}`; + 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) { return next(e); } @@ -55,24 +88,11 @@ export default (app: Router) => { route.get( '/:file', - async (req: Request, res: Response, next: NextFunction) => { - try { - const logService = Container.get(LogService); - const finalPath = logService.checkFilePath( - (req.query.path as string) || '', - (req.params.file as string) || '', - ); - if (!finalPath || blacklist.includes(req.query.path as string)) { - return res.send({ - code: 403, - message: '暂无权限', - }); - } - const content = await getFileContentByName(finalPath); - res.send({ code: 200, data: content }); - } catch (e) { - return next(e); - } + (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /logs/detail 接口'), + }); }, ); @@ -96,7 +116,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(path)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await rmPath(finalPath); @@ -126,7 +146,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } return res.download(filePath, filename, (err) => { diff --git a/back/api/open.ts b/back/api/open.ts index c704d961..8e59b58e 100644 --- a/back/api/open.ts +++ b/back/api/open.ts @@ -3,6 +3,7 @@ import { Container } from 'typedi'; import OpenService from '../services/open'; import { Logger } from 'winston'; import { celebrate, Joi } from 'celebrate'; +import { rateLimit } from 'express-rate-limit'; const route = Router(); export default (app: Router) => { @@ -100,6 +101,7 @@ export default (app: Router) => { route.get( '/auth/token', + rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }), celebrate({ query: { client_id: Joi.string().required(), diff --git a/back/api/retention.ts b/back/api/retention.ts new file mode 100644 index 00000000..c75f9f64 --- /dev/null +++ b/back/api/retention.ts @@ -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); + } + }, + ); +}; diff --git a/back/api/script.ts b/back/api/script.ts index 1ecce902..e1e7da89 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; +import { resolveFileAccess } from '../shared/fileAccess'; import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util'; import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; @@ -5,18 +7,68 @@ import { Logger } from 'winston'; import config from '../config'; import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; -import path, { join, parse } from 'path'; +import path, { dirname, join, parse, resolve, sep } from 'path'; import ScriptService from '../services/script'; +import ScriptHistoryService from '../services/scriptHistory'; +import { t } from '../shared/i18n'; import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; +import { + ScriptHistoryError, + HistoryUnavailableError, +} from '../shared/scriptHistory'; const route = Router(); +function isPathAllowed(targetPath: string): boolean { + const resolved = path.resolve(targetPath); + return config.writePathList.some((x) => + 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 { + if (!isPathAllowed(targetPath)) { + throw new ScriptHistoryError(403, '暂无权限'); + } + const resolved = resolve(targetPath); + for (const writableRoot of config.writePathList) { + const root = resolve(writableRoot); + const rootPrefix = root.endsWith(sep) ? root : root + sep; + // Keep an explicit normalized boundary check at the filesystem operation. + if (!resolved.startsWith(rootPrefix)) continue; + const realPath = await fs.realpath(resolved); + const realRoot = await fs.realpath(root); + const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep; + if ( + !realPath.startsWith(realRootPrefix) || + !resolveFileAccess( + realRoot, + [realPath], + root === resolve(config.configPath) ? config.blackFileList : [], + ) + ) { + throw new ScriptHistoryError(403, '暂无权限'); + } + return realPath; + } + throw new ScriptHistoryError(403, '暂无权限'); +} + const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, config.scriptPath); + cb(null, config.tmpPath); }, filename: function (req, file, cb) { - cb(null, file.originalname); + cb(null, randomUUID()); }, }); const upload = multer({ storage: storage }); @@ -44,6 +96,11 @@ export default (app: Router) => { 'package-lock.json', ]; if (req.query.path) { + if ( + !resolveFileAccess(config.scriptPath, [req.query.path as string]) + ) { + return res.send({ code: 403, message: t('暂无权限') }); + } result = await readDir( req.query.path as string, config.scriptPath, @@ -71,7 +128,8 @@ export default (app: Router) => { logger.error('🔥 error: %o', e); return next(e); } - }); + }, + ); route.get( '/detail', @@ -85,7 +143,7 @@ export default (app: Router) => { try { const scriptService = Container.get(ScriptService); const content = await scriptService.getFile( - req.query?.path as string || '', + (req.query?.path as string) || '', req.query.file as string, ); res.send({ code: 200, data: content }); @@ -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( - '/:file', + '/history', + celebrate({ query: Joi.object(historyQuery).unknown(true) }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const service = Container.get(ScriptHistoryService); + const data = await service.list( + (req.query.path as string) || '', + req.query.filename as string, + ); + res.send({ code: 200, data }); + } catch (error) { + historyError(error, res, next); + } + }, + ); + route.get( + '/history/detail', celebrate({ - params: Joi.object({ - file: Joi.string().required(), - }), query: Joi.object({ - path: Joi.string().optional().allow(''), + ...historyQuery, + id: Joi.string().uuid().required(), }).unknown(true), }), async (req: Request, res: Response, next: NextFunction) => { try { - const scriptService = Container.get(ScriptService); - const content = await scriptService.getFile( - req.query?.path as string || '', - req.params.file, + const service = Container.get(ScriptHistoryService); + const data = await service.detail( + (req.query.path as string) || '', + req.query.filename as string, + req.query.id as string, ); - res.send({ code: 200, data: content }); - } catch (e) { - return next(e); + res.send({ code: 200, data }); + } catch (error) { + historyError(error, res, next); + } + }, + ); + route.put( + '/history/restore', + celebrate({ + body: Joi.object({ + ...historyQuery, + id: Joi.string().uuid().required(), + expectedHash: Joi.string().hex().length(64).required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { path = '', filename, id, expectedHash } = req.body; + const service = Container.get(ScriptHistoryService); + const data = await service.restore(path, filename, id, expectedHash); + res.send({ code: 200, data }); + } catch (error) { + historyError(error, res, next); } }, ); + route.get('/:file', (req: Request, res: Response) => { + return res.send({ + code: 410, + message: t('接口已下线,请使用 /scripts/detail 接口'), + }); + }); + route.post( '/', + (req: Request, res: Response, next: NextFunction) => { + res.on('finish', () => { + if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined); + }); + next(); + }, upload.single('file'), celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''), + skipHistory: Joi.boolean().optional(), + expectedHash: Joi.string() + .hex() + .length(64) + .when('skipHistory', { is: true, then: Joi.required() }), originFilename: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''), file: Joi.string().optional().allow(''), @@ -155,30 +283,87 @@ export default (app: Router) => { if (config.writePathList.every((x) => !path.startsWith(x))) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } 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 }); } 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 }); } if (!originFilename) { originFilename = filename; } - const originFilePath = join( - path, - `${originFilename.replace(/\//g, '')}`, - ); - await fs.mkdir(path, { recursive: true }); - const filePath = join(path, `${filename.replace(/\//g, '')}`); + const originFilePath = join(path, originFilename); + const filePath = join(path, filename); + if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } + // Check the canonical parent directly at the directory creation boundary. + const parentPath = resolve(dirname(filePath)); + let parentCreated = false; + for (const writableRoot of config.writePathList) { + const root = resolve(writableRoot); + const rootPrefix = root.endsWith(sep) ? root : root + sep; + if (parentPath === root) { + await fs.mkdir(root, { recursive: true }); + } else if (parentPath.startsWith(rootPrefix)) { + await fs.mkdir(parentPath, { recursive: true }); + } else { + continue; + } + parentCreated = true; + break; + } + if (!parentCreated) { + return res.send({ code: 403, message: t('暂无权限') }); + } const fileExists = await fileExist(filePath); + if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) { + let removeSource = filename !== originFilename; + let backupSource = ''; + if (removeSource) { + const [originRealPath, targetRealPath] = await Promise.all([ + existingWritableRealPath(originFilePath), + existingWritableRealPath(filePath), + ]); + // Aliases of one script are an in-place save. Removing the source + // would also remove the destination behind a target symlink. + removeSource = originRealPath !== targetRealPath; + backupSource = originRealPath; + } + // Save-as removes the source after committing the destination. Keep + // its original content too: destination history only protects the + // file being overwritten, not the source being deleted. + if (removeSource) { + await fs.copyFile( + backupSource, + join(config.bakPath, originFilename.replace(/\//g, '')), + ); + } + const service = Container.get(ScriptHistoryService); + const data = await service.save(path, filename, content, { + skipHistory: req.body.skipHistory, + expectedHash: req.body.expectedHash, + }); + if (removeSource) await rmPath(originFilePath); + return res.send({ code: 200, data }); + } if (fileExists) { await fs.copyFile( originFilePath, @@ -191,7 +376,7 @@ export default (app: Router) => { await writeFileWithLock(filePath, content); return res.send({ code: 200 }); } catch (e) { - return next(e); + return historyError(e, res, next); } }, ); @@ -203,6 +388,11 @@ export default (app: Router) => { filename: Joi.string().required(), path: Joi.string().optional().allow(''), content: Joi.string().required().allow(''), + skipHistory: Joi.boolean().optional(), + expectedHash: Joi.string() + .hex() + .length(64) + .when('skipHistory', { is: true, then: Joi.required() }), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -217,13 +407,17 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } - await writeFileWithLock(filePath, content); - return res.send({ code: 200 }); + const service = Container.get(ScriptHistoryService); + const data = await service.save(path || '', filename, content, { + skipHistory: req.body.skipHistory, + expectedHash: req.body.expectedHash, + }); + return res.send({ code: 200, data }); } catch (e) { - return next(e); + return historyError(e, res, next); } }, ); @@ -251,7 +445,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await rmPath(filePath); @@ -284,7 +478,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } return res.download(filePath, filename, (err) => { @@ -316,6 +510,9 @@ export default (app: Router) => { } const { name, ext } = parse(filename); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); + if (!isPathAllowed(filePath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } await writeFileWithLock(filePath, content || ''); const scriptService = Container.get(ScriptService); @@ -344,12 +541,24 @@ export default (app: Router) => { } const { name, ext } = parse(filename); 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 result = await scriptService.stopScript(filePath, pid); setTimeout(() => { - rmPath(logPath); + const cleanupPath = resolveFileAccess(config.logPath, [logPath]); + if (cleanupPath) { + void rmPath(cleanupPath); + } }, 3000); res.send(result); } catch (e) { @@ -379,6 +588,9 @@ export default (app: Router) => { } const filePath = join(config.scriptPath, path, filename); const newPath = join(config.scriptPath, path, newFilename); + if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) { + return res.send({ code: 403, message: t('暂无权限') }); + } await fs.rename(filePath, newPath); res.send({ code: 200 }); } catch (e) { diff --git a/back/api/subscription.ts b/back/api/subscription.ts index dd365a27..6683d88f 100644 --- a/back/api/subscription.ts +++ b/back/api/subscription.ts @@ -3,7 +3,7 @@ import { Container } from 'typedi'; import { Logger } from 'winston'; import SubscriptionService from '../services/subscription'; import { celebrate, Joi } from 'celebrate'; -import CronExpressionParser from 'cron-parser'; +import { isValidCronSchedule } from '../shared/cronSchedule'; const route = Router(); export default (app: Router) => { @@ -58,10 +58,7 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { - if ( - !req.body.schedule || - CronExpressionParser.parse(req.body.schedule).hasNext() - ) { + if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.create(req.body); return res.send({ code: 200, data }); @@ -148,13 +145,27 @@ export default (app: Router) => { params: Joi.object({ id: Joi.number().required(), }), + query: Joi.object({ + offset: Joi.number().integer().min(0).optional(), + limit: Joi.number() + .integer() + .min(1) + .max(1024 * 1024) + .optional(), + tail: Joi.boolean().optional(), + t: Joi.string().optional(), + }).unknown(true), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); - const data = await subscriptionService.log(req.params.id); - return res.send({ code: 200, data }); + const result = await subscriptionService.log(req.params.id, { + offset: req.query.offset as unknown as number, + limit: req.query.limit as unknown as number, + tail: req.query.tail as unknown as boolean, + }); + return res.send({ code: 200, data: result.content, ...result }); } catch (e) { return next(e); } @@ -190,11 +201,7 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { - if ( - !req.body.schedule || - typeof req.body.schedule === 'object' || - CronExpressionParser.parse(req.body.schedule).hasNext() - ) { + if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.update(req.body); return res.send({ code: 200, data }); diff --git a/back/api/system.ts b/back/api/system.ts index f51c6575..e58592e5 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -6,15 +6,14 @@ import config from '../config'; import SystemService from '../services/system'; import { celebrate, Joi } from 'celebrate'; import UserService from '../services/user'; -import { - getUniqPath, - handleLogPath, - parseVersion, - promiseExec, -} from '../config/util'; +import { t } from '../shared/i18n'; +import { isDefaultAuthInfo } from '../shared/auth'; +import { getUniqPath, handleLogPath, parseVersion } from '../config/util'; +import { findCronId } from '../shared/cronCommand'; import dayjs from 'dayjs'; import multer from 'multer'; import { logStreamManager } from '../shared/logStreamManager'; +import { rateLimit } from 'express-rate-limit'; const route = Router(); const storage = multer.diskStorage({ @@ -38,14 +37,7 @@ export default (app: Router) => { const { version, changeLog, changeLogLink, publishTime } = await parseVersion(config.versionFile); - let isInitialized = true; - if ( - Object.keys(authInfo).length === 2 && - authInfo.username === 'admin' && - authInfo.password === 'admin' - ) { - isInitialized = false; - } + const isInitialized = !isDefaultAuthInfo(authInfo); res.send({ code: 200, data: { @@ -254,6 +246,7 @@ export default (app: Router) => { route.put( '/command-run', + rateLimit({ windowMs: 60 * 1000, limit: 60 }), celebrate({ body: Joi.object({ command: Joi.string().required(), @@ -263,8 +256,13 @@ export default (app: Router) => { try { const systemService = Container.get(SystemService); const command = req.body.command; - const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`; - let id = await promiseExec(idStr); + const crontab = await fs + .readFile(config.crontabFile, 'utf8') + .catch((e) => { + if (e.code === 'ENOENT') return ''; + throw e; + }); + const id = findCronId(crontab, command); const uniqPath = await getUniqPath(command, id); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logPath = `${uniqPath}/${logTime}.log`; @@ -278,8 +276,13 @@ export default (app: Router) => { }, onEnd: async (cp, endTime, diff) => { // Close the stream after task completion - await logStreamManager.closeStream(await handleLogPath(logPath)); - res.end(); + try { + await logStreamManager.closeStream( + await handleLogPath(logPath), + ); + } finally { + res.end(); + } }, onError: async (message: string) => { res.write(message); @@ -355,6 +358,11 @@ export default (app: Router) => { query: { startTime: Joi.string().allow('').optional(), endTime: Joi.string().allow('').optional(), + limit: Joi.number() + .integer() + .min(1) + .max(1024 * 1024) + .optional(), t: Joi.string().optional(), }, }), @@ -366,6 +374,7 @@ export default (app: Router) => { req.query as { startTime?: string; endTime?: string; + limit?: number; }, ); } catch (e) { @@ -400,8 +409,11 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { try { const userService = Container.get(UserService); - await userService.resetAuthInfo(req.body); - res.send({ code: 200, message: '更新成功' }); + const result = await userService.resetAuthInfo(req.body); + if (result) { + return res.send(result); + } + res.send({ code: 200, message: t('更新成功') }); } catch (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( '/config/global-ssh-key', celebrate({ diff --git a/back/api/user.ts b/back/api/user.ts index 6155f0a4..4e92b39b 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -8,6 +8,7 @@ import path from 'path'; import { v4 as uuidV4 } from 'uuid'; import rateLimit from 'express-rate-limit'; import config from '../config'; +import { t } from '../shared/i18n'; import { isDemoEnv, getToken } from '../config/util'; const route = Router(); @@ -21,7 +22,25 @@ const storage = multer.diskStorage({ cb(null, key + ext); }, }); -const upload = multer({ storage: storage }); +const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024, files: 1 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const imageTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + }; + if (imageTypes[ext] !== file.mimetype) { + return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片'))); + } + cb(null, true); + }, +}); export default (app: Router) => { app.use('/user', route); @@ -34,8 +53,8 @@ export default (app: Router) => { }), celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -69,18 +88,18 @@ export default (app: Router) => { '/', celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { if (isDemoEnv()) { - return res.send({ code: 450, message: '未知错误' }); + return res.send({ code: 450, message: t('未知错误') }); } const userService = Container.get(UserService); - await userService.updateUsernameAndPassword(req.body); - res.send({ code: 200, message: '更新成功' }); + const result = await userService.updateUsernameAndPassword(req.body); + res.send(result); } catch (e) { return next(e); } @@ -155,11 +174,12 @@ export default (app: Router) => { route.put( '/two-factor/login', + rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), celebrate({ body: Joi.object({ code: Joi.string().required(), - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { @@ -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( '/notification', async (req: Request, res: Response, next: NextFunction) => { @@ -218,18 +291,19 @@ export default (app: Router) => { route.put( '/init', + rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }), celebrate({ body: Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), + username: Joi.string().max(1024).required(), + password: Joi.string().max(1024).required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); - await userService.updateUsernameAndPassword(req.body); - res.send({ code: 200, message: '更新成功' }); + const result = await userService.initializeUser(req.body); + res.send(result); } catch (e) { return next(e); } diff --git a/back/app.ts b/back/app.ts index fc95a72f..96276014 100644 --- a/back/app.ts +++ b/back/app.ts @@ -1,13 +1,10 @@ import 'reflect-metadata'; import cluster, { type Worker } from 'cluster'; -import compression from 'compression'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; +import type express from 'express'; import { Container } from 'typedi'; import config from './config'; import Logger from './loaders/logger'; -import { monitoringMiddleware } from './middlewares/monitoring'; +import { errStack } from './shared/errors'; import { type GrpcServerService } from './services/grpc'; import { type HttpServerService } from './services/http'; @@ -19,24 +16,12 @@ interface WorkerMetadata { } class Application { - private app: express.Application; private httpServerService?: HttpServerService; private grpcServerService?: GrpcServerService; private isShuttingDown = false; private workerMetadataMap = new Map(); private httpWorker?: Worker; - constructor() { - this.app = express(); - // 创建一个全局中间件,删除查询参数中的t - this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { - if (req.query.t) { - delete req.query.t; - } - next(); - }); - } - async start() { try { if (cluster.isPrimary) { @@ -48,7 +33,7 @@ class Application { await this.startWorkerProcess(); } } catch (error) { - Logger.error('Failed to start application:', error); + Logger.error(`Failed to start application:\n${errStack(error)}`); process.exit(1); } } @@ -60,11 +45,11 @@ class Application { // Wait for gRPC worker to signal it's ready before starting HTTP worker this.waitForWorkerReady(grpcWorker, 30000) .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'); }) .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); }); @@ -73,34 +58,39 @@ class Application { if (metadata) { if (!this.isShuttingDown) { Logger.error( - `✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code + `${metadata.serviceType} worker ${worker.process.pid} died (${signal || code }). Restarting...`, ); // If gRPC worker died, restart it and wait for it to be ready if (metadata.serviceType === 'grpc') { + try { + this.httpWorker?.send('scheduler-unavailable'); + } catch (error) { + Logger.warn('Unable to notify HTTP worker of scheduler exit'); + } const newGrpcWorker = this.forkWorker('grpc'); this.waitForWorkerReady(newGrpcWorker, 30000) .then(() => { - Logger.info('✌️ gRPC worker restarted and ready'); + Logger.info('gRPC worker restarted and ready'); // Re-register cron jobs by notifying the HTTP worker if (this.httpWorker) { try { 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) { - Logger.error('✌️ Failed to send reregister-crons message:', error); + Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`); } } }) .catch((error) => { - Logger.error('✌️ Failed to restart gRPC worker:', error); + Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`); process.exit(1); }); } else { // For HTTP worker, just restart it const newWorker = this.forkWorker(metadata.serviceType); 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 { - const worker = cluster.fork({ SERVICE_TYPE: serviceType }); + const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType }; + // PM2's fork launcher is inherited by our own cluster workers. Their APM + // messages go to this primary, not PM2, and duplicate its sampling work. + // Keep primary monitoring and allow restoring the inherited worker APM. + if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') { + workerEnv.pmx = 'false'; + } + const worker = cluster.fork(workerEnv); this.workerMetadataMap.set(worker.id, { id: worker.id, @@ -144,17 +141,37 @@ class Application { } private async initializeDatabase() { - const dbLoader = await import('./loaders/db'); - await dbLoader.default(); + const { runStartupProcess } = await import('./shared/startupProcess'); + await runStartupProcess(require.resolve('./bootstrap/database')); } - private setupMiddlewares() { - this.app.use(helmet({ + private async setupMiddlewares(): Promise { + // Only the HTTP worker needs an Express application and its middleware. + const [ + { default: createExpress }, + { default: helmet }, + { default: cors }, + { default: compression }, + { monitoringMiddleware }, + ] = await Promise.all([ + import('express'), + import('helmet'), + import('cors'), + import('compression'), + import('./middlewares/monitoring'), + ]); + const app = createExpress(); + app.use((req, res, next) => { + if (req.query.t) delete req.query.t; + next(); + }); + app.use(helmet({ contentSecurityPolicy: false, })); - this.app.use(cors(config.cors)); - this.app.use(compression()); - this.app.use(monitoringMiddleware); + app.use(cors(config.cors)); + app.use(compression()); + app.use(monitoringMiddleware); + return app; } private setupMasterShutdown() { @@ -169,17 +186,14 @@ class Application { if (worker) { const exitPromise = new Promise((resolve) => { worker.once('exit', () => { - Logger.info(`✌️ Worker ${worker.process.pid} exited`); + Logger.info(`Worker ${worker.process.pid} exited`); resolve(); }); try { worker.send('shutdown'); } catch (error) { - Logger.warn( - `✌️ Failed to send shutdown to worker ${worker.process.pid}:`, - error, - ); + Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`); } }); @@ -192,14 +206,14 @@ class Application { Promise.all(workerPromises), new Promise((resolve) => { setTimeout(() => { - Logger.warn('✌️ Worker shutdown timeout reached'); + Logger.warn('Worker shutdown timeout reached'); resolve(); }, 10000); }), ]); process.exit(0); } catch (error) { - Logger.error('✌️ Error during worker shutdown:', error); + Logger.error(`Error during worker shutdown:\n${errStack(error)}`); process.exit(1); } }; @@ -211,11 +225,11 @@ class Application { private async startWorkerProcess() { const serviceType = process.env.SERVICE_TYPE; if (!serviceType || !['http', 'grpc'].includes(serviceType)) { - Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType); + Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType); process.exit(1); } - Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`); + Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`); try { if (serviceType === 'http') { @@ -226,22 +240,26 @@ class Application { process.send?.('ready'); } catch (error) { - Logger.error(`✌️ ${serviceType} worker failed:`, error); + Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`); process.exit(1); } } 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'); this.httpServerService = Container.get(HttpServerService); const appLoader = await import('./loaders/app'); - await appLoader.default({ app: this.app }); + await appLoader.default({ app }); const server = await this.httpServerService.initialize( - this.app, + app, config.port, ); @@ -262,17 +280,10 @@ class Application { process.on('message', async (msg) => { if (msg === 'shutdown') { this.gracefulShutdown(serviceType); - } else if (msg === 'reregister-crons' && serviceType === 'http') { - // Re-register cron jobs when gRPC worker restarts - try { - Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...'); - const CronService = (await import('./services/cron')).default; - const cronService = Container.get(CronService); - await cronService.autosave_crontab(); - Logger.info('✌️ Cron jobs re-registered successfully'); - } catch (error) { - Logger.error('✌️ Failed to re-register cron jobs:', error); - } + } else if (serviceType === 'http' && + (msg === 'reregister-crons' || msg === 'scheduler-unavailable')) { + const { default: cronClient } = await import('./schedule/client'); + cronClient.readiness.invalidate(); } }); @@ -293,7 +304,7 @@ class Application { } process.exit(0); } catch (error) { - Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error); + Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`); process.exit(1); } } @@ -301,6 +312,6 @@ class Application { const app = new Application(); app.start().catch((error) => { - Logger.error('🙅‍♀️ Application failed to start:', error); + Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`); process.exit(1); }); diff --git a/back/bootstrap/database.ts b/back/bootstrap/database.ts new file mode 100644 index 00000000..4fdeb4d0 --- /dev/null +++ b/back/bootstrap/database.ts @@ -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(); + }); diff --git a/back/config/const.ts b/back/config/const.ts index a6167c76..459cf0fd 100644 --- a/back/config/const.ts +++ b/back/config/const.ts @@ -1,3 +1,5 @@ +import { maybeSudo } from './container'; + export const LOG_END_SYMBOL = '     '; export const TASK_COMMAND = 'task'; @@ -49,6 +51,8 @@ export const NotificationModeStringMap = { 18: 'chronocat', 19: 'ntfy', 20: 'wxPusherBot', + 21: 'wxPusherSpt', + 22: 'wpush', } as const; export const LINUX_DEPENDENCE_COMMAND: Record< @@ -61,17 +65,17 @@ export const LINUX_DEPENDENCE_COMMAND: Record< } > = { Debian: { - install: 'sudo apt-get install -y', - uninstall: 'sudo apt-get remove -y', - info: 'sudo dpkg-query -s', + 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: 'sudo apt-get install -y', - uninstall: 'sudo apt-get remove -y', - info: 'sudo dpkg-query -s', + 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'); }, diff --git a/back/config/container.ts b/back/config/container.ts new file mode 100644 index 00000000..57539684 --- /dev/null +++ b/back/config/container.ts @@ -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; +} diff --git a/back/config/grpcCerts.ts b/back/config/grpcCerts.ts new file mode 100644 index 00000000..b8c20abb --- /dev/null +++ b/back/config/grpcCerts.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/back/config/index.ts b/back/config/index.ts index fb1c9ca5..00b2a7b8 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -1,6 +1,5 @@ import dotenv from 'dotenv'; import path from 'path'; -import { createRandomString } from './share'; dotenv.config({ path: path.join(__dirname, '../../.env'), @@ -110,6 +109,7 @@ const jsEnvFile = path.join(preloadPath, 'env.js'); const pyEnvFile = path.join(preloadPath, 'env.py'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); +const langEnvFile = path.join(preloadPath, 'lang_env.sh'); const confFile = path.join(configPath, 'config.sh'); const crontabFile = path.join(configPath, 'crontab.list'); const authConfigFile = path.join(configPath, 'auth.json'); @@ -118,8 +118,6 @@ const confBakDir = path.join(dataPath, 'config/bak/'); const sampleFile = path.join(samplePath, 'config.sample.sh'); const sqliteFile = path.join(samplePath, 'database.sqlite'); -const authError = '错误的用户名密码,请重试'; -const loginFaild = '请先登录!'; const configString = 'config sample crontab shareCode diy'; const versionFile = path.join(rootPath, 'version.yaml'); const dataTgzFile = path.join(tmpPath, 'data.tgz'); @@ -141,8 +139,6 @@ export default { shareShellFile, dependenceProxyFile, configString, - loginFaild, - authError, logPath, extraFile, authConfigFile, @@ -155,6 +151,7 @@ export default { pyEnvFile, jsNotifyFile, pyNotifyFile, + langEnvFile, dbPath, uploadPath, configPath, @@ -171,6 +168,8 @@ export default { 'env.js', 'env.py', 'token.json', + 'grpc', + '__pycache__', ], writePathList: [configPath, scriptPath], bakPath, diff --git a/back/config/share.ts b/back/config/share.ts index 8577feea..24c52fa7 100644 --- a/back/config/share.ts +++ b/back/config/share.ts @@ -1,3 +1,5 @@ +import { randomInt } from 'crypto'; + export function createRandomString(min: number, max: number): string { const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; const english = [ @@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string { arr.push(getOne(ENGLISH)); arr.push(getOne(special)); - const len = min + Math.floor(Math.random() * (max - min + 1)); + const len = min + randomInt(max - min + 1); for (let i = 4; i < len; i++) { - arr.push(config[Math.floor(Math.random() * config.length)]); + arr.push(config[randomInt(config.length)]); } const newArr = []; for (let j = 0; j < len; j++) { - newArr.push(arr.splice(Math.random() * arr.length, 1)[0]); + newArr.push(arr.splice(randomInt(arr.length), 1)[0]); } function getOne(arr: any[]) { - return arr[Math.floor(Math.random() * arr.length)]; + return arr[randomInt(arr.length)]; } return newArr.join(''); -} \ No newline at end of file +} diff --git a/back/config/subscription.ts b/back/config/subscription.ts index 0973948b..88ce358e 100644 --- a/back/config/subscription.ts +++ b/back/config/subscription.ts @@ -1,5 +1,7 @@ import { Subscription } from '../data/subscription'; import isNil from 'lodash/isNil'; +import { shellQuote } from '../shared/shellQuote'; +import { getSubscriptionSshAlias } from '../shared/subscriptionPath'; export function formatUrl(doc: Subscription) { let url = doc.url; @@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) { if (doc.type === 'private-repo') { if (doc.pull_type === 'ssh-key') { host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1'); - url = doc.url!.replace(host, doc.alias); + url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias)); } else { host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1'); const { username, password } = doc.pull_option as any; @@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) { } export function formatCommand(doc: Subscription, url?: string) { - let command = `SUB_ID=${doc.id} ql `; - let _url = url || formatUrl(doc).url; - const { - type, - whitelist, - blacklist, - dependences, - branch, - extensions, - proxy, - autoAddCron, - autoDelCron, - } = doc; - if (type === 'file') { - command += `raw "${_url}" "${proxy || ''}" "${ - isNil(autoAddCron) ? true : Boolean(autoAddCron) - }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; - } else { - command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${ - dependences || '' - }" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${ - isNil(autoAddCron) ? true : Boolean(autoAddCron) - }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; - } - return command; + const args = + doc.type === 'file' + ? ['raw', url || formatUrl(doc).url, doc.proxy || ''] + : [ + 'repo', + url || formatUrl(doc).url, + doc.whitelist || '', + doc.blacklist || '', + doc.dependences || '', + doc.branch || '', + doc.extensions || '', + doc.proxy || '', + ]; + args.push( + String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)), + String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)), + ); + return `SUB_ID=${shellQuote(String(doc.id))} ql ${args + .map((arg) => shellQuote(arg!)) + .join(' ')}`; } diff --git a/back/config/util.ts b/back/config/util.ts index 5eb1c339..6681848b 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -11,6 +11,9 @@ import { writeFileWithLock } from '../shared/utils'; import { DependenceTypes } from '../data/dependence'; 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'; @@ -143,7 +146,8 @@ export async function handleLogPath( logPath: string, data: string = '', ): Promise { - const absolutePath = path.resolve(config.logPath, logPath); + const absolutePath = resolveFileAccess(config.logPath, [logPath]); + if (!absolutePath) throw new Error('Log path is outside the log directory'); const logFileExist = await fileExist(absolutePath); if (!logFileExist) { await createFile(absolutePath, data); @@ -213,48 +217,105 @@ export function dirSort(a: IFile, b: IFile): number { } } +const FILE_SYSTEM_READ_CONCURRENCY = 32; + +type FileSystemTaskRunner = (task: () => Promise) => Promise; + +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 (task: () => Promise) => + new Promise((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 { + 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 => { + if (blacklist.includes(entry.name) || entry.isSymbolicLink()) { + return undefined; + } + + const subPath = path.join(dir, entry.name); + const stats = await runFileSystemTask(() => fs.lstat(subPath)); + if (stats.isSymbolicLink()) { + return undefined; + } + const key = path.join(relativePath, entry.name); + + if (stats.isDirectory()) { + const children = await readDirsWithRunner( + subPath, + baseDir, + blacklist, + sort, + runFileSystemTask, + ); + return { + title: entry.name, + key, + type: 'directory', + parent: relativePath, + createTime: stats.birthtime.getTime(), + children, + }; + } + + return { + title: entry.name, + type: 'file', + key, + parent: relativePath, + size: stats.size, + createTime: stats.birthtime.getTime(), + }; + }), + ); + + return items.filter((item): item is IFile => Boolean(item)).sort(sort); +} + export async function readDirs( dir: string, baseDir: string = '', blacklist: string[] = [], sort: (a: IFile, b: IFile) => number = dirSort, ): Promise { - const relativePath = path.relative(baseDir, dir); - const files = await fs.readdir(dir); - const result: IFile[] = []; - - for (const file of files) { - const subPath = path.join(dir, file); - const stats = await fs.lstat(subPath); - const key = path.join(relativePath, file); - - if (blacklist.includes(file) || stats.isSymbolicLink()) { - continue; - } - - if (stats.isDirectory()) { - const children = await readDirs(subPath, baseDir, blacklist, sort); - result.push({ - title: file, - key, - type: 'directory', - parent: relativePath, - createTime: stats.birthtime.getTime(), - children: children.sort(sort), - }); - } else { - result.push({ - title: file, - type: 'file', - key, - parent: relativePath, - size: stats.size, - createTime: stats.birthtime.getTime(), - }); - } - } - - return result.sort(sort); + return readDirsWithRunner( + dir, + baseDir, + blacklist, + sort, + createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY), + ); } export async function readDir( @@ -429,18 +490,69 @@ export function psTree(pid: number): Promise { }); } -export async function killTask(pid: number) { - const pids = await psTree(pid); - - if (pids.length) { +export async function killTask(pid: number, waitForExit = false) { + const descendants = await psTree(pid); + const signal = (target: number, sig: NodeJS.Signals) => { try { - [pid, ...pids].reverse().forEach((x) => { - process.kill(x, 15); - }); - } catch (error) {} - } else { - process.kill(pid, 2); + process.kill(target, sig); + } catch (error: any) { + if (error.code !== 'ESRCH') throw error; + } + }; + if (!waitForExit) { + if (descendants.length) { + // A child may exit after psTree; keep signalling the remaining tree. + for (const target of [pid, ...descendants].reverse()) { + signal(target, 'SIGTERM'); + } + } else signal(pid, 'SIGINT'); + return; } + const pids = [...descendants.reverse(), pid]; + for (const target of pids) signal(target, 'SIGTERM'); + const alive = async (target: number) => { + try { + process.kill(target, 0); + } catch (error: any) { + if (error.code === 'ESRCH') return false; + throw error; + } + if (process.platform === 'linux') { + try { + const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8'); + // The command field may contain spaces and parentheses. Zombies have + // exited even while their parent has not reaped the PID yet. + const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0]; + if (['Z', 'X', 'x'].includes(state)) return false; + } catch { + // /proc may be unavailable, or the process may have just exited. + // Retain the portable signal probe rather than assuming it is dead. + try { + process.kill(target, 0); + } catch (error: any) { + if (error.code === 'ESRCH') return false; + throw error; + } + } + } + return true; + }; + const wait = async () => { + const deadline = Date.now() + 1000; + let remaining = pids; + while (true) { + const states = await Promise.all(remaining.map(alive)); + remaining = remaining.filter((_, index) => states[index]); + if (!remaining.length || Date.now() >= deadline) return remaining; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }; + let remaining = await wait(); + if (!remaining.length) return; + for (const target of remaining) signal(target, 'SIGKILL'); + remaining = await wait(); + if (remaining.length) + throw new Error(`Task processes did not exit: ${remaining.join(', ')}`); } export async function getPid(cmd: string) { @@ -535,11 +647,13 @@ export function safeJSONParse(value?: string) { try { return JSON.parse(value); } catch (error) { - Logger.error('[safeJSONParse失败]', error); + Logger.error('[safeJSONParse error]', error); return {}; } } +export { errStack } from '../shared/errors'; + export async function rmPath(path: string) { try { const _exsit = await fileExist(path); @@ -547,7 +661,7 @@ export async function rmPath(path: string) { await fs.rm(path, { force: true, recursive: true, maxRetries: 5 }); } } catch (error) { - Logger.error('[rmPath失败]', error); + Logger.error('[rmPath error]', error); } } @@ -557,12 +671,14 @@ export async function setSystemTimezone(timezone: string): Promise { throw new Error('Invalid timezone'); } - await promiseExec(`sudo ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); - await promiseExec(`echo "${timezone}" | sudo tee /etc/timezone`); + await promiseExec( + maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`), + ); + await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`); return true; } catch (error) { - Logger.error('[setSystemTimezone失败]', error); + Logger.error('[setSystemTimezone error]', error); return false; } } @@ -571,7 +687,13 @@ export function getGetCommand(type: DependenceTypes, name: string): string { const baseCommands = { [DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`, [DependenceTypes.python3]: ` - python3 -c "exec(''' + ${ + PYTHON_VENV_DIR + ? shellQuote( + path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'), + ) + : 'python3' + } -c "exec(''' name='${name}' try: from importlib.metadata import version @@ -582,9 +704,10 @@ except: spec=u.find_spec(name) print(name if spec else '') ''')"`, - [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' - ? `apk info -es ${name}` - : `sudo dpkg-query -s ${name}`, + [DependenceTypes.linux]: + getOsTypeSync() === 'Alpine' + ? `apk info -es ${name}` + : maybeSudo(`dpkg-query -s ${name}`), }; return baseCommands[type]; @@ -593,16 +716,26 @@ except: export function getInstallCommand(type: DependenceTypes, name: string): string { const baseCommands = { [DependenceTypes.nodejs]: 'pnpm add -g', - [DependenceTypes.python3]: - 'pip3 install --disable-pip-version-check --root-user-action=ignore', - [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' - ? 'apk add --no-check-certificate' - : 'sudo apt-get install -y', + [DependenceTypes.python3]: `${ + PYTHON_VENV_DIR + ? shellQuote( + path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'), + ) + ' -m pip' + : 'pip3' + } install --disable-pip-version-check --root-user-action=ignore`, + [DependenceTypes.linux]: + getOsTypeSync() === 'Alpine' + ? 'apk add --no-check-certificate' + : maybeSudo('apt-get install -y'), }; let command = baseCommands[type]; - if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR) { + if ( + type === DependenceTypes.python3 && + PYTHON_INSTALL_DIR && + !PYTHON_VENV_DIR + ) { command = `${command} --prefix=${PYTHON_INSTALL_DIR}`; } @@ -615,11 +748,15 @@ export function getUninstallCommand( ): string { const baseCommands = { [DependenceTypes.nodejs]: 'pnpm remove -g', - [DependenceTypes.python3]: - 'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', - [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' - ? 'apk del' - : 'sudo apt-get remove -y', + [DependenceTypes.python3]: `${ + PYTHON_VENV_DIR + ? shellQuote( + path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'), + ) + ' -m pip' + : 'pip3' + } uninstall --disable-pip-version-check --root-user-action=ignore -y`, + [DependenceTypes.linux]: + getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'), }; return `${baseCommands[type]} ${name.trim()}`; @@ -732,31 +869,52 @@ async function _updateLinuxMirror( osType: string, mirrorDomainWithScheme: string, ): Promise { + 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 `sudo sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (sudo 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" | sudo tee ${filePath}) && sudo apt-get update`; + 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 `sudo 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" | sudo tee ${filePath} && sudo apt-get update`; + 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 `sudo sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (sudo 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" | sudo tee ${filePath}) && sudo apt-get update`; + 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 `sudo 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" | sudo tee ${filePath} && sudo apt-get update`; + 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`; + 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`; + 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.'); diff --git a/back/data/cron.ts b/back/data/cron.ts index 32b0b554..c0c0ad0e 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -13,6 +13,7 @@ export class Crontab { pid?: number; isDisabled?: 1 | 0; log_path?: string; + queued_token?: string | null; isPinned?: 1 | 0; labels?: string[]; last_running_time?: number; @@ -83,6 +84,7 @@ export const CrontabModel = sequelize.define('Crontab', { isDisabled: DataTypes.NUMBER, isPinned: DataTypes.NUMBER, log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, labels: DataTypes.JSON, last_running_time: DataTypes.NUMBER, last_execution_time: DataTypes.NUMBER, diff --git a/back/data/notify.ts b/back/data/notify.ts index 1d048dfe..e9e5c5fd 100644 --- a/back/data/notify.ts +++ b/back/data/notify.ts @@ -21,6 +21,7 @@ export enum NotificationMode { 'ntfy' = 'ntfy', 'wxPusherBot' = 'wxPusherBot', 'openiLink' = 'openiLink', + 'wpush' = 'wpush', } abstract class NotificationBaseInfo { @@ -162,12 +163,22 @@ export class WxPusherBotNotification extends NotificationBaseInfo { 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 extends GoCqHttpBotNotification, GotifyNotification, @@ -190,4 +201,6 @@ export interface NotificationInfo LarkNotification, NtfyNotification, WxPusherBotNotification, - OpeniLinkNotification {} + WxPusherSptNotification, + OpeniLinkNotification, + WpushNotification {} diff --git a/back/data/runningInstance.ts b/back/data/runningInstance.ts new file mode 100644 index 00000000..5d8b3be4 --- /dev/null +++ b/back/data/runningInstance.ts @@ -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 {} + +export const RunningInstanceModel = sequelize.define( + '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, + }, + }, +); diff --git a/back/data/sock.ts b/back/data/sock.ts index 8dae8e13..bb4d7798 100644 --- a/back/data/sock.ts +++ b/back/data/sock.ts @@ -2,11 +2,13 @@ export class SockMessage { message?: string; type?: SockMessageType; references?: number[]; + status?: number | string; constructor(options: SockMessage) { this.type = options.type; this.message = options.message; this.references = options.references; + this.status = options.status; } } diff --git a/back/data/subscription.ts b/back/data/subscription.ts index bb561d2f..78bdfa03 100644 --- a/back/data/subscription.ts +++ b/back/data/subscription.ts @@ -20,7 +20,7 @@ export class Subscription { pull_option?: | { private_key: string } | { username: string; password: string }; - pid?: number; + pid?: number | null; is_disabled?: 1 | 0; log_path?: string; alias: string; diff --git a/back/data/system.ts b/back/data/system.ts index 2dc14f50..af20e8ba 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -31,6 +31,8 @@ export enum AuthDataType { } export interface SystemConfigInfo { + lang?: string; + panelTitle?: string; logRemoveFrequency?: number; cronConcurrency?: number; dependenceProxy?: string; @@ -39,6 +41,8 @@ export interface SystemConfigInfo { linuxMirror?: string; timezone?: string; globalSshKey?: string; + runningInstanceRetentionDays?: number; + cronStatRetentionDays?: number; } export interface LoginLogInfo { @@ -71,11 +75,14 @@ export interface AuthInfo { lastaddr: string; platform: string; isTwoFactorChecking: boolean; + twoFactorExpiresAt?: number; + lastTwoFactorStep?: number; token: string; tokens: Record; twoFactorActivated: boolean; twoFactorSecret: string; avatar: string; + blockedIps?: string[]; } export type SystemModelInfo = SystemConfigInfo & diff --git a/back/loaders/app.ts b/back/loaders/app.ts index c9f8e2dc..094aac9b 100644 --- a/back/loaders/app.ts +++ b/back/loaders/app.ts @@ -6,23 +6,25 @@ import { Application } from 'express'; import linkDeps from './deps'; import initTask from './initTask'; import initFile from './initFile'; +import { initializeTrustProxy } from '../shared/trustProxy'; export default async ({ app }: { app: Application }) => { depInjectorLoader(); - Logger.info('✌️ Dependency loaded'); + Logger.info('[boot] Dependency loaded'); await linkDeps(); - Logger.info('✌️ Link deps loaded'); + Logger.info('[boot] Link deps loaded'); await initFile(); - Logger.info('✌️ Init file loaded'); + Logger.info('[boot] Init file loaded'); await initData(); - Logger.info('✌️ Init data loaded'); + Logger.info('[boot] Init data loaded'); initTask(); - Logger.info('✌️ Init task loaded'); + Logger.info('[boot] Init task loaded'); expressLoader({ app }); - Logger.info('✌️ Express loaded'); + await initializeTrustProxy(app); + Logger.info('[boot] Express loaded'); }; diff --git a/back/loaders/db.ts b/back/loaders/db.ts index 389c2c1e..44a9c076 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -7,7 +7,9 @@ import { SystemModel } from '../data/system'; import { SubscriptionModel } from '../data/subscription'; import { CrontabViewModel } from '../data/cronView'; import { CrontabStatModel } from '../data/cronStats'; +import { RunningInstanceModel } from '../data/runningInstance'; import { sequelize } from '../data'; +import { migrateSchema } from '../shared/schemaMigrations'; export default async () => { try { @@ -19,45 +21,13 @@ export default async () => { await SubscriptionModel.sync(); await CrontabViewModel.sync(); await CrontabStatModel.sync(); + await RunningInstanceModel.sync(); - // 初始化新增字段 - const migrations = [ - { - table: 'CrontabViews', - column: 'filterRelation', - type: 'VARCHAR(255)', - }, - { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, - { table: 'CrontabViews', column: 'type', type: 'NUMBER' }, - { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, - { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, - { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, - { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, - { table: 'Crontabs', column: 'task_before', type: 'TEXT' }, - { table: 'Crontabs', column: 'task_after', type: 'TEXT' }, - { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, - { - table: 'Crontabs', - column: 'allow_multiple_instances', - type: 'NUMBER', - }, - { table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' }, - { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, - { table: 'Envs', column: 'labels', type: 'JSON' }, - ]; + await migrateSchema(sequelize); - for (const migration of migrations) { - try { - await sequelize.query( - `alter table ${migration.table} add column ${migration.column} ${migration.type}`, - ); - } catch (error) { - // Column already exists or other error, continue - } - } - - Logger.info('✌️ DB loaded'); + Logger.info('[boot] DB loaded'); } catch (error) { - Logger.error('✌️ DB load failed', error); + Logger.error('[boot] DB load failed', error); + throw error; } }; diff --git a/back/loaders/deps.ts b/back/loaders/deps.ts index cb2ed171..59fd5b21 100644 --- a/back/loaders/deps.ts +++ b/back/loaders/deps.ts @@ -1,22 +1,9 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; -import chokidar from 'chokidar'; import config from '../config/index'; 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() { const homeDir = os.homedir(); let userBinDir = path.join(homeDir, 'bin'); @@ -30,6 +17,50 @@ async function linkCommand() { } async function linkCommandToDir(commandDir: string) { + const cliRoot = process.env.QL_CLI_ROOT; + if (cliRoot) { + if (!path.isAbsolute(cliRoot)) { + throw new Error('QL_CLI_ROOT must be an absolute CLI installation path'); + } + const { installCliEntrypoints } = require(path.join( + cliRoot, + 'dist/local/entrypoints.js', + )); + await installCliEntrypoints(commandDir, cliRoot); + const { installCronEntrypoint } = require(path.join( + cliRoot, + 'dist/local/cronEntrypoint.js', + )); + await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, { + ...process.env, + QL_DIR: config.rootPath, + QL_DATA_DIR: config.dataPath, + }); + // Container-wide legacy links may precede ~/bin. Ensure panel children use + // the explicitly selected entries without changing system-wide links. + process.env.PATH = [ + commandDir, + ...(process.env.PATH?.split(path.delimiter) ?? []).filter( + (entry) => entry !== commandDir, + ), + ].join(path.delimiter); + return; + } + + const cronBridge = path.join(commandDir, 'crontab'); + try { + if ( + (await fs.lstat(cronBridge)).isFile() && + (await fs.readFile(cronBridge, 'utf8')).includes( + '// QingLong CLI crontab bridge', + ) + ) { + await fs.unlink(cronBridge); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const linkShell = [ { src: 'update.sh', @@ -52,24 +83,13 @@ async function linkCommandToDir(commandDir: string) { if (stats) { await fs.unlink(tmpTarget); } - } catch (error) { } + } catch (error) {} await fs.symlink(source, tmpTarget); await fs.rename(tmpTarget, target); } } -export default async (src: string = 'deps') => { +export default async () => { 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)); }; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index b5bf221b..8ef1f06d 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -8,42 +8,67 @@ import { getPlatform, getToken } from '../config/util'; import rewrite from 'express-urlrewrite'; import { errors } from 'celebrate'; import { serveEnv } from '../config/serverEnv'; -import { IKeyvStore, shareStore } from '../shared/store'; -import { isValidToken } from '../shared/auth'; +import { shareStore } from '../shared/store'; +import { isValidToken, isDefaultAuthInfo } from '../shared/auth'; +import { AuthInfo } from '../data/system'; import path from 'path'; +import { t } from '../shared/i18n'; +import { AppScope } from '../data/open'; +import uniqueConstraintError from '../middlewares/uniqueConstraintError'; +import protectedPathCase from '../middlewares/protectedPathCase'; + +function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { + const setting = value?.trim(); + if (!setting) { + return 'loopback'; + } + if (setting === 'true' || setting === 'false') { + return setting === 'true'; + } + if (/^\d+$/.test(setting)) { + return Number(setting); + } + return setting; +} export default ({ app }: { app: Application }) => { // Security: Enable strict routing to prevent case-insensitive path bypass app.set('case sensitive routing', true); app.set('strict routing', true); - app.set('trust proxy', 'loopback'); + app.set('trust proxy', resolveTrustProxy()); app.use(cors()); - - // Security: Path normalization middleware to prevent case variation attacks - app.use((req, res, next) => { - const originalPath = req.path; - const normalizedPath = originalPath.toLowerCase(); - - // Block requests with case variations on protected paths - if (originalPath !== normalizedPath && - (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { - return res.status(400).json({ - code: 400, - message: 'Invalid path format' - }); - } - - next(); - }); - + + // Security: Reject case variations under protected API namespaces before + // authentication checks can interpret the request differently from routing. + app.use(protectedPathCase); + // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness if (config.baseUrl) { app.use(rewrite(`${config.baseUrl}/*`, '/$1')); } - + app.get(`${config.api.prefix}/env.js`, serveEnv); - app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); + app.use( + `${config.api.prefix}/static`, + express.static(config.uploadPath, { + setHeaders: (res) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'"); + }, + }), + ); + + const credentialPaths = ['/api', '/open'].flatMap((prefix) => + ['/user/login', '/user/init', '/user/two-factor/login'].map( + (route) => `${prefix}${route}`, + ), + ); + app.use( + credentialPaths, + bodyParser.json({ limit: '16kb' }), + bodyParser.urlencoded({ limit: '16kb', extended: false }), + ); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); @@ -86,17 +111,29 @@ export default ({ app }: { app: Application }) => { const currentToken = doc.tokens.find((x) => x.value === headerToken); const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/); const key = keyMatch && keyMatch[1]; - if ( - doc.scopes.includes(key as any) && - currentToken && - currentToken.expiration >= Math.round(Date.now() / 1000) - ) { - return next(); + + if (!doc.scopes.includes(key as AppScope)) { + const err = new UnauthorizedError('credentials_bad_scheme', { + message: t('暂无权限'), + }); + 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 ( !headerToken && originPath && @@ -106,14 +143,12 @@ export default ({ app }: { app: Application }) => { } const authInfo = await shareStore.getAuthInfo(); - if (isValidToken(authInfo, headerToken, req.platform)) { + if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) { return next(); } const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; - const errorMessage = headerToken - ? 'jwt malformed' - : 'No authorization token was found'; + const errorMessage = headerToken ? t('Token 已失效') : t('请先登录'); const err = new UnauthorizedError(errorCode, { message: errorMessage }); next(err); }); @@ -126,24 +161,16 @@ export default ({ app }: { app: Application }) => { '/api/user/notification/init', '/open/user/init', '/open/user/notification/init', - ].includes(req.path) + ].includes(pathLower) ) { return next(); } - const authInfo = - (await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']); + const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo); - let isInitialized = true; - if ( - Object.keys(authInfo).length === 2 && - authInfo.username === 'admin' && - authInfo.password === 'admin' - ) { - isInitialized = false; - } + let isInitialized = !isDefaultAuthInfo(authInfo); if (isInitialized) { - return res.send({ code: 450, message: '未知错误' }); + return res.send({ code: 450, message: t('未知错误') }); } else { return next(); } @@ -164,6 +191,7 @@ export default ({ app }: { app: Application }) => { }); app.use(errors()); + app.use(uniqueConstraintError); app.use( ( diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index 264d7e05..7e36171b 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -13,11 +13,13 @@ import { AuthDataType, SystemModel } from '../data/system'; import SystemService from '../services/system'; import UserService from '../services/user'; import { writeFile, readFile } from 'fs/promises'; -import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util'; +import { fileExist, isDemoEnv, safeJSONParse } from '../config/util'; import OpenService from '../services/open'; import { shareStore } from '../shared/store'; import Logger from './logger'; -import { AppModel } from '../data/open'; +import cronClient from '../schedule/client'; +import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; +import { setLang, systemLang } from '../shared/i18n'; export default async () => { const cronService = Container.get(CronService); @@ -28,24 +30,7 @@ export default async () => { const openService = Container.get(OpenService); // 初始化增加系统配置 - let systemApp = ( - await AppModel.findOne({ - where: { name: 'system' }, - }) - )?.get({ plain: true }); - if (!systemApp) { - systemApp = await AppModel.create({ - name: 'system', - scopes: ['crons', 'system', 'dashboard'], - client_id: createRandomString(12, 12), - client_secret: createRandomString(24, 24), - }); - } else if (!systemApp.scopes.includes('dashboard')) { - await AppModel.update( - { scopes: [...systemApp.scopes, 'dashboard'] }, - { where: { name: 'system' } }, - ); - } + await openService.initializeSystemApp(); const [systemConfig] = await SystemModel.findOrCreate({ where: { type: AuthDataType.systemConfig }, }); @@ -139,6 +124,16 @@ export default async () => { // 初始化更新所有任务状态为空闲 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 任务 CrontabModel.findAll({ where: { @@ -215,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和定时任务数据 - await cronService.autosave_crontab(); + cronClient.readiness.configure(async () => { + await cronService.autosave_crontab(true); + await systemService.restoreCronConcurrency(); + }); + await cronClient.readiness.recover(); + await envService.set_envs(); const authInfo = await userService.getAuthInfo(); - const apps = await openService.findApps(); await shareStore.updateAuthInfo(authInfo); - if (apps?.length) { - await shareStore.updateApps(apps); - } + await openService.refreshApps(); }; diff --git a/back/loaders/initFile.ts b/back/loaders/initFile.ts index ac629d63..1c1a93dd 100644 --- a/back/loaders/initFile.ts +++ b/back/loaders/initFile.ts @@ -125,5 +125,5 @@ export default async () => { } } - Logger.info('✌️ Init file down'); + Logger.info('[boot] Init file down'); }; diff --git a/back/loaders/initTask.ts b/back/loaders/initTask.ts index bdc57d42..7424a6b5 100644 --- a/back/loaders/initTask.ts +++ b/back/loaders/initTask.ts @@ -6,6 +6,9 @@ import SshKeyService from '../services/sshKey'; import config from '../config'; import { fileExist } from '../config/util'; import { join } from 'path'; +import { t } from '../shared/i18n'; +import Logger from './logger'; +import { assertSubscriptionAlias } from '../shared/subscriptionPath'; export default async () => { const systemService = Container.get(SystemService); @@ -23,9 +26,10 @@ export default async () => { if (await fileExist(tokenFile)) { tokenCommand = `node ${tokenFile}`; } + tokenCommand += ' --renew'; const cron = { id: NaN, - name: '生成token', + name: t('生成token'), command: tokenCommand, runOrigin: 'system', } as ScheduleTaskType; @@ -44,7 +48,7 @@ export default async () => { if (data.info.logRemoveFrequency) { const rmlogCron = { id: data.id as number, - name: '删除日志', + name: t('删除日志'), command: `ql rmlog ${data.info.logRemoveFrequency}`, runOrigin: 'system' as const, }; @@ -69,6 +73,12 @@ export default async () => { await subscriptionService.setSshConfig(); const subs = await subscriptionService.list(); for (const sub of subs) { - subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled); + try { + const doc = sub.get({ plain: true }); + assertSubscriptionAlias(doc.alias); + await subscriptionService.handleTask(doc, !sub.is_disabled); + } catch (error) { + Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error); + } } }; diff --git a/back/loaders/server.ts b/back/loaders/server.ts index dd010777..e3adb624 100644 --- a/back/loaders/server.ts +++ b/back/loaders/server.ts @@ -4,7 +4,7 @@ import Sock from './sock'; export default async ({ server }: { server: Server }) => { await Sock({ server }); - Logger.info('✌️ Sock loaded'); + Logger.info('[boot] Sock loaded'); process.on('uncaughtException', (error) => { Logger.error('Uncaught exception:', error); diff --git a/back/loaders/sock.ts b/back/loaders/sock.ts index 5ab0b497..b09d6aeb 100644 --- a/back/loaders/sock.ts +++ b/back/loaders/sock.ts @@ -1,4 +1,4 @@ -import sockJs from 'sockjs'; +import sockJs, { Connection } from 'sockjs'; import { Server } from 'http'; import { Container } from 'typedi'; import SockService from '../services/sock'; @@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth'; import config from '../config'; export default async ({ server }: { server: Server }) => { - const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } }); + const echo = sockJs.createServer({ + prefix: `${config.baseUrl}/api/ws`, + log: () => {}, + }); const sockService = Container.get(SockService); + const sessions = new Map(); + let sessionTimer: ReturnType | 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>(); + for (const [conn, { token, platform }] of batch) { + if (!sessions.has(conn)) continue; + let platforms = validated.get(token); + if (!platforms) { + platforms = new Map(); + validated.set(token, platforms); + } + let valid = platforms.get(platform); + if (valid === undefined) { + valid = isValidToken(current, token, platform, config.jwt.secret); + platforms.set(platform, valid); + } + if (!valid) conn.close('401'); + } + } catch { + for (const [conn] of batch) { + if (sessions.has(conn)) conn.close('401'); + } + } finally { + checking = false; + } + }; echo.on('connection', async (conn) => { if (!conn.headers || !conn.url || !conn.pathname) { conn.close('404'); + return; } - const authInfo = await shareStore.getAuthInfo(); + let closed = false; + conn.on('close', () => { + closed = true; + sessions.delete(conn); + sockService.removeClient(conn); + if (sessions.size === 0 && sessionTimer) { + clearInterval(sessionTimer); + sessionTimer = undefined; + } + }); + + let authInfo; + try { + authInfo = await shareStore.getAuthInfo(); + } catch { + if (!closed) conn.close('401'); + return; + } + if (closed) return; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); - if (isValidToken(authInfo, headerToken, platform)) { + if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) { sockService.addClient(conn); + sessions.set(conn, { token: headerToken, platform }); + if (!sessionTimer) { + sessionTimer = setInterval(checkSessions, 1000); + sessionTimer.unref(); + } conn.on('data', (message) => { conn.write(message); }); - conn.on('close', function () { - sockService.removeClient(conn); - }); - return; } diff --git a/back/middlewares/monitoring.ts b/back/middlewares/monitoring.ts index d0d3f2f4..ab9b6c01 100644 --- a/back/middlewares/monitoring.ts +++ b/back/middlewares/monitoring.ts @@ -3,46 +3,34 @@ import Logger from '../loaders/logger'; import { performance } from 'perf_hooks'; import { metricsService } from '../services/metrics'; -interface RequestMetrics { - method: string; - path: string; - duration: number; - statusCode: number; - timestamp: number; - platform?: string; -} - -const requestMetrics: RequestMetrics[] = []; +const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health']; +const HTTP_METRIC_SAMPLE_INTERVAL = 10; +let requestSampleOffset = 0; export const monitoringMiddleware = ( req: Request, res: Response, next: NextFunction, ) => { + if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) { + return next(); + } + const start = performance.now(); const originalEnd = res.end; res.end = function (chunk?: any, encoding?: any, cb?: any) { const duration = performance.now() - start; - const metric: RequestMetrics = { - method: req.method, - path: req.path, - duration, - statusCode: res.statusCode, - timestamp: Date.now(), - platform: req.platform, - }; - - requestMetrics.push(metric); - metricsService.record('http_request', duration, { - method: req.method, - path: req.path, - statusCode: res.statusCode.toString(), - ...(req.platform && { platform: req.platform }), - }); - - if (requestMetrics.length > 1000) { - requestMetrics.shift(); + const shouldSample = requestSampleOffset === 0; + requestSampleOffset = + (requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL; + if (shouldSample) { + metricsService.record('http_request', duration, { + method: req.method, + path: req.path, + statusCode: res.statusCode.toString(), + ...(req.platform && { platform: req.platform }), + }); } if (duration > 1000) { @@ -58,23 +46,3 @@ export const monitoringMiddleware = ( next(); }; - -export const getMetrics = () => { - return { - totalRequests: requestMetrics.length, - averageDuration: - requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) / - requestMetrics.length, - requestsByMethod: requestMetrics.reduce((acc, curr) => { - acc[curr.method] = (acc[curr.method] || 0) + 1; - return acc; - }, {} as Record), - requestsByPlatform: requestMetrics.reduce((acc, curr) => { - if (curr.platform) { - acc[curr.platform] = (acc[curr.platform] || 0) + 1; - } - return acc; - }, {} as Record), - recentRequests: requestMetrics.slice(-10), - }; -}; diff --git a/back/middlewares/protectedPathCase.ts b/back/middlewares/protectedPathCase.ts new file mode 100644 index 00000000..21d2b62e --- /dev/null +++ b/back/middlewares/protectedPathCase.ts @@ -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(); +} diff --git a/back/middlewares/uniqueConstraintError.ts b/back/middlewares/uniqueConstraintError.ts new file mode 100644 index 00000000..8d4a6eda --- /dev/null +++ b/back/middlewares/uniqueConstraintError.ts @@ -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; diff --git a/back/protos/api.proto b/back/protos/api.proto index f819c062..4c3269eb 100644 --- a/back/protos/api.proto +++ b/back/protos/api.proto @@ -151,6 +151,8 @@ enum NotificationMode { chronocat = 18; ntfy = 19; wxPusherBot = 20; + wxPusherSpt = 21; + wpush = 22; } message NotificationInfo { @@ -244,6 +246,12 @@ message NotificationInfo { optional string wxPusherBotAppToken = 66; optional string wxPusherBotTopicIds = 67; optional string wxPusherBotUids = 68; + + optional string wxPusherSptList = 70; + + optional string wpushApiKey = 71; + optional string wpushChannel = 72; + optional string wpushTopicCode = 73; } message SystemNotifyRequest { @@ -272,4 +280,4 @@ service Api { rpc EnableCrons(EnableCronsRequest) returns (Response) {} rpc DisableCrons(DisableCronsRequest) returns (Response) {} rpc RunCrons(RunCronsRequest) returns (Response) {} -} \ No newline at end of file +} diff --git a/back/protos/api.ts b/back/protos/api.ts index 9359b33b..babc07b1 100644 --- a/back/protos/api.ts +++ b/back/protos/api.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.21.12 +// protoc v3.17.3 // source: back/protos/api.proto /* eslint-disable */ @@ -43,6 +43,8 @@ export enum NotificationMode { chronocat = 18, ntfy = 19, wxPusherBot = 20, + wxPusherSpt = 21, + wpush = 22, UNRECOGNIZED = -1, } @@ -111,6 +113,12 @@ export function notificationModeFromJSON(object: any): NotificationMode { case 20: case "wxPusherBot": return NotificationMode.wxPusherBot; + case 21: + case "wxPusherSpt": + return NotificationMode.wxPusherSpt; + case 22: + case "wpush": + return NotificationMode.wpush; case -1: case "UNRECOGNIZED": default: @@ -162,6 +170,10 @@ export function notificationModeToJSON(object: NotificationMode): string { return "ntfy"; case NotificationMode.wxPusherBot: return "wxPusherBot"; + case NotificationMode.wxPusherSpt: + return "wxPusherSpt"; + case NotificationMode.wpush: + return "wpush"; case NotificationMode.UNRECOGNIZED: default: return "UNRECOGNIZED"; @@ -393,6 +405,10 @@ export interface NotificationInfo { wxPusherBotAppToken?: string | undefined; wxPusherBotTopicIds?: string | undefined; wxPusherBotUids?: string | undefined; + wxPusherSptList?: string | undefined; + wpushApiKey?: string | undefined; + wpushChannel?: string | undefined; + wpushTopicCode?: string | undefined; } export interface SystemNotifyRequest { @@ -2959,6 +2975,10 @@ function createBaseNotificationInfo(): NotificationInfo { wxPusherBotAppToken: undefined, wxPusherBotTopicIds: undefined, wxPusherBotUids: undefined, + wxPusherSptList: undefined, + wpushApiKey: undefined, + wpushChannel: undefined, + wpushTopicCode: undefined, }; } @@ -3171,6 +3191,18 @@ export const NotificationInfo: MessageFns = { if (message.wxPusherBotUids !== undefined) { 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; }, @@ -3733,6 +3765,38 @@ export const NotificationInfo: MessageFns = { message.wxPusherBotUids = reader.string(); 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) { break; @@ -3825,6 +3889,10 @@ export const NotificationInfo: MessageFns = { ? globalThis.String(object.wxPusherBotTopicIds) : 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 = { if (message.wxPusherBotUids !== undefined) { 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; }, @@ -4114,6 +4194,10 @@ export const NotificationInfo: MessageFns = { message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined; message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined; message.wxPusherBotUids = object.wxPusherBotUids ?? undefined; + message.wxPusherSptList = object.wxPusherSptList ?? undefined; + message.wpushApiKey = object.wpushApiKey ?? undefined; + message.wpushChannel = object.wpushChannel ?? undefined; + message.wpushTopicCode = object.wpushTopicCode ?? undefined; return message; }, }; diff --git a/back/protos/cron.proto b/back/protos/cron.proto index e88d6b1d..539f6435 100644 --- a/back/protos/cron.proto +++ b/back/protos/cron.proto @@ -5,6 +5,7 @@ package com.ql.cron; service Cron { rpc addCron(AddCronRequest) returns (AddCronResponse); rpc delCron(DeleteCronRequest) returns (DeleteCronResponse); + rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse); } message ISchedule { string schedule = 1; } @@ -17,10 +18,16 @@ message ICron { string name = 5; } -message AddCronRequest { repeated ICron crons = 1; } +message AddCronRequest { + repeated ICron crons = 1; + bool replace = 2; +} message AddCronResponse {} message DeleteCronRequest { repeated string ids = 1; } message DeleteCronResponse {} + +// Zero restores the default limit. +message SetConcurrencyRequest { int32 concurrency = 1; } diff --git a/back/protos/cron.ts b/back/protos/cron.ts index 82d1c44c..3618725f 100644 --- a/back/protos/cron.ts +++ b/back/protos/cron.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.21.12 +// protoc v5.28.3 // source: back/protos/cron.proto /* eslint-disable */ @@ -35,6 +35,7 @@ export interface ICron { export interface AddCronRequest { crons: ICron[]; + replace: boolean; } export interface AddCronResponse { @@ -47,6 +48,11 @@ export interface DeleteCronRequest { export interface DeleteCronResponse { } +/** Zero restores the default limit. */ +export interface SetConcurrencyRequest { + concurrency: number; +} + function createBaseISchedule(): ISchedule { return { schedule: "" }; } @@ -232,7 +238,7 @@ export const ICron: MessageFns = { }; function createBaseAddCronRequest(): AddCronRequest { - return { crons: [] }; + return { crons: [], replace: false }; } export const AddCronRequest: MessageFns = { @@ -240,6 +246,9 @@ export const AddCronRequest: MessageFns = { for (const v of message.crons) { ICron.encode(v!, writer.uint32(10).fork()).join(); } + if (message.replace !== false) { + writer.uint32(16).bool(message.replace); + } return writer; }, @@ -258,6 +267,14 @@ export const AddCronRequest: MessageFns = { message.crons.push(ICron.decode(reader, reader.uint32())); continue; } + case 2: { + if (tag !== 16) { + break; + } + + message.replace = reader.bool(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -268,7 +285,10 @@ export const AddCronRequest: MessageFns = { }, fromJSON(object: any): AddCronRequest { - return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] }; + return { + crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [], + replace: isSet(object.replace) ? globalThis.Boolean(object.replace) : false, + }; }, toJSON(message: AddCronRequest): unknown { @@ -276,6 +296,9 @@ export const AddCronRequest: MessageFns = { if (message.crons?.length) { obj.crons = message.crons.map((e) => ICron.toJSON(e)); } + if (message.replace !== false) { + obj.replace = message.replace; + } return obj; }, @@ -285,6 +308,7 @@ export const AddCronRequest: MessageFns = { fromPartial, I>>(object: I): AddCronRequest { const message = createBaseAddCronRequest(); message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || []; + message.replace = object.replace ?? false; return message; }, }; @@ -433,6 +457,64 @@ export const DeleteCronResponse: MessageFns = { }, }; +function createBaseSetConcurrencyRequest(): SetConcurrencyRequest { + return { concurrency: 0 }; +} + +export const SetConcurrencyRequest: MessageFns = { + 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>>(base?: I): SetConcurrencyRequest { + return SetConcurrencyRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): SetConcurrencyRequest { + const message = createBaseSetConcurrencyRequest(); + message.concurrency = object.concurrency ?? 0; + return message; + }, +}; + export type CronService = typeof CronService; export const CronService = { addCron: { @@ -453,11 +535,21 @@ export const CronService = { responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value), }, + setConcurrency: { + path: "/com.ql.cron.Cron/setConcurrency", + requestStream: false, + responseStream: false, + requestSerialize: (value: SetConcurrencyRequest) => Buffer.from(SetConcurrencyRequest.encode(value).finish()), + requestDeserialize: (value: Buffer) => SetConcurrencyRequest.decode(value), + responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()), + responseDeserialize: (value: Buffer) => AddCronResponse.decode(value), + }, } as const; export interface CronServer extends UntypedServiceImplementation { addCron: handleUnaryCall; delCron: handleUnaryCall; + setConcurrency: handleUnaryCall; } export interface CronClient extends Client { @@ -491,6 +583,21 @@ export interface CronClient extends Client { options: Partial, callback: (error: ServiceError | null, response: DeleteCronResponse) => void, ): ClientUnaryCall; + setConcurrency( + request: SetConcurrencyRequest, + callback: (error: ServiceError | null, response: AddCronResponse) => void, + ): ClientUnaryCall; + setConcurrency( + request: SetConcurrencyRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: AddCronResponse) => void, + ): ClientUnaryCall; + setConcurrency( + request: SetConcurrencyRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: AddCronResponse) => void, + ): ClientUnaryCall; } export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as { diff --git a/back/protos/health.ts b/back/protos/health.ts index b88f2a9c..adaf9761 100644 --- a/back/protos/health.ts +++ b/back/protos/health.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.21.12 +// protoc v3.17.3 // source: back/protos/health.proto /* eslint-disable */ diff --git a/back/schedule/addCron.ts b/back/schedule/addCron.ts index a8e2e9fb..5ebdf7a5 100644 --- a/back/schedule/addCron.ts +++ b/back/schedule/addCron.ts @@ -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 nodeSchedule from 'node-schedule'; +import { createCronJob, CronJob } from '../shared/cronScheduler'; +import { isValidCronSchedule } from '../shared/cronSchedule'; import { scheduleStacks } from './data'; import { runCron } from '../shared/runCron'; import Logger from '../loaders/logger'; +import { tf } from '../shared/i18n'; + +// Validate the entire batch before replacing any existing jobs. +const isValidCronField = (cron: string): boolean => { + return isValidCronSchedule(cron); +}; const addCron = ( call: ServerUnaryCall, callback: sendUnaryData, ) => { - for (const item of call.request.crons) { - const { id, schedule, command, extra_schedules, name } = item; - if (scheduleStacks.has(id)) { - scheduleStacks.get(id)?.forEach((x) => x.cancel()); - } + // ===== 第一遍:预校验所有 cron 表达式 ===== + const validationErrors: string[] = []; - Logger.info( - '[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', - id, - name, - schedule, - command, - ); + for (const item of call.request.crons) { + const { id, schedule, extra_schedules } = item; + + if (!isValidCronField(schedule)) { + validationErrors.push( + tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule), + ); + } if (extra_schedules?.length) { extra_schedules.forEach((x) => { - Logger.info( - '[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', - id, - name, - x.schedule, - command, - ); + if (!isValidCronField(x.schedule)) { + validationErrors.push( + tf( + '任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"', + String(id), + x.schedule, + ), + ); + } }); } + } - scheduleStacks.set(id, [ - nodeSchedule.scheduleJob(id, schedule, async () => { - Logger.info(`[schedule][准备运行任务] 命令: ${command}`); - runCron(command, item); - }), - ...(extra_schedules?.length - ? extra_schedules.map((x) => - nodeSchedule.scheduleJob(id, x.schedule, async () => { - Logger.info(`[schedule][准备运行任务] 命令: ${command}`); - runCron(command, item); - }), - ) - : []), - ]); + if (validationErrors.length > 0) { + const details = validationErrors.join('\n'); + const err: any = new Error(details); + err.code = status.INVALID_ARGUMENT; + err.details = details; + callback(err, null); + return; + } + + // Prepare stopped jobs first: construction errors must preserve the old snapshot. + const prepared = new Map(); + 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); diff --git a/back/schedule/api.ts b/back/schedule/api.ts index 81747958..08f838ee 100644 --- a/back/schedule/api.ts +++ b/back/schedule/api.ts @@ -38,6 +38,7 @@ import { RunCronsRequest, } from '../protos/api'; import { NotificationInfo } from '../data/notify'; +import { Model } from 'sequelize'; Container.set('logger', LoggerInstance); @@ -247,13 +248,16 @@ export const systemNotify = async ( const normalizeCronData = (data: CronItem | null): CronItem | undefined => { if (!data) return undefined; + // create() returns a Sequelize instance; spreading it omits attribute getters. + const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data; return { - ...data, - sub_id: data.sub_id ?? undefined, - extra_schedules: data.extra_schedules ?? [], - pid: data.pid ?? undefined, - task_before: data.task_before ?? undefined, - task_after: data.task_after ?? undefined, + ...cron, + labels: cron.labels ?? [], + sub_id: cron.sub_id ?? undefined, + extra_schedules: cron.extra_schedules ?? [], + pid: cron.pid ?? undefined, + task_before: cron.task_before ?? undefined, + task_after: cron.task_after ?? undefined, }; }; diff --git a/back/schedule/client.ts b/back/schedule/client.ts index 6b086452..9089c78e 100644 --- a/back/schedule/client.ts +++ b/back/schedule/client.ts @@ -1,4 +1,4 @@ -import { credentials } from '@grpc/grpc-js'; +import { credentials, status, Metadata } from '@grpc/grpc-js'; import { AddCronRequest, AddCronResponse, @@ -7,30 +7,122 @@ import { DeleteCronResponse, } from '../protos/cron'; import config from '../config'; +import { getGrpcCerts } from '../config/grpcCerts'; + +import { HealthService } from '../protos/health'; +import { SchedulerReadiness } from '../shared/schedulerReadiness'; class Client { - private client = new CronClient( - `localhost:${config.grpcPort}`, - credentials.createInsecure(), - { 'grpc.enable_http_proxy': 0 }, - ); + readonly readiness = new SchedulerReadiness(() => this.probe()); - addCron(request: AddCronRequest['crons']): Promise { - return new Promise((resolve, reject) => { - this.client.addCron({ crons: request }, (err, res) => { - if (err) { - reject(err); - } - resolve(res); + private async waitForReady(timeoutMs: number) { + try { + await new Promise((resolve, reject) => { + this.client.waitForReady(Date.now() + timeoutMs, (err) => + err ? reject(err) : resolve() + ); }); + } catch (error) { + this.readiness.invalidate(); + throw Object.assign( + error instanceof Error ? error : new Error(String(error)), + { status: 503 } + ); + } + } + + private async probe(): Promise { + await this.waitForReady(1000); + await new Promise((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 { + 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 { + async addCron( + request: AddCronRequest['crons'], + replace = false + ): Promise { + await this.waitForReady(2000); + // Include every rule in a recovery snapshot, but bound a stuck worker. + const ruleCount = request.reduce( + (count, item) => count + 1 + (item.extra_schedules?.length || 0), + 0, + ); + const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5); return new Promise((resolve, reject) => { - this.client.delCron({ ids: request }, (err, res) => { + this.client.addCron( + { crons: request, replace }, + new Metadata(), + { deadline: Date.now() + registrationTimeoutMs }, + (err, res) => { + if (err) { + if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) { + // A timed-out write may already have reached the scheduler. + // Reconcile its state from the DB instead of replaying the RPC. + this.readiness.invalidate(); + Object.assign(err, { status: 503 }); + } + return reject(err); + } + resolve(res); + } + ); + }); + } + + async delCron(request: DeleteCronRequest['ids']): Promise { + await this.waitForReady(2000); + return new Promise((resolve, reject) => { + this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => { if (err) { - reject(err); + if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) { + this.readiness.invalidate(); + Object.assign(err, { status: 503 }); + } + return reject(err); } resolve(res); }); diff --git a/back/schedule/data.ts b/back/schedule/data.ts index 3d911529..94969b81 100644 --- a/back/schedule/data.ts +++ b/back/schedule/data.ts @@ -1,6 +1,6 @@ -import nodeSchedule from 'node-schedule'; +import { CronJob } from '../shared/cronScheduler'; import { ToadScheduler } from 'toad-scheduler'; -export const scheduleStacks = new Map(); +export const scheduleStacks = new Map(); export const intervalSchedule = new ToadScheduler(); diff --git a/back/schedule/delCron.ts b/back/schedule/delCron.ts index 6e35c080..1f5f86ae 100644 --- a/back/schedule/delCron.ts +++ b/back/schedule/delCron.ts @@ -13,7 +13,20 @@ const delCron = ( '[schedule][取消定时任务] 任务ID: %s', 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); } } diff --git a/back/schedule/health.ts b/back/schedule/health.ts index dc90f7e6..2184f775 100644 --- a/back/schedule/health.ts +++ b/back/schedule/health.ts @@ -1,28 +1,107 @@ import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; +import fs from 'fs/promises'; +import path from 'path'; +import { request } from 'undici'; import { HealthCheckRequest, HealthCheckResponse } from '../protos/health'; import config from '../config'; -import { promiseExec } from '../config/util'; + +function formatError(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + + const detailedError = error as Error & { + cause?: unknown; + code?: string; + errors?: unknown[]; + }; + const details = [error.message, detailedError.code]; + if (Array.isArray(detailedError.errors)) { + details.push(...detailedError.errors.map(formatError)); + } else if (detailedError.cause) { + details.push(formatError(detailedError.cause)); + } + + return [...new Set(details.filter(Boolean))].join(': ') || error.name; +} + +async function getRecentSystemLog(lineLimit = 300): Promise { + try { + const entries = await fs.readdir(config.systemLogPath); + const latestLog = entries + .filter((entry) => entry.endsWith('.log')) + .sort() + .at(-1); + if (!latestLog) { + return `No system log found in ${config.systemLogPath}`; + } + const content = await fs.readFile( + path.join(config.systemLogPath, latestLog), + 'utf8', + ); + return content.split('\n').slice(-lineLimit).join('\n').trim(); + } catch (error) { + return `Unable to read system log from ${config.systemLogPath}: ${ + error instanceof Error ? error.message : String(error) + }`; + } +} const check = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { switch (call.request.service) { - case 'cron': - const res = await promiseExec( - `curl -s --noproxy '*' http://localhost:${config.port}/api/system`, - ); - - if (res.includes('200')) { - return callback(null, { status: 1 }); + // Local scheduler liveness only: never call HTTP from this probe. + case 'scheduler': + return callback(null, { status: 1 }); + case 'cron': { + const healthUrl = `http://localhost:${config.port}${ + config.baseUrl || '' + }/api/health`; + let failure = ''; + try { + const response = await request(healthUrl, { + method: 'GET', + headersTimeout: 5000, + bodyTimeout: 5000, + }); + const body = (await response.body.json()) as { + code?: number; + data?: { status?: string }; + }; + if ( + response.statusCode >= 200 && + response.statusCode < 300 && + body.code === 200 && + body.data?.status === 'ok' + ) { + return callback(null, { status: 1 }); + } + failure = `HTTP ${response.statusCode}: ${JSON.stringify(body)}`; + } catch (error) { + failure = formatError(error); } - const qinglongErrLog = await promiseExec( - `tail -n 300 ~/.pm2/logs/qinglong-error.log`, - ); + const systemLog = await getRecentSystemLog(); + const containerHint = + process.env.QL_CONTAINER === 'true' + ? 'PM2 file logging is disabled in containers. Check `docker logs ` for early startup errors.' + : 'Check `pm2 logs qinglong --lines 300` for early startup errors.'; return callback( - new Error(`${qinglongErrLog || ''}\n${res}`.trim()), + new Error( + [ + `HTTP health check failed: ${healthUrl}`, + failure, + containerHint, + `Recent system log (${config.systemLogPath}):`, + systemLog, + ] + .filter(Boolean) + .join('\n'), + ), ); + } default: return callback(null, { status: 1 }); diff --git a/back/schedule/setConcurrency.ts b/back/schedule/setConcurrency.ts new file mode 100644 index 00000000..20fb1876 --- /dev/null +++ b/back/schedule/setConcurrency.ts @@ -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, + callback: sendUnaryData, +) { + 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); + } +} diff --git a/back/services/config.ts b/back/services/config.ts index a12a9419..6410291b 100644 --- a/back/services/config.ts +++ b/back/services/config.ts @@ -1,9 +1,10 @@ -import { Service, Inject } from 'typedi'; -import path, { join } from 'path'; +import { Service } from 'typedi'; import config from '../config'; import { getFileContentByName } from '../config/util'; +import { t } from '../shared/i18n'; import { Response } from 'express'; import { request } from 'undici'; +import { resolveFileAccess } from '../shared/fileAccess'; @Service() export default class ConfigService { @@ -11,19 +12,16 @@ export default class ConfigService { public async getFile(filePath: string, res: Response) { let content = ''; - const avaliablePath = [config.rootPath, config.configPath].map((x) => - path.resolve(x, filePath), + if (!filePath || filePath.startsWith('data/scripts/')) { + return res.send({ code: 403, message: t('文件无法访问') }); + } + const resolved = resolveFileAccess( + config.configPath, + [filePath], + config.blackFileList, ); - - if ( - config.blackFileList.includes(filePath) || - avaliablePath.every( - (x) => - !x.startsWith(config.scriptPath) && !x.startsWith(config.configPath), - ) || - !filePath - ) { - return res.send({ code: 403, message: '文件无法访问' }); + if (!resolved) { + return res.send({ code: 403, message: t('文件无法访问') }); } if (filePath.startsWith('sample/')) { @@ -31,10 +29,8 @@ export default class ConfigService { `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, ); content = await res.body.text(); - } else if (filePath.startsWith('data/scripts/')) { - content = await getFileContentByName(join(config.rootPath, filePath)); } else { - content = await getFileContentByName(join(config.configPath, filePath)); + content = await getFileContentByName(resolved); } res.send({ code: 200, data: content }); diff --git a/back/services/cron.ts b/back/services/cron.ts index 798ef37a..2adc6edd 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -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 winston from 'winston'; import config from '../config'; +import { resolveLogPath } from '../shared/logPath'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; +import { + RunningInstanceModel, + InstanceStatus, +} from '../data/runningInstance'; import { exec, execSync } from 'child_process'; import fs from 'fs/promises'; -import CronExpressionParser from 'cron-parser'; import { getFileContentByName, fileExist, killTask, - killAllTasks, getUniqPath, safeJSONParse, isDemoEnv, @@ -24,8 +33,13 @@ import dayjs from 'dayjs'; import pickBy from 'lodash/pickBy'; import omit from 'lodash/omit'; import { writeFileWithLock } from '../shared/utils'; +import { t } from '../shared/i18n'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; +import { observeChildProcess, asError } from '../shared/childProcess'; +import { isEmpty } from 'lodash'; +import { LogReadOptions, readLogChunk } from '../shared/logReader'; +import { resolveFileAccess } from '../shared/fileAccess'; @Service() export default class CronService { @@ -33,10 +47,21 @@ export default class CronService { private isNodeCron(cron: Crontab) { const { schedule, extra_schedules } = cron; - if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) { - return true; - } - return false; + const fields = schedule?.trim().split(/\s+/) || []; + // System crontab only receives portable numeric five-field expressions. + // Extended syntax, macros and legacy shorthand use the Node scheduler. + return ( + fields.length !== 5 || + /[^\d\s*,/\-]/.test(schedule || '') || + Boolean(extra_schedules?.length) || + // BusyBox treats N/step as a single value and doesn't support Sunday=7. + fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) || + fields[4].includes('7') || + // BusyBox steps DOM from zero; its full-range day/week wildcard + // handling also differs from the legacy Node parser's OR semantics. + fields[2].includes('/') || + (fields[2] !== '*' && fields[4] !== '*') + ); } private get schedulerMode(): 'system' | 'node' { @@ -90,29 +115,45 @@ export default class CronService { } public async create(payload: Crontab): Promise { - const tab = new Crontab(payload); - tab.saved = false; - tab.log_name = await this.getLogName(tab); - const doc = await this.insert(tab); + return withSchedulerMutation(async () => { + const tab = new Crontab(payload); + tab.saved = false; + tab.log_name = await this.getLogName(tab); + const doc = await this.insert(tab); - if (isDemoEnv()) { + 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; - } - - if (this.shouldUseCronClient(doc)) { - 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 { @@ -120,32 +161,68 @@ export default class CronService { } public async update(payload: Partial): Promise { - const doc = await this.getDb({ id: payload.id }); - const tab = new Crontab({ ...doc, ...payload }); - tab.saved = false; - tab.log_name = await this.getLogName(tab); - const newDoc = await this.updateDb(tab); + return withSchedulerMutation(async () => { + const doc = await this.getDb({ id: payload.id }); + const tab = new Crontab({ ...doc, ...payload }); + tab.saved = false; + tab.log_name = await this.getLogName(tab); + if (doc.isDisabled === 1 || isDemoEnv()) { + return await this.updateDb(tab); + } - if (doc.isDisabled === 1 || isDemoEnv()) { + // Keep the DB snapshot unchanged if deletion has an uncertain outcome. + // Recovery uses that snapshot after this mutation releases its lock. + await cronClient.delCron([String(doc.id)]); + const newDoc = await this.updateDb(tab); + + if (this.shouldUseCronClient(newDoc)) { + try { + await cronClient.addCron([ + { + name: doc.name || '', + id: String(newDoc.id), + schedule: newDoc.schedule!, + command: this.makeCommand(newDoc), + extra_schedules: newDoc.extra_schedules || [], + }, + ]); + } catch (error: any) { + // gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册 + await CrontabModel.update(omit(doc, ['queued_token']), { + where: { id: doc.id }, + }); + if (this.shouldUseCronClient(doc)) { + try { + await cronClient.addCron([ + { + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + }, + ]); + } catch (_recoveryError: any) { + this.logger.warn( + '[crontab] Failed to restore old cron job in scheduler after rollback:', + _recoveryError?.message || _recoveryError, + ); + } + } + this.logger.error( + '[crontab] Failed to register updated cron job in scheduler, update rolled back:', + error?.message || error, + ); + throw schedulerRegistrationError( + `${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`, + error, + ); + } + } + + await this.setCrontab(); return newDoc; - } - - await cronClient.delCron([String(newDoc.id)]); - - if (this.shouldUseCronClient(newDoc)) { - await cronClient.addCron([ - { - name: doc.name || '', - id: String(newDoc.id), - schedule: newDoc.schedule!, - command: this.makeCommand(newDoc), - extra_schedules: newDoc.extra_schedules || [], - }, - ]); - } - - await this.setCrontab(); - return newDoc; + }); } public async updateDb(payload: Crontab): Promise { @@ -160,6 +237,7 @@ export default class CronService { log_path, last_running_time = 0, last_execution_time = 0, + exit_code, }: { ids: number[]; status: CrontabStatus; @@ -167,7 +245,11 @@ export default class CronService { log_path: string; last_running_time: number; last_execution_time: number; + exit_code?: number; }) { + if (log_path && log_path !== '/dev/null') { + resolveLogPath(config.logPath, log_path); + } let options: any = { status, pid, @@ -189,6 +271,40 @@ export default class CronService { if (status === CrontabStatus.idle && log_path !== cron.log_path) { 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( { ...pickBy(options, (v) => v === 0 || !!v) }, { where: { id } }, @@ -197,9 +313,11 @@ export default class CronService { } public async remove(ids: number[]) { - await CrontabModel.destroy({ where: { id: ids } }); - await cronClient.delCron(ids.map(String)); - await this.setCrontab(); + return withSchedulerMutation(async () => { + await cronClient.delCron(ids.map(String)); + await CrontabModel.destroy({ where: { id: ids } }); + await this.setCrontab(); + }); } public async pin(ids: number[]) { @@ -367,7 +485,7 @@ export default class CronService { } private formatFilterQuery(query: any, filterQuery: any) { - if (filterQuery) { + if (!isEmpty(filterQuery)) { if (!query[Op.and]) { query[Op.and] = []; } @@ -470,175 +588,385 @@ export default class CronService { } public async run(ids: number[]) { + const queuedToken = randomUUID(); await CrontabModel.update( - { status: CrontabStatus.queued }, + { status: CrontabStatus.queued, queued_token: queuedToken }, { where: { id: ids } }, ); ids.forEach((id) => { - this.runSingle(id); + this.runSingle(id, queuedToken); }); } public async stop(ids: number[]) { const docs = await CrontabModel.findAll({ where: { id: ids } }); + // Cancel the queued snapshot first, so a late spawn cannot claim it. for (const doc of docs) { - // Kill all running instances of this task - try { - if (doc.pid) { - await killTask(doc.pid); - } - const command = doc.command.replace(/\s+/g, ' ').trim(); - await killAllTasks(command); - this.logger.info( - `[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, + if (doc.status === CrontabStatus.queued) { + const [cancelled] = await CrontabModel.update( + { status: CrontabStatus.idle, pid: null, queued_token: null } as any, + { + where: { + id: doc.id, + status: CrontabStatus.queued, + [Op.and]: [ + where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }), + where(colFn('queued_token'), { + [Op.eq]: doc.queued_token ?? null, + }), + ], + }, + } ); + // A concurrent claim may have won; capture its PID before signalling. + if (!cancelled) await doc.reload(); + } + } + const stoppingInstances = await RunningInstanceModel.findAll({ + attributes: ['id', 'pid', 'cron_id'], + where: { cron_id: ids, status: InstanceStatus.running }, + }); + const targets = new Set( + [ + ...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(); + for (const pid of targets) { + try { + await killTask(pid, true); + stopped.add(pid); } catch (error) { this.logger.error( - `[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`, + '[panel][停止任务失败] PID: %s, 错误: %s', + pid, + asError(error).message ); } } - - await CrontabModel.update( - { status: CrontabStatus.idle, pid: undefined }, - { where: { id: ids } }, - ); + const stoppedIds = stoppingInstances + .filter((instance) => instance.pid && stopped.has(instance.pid)) + .map((instance) => instance.id!); + if (stoppedIds.length) { + await RunningInstanceModel.update( + { status: InstanceStatus.stopped, finished_at: dayjs().unix() }, + { where: { id: stoppedIds } } + ); + } + for (const doc of docs) { + if ( + doc.status !== CrontabStatus.running || + (doc.pid && !stopped.has(doc.pid)) + ) + continue; + const remaining = await RunningInstanceModel.count({ + where: { cron_id: doc.id, status: InstanceStatus.running }, + }); + if (remaining) continue; + await CrontabModel.update( + { status: CrontabStatus.idle, pid: null, queued_token: null } as any, + { + where: { + id: doc.id, + status: CrontabStatus.running, + [Op.and]: [ + where(colFn('queued_token'), { [Op.eq]: doc.queued_token ?? null }), + where(colFn('pid'), { [Op.eq]: doc.pid ?? null }), + where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }), + ], + }, + } + ); + } } - private async runSingle(cronId: number): Promise { - return taskLimit.manualRunWithCronLimit(() => { - return new Promise(async (resolve: any) => { - const cron = await this.getDb({ id: cronId }); - const params = { - name: cron.name, - command: cron.command, - schedule: cron.schedule, - extra_schedules: cron.extra_schedules, - }; - if (cron.status !== CrontabStatus.queued) { - resolve(params); - return; - } - - this.logger.info( - `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, + public async stopInstance(instanceId: number) { + const instance = await RunningInstanceModel.findOne({ + where: { id: instanceId, status: InstanceStatus.running }, + }); + if (!instance) { + return { code: 400, message: t('实例不存在或已停止') }; + } + if (instance.pid) { + try { + await killTask(instance.pid); + } catch (error) { + this.logger.error( + `[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`, ); + } + } + 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 { + 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 = log_name === '/dev/null' || !log_name ? await getUniqPath(command, `${id}`) : log_name; const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); - const logDirPath = path.resolve(config.logPath, `${uniqPath}`); - await fs.mkdir(logDirPath, { recursive: true }); - const logPath = `${uniqPath}/${logTime}.log`; - const absolutePath = path.resolve(config.logPath, `${logPath}`); + logPath = `${uniqPath}/${logTime}.log`; + absolutePath = resolveFileAccess(config.logPath, [logPath]); + if (!absolutePath) + throw new Error('Log path is outside the log directory'); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + const outputPath = absolutePath; const cp = spawn( `real_log_path=${logPath} no_delay=true ${this.makeCommand( cron, - true, + true )}`, - { shell: '/bin/bash' }, + { shell: '/bin/bash' } ); - - await CrontabModel.update( - { status: CrontabStatus.running, pid: cp.pid, log_path: logPath }, - { where: { id } }, - ); - cp.stdout.on('data', async (data) => { - await logStreamManager.write(absolutePath, data.toString()); + // Install observers before the first await: very short children may already exit. + const { completed } = observeChildProcess(cp, { + onStart: async () => { + try { + const [count] = await CrontabModel.update( + { + status: CrontabStatus.running, + pid: cp.pid, + log_path: logPath, + }, + { + where: { + id, + status: CrontabStatus.queued, + [Op.and]: [ + where(colFn('queued_token'), { [Op.eq]: queuedToken }), + where(colFn('log_path'), { [Op.eq]: queuedLogPath }), + ], + }, + } + ); + if (count !== 1) + throw new Error( + 'Task was stopped or superseded before startup' + ); + claimed = true; + } catch (error) { + if (cp.pid) await killTask(cp.pid, true); + throw error; + } + }, + onStdout: (message) => logStreamManager.write(outputPath, message), + onStderr: (message) => logStreamManager.write(outputPath, message), }); - cp.stderr.on('data', async (data) => { - this.logger.info( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', - command, - data.toString(), - ); - await logStreamManager.write(absolutePath, data.toString()); - }); - cp.on('error', async (err) => { + const result = await completed; + if (result.error) { this.logger.error( - '[panel][创建任务失败] 命令: %s, 错误信息: %j', - command, - err, + '[panel][执行任务失败] 任务ID: %s, 错误: %s', + id, + result.error.message ); - await logStreamManager.write(absolutePath, JSON.stringify(err)); - }); - - cp.on('exit', async (code) => { - this.logger.info( - '[panel][执行任务结束] 参数: %s, 退出码: %j', - JSON.stringify(params), - code, + } + this.logger.info( + '[panel][执行任务结束] 任务ID: %s, 退出码: %j', + id, + result.code + ); + return { ...cron, pid: cp.pid, ...result } as any; + } catch (error) { + this.logger.error( + '[panel][创建任务失败] 任务ID: %s, 错误: %s', + cronId, + asError(error).message + ); + } finally { + try { + if (absolutePath) await logStreamManager.closeStream(absolutePath); + } catch (error) { + this.logger.error( + '[panel][关闭任务日志失败] %s', + asError(error).message ); - await logStreamManager.closeStream(absolutePath); + } + try { + // Do not overwrite a newer run's state or its script-reported exit code. await CrontabModel.update( - { status: CrontabStatus.idle, pid: undefined }, - { where: { id } }, + { status: CrontabStatus.idle, pid: null, queued_token: null } as any, + { + where: { + id: cronId, + [Op.and]: where(colFn('queued_token'), { [Op.eq]: queuedToken }), + [Op.or]: [ + ...(queuedLogPath !== undefined && !claimed + ? [ + { + status: CrontabStatus.queued, + [Op.and]: where(colFn('log_path'), { + [Op.eq]: queuedLogPath, + }), + }, + ] + : []), + ...(claimed && logPath + ? [{ log_path: logPath, status: CrontabStatus.running }] + : []), + ], + }, + } ); - resolve({ ...params, pid: cp.pid, code }); - }); - }); + } catch (error) { + this.logger.error( + '[panel][清理任务状态失败] %s', + asError(error).message + ); + } + } }); } public async disabled(ids: number[]) { - await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); - await cronClient.delCron(ids.map(String)); - await this.setCrontab(); + return withSchedulerMutation(async () => { + await cronClient.delCron(ids.map(String)); + await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); + await this.setCrontab(); + }); } public async enabled(ids: number[]) { - await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); - const docs = await CrontabModel.findAll({ where: { id: ids } }); - const crons = docs - .filter((x) => this.shouldUseCronClient(x)) - .map((doc) => ({ - name: doc.name || '', - id: String(doc.id), - schedule: doc.schedule!, - command: this.makeCommand(doc), - extra_schedules: doc.extra_schedules || [], - })); + return withSchedulerMutation(async () => { + await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); + const docs = await CrontabModel.findAll({ where: { id: ids } }); + const crons = docs + .filter((x) => this.shouldUseCronClient(x)) + .map((doc) => ({ + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + })); - if (isDemoEnv()) { - return; - } + if (isDemoEnv()) { + return; + } - await cronClient.addCron(crons); - 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 }); if (!doc) { - return ''; + return { + content: '', + status: 'empty', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; } 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)); 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 { - return typeof doc.status === 'number' && + const status = + typeof doc.status === 'number' && [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) { const doc = await this.getDb({ id }); - if (!doc || !doc.log_path) { + if (!doc || !doc.log_path || doc.log_path === '/dev/null') { return []; } const relativeDir = path.dirname(`${doc.log_path}`); - const dir = path.resolve(config.logPath, relativeDir); + const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path)); const dirExist = await fileExist(dir); if (dirExist) { let files = await fs.readdir(dir); @@ -742,7 +1070,7 @@ export default class CronService { if ( command && schedule && - CronExpressionParser.parse(schedule).hasNext() + isValidCronSchedule(schedule) ) { const name = namePrefix + '_' + index; @@ -761,28 +1089,56 @@ export default class CronService { }); } - public async autosave_crontab() { - const tabs = await this.crontabs(); - const regularCrons = tabs.data - .filter( - (x) => - x.isDisabled !== 1 && - this.shouldUseCronClient(x), - ) - .map((doc) => ({ - name: doc.name || '', - id: String(doc.id), - schedule: doc.schedule!, - command: this.makeCommand(doc), - extra_schedules: doc.extra_schedules || [], - })); + public async autosave_crontab(requireScheduler = false) { + return withSchedulerMutation(async () => { + const tabs = await this.crontabs(); + // A bad persisted rule must not block startup or all other schedules. + // Keep the DB row editable; omit it from both runtime scheduler snapshots. + tabs.data = tabs.data.filter((doc) => { + if (doc.isDisabled === 1) return true; + const invalidSchedules = getInvalidCronSchedules(doc); + if (invalidSchedules.length === 0) return true; + this.logger.warn( + '[crontab] Skipping task with invalid schedule (id=%s, name=%s): %s', + doc.id, + doc.name || '', + JSON.stringify(invalidSchedules), + ); + return false; + }); + const regularCrons = tabs.data + .filter( + (x) => + x.isDisabled !== 1 && + this.shouldUseCronClient(x), + ) + .map((doc) => ({ + name: doc.name || '', + id: String(doc.id), + schedule: doc.schedule!, + command: this.makeCommand(doc), + extra_schedules: doc.extra_schedules || [], + })); - if (isDemoEnv()) { - await writeFileWithLock(config.crontabFile, ''); - return; - } - await cronClient.addCron(regularCrons); - this.setCrontab(tabs); + if (isDemoEnv()) { + await writeFileWithLock(config.crontabFile, ''); + return; + } + + // 先同步 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() { @@ -791,13 +1147,7 @@ export default class CronService { (x) => !x.isDisabled && this.isBootSchedule(x.schedule), ); if (bootTasks.length > 0) { - await CrontabModel.update( - { status: CrontabStatus.queued }, - { where: { id: bootTasks.map((t) => t.id!) } }, - ); - for (const task of bootTasks) { - this.runSingle(task.id!); - } + await this.run(bootTasks.map((task) => task.id!)); } } } diff --git a/back/services/dependence.ts b/back/services/dependence.ts index c00422b3..c33ff43f 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -16,6 +16,7 @@ import { getPid, killTask, promiseExecSuccess, + concurrentRun, getInstallCommand, getUninstallCommand, getGetCommand, @@ -24,6 +25,7 @@ import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; import { detectOS } from '../config/util'; import { LINUX_DEPENDENCE_COMMAND } from '../config/const'; +import { t, tf } from '../shared/i18n'; @Service() export default class DependenceService { @@ -109,6 +111,11 @@ export default class DependenceService { query: any = {}, ): Promise { let condition = query; + const dependenceType = + type && DependenceTypes[type] !== undefined + ? DependenceTypes[type] + : undefined; + await this.refreshInstalledStatuses(dependenceType); if (type && DependenceTypes[type] !== undefined) { condition.type = DependenceTypes[type]; } @@ -131,6 +138,69 @@ export default class DependenceService { } } + private async refreshInstalledStatuses(type?: DependenceTypes) { + const cacheDependenceTypes = [ + DependenceTypes.nodejs, + DependenceTypes.python3, + ]; + if (type !== undefined && !cacheDependenceTypes.includes(type)) { + return; + } + + const docs = await DependenceModel.findAll({ + where: { + status: DependenceStatus.installed, + type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type, + }, + }); + const checks = await concurrentRun( + docs.map((doc) => async () => { + return (await this.isDependenceInstalled(doc)) ? undefined : doc.id; + }), + 5, + ); + const missingIds = (checks || []).filter( + (id): id is number => id !== undefined, + ); + + if (missingIds.length) { + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { where: { id: missingIds } }, + ); + } + } + + private async isDependenceInstalled(dependency: Dependence) { + let depName = dependency.name.trim(); + const depVersionStr = versionDependenceCommandTypes[dependency.type]; + let depVersion = ''; + if (depName.includes(depVersionStr)) { + const symbolRegx = new RegExp( + `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, + ); + const [, parsedName, parsedVersion] = depName.match(symbolRegx) || []; + if (parsedVersion && parsedName) { + depName = parsedName; + depVersion = parsedVersion; + } + } + + const depInfo = ( + await promiseExecSuccess(getGetCommand(dependency.type, depName)) + ) + .replace(/\s{2,}/, ' ') + .replace(/\s+$/, ''); + const nameMatches = + (dependency.type === DependenceTypes.nodejs && + depInfo.split(' ')?.[0] === depName) || + dependency.type === DependenceTypes.python3; + + return Boolean( + depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)), + ); + } + public installDependenceOneByOne( docs: Dependence[], isInstall: boolean = true, @@ -232,7 +302,7 @@ export default class DependenceService { } const depIds = [dependency.id!]; let depName = dependency.name.trim(); - const actionText = isInstall ? '安装' : '删除'; + const actionText = isInstall ? t('安装') : t('删除'); const socketMessageType = isInstall ? 'installDependence' : 'uninstallDependence'; @@ -249,15 +319,20 @@ export default class DependenceService { { where: { id: depIds } }, ); const startTime = dayjs(); - const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( - 'YYYY-MM-DD HH:mm:ss', - )}\n\n当前系统不支持\n\n依赖${actionText}失败,结束时间 ${startTime.format( - 'YYYY-MM-DD HH:mm:ss', - )},耗时 ${startTime.diff(startTime, 'second')} 秒`; + 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); @@ -280,13 +355,17 @@ export default class DependenceService { } const startTime = dayjs(); - const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( - 'YYYY-MM-DD HH:mm:ss', - )}\n\n`; + const message = tf( + '开始%s依赖 %s,开始时间 %s\n\n', + actionText, + depName, + startTime.format('YYYY-MM-DD HH:mm:ss'), + ); this.sockService.sendMessage({ type: socketMessageType, message, references: depIds, + status, }); this.updateLog(depIds, message); @@ -322,13 +401,19 @@ export default class DependenceService { (!depVersion || depInfo.includes(depVersion)) ) { const endTime = dayjs(); - const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format( - 'YYYY-MM-DD HH:mm:ss', - )},耗时 ${endTime.diff(startTime, 'second')} 秒`; + const _message = tf( + '检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒', + depName, + depInfo, + actionText, + endTime.format('YYYY-MM-DD HH:mm:ss'), + String(endTime.diff(startTime, 'second')), + ); this.sockService.sendMessage({ type: socketMessageType, message: _message, references: depIds, + status: DependenceStatus.installed, }); this.updateLog(depIds, _message); await DependenceModel.update( @@ -353,6 +438,7 @@ export default class DependenceService { type: socketMessageType, message: data.toString(), references: depIds, + status, }); this.updateLog(depIds, data.toString()); }); @@ -362,6 +448,7 @@ export default class DependenceService { type: socketMessageType, message: data.toString(), references: depIds, + status, }); this.updateLog(depIds, data.toString()); }); @@ -371,6 +458,7 @@ export default class DependenceService { type: socketMessageType, message: JSON.stringify(err), references: depIds, + status, }); this.updateLog(depIds, JSON.stringify(err)); }); @@ -378,28 +466,27 @@ export default class DependenceService { cp.on('exit', async (code) => { const endTime = dayjs(); const isSucceed = code === 0; - const resultText = isSucceed ? '成功' : '失败'; + const resultText = isSucceed ? t('成功') : t('失败'); - const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format( - 'YYYY-MM-DD HH:mm:ss', - )},耗时 ${endTime.diff(startTime, 'second')} 秒`; + const message = + '\n' + + 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({ type: socketMessageType, message, references: depIds, + status: exitStatus, }); 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 _docIds = docs .filter((x) => x.status !== DependenceStatus.cancelled) @@ -407,7 +494,7 @@ export default class DependenceService { if (_docIds.length > 0) { await DependenceModel.update( - { status }, + { status: exitStatus }, { where: { id: _docIds } }, ); } diff --git a/back/services/env.ts b/back/services/env.ts index 5efd148b..351fa594 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -262,10 +262,9 @@ export default class EnvService { .map((x) => x.value) .join('&') .replace(/\\/g, '\\\\')}`; - js_env_string += `process.env.${key}=\`${_env_value.replace( - /\`/g, - '\\`', - )}\`;\n`; + js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify( + group.map((x) => x.value).join('&'), + )};\n`; py_env_string += `os.environ['${key}']='''${_env_value.replace( /\'/g, "\\'", diff --git a/back/services/grpc.ts b/back/services/grpc.ts index 5edd9d2c..c1c46c2b 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -4,6 +4,7 @@ import { HealthService } from '../protos/health'; import { ApiService } from '../protos/api'; import { addCron } from '../schedule/addCron'; import { delCron } from '../schedule/delCron'; +import { setConcurrency } from '../schedule/setConcurrency'; import { check } from '../schedule/health'; import * as Api from '../schedule/api'; import Logger from '../loaders/logger'; @@ -11,6 +12,7 @@ import { promisify } from 'util'; import config from '../config'; import { metricsService } from './metrics'; import { Service } from 'typedi'; +import { initGrpcCerts } from '../config/grpcCerts'; @Service() export class GrpcServerService { @@ -26,13 +28,20 @@ export class GrpcServerService { async initialize() { try { this.server.addService(HealthService, { check }); - this.server.addService(CronService, { addCron, delCron }); + this.server.addService(CronService, { addCron, delCron, setConcurrency }); this.server.addService(ApiService, Api); + const tlsConfig = await initGrpcCerts(); + const credentials = ServerCredentials.createSsl( + Buffer.from(tlsConfig.caCert), + [{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }], + true, + ); + const grpcPort = config.grpcPort; const hostsToTry = [ config.bindHostGrpc, - ...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : []) + ...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : []) ]; const bindAsync = promisify(this.server.bindAsync).bind(this.server); @@ -41,8 +50,8 @@ export class GrpcServerService { for (const host of hostsToTry) { try { const address = this.formatGrpcAddress(host, grpcPort); - await bindAsync(address, ServerCredentials.createInsecure()); - Logger.debug(`✌️ gRPC service started successfully on ${address}`); + await bindAsync(address, credentials); + Logger.debug(`[boot] gRPC service started successfully on ${address}`); metricsService.record('grpc_service_start', 1, { port: grpcPort.toString(), host diff --git a/back/services/health.ts b/back/services/health.ts index d971768d..3c86f5fa 100644 --- a/back/services/health.ts +++ b/back/services/health.ts @@ -1,6 +1,6 @@ import { Service } from 'typedi'; import Logger from '../loaders/logger'; -import { GrpcServerService } from './grpc'; +import cronClient from '../schedule/client'; import { HttpServerService } from './http'; interface HealthStatus { @@ -23,7 +23,6 @@ export class HealthService { private startTime = Date.now(); constructor( - private grpcServerService: GrpcServerService, private httpServerService: HttpServerService, ) {} @@ -56,8 +55,7 @@ export class HealthService { } try { - const grpcServer = this.grpcServerService.getServer(); - if (!grpcServer) { + if (!(await cronClient.readiness.check())) { status.services.grpc = false; status.status = 'error'; } diff --git a/back/services/http.ts b/back/services/http.ts index bbce8ff7..44ee30ee 100644 --- a/back/services/http.ts +++ b/back/services/http.ts @@ -12,7 +12,7 @@ export class HttpServerService { async initialize(expressApp: express.Application, port: number) { const hostsToTry = [ config.bindHost, - ...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : []) + ...(config.bindHost === '::' ? ['0.0.0.0'] : []) ]; let lastError: Error | null = null; @@ -20,7 +20,7 @@ export class HttpServerService { for (const host of hostsToTry) { try { const server = await this.tryListen(expressApp, port, host); - Logger.debug(`✌️ HTTP service started successfully on ${host}:${port}`); + Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`); metricsService.record('http_service_start', 1, { port: port.toString(), host @@ -39,7 +39,13 @@ export class HttpServerService { private async tryListen(expressApp: express.Application, port: number, host: string): Promise { return new Promise((resolve, reject) => { - const server = expressApp.listen(port, host, () => { + // There is one HTTP worker; accepting here avoids primary IPC handoff + // for every connection. Restore shared listening for custom clusters. + const server = expressApp.listen({ + port, + host, + exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true', + }, () => { resolve(server); }); diff --git a/back/services/log.ts b/back/services/log.ts index ab3f16e4..57987a36 100644 --- a/back/services/log.ts +++ b/back/services/log.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import { resolveFileAccess } from '../shared/fileAccess'; import { Inject, Service } from 'typedi'; import winston from 'winston'; import config from '../config'; @@ -8,7 +8,6 @@ export default class LogService { constructor(@Inject('logger') private logger: winston.Logger) {} public checkFilePath(filePath: string, fileName: string) { - const finalPath = path.resolve(config.logPath, filePath, fileName); - return finalPath.startsWith(config.logPath) ? finalPath : ''; + return resolveFileAccess(config.logPath, [filePath || '', fileName]); } } diff --git a/back/services/metrics.ts b/back/services/metrics.ts index 7ed44664..08a859a4 100644 --- a/back/services/metrics.ts +++ b/back/services/metrics.ts @@ -8,16 +8,19 @@ interface Metric { tags?: Record; } -class MetricsService { - private metrics: Metric[] = []; +export const MAX_METRIC_SAMPLES = 1000; +const METRIC_RETENTION_MS = 60 * 60 * 1000; + +export class MetricsService { + private metrics: Array = new Array(MAX_METRIC_SAMPLES); + private metricCount = 0; + private nextMetricIndex = 0; private static instance: MetricsService; private constructor() { // 定期清理旧数据 - setInterval(() => { - const oneHourAgo = Date.now() - 3600000; - this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo); - }, 60000); + const cleanupTimer = setInterval(() => this.removeExpiredMetrics(), 60000); + cleanupTimer.unref(); } static getInstance(): MetricsService { @@ -28,12 +31,14 @@ class MetricsService { } record(name: string, value: number, tags?: Record) { - this.metrics.push({ + this.metrics[this.nextMetricIndex] = { name, value, timestamp: Date.now(), tags, - }); + }; + this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES; + this.metricCount = Math.min(this.metricCount + 1, MAX_METRIC_SAMPLES); } measure(name: string, fn: () => void, tags?: Record) { @@ -46,7 +51,11 @@ class MetricsService { } } - async measureAsync(name: string, fn: () => Promise, tags?: Record) { + async measureAsync( + name: string, + fn: () => Promise, + tags?: Record, + ) { const start = performance.now(); try { await fn(); @@ -57,28 +66,61 @@ class MetricsService { } getMetrics(name?: string, tags?: Record) { - let filtered = this.metrics; - + this.removeExpiredMetrics(); + let filtered = this.getMetricSnapshot(); + if (name) { - filtered = filtered.filter(m => m.name === name); + filtered = filtered.filter((m) => m.name === name); } - + if (tags) { - filtered = filtered.filter(m => { + filtered = filtered.filter((m) => { if (!m.tags) return false; - return Object.entries(tags).every(([key, value]) => m.tags![key] === value); + return Object.entries(tags).every( + ([key, value]) => m.tags![key] === value, + ); }); } + const values = filtered.map((metric) => metric.value); return { count: filtered.length, - average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length, - min: Math.min(...filtered.map(m => m.value)), - max: Math.max(...filtered.map(m => m.value)), + average: values.length + ? values.reduce((acc, value) => acc + value, 0) / values.length + : 0, + min: values.length ? Math.min(...values) : 0, + max: values.length ? Math.max(...values) : 0, metrics: filtered, }; } + private getMetricSnapshot(): Metric[] { + if (this.metricCount < MAX_METRIC_SAMPLES) { + return this.metrics.slice(0, this.metricCount) as Metric[]; + } + return [ + ...this.metrics.slice(this.nextMetricIndex), + ...this.metrics.slice(0, this.nextMetricIndex), + ] as Metric[]; + } + + private removeExpiredMetrics() { + const oldestTimestamp = Date.now() - METRIC_RETENTION_MS; + const retained = this.getMetricSnapshot().filter( + (metric) => metric.timestamp > oldestTimestamp, + ); + if (retained.length === this.metricCount) return; + + this.metrics = new Array(MAX_METRIC_SAMPLES); + this.metricCount = 0; + this.nextMetricIndex = 0; + for (const metric of retained) { + this.metrics[this.nextMetricIndex] = metric; + this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES; + this.metricCount++; + } + } + report() { const report = { timestamp: Date.now(), @@ -89,4 +131,4 @@ class MetricsService { } } -export const metricsService = MetricsService.getInstance(); \ No newline at end of file +export const metricsService = MetricsService.getInstance(); diff --git a/back/services/notify.ts b/back/services/notify.ts index 39a0e47b..e92269a5 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -3,6 +3,7 @@ import nodemailer from 'nodemailer'; import { Inject, Service } from 'typedi'; import { parseBody, parseHeaders } from '../config/util'; import { NotificationInfo } from '../data/notify'; +import { t } from '../shared/i18n'; import UserService from './user'; import { httpClient } from '../config/http'; import { ProxyAgent } from 'undici'; @@ -34,7 +35,9 @@ export default class NotificationService { ['chronocat', this.chronocat], ['ntfy', this.ntfy], ['wxPusherBot', this.wxPusherBot], + ['wxPusherSpt', this.wxPusherSpt], ['openiLink', this.openiLink], + ['wpush', this.wpush], ]); private title = ''; @@ -364,7 +367,7 @@ export default class NotificationService { { title: `${this.title}`, thumb_media_id, - author: `智能助手`, + author: t('智能助手'), content_source_url: ``, content: `${this.content.replace(/\n/g, '
')}`, digest: `${this.content}`, @@ -380,7 +383,7 @@ export default class NotificationService { title: `${this.title}`, description: `${this.content}`, url: 'https://github.com/whyour/qinglong', - btntxt: '更多', + btntxt: t('更多'), }, }; break; @@ -431,7 +434,7 @@ export default class NotificationService { roomName: `${aibotkName}`, message: { type: 1, - content: `【青龙快讯】\n\n${this.title}\n${this.content}`, + content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`, }, }; break; @@ -442,7 +445,7 @@ export default class NotificationService { name: `${aibotkName}`, message: { type: 1, - content: `【青龙快讯】\n\n${this.title}\n${this.content}`, + content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`, }, }; break; @@ -612,7 +615,7 @@ export default class NotificationService { }); const info = await transporter.sendMail({ - from: `"青龙快讯" <${emailUser}>`, + from: `"${t('青龙快讯')}" <${emailUser}>`, to: recipients, subject: `${this.title}`, html: `${this.content.replace(/\n/g, '
')}`, @@ -727,7 +730,7 @@ export default class NotificationService { // topic_ids 和 uids 至少要有一个 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`; @@ -755,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: `

${this.title}


${this.content}
`, + 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() { const { chronocatURL, chronocatQQ, chronocatToken } = this.params; try { @@ -823,7 +872,7 @@ export default class NotificationService { } = this.params; if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) { - throw new Error('Url 或者 Body 中必须包含 $title'); + throw new Error(t('Url 或者 Body 中必须包含 $title')); } const headers = parseHeaders(webhookHeaders); @@ -899,4 +948,31 @@ export default class NotificationService { throw new Error(error.response ? error.response.body : error); } } + private async wpush() { + const { wpushApiKey, wpushChannel, wpushTopicCode } = this.params; + const url = 'https://api.wpush.cn/api/v1/send'; + const json: Record = { + 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); + } + } + } diff --git a/back/services/open.ts b/back/services/open.ts index 59dcc5bc..e39486a6 100644 --- a/back/services/open.ts +++ b/back/services/open.ts @@ -5,27 +5,78 @@ import { App, AppModel } from '../data/open'; import { v4 as uuidV4 } from 'uuid'; import sequelize, { Op } from 'sequelize'; import { shareStore } from '../shared/store'; +import { t } from '../shared/i18n'; +import { lock } from 'proper-lockfile'; +import path from 'path'; +import os from 'os'; +import { writeFile, rename, rm, stat, chmod } from 'fs/promises'; +import config from '../config'; @Service() export default class OpenService { constructor(@Inject('logger') private logger: winston.Logger) {} + // Every database mutation and full authentication-cache publication shares + // this cross-process lock. Lock a separate marker, not token.json itself. + private async withAppsLock(operation: () => Promise): Promise { + 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 { + 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 { + await this.withAppsLock(async () => { + await shareStore.updateApps(await this.find({})); + }); + } + public async findApps(): Promise { const docs = await this.find({}); return docs; } public async create(payload: App): Promise { - const tab = { ...payload }; - tab.client_id = createRandomString(12, 12); - tab.client_secret = createRandomString(24, 24); - const doc = await this.insert(tab); - const apps = await this.find({}); - await shareStore.updateApps(apps); - return { ...doc, tokens: [] }; + return this.withAppsLock(async () => { + const tab = { ...payload }; + tab.client_id = createRandomString(12, 12); + tab.client_secret = createRandomString(24, 24); + const doc = await this.insert(tab); + const apps = await this.find({}); + await shareStore.updateApps(apps); + return { ...doc, tokens: [] }; + }); } - public async insert(payload: App): Promise { + private async insert(payload: App): Promise { const doc = await AppModel.create(payload, { returning: true }); return doc.get({ plain: true }); } @@ -40,10 +91,12 @@ export default class OpenService { } private async updateDb(payload: Partial): Promise { - await AppModel.update(payload, { where: { id: payload.id } }); - const apps = await this.find({}); - await shareStore.updateApps(apps); - return apps?.find((x) => x.id === payload.id) as App; + return this.withAppsLock(async () => { + await AppModel.update(payload, { where: { id: payload.id } }); + const apps = await this.find({}); + await shareStore.updateApps(apps); + return apps?.find((x) => x.id === payload.id) as App; + }); } public async getDb(query: Record): Promise { @@ -55,9 +108,11 @@ export default class OpenService { } public async remove(ids: number[]) { - await AppModel.destroy({ where: { id: ids } }); - const apps = await this.find({}); - await shareStore.updateApps(apps); + return this.withAppsLock(async () => { + await AppModel.destroy({ where: { id: ids } }); + const apps = await this.find({}); + await shareStore.updateApps(apps); + }); } public async resetSecret(id: number): Promise { @@ -111,7 +166,15 @@ export default class OpenService { return docs.map((x) => x.get({ plain: true })); } - public async authToken({ + public async authToken(credentials: { + client_id: string; + client_secret: string; + }): Promise { + return this.withAppsLock(() => this.issueToken(credentials)); + } + + // Caller holds withAppsLock through database update and cache publication. + private async issueToken({ client_id, client_secret, }: { @@ -151,31 +214,51 @@ export default class OpenService { }, }; } 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; expiration: number; }> { - const apps = await shareStore.getApps(); - const systemApp = apps?.find((x) => x.name === 'system'); - if (!systemApp) { - throw new Error('system app not found'); - } - const now = Math.round(Date.now() / 1000); - const currentToken = systemApp.tokens?.find((x) => x.expiration > now); - if (currentToken) { - return currentToken; - } - const { data } = await this.authToken({ - client_id: systemApp.client_id, - client_secret: systemApp.client_secret, + return this.withAppsLock(async () => { + // Read after acquiring the lock; a concurrent issuer may have renewed. + const systemApp = await this.getDb({ name: 'system' }); + const now = Math.round(Date.now() / 1000); + let appToken = systemApp.tokens + ?.filter((token) => token.expiration > now) + .sort((left, right) => right.expiration - left.expiration)[0]; + if (renew || !appToken) { + const { data } = await this.issueToken({ + client_id: systemApp.client_id, + client_secret: systemApp.client_secret, + }); + appToken = { value: data.token, expiration: data.expiration }; + } else { + // Recover a prior database commit whose cache publication failed. + await shareStore.updateApps(await this.find({})); + } + const tokenFile = path.join(config.configPath, 'token.json'); + const temporaryFile = `${tokenFile}.${process.pid}.tmp`; + const mode = await stat(tokenFile).then( + (file) => file.mode & 0o777, + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return 0o600; + throw error; + }, + ); + try { + await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, { + mode, + }); + await chmod(temporaryFile, mode); + // Shell readers see a complete old or new document, never a partial one. + await rename(temporaryFile, tokenFile); + } finally { + await rm(temporaryFile, { force: true }); + } + return appToken; }); - return { - ...data, - value: data.token, - }; } } diff --git a/back/services/retention.ts b/back/services/retention.ts new file mode 100644 index 00000000..851a4a3c --- /dev/null +++ b/back/services/retention.ts @@ -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) { + 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), + ), + }; + } +} diff --git a/back/services/schedule.ts b/back/services/schedule.ts index df938674..d61b56b9 100644 --- a/back/services/schedule.ts +++ b/back/services/schedule.ts @@ -1,6 +1,6 @@ import { Service, Inject } from 'typedi'; import winston from 'winston'; -import nodeSchedule from 'node-schedule'; +import { createCronJob, CronJob } from '../shared/cronScheduler'; import { ChildProcessWithoutNullStreams } from 'child_process'; import { ToadScheduler, @@ -11,6 +11,11 @@ import { import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; import { spawn } from 'cross-spawn'; +import { + observeChildProcess, + asError, + ProcessResult, +} from '../shared/childProcess'; export interface ScheduleTaskType { id?: number; @@ -27,7 +32,7 @@ export interface TaskCallbacks { startTime: dayjs.Dayjs, ) => Promise; onEnd?: ( - cp: ChildProcessWithoutNullStreams, + cp: ChildProcessWithoutNullStreams | undefined, endTime: dayjs.Dayjs, diff: number, ) => Promise; @@ -37,7 +42,7 @@ export interface TaskCallbacks { @Service() export default class ScheduleService { - private scheduleStacks = new Map(); + private scheduleStacks = new Map(); private intervalSchedule = new ToadScheduler(); @@ -63,73 +68,85 @@ export default class ScheduleService { ) { const { runOrigin, ...others } = params; - return taskLimit[this.taskLimitMap[runOrigin]](others, () => { - return new Promise(async (resolve, reject) => { - this.logger.info( - `[panel][开始执行任务] 参数: ${JSON.stringify({ - ...others, - command, - })}`, - ); - - try { - const startTime = dayjs(); - await callbacks.onBefore?.(startTime); - - const cp = spawn(command, { shell: '/bin/bash' }); - - callbacks.onStart?.(cp, startTime); - completionTime === 'start' && resolve(cp.pid); - - cp.stdout.on('data', async (data) => { - await callbacks.onLog?.(data.toString()); - }); - - cp.stderr.on('data', async (data) => { - this.logger.info( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', - command, - data.toString(), - ); - await callbacks.onError?.(data.toString()); - }); - - cp.on('error', async (err) => { - this.logger.error( - '[panel][创建任务失败] 命令: %s, 错误信息: %j', - command, - err, - ); - await callbacks.onError?.(JSON.stringify(err)); - }); - - cp.on('exit', async (code) => { - this.logger.info( - '[panel][执行任务结束] 参数: %s, 退出码: %j', - JSON.stringify({ - ...others, - command, - }), - code, - ); - const endTime = dayjs(); - await callbacks.onEnd?.( - cp, - endTime, - endTime.diff(startTime, 'seconds'), - ); - resolve({ ...others, pid: cp.pid, code }); - }); - } catch (error) { - this.logger.error( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', - command, - error, - ); - await callbacks.onError?.(JSON.stringify(error)); - } - }); + let resolveStart!: (pid: number | undefined) => void; + let rejectStart!: (error: Error) => void; + const startResult = new Promise((resolve, reject) => { + resolveStart = resolve; + rejectStart = reject; }); + // Most scheduled callers only observe completion (or intentionally detach). + void startResult.catch(() => {}); + const completion = taskLimit[this.taskLimitMap[runOrigin]]( + others, + async () => { + const startTime = dayjs(); + let cp: ChildProcessWithoutNullStreams | undefined; + let result: ProcessResult = { code: null, signal: null }; + try { + this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id); + await callbacks.onBefore?.(startTime); + cp = spawn(command, { shell: '/bin/bash' }); + const child = cp; + const observed = observeChildProcess(child, { + onStart: async () => { + await callbacks.onStart?.(child, startTime); + }, + onStdout: callbacks.onLog, + onStderr: callbacks.onError, + }); + observed.started.then(resolveStart, rejectStart); + result = await observed.completed; + } catch (error) { + result.error = asError(error); + rejectStart(result.error); + } + + if (result.error) { + this.logger.error( + '[panel][执行任务失败] 任务ID: %s, 错误: %s', + others.id, + result.error.message, + ); + try { + await callbacks.onError?.(result.error.message); + } catch (error) { + this.logger.error( + '[panel][任务错误回调失败] %s', + asError(error).message, + ); + } + } + // Cleanup also runs after setup/spawn failure, and only after both pipes drain. + const endTime = dayjs(); + try { + await callbacks.onEnd?.( + cp, + endTime, + endTime.diff(startTime, 'seconds'), + ); + } catch (error) { + result.error ??= asError(error); + this.logger.error( + '[panel][任务结束回调失败] %s', + asError(error).message, + ); + } + this.logger.info( + '[panel][执行任务结束] 任务ID: %s, 退出码: %j', + others.id, + result.code, + ); + return { ...others, pid: cp?.pid, ...result }; + }, + ).catch((error) => { + // Queue/setup failures must not become unhandled rejections in detached callers. + rejectStart(asError(error)); + this.logger.error('[panel][任务队列失败] %s', asError(error).message); + return { ...others, code: null, signal: null, error: asError(error) }; + }); + + // Returning a PID must not release the execution slot while the process runs. + return completionTime === 'start' ? startResult : completion; } async createCronTask( @@ -146,18 +163,33 @@ export default class ScheduleService { command, ); - this.scheduleStacks.set( - _id, - nodeSchedule.scheduleJob(_id, schedule, async () => { - this.runTask(command, callbacks, { - name, + if (schedule) { + try { + const job = createCronJob( schedule, - command, - id: _id, - runOrigin, - }); - }), - ); + () => + this.runTask(command, callbacks, { + name, + schedule, + command, + id: _id, + runOrigin, + }), + { name: `${_id}: ${name || ''}`, logger: this.logger, start: false }, + ); + this.scheduleStacks.get(_id)?.cancel(); + this.scheduleStacks.set(_id, job); + job.start(); + } catch (error) { + // A persisted invalid subscription must not become an unhandled startup rejection. + this.logger.warn( + '[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s', + _id, + schedule, + asError(error).message, + ); + } + } if (runImmediately) { this.runTask(command, callbacks, { diff --git a/back/services/script.ts b/back/services/script.ts index 52921c6c..5caa6116 100644 --- a/back/services/script.ts +++ b/back/services/script.ts @@ -1,3 +1,4 @@ +import { resolveFileAccess } from '../shared/fileAccess'; import { Service, Inject } from 'typedi'; import winston from 'winston'; import path, { join } from 'path'; @@ -57,16 +58,15 @@ export default class ScriptService { taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-')); pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number; } - try { - await killTask(pid); - } catch (error) {} + if (pid) { + await killTask(pid, true); + } return { code: 200 }; } public checkFilePath(filePath: string, fileName: string) { - const finalPath = path.resolve(config.scriptPath, filePath, fileName); - return finalPath.startsWith(config.scriptPath) ? finalPath : ''; + return resolveFileAccess(config.scriptPath, [filePath || '', fileName]); } public async getFile(filePath: string, fileName: string) { diff --git a/back/services/scriptHistory.ts b/back/services/scriptHistory.ts new file mode 100644 index 00000000..936ffffb --- /dev/null +++ b/back/services/scriptHistory.ts @@ -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')); + } +} diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index 99465109..f140df92 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -8,10 +8,22 @@ import { formatUrl } from '../config/subscription'; import config from '../config'; import { fileExist, rmPath } from '../config/util'; import { writeFileWithLock } from '../shared/utils'; +import { + getSubscriptionSshAlias, + resolveSubscriptionPath, +} from '../shared/subscriptionPath'; @Service() export default class SshKeyService { - private homedir = os.homedir(); + // OpenSSH uses the effective user's passwd entry, not the HOME override. + private get homedir(): string { + try { + return os.userInfo().homedir; + } catch { + // Custom container UIDs may have no passwd entry. Keep startup working. + return os.homedir(); + } + } private sshPath = config.sshdPath; private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; @@ -21,6 +33,10 @@ export default class SshKeyService { } private async initSshConfigFile() { + await fs.mkdir(path.dirname(this.sshConfigFilePath), { + recursive: true, + mode: 0o700, + }); let config = ''; const _exist = await fileExist(this.sshConfigFilePath); if (_exist) { @@ -42,13 +58,11 @@ export default class SshKeyService { key: string, ): Promise { try { - await writeFileWithLock( - path.join(this.sshPath, alias), - `${key}${os.EOL}`, - { - mode: '400', - }, - ); + const filePath = resolveSubscriptionPath(this.sshPath, alias); + try { + await rmPath(filePath); + } catch { } + await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' }); } catch (error) { this.logger.error('生成私钥文件失败', error); } @@ -56,7 +70,7 @@ export default class SshKeyService { private async removePrivateKeyFile(alias: string): Promise { try { - const filePath = path.join(this.sshPath, alias); + const filePath = resolveSubscriptionPath(this.sshPath, alias); await rmPath(filePath); } catch (error) { this.logger.error('删除私钥文件失败', error); @@ -68,18 +82,19 @@ export default class SshKeyService { host: string, proxy?: string, ) { + if (!/^[a-zA-Z0-9.-]+$/.test(host) || + (proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) { + throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 }); + } if (host === 'github.com') { host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; } const proxyStr = proxy ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` : ''; - const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( - this.sshPath, - alias, - )}\n StrictHostKeyChecking no\n${proxyStr}`; + const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`; await writeFileWithLock( - `${path.join(this.sshPath, `${alias}.config`)}`, + `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`, config, { encoding: 'utf8', @@ -90,7 +105,7 @@ export default class SshKeyService { private async removeSshConfig(alias: string) { try { - const filePath = path.join(this.sshPath, `${alias}.config`); + const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config'); await rmPath(filePath); } catch (error) { this.logger.error(`删除ssh配置文件${alias}失败`, error); @@ -103,6 +118,7 @@ export default class SshKeyService { host: string, proxy?: string, ): Promise { + alias = getSubscriptionSshAlias(alias); await this.generatePrivateKeyFile(alias, key); await this.generateSingleSshConfig(alias, host, proxy); } @@ -112,6 +128,7 @@ export default class SshKeyService { host: string, proxy?: string, ): Promise { + alias = getSubscriptionSshAlias(alias); await this.removePrivateKeyFile(alias); await this.removeSshConfig(alias); } @@ -119,15 +136,19 @@ export default class SshKeyService { public async setSshConfig(docs: Subscription[]) { for (const doc of docs) { if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { - const { alias, proxy } = doc; - const { host } = formatUrl(doc); - await this.removePrivateKeyFile(alias); - await this.removeSshConfig(alias); - await this.generatePrivateKeyFile( - alias, - (doc.pull_option as any).private_key, - ); - await this.generateSingleSshConfig(alias, host, proxy); + try { + const alias = getSubscriptionSshAlias(doc.alias); + const { host } = formatUrl(doc); + await this.removePrivateKeyFile(alias); + await this.removeSshConfig(alias); + await this.generatePrivateKeyFile( + alias, + (doc.pull_option as any).private_key, + ); + await this.generateSingleSshConfig(alias, host, doc.proxy); + } catch (error) { + this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error); + } } } } @@ -146,12 +167,9 @@ export default class SshKeyService { private async generateGlobalSshConfig(alias: string) { // Create a config that matches all hosts, making this key globally available - const config = `Host *\n IdentityFile ${path.join( - this.sshPath, - alias, - )}\n StrictHostKeyChecking no\n`; + const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`; await writeFileWithLock( - `${path.join(this.sshPath, `${alias}.config`)}`, + `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`, config, { encoding: 'utf8', diff --git a/back/services/subscription.ts b/back/services/subscription.ts index 1a8638f8..225b5b24 100644 --- a/back/services/subscription.ts +++ b/back/services/subscription.ts @@ -20,18 +20,28 @@ import { } from '../config/util'; import fs from 'fs/promises'; import { FindOptions, Op } from 'sequelize'; -import path, { join } from 'path'; +import path from 'path'; import ScheduleService, { TaskCallbacks } from './schedule'; import { SimpleIntervalSchedule } from 'toad-scheduler'; import SockService from './sock'; +import { t, tf } from '../shared/i18n'; import SshKeyService from './sshKey'; import dayjs from 'dayjs'; import { LOG_END_SYMBOL } from '../config/const'; import { formatCommand, formatUrl } from '../config/subscription'; import { CrontabModel } from '../data/cron'; +import { assertSubscriptionAlias } from '../shared/subscriptionPath'; +import { resolveLogPath } from '../shared/logPath'; +import { + getSubscriptionStorageCandidates, + isSubscriptionStorageName, + resolveSubscriptionStoragePath, +} from '../shared/subscriptionStorage'; +import { withSubscriptionMutation } from '../shared/subscriptionMutationLock'; import CrontabService from './cron'; import taskLimit from '../shared/pLimit'; import { logStreamManager } from '../shared/logStreamManager'; +import { LogReadOptions, readLogChunk } from '../shared/logReader'; @Service() export default class SubscriptionService { @@ -86,9 +96,11 @@ export default class SubscriptionService { needCreate = true, runImmediately = false, ) { - const { url } = formatUrl(doc); - - doc.command = formatCommand(doc, url as string); + // Cancelling by ID must also work for invalid historical subscriptions. + if (needCreate) { + const { url } = formatUrl(doc); + doc.command = formatCommand(doc, url as string); + } if (doc.schedule_type === 'crontab') { this.scheduleService.cancelCronTask(doc as any); @@ -130,14 +142,14 @@ export default class SubscriptionService { ); const absolutePath = await handleLogPath( 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 let beforeStr = ''; try { 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); } } catch (error: any) { @@ -157,44 +169,54 @@ export default class SubscriptionService { ); }, onEnd: async (cp, endTime, diff) => { - const sub = await this.getDb({ id: doc.id }); - const absolutePath = await handleLogPath(sub.log_path as string); - - // 执行 sub_after - let afterStr = ''; + let absolutePath: string | undefined; try { - if (sub.sub_after) { - await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`); - afterStr = await promiseExec(sub.sub_after); + const sub = await this.getDb({ id: doc.id }); + absolutePath = await handleLogPath(sub.log_path as string); + + // 执行 sub_after + let afterStr = ''; + try { + if (sub.sub_after) { + await logStreamManager.write( + absolutePath, + `\n\n## ${t('执行after命令...')}\n\n`, + ); + afterStr = await promiseExec(sub.sub_after); + } + } catch (error: any) { + afterStr = + (error.stderr && error.stderr.toString()) || JSON.stringify(error); + } + if (afterStr) { + await logStreamManager.write(absolutePath, `${afterStr}\n`); + } + + await logStreamManager.write( + absolutePath, + '\n' + + tf( + '## 执行结束... %s 耗时 %s 秒', + endTime.format('YYYY-MM-DD HH:mm:ss'), + String(diff), + ) + + LOG_END_SYMBOL, + ); + } finally { + try { + if (absolutePath) await logStreamManager.closeStream(absolutePath); + } finally { + await SubscriptionModel.update( + { status: SubscriptionStatus.idle, pid: null } as any, + { where: { id: doc.id } }, + ); + this.sockService.sendMessage({ + type: 'runSubscriptionEnd', + message: t('订阅执行完成'), + references: [doc.id as number], + }); } - } catch (error: any) { - afterStr = - (error.stderr && error.stderr.toString()) || JSON.stringify(error); } - if (afterStr) { - await logStreamManager.write(absolutePath, `${afterStr}\n`); - } - - await logStreamManager.write( - absolutePath, - `\n## 执行结束... ${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) => { const sub = await this.getDb({ id: doc.id }); @@ -210,11 +232,14 @@ export default class SubscriptionService { } public async create(payload: Subscription): Promise { - const tab = new Subscription(payload); - const doc = await this.insert(tab); - await this.handleTask(doc.get({ plain: true })); - await this.setSshConfig(); - return doc; + assertSubscriptionAlias(payload.alias); + return withSubscriptionMutation(async () => { + const tab = new Subscription(payload); + const doc = await this.insert(tab); + await this.handleTask(doc.get({ plain: true })); + await this.setSshConfig(); + return doc; + }); } public async insert(payload: Subscription): Promise { @@ -222,12 +247,27 @@ export default class SubscriptionService { } public async update(payload: Subscription): Promise { - const doc = await this.getDb({ id: payload.id }); - const tab = new Subscription({ ...doc, ...payload }); - const newDoc = await this.updateDb(tab); - await this.handleTask(newDoc, !newDoc.is_disabled); - await this.setSshConfig(); - return newDoc; + assertSubscriptionAlias(payload.alias); + return withSubscriptionMutation(async () => { + const doc = await this.getDb({ id: payload.id }); + const tab = new Subscription({ ...doc, ...payload }); + const newDoc = await this.updateDb(tab); + await this.handleTask(newDoc, !newDoc.is_disabled); + await this.removeSshConfigForSubscription(doc); + await this.setSshConfig(); + return newDoc; + }); + } + + private async removeSshConfigForSubscription(doc: Subscription) { + if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return; + // Invalid historical rows must remain deletable without deriving unsafe paths. + try { + assertSubscriptionAlias(doc.alias); + } catch { + return; + } + await this.sshKeyService.removeSSHKey(doc.alias, ''); } public async updateDb(payload: Subscription): Promise { @@ -250,6 +290,7 @@ export default class SubscriptionService { last_running_time: number; last_execution_time: number; }) { + if (log_path) resolveLogPath(config.logPath, log_path); const options: any = { status, pid, @@ -267,25 +308,71 @@ export default class SubscriptionService { } public async remove(ids: number[], query: { force?: boolean }) { - const docs = await SubscriptionModel.findAll({ where: { id: ids } }); - for (const doc of docs) { - await this.handleTask(doc.get({ plain: true }), false); - } - await SubscriptionModel.destroy({ where: { id: ids } }); - await this.setSshConfig(); - - if (query?.force === true) { - const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); - if (crons?.length) { - await this.crontabService.remove(crons.map((x) => x.id!)); + return withSubscriptionMutation(async () => { + const docs = await SubscriptionModel.findAll({ where: { id: ids } }); + const storagePaths: string[] = []; + const retainedPaths: string[] = []; + const collectPaths = ( + doc: Subscription, + targets: string[], + deleting = false, + ) => { + for (const names of getSubscriptionStorageCandidates(doc)) { + // A URL accepted by one engine can produce an invalid name in another. + // Validate each candidate independently, retaining the safe candidate. + if (!Object.values(names).every(isSubscriptionStorageName)) continue; + const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve; + const scriptPath = resolve(config.scriptPath, names.script); + if (scriptPath) targets.push(scriptPath); + if (names.repo) { + const repoPath = resolve(config.repoPath, names.repo); + if (repoPath) targets.push(repoPath); + } + if (names.raw) { + const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw); + if (rawPath) targets.push(rawPath); + } + } + }; + // Validate all deletion targets before changing rows or removing any files. + if (query?.force === true) { + for (const doc of docs) { + assertSubscriptionAlias(doc.alias); + resolveSubscriptionStoragePath(config.scriptPath, doc.alias); + resolveSubscriptionStoragePath(config.repoPath, doc.alias); + collectPaths(doc, storagePaths, true); + } + const remaining = await SubscriptionModel.findAll({ where: {} }); + for (const doc of remaining) { + if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths); + } } for (const doc of docs) { - const filePath = join(config.scriptPath, doc.alias); - const repoPath = join(config.repoPath, doc.alias); - await rmPath(filePath); - await rmPath(repoPath); + await this.handleTask(doc.get({ plain: true }), false); } - } + await SubscriptionModel.destroy({ where: { id: ids } }); + for (const doc of docs) { + await this.removeSshConfigForSubscription(doc); + } + await this.setSshConfig(); + + if (query?.force === true) { + const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); + if (crons?.length) { + await this.crontabService.remove(crons.map((x) => x.id!)); + } + for (const storagePath of new Set(storagePaths)) { + // rm is recursive: an ancestor must also be retained if a surviving + // subscription uses a nested branch, and vice versa. + const shared = retainedPaths.some((retained) => + retained === storagePath || + retained.startsWith(`${storagePath}${path.sep}`) || + storagePath.startsWith(`${retained}${path.sep}`), + ); + if (!shared) await rmPath(storagePath); + } + } + }); } public async getDb( @@ -310,20 +397,22 @@ export default class SubscriptionService { public async stop(ids: number[]) { const docs = await SubscriptionModel.findAll({ where: { id: ids } }); + let failure: unknown; for (const doc of docs) { - if (doc.pid) { - try { - await killTask(doc.pid); - } catch (error) { - this.logger.error(error); + try { + if (doc.pid) { + await killTask(doc.pid, true); } + await SubscriptionModel.update( + { status: SubscriptionStatus.idle, pid: null } as any, + { where: { id: doc.id, pid: doc.pid ?? null } }, + ); + } catch (error) { + this.logger.error(error); + failure ??= error; } } - - await SubscriptionModel.update( - { status: SubscriptionStatus.idle, pid: undefined }, - { where: { id: ids } }, - ); + if (failure) throw failure; } private async runSingle(subscriptionId: number) { @@ -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 }); if (!doc || !doc.log_path) { - return ''; + return { + content: '', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }; } const absolutePath = await handleLogPath(doc.log_path as string); - return await getFileContentByName(absolutePath); + return await readLogChunk(absolutePath, options); } public async logs(id: number) { @@ -379,7 +474,7 @@ export default class SubscriptionService { if (doc.log_path) { const relativeDir = path.dirname(`${doc.log_path}`); - const dir = path.resolve(config.logPath, relativeDir); + const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path)); const _exist = await fileExist(dir); if (_exist) { let files = await fs.readdir(dir); diff --git a/back/services/system.ts b/back/services/system.ts index 97b500f1..7567a3ee 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -1,8 +1,10 @@ import { spawn } from 'cross-spawn'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { shellQuote } from '../shared/shellQuote'; import { Response } from 'express'; import fs from 'fs'; import { Agent, request } from 'undici'; -import sum from 'lodash/sum'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; @@ -32,11 +34,14 @@ import { SystemModelInfo, } from '../data/system'; import taskLimit from '../shared/pLimit'; +import cronClient from '../schedule/client'; +import { withSchedulerMutation } from '../shared/schedulerMutationLock'; import NotificationService from './notify'; import ScheduleService, { TaskCallbacks } from './schedule'; import SockService from './sock'; import os from 'os'; import dayjs from 'dayjs'; +import { t, setLang } from '../shared/i18n'; import { updateLinuxMirrorFile } from '../config/util'; @Service() @@ -77,8 +82,8 @@ export default class SystemService { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, - '青龙', - `【蛟龙】测试通知 https://t.me/jiao_long`, + t('青龙'), + t('【蛟龙】测试通知 https://t.me/jiao_long'), ); if (isSuccess) { const result = await this.updateAuthDb({ @@ -87,7 +92,7 @@ export default class SystemService { }); return { code: 200, data: { ...result, code } }; } else { - return { code: 400, message: '通知发送失败,请检查参数' }; + return { code: 400, message: t('通知发送失败,请检查参数') }; } } @@ -99,7 +104,7 @@ export default class SystemService { }); const cron = { id: result.id as number, - name: '删除日志', + name: t('删除日志'), command: `ql rmlog ${info.logRemoveFrequency}`, runOrigin: 'system' as const, }; @@ -119,15 +124,29 @@ export default class SystemService { } public async updateCronConcurrency(info: SystemModelInfo) { - const oDoc = await this.getSystemConfig(); - await this.updateAuthDb({ - ...oDoc, - info: { ...oDoc.info, ...info }, - }); - if (info.cronConcurrency) { - await taskLimit.setCustomLimit(info.cronConcurrency); + const limit = info.cronConcurrency ?? 0; + if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) { + throw Object.assign(new Error(t('参数错误')), { status: 400 }); } - return { code: 200, data: info }; + return withSchedulerMutation(async () => { + const oDoc = await this.getSystemConfig(); + await this.updateAuthDb({ + ...oDoc, + info: { ...oDoc.info, ...info, cronConcurrency: limit }, + }); + await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4)); + await cronClient.setConcurrency(limit); + return { code: 200, data: info }; + }); + } + + public async restoreCronConcurrency() { + await withSchedulerMutation(async () => { + const doc = await this.getSystemConfig(); + const limit = doc.info?.cronConcurrency ?? 0; + await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4)); + await cronClient.setConcurrency(limit); + }); } public async updateDependenceProxy(info: SystemModelInfo) { @@ -139,7 +158,7 @@ export default class SystemService { if (info.dependenceProxy) { await fs.promises.writeFile( config.dependenceProxyFile, - `export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`, + `export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`, ); } else { await fs.promises.rm(config.dependenceProxyFile); @@ -155,7 +174,7 @@ export default class SystemService { }); let cmd = 'pnpm config delete registry'; if (info.nodeMirror) { - cmd = `pnpm config set registry ${info.nodeMirror}`; + cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`; } let command = `cd && ${cmd}`; const docs = await DependenceModel.findAll({ @@ -178,6 +197,7 @@ export default class SystemService { this.sockService.sendMessage({ type: 'updateNodeMirror', message: 'update node mirror end', + status: 'completed', }); }, onError: async (message: string) => { @@ -203,7 +223,7 @@ export default class SystemService { }); let cmd = 'pip config unset global.index-url'; if (info.pythonMirror) { - cmd = `pip3 config set global.index-url ${info.pythonMirror}`; + cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`; } await promiseExec(cmd); return { code: 200, data: info }; @@ -219,7 +239,6 @@ export default class SystemService { return; } const command = await updateLinuxMirrorFile(info.linuxMirror || ''); - let hasError = false; this.scheduleService.runTask( command, { @@ -227,13 +246,15 @@ export default class SystemService { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, - onEnd: async () => { + onEnd: async (cp) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'update linux mirror end', + status: 'completed', }); onEnd?.(); - if (!hasError) { + // Tools such as sudo and apt may write warnings to stderr on success. + if (cp?.exitCode === 0 && cp.signalCode === null) { await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, @@ -241,7 +262,6 @@ export default class SystemService { } }, onError: async (message: string) => { - hasError = true; this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, onLog: async (message: string) => { @@ -339,6 +359,15 @@ export default class SystemService { this.sockService.sendMessage({ type: 'updateSystemVersion', message: JSON.stringify(err), + status: 'failed', + }); + }); + + cp.on('exit', (code) => { + this.sockService.sendMessage({ + type: 'updateSystemVersion', + message: '', + status: code === 0 ? 'success' : 'failed', }); }); @@ -346,7 +375,10 @@ export default class SystemService { } public async reloadSystem(target?: 'system' | 'data') { - const cmd = `real_time=true ql reload ${target || ''}`; + if (target && !['system', 'data'].includes(target)) { + return { code: 400, message: t('参数错误') }; + } + const cmd = `real_time=true ql reload ${shellQuote(target || '')}`; const cp = spawn(cmd, { shell: '/bin/bash', detached: true, @@ -381,9 +413,9 @@ export default class SystemService { notificationInfo, ); if (isSuccess) { - return { code: 200, message: '通知发送成功' }; + return { code: 200, message: t('通知发送成功') }; } else { - return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; + return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') }; } } @@ -401,7 +433,7 @@ export default class SystemService { public async stop({ command, pid }: { command: string; pid: number }) { if (!pid && !command) { - return { code: 400, message: '参数错误' }; + return { code: 400, message: t('参数错误') }; } if (pid) { @@ -417,7 +449,7 @@ export default class SystemService { await killTask(_pid); return { code: 200 }; } else { - return { code: 400, message: '任务未找到' }; + return { code: 400, message: t('任务未找到') }; } } @@ -427,11 +459,20 @@ export default class SystemService { if (type && type.length) { dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); } - const dataPaths = dataDirs.map((dir) => `data/${dir}`); - await promiseExec( - `cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile - } ${dataPaths.join(' ')}`, - ); + const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps', + 'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']); + if (dataDirs.some((dir) => !allowed.has(dir))) { + return res.status(400).send({ code: 400, message: t('参数错误') }); + } + if (dataDirs.includes('scripts')) dataDirs.push('script-history'); + // History is created lazily; old/fresh installations must still export. + if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) { + dataDirs = dataDirs.filter((dir) => dir !== 'script-history'); + } + const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`); + await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], { + cwd: path.dirname(config.dataPath), + }); res.download(config.dataTgzFile); } catch (error: any) { return res.send({ code: 400, message: error.message }); @@ -455,6 +496,7 @@ export default class SystemService { query: { startTime?: string; endTime?: string; + limit?: number; }, ) { const startTime = dayjs(query.startTime || undefined) @@ -469,8 +511,30 @@ export default class SystemService { .filter((x) => x.title.endsWith('.log')) .filter((x) => x.createTime >= startTime && x.createTime <= endTime); + const limit = Math.min( + Math.max(Number(query.limit) || 1024 * 1024, 1), + 1024 * 1024, + ); + const total = logs.reduce((size, log) => size + (log.size || 0), 0); + let remaining = limit; + const selected: Array< + (typeof logs)[number] & { start: number; length: number } + > = []; + for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) { + const log = logs[index]; + const size = log.size || 0; + const length = Math.min(size, remaining); + if (length > 0) { + selected.unshift({ ...log, start: size - length, length }); + remaining -= length; + } + } + + const contentLength = selected.reduce((size, log) => size + log.length, 0); res.set({ - 'Content-Length': sum(logs.map((x) => x.size)), + 'Content-Length': contentLength, + 'X-QL-Log-Total': total, + 'X-QL-Log-Truncated': total > contentLength ? 'true' : 'false', }); (function sendFiles(res, fileNames) { if (fileNames.length === 0) { @@ -482,13 +546,17 @@ export default class SystemService { if (currentLog) { const currentFileStream = fs.createReadStream( path.join(config.systemLogPath, currentLog.title), + { + start: currentLog.start, + end: currentLog.start + currentLog.length - 1, + }, ); currentFileStream.on('end', () => { sendFiles(res, fileNames); }); currentFileStream.pipe(res, { end: false }); } - })(res, logs); + })(res, selected); } public async deleteSystemLog() { @@ -512,39 +580,77 @@ export default class SystemService { if (success) { return { code: 200, data: info }; } 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) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); - + // Apply the global SSH key const SshKeyService = require('./sshKey').default; const Container = require('typedi').Container; const sshKeyService = Container.get(SshKeyService); - + if (info.globalSshKey) { await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global'); } else { await sshKeyService.removeGlobalSSHKey('global'); } - + return { code: 200, data: result }; } public async cleanDependence(type: 'node' | 'python3') { if (!type || !['node', 'python3'].includes(type)) { - return { code: 400, message: '参数错误' }; + return { code: 400, message: t('参数错误') }; } - try { - const finalPath = path.join(config.dependenceCachePath, type); - await fs.promises.rm(finalPath, { recursive: true }); - } catch (error) { } + const finalPath = path.join(config.dependenceCachePath, type); + await fs.promises.rm(finalPath, { recursive: true, force: true }); + await DependenceModel.update( + { status: DependenceStatus.installFailed }, + { + where: { + type: + type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3, + status: DependenceStatus.installed, + }, + }, + ); return { code: 200 }; } } diff --git a/back/services/user.ts b/back/services/user.ts index 362cb693..da982793 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -20,14 +20,24 @@ import ScheduleService from './schedule'; import SockService from './sock'; import dayjs from 'dayjs'; import IP2Region from 'ip2region'; -import requestIp from 'request-ip'; import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; +import { t, tf } from '../shared/i18n'; +import { getClientIp, normalizeClientIp } from '../shared/clientIp'; +import { isDefaultAuthInfo } from '../shared/auth'; +import { + hashPassword, + isPasswordHash, + verifyPassword, +} from '../shared/password'; +import { serializeAuthMutation } from '../shared/authMutation'; +import { LoginThrottle } from '../shared/loginThrottle'; @Service() export default class UserService { + private loginThrottle = new LoginThrottle(); @Inject((type) => NotificationService) private notificationService!: NotificationService; @@ -37,7 +47,15 @@ export default class UserService { private sockService: SockService, ) {} + @serializeAuthMutation public async login( + payloads: { username: string; password: string }, + req: Request, + ): Promise { + return this.authenticate(payloads, req); + } + + private async authenticate( payloads: { username: string; password: string; @@ -47,7 +65,18 @@ export default class UserService { ): Promise { let { username, password } = payloads; const content = await this.getAuthInfo(); + if (isDefaultAuthInfo(content)) { + return { code: 450, message: t('请先初始化') }; + } const timestamp = Date.now(); + const ip = getClientIp(req); + const query = new IP2Region(); + const ipAddress = query.search(ip); + let address = ''; + if (ipAddress) { + const { country, province, city, isp } = ipAddress; + address = uniq([country, province, city, isp]).filter(Boolean).join(' '); + } let { username: cUsername, password: cPassword, @@ -58,27 +87,43 @@ export default class UserService { twoFactorActivated, tokens = {}, platform, + blockedIps = [], } = content; - const retriesTime = Math.pow(3, retries) * 1000; - if (retries > 2 && timestamp - lastlogon < retriesTime) { - const waitTime = Math.ceil( - (retriesTime - (timestamp - lastlogon)) / 1000, - ); + + if ( + ip && + blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip) + ) { + await this.insertDb({ + type: AuthDataType.loginLog, + info: { + timestamp, + address, + ip, + platform: req.platform, + status: LoginStatus.fail, + }, + }); + this.getLoginLog(); + return { code: 403, message: t('该 IP 已被列入黑名单') }; + } + + const waitTime = this.loginThrottle.retryAfter(ip, timestamp); + if (waitTime > 0) { return { code: 410, - message: `失败次数过多,请${waitTime}秒后重试`, + message: tf('失败次数过多,请%s秒后重试', waitTime), data: waitTime, }; } - if ( - username === cUsername && - password === cPassword && - twoFactorActivated && - needTwoFactor - ) { + const passwordMatches = + username === cUsername && (await verifyPassword(password, cPassword)); + + if (passwordMatches && twoFactorActivated && needTwoFactor) { await this.updateAuthInfo(content, { isTwoFactorChecking: true, + twoFactorExpiresAt: timestamp + 5 * 60 * 1000, }); return { code: 420, @@ -86,15 +131,8 @@ export default class UserService { }; } - const ip = requestIp.getClientIp(req) || ''; - const query = new IP2Region(); - const ipAddress = query.search(ip); - let address = ''; - if (ipAddress) { - const { country, province, city, isp } = ipAddress; - address = uniq([country, province, city, isp]).filter(Boolean).join(' '); - } - if (username === cUsername && password === cPassword) { + if (passwordMatches) { + this.loginThrottle.reset(ip); const data = createRandomString(50, 100); const expiration = twoFactorActivated ? '60d' : '20d'; let token = jwt.sign({ data }, config.jwt.secret, { @@ -117,6 +155,9 @@ export default class UserService { ); await this.updateAuthInfo(content, { + password: isPasswordHash(cPassword) + ? cPassword + : await hashPassword(password), token, tokens: updatedTokens, lastlogon: timestamp, @@ -125,12 +166,22 @@ export default class UserService { lastaddr: address, platform: req.platform, isTwoFactorChecking: false, + twoFactorExpiresAt: 0, }); this.notificationService.notify( - '登录通知', - `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ - req.platform - }端 登录成功,ip地址 ${ip}`, + t('登录通知'), + t('你于') + + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + + t('在') + + address + + ' ' + + req.platform + + t('端') + + ' ' + + t('登录成功') + + t(',ip地址') + + ' ' + + ip, ); await this.insertDb({ type: AuthDataType.loginLog, @@ -155,18 +206,28 @@ export default class UserService { }, }; } else { + const failures = this.loginThrottle.fail(ip, timestamp); await this.updateAuthInfo(content, { - retries: retries + 1, + retries: failures, lastlogon: timestamp, lastip: ip, lastaddr: address, platform: req.platform, }); this.notificationService.notify( - '登录通知', - `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ - req.platform - }端 登录失败,ip地址 ${ip}`, + t('登录通知'), + t('你于') + + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + + t('在') + + address + + ' ' + + req.platform + + t('端') + + ' ' + + t('登录失败') + + t(',ip地址') + + ' ' + + ip, ); await this.insertDb({ type: AuthDataType.loginLog, @@ -179,19 +240,20 @@ export default class UserService { }, }); this.getLoginLog(); - if (retries > 2) { - const waitTime = Math.round(Math.pow(3, retries + 1)); + if (failures > 3) { + const waitTime = this.loginThrottle.retryAfter(ip, timestamp); return { code: 410, - message: `失败次数过多,请${waitTime}秒后重试`, + message: tf('失败次数过多,请%s秒后重试', waitTime), data: waitTime, }; } else { - return { code: 400, message: config.authError }; + return { code: 400, message: t('错误的用户名密码,请重试') }; } } } + @serializeAuthMutation public async logout(platform: string, tokenValue: string): Promise { if (!platform || !tokenValue) { this.logger.warn('Invalid logout parameters - empty platform or token'); @@ -245,11 +307,67 @@ export default class UserService { return []; } + public async getIpBlacklist(): Promise { + const authInfo = await this.getAuthInfo(); + return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter( + Boolean, + ); + } + + @serializeAuthMutation + public async blockIp(ip: string): Promise { + 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 { + 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 { const doc = await SystemModel.create({ ...payload }, { returning: true }); return doc; } + @serializeAuthMutation + public async initializeUser({ + username, + password, + }: { + username: string; + password: string; + }) { + const authInfo = await this.getAuthInfo(); + if (!isDefaultAuthInfo(authInfo)) { + return { code: 450, message: t('未知错误') }; + } + if (password === 'admin') { + return { code: 400, message: t('密码不能设置为admin') }; + } + await this.updateAuthInfo(authInfo, { + username, + password: await hashPassword(password), + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + return { code: 200, message: t('更新成功') }; + } + + @serializeAuthMutation public async updateUsernameAndPassword({ username, password, @@ -258,27 +376,40 @@ export default class UserService { password: string; }) { if (password === 'admin') { - return { code: 400, message: '密码不能设置为admin' }; + return { code: 400, message: t('密码不能设置为admin') }; } const authInfo = await this.getAuthInfo(); - await this.updateAuthInfo(authInfo, { username, password }); - return { code: 200, message: '更新成功' }; + await this.updateAuthInfo(authInfo, { + username, + password: await hashPassword(password), + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + return { code: 200, message: t('更新成功') }; } + @serializeAuthMutation public async updateAvatar(avatar: string) { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { avatar }); - return { code: 200, data: avatar, message: '更新成功' }; + return { code: 200, data: avatar, message: t('更新成功') }; } + @serializeAuthMutation public async initTwoFactor() { const secret = authenticator.generateSecret(); const authInfo = await this.getAuthInfo(); + if (authInfo.twoFactorActivated) { + throw new Error(t('请先关闭两步验证')); + } const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); return { secret, url: otpauth }; } + @serializeAuthMutation public async activeTwoFactor(code: string) { const authInfo = await this.getAuthInfo(); const isValid = authenticator.verify({ @@ -286,11 +417,18 @@ export default class UserService { secret: authInfo.twoFactorSecret, }); if (isValid) { - await this.updateAuthInfo(authInfo, { twoFactorActivated: true }); + await this.updateAuthInfo(authInfo, { + twoFactorActivated: true, + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); } return isValid; } + @serializeAuthMutation public async twoFactorLogin( { username, @@ -301,17 +439,30 @@ export default class UserService { ) { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; - if (!isTwoFactorChecking) { - return { code: 450, message: '未知错误' }; + const now = Date.now(); + const source = getClientIp(req); + if (this.loginThrottle.retryAfter(source, now) > 0) { + return { code: 410, message: t('失败次数过多,请稍后重试') }; } - const isValid = authenticator.verify({ - token: code, - secret: twoFactorSecret, - }); + if ( + !isTwoFactorChecking || + !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) { - return this.login({ username, password }, req, false); + await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step }); + return this.authenticate({ username, password }, req, false); } else { - const ip = requestIp.getClientIp(req) || ''; + const ip = getClientIp(req); const query = new IP2Region(); const ipAddress = query.search(ip); let address = ''; @@ -322,19 +473,26 @@ export default class UserService { .join(' '); } await this.updateAuthInfo(authInfo, { + retries: this.loginThrottle.fail(source, now), + lastlogon: now, lastip: ip, lastaddr: address, platform: req.platform, }); - return { code: 430, message: '验证失败' }; + return { code: 430, message: t('验证失败') }; } } + @serializeAuthMutation public async deactivateTwoFactor() { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { twoFactorActivated: false, twoFactorSecret: '', + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, }); return true; } @@ -355,6 +513,9 @@ export default class UserService { type: AuthDataType.authConfig, info: result, }); + if (info.tokens && Object.keys(info.tokens).length === 0) { + this.sockService.getClients().forEach((conn) => conn.close('401')); + } } public async getNotificationMode(): Promise { @@ -388,8 +549,8 @@ export default class UserService { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, - '青龙', - `【蛟龙】测试通知 https://t.me/jiao_long`, + t('青龙'), + t('【蛟龙】测试通知 https://t.me/jiao_long'), ); if (isSuccess) { const result = await this.updateAuthDb({ @@ -398,7 +559,7 @@ export default class UserService { }); return { code: 200, data: { ...result, code } }; } else { - return { code: 400, message: '通知发送失败,请检查参数' }; + return { code: 400, message: t('通知发送失败,请检查参数') }; } } @@ -496,8 +657,12 @@ export default class UserService { return undefined; } + @serializeAuthMutation public async resetAuthInfo(info: Partial) { const { retries, twoFactorActivated, password, username } = info; + if (password === 'admin') { + return { code: 400, message: t('密码不能设置为admin') }; + } const authInfo = await this.getAuthInfo(); const payload = pickBy( { @@ -509,6 +674,24 @@ export default class UserService { (x) => !isNil(x), ); + if (password !== undefined) { + payload.password = await hashPassword(password); + } + if ( + password !== undefined || + username !== undefined || + twoFactorActivated !== undefined + ) { + Object.assign(payload, { + token: '', + tokens: {}, + isTwoFactorChecking: false, + twoFactorExpiresAt: 0, + }); + } await this.updateAuthInfo(authInfo, payload); + if (retries === 0 || password !== undefined || username !== undefined) { + this.loginThrottle.clear(); + } } } diff --git a/back/shared/auth.ts b/back/shared/auth.ts index 8789f5cf..2a0e7fcf 100644 --- a/back/shared/auth.ts +++ b/back/shared/auth.ts @@ -1,4 +1,9 @@ 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. @@ -13,11 +18,21 @@ export function isValidToken( authInfo: AuthInfo | null | undefined, headerToken: string, platform: string, + secret: string, ): boolean { if (!authInfo || !headerToken) { return false; } + try { + const claims = jwt.verify(headerToken, secret, { algorithms: ['HS384'] }); + if (typeof claims === 'string' || typeof claims.exp !== 'number') { + return false; + } + } catch { + return false; + } + const { token = '', tokens = {} } = authInfo; // Check legacy token field @@ -38,7 +53,12 @@ export function isValidToken( return headerToken === platformTokens; } else if (Array.isArray(platformTokens)) { // New format: array of TokenInfo objects - return platformTokens.some((t: TokenInfo) => t && t.value === headerToken); + return platformTokens.some( + (t: TokenInfo) => + t && + t.value === headerToken && + (t.expiration === undefined || t.expiration > Date.now() / 1000), + ); } // Unexpected type - log warning and reject diff --git a/back/shared/authMutation.ts b/back/shared/authMutation.ts new file mode 100644 index 00000000..b041ddd7 --- /dev/null +++ b/back/shared/authMutation.ts @@ -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 = 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; + }; +} diff --git a/back/shared/childProcess.ts b/back/shared/childProcess.ts new file mode 100644 index 00000000..a5cacb5f --- /dev/null +++ b/back/shared/childProcess.ts @@ -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; + onStdout?: (message: string) => Promise; + onStderr?: (message: string) => Promise; + } = {}, +) { + let failure: Error | undefined; + const recordError = (error: unknown) => { + failure ??= asError(error); + }; + const spawned = new Promise((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((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, + ) => { + // 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 }; +} diff --git a/back/shared/clientIp.ts b/back/shared/clientIp.ts new file mode 100644 index 00000000..328ab0f6 --- /dev/null +++ b/back/shared/clientIp.ts @@ -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); +} diff --git a/back/shared/cronCommand.ts b/back/shared/cronCommand.ts new file mode 100644 index 00000000..bc4d6b2f --- /dev/null +++ b/back/shared/cronCommand.ts @@ -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 ''; +} diff --git a/back/shared/cronSchedule.ts b/back/shared/cronSchedule.ts new file mode 100644 index 00000000..061a072c --- /dev/null +++ b/back/shared/cronSchedule.ts @@ -0,0 +1,110 @@ +import CronExpressionParser from 'cron-parser'; +import { validate } from 'node-cron'; +import { ScheduleType } from '../interface/schedule'; + +const aliases: Record = { + '@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(); + +// 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)); +} diff --git a/back/shared/cronScheduler.ts b/back/shared/cronScheduler.ts new file mode 100644 index 00000000..65443a7d --- /dev/null +++ b/back/shared/cronScheduler.ts @@ -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, + 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; +} diff --git a/back/shared/errors.ts b/back/shared/errors.ts new file mode 100644 index 00000000..72945c9b --- /dev/null +++ b/back/shared/errors.ts @@ -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); +} diff --git a/back/shared/fileAccess.ts b/back/shared/fileAccess.ts new file mode 100644 index 00000000..2cda7e88 --- /dev/null +++ b/back/shared/fileAccess.ts @@ -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 ''; + } +} diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts new file mode 100644 index 00000000..fbd83984 --- /dev/null +++ b/back/shared/i18n.ts @@ -0,0 +1,179 @@ +import { shareStore } from './store'; + +const messages: Record> = { + 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( + (str, arg) => str.replace(/%s|%d/, String(arg)), + t(key), + ); +} diff --git a/back/shared/interface.ts b/back/shared/interface.ts index 01fd6cb7..2ea99f04 100644 --- a/back/shared/interface.ts +++ b/back/shared/interface.ts @@ -18,6 +18,7 @@ export interface IDependencyFn { export interface ICronFn { (): Promise; cron?: TCron; + isCurrent?: () => boolean; } export interface ISchedule { diff --git a/back/shared/logPath.ts b/back/shared/logPath.ts new file mode 100644 index 00000000..ac5d3ccd --- /dev/null +++ b/back/shared/logPath.ts @@ -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; +} diff --git a/back/shared/logReader.ts b/back/shared/logReader.ts new file mode 100644 index 00000000..c1cc5ae0 --- /dev/null +++ b/back/shared/logReader.ts @@ -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 { + 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(); + } +} diff --git a/back/shared/logStreamManager.ts b/back/shared/logStreamManager.ts index 815ce409..ff2ff1ec 100644 --- a/back/shared/logStreamManager.ts +++ b/back/shared/logStreamManager.ts @@ -1,5 +1,8 @@ import { createWriteStream, WriteStream } from 'fs'; import { EventEmitter } from 'events'; +import path from 'path'; +import config from '../config'; +import { resolveFileAccess } from './fileAccess'; /** * Manages write streams for log files to improve performance by avoiding repeated file opens @@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter { private streams: Map = new Map(); private pendingWrites: Map> = new Map(); - /** - * Write data to a log file using a managed stream - * @param filePath - Absolute path to the log file - * @param data - Data to write to the log file - */ - async write(filePath: string, data: string): Promise { - // Wait for any pending writes to this file to complete - const pending = this.pendingWrites.get(filePath); - if (pending) { - await pending; - } + private closingStreams = new Map>(); + private closedStreams = new WeakSet(); + private streamErrors = new Map(); - // Create a new promise for this write operation - const writePromise = new Promise((resolve, reject) => { - let stream = this.streams.get(filePath); - - if (!stream) { - // Create a new write stream if one doesn't exist - stream = createWriteStream(filePath, { flags: 'a' }); - this.streams.set(filePath, stream); - - // Handle stream errors - stream.on('error', (error) => { - this.emit('error', { filePath, error }); - // Remove the stream from the map on error - this.streams.delete(filePath); - reject(error); - }); - } - - // Write the data - const canContinue = stream.write(data, 'utf8', (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - - // Handle backpressure - if (!canContinue) { - stream.once('drain', () => { - // Stream is ready for more data - }); - } - }); - - this.pendingWrites.set(filePath, writePromise); - - try { - await writePromise; - } finally { - this.pendingWrites.delete(filePath); - } + constructor(private readonly logRoot = config.logPath) { + super(); } - /** - * Close the stream for a specific file path - * @param filePath - Absolute path to the log file - */ - async closeStream(filePath: string): Promise { - // Wait for any pending writes to complete - const pending = this.pendingWrites.get(filePath); - if (pending) { - await pending.catch(() => { - // Ignore errors on pending writes during close - }); + /** Register each write synchronously, so concurrent callers cannot lose the tail. */ + async write(filePath: string, data: string): Promise { + if (this.closingStreams.has(filePath)) { + throw new Error(`Log stream is closing: ${filePath}`); } + const previous = this.pendingWrites.get(filePath) || Promise.resolve(); + const pending = previous.then( + () => + new Promise((resolve, reject) => { + const failure = this.streamErrors.get(filePath); + if (failure) return reject(failure); + let stream = this.streams.get(filePath); + if (!stream) { + // Validate only when opening: subsequent chunks reuse the same descriptor. + const root = path.resolve(this.logRoot); + const target = path.resolve(filePath); + if ( + !target.startsWith(root + path.sep) || + !resolveFileAccess(root, [target]) + ) { + return reject(new Error('Log path is outside the log directory')); + } + stream = createWriteStream(target, { flags: 'a' }); + this.streams.set(filePath, stream); + const current = stream; + stream.once('close', () => this.closedStreams.add(current)); + stream.on('error', (error) => { + this.streamErrors.set(filePath, error); + // EventEmitter's unobserved "error" event would crash the caller. + if (this.listenerCount('error') > 0) + this.emit('error', { filePath, error }); + }); + } + stream.write(data, 'utf8', (error) => + error ? reject(error) : resolve(), + ); + }), + ); + this.pendingWrites.set(filePath, pending); + // Keep the tail until close, including failures; never reopen a failed log mid-run. + return pending; + } - const stream = this.streams.get(filePath); - if (stream) { - return new Promise((resolve) => { - stream.end(() => { - this.streams.delete(filePath); - resolve(); - }); - }); + async closeStream(filePath: string): Promise { + const closing = this.closingStreams.get(filePath); + if (closing) return closing; + const pending = this.pendingWrites.get(filePath); + const result = (async () => { + let failure: unknown; + try { + await pending; + } catch (error) { + failure = error; + } + const stream = this.streams.get(filePath); + try { + if (stream && !this.closedStreams.has(stream)) { + await new Promise((resolve) => { + stream.once('close', resolve); + if (failure || stream.destroyed) stream.destroy(); + else stream.end(); + }); + } + failure ||= this.streamErrors.get(filePath); + if (failure) throw failure; + } finally { + this.streams.delete(filePath); + this.pendingWrites.delete(filePath); + this.streamErrors.delete(filePath); + } + })(); + this.closingStreams.set(filePath, result); + try { + await result; + } finally { + this.closingStreams.delete(filePath); } } @@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter { * Close all open streams */ async closeAll(): Promise { - const closePromises = Array.from(this.streams.keys()).map((filePath) => + const paths = new Set([ + ...this.streams.keys(), + ...this.pendingWrites.keys(), + ]); + const closePromises = Array.from(paths).map((filePath) => this.closeStream(filePath), ); await Promise.all(closePromises); diff --git a/back/shared/loginThrottle.ts b/back/shared/loginThrottle.ts new file mode 100644 index 00000000..73987de1 --- /dev/null +++ b/back/shared/loginThrottle.ts @@ -0,0 +1,47 @@ +const RETRY_TTL = 15 * 60 * 1000; +const MAX_SOURCES = 10000; +const MAX_RETRY_SECONDS = 5 * 60; + +/** Source-specific, bounded backoff; persisted account history is not a lock. */ +export class LoginThrottle { + private sources = new Map< + string, + { failures: number; lastFailure: number } + >(); + + private get(source: string, now: number) { + const state = this.sources.get(source); + if (state && now - state.lastFailure >= RETRY_TTL) { + this.sources.delete(source); + return undefined; + } + return state; + } + + retryAfter(source: string, now: number): number { + const state = this.get(source, now); + if (!state || state.failures < 3) return 0; + const seconds = Math.min(3 ** state.failures, MAX_RETRY_SECONDS); + return Math.max( + 0, + Math.ceil((seconds * 1000 - (now - state.lastFailure)) / 1000), + ); + } + + fail(source: string, now: number): number { + const failures = Math.min((this.get(source, now)?.failures || 0) + 1, 6); + this.sources.delete(source); + if (this.sources.size >= MAX_SOURCES) { + this.sources.delete(this.sources.keys().next().value!); + } + this.sources.set(source, { failures, lastFailure: now }); + return failures; + } + + reset(source: string) { + this.sources.delete(source); + } + clear() { + this.sources.clear(); + } +} diff --git a/back/shared/pLimit.ts b/back/shared/pLimit.ts index 2f04d5be..969a6d58 100644 --- a/back/shared/pLimit.ts +++ b/back/shared/pLimit.ts @@ -4,6 +4,7 @@ import { AuthDataType, SystemModel } from '../data/system'; import Logger from '../loaders/logger'; import { Dependence } from '../data/dependence'; import NotificationService from '../services/notify'; +import { t, tf } from '../shared/i18n'; import { ICronFn, IDependencyFn, @@ -14,6 +15,7 @@ import { import config from '../config'; import { credentials } from '@grpc/grpc-js'; import { ApiClient } from '../protos/api'; +import { getGrpcCerts } from '../config/grpcCerts'; class TaskLimit { private dependenyLimit = new PQueue({ concurrency: 1 }); @@ -36,11 +38,24 @@ class TaskLimit { private systemLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); - private client = new ApiClient( - `localhost:${config.grpcPort}`, - credentials.createInsecure(), - { 'grpc.enable_http_proxy': 0 }, - ); + private _client: ApiClient | null = null; + + private get client(): ApiClient { + if (!this._client) { + const tlsConfig = getGrpcCerts(); + const creds = tlsConfig + ? credentials.createSsl( + Buffer.from(tlsConfig.caCert), + Buffer.from(tlsConfig.clientKey), + Buffer.from(tlsConfig.clientCert), + ) + : credentials.createInsecure(); + this._client = new ApiClient(`localhost:${config.grpcPort}`, creds, { + 'grpc.enable_http_proxy': 0, + }); + } + return this._client; + } get cronLimitActiveCount() { return this.cronLimit.pending; @@ -96,12 +111,15 @@ class TaskLimit { } } - public removeQueuedCron(id: string) { + public removeQueuedCron(id: string, completed?: ICronFn) { if (this.queuedCrons.has(id)) { const runs = this.queuedCrons.get(id); if (runs && runs.length > 0) { - runs.pop(); - this.queuedCrons.set(id, runs); + const remaining = completed + ? runs.filter((run) => run !== completed) + : runs.slice(0, -1); + if (remaining.length) this.queuedCrons.set(id, remaining); + else this.queuedCrons.delete(id); } } } @@ -126,9 +144,15 @@ class TaskLimit { cron: TCron, fn: ICronFn, options?: Partial, + isCurrent: () => boolean = () => true, ): Promise { + if (!isCurrent()) return; fn.cron = cron; - let runs = this.queuedCrons.get(cron.id); + fn.isCurrent = isCurrent; + // Invalidated snapshots must not consume the new revision's repeat budget. + const runs = this.queuedCrons + .get(cron.id) + ?.filter((run) => run.isCurrent?.() !== false); const result = runs?.length ? [...runs, fn] : [fn]; const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0; if (result?.length > 5) { @@ -136,8 +160,14 @@ class TaskLimit { this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1); this.client.systemNotify( { - title: '任务重复运行', - content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`, + title: t('任务重复运行'), + content: tf( + '任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置', + cron.name || '', + cron.command || '', + cron.schedule || '', + 5, + ), }, (err, res) => { if (err) { diff --git a/back/shared/password.ts b/back/shared/password.ts new file mode 100644 index 00000000..f59150df --- /dev/null +++ b/back/shared/password.ts @@ -0,0 +1,30 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'crypto'; +import { promisify } from 'util'; + +const deriveKey = promisify(scrypt); +const HASH_PREFIX = 'scrypt$'; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const key = (await deriveKey(password, salt, 64)) as Buffer; + return `${HASH_PREFIX}${salt}$${key.toString('hex')}`; +} + +export function isPasswordHash(password: string): boolean { + return /^scrypt\$[a-f0-9]{32}\$[a-f0-9]{128}$/.test(password); +} + +export async function verifyPassword( + password: string, + stored: string, +): Promise { + if (!isPasswordHash(stored)) { + // Existing installations migrate after a successful password check. + const input = Buffer.from(password); + const expected = Buffer.from(stored); + return input.length === expected.length && timingSafeEqual(input, expected); + } + const [, salt, hash] = stored.split('$'); + const key = (await deriveKey(password, salt, 64)) as Buffer; + return timingSafeEqual(key, Buffer.from(hash, 'hex')); +} diff --git a/back/shared/retention.ts b/back/shared/retention.ts new file mode 100644 index 00000000..815d8694 --- /dev/null +++ b/back/shared/retention.ts @@ -0,0 +1,64 @@ +import fs from 'fs/promises'; +import { Dirent } from 'fs'; +import path from 'path'; + +export const MAX_RETENTION_DAYS = 3650; +export const DEPENDENCE_CACHE_TYPES = ['node', 'python3'] as const; + +export type DependenceCacheType = (typeof DEPENDENCE_CACHE_TYPES)[number]; + +export interface RetentionPolicy { + runningInstanceRetentionDays: number; + cronStatRetentionDays: number; +} + +export function normalizeRetentionDays(value: unknown) { + const days = Number(value); + if (!Number.isFinite(days)) return 0; + return Math.min(Math.max(Math.trunc(days), 0), MAX_RETENTION_DAYS); +} + +export function normalizeRetentionPolicy( + policy: Partial, +): RetentionPolicy { + return { + runningInstanceRetentionDays: normalizeRetentionDays( + policy.runningInstanceRetentionDays, + ), + cronStatRetentionDays: normalizeRetentionDays(policy.cronStatRetentionDays), + }; +} + +export function isDependenceCacheType( + value: string, +): value is DependenceCacheType { + return DEPENDENCE_CACHE_TYPES.includes(value as DependenceCacheType); +} + +export async function getDirectorySize(rootPath: string): Promise { + const pending = [rootPath]; + let total = 0; + + while (pending.length > 0) { + const currentPath = pending.pop()!; + let entries: Dirent[]; + try { + entries = await fs.readdir(currentPath, { withFileTypes: true }); + } catch (error: any) { + if (error?.code === 'ENOENT') continue; + throw error; + } + + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const entryPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile()) { + total += (await fs.stat(entryPath)).size; + } + } + } + + return total; +} diff --git a/back/shared/runCron.ts b/back/shared/runCron.ts index 9ccfdb05..8a862681 100644 --- a/back/shared/runCron.ts +++ b/back/shared/runCron.ts @@ -4,16 +4,27 @@ import Logger from '../loaders/logger'; import { ICron } from '../protos/cron'; import { CrontabModel, CrontabStatus } from '../data/cron'; import { killTask } from '../config/util'; +import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance'; +import dayjs from 'dayjs'; +import { observeChildProcess, asError } from './childProcess'; -export function runCron(cmd: string, cron: ICron): Promise { - return taskLimit.runWithCronLimit(cron, () => { - return new Promise(async (resolve: any) => { +export function runCron( + cmd: string, + cron: ICron, + isCurrent: () => boolean = () => true, +): Promise { + const execute = async () => { + try { + if (!isCurrent()) return; // Check if the cron is already running and stop it (only if multiple instances are not allowed) try { const existingCron = await CrontabModel.findOne({ where: { id: Number(cron.id) }, }); + // A mutation may commit while this queued run is reading the database. + if (!isCurrent() || !existingCron || existingCron.isDisabled) return; + // Default to single instance mode (0) for backward compatibility const allowSingleInstances = existingCron?.allow_multiple_instances === 0; @@ -28,7 +39,18 @@ export function runCron(cmd: string, cron: ICron): Promise { Logger.info( `[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`, ); - await killTask(existingCron.pid); + await killTask(existingCron.pid, true); + // Mark old running instances as stopped + const stoppedAt = dayjs().unix(); + await RunningInstanceModel.update( + { status: InstanceStatus.stopped, finished_at: stoppedAt }, + { + where: { + cron_id: Number(cron.id), + status: InstanceStatus.running, + }, + }, + ); // Update the status to idle after killing await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, @@ -39,8 +61,11 @@ export function runCron(cmd: string, cron: ICron): Promise { Logger.error( `[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`, ); + throw error; } + // In particular, invalidation can happen while stopping a previous run. + if (!isCurrent()) return; Logger.info( `[schedule][开始执行任务] 参数 ${JSON.stringify({ ...cron, @@ -49,33 +74,38 @@ export function runCron(cmd: string, cron: ICron): Promise { ); const cp = spawn(cmd, { shell: '/bin/bash' }); - cp.stderr.on('data', (data) => { - Logger.info( - '[schedule][执行任务失败] 命令: %s, 错误信息: %j', - cmd, - data.toString(), - ); + const { completed } = observeChildProcess(cp, { + onStderr: async (message) => { + Logger.info( + '[schedule][任务标准错误] 命令: %s, 信息: %s', + cmd, + message, + ); + }, }); - cp.on('error', (err) => { + const result = await completed; + if (result.error) { Logger.error( - '[schedule][创建任务失败] 命令: %s, 错误信息: %j', + '[schedule][执行任务失败] 命令: %s, 错误: %s', cmd, - err, + result.error.message, ); - }); - - cp.on('exit', async (code) => { - taskLimit.removeQueuedCron(cron.id); - Logger.info( - '[schedule][执行任务结束] 参数: %s, 退出码: %j', - JSON.stringify({ - ...cron, - command: cmd, - }), - code, - ); - resolve({ ...cron, command: cmd, pid: cp.pid, code }); - }); - }); - }); + } + Logger.info( + '[schedule][执行任务结束] 任务ID: %s, 退出码: %j', + cron.id, + result.code, + ); + return { ...cron, command: cmd, pid: cp.pid, ...result } as any; + } catch (error) { + Logger.error( + '[schedule][创建任务失败] 命令: %s, 错误: %s', + cmd, + asError(error).message, + ); + } finally { + taskLimit.removeQueuedCron(cron.id, execute); + } + }; + return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent); } diff --git a/back/shared/schedulerMutationLock.ts b/back/shared/schedulerMutationLock.ts new file mode 100644 index 00000000..a31c7fa3 --- /dev/null +++ b/back/shared/schedulerMutationLock.ts @@ -0,0 +1,40 @@ +import lockfile from 'proper-lockfile'; +import config from '../config'; + +// HTTP and gRPC both mutate cron definitions. Hold one shared lock from the +// initial DB read/write through scheduler registration (including rollback). +// Recovery takes the same lock before reading its replacement snapshot. +export async function withSchedulerMutation( + operation: () => Promise, +): Promise { + let release: () => Promise; + try { + // proper-lockfile indexes held locks by target, not lockfilePath. Keep + // this identity separate from nested writeFileWithLock(crontabFile). + release = await lockfile.lock(`${config.crontabFile}.scheduler`, { + realpath: false, + lockfilePath: `${config.crontabFile}.scheduler.lock`, + stale: 30000, + update: 10000, + retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 }, + }); + } catch (cause) { + throw Object.assign( + new Error('Scheduler configuration is busy', { cause }), + { + status: 503, + }, + ); + } + try { + return await operation(); + } finally { + await release(); + } +} + +export function schedulerRegistrationError(message: string, cause: any): Error { + return Object.assign(new Error(message, { cause }), { + status: cause?.status === 503 ? 503 : 500, + }); +} diff --git a/back/shared/schedulerReadiness.ts b/back/shared/schedulerReadiness.ts new file mode 100644 index 00000000..f2100695 --- /dev/null +++ b/back/shared/schedulerReadiness.ts @@ -0,0 +1,77 @@ +// Recovery is single-flight and retried only while unavailable, never at idle. +export class SchedulerReadiness { + private ready = false; + private generation = 0; + private restore?: () => Promise; + private pending?: Promise; + private retry?: NodeJS.Timeout; + + constructor(private probe: () => Promise, private retryMs = 1000) {} + + configure(restore: () => Promise) { + this.restore = restore; + } + + invalidate() { + this.ready = false; + this.generation++; + void this.recover(); + } + + recover(): Promise { + if (this.pending) return this.pending; + if (!this.restore) return Promise.resolve(false); + clearTimeout(this.retry); + const generation = this.generation; + this.ready = false; + this.pending = (async () => { + try { + await this.probe(); + await this.restore!(); + await this.probe(); + this.ready = generation === this.generation; + } catch { + this.ready = false; + } + return this.ready; + })().finally(() => { + this.pending = undefined; + if (!this.ready) { + this.retry = setTimeout(() => void this.recover(), this.retryMs); + this.retry.unref(); + } + }); + return this.pending; + } + + async check(): Promise { + if (!this.ready) return false; + const generation = this.generation; + try { + await this.probe(); + return this.ready && generation === this.generation; + } catch { + this.invalidate(); + return false; + } + } + + async ensureReady(timeoutMs = 2000): Promise { + let timer: NodeJS.Timeout | undefined; + try { + const available = await Promise.race([ + (async () => (await this.check()) || (await this.recover()))(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + if (!available) { + throw Object.assign(new Error('Scheduler is recovering; try again later'), { + status: 503, + }); + } + } finally { + clearTimeout(timer); + } + } +} diff --git a/back/shared/schemaMigrations.ts b/back/shared/schemaMigrations.ts new file mode 100644 index 00000000..ed7a6658 --- /dev/null +++ b/back/shared/schemaMigrations.ts @@ -0,0 +1,70 @@ +import { QueryTypes, Sequelize } from 'sequelize'; + +// Append new entries; IDs are persisted and must not be renumbered or reused. +const columns = [ + { + table: 'CrontabViews', + column: 'filterRelation', + type: 'VARCHAR(255)', + }, + { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, + { table: 'CrontabViews', column: 'type', type: 'NUMBER' }, + { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, + { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, + { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, + { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, + { table: 'Crontabs', column: 'task_before', type: 'TEXT' }, + { table: 'Crontabs', column: 'task_after', type: 'TEXT' }, + { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, + { + table: 'Crontabs', + column: 'allow_multiple_instances', + type: 'NUMBER', + }, + { table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' }, + { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, + { table: 'Envs', column: 'labels', type: 'JSON' }, + { table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' }, +]; + +export async function migrateSchema(database: Sequelize): Promise { + await database.transaction(async (transaction) => { + await database.query( + 'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)', + { transaction }, + ); + const applied = await database.query<{ id: string }>( + 'SELECT "id" FROM "SchemaMigrations"', + { type: QueryTypes.SELECT, transaction }, + ); + const appliedIds = new Set(applied.map(({ id }) => id)); + for (const { table, column, type } of columns) { + const id = `add-${table}-${column}`; + const fields = await database.query<{ name: string }>( + `PRAGMA table_info("${table}")`, + { + type: QueryTypes.SELECT, + transaction, + }, + ); + if (fields.length === 0) + throw new Error(`Migration table is missing: ${table}`); + if (!fields.some((field) => field.name === column)) { + // table/column/type come only from the static migration manifest above. + await database.query( + `ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`, + { transaction }, + ); + } + if (!appliedIds.has(id)) { + await database.query( + 'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)', + { + replacements: { id, appliedAt: new Date().toISOString() }, + transaction, + }, + ); + } + } + }); +} diff --git a/back/shared/scriptHistory.ts b/back/shared/scriptHistory.ts new file mode 100644 index 00000000..c74e7ace --- /dev/null +++ b/back/shared/scriptHistory.ts @@ -0,0 +1,467 @@ +import * as fs from 'fs/promises'; +import { createReadStream } from 'fs'; +import path from 'path'; +import { createHash, randomUUID } from 'crypto'; +import { promisify } from 'util'; +import { brotliCompress, brotliDecompress, constants } from 'zlib'; +import { lock } from 'proper-lockfile'; +import { resolveFileAccess } from './fileAccess'; +import { getUniqueLockPath } from './utils'; + +const compress = promisify(brotliCompress); +const decompress = promisify(brotliDecompress); +const hash = (content: string) => + createHash('sha256').update(content).digest('hex'); + +export const SCRIPT_HISTORY_LIMITS = { + versions: 20, + fileBytes: 1024 * 1024, + unpackedBytes: 8 * 1024 * 1024, + archiveBytes: 2 * 1024 * 1024, + totalBytes: 32 * 1024 * 1024, + files: 1000, +}; + +type Source = 'baseline' | 'save' | 'restore' | 'external'; +interface Version { + id: string; + createdAt: string; + source: Source; + hash: string; + content: string; +} +interface Archive { + format: 1; + path: string; + versions: Version[]; + head?: { hash: string; createdAt: string; source: Source }; +} + +export class ScriptHistoryError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +export class HistoryUnavailableError extends ScriptHistoryError { + constructor(public currentHash: string) { + super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本'); + } +} +interface SaveOptions { + skipHistory?: boolean; + expectedHash?: string; +} + +/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache. + * Full snapshots share a Brotli window, so similar revisions compress together + * without a patch chain. All mutations use the existing script lock and a + * cross-process archive lock. History is durable before overwriting the script. */ +export class ScriptHistory { + constructor( + private scriptRoot: string, + private historyRoot: string, + private blacklist: string[] = [], + private limits = SCRIPT_HISTORY_LIMITS, + ) {} + + private async target(directory: string, filename: string) { + const candidate = resolveFileAccess( + this.scriptRoot, + [directory, filename], + this.blacklist, + ); + if (!candidate) throw new ScriptHistoryError(403, '暂无权限'); + try { + const file = await fs.realpath(candidate); + const root = await fs.realpath(this.scriptRoot); + const relative = path.relative(root, file); + const key = hash(relative); + return { + file, + relative, + archive: path.join(this.historyRoot, key + '.br'), + }; + } catch (error: any) { + if (error.code === 'ENOENT') + throw new ScriptHistoryError(404, '脚本不存在'); + throw error; + } + } + + private async readText(file: string) { + const handle = await fs.open(file, 'r'); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件'); + if (stat.size > this.limits.fileBytes) + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + // Bounded read even if an external process grows the file after stat. + const bytes = Buffer.alloc(this.limits.fileBytes + 1); + let size = 0; + while (size < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + size, + bytes.length - size, + null, + ); + if (!bytesRead) break; + size += bytesRead; + } + const buffer = bytes.subarray(0, size); + const content = buffer.toString('utf8'); + if ( + size > this.limits.fileBytes || + content.includes('\0') || + !Buffer.from(content).equals(buffer) + ) { + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + } + return content; + } finally { + await handle.close(); + } + } + + private async readArchive(file: string, relative: string): Promise { + try { + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.size > this.limits.archiveBytes) + throw new Error('Invalid archive'); + const bytes = await decompress(await fs.readFile(file), { + maxOutputLength: this.limits.unpackedBytes, + }); + const archive: Archive = JSON.parse(bytes.toString('utf8')); + if ( + archive.format !== 1 || + archive.path !== relative || + !Array.isArray(archive.versions) || + archive.versions.length > this.limits.versions || + archive.versions.some( + (v) => + typeof v.content !== 'string' || + typeof v.id !== 'string' || + typeof v.createdAt !== 'string' || + !['baseline', 'save', 'restore', 'external'].includes(v.source) || + Buffer.byteLength(v.content) > this.limits.fileBytes || + hash(v.content) !== v.hash, + ) + ) { + throw new Error('Invalid archive'); + } + return archive; + } catch (error: any) { + if (error.code === 'ENOENT') + return { format: 1, path: relative, versions: [] }; + throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件'); + } + } + + async list(directory: string, filename: string) { + const target = await this.target(directory, filename); + const [current, archive] = await Promise.all([ + this.readText(target.file), + this.readArchive(target.archive, target.relative), + ]); + const currentHash = hash(current); + const versions = archive.versions + .slice() + .reverse() + .map(({ content, ...v }) => ({ + ...v, + size: Buffer.byteLength(content), + identical: v.hash === currentHash, + })); + return { versions, currentHash, limit: this.limits.versions }; + } + + async detail(directory: string, filename: string, id: string) { + const target = await this.target(directory, filename); + const [current, archive] = await Promise.all([ + this.readText(target.file), + this.readArchive(target.archive, target.relative), + ]); + const version = archive.versions.find((v) => v.id === id); + if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理'); + return { version, current, currentHash: hash(current) }; + } + + async save( + directory: string, + filename: string, + content: string, + options: SaveOptions = {}, + ) { + return this.mutate(directory, filename, { content, ...options }); + } + + async restore( + directory: string, + filename: string, + id: string, + expectedHash: string, + ) { + return this.mutate(directory, filename, { id, expectedHash }); + } + + private async mutate( + directory: string, + filename: string, + change: + | ({ content: string } & SaveOptions) + | { id: string; expectedHash: string }, + ) { + const target = await this.target(directory, filename); + const releaseFile = await lock(target.file, { + lockfilePath: getUniqueLockPath(target.file), + retries: { retries: 20, minTimeout: 50, maxTimeout: 250 }, + }); + try { + let current: string; + try { + current = await this.readText(target.file); + if ( + 'content' in change && + (Buffer.byteLength(change.content) > this.limits.fileBytes || + change.content.includes('\0')) + ) { + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + } + } catch (error) { + if ( + !('content' in change) || + !(error instanceof ScriptHistoryError) || + error.status !== 413 + ) + throw error; + // Hash oversized or non-UTF-8 files without reading them all into memory. + const digest = createHash('sha256'); + for await (const chunk of createReadStream(target.file)) + digest.update(chunk); + const currentHash = digest.digest('hex'); + if (!change.skipHistory) throw new HistoryUnavailableError(currentHash); + if (change.expectedHash !== currentHash) + throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试'); + await this.preparedWrite(target.file, change.content, (commit) => + commit(), + ); + return { + content: change.content, + changed: true, + historyRecorded: false, + }; + } + if ( + ('id' in change || change.skipHistory) && + hash(current) !== change.expectedHash + ) { + throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试'); + } + if ('content' in change && change.content === current) + return { content: current, changed: false }; + await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 }); + const releaseHistory = await lock(this.historyRoot, { + retries: { retries: 20, minTimeout: 50, maxTimeout: 250 }, + }); + try { + const archive = await this.readArchive(target.archive, target.relative); + const restored = + 'id' in change + ? archive.versions.find((v) => v.id === change.id) + : undefined; + if ('id' in change && !restored) + throw new ScriptHistoryError(404, '历史版本不存在或已清理'); + const content = + 'content' in change ? change.content : restored!.content; + if (content === current) return { content, changed: false }; + if ( + Buffer.byteLength(content) > this.limits.fileBytes || + content.includes('\0') + ) { + throw new ScriptHistoryError( + 413, + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本', + ); + } + const currentHash = hash(current); + if ( + archive.versions[archive.versions.length - 1]?.hash !== currentHash + ) { + const head = + archive.head?.hash === currentHash ? archive.head : undefined; + archive.versions.push({ + id: randomUUID(), + createdAt: head?.createdAt || new Date().toISOString(), + source: + head?.source || + (archive.versions.length ? 'external' : 'baseline'), + hash: currentHash, + content: current, + }); + } + // Only prior contents are stored. Head metadata labels the next snapshot, + // but is used only if its hash matches the file. A failed file write can + // never introduce a phantom content version or lose the old snapshot. + archive.head = { + hash: hash(content), + createdAt: new Date().toISOString(), + source: 'id' in change ? 'restore' : 'save', + }; + const packed = await this.pack(archive); + const cleanupPending = await this.preparedWrite( + target.file, + content, + (commit) => this.store(target.archive, packed, commit), + ); + return { + content, + changed: true, + historyRecorded: true, + cleanupPending, + }; + } finally { + await releaseHistory(); + } + } finally { + await releaseFile(); + } + } + + private async pack(archive: Archive) { + archive.versions = archive.versions.slice(-this.limits.versions); + while (true) { + const serialized = Buffer.from(JSON.stringify(archive)); + if (serialized.length <= this.limits.unpackedBytes) { + const compressed = await compress(serialized, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 4, + [constants.BROTLI_PARAM_LGWIN]: 22, + }, + }); + if (compressed.length <= this.limits.archiveBytes) return compressed; + } + if (archive.versions.length <= 1) + throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存'); + archive.versions.shift(); + } + } + + /** Stage the script before publishing history. A failed write/fsync never + * truncates the original. Preserve the existing file's ownership and mode. */ + private async preparedWrite( + file: string, + content: string, + run: (commit: () => Promise) => Promise, + ): Promise { + const stat = await fs.stat(file); + const temporary = path.join( + path.dirname(file), + `.ql-save-${randomUUID()}.tmp`, + ); + try { + await fs.writeFile(temporary, content, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + if ( + typeof process.getuid === 'function' && + typeof process.getgid === 'function' && + (stat.uid !== process.getuid() || stat.gid !== process.getgid()) + ) { + await fs.chown(temporary, stat.uid, stat.gid); + } + await fs.chmod(temporary, stat.mode & 0o7777); + const handle = await fs.open(temporary, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + return await run(() => fs.rename(temporary, file)); + } finally { + await fs.unlink(temporary).catch(() => undefined); + } + } + + private async store( + file: string, + content: Buffer, + commit: () => Promise, + ) { + const entries = []; + for (const name of await fs.readdir(this.historyRoot)) { + if (!/^[a-f0-9]{64}\.br$/.test(name)) continue; + const fullPath = path.join(this.historyRoot, name); + if (fullPath === file) continue; + const stat = await fs.lstat(fullPath); + entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs }); + } + entries.sort((a, b) => a.time - b.time); + let total = entries.reduce((sum, v) => sum + v.size, content.length); + const victims: string[] = []; + while ( + entries.length && + (total > this.limits.totalBytes || entries.length >= this.limits.files) + ) { + const oldest = entries.shift()!; + victims.push(oldest.file); + total -= oldest.size; + } + if (total > this.limits.totalBytes) + throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存'); + const previous = await fs.readFile(file).catch((error) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + const temporary = path.join(this.historyRoot, '.pending.tmp'); + const publish = async (bytes: Buffer) => { + await fs.unlink(temporary).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' }); + const handle = await fs.open(temporary, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temporary, file); + }; + try { + await publish(content); + try { + await commit(); + } catch (error) { + // The original script is still intact. Restore its exact history before + // returning an error; no unrelated archive has been removed yet. + if (previous) await publish(previous); + else await fs.unlink(file); + throw error; + } + // Quota eviction is post-commit maintenance. Report cleanup failures as a + // warning, not a failed save (which would encourage a misleading retry). + let cleanupPending = false; + for (const victim of victims) { + try { + await fs.unlink(victim); + } catch (error: any) { + if (error.code !== 'ENOENT') cleanupPending = true; + } + } + return cleanupPending; + } finally { + await fs.unlink(temporary).catch(() => undefined); + } + } +} diff --git a/back/shared/shellQuote.ts b/back/shared/shellQuote.ts new file mode 100644 index 00000000..64281d41 --- /dev/null +++ b/back/shared/shellQuote.ts @@ -0,0 +1,5 @@ +/** Quote one literal POSIX shell argument, including quotes and substitutions. */ +export function shellQuote(value: string): string { + if (value.includes('\0')) throw new Error('Invalid shell argument'); + return "'" + value.replace(/'/g, "'\\''") + "'"; +} diff --git a/back/shared/startupProcess.ts b/back/shared/startupProcess.ts new file mode 100644 index 00000000..64ee9dcb --- /dev/null +++ b/back/shared/startupProcess.ts @@ -0,0 +1,47 @@ +import { fork } from 'child_process'; + +// Keep one-off startup dependencies out of the long-lived cluster primary. +export function runStartupProcess(entrypoint: string): Promise { + return new Promise((resolve, reject) => { + const child = fork(entrypoint, [], { stdio: 'inherit' }); + let interrupted: NodeJS.Signals | undefined; + let killTimer: NodeJS.Timeout | undefined; + + const terminate = (signal: NodeJS.Signals) => { + if (interrupted) return; + interrupted = signal; + child.kill(signal); + killTimer = setTimeout(() => child.kill('SIGKILL'), 8000); + killTimer.unref(); + }; + const onSigterm = () => terminate('SIGTERM'); + const onSigint = () => terminate('SIGINT'); + const onExit = () => child.kill('SIGKILL'); + const cleanup = () => { + if (killTimer) clearTimeout(killTimer); + process.removeListener('SIGTERM', onSigterm); + process.removeListener('SIGINT', onSigint); + process.removeListener('exit', onExit); + }; + + process.once('SIGTERM', onSigterm); + process.once('SIGINT', onSigint); + process.once('exit', onExit); + child.once('error', (error) => { + cleanup(); + reject(error); + }); + child.once('close', (code, signal) => { + cleanup(); + if (code === 0 && !interrupted) { + resolve(); + } else { + reject( + new Error( + `Startup process failed (${interrupted || signal || code})`, + ), + ); + } + }); + }); +} diff --git a/back/shared/store.ts b/back/shared/store.ts index 186adf43..aefa392a 100644 --- a/back/shared/store.ts +++ b/back/shared/store.ts @@ -8,11 +8,13 @@ import path from 'path'; export enum EKeyv { 'apps' = 'apps', 'authInfo' = 'authInfo', + 'lang' = 'lang', } export interface IKeyvStore { apps: App[]; authInfo: AuthInfo; + lang: string; } const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite')); @@ -31,4 +33,10 @@ export const shareStore = { updateApps(apps: App[]) { return keyvStore.set(EKeyv.apps, apps); }, + getLang() { + return keyvStore.get(EKeyv.lang); + }, + setLang(value: IKeyvStore['lang']) { + return keyvStore.set(EKeyv.lang, value); + }, }; diff --git a/back/shared/subscriptionMutationLock.ts b/back/shared/subscriptionMutationLock.ts new file mode 100644 index 00000000..09d1a272 --- /dev/null +++ b/back/shared/subscriptionMutationLock.ts @@ -0,0 +1,30 @@ +import lockfile from 'proper-lockfile'; +import path from 'path'; +import config from '../config'; + +// Protect subscription storage ownership from the first database read through +// cleanup. Creation and URL/branch updates must use the same cross-process lock. +export async function withSubscriptionMutation( + operation: () => Promise, +): Promise { + const target = `${path.resolve(config.scriptPath)}.subscriptions`; + let release: () => Promise; + try { + release = await lockfile.lock(target, { + realpath: false, + stale: 30000, + update: 10000, + retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 }, + }); + } catch (cause) { + throw Object.assign( + new Error('Subscription configuration is busy', { cause }), + { status: 503 }, + ); + } + try { + return await operation(); + } finally { + await release(); + } +} diff --git a/back/shared/subscriptionPath.ts b/back/shared/subscriptionPath.ts new file mode 100644 index 00000000..278c67d6 --- /dev/null +++ b/back/shared/subscriptionPath.ts @@ -0,0 +1,45 @@ +import { resolveFileAccess } from './fileAccess'; +import { createHash } from 'crypto'; + +export function assertSubscriptionAlias(alias: string): void { + if ( + typeof alias !== 'string' || + alias.length > 200 || + !/^[^\s\\\p{C}]+$/u.test(alias) || + alias.startsWith('~') || + alias.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw Object.assign(new Error('Invalid subscription alias'), { + status: 400, + }); + } +} + +/** Keep ordinary SSH names and use a reserved namespace for path-like aliases. + * The database alias and its script/log directories remain unchanged. */ +export function getSubscriptionSshAlias(alias: string): string { + assertSubscriptionAlias(alias); + if (/^[\p{L}\p{N}_.+-]+$/u.test(alias) && !alias.endsWith('.config')) { + return alias; + } + return `~sub_${createHash('sha256').update(alias).digest('hex')}`; +} + +export function resolveSubscriptionPath( + root: string, + alias: string, + suffix = '', +): string { + // Internal SSH filenames cannot collide with user-supplied aliases. + if (!/^~sub_[a-f0-9]{64}$/.test(alias)) { + assertSubscriptionAlias( + alias.startsWith('~global_') ? alias.slice(8) : alias, + ); + } + const target = resolveFileAccess(root, [alias + suffix]); + if (!target) + throw Object.assign(new Error('Invalid subscription path'), { + status: 400, + }); + return target; +} diff --git a/back/shared/subscriptionStorage.ts b/back/shared/subscriptionStorage.ts new file mode 100644 index 00000000..156e49bb --- /dev/null +++ b/back/shared/subscriptionStorage.ts @@ -0,0 +1,106 @@ +import type { Subscription } from '../data/subscription'; +import { formatUrl } from '../config/subscription'; +import path from 'path'; +import fs from 'fs'; +import { resolveFileAccess } from './fileAccess'; + +// Storage namespaces come from URLs and branches, not user aliases. In +// particular, a legal Git directory can exceed the alias's 200-character limit. +export function isSubscriptionStorageName(name: string): boolean { + return ( + typeof name === 'string' && + !!name && + !path.isAbsolute(name) && + !/[\0\r\n\\]/.test(name) && + !name.split('/').some((part) => !part || part === '.' || part === '..') + ); +} + +export function resolveSubscriptionStoragePath( + root: string, + name: string, +): string | undefined { + const target = isSubscriptionStorageName(name) + ? resolveFileAccess(root, [name]) + : ''; + if (!target) { + if (isSubscriptionStorageName(name)) { + try { + fs.lstatSync(path.resolve(root, name)); + } catch (error: any) { + // A long query can make the Shell candidate unrepresentable while + // the CLI's pathname-based file exists. Use the actual filesystem + // limit: macOS permits Unicode names exceeding 255 UTF-8 bytes. + if (error.code === 'ENAMETOOLONG') return undefined; + } + } + throw Object.assign(new Error('Invalid subscription path'), { + status: 400, + }); + } + return target; +} + +/** Match shell/update.sh get_uniq_path and update_raw, including SCP URLs. */ +export function getSubscriptionStorageNames( + doc: Subscription, + engine: 'shell' | 'cli' = 'shell', +): { + script: string; + repo?: string; + raw?: string; +} { + // Old imported rows may only have an alias and no URL. + if (!doc.url) return { script: doc.alias, repo: doc.alias }; + const url = formatUrl(doc).url!; + const trimmed = url.endsWith('/') ? url.slice(0, -1) : url; + const basename = trimmed.slice(trimmed.lastIndexOf('/') + 1); + const dot = basename.lastIndexOf('.'); + const repo = dot < 0 ? basename : basename.slice(0, dot); + const parentUrl = engine === 'cli' ? trimmed : url; + const slash = parentUrl.lastIndexOf('/'); + const parent = + engine === 'shell' && slash < 0 ? url : parentUrl.slice(0, slash); + const owner = parent.slice(parent.lastIndexOf('/') + 1); + const author = owner.slice(owner.lastIndexOf(':') + 1); + const stem = `${author.slice(author.lastIndexOf('.') + 1)}_${repo}`; + if (doc.type === 'file') { + const suffix = + engine === 'cli' + ? path.extname(new URL(url).pathname) + : `.${url.slice(url.lastIndexOf('.') + 1)}`; + const raw = `${stem}${suffix}`; + return { script: `raw_${raw}`, raw }; + } + const directory = doc.branch ? `${stem}_${doc.branch}` : stem; + return { script: directory, repo: directory }; +} + +// A subscription may have run through either engine before being deleted. +export function getSubscriptionStorageCandidates(doc: Subscription) { + const candidates: ReturnType[] = []; + for (const engine of ['shell', 'cli'] as const) { + try { + candidates.push(getSubscriptionStorageNames(doc, engine)); + } catch { + // Historical rows may have an invalid SSH alias or missing credentials. + // Retain their URL-derived storage without needing an executable SSH URL. + try { + candidates.push( + getSubscriptionStorageNames( + { + url: doc.url, + alias: doc.alias, + branch: doc.branch, + type: doc.type === 'private-repo' ? 'public-repo' : doc.type, + }, + engine, + ), + ); + } catch { + // One engine's invalid URL must not hide the other engine's candidate. + } + } + } + return candidates; +} diff --git a/back/shared/trustProxy.ts b/back/shared/trustProxy.ts new file mode 100644 index 00000000..4afc3a8c --- /dev/null +++ b/back/shared/trustProxy.ts @@ -0,0 +1,142 @@ +import express, { Application, Request } from 'express'; +import { AuthDataType, SystemModel } from '../data/system'; +import { normalizeClientIp } from './clientIp'; + +const DEFAULT_TRUST_PROXY = 'loopback'; + +type TrustProxyValue = boolean | number | string; + +let activeApp: Application | undefined; + +function getEnvironmentSetting(): string { + return process.env.QL_TRUST_PROXY?.trim() || ''; +} + +function normalizeSetting(value?: string): string { + return value?.trim() || DEFAULT_TRUST_PROXY; +} + +export function resolveTrustProxy(value?: string): TrustProxyValue { + const setting = normalizeSetting(value); + if (setting === 'true' || setting === 'false') { + return setting === 'true'; + } + if (/^\d+$/.test(setting)) { + return Number(setting); + } + return setting; +} + +function validateTrustProxy(value: string): string { + const setting = normalizeSetting(value); + if (setting.length > 500 || /[\r\n]/.test(setting)) { + throw new Error('trust proxy 配置格式无效'); + } + if (/^\d+$/.test(setting) && Number(setting) > 20) { + throw new Error('代理层数不能超过 20'); + } + + const probe = express(); + probe.set('trust proxy', resolveTrustProxy(setting)); + return setting; +} + +async function getStoredSetting(): Promise { + const doc = await SystemModel.findOne({ + where: { type: AuthDataType.systemConfig }, + }); + const info = (doc?.get('info') || {}) as Record; + return typeof info.trustProxy === 'string' ? info.trustProxy : ''; +} + +export async function getTrustProxyConfig() { + const environmentSetting = getEnvironmentSetting(); + const storedSetting = await getStoredSetting(); + const trustProxy = normalizeSetting(environmentSetting || storedSetting); + + return { + trustProxy, + source: environmentSetting + ? 'environment' + : storedSetting + ? 'system' + : 'default', + editable: !environmentSetting, + }; +} + +export async function initializeTrustProxy(app: Application) { + activeApp = app; + const { trustProxy } = await getTrustProxyConfig(); + app.set('trust proxy', resolveTrustProxy(trustProxy)); +} + +export async function updateTrustProxy(value: string) { + if (getEnvironmentSetting()) { + throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'); + } + + const trustProxy = validateTrustProxy(value); + const doc = await SystemModel.findOne({ + where: { type: AuthDataType.systemConfig }, + }); + if (!doc) { + throw new Error('系统配置不存在'); + } + + const plain = doc.get({ plain: true }); + await SystemModel.update( + { info: { ...(plain.info || {}), trustProxy } as any }, + { where: { id: plain.id } }, + ); + activeApp?.set('trust proxy', resolveTrustProxy(trustProxy)); + + return getTrustProxyConfig(); +} + +function parseForwardedFor(value: string | string[] | undefined): string[] { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values + .flatMap((item) => item.split(',')) + .map((item) => normalizeClientIp(item)) + .filter(Boolean); +} + +export async function diagnoseClientIp(req: Request) { + const remoteAddress = normalizeClientIp(req.socket.remoteAddress); + const forwardedFor = parseForwardedFor(req.headers['x-forwarded-for']); + const hopsFromApp = [remoteAddress, ...forwardedFor.slice().reverse()].filter( + Boolean, + ); + const trust = req.app.get('trust proxy fn') as + | ((ip: string, hop: number) => boolean) + | undefined; + + let selectedIndex = Math.max(hopsFromApp.length - 1, 0); + for (let index = 0; index < hopsFromApp.length - 1; index += 1) { + if (!trust?.(hopsFromApp[index], index)) { + selectedIndex = index; + break; + } + } + + const hops = hopsFromApp.map((ip, index) => ({ + ip, + hop: index, + status: + index < selectedIndex + ? 'trusted' + : index === selectedIndex + ? 'client' + : 'not_checked', + })); + + return { + ...(await getTrustProxyConfig()), + remoteAddress, + forwardedFor, + expressIps: req.ips.map(normalizeClientIp), + clientIp: normalizeClientIp(req.ip || req.socket.remoteAddress), + hops, + }; +} diff --git a/back/shared/utils.ts b/back/shared/utils.ts index fb344111..6760f4f9 100644 --- a/back/shared/utils.ts +++ b/back/shared/utils.ts @@ -4,7 +4,7 @@ import path from 'path'; import { writeFile, open, chmod } from 'fs/promises'; import { fileExist } from '../config/util'; -function getUniqueLockPath(filePath: string) { +export function getUniqueLockPath(filePath: string) { const sanitizedPath = filePath .replace(/[<>:"/\\|?*]/g, '_') .replace(/^_/, ''); diff --git a/back/token.ts b/back/token.ts index d307c623..e4400ca3 100755 --- a/back/token.ts +++ b/back/token.ts @@ -2,30 +2,19 @@ import 'reflect-metadata'; import OpenService from './services/open'; import { Container } from 'typedi'; import LoggerInstance from './loaders/logger'; -import config from './config'; -import path from 'path'; -import os from 'os'; -import { writeFileWithLock } from './shared/utils'; - -const tokenFile = path.join(config.configPath, 'token.json'); async function getToken() { try { Container.set('logger', LoggerInstance); const openService = Container.get(OpenService); - const appToken = await openService.generateSystemToken(); + const appToken = await openService.generateSystemToken( + process.argv.includes('--renew'), + ); console.log(appToken.value); - await writeFile({ - value: appToken.value, - expiration: appToken.expiration, - }); } catch (error) { - console.log(error); + console.error(error); + process.exitCode = 1; } } -async function writeFile(data: any) { - await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`); -} - getToken(); diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index d5ba6a54..c6422c00 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -1,5 +1,5 @@ import { Joi } from 'celebrate'; -import CronExpressionParser from 'cron-parser'; +import { isValidCronSchedule } from '../shared/cronSchedule'; import { ScheduleType } from '../interface/schedule'; import path from 'path'; import config from '../config'; @@ -12,13 +12,7 @@ const validateSchedule = (value: string, helpers: any) => { return value; } - try { - if (CronExpressionParser.parse(value).hasNext()) { - return value; - } - } catch (e) { - return helpers.error('any.invalid'); - } + if (isValidCronSchedule(value)) return value; return helpers.error('any.invalid'); }; @@ -34,7 +28,7 @@ export const commonCronSchema = { name: Joi.string().optional(), command: Joi.string().required(), schedule: scheduleSchema, - labels: Joi.array().optional(), + labels: Joi.array().optional().allow(null), sub_id: Joi.number().optional().allow(null), extra_schedules: Joi.array().optional().allow(null), task_before: Joi.string().optional().allow('').allow(null), @@ -65,7 +59,7 @@ export const commonCronSchema = { } if ( - !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( + !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test( value, ) ) { @@ -77,7 +71,7 @@ export const commonCronSchema = { return value; }) .messages({ - 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', + 'string.pattern.base': '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符', 'string.max': '日志名称不能超过100个字符', 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', }), diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..16fd4ff8 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +/dist/ +node_modules diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..dde8d7b4 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 WHYOUR . + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/cli/LOCAL.en.md b/cli/LOCAL.en.md new file mode 100644 index 00000000..0b7c822d --- /dev/null +++ b/cli/LOCAL.en.md @@ -0,0 +1,45 @@ +# Panel-internal TypeScript tools + +[简体中文](LOCAL.md) | **English** + + +The npm and panel-internal entries both use the name `ql`, but have separate Commander command trees. The npm entry only calls remote APIs; the internal entry only runs local tools. Verify the absolute executable path and `--help` before use. Installing the npm package does not migrate the built-in Shell commands. + +These tools ship with panel builds and are excluded from the @whyour/qinglong-cli npm package. Node >=22.12 is required; user configuration/hooks remain Bash and scripts need their interpreters. Development publishing was removed from the CLI; shell/pub.sh remains available to release workflows. + +```sh +npm ci --prefix cli +npm run build:cli +node cli/dist/ql.js --help +node cli/dist/ql.js task exec --root /ql demo.js now +node cli/dist/ql.js reload --root /ql +``` + +Run on the actual panel host or inside its container. For Docker use `docker exec ...`. A workstation npm CLI cannot reset a remote account; a mounted data directory alone is not the complete operational environment. + +| Capability | Internal commands | +| --- | --- | +| Execution/inventory | `ql task exec [options] [script]`, with `ql task ', + ); + const legacyUpload = await fetch( + `http://127.0.0.1:${server.address().port}/api/static/legacy.html`, + ); + assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff'); + assert.equal( + legacyUpload.headers.get('content-security-policy'), + "sandbox; default-src 'none'", + ); + for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) { + assert.equal( + (await request(`${prefix}/envs`, expired)).status, + 401, + prefix, + ); + assert.equal( + (await request(`${prefix}/envs`, valid)).body.code, + 200, + prefix, + ); + assert.equal((await request(`${prefix}/envs`)).status, 401, prefix); + } + assert.equal((await request('/open/envs', 'env-app')).body.code, 200); + assert.equal( + (await request('/open/configs/detail?path=normal.txt', 'env-app')).status, + 401, + ); + assert.equal( + (await request('/open/configs/detail?path=normal.txt', 'config-app')).body + .data, + 'normal', + ); + assert.equal( + (await request('/open/configs/detail?path=grpc/client.key', 'config-app')) + .body.code, + 403, + ); + assert.equal( + ( + await request('/open/configs/save', 'config-app', 'POST', { + name: 'grpc/client.key', + content: 'changed', + }) + ).body.code, + 403, + ); + assert.equal( + fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'), + 'SENTINEL', + ); + assert.equal( + ( + await request('/Api/user/init', undefined, 'PUT', { + username: 'x', + password: 'y', + }) + ).status, + 400, + ); + const scriptName = 'data/scripts/test.js'; + for (const prefix of ['/open', '/panel/open']) { + const denied = await request( + `${prefix}/configs/save`, + 'config-app', + 'POST', + { + name: scriptName, + content: 'overwrite', + }, + ); + assert.equal(denied.body.code, 403); + assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false); + } + assert.equal( + ( + await request('/open/configs/save', 'script-config-app', 'POST', { + name: scriptName, + content: 'authorized script', + }) + ).body.code, + 403, + ); + assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false); + assert.equal( + (await request(`/open/configs/detail?path=${scriptName}`, 'config-app')) + .body.code, + 403, + ); + assert.equal( + ( + await request('/open/configs/save', 'config-app', 'POST', { + name: 'normal.txt', + content: 'normal config', + }) + ).body.code, + 200, + ); + assert.equal( + ( + await request('/api/configs/save', valid, 'POST', { + name: scriptName, + content: 'owner script', + }) + ).body.code, + 403, + ); + for (const prefix of ['/open', '/panel/open']) { + assert.equal( + ( + await request(`${prefix}/scripts`, 'config-app', 'POST', { + filename: 'test.js', + content: 'unauthorized', + }) + ).status, + 401, + ); + assert.equal( + ( + await request(`${prefix}/scripts`, 'script-app', 'POST', { + filename: 'test.js', + content: 'authorized script', + }) + ).body.code, + 200, + ); + assert.equal( + fs.readFileSync(path.join(tmp, 'scripts/test.js'), 'utf8'), + 'authorized script', + ); + } +}); diff --git a/test/back/legacy-cron-recovery.test.cjs b/test/back/legacy-cron-recovery.test.cjs new file mode 100644 index 00000000..3a50a106 --- /dev/null +++ b/test/back/legacy-cron-recovery.test.cjs @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { format } = require('node:util'); +const load = require('../helpers/load-security-module.cjs'); +const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness'); + +const { getTasks } = require('node-cron'); + +const legacySchedules = [ + '* * * * *', + '*/5 * * * *', + '0 */10 * * * ?', + '0 */2 * * * ?', + '0 */5 * * * ?', + '0 0/30 * * * ?', + '0 0/5 * * * ?', + '0 0 12 ? * MON', +]; + +test('API accepts legacy question-mark schedules but rejects malformed cron', () => { + const { scheduleSchema } = load('back/validation/schedule.ts', { + '../config': { logPath: '/ql/data/log' }, + }); + for (const schedule of [...legacySchedules, '@once', '@boot']) { + assert.equal(scheduleSchema.validate(schedule).error, undefined, schedule); + } + for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?', 'not a cron']) { + assert.ok(scheduleSchema.validate(schedule).error, schedule); + } +}); + +test('legacy main and extra schedules restore real jobs and healthy readiness', async (t) => { + const stacks = new Map(); + t.after(() => { + for (const jobs of stacks.values()) for (const job of jobs) job.cancel(); + }); + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + '../shared/runCron': { runCron() {} }, + '../loaders/logger': { info() {}, warn() {} }, + '../shared/i18n': { tf: format }, + }); + const crons = legacySchedules.map((schedule, index) => ({ + id: `legacy-${index}`, name: 'legacy', schedule, command: 'true', + extra_schedules: [{ schedule: '0 0/30 * * * ?' }], + })); + const register = (rows) => new Promise((resolve, reject) => { + addCron({ request: { replace: true, crons: rows } }, (error) => + error ? reject(error) : resolve()); + }); + const readiness = new SchedulerReadiness(async () => {}, 60000); + t.after(() => clearTimeout(readiness.retry)); + readiness.configure(() => register(crons)); + assert.equal(await readiness.recover(), true); + assert.equal(await readiness.check(), true); + assert.equal(stacks.size, crons.length); + for (const jobs of stacks.values()) { + assert.equal(jobs.length, 2); + assert.ok(jobs.every((job) => typeof job.cancel === 'function')); + } + const nativeJobs = [...getTasks().values()]; + assert.equal(nativeJobs.length, crons.length * 2); + assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped')); + const previousJobs = [...stacks.values()].flat(); + for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?']) { + for (const invalid of [ + { ...crons[0], schedule }, + { ...crons[0], extra_schedules: [{ schedule }] }, + ]) { + await assert.rejects(register([invalid]), (error) => error.code === 3); + assert.deepEqual([...stacks.values()].flat(), previousJobs); + assert.deepEqual([...getTasks().values()], nativeJobs); + assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped')); + } + } +}); diff --git a/test/back/linux-mirror-result.test.cjs b/test/back/linux-mirror-result.test.cjs new file mode 100644 index 00000000..4945fb7d --- /dev/null +++ b/test/back/linux-mirror-result.test.cjs @@ -0,0 +1,176 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { spawn, spawnSync } = require('node:child_process'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); + +for (const scenario of [ + { + name: 'stderr warning with exit zero', + code: 'process.stderr.write("sudo: warning\\n")', + saved: true, + }, + { + name: 'nonzero exit without stderr', + code: 'process.exitCode = 7', + saved: false, + }, + { + name: 'signal termination', + code: 'process.kill(process.pid, "SIGTERM")', + saved: false, + }, + { name: 'spawn failure', shell: '/nonexistent-ql-test-shell', saved: false }, +]) { + test(`Linux mirror persistence follows the process result: ${scenario.name}`, async () => { + const logger = { info() {}, error() {} }; + const ScheduleService = load('back/services/schedule.ts', { + '../shared/cronScheduler': {}, + '../shared/pLimit': { runWithSystemLimit: async (_params, fn) => fn() }, + 'cross-spawn': { + spawn: () => + spawn( + process.execPath, + ['-e', scenario.code || ''], + scenario.shell ? { shell: scenario.shell } : {}, + ), + }, + }).default; + const schedule = new ScheduleService(logger); + let completion; + const runTask = schedule.runTask.bind(schedule); + schedule.runTask = (...args) => (completion = runTask(...args)); + const messages = [], + writes = []; + const SystemService = load('back/services/system.ts', { + os: { platform: () => 'linux' }, + '../config': {}, + '../config/const': {}, + '../config/util': { updateLinuxMirrorFile: async () => 'fixture' }, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': {}, + '../schedule/client': {}, + '../shared/pLimit': {}, + '../shared/i18n': {}, + '../shared/schedulerMutationLock': {}, + './notify': class {}, + './schedule': ScheduleService, + './sock': class {}, + }).default; + const service = new SystemService(logger, schedule, { + sendMessage: (message) => messages.push(message), + }); + service.getSystemConfig = async () => ({ id: 1, info: { lang: 'en-US' } }); + service.updateAuthDb = async (doc) => writes.push(doc); + let ended = 0; + await service.updateLinuxMirror( + { linuxMirror: 'https://mirror.example' }, + undefined, + () => ended++, + ); + const result = await completion; + assert.equal(ended, 1); + assert.equal(writes.length, scenario.saved ? 1 : 0); + assert.ok(messages.some((message) => message.status === 'completed')); + if (scenario.saved) { + assert.equal(result.code, 0); + assert.ok( + messages.some((message) => message.message.includes('sudo: warning')), + ); + assert.deepEqual(writes[0].info, { + lang: 'en-US', + linuxMirror: 'https://mirror.example', + }); + } + }); +} + +const hasApt = spawnSync('apt-get', ['--version']).status === 0; +for (const distribution of ['Debian', 'Ubuntu']) { + for (const existingMirror of [false, true]) { + test( + `${distribution} mirror update rejects failed index downloads with ${ + existingMirror ? 'an existing' : 'an empty' + } source`, + { skip: !hasApt }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-apt-result-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const parts = path.join(root, 'parts'); + await fs.mkdir(parts); + await fs.mkdir(path.join(root, 'lists', 'partial'), { + recursive: true, + }); + const source = path.join(root, 'sources.list'); + await fs.writeFile( + source, + 'deb http://127.0.0.1:1/debian bookworm main\n', + ); + // Load only fixture configuration, including all writable APT paths. + const aptConfig = path.join(root, 'apt.conf'); + await fs.writeFile( + aptConfig, + `Dir::Etc::main "/dev/null"; +Dir::Etc::parts "${parts}"; +Dir::Etc::sourcelist "${source}"; +Dir::Etc::sourceparts "${parts}"; +Dir::State::lists "${path.join(root, 'lists')}"; +Dir::Cache "${path.join(root, 'cache')}"; +Dir::Log "${path.join(root, 'logs')}"; +Acquire::Retries "0"; +Acquire::http::Proxy "DIRECT"; +Acquire::http::Timeout "1"; +`, + ); + const util = load('back/config/util.ts', { + 'fs/promises': { + readFile: async (file) => + file === '/etc/os-release' + ? distribution + : existingMirror + ? 'URIs: http://old.example/debian\n' + : '', + }, + os: { platform: () => 'linux' }, + './index': {}, + './const': {}, + './share': {}, + './container': { isInContainer: () => false }, + '../loaders/logger': {}, + '../shared/utils': {}, + '../shared/fileAccess': {}, + '../data/dependence': {}, + }); + const previousOS = process.env.QL_OS_TYPE; + let command; + try { + process.env.QL_OS_TYPE = distribution.toLowerCase(); + command = await util.updateLinuxMirrorFile('http://127.0.0.1:1'); + } finally { + if (previousOS === undefined) delete process.env.QL_OS_TYPE; + else process.env.QL_OS_TYPE = previousOS; + } + // Exercise the generated updater without modifying host source files. + const [program, ...args] = command + .slice(command.lastIndexOf('&&') + 2) + .trim() + .split(/\s+/); + assert.equal(program, 'apt-get'); + const options = { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, APT_CONFIG: aptConfig }, + }; + const baseline = spawnSync('apt-get', ['update'], options); + assert.equal(baseline.status, 0, baseline.stderr); + assert.match(baseline.stderr, /Failed to fetch/); + const result = spawnSync(program, args, options); + assert.equal(result.status, 100, result.stderr); + assert.match(result.stderr, /Failed to fetch/); + }, + ); + } +} diff --git a/test/back/listen-security.test.cjs b/test/back/listen-security.test.cjs new file mode 100644 index 00000000..2f73cf55 --- /dev/null +++ b/test/back/listen-security.test.cjs @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const logger = { debug() {}, warn() {}, error() {} }; + +for (const host of ['127.0.0.1', '::1', '192.0.2.1', '::']) { + test(`HTTP binding ${host} never broadens an explicit private address`, async () => { + const Http = load(path.join(__dirname, '../../back/services/http.ts'), { + '../config': { bindHost: host }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + }).HttpServerService; + const instance = new Http(); + const attempted = []; + instance.tryListen = async (_app, _port, address) => { + attempted.push(address); + throw new Error('unavailable'); + }; + await assert.rejects(instance.initialize({}, 5700)); + assert.deepEqual(attempted, host === '::' ? ['::', '0.0.0.0'] : [host]); + }); + test(`gRPC binding ${host} never broadens an explicit private address`, async () => { + const attempted = []; + const Grpc = load(path.join(__dirname, '../../back/services/grpc.ts'), { + '../config': { bindHostGrpc: host, grpcPort: 5500 }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + typedi: { Service: () => (x) => x }, + '@grpc/grpc-js': { + Server: class { + addService() {} + bindAsync(address, _credentials, cb) { + attempted.push(address); + cb(new Error('unavailable')); + } + }, + ServerCredentials: { + createSsl(_ca, _certs, requireClientCert) { + assert.equal(requireClientCert, true); + return {}; + }, + }, + }, + '../protos/cron': { CronService: {} }, + '../protos/health': { HealthService: {} }, + '../protos/api': { ApiService: {} }, + '../schedule/addCron': {}, + '../schedule/delCron': {}, + '../schedule/setConcurrency': {}, + '../schedule/health': {}, + '../schedule/api': {}, + '../config/grpcCerts': { + initGrpcCerts: async () => ({ + caCert: 'test', + serverCert: 'test', + serverKey: 'test', + }), + }, + }).GrpcServerService; + await assert.rejects(new Grpc().initialize()); + assert.deepEqual( + attempted, + host === '::' ? ['[::]:5500', '0.0.0.0:5500'] : [`${host}:5500`], + ); + }); +} diff --git a/test/back/log-path-security.test.cjs b/test/back/log-path-security.test.cjs new file mode 100644 index 00000000..b925b977 --- /dev/null +++ b/test/back/log-path-security.test.cjs @@ -0,0 +1,158 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +async function fixture(t) { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-boundary-')); + t.after(() => fs.rm(base, { recursive: true, force: true })); + const root = path.join(base, 'log'); + const outside = path.join(base, 'log-other'); + await fs.mkdir(root); + await fs.mkdir(outside); + const victim = path.join(outside, 'victim.log'); + await fs.writeFile(victim, 'unchanged'); + await fs.symlink(outside, path.join(root, 'escape-dir')); + await fs.symlink(victim, path.join(root, 'escape-file')); + await fs.symlink( + path.join(outside, 'missing.log'), + path.join(root, 'dangling'), + ); + const invalid = [ + path.join(root, '..', 'log-other', 'new.log'), + path.join(root, 'escape-dir', 'new.log'), + path.join(root, 'escape-file'), + path.join(root, 'dangling'), + root, + path.join(root, 'bad\0name'), + ]; + return { root, outside, victim, invalid }; +} + +test('log streams reject traversal, sibling prefixes and escaping symlinks before writing', async (t) => { + const { root, outside, victim, invalid } = await fixture(t); + const manager = new LogStreamManager(root); + for (const target of invalid) { + await assert.rejects( + manager.write(target, 'overwrite'), + /outside the log directory/, + ); + await assert.rejects( + manager.closeStream(target), + /outside the log directory/, + ); + assert.equal(manager.getOpenStreamCount(), 0); + } + assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged'); + assert.deepEqual(await fs.readdir(outside), ['victim.log']); + const folder = path.join(root, '中文 日志'); + await fs.mkdir(folder); + const log = path.join(folder, 'task.log'); + await Promise.all([ + manager.write(log, '开始\n'), + manager.write(log, '结束\n'), + ]); + await manager.closeAll(); + assert.equal(await fs.readFile(log, 'utf8'), '开始\n结束\n'); +}); + +test('log initialization rejects unsafe paths before mkdir or file writes', async (t) => { + const { root, outside, victim, invalid } = await fixture(t); + const { handleLogPath } = load(path.resolve('back/config/util.ts'), { + './index': { logPath: root }, + './share': {}, + '../loaders/logger': {}, + '../shared/utils': { + writeFileWithLock: (file, data) => fs.writeFile(file, data), + }, + '../data/dependence': { DependenceTypes: {} }, + }); + for (const target of invalid) { + await assert.rejects( + handleLogPath(target, 'overwrite'), + /outside the log directory/, + ); + } + await assert.rejects( + handleLogPath('../log-other/new/sub/task.log', 'overwrite'), + /outside the log directory/, + ); + assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged'); + assert.deepEqual(await fs.readdir(outside), ['victim.log']); + const log = await handleLogPath('中文 日志/nested/task.log', 'initial'); + assert.equal(await fs.readFile(log, 'utf8'), 'initial'); + assert.equal(await handleLogPath(log, 'ignored'), log); + assert.equal(await fs.readFile(log, 'utf8'), 'initial'); +}); + +test('manual execution rejects escaping log names before creating directories or spawning', async (t) => { + const { root, outside, victim } = await fixture(t); + let spawned = 0; + let releases = 0; + const errors = []; + const CronService = load(path.resolve('back/services/cron.ts'), { + '../config': { logPath: root }, + '../data/cron': { + CrontabStatus: { queued: 3, idle: 1 }, + CrontabModel: { update: async () => {} }, + }, + '../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} }, + '../config/util': {}, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: async (fn) => { + try { + return await fn(); + } finally { + releases++; + } + }, + }, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': { + logStreamManager: { closeStream: async () => {} }, + }, + 'cross-spawn': { + spawn: () => { + spawned++; + throw new Error('must not spawn'); + }, + }, + }).default; + const service = new CronService({ + info() {}, + error: (...args) => errors.push(args), + }); + for (const log_name of [ + '../log-other/new', + outside, + 'escape-dir/new', + 'dangling', + 'bad\0name', + ]) { + service.getDb = async () => ({ + id: 1, + status: 3, + command: 'ignored', + log_path: '', + log_name, + }); + await service.runSingle(1); + } + assert.equal(spawned, 0); + assert.equal(releases, 5); + assert.equal( + errors.filter((args) => + args.includes('Log path is outside the log directory'), + ).length, + 5, + ); + assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged'); + assert.deepEqual(await fs.readdir(outside), ['victim.log']); +}); diff --git a/test/back/logReader.test.cjs b/test/back/logReader.test.cjs new file mode 100644 index 00000000..3df41924 --- /dev/null +++ b/test/back/logReader.test.cjs @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + DEFAULT_LOG_CHUNK_BYTES, + MAX_LOG_CHUNK_BYTES, + readLogChunk, +} = require('../../back/shared/logReader'); + +test('log reader defaults to a bounded tail and supports incremental reads', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-')); + const file = path.join(directory, 'task.log'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + + const initial = 'a'.repeat(DEFAULT_LOG_CHUNK_BYTES * 2); + await fs.writeFile(file, initial); + + const tail = await readLogChunk(file); + assert.equal(tail.content.length, DEFAULT_LOG_CHUNK_BYTES); + assert.equal(tail.offset, DEFAULT_LOG_CHUNK_BYTES); + assert.equal(tail.nextOffset, initial.length); + assert.equal(tail.truncated, true); + + await fs.appendFile(file, 'next'); + const incremental = await readLogChunk(file, { offset: tail.nextOffset }); + assert.equal(incremental.content, 'next'); + assert.equal(incremental.nextOffset, initial.length + 4); +}); + +test('log reader enforces the maximum chunk size', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-')); + const file = path.join(directory, 'large.log'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.writeFile(file, 'x'.repeat(MAX_LOG_CHUNK_BYTES + 1024)); + + const chunk = await readLogChunk(file, { limit: Number.MAX_SAFE_INTEGER }); + assert.equal(Buffer.byteLength(chunk.content), MAX_LOG_CHUNK_BYTES); +}); + +test('log reader preserves UTF-8 characters across byte boundaries', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-')); + const file = path.join(directory, 'utf8.log'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.writeFile(file, 'a'.repeat(5) + '中文日志'); + + const first = await readLogChunk(file, { offset: 0, limit: 7 }); + const second = await readLogChunk(file, { + offset: first.nextOffset, + limit: 7, + }); + const third = await readLogChunk(file, { + offset: second.nextOffset, + limit: 7, + }); + + assert.equal(first.content + second.content + third.content, 'aaaaa中文日志'); + assert.equal((first.content + second.content + third.content).includes('�'), false); +}); + +test('missing logs return an empty chunk', async () => { + const chunk = await readLogChunk('/path/that/does/not/exist.log'); + assert.deepEqual(chunk, { + content: '', + offset: 0, + nextOffset: 0, + total: 0, + truncated: false, + }); +}); diff --git a/test/back/manual-execution.test.cjs b/test/back/manual-execution.test.cjs new file mode 100644 index 00000000..d60897e2 --- /dev/null +++ b/test/back/manual-execution.test.cjs @@ -0,0 +1,85 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { spawn } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +test( + 'manual execution captures a short child before slow status storage and flushes before release', + { timeout: 5000 }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const logs = new LogStreamManager(root); + const updates = []; + let active = 0; + let failSpawn = false; + const CronService = load(path.resolve('back/services/cron.ts'), { + '../config': { logPath: root }, + '../data/cron': { + CrontabStatus: { queued: 3, running: 0, idle: 1 }, + CrontabModel: { + update: async (data, options) => { + await new Promise((resolve) => setTimeout(resolve, 40)); + updates.push({ data, options }); + return [1]; + }, + }, + }, + '../data/runningInstance': { + RunningInstanceModel: {}, + InstanceStatus: {}, + }, + '../config/util': { getUniqPath: async () => 'task' }, + '../config/const': { TASK_PREFIX: 'task ', QL_PREFIX: 'ql ' }, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: async (fn) => { + active++; + try { + return await fn(); + } finally { + active--; + } + }, + }, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': { logStreamManager: logs }, + 'cross-spawn': { + spawn: () => + failSpawn + ? spawn('true', { shell: '/nonexistent-ql-manual-shell' }) + : spawn(process.execPath, [ + '-e', + 'process.stdout.write("末尾\\n")', + ]), + }, + }).default; + const service = new CronService({ info() {}, error() {} }); + service.getDb = async () => ({ + id: 1, + status: 3, + command: 'ignored', + log_path: '', + }); + service.makeCommand = () => 'ignored'; + await service.runSingle(1); + const [file] = await fs.readdir(path.join(root, 'task')); + assert.equal( + await fs.readFile(path.join(root, 'task', file), 'utf8'), + '末尾\n', + ); + assert.equal(logs.getOpenStreamCount(), 0); + assert.equal(active, 0); + assert.equal(updates.at(-1).data.status, 1); + failSpawn = true; + await service.runSingle(1); + assert.equal(active, 0); + assert.equal(updates.at(-1).data.status, 1); + }, +); diff --git a/test/back/manual-stop-claim.test.cjs b/test/back/manual-stop-claim.test.cjs new file mode 100644 index 00000000..8597c236 --- /dev/null +++ b/test/back/manual-stop-claim.test.cjs @@ -0,0 +1,220 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); +const { killTask } = require('../../back/config/util'); +const { LogStreamManager } = require('../../back/shared/logStreamManager'); + +for (const conflict of ['stop', 'newer-queue', 'stop-requeue']) { + test( + `manual startup loses its conditional claim after ${conflict} and terminates the late child`, + { timeout: 10000 }, + async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-claim-')); + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(async () => { + await db.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + const crons = db.define('Cron', { + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, + command: DataTypes.STRING, + }); + const instances = db.define('Instance', { + cron_id: DataTypes.INTEGER, + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + }); + await db.sync(); + await crons.create({ + id: 1, + status: 3, + pid: null, + log_path: 'previous.log', + command: 'ignored', + }); + let release, + entered, + child, + released = 0; + const pending = new Promise((resolve) => (entered = resolve)); + const gate = new Promise((resolve) => (release = resolve)); + const logs = new LogStreamManager(root); + t.after(() => logs.closeAll()); + const CronService = load('back/services/cron.ts', { + '../config': { logPath: root }, + '../data/cron': { + CrontabModel: crons, + CrontabStatus: { queued: 3, running: 0, idle: 1 }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, stopped: 2 }, + }, + '../config/util': { + getUniqPath: async () => { + entered(); + await gate; + return 'task'; + }, + killTask, + }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: async (fn) => { + try { + return await fn(); + } finally { + released++; + } + }, + }, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': { logStreamManager: logs }, + 'cross-spawn': { + spawn: () => { + child = spawn(process.execPath, [ + '-e', + 'setInterval(() => {}, 1000)', + ]); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + return child; + }, + }, + }).default; + const service = new CronService({ info() {}, error() {} }); + service.getDb = async () => + (await crons.findByPk(1)).get({ plain: true }); + service.makeCommand = () => 'ignored'; + const running = service.runSingle(1); + await pending; + if (conflict === 'stop-requeue') { + await service.stop([1]); + // Queue again through the real API; leave the new runner pending. + const pendingRuns = []; + service.runSingle = (id, token) => pendingRuns.push({ id, token }); + await service.run([1]); + assert.equal(pendingRuns.length, 1); + assert.equal( + pendingRuns[0].token, + (await crons.findByPk(1)).queued_token, + ); + } else if (conflict === 'stop') await service.stop([1]); + else await crons.update({ log_path: 'newer.log' }, { where: { id: 1 } }); + release(); + const result = await running; + assert.match(result.error.message, /stopped or superseded/); + assert.ok(child.signalCode || child.exitCode !== null); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + const row = await crons.findByPk(1); + assert.equal(row.status, conflict === 'stop' ? 1 : 3); + assert.equal( + row.log_path, + conflict === 'newer-queue' ? 'newer.log' : 'previous.log', + ); + assert.equal(row.pid, null); + assert.equal(released, 1); + }, + ); +} + +test( + 'verified termination waits for a SIGTERM-resistant target to exit', + { timeout: 5000 }, + async (t) => { + const child = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM",()=>{}); process.stdout.write("ready"); setInterval(()=>{},1000)', + ]); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + const exited = once(child, 'exit'); + await new Promise((resolve) => child.stdout.once('data', resolve)); + await killTask(child.pid, true); + // Linux may report an exited zombie before Node reaps our child. Wait for + // that separate event before requiring the PID to disappear. + await exited; + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); + assert.equal(child.signalCode, 'SIGKILL'); + }, +); + +test('a runner waiting for a concurrency slot cannot adopt a newer queued generation', async (t) => { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const crons = db.define('Cron', { + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, + }); + await db.sync(); + await crons.create({ id: 1, status: 1, log_path: 'same.log' }); + const waiting = []; + const CronService = load('back/services/cron.ts', { + '../config': {}, + '../data/cron': { + CrontabModel: crons, + CrontabStatus: { queued: 3, idle: 1, running: 0 }, + }, + '../data/runningInstance': { + RunningInstanceModel: { findAll: async () => [] }, + InstanceStatus: { running: 0 }, + }, + '../config/util': { + getUniqPath: () => assert.fail('stale runner must not prepare a child'), + }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': { + manualRunWithCronLimit: (fn) => { + waiting.push(fn); + return Promise.resolve(); + }, + }, + '../shared/utils': {}, + '../shared/i18n': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + 'cross-spawn': { spawn: () => assert.fail('stale runner must not spawn') }, + }).default; + const errors = []; + const service = new CronService({ error: (...args) => errors.push(args) }); + await service.run([1]); + const first = (await crons.findByPk(1)).queued_token; + await service.stop([1]); + await service.run([1]); + const second = (await crons.findByPk(1)).queued_token; + assert.notEqual(first, second); + assert.equal(waiting.length, 2); + await waiting[0](); + const row = await crons.findByPk(1); + assert.equal(row.status, 3); + assert.equal(row.queued_token, second); + assert.equal(row.log_path, 'same.log'); + assert.deepEqual(errors, []); +}); diff --git a/test/back/metrics.test.cjs b/test/back/metrics.test.cjs new file mode 100644 index 00000000..d1f45f45 --- /dev/null +++ b/test/back/metrics.test.cjs @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + MAX_METRIC_SAMPLES, + metricsService, +} = require('../../back/services/metrics'); + +test('metrics keep only the newest bounded samples', () => { + const sampleCount = MAX_METRIC_SAMPLES * 50; + for (let index = 0; index < sampleCount; index++) { + metricsService.record('http_request', index, { + path: `/health/${index}`, + }); + } + + const result = metricsService.getMetrics('http_request'); + assert.equal(result.count, MAX_METRIC_SAMPLES); + assert.equal(result.metrics[0].value, sampleCount - MAX_METRIC_SAMPLES); + assert.equal(result.metrics.at(-1).value, sampleCount - 1); +}); + +test('empty metric queries return finite aggregates', () => { + const result = metricsService.getMetrics('missing_metric'); + assert.deepEqual( + { + count: result.count, + average: result.average, + min: result.min, + max: result.max, + }, + { count: 0, average: 0, min: 0, max: 0 }, + ); +}); diff --git a/test/back/mirror-status.test.cjs b/test/back/mirror-status.test.cjs new file mode 100644 index 00000000..e0eec3fe --- /dev/null +++ b/test/back/mirror-status.test.cjs @@ -0,0 +1,257 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawn, spawnSync } = require('node:child_process'); + +const script = path.resolve('scripts/wait-for-mirror.sh'); +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); +const options = (env = {}) => ({ + encoding: 'utf8', + env: { + ...process.env, + QL_MIRROR_WAIT_SECONDS: '3', + QL_MIRROR_POLL_INTERVAL: '1', + QL_MIRROR_QUERY_TIMEOUT: '1', + ...env, + }, +}); + +function fixture(t) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-status-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'); + const mirror = path.join(tmp, 'mirror.git'); + git('init', source); + git('init', '--bare', mirror); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + git('-C', source, 'checkout', '-b', 'develop'); + fs.writeFileSync(path.join(source, 'snapshot'), 'first'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'first'); + return { tmp, source, mirror }; +} + +test('mirror verification reads matching refs without changing either repository', (t) => { + const { source, mirror } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + const before = git('ls-remote', '--refs', mirror); + const result = spawnSync( + 'bash', + [script, source, mirror, 'develop'], + options(), + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /Mirror matches the latest source refs/); + assert.equal(git('ls-remote', '--refs', mirror), before); + assert.equal(git('-C', source, 'status', '--porcelain'), ''); +}); + +test('mirror polling follows a source that advances while waiting', async (t) => { + const { source, mirror } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + fs.writeFileSync(path.join(source, 'snapshot'), 'second'); + git('-C', source, 'commit', '-am', 'second'); + const child = spawn( + 'bash', + [script, source, mirror, 'develop'], + options({ + QL_MIRROR_WAIT_SECONDS: '6', + }), + ); + let output = ''; + let error = ''; + let moved = false; + child.stderr.on('data', (data) => { + error += data; + }); + child.stdout.on('data', (data) => { + output += data; + if (!moved && output.includes('Waiting for mirror')) { + moved = true; + fs.writeFileSync(path.join(source, 'snapshot'), 'third'); + git('-C', source, 'commit', '-am', 'third'); + git('-C', source, 'push', mirror, 'develop'); + } + }); + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + assert.equal(status, 0, error + output); + assert.ok(moved); + assert.match(output, new RegExp(git('-C', source, 'rev-parse', 'develop'))); +}); + +test('empty or missing refs never count as a synchronized mirror', (t) => { + const { source, mirror } = fixture(t); + for (const ref of ['develop', 'missing']) { + const result = spawnSync( + 'bash', + [script, source, mirror, ref], + // Leave time to read both refs before checking the missing-ref diagnostic. + options(), + ); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.match(result.stdout, /is missing refs\/heads\//); + assert.match(result.stdout, /::error::Mirror did not match/); + } +}); + +test('static releases require both the version branch and annotated tag object', (t) => { + const { source, mirror } = fixture(t); + git('-C', source, 'branch', 'v2.22.0'); + git('-C', source, 'tag', '-a', 'v2.22.0', '-m', 'release'); + git('-C', source, 'push', mirror, 'refs/tags/v2.22.0'); + const args = [script, source, mirror, 'v2.22.0', 'tag']; + const env = { + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true', + QL_MIRROR_WAIT_SECONDS: '3', + }; + const partial = spawnSync('bash', args, options(env)); + assert.equal(partial.status, 1, partial.stderr + partial.stdout); + assert.match(partial.stdout, /mirror is missing refs\/heads\/v2.22.0/); + git('-C', source, 'push', mirror, 'refs/heads/v2.22.0'); + const complete = spawnSync('bash', args, options(env)); + assert.equal(complete.status, 0, complete.stderr + complete.stdout); + assert.match( + complete.stdout, + new RegExp(git('-C', source, 'rev-parse', 'refs/tags/v2.22.0')), + ); + // A tag pointing to the same commit with different annotation is still stale. + git('-C', source, 'tag', '-fa', 'v2.22.0', '-m', 'updated annotation'); + const stale = spawnSync('bash', args, options(env)); + assert.equal(stale.status, 1, stale.stderr + stale.stdout); +}); + +test('network failures and hung ref queries stop within the configured deadline', (t) => { + const { tmp, source, mirror } = fixture(t); + const failure = spawnSync( + 'bash', + [script, source, path.join(tmp, 'absent'), 'develop'], + options({ + QL_MIRROR_WAIT_SECONDS: '3', + }), + ); + assert.equal(failure.status, 1, failure.stderr + failure.stdout); + assert.match(failure.stdout, /Could not read mirror refs/); + + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim(); + fs.writeFileSync( + path.join(bin, 'git'), + '#!/bin/sh\nif [ "$1" = ls-remote ]; then sleep 30; fi\nexec "$QL_TEST_GIT" "$@"\n', + { mode: 0o755 }, + ); + const started = Date.now(); + const hung = spawnSync('bash', [script, source, mirror, 'develop'], { + ...options({ + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + QL_TEST_GIT: realGit, + QL_MIRROR_WAIT_SECONDS: '2', + QL_MIRROR_QUERY_TIMEOUT: '20', + }), + timeout: 5000, + }); + assert.equal(hung.status, 1, hung.stderr + hung.stdout); + assert.ok(Date.now() - started < 4500); + assert.match(hung.stdout, /within 2s/); +}); + +test('invalid mirror parameters fail before polling', (t) => { + const { source, mirror } = fixture(t); + for (const args of [ + [], + [source, mirror, 'develop', 'other'], + [source, mirror, 'bad ref'], + ]) { + assert.notEqual(spawnSync('bash', [script, ...args], options()).status, 0); + } + for (const value of ['0', '-1', '1.5', '1+2', '9999999999999']) { + const result = spawnSync( + 'bash', + [script, source, mirror, 'develop'], + options({ + QL_MIRROR_WAIT_SECONDS: value, + }), + ); + assert.equal(result.status, 2, result.stderr + result.stdout); + } +}); + +test('Gitee workflows verify both mirrors and do not gate image publication', () => { + const workflow = require('js-yaml').load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + assert.equal(workflow.jobs.code_gitee, undefined); + assert.equal(workflow.jobs.static_gitee, undefined); + const job = workflow.jobs['verify-gitee-mirrors']; + assert.equal(job.needs, 'build-static'); + assert.equal(job['timeout-minutes'], 14); + assert.deepEqual(job.permissions, { contents: 'write' }); + assert.deepEqual(job.strategy.matrix.repository, [ + 'qinglong', + 'qinglong-static', + ]); + const verify = job.steps.find((step) => step.run); + assert.equal(verify.env.MIRROR_REF, '${{ github.ref_name }}'); + assert.equal(verify.env.MIRROR_REF_TYPE, '${{ github.ref_type }}'); + assert.equal( + verify.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, + "${{ matrix.repository == 'qinglong-static' }}", + ); + assert.match(verify.run, /bash scripts\/wait-for-mirror.sh/); + assert.equal(verify.if, "${{ matrix.repository == 'qinglong' }}"); + assert.equal(verify.env.GH_TOKEN, '${{ github.token }}'); + assert.equal(verify.env.QL_MIRROR_NOTIFY_ON_START, 'false'); + assert.equal(verify.env.QL_MIRROR_NOTIFY_REPO, 'whyour/qinglong'); + assert.doesNotMatch( + JSON.stringify(verify), + /secrets\.|git push|sync-static|ssh-keyscan/, + ); + const notify = job.steps.find((step) => + /Notify and wait/.test(step.name || ''), + ); + assert.equal(notify.if, "${{ matrix.repository == 'qinglong-static' }}"); + assert.equal(notify.env.GH_TOKEN, '${{ secrets.API_TOKEN }}'); + assert.match(notify.run, /bash scripts\/wait-for-mirror.sh/); + assert.equal(notify.env.QL_MIRROR_NOTIFY_REPO, 'whyour/qinglong-static'); + assert.equal(notify.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, 'true'); + assert.doesNotMatch( + JSON.stringify(job), + /GITEE_TOKEN|git@gitee|sync-static|ssh-keyscan/, + ); + const backfill = workflow.jobs['verify-existing-static-gitee']; + assert.match(backfill.if, /workflow_dispatch.*inputs.static_sync_only/); + assert.equal(backfill.needs, undefined); + const verifyExisting = backfill.steps.find((step) => step.run); + assert.equal(verifyExisting.env.MIRROR_REF, '${{ inputs.static_ref }}'); + assert.equal( + verifyExisting.env.MIRROR_REF_TYPE, + '${{ inputs.static_ref_type }}', + ); + assert.equal(verifyExisting.env.GH_TOKEN, '${{ secrets.API_TOKEN }}'); + assert.match(verifyExisting.run, /wait-for-mirror.sh/); + assert.equal( + verifyExisting.env.QL_MIRROR_NOTIFY_REPO, + 'whyour/qinglong-static', + ); + assert.equal(verifyExisting.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, 'true'); + assert.deepEqual(workflow.on.push.branches, ['master', 'develop']); + assert.doesNotMatch( + JSON.stringify(backfill), + /GITEE_TOKEN|git@gitee|sync-static|ssh-keyscan/, + ); + for (const name of [ + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) { + assert.equal(workflow.jobs[name].needs, 'build-static'); + } +}); diff --git a/test/back/monitoring.test.cjs b/test/back/monitoring.test.cjs new file mode 100644 index 00000000..eec34833 --- /dev/null +++ b/test/back/monitoring.test.cjs @@ -0,0 +1,73 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { monitoringMiddleware } = require('../../back/middlewares/monitoring'); +const { metricsService } = require('../../back/services/metrics'); + +function createResponse() { + return { + statusCode: 200, + ended: false, + end() { + this.ended = true; + }, + }; +} + +test('health probes bypass request metric retention with or without base URL', () => { + const before = metricsService.getMetrics('http_request', { + path: '/api/health', + }).count; + let nextCalled = false; + + for (const path of ['/api/health', '/ql/api/health']) { + const response = createResponse(); + monitoringMiddleware({ method: 'GET', path }, response, () => { + nextCalled = true; + }); + response.end(); + assert.equal(response.ended, true); + } + + const after = metricsService.getMetrics('http_request', { + path: '/api/health', + }).count; + assert.equal(nextCalled, true); + assert.equal(after, before); +}); + +test('non-health requests keep bounded service metrics', () => { + const path = '/api/monitoring-test'; + const before = metricsService.getMetrics('http_request', { path }).count; + const response = createResponse(); + + monitoringMiddleware( + { method: 'GET', path, platform: 'desktop' }, + response, + () => {}, + ); + response.end(); + + const after = metricsService.getMetrics('http_request', { path }).count; + assert.equal(response.ended, true); + assert.equal(after, before + 1); +}); + +test('ordinary request metrics are sampled instead of retained per request', () => { + const path = '/api/monitoring-sampling-test'; + const before = metricsService.getMetrics('http_request', { path }).count; + + for (let index = 0; index < 30; index += 1) { + const response = createResponse(); + monitoringMiddleware( + { method: 'GET', path, platform: 'desktop' }, + response, + () => {}, + ); + response.end(); + } + + const recorded = + metricsService.getMetrics('http_request', { path }).count - before; + assert.ok(recorded >= 2 && recorded <= 3); +}); diff --git a/test/back/node-cron-migration.test.cjs b/test/back/node-cron-migration.test.cjs new file mode 100644 index 00000000..1b2f70d9 --- /dev/null +++ b/test/back/node-cron-migration.test.cjs @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const load = require('../helpers/load-security-module.cjs'); +const { createCronJob } = require('../../back/shared/cronScheduler'); +const logger = { info() {}, warn() {}, error() {} }; + +test('native calendar skips nonexistent spring time and duplicate fall hour', async (t) => { + const previous = process.env.TZ; + process.env.TZ = 'America/New_York'; + t.after(() => { if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous; }); + t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-03-08T05:00:00Z') }); + const springRuns = []; + const spring = createCronJob('0 30 2 * * *', (date) => springRuns.push(date.toISOString()), { name: 'spring', logger }); + t.after(() => spring.cancel()); + t.mock.timers.tick(new Date('2026-03-09T06:30:00Z') - Date.now()); + await new Promise(setImmediate); + assert.deepEqual(springRuns, ['2026-03-09T06:30:00.000Z']); + spring.cancel(); + t.mock.timers.setTime(new Date('2026-11-01T05:30:00Z').getTime()); + const fallRuns = []; + const fall = createCronJob('0 30 1 * * *', (date) => fallRuns.push(date.toISOString()), { name: 'fall', logger }); + t.after(() => fall.cancel()); + t.mock.timers.tick(new Date('2026-11-02T06:30:00Z') - Date.now()); + await new Promise(setImmediate); + assert.deepEqual(fallRuns, ['2026-11-02T06:30:00.000Z']); +}); + +test('construction failure cleans staged jobs and preserves the running snapshot', async () => { + const cancelled = []; + const stacks = new Map([['old', [{ cancel: () => cancelled.push('old') }]]]); + let count = 0; + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + '../shared/cronScheduler': { createCronJob: () => { + if (++count === 2) throw Error('construction failed'); + return { start() { throw Error('must not start'); }, cancel: () => cancelled.push('staged') }; + } }, + '../shared/runCron': {}, '../loaders/logger': logger, '../shared/i18n': { tf: require('node:util').format }, + }); + await assert.rejects(new Promise((resolve, reject) => addCron({ request: { + replace: true, crons: [{ id: 'new', schedule: '* * * * *', extra_schedules: [{ schedule: '0 * * * *' }] }], + } }, (error) => error ? reject(error) : resolve())), /construction failed/); + assert.deepEqual([...stacks.keys()], ['old']); + assert.deepEqual(cancelled, ['staged']); +}); + +test('invalid subscription schedules are isolated and immediate runs without a schedule survive', async () => { + const warnings = [], runs = []; + const Service = load('back/services/schedule.ts', { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../shared/pLimit': {}, + }).default; + const service = new Service({ ...logger, warn: (...args) => warnings.push(args) }); + service.runTask = async (...args) => { runs.push(args); }; + const item = { id: 123, name: 'subscription', command: 'true', runOrigin: 'subscription' }; + await service.createCronTask({ ...item, schedule: 'not a cron' }); + assert.equal(warnings.length, 1); + assert.equal(service.scheduleStacks.size, 0); + await service.createCronTask(item, {}, true); + assert.equal(runs.length, 1); + await service.createCronTask({ ...item, schedule: '* * * * *' }); + assert.equal(service.scheduleStacks.size, 1); + await service.cancelCronTask(item); + assert.equal(service.scheduleStacks.size, 0); +}); diff --git a/test/back/node-path-cache.test.cjs b/test/back/node-path-cache.test.cjs new file mode 100644 index 00000000..06f272d2 --- /dev/null +++ b/test/back/node-path-cache.test.cjs @@ -0,0 +1,202 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync, spawn } = require('node:child_process'); +const helper = path.resolve('shell/node_path_cache.sh'); +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-path-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.symlinkSync(process.execPath, path.join(bin, 'node')); + const calls = path.join(root, 'calls'); + fs.writeFileSync( + path.join(bin, 'pnpm'), + '#!/bin/bash\necho call >> "$CALLS"\n[[ "${FAIL:-0}" == 1 ]] && exit 1\nprintf "%s\\n" "${ANSWER:-/test/global/node_modules}"\n', + { mode: 0o755 }, + ); + const env = { + ...process.env, + HOME: root, + PATH: `${bin}:${process.env.PATH}`, + dir_tmp: path.join(root, 'cache'), + CALLS: calls, + HELPER: helper, + }; + const command = '. "$HELPER"; ql_get_node_global_path'; + const run = (extra = {}, code = command) => + spawnSync('/bin/bash', ['-uc', code], { + cwd: root, + env: { ...env, ...extra }, + encoding: 'utf8', + }); + const count = () => + fs.existsSync(calls) + ? fs.readFileSync(calls, 'utf8').trim().split('\n').length + : 0; + return { root, bin, env, command, run, count }; +} +test('warm cache avoids pnpm, preserves spaces, and never evaluates cached text', (t) => { + const f = fixture(t); + const answer = '/test/path with spaces/$(touch injected)'; + assert.equal(f.run({ ANSWER: answer }).stdout.trim(), answer); + assert.equal(f.run({ ANSWER: '/different' }).stdout.trim(), answer); + assert.equal(f.count(), 1); + assert.equal(fs.existsSync(path.join(f.root, 'injected')), false); +}); +test('config, environment, cwd, and executable changes invalidate discovery', (t) => { + const f = fixture(t); + f.run(); + fs.writeFileSync(path.join(f.root, '.npmrc'), 'global-dir=/other\n'); + f.run(); + assert.equal(f.count(), 2); + f.run({ npm_config_global_dir: '/third' }); + assert.equal(f.count(), 3); + f.run({ PNPM_HOME: '/fourth' }); + assert.equal(f.count(), 4); + fs.mkdirSync(path.join(f.root, 'child')); + f.run({}, 'cd child; ' + f.command); + assert.equal(f.count(), 5); + f.run(); + const before = f.count(); + fs.appendFileSync(path.join(f.bin, 'pnpm'), '# upgrade\n'); + f.run(); + assert.equal(f.count(), before + 1); +}); +test('alternating Node/crond working directories retain independent cached roots', (t) => { + const f = fixture(t); + fs.mkdirSync(path.join(f.root, 'child')); + fs.writeFileSync(path.join(f.root, 'child', '.npmrc'), 'global-dir=/child\n'); + for (let i = 0; i < 4; i++) { + assert.equal(f.run({ ANSWER: '/parent/modules' }).stdout.trim(), '/parent/modules'); + assert.equal( + f.run({ ANSWER: '/child/modules' }, 'cd child; ' + f.command).stdout.trim(), + '/child/modules', + ); + } + assert.equal(f.count(), 2, 'each working directory should discover only once'); + assert.equal(fs.readdirSync(f.env.dir_tmp).filter((x) => x.endsWith('.cache')).length, 2); +}); +test('refresh retires old per-environment records without removing locks or unrelated files', (t) => { + const f = fixture(t); + fs.mkdirSync(f.env.dir_tmp); + const prefix = `pnpm-root-${process.getuid()}`; + const names = [`${prefix}-1-2.cache`, `${prefix}.lock`, 'unrelated.cache']; + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + for (const name of names) { + const file = path.join(f.env.dir_tmp, name); + fs.writeFileSync(file, ''); + fs.utimesSync(file, old, old); + } + assert.equal(f.run().status, 0); + assert.equal(fs.existsSync(path.join(f.env.dir_tmp, names[0])), false); + for (const name of names.slice(1)) assert.ok(fs.existsSync(path.join(f.env.dir_tmp, name))); +}); +test('expired or malformed records refresh and failed lookups are not cached', (t) => { + const f = fixture(t); + f.run(); + const file = path.join( + f.env.dir_tmp, + fs.readdirSync(f.env.dir_tmp).find((name) => name.endsWith('.cache')), + ); + let lines = fs.readFileSync(file, 'utf8').split('\n'); + lines[1] = '0'; + fs.writeFileSync(file, lines.join('\n')); + f.run(); + assert.equal(f.count(), 2); + fs.writeFileSync(file, lines[0] + '\n08\n/incorrect\n'); + assert.equal(f.run().status, 0); + assert.equal(f.count(), 3); + fs.writeFileSync(file, lines[0]); + assert.equal(f.run().status, 0); + assert.equal(f.count(), 4); + fs.rmSync(file); + assert.equal(f.run({ FAIL: '1' }).status, 1); + assert.equal(fs.existsSync(file), false); + assert.equal(f.run().status, 0); + assert.equal(f.count(), 6); +}); +test('disabled or unavailable cache falls back without changing discovery output', (t) => { + const f = fixture(t); + for (let i = 0; i < 2; i++) + assert.equal( + f.run({ QL_NODE_PATH_CACHE: '0' }).stdout.trim(), + '/test/global/node_modules', + ); + assert.equal(f.count(), 2); + assert.equal(fs.existsSync(f.env.dir_tmp), false); + const blocked = path.join(f.root, 'file'); + fs.writeFileSync(blocked, ''); + assert.equal( + f.run({ dir_tmp: blocked }).stdout.trim(), + '/test/global/node_modules', + ); + assert.equal(f.count(), 3); +}); +test('concurrent refreshes publish complete records and do not overwrite symlink targets', async (t) => { + const f = fixture(t); + await Promise.all( + Array.from( + { length: 8 }, + () => + new Promise((resolve, reject) => { + const p = spawn('/bin/bash', ['-uc', f.command], { + cwd: f.root, + env: f.env, + }); + let out = ''; + p.stdout.on('data', (c) => (out += c)); + p.once('error', reject); + p.once('close', (code) => { + try { + assert.equal(code, 0); + assert.equal(out.trim(), '/test/global/node_modules'); + resolve(); + } catch (e) { + reject(e); + } + }); + }), + ), + ); + const before = f.count(); + f.run(); + assert.equal(f.count(), before); + const files = fs + .readdirSync(f.env.dir_tmp) + .filter((file) => file.endsWith('.cache')); + assert.equal(files.length, 1); + const file = path.join(f.env.dir_tmp, files[0]); + fs.rmSync(file); + const victim = path.join(f.root, 'victim'); + fs.writeFileSync(victim, 'unchanged'); + fs.symlinkSync(victim, file); + assert.equal(f.run().status, 0); + assert.equal(fs.readFileSync(victim, 'utf8'), 'unchanged'); + assert.equal(fs.lstatSync(file).isSymbolicLink(), false); +}); + +test('task entry tolerates discovery failure with errexit enabled', (t) => { + const f = fixture(t); + const source = fs.readFileSync(path.resolve('shell/otask.sh'), 'utf8'); + const start = source.indexOf('append_node_dependency_path() {'); + const end = source.indexOf('\nenter_script_workdir()', start); + const script = + source.slice(start, end) + + '\nappend_node_dependency_path; printf "continued:%s" "$NODE_PATH"'; + for (const dir of [path.dirname(helper), f.root]) { + const r = f.run( + { + FAIL: '1', + dir_shell: dir, + dir_dep: '/legacy/deps', + NODE_PATH: '/existing', + }, + 'set -e; ' + script, + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'continued:/existing:/legacy/deps'); + } +}); diff --git a/test/back/node-path-lock.test.cjs b/test/back/node-path-lock.test.cjs new file mode 100644 index 00000000..36b409ef --- /dev/null +++ b/test/back/node-path-lock.test.cjs @@ -0,0 +1,182 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const helper = path.resolve('shell/node_path_cache.sh'); +const hasFlock = spawnSync('/bin/bash', ['-c', 'type -P flock']).status === 0; +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-path-lock-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.symlinkSync(process.execPath, path.join(bin, 'node')); + fs.writeFileSync( + path.join(bin, 'pnpm'), + '#!/bin/bash\nprintf "call\\n" >> "$CALLS"\n[[ -n "${UMASK_FILE:-}" ]] && umask > "$UMASK_FILE"\nsleep "${LOOKUP_DELAY:-0.2}"\n[[ "${FAIL:-0}" == 1 ]] && exit 17\nprintf "%s\\n" "${npm_config_global_dir:-/test/global/node_modules}"\n', + { mode: 0o755 }, + ); + const env = { + ...process.env, + HOME: root, + PATH: bin + ':' + process.env.PATH, + dir_tmp: path.join(root, 'cache'), + CALLS: path.join(root, 'calls'), + HELPER: helper, + }; + const command = '. "$HELPER"; ql_get_node_global_path'; + const run = (extra = {}, code = command) => + spawnSync('/bin/bash', ['-euc', code], { + cwd: root, + env: { ...env, ...extra }, + encoding: 'utf8', + }); + const asyncRun = (extra = {}) => + new Promise((resolve, reject) => { + const child = spawn('/bin/bash', ['-euc', command], { + cwd: root, + env: { ...env, ...extra }, + }); + let out = '', + err = ''; + child.stdout.on('data', (c) => (out += c)); + child.stderr.on('data', (c) => (err += c)); + child.on('error', reject); + child.on('close', (status) => + resolve({ status, stdout: out, stderr: err }), + ); + }); + const count = () => + fs.existsSync(env.CALLS) + ? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length + : 0; + const key = run({}, '. "$HELPER"; ql_node_path_cache_key').stdout.trim(); + const cache = path.join( + env.dir_tmp, + `pnpm-root-${process.getuid()}-${key.replace(' ', '-')}.cache`, + ); + return { + root, + bin, + env, + command, + run, + asyncRun, + count, + cache, + lock: path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.lock`), + }; +} +function check(r, output = '/test/global/node_modules') { + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), output); +} +test( + 'simultaneous cold and expired lookups share one successful refresh', + { skip: !hasFlock }, + async (t) => { + const f = fixture(t); + for (let batch = 0; batch < 2; batch++) { + if (batch) { + const lines = fs.readFileSync(f.cache, 'utf8').split('\n'); + lines[1] = '0'; + fs.writeFileSync(f.cache, lines.join('\n')); + } + const rows = await Promise.all( + Array.from({ length: 8 }, () => f.asyncRun({ LOOKUP_DELAY: '0.5' })), + ); + rows.forEach((r) => check(r)); + assert.equal(f.count(), batch + 1); + } + assert.equal(fs.statSync(f.lock).mode & 0o777, 0o600); + check(f.run()); + assert.equal(f.count(), 2); + }, +); +test( + 'waiters with different configuration do not reuse another key', + { skip: !hasFlock }, + async (t) => { + const f = fixture(t); + const rows = await Promise.all( + ['/first', '/second'].map((v) => + f.asyncRun({ npm_config_global_dir: v }), + ), + ); + rows.forEach((r, i) => check(r, ['/first', '/second'][i])); + assert.equal(f.count(), 2); + }, +); +test( + 'lock timeout falls back while a holder is still alive', + { skip: !hasFlock }, + async (t) => { + const f = fixture(t); + fs.mkdirSync(f.env.dir_tmp); + const holder = spawn( + '/bin/bash', + ['-c', 'exec 9>> "$LOCK"; flock 9; echo ready; sleep 30'], + { env: { ...f.env, LOCK: f.lock }, detached: true }, + ); + t.after(() => { + try { + process.kill(-holder.pid, 'SIGKILL'); + } catch {} + }); + await new Promise((resolve, reject) => { + holder.stdout.once('data', resolve); + holder.once('error', reject); + }); + const start = Date.now(); + check(f.run()); + assert.ok(Date.now() - start >= 1800); + assert.equal(holder.exitCode, null); + assert.equal(f.count(), 1); + }, +); +test('absent or unsupported flock and unsafe lock paths preserve discovery', (t) => { + const f = fixture(t); + check( + f.run( + {}, + 'type(){ if [[ "$*" == "-P flock" ]]; then return 1; fi; builtin type "$@"; }; ' + + f.command, + ), + ); + fs.rmSync(f.cache); + fs.writeFileSync(path.join(f.bin, 'flock'), '#!/bin/bash\nexit 64\n', { + mode: 0o755, + }); + check(f.run()); + fs.rmSync(f.cache); + fs.rmSync(f.lock, { force: true }); + const victim = path.join(f.root, 'victim'); + fs.writeFileSync(victim, 'untouched'); + fs.symlinkSync(victim, f.lock); + check(f.run()); + assert.equal(fs.readFileSync(victim, 'utf8'), 'untouched'); + assert.equal(f.count(), 3); +}); +test('failed refresh releases lock and does not publish a success', (t) => { + const f = fixture(t); + assert.equal(f.run({ FAIL: '1' }).status, 17); + assert.equal(fs.existsSync(f.cache), false); + check(f.run()); + assert.equal(f.count(), 2); +}); +test('refresh leaves the caller file descriptor and umask unchanged', (t) => { + const f = fixture(t); + const sentinel = path.join(f.root, 'fd'); + const mask = path.join(f.root, 'mask'); + const r = f.run( + { SENTINEL: sentinel, UMASK_FILE: mask }, + 'umask 022; exec 9> "$SENTINEL"; ' + + f.command + + ' >/dev/null; printf preserved >&9; umask', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), '0022'); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'preserved'); + assert.equal(fs.readFileSync(mask, 'utf8').trim(), '0022'); +}); diff --git a/test/back/npm-publication.test.cjs b/test/back/npm-publication.test.cjs new file mode 100644 index 00000000..9a816354 --- /dev/null +++ b/test/back/npm-publication.test.cjs @@ -0,0 +1,70 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const yaml = require('js-yaml'); +const workflow = yaml.load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), +); +const publication = workflow.jobs.publish.steps.find( + (step) => step.name === 'Publish npm package with OIDC', +).run; + +test('npm publication waits for every release image, including Python 3.10', () => { + for (const name of [ + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) + assert.ok(workflow.jobs.publish.needs.includes(name), name); +}); + +for (const [scenario, expectedStatus, published] of [ + ['existing', 0, false], + ['missing', 0, true], + ['unauthorized', 1, false], + ['network', 1, false], + ['unexpected-version', 1, false], +]) { + test(`npm publication handles ${scenario} without publishing an unverified version`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-npm-publication-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }), + ); + fs.writeFileSync( + path.join(bin, 'npm'), + `#!/usr/bin/env node +const fs = require('node:fs'); +if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); } +if (process.argv[2] !== 'view') process.exit(99); +switch (process.env.QL_PUBLISH_SCENARIO) { +case 'existing': console.log(JSON.stringify('2.22.0')); break; +case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break; +case 'missing': console.log(JSON.stringify({error:{code:'E404'}})); process.exit(1); +case 'unauthorized': console.log(JSON.stringify({error:{code:'E401'}})); process.exit(1); +case 'network': console.log('upstream unavailable'); process.exit(1); +} +`, + { mode: 0o755 }, + ); + const result = spawnSync('bash', ['-c', publication], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + RUNNER_TEMP: root, + QL_PUBLISH_SCENARIO: scenario, + }, + }); + assert.equal(result.status, expectedStatus, result.stdout + result.stderr); + assert.equal(fs.existsSync(path.join(root, 'published')), published); + }); +} diff --git a/test/back/preload-lazy-notify.test.cjs b/test/back/preload-lazy-notify.test.cjs new file mode 100644 index 00000000..b4ab3533 --- /dev/null +++ b/test/back/preload-lazy-notify.test.cjs @@ -0,0 +1,168 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +function fixture(t, language) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-lazy-notify-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const extension = language === 'python' ? 'py' : 'js'; + fs.copyFileSync(`shell/preload/sitecustomize.${extension}`, path.join(root, `sitecustomize.${extension}`)); + fs.writeFileSync(path.join(root, `env.${extension}`), ''); + fs.writeFileSync(path.join(root, 'before.sh'), 'export FROM_SHELL=before\n'); + fs.writeFileSync(path.join(root, 'before.js'), 'process.env.FROM_LANGUAGE = "javascript";\n'); + fs.writeFileSync(path.join(root, 'task_before.py'), 'import os\nos.environ["FROM_LANGUAGE"] = "python"\n'); + fs.writeFileSync(path.join(root, 'client.js'), 'module.exports = { getEnvs: () => "client-ready" };\n'); + fs.copyFileSync('shell/preload/client.py', path.join(root, 'client.py')); + fs.copyFileSync('shell/preload/esm-loader.mjs', path.join(root, 'esm-loader.mjs')); + const env = { + ...process.env, + PYTHONPATH: root, + PREV_PYTHONPATH: '', + NODE_OPTIONS: '', + PREV_NODE_OPTIONS: '', + QL_NODE_GLOBAL_PATH: '', + file_task_before: path.join(root, 'before.sh'), + file_task_before_js: path.join(root, 'before.js'), + dir_scripts: root, + task_before: 'export FROM_COMMAND=command', + envParam: 'ACCOUNTS', + numParam: '2-3', + ACCOUNTS: 'first&second&third', + }; + const write = (name, text) => fs.writeFileSync(path.join(root, name), text); + const run = (body, extensionOverride = extension) => { + const entry = path.join(root, `entry.${extensionOverride}`); + write(path.basename(entry), body); + const python = language === 'python'; + const result = spawnSync(python ? 'python3' : process.execPath, + python ? [entry] : ['--require', path.join(root, 'sitecustomize.js'), entry], + { cwd: root, env, encoding: 'utf8', timeout: 15000 }); + assert.equal(result.status, 0, result.stderr + result.stdout); + return result.stdout; + }; + return { root, env, write, run }; +} + +for (const extension of ['js', 'mjs']) { + test(`${extension} startup preserves hooks, environment selection and client without importing notifications`, (t) => { + const f = fixture(t, 'javascript'); + f.write('__ql_notify__.js', 'throw Error("notification dependencies must not load at startup");'); + f.run(` + const assert = ${extension === 'mjs' ? '(await import("node:assert/strict")).default' : 'require("node:assert/strict")'}; + assert.equal(process.env.FROM_SHELL, 'before'); + assert.equal(process.env.FROM_LANGUAGE, 'javascript'); + assert.equal(process.env.FROM_COMMAND, 'command'); + assert.equal(process.env.ACCOUNTS, 'second&third'); + assert.equal(QLAPI.getEnvs(), 'client-ready'); + assert.equal(typeof QLAPI.notify, 'function'); + `, extension); + }); +} + +test('JavaScript first notification imports once and preserves arguments, this, promises and errors', (t) => { + const f = fixture(t, 'javascript'); + f.write('__ql_notify__.js', ` + global.notificationLoads = (global.notificationLoads || 0) + 1; + exports.sendNotify = function (...args) { + if (args[0] === 'fail') throw new Error('send failed'); + global.notificationResult = Promise.resolve({ args, receiver: this }); + return global.notificationResult; + }; + `); + f.run(` + const assert = require('node:assert/strict'); + assert.equal(global.notificationLoads, undefined); + (async () => { + const options = { channel: 'fixture' }; + const promise = QLAPI.notify('title', 'content', options); + assert.equal(promise, global.notificationResult); + const result = await promise; + assert.deepEqual(result.args, ['title', 'content', options]); + assert.equal(result.receiver, QLAPI); + await QLAPI.notify('second'); + assert.equal(global.notificationLoads, 1); + assert.throws(() => QLAPI.notify('fail'), /send failed/); + })().catch(error => { console.error(error); process.exitCode = 1; }); + `); +}); + +test('JavaScript reports import failures on notify and permits a later retry', (t) => { + const f = fixture(t, 'javascript'); + f.write('__ql_notify__.js', ` + if (!process.env.NOTIFY_READY) throw Error('notification dependency unavailable'); + exports.sendNotify = (...args) => args; + `); + f.run(` + const assert = require('node:assert/strict'); + assert.equal(QLAPI.getEnvs(), 'client-ready'); + assert.throws(() => QLAPI.notify('first'), /notification dependency unavailable/); + process.env.NOTIFY_READY = '1'; + assert.deepEqual(QLAPI.notify('retry'), ['retry']); + `); +}); + +test('Python startup preserves hooks, environment selection and client without importing notifications', (t) => { + const f = fixture(t, 'python'); + f.write('__ql_notify__.py', 'raise RuntimeError("notification dependencies must not load at startup")\n'); + f.run(` +import os, sys +assert os.environ['FROM_SHELL'] == 'before' +assert os.environ['FROM_LANGUAGE'] == 'python' +assert os.environ['FROM_COMMAND'] == 'command' +assert os.environ['ACCOUNTS'] == 'second&third' +assert '__ql_notify__' not in sys.modules +assert callable(QLAPI.notify) +assert callable(QLAPI.getEnvs) +`); +}); + +test('Python first notification imports once and preserves args, kwargs, return values and errors', (t) => { + const f = fixture(t, 'python'); + f.write('__ql_notify__.py', ` +import builtins +builtins.notification_loads = getattr(builtins, 'notification_loads', 0) + 1 +def send(*args, **kwargs): + if args[0] == 'fail': + raise ValueError('send failed') + return args, kwargs +`); + f.run(` +import builtins, sys +assert '__ql_notify__' not in sys.modules +assert QLAPI.notify('title', 'content', channel='fixture') == (('title', 'content'), {'channel': 'fixture'}) +assert QLAPI.notify('second') == (('second',), {}) +assert builtins.notification_loads == 1 +try: + QLAPI.notify('fail') +except ValueError as error: + assert str(error) == 'send failed' +else: + raise AssertionError('notification exception was swallowed') +`); +}); + +test('Python reports import failures on notify and permits a later retry', (t) => { + const f = fixture(t, 'python'); + f.write('__ql_notify__.py', ` +import os +if not os.environ.get('NOTIFY_READY'): + raise RuntimeError('notification dependency unavailable') +def send(*args, **kwargs): + return args +`); + f.run(` +import os, sys +assert '__ql_notify__' not in sys.modules +try: + QLAPI.notify('first') +except RuntimeError as error: + assert str(error) == 'notification dependency unavailable' +else: + raise AssertionError('import exception was swallowed') +os.environ['NOTIFY_READY'] = '1' +assert QLAPI.notify('retry') == ('retry',) +`); +}); diff --git a/test/back/primary-apm.test.cjs b/test/back/primary-apm.test.cjs new file mode 100644 index 00000000..ab0e09e1 --- /dev/null +++ b/test/back/primary-apm.test.cjs @@ -0,0 +1,24 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const source = fs.readFileSync('ecosystem.config.js', 'utf8'); +const appSource = fs.readFileSync('back/app.ts', 'utf8'); +const ts = require('typescript'); +const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText; +function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];} +test('container APM policy keeps worker isolation and can restore all inherited probes', () => { + for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) { + const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers}; + const conf=config(env);env.pm_id='0';env.pmx=String(conf.pmx); + const calls=[],module={exports:{}}; + vm.runInNewContext(compiled,{module,exports:module.exports,process:{env},require(name){if(name==='cluster')return {fork(options){calls.push({...env,...options});return {id:1,process:{pid:123}}}};if(name==='express')return ()=>({use(){}});return {};}}); + new module.exports().forkWorker('http'); + assert.equal(conf.pmx,primary==='true'); + assert.equal(calls[0].pmx,primary==='true'&&workers==='true'?'true':'false'); + assert.equal(conf.max_restarts,5);assert.equal(conf.script,'static/build/app.js'); + } +}); +test('standalone PM2 monitoring stays enabled', () => { + for(const QL_PRIMARY_APM of [undefined,'true','false']) assert.equal(config({QL_PRIMARY_APM}).pmx,true); +}); diff --git a/test/back/process-exit-state.test.cjs b/test/back/process-exit-state.test.cjs new file mode 100644 index 00000000..33173932 --- /dev/null +++ b/test/back/process-exit-state.test.cjs @@ -0,0 +1,81 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); + +// Isolate OS probes while exercising the production termination algorithm. +function terminationFixture(readStat, signalProbe = () => {}) { + const source = fs.readFileSync('back/config/util.ts', 'utf8'); + const start = source.indexOf('export async function killTask('); + const end = source.indexOf('export async function getPid(', start); + const code = ts.transpileModule(source.slice(start, end), { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + }).outputText; + const signals = []; + const exports = {}; + new Function('exports', 'process', 'psTree', 'fs', 'setTimeout', code)( + exports, + { + platform: 'linux', + kill: (pid, signal) => { + signals.push([pid, signal]); + signalProbe(pid, signal); + }, + }, + async () => [], + { readFile: readStat }, + setTimeout, + ); + return { killTask: exports.killTask, signals }; +} + +for (const state of ['Z', 'X', 'x']) { + test(`termination accepts Linux exited state ${state} before PID reaping`, async () => { + const { killTask, signals } = terminationFixture( + async () => `42 (name with ) parentheses) ${state} 1 0 0`, + ); + await killTask(42, true); + assert.equal( + signals.some(([, signal]) => signal === 'SIGKILL'), + false, + ); + }); +} + +test('termination keeps waiting while the process is running', async () => { + let reads = 0; + const { killTask } = terminationFixture( + async () => `42 (worker) ${++reads === 1 ? 'S' : 'Z'} 1 0 0`, + ); + await killTask(42, true); + assert.equal(reads, 2); +}); + +test('a process disappearing during the procfs read is accepted', async () => { + let probes = 0; + const { killTask } = terminationFixture( + async () => { + throw Object.assign(Error('gone'), { code: 'ENOENT' }); + }, + (_pid, signal) => { + if (signal === 0 && ++probes > 1) + throw Object.assign(Error('gone'), { code: 'ESRCH' }); + }, + ); + await killTask(42, true); + assert.equal(probes, 2); +}); + +test('permission failures are not reported as successful termination', async () => { + const denied = Object.assign(Error('denied'), { code: 'EPERM' }); + const { killTask } = terminationFixture( + async () => '', + () => { + throw denied; + }, + ); + await assert.rejects(killTask(42, true), (error) => error === denied); +}); diff --git a/test/back/process-stop-signal.test.cjs b/test/back/process-stop-signal.test.cjs new file mode 100644 index 00000000..285aee41 --- /dev/null +++ b/test/back/process-stop-signal.test.cjs @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); + +// Exercise the production function with a deterministic process-tree race. +function fixture(descendants, probe = () => {}) { + const source = fs.readFileSync('back/config/util.ts', 'utf8'); + const start = source.indexOf('export async function killTask('); + const end = source.indexOf('export async function getPid(', start); + const code = ts.transpileModule(source.slice(start, end), { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + }).outputText; + const signals = []; + const exports = {}; + new Function('exports', 'process', 'psTree', 'fs', 'setTimeout', code)( + exports, + { + platform: 'linux', + kill: (pid, signal) => { + const normalized = { 15: 'SIGTERM', 2: 'SIGINT' }[signal] || signal; + signals.push([pid, normalized]); + probe(pid, normalized); + }, + }, + async () => [...descendants], + {}, + setTimeout, + ); + return { killTask: exports.killTask, signals }; +} + +test('an exited descendant does not prevent stopping its siblings and parent', async () => { + const { killTask, signals } = fixture([101, 102], (pid) => { + if (pid === 102) throw Object.assign(Error('gone'), { code: 'ESRCH' }); + }); + await killTask(100); + assert.deepEqual(signals, [ + [102, 'SIGTERM'], + [101, 'SIGTERM'], + [100, 'SIGTERM'], + ]); +}); + +test('an already exited leaf can be stopped repeatedly', async () => { + const { killTask } = fixture([], () => { + throw Object.assign(Error('gone'), { code: 'ESRCH' }); + }); + await killTask(100); + await killTask(100); +}); + +for (const descendants of [[], [101]]) { + test(`signal permission failure is surfaced with ${descendants.length} descendants`, async () => { + const denied = Object.assign(Error('denied'), { code: 'EPERM' }); + const { killTask } = fixture(descendants, () => { + throw denied; + }); + await assert.rejects(killTask(100), (error) => error === denied); + }); +} + +test('a leaf retains SIGINT and a process tree retains child-first SIGTERM', async () => { + const leaf = fixture([]); + await leaf.killTask(100); + assert.deepEqual(leaf.signals, [[100, 'SIGINT']]); + const tree = fixture([101, 102]); + await tree.killTask(100); + assert.deepEqual(tree.signals, [ + [102, 'SIGTERM'], + [101, 'SIGTERM'], + [100, 'SIGTERM'], + ]); +}); diff --git a/test/back/protected-path-case.test.cjs b/test/back/protected-path-case.test.cjs new file mode 100644 index 00000000..f4e290bc --- /dev/null +++ b/test/back/protected-path-case.test.cjs @@ -0,0 +1,58 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const protectedPathCase = require( + '../../back/middlewares/protectedPathCase', +).default; + +function runMiddleware(path) { + let status; + let body; + let nextCalled = false; + const response = { + status(value) { + status = value; + return this; + }, + json(value) { + body = value; + return this; + }, + }; + + protectedPathCase({ path }, response, () => { + nextCalled = true; + }); + + return { status, body, nextCalled }; +} + +test('rejects case variations under protected API namespaces', () => { + for (const path of [ + '/Api/configs/detail', + '/api/Configs/detail', + '/OPEN/scripts/detail', + '/open/scripts/Detail', + ]) { + assert.deepEqual(runMiddleware(path), { + status: 400, + body: { code: 400, message: 'Invalid path format' }, + nextCalled: false, + }); + } +}); + +test('allows normalized protected paths and unrelated paths', () => { + for (const path of [ + '/api/configs/detail', + '/open/scripts/detail', + '/OpenApi/status', + '/assets/AppBundle.js', + ]) { + assert.deepEqual(runMiddleware(path), { + status: undefined, + body: undefined, + nextCalled: true, + }); + } +}); diff --git a/test/back/python-venv.test.cjs b/test/back/python-venv.test.cjs new file mode 100644 index 00000000..0b7439b6 --- /dev/null +++ b/test/back/python-venv.test.cjs @@ -0,0 +1,69 @@ +const test = require('node:test'), + assert = require('node:assert/strict'), + fs = require('node:fs'), + os = require('node:os'), + path = require('node:path'), + { execFileSync } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); +test('venv installs, uninstalls and probes use the selected Python, including spaces', () => { + const methods = load('back/config/util.ts', { + './index': { rootPath: '/application' }, + './const': { + PYTHON_VENV_DIR: 'virtual env', + PYTHON_INSTALL_DIR: '/legacy', + }, + '../loaders/logger': {}, + '../shared/utils': {}, + '../data/dependence': { + DependenceTypes: { nodejs: 0, python3: 1, linux: 2 }, + }, + }); + for (const name of [ + 'getInstallCommand', + 'getUninstallCommand', + 'getGetCommand', + ]) { + const cmd = methods[name](1, 'requests'); + assert.match(cmd, /'\/application\/virtual env\/bin\/python3'/); + assert.doesNotMatch(cmd, /--prefix=/); + } +}); +test('venv setup keeps Python packages isolated and retains existing startup configuration', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-venv-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const ql = path.join(dir, 'application with spaces'); + fs.mkdirSync(ql); + const venv = path.join(dir, 'venv with spaces'); + execFileSync('python3', ['-m', 'venv', venv]); + const source = fs.readFileSync('shell/start-simplify.sh', 'utf8'); + const snippet = source.slice( + source.indexOf('export PNPM_HOME='), + source.indexOf('\ncd "${QL_DIR}"'), + ); + const result = execFileSync( + '/bin/bash', + [ + '-c', + snippet + + '\npython3 -c "import sys; print(sys.prefix); print(sys.base_prefix)"', + ], + { + env: { + ...process.env, + QL_DIR: ql, + QL_DATA_DIR: path.join(dir, 'data'), + PYTHON_VENV_DIR: venv, + PYTHONPATH: '/legacy/modules', + PYTHONUSERBASE: '/legacy', + PATH: process.env.PATH, + }, + encoding: 'utf8', + }, + ) + .trim() + .split('\n'); + assert.equal(fs.realpathSync(result[0]), fs.realpathSync(venv)); + assert.notEqual(result[1], venv); + assert.match(source, /set -C; cat .env.example > .env/); + assert.doesNotMatch(source, /npm install|pip3 install|chmod 777/); +}); diff --git a/test/back/qlServiceLogCommand.test.cjs b/test/back/qlServiceLogCommand.test.cjs new file mode 100644 index 00000000..74fab549 --- /dev/null +++ b/test/back/qlServiceLogCommand.test.cjs @@ -0,0 +1,128 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { after, test } = require('node:test'); + +const root = path.resolve(__dirname, '../..'); +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-log-command-')); + +after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + +function writeExecutable(filePath, content) { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +function prepareFixture(name) { + const testRoot = path.join(fixtureRoot, name); + const shellDir = path.join(testRoot, 'shell'); + const binDir = path.join(testRoot, 'bin'); + fs.mkdirSync(shellDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + + fs.copyFileSync( + path.join(root, 'shell/update.sh'), + path.join(shellDir, 'update.sh'), + ); + fs.writeFileSync( + path.join(shellDir, 'share.sh'), + [ + 'dir_data="${QL_DATA_DIR:-$QL_DIR/data}"', + 'load_ql_envs() { :; }', + 'import_config() { :; }', + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(shellDir, 'api.sh'), ''); + fs.writeFileSync(path.join(shellDir, 'env.sh'), ''); + writeExecutable( + path.join(binDir, 'pm2'), + '#!/usr/bin/env bash\necho "pm2 must not be called" >&2\nexit 99\n', + ); + + return { binDir, shellDir, testRoot }; +} + +test('ql log reads the requested system log history without PM2', () => { + const { binDir, shellDir, testRoot } = prepareFixture('host'); + const systemLogDir = path.join(testRoot, 'data/syslog'); + fs.mkdirSync(systemLogDir, { recursive: true }); + fs.writeFileSync( + path.join(systemLogDir, '2026-08-20.log'), + 'system-one\nsystem-two\nsystem-three\n', + ); + + const result = spawnSync( + '/bin/bash', + [path.join(shellDir, 'update.sh'), 'log', '--lines', '2', '--nostream'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + QL_CONTAINER: 'false', + QL_DIR: testRoot, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'system-two\nsystem-three\n'); + assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); +}); + +test('ql log follows the current Winston system log without PM2', () => { + const { binDir, shellDir, testRoot } = prepareFixture('container'); + const systemLogDir = path.join(testRoot, 'data/syslog'); + const currentDate = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim(); + const currentLog = path.join(systemLogDir, `${currentDate}.log`); + fs.mkdirSync(systemLogDir, { recursive: true }); + fs.writeFileSync(currentLog, 'system-one\n'); + writeExecutable( + path.join(binDir, 'tail'), + '#!/usr/bin/env bash\nprintf "tail"\nprintf " <%s>" "$@"\nprintf "\\n"\n', + ); + + const result = spawnSync( + '/bin/bash', + [path.join(shellDir, 'update.sh'), 'log', '--lines', '0'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + QL_CONTAINER: 'true', + QL_DIR: testRoot, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`); + assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); +}); + +test('ql log reports when no persisted system log exists', () => { + const { binDir, shellDir, testRoot } = prepareFixture('container-nostream'); + + const result = spawnSync( + '/bin/bash', + [path.join(shellDir, 'update.sh'), 'log', '--nostream'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + QL_CONTAINER: 'true', + QL_DIR: testRoot, + }, + }, + ); + + assert.equal(result.status, 0); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /no system log found/); +}); diff --git a/test/back/queued-cron-invalidation.test.cjs b/test/back/queued-cron-invalidation.test.cjs new file mode 100644 index 00000000..a08417e4 --- /dev/null +++ b/test/back/queued-cron-invalidation.test.cjs @@ -0,0 +1,98 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const load = require('../helpers/load-security-module.cjs'); +const tick = () => new Promise(setImmediate); +function gate() { let release; const promise = new Promise(r => release = r); return { promise, release }; } + +async function fixture() { + const logger = { info() {}, error() {}, warn() {} }, notices = []; + const limit = load('back/shared/pLimit.ts', { + '../data/system': { AuthDataType: { systemConfig: 'systemConfig' }, SystemModel: { sync: async () => {}, findOne: async () => ({ info: { cronConcurrency: 1 } }) } }, + '../loaders/logger': logger, + '../services/notify': class {}, + '../shared/i18n': { t: s => s, tf: s => s }, + '../config': { grpcPort: 1 }, + '../protos/api': { ApiClient: class { systemNotify(value, cb) { notices.push(value); cb(); } } }, + '../config/grpcCerts': { getGrpcCerts: () => null }, + }).default; + await tick(); + const spawned = [], records = new Map(), jobs = new Map(); + let readGate, killGate; + const { runCron } = load('back/shared/runCron.ts', { + 'cross-spawn': { spawn: command => { spawned.push(command); return {}; } }, + './pLimit': limit, + '../loaders/logger': logger, + '../data/cron': { CrontabModel: { findOne: async ({ where }) => { const row = records.get(String(where.id)); if (readGate) await readGate.promise; return row; }, update: async () => {} }, CrontabStatus: { running: 0, queued: 3, idle: 1 } }, + '../data/runningInstance': { RunningInstanceModel: { update: async () => {} }, InstanceStatus: { running: 0, stopped: 2 } }, + '../config/util': { killTask: async () => { if (killGate) await killGate.promise; } }, + './childProcess': { observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }), asError: e => e }, + }); + const { addCron } = load('back/schedule/addCron.ts', { + '../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) }, + '../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' }, + './data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger, + '../shared/i18n': { tf: s => s }, + }); + const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger }); + function add(id = '1', command = 'old', replace = false) { + records.set(id, { isDisabled: 0, allow_multiple_instances: 1 }); + addCron({ request: { crons: [{ id, command, schedule: '* * * * * *' }], replace } }, err => assert.ifError(err)); + return jobs.get(id)[0]; + } + function remove(id = '1') { delCron({ request: { ids: [id] } }, err => assert.ifError(err)); } + async function block() { const g = gate(); const done = limit.runWithCronLimit({ id: 'blocker' }, async () => { await g.promise; }); await tick(); return { release: async () => { g.release(); await done; } }; } + return { limit, spawned, records, jobs, add, remove, block, notices, setReadGate: g => readGate = g, setKillGate: g => killGate = g }; +} + +for (const operation of ['disable', 'delete', 'update', 'replace', 'disable-enable']) { + test(`queued scheduled execution is invalidated by ${operation}`, async () => { + const f = await fixture(), old = f.add(), blocker = await f.block(); + const queued = old.fire(); + if (operation === 'disable' || operation === 'disable-enable') { f.records.get('1').isDisabled = 1; f.remove(); } + if (operation === 'delete') { f.records.delete('1'); f.remove(); } + if (operation === 'update') f.add('1', 'new'); + if (operation === 'replace') f.add('1', 'new', true); + if (operation === 'disable-enable') f.add('1', 'old'); + const next = f.jobs.get('1')?.[0].fire(); + await blocker.release(); await queued; await next; + assert.deepEqual(f.spawned, ['update', 'replace'].includes(operation) ? ['new'] : operation === 'disable-enable' ? ['old'] : []); + assert.equal(f.limit.cronLimitPendingCount, 0); + assert.equal(f.limit.cronLimitActiveCount, 0); + }); +} + +test('stale callbacks cannot re-enqueue after cancellation', async () => { + const f = await fixture(), old = f.add(); f.remove(); await old.fire(); + assert.deepEqual(f.spawned, []); +}); + +test('invalidation during an asynchronous database read prevents spawning', async () => { + const f = await fixture(), old = f.add(), pending = gate(); f.setReadGate(pending); + const run = old.fire(); await tick(); f.remove(); pending.release(); await run; + assert.deepEqual(f.spawned, []); +}); + +test('invalidation while replacing a running instance prevents the old spawn', async () => { + const f = await fixture(), old = f.add(), pending = gate(); + f.records.set('1', { isDisabled: 0, allow_multiple_instances: 0, pid: 10, status: 0 }); f.setKillGate(pending); + const run = old.fire(); await tick(); f.remove(); pending.release(); await run; + assert.deepEqual(f.spawned, []); +}); + +test('database removal or disable is respected before scheduler RPC arrives', async () => { + for (const disabled of [true, false]) { + const f = await fixture(), old = f.add(), blocker = await f.block(); const run = old.fire(); + if (disabled) f.records.get('1').isDisabled = 1; else f.records.delete('1'); + await blocker.release(); await run; assert.deepEqual(f.spawned, []); + } +}); + +test('five stale runs do not suppress the new revision or erase its repeat accounting', async () => { + const f = await fixture(), old = f.add(), blocker = await f.block(); + const pending = Array.from({ length: 5 }, () => old.fire()); + const current = f.add('1', 'new'); pending.push(...Array.from({ length: 5 }, () => current.fire())); + assert.equal(f.notices.length, 0); + await blocker.release(); await Promise.all(pending); + assert.deepEqual(f.spawned, Array(5).fill('new')); + await current.fire(); assert.equal(f.spawned.length, 6); +}); diff --git a/test/back/restart-instance-recovery.test.cjs b/test/back/restart-instance-recovery.test.cjs new file mode 100644 index 00000000..ac0f78bd --- /dev/null +++ b/test/back/restart-instance-recovery.test.cjs @@ -0,0 +1,116 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); + +test('startup closes interrupted instances without inventing exit codes or rewriting history', async (t) => { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const instances = db.define('Instance', { + status: DataTypes.INTEGER, + started_at: DataTypes.INTEGER, + finished_at: DataTypes.INTEGER, + exit_code: DataTypes.INTEGER, + }); + await db.sync(); + await instances.bulkCreate([ + { id: 1, status: 0, started_at: 100 }, + { id: 2, status: 1, started_at: 100, finished_at: 110, exit_code: 0 }, + { id: 3, status: 2, started_at: 100, finished_at: 120, exit_code: 143 }, + { id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 }, + ]); + const systemConfig = { info: { lang: 'en-US' } }; + const service = { + set_envs: async () => {}, + getAuthInfo: async () => ({}), + initializeSystemApp: async () => {}, + refreshApps: async () => {}, + }; + const initData = load('back/loaders/initData.ts', { + typedi: { Container: { get: () => service } }, + '../services/dependence': class {}, + '../services/cron': class {}, + '../services/env': class {}, + '../services/system': class {}, + '../services/user': class {}, + '../services/open': class {}, + '../data/cron': { + CrontabModel: { update: async () => {}, findAll: async () => [] }, + CrontabStatus: { idle: 1 }, + }, + '../data/dependence': { + DependenceModel: { findAll: async () => [], update: async () => {} }, + DependenceStatus: { queued: 0 }, + }, + '../data/cronView': { + CrontabViewModel: { findAll: async () => [{}] }, + CronViewType: { 系统: 0 }, + }, + '../data/env': {}, + '../data/system': { + SystemModel: { findOrCreate: async () => [systemConfig] }, + AuthDataType: {}, + }, + '../data/open': { + AppModel: { + findOne: async () => ({ get: () => ({ scopes: ['dashboard'] }) }), + }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, stopped: 2 }, + }, + '../config': {}, + '../config/util': { fileExist: async () => true, isDemoEnv: () => false }, + '../shared/store': { shareStore: { updateAuthInfo: async () => {} } }, + '../shared/i18n': { setLang() {} }, + './logger': {}, + '../schedule/client': { + readiness: { configure() {}, recover: async () => {} }, + }, + }).default; + // Dependency installation is unrelated to startup reconciliation. + const originalTimeout = global.setTimeout; + global.setTimeout = () => 0; + const before = Math.floor(Date.now() / 1000); + try { + await initData(); + } finally { + global.setTimeout = originalTimeout; + } + const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true }); + assert.equal(rows[0].status, 2); + assert.ok( + rows[0].finished_at >= before && + rows[0].finished_at <= Math.floor(Date.now() / 1000), + ); + assert.equal(rows[0].exit_code, null); + assert.deepEqual( + rows + .slice(1) + .map(({ status, finished_at, exit_code }) => [ + status, + finished_at, + exit_code, + ]), + [ + [1, 110, 0], + [2, 120, 143], + [3, 130, 7], + ], + ); + global.setTimeout = () => 0; + try { + await initData(); + } finally { + global.setTimeout = originalTimeout; + } + assert.deepEqual( + await instances.findAll({ order: [['id', 'ASC']], raw: true }), + rows, + ); +}); diff --git a/test/back/retention.test.cjs b/test/back/retention.test.cjs new file mode 100644 index 00000000..89c7f756 --- /dev/null +++ b/test/back/retention.test.cjs @@ -0,0 +1,131 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { Op } = require('sequelize'); + +const { + getDirectorySize, + normalizeRetentionPolicy, +} = require('../../back/shared/retention'); + +test('retention policy defaults to disabled and clamps invalid values', () => { + assert.deepEqual(normalizeRetentionPolicy({}), { + runningInstanceRetentionDays: 0, + cronStatRetentionDays: 0, + }); + assert.deepEqual( + normalizeRetentionPolicy({ + runningInstanceRetentionDays: -10, + cronStatRetentionDays: 99999, + }), + { + runningInstanceRetentionDays: 0, + cronStatRetentionDays: 3650, + }, + ); +}); + +test('directory preview counts files recursively and ignores symlinks', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-')); + const nested = path.join(directory, 'nested'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.mkdir(nested); + await fs.writeFile(path.join(directory, 'one'), Buffer.alloc(7)); + await fs.writeFile(path.join(nested, 'two'), Buffer.alloc(11)); + await fs.symlink(path.join(nested, 'two'), path.join(directory, 'link')); + + assert.equal(await getDirectorySize(directory), 18); + assert.equal(await getDirectorySize(path.join(directory, 'missing')), 0); +}); + +test('cleanup previews first, protects running instances, and uses explicit options', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-')); + const nodeCache = path.join(directory, 'node'); + await fs.mkdir(nodeCache); + await fs.writeFile(path.join(nodeCache, 'cache'), Buffer.alloc(13)); + + const config = require('../../back/config').default; + const originalCachePath = config.dependenceCachePath; + const RunningInstanceModel = {}; + const CrontabStatModel = {}; + const InstanceStatus = { running: 0 }; + const sequelize = {}; + const stubModule = (modulePath, exports) => { + require.cache[require.resolve(modulePath)] = { + id: require.resolve(modulePath), + filename: require.resolve(modulePath), + loaded: true, + exports, + children: [], + paths: [], + }; + }; + stubModule('../../back/data', { sequelize }); + stubModule('../../back/data/cronStats', { CrontabStatModel }); + stubModule('../../back/data/runningInstance', { + InstanceStatus, + RunningInstanceModel, + }); + stubModule('../../back/data/system', { + AuthDataType: { systemConfig: 'systemConfig' }, + SystemModel: {}, + }); + delete require.cache[require.resolve('../../back/services/retention')]; + const RetentionService = require('../../back/services/retention').default; + + t.after(async () => { + config.dependenceCachePath = originalCachePath; + await fs.rm(directory, { recursive: true, force: true }); + }); + + let instanceWhere; + let vacuumed = false; + let instanceCountCalls = 0; + let statCountCalls = 0; + config.dependenceCachePath = directory; + RunningInstanceModel.count = async ({ where }) => { + instanceCountCalls++; + instanceWhere = where; + return 2; + }; + RunningInstanceModel.destroy = async ({ where }) => { + instanceWhere = where; + return 2; + }; + CrontabStatModel.count = async () => { + statCountCalls++; + return 3; + }; + CrontabStatModel.destroy = async () => 3; + sequelize.transaction = async (callback) => callback({}); + sequelize.query = async (query) => { + vacuumed = query === 'VACUUM'; + return []; + }; + + const service = new RetentionService(); + const disabledPreview = await service.preview({ + runningInstanceRetentionDays: 0, + cronStatRetentionDays: 0, + }); + assert.equal(disabledPreview.runningInstances, 0); + assert.equal(disabledPreview.cronStats, 0); + assert.equal(instanceCountCalls, 0); + assert.equal(statCountCalls, 0); + + const result = await service.cleanup({ + runningInstanceRetentionDays: 30, + cronStatRetentionDays: 90, + dependenceCacheTypes: ['node'], + compactDatabase: true, + }); + + assert.equal(instanceWhere.status[Op.ne], InstanceStatus.running); + assert.equal(result.deleted.runningInstances, 2); + assert.equal(result.deleted.cronStats, 3); + assert.equal(result.deleted.dependenceCacheBytes, 13); + assert.equal(vacuumed, true); + await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' }); +}); diff --git a/test/back/role-loading.test.cjs b/test/back/role-loading.test.cjs new file mode 100644 index 00000000..ac59c8bb --- /dev/null +++ b/test/back/role-loading.test.cjs @@ -0,0 +1,118 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const ts = require('typescript'); +const source = fs.readFileSync('back/app.ts', 'utf8'); +const compiled = ts.transpileModule( + source.slice(0, source.indexOf('\nconst app = new Application();')) + + '\nmodule.exports = Application;', + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }, +).outputText; + +function fixture() { + const required = [], + listeners = new Map(); + let invalidations = 0; + const module = { exports: {} }; + const localRequire = createRequire(path.resolve('back/app.ts')); + const mocks = { + './config': { cors: { origin: ['http://localhost'] } }, + './loaders/logger': { error() {}, warn() {}, info() {} }, + './middlewares/monitoring': { + monitoringMiddleware: (_req, _res, next) => next(), + }, + './schedule/client': { + default: { readiness: { invalidate: () => invalidations++ } }, + __esModule: true, + }, + }; + new Function('require', 'module', 'exports', 'process', compiled)( + (name) => { + required.push(name); + return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name); + }, + module, + module.exports, + { env: {}, on: (name, fn) => listeners.set(name, fn) }, + ); + return { + app: new module.exports(), + required, + listeners, + invalidations: () => invalidations, + }; +} + +test('primary construction does not load HTTP middleware, metrics or the gRPC client', () => { + const { required } = fixture(); + for (const name of [ + 'express', + 'helmet', + 'cors', + 'compression', + './middlewares/monitoring', + './schedule/client', + './config/util', + ]) { + assert.equal(required.includes(name), false, name); + } +}); + +test('lazy HTTP initialization preserves query filtering, security headers, CORS and compression', async (t) => { + const { app, required } = fixture(); + const http = await app.setupMiddlewares(); + http.get('/test', (req, res) => + res.json({ query: req.query, data: 'x'.repeat(2048) }), + ); + const server = await new Promise((resolve) => { + const server = http.listen(0, '127.0.0.1', () => resolve(server)); + }); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }), + ); + const response = await fetch( + `http://127.0.0.1:${server.address().port}/test?t=123&keep=yes`, + { + headers: { Origin: 'http://localhost', 'Accept-Encoding': 'gzip' }, + }, + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.equal( + response.headers.get('access-control-allow-origin'), + 'http://localhost', + ); + assert.equal(response.headers.get('content-encoding'), 'gzip'); + assert.deepEqual((await response.json()).query, { keep: 'yes' }); + assert.ok(required.includes('express')); + assert.equal(required.includes('./schedule/client'), false); +}); + +test('worker recovery messages still invalidate HTTP readiness, but gRPC shutdown setup does not load its client', async () => { + const grpc = fixture(); + grpc.app.setupWorkerShutdown('grpc'); + await grpc.listeners.get('message')('scheduler-unavailable'); + assert.equal(grpc.required.includes('./schedule/client'), false); + const http = fixture(); + http.app.setupWorkerShutdown('http'); + await http.listeners.get('message')('scheduler-unavailable'); + await http.listeners.get('message')('reregister-crons'); + assert.equal(http.invalidations(), 2); + const shutdowns = []; + http.app.gracefulShutdown = (role) => shutdowns.push(role); + await http.listeners.get('message')('shutdown'); + http.listeners.get('SIGTERM')(); + assert.deepEqual(shutdowns, ['http', 'http']); +}); diff --git a/test/back/schedule-health.test.cjs b/test/back/schedule-health.test.cjs new file mode 100644 index 00000000..7a5eb62b --- /dev/null +++ b/test/back/schedule-health.test.cjs @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const config = require('../../back/config').default; +const { check } = require('../../back/schedule/health'); + +function runCheck(service = 'cron') { + return new Promise((resolve) => { + check({ request: { service } }, (error, response) => { + resolve({ error, response }); + }); + }); +} + +test('schedule health check uses the HTTP health endpoint and system logs', async (t) => { + const originalBaseUrl = config.baseUrl; + const originalPort = config.port; + const originalSystemLogPath = config.systemLogPath; + const originalContainer = process.env.QL_CONTAINER; + + t.after(() => { + config.baseUrl = originalBaseUrl; + config.port = originalPort; + config.systemLogPath = originalSystemLogPath; + if (originalContainer === undefined) { + delete process.env.QL_CONTAINER; + } else { + process.env.QL_CONTAINER = originalContainer; + } + }); + + await t.test( + 'returns serving for a healthy prefixed HTTP service', + async () => { + let requestedUrl = ''; + const server = http.createServer((request, response) => { + requestedUrl = request.url; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ code: 200, data: { status: 'ok' } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + config.baseUrl = '/ql'; + config.port = server.address().port; + + const result = await runCheck(); + assert.equal(result.error, null); + assert.deepEqual(result.response, { status: 1 }); + assert.equal(requestedUrl, '/ql/api/health'); + }, + ); + + await t.test( + 'reports recent system logs when HTTP startup fails', + async () => { + const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-health-')); + t.after(() => fs.rm(logDir, { recursive: true, force: true })); + const lines = Array.from( + { length: 305 }, + (_, index) => `line-${index + 1}`, + ); + await fs.writeFile(path.join(logDir, '2026-08-16.log'), 'older-log'); + await fs.writeFile(path.join(logDir, '2026-08-17.log'), lines.join('\n')); + config.systemLogPath = logDir; + config.port = 1; + process.env.QL_CONTAINER = 'true'; + + const result = await runCheck(); + assert.equal(result.response, undefined); + assert.match(result.error.message, /ECONNREFUSED|connect/); + assert.match( + result.error.message, + /http:\/\/localhost:1\/ql\/api\/health/, + ); + assert.match(result.error.message, /docker logs /); + assert.match(result.error.message, /line-305/); + assert.doesNotMatch(result.error.message, /line-1\n/); + assert.doesNotMatch(result.error.message, /qinglong-error\.log/); + }, + ); +}); diff --git a/test/back/scheduler-bulk-recovery.test.cjs b/test/back/scheduler-bulk-recovery.test.cjs new file mode 100644 index 00000000..87167ad9 --- /dev/null +++ b/test/back/scheduler-bulk-recovery.test.cjs @@ -0,0 +1,40 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const grpc = require('@grpc/grpc-js'); +const load = require('../helpers/load-security-module.cjs'); +const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness'); + +for (const [name, rows, delay, succeeds] of [ + ['large distinct snapshot', Array.from({length:10000}, (_, i) => ({id:String(i), schedule:`${i%60} ${Math.floor(i/60)%60} ${Math.floor(i/3600)} * * *`})), 10000, true], + ['extra schedules', [{id:'extra', schedule:'* * * * *', extra_schedules:Array.from({length:2000},()=>({schedule:'0 * * * *'}))}], 10000, true], + ['unresponsive small write', [{id:'small', schedule:'* * * * *'}], 30000, false], + ['unresponsive large write remains bounded', Array.from({length:30000},()=>({schedule:'* * * * *'})), 180000, false], +]) { + test(`recovery deadline: ${name}`, async (t) => { + t.mock.timers.enable({apis:['Date','setTimeout'], now:0}); + let calls=0, deadline; + const fake={waitForReady:(_,cb)=>cb(), addCron:(_req,_meta,options,cb)=>{ + calls++; deadline=options.deadline; + const budget=deadline-Date.now(); + setTimeout(()=>cb(delay<=budget ? null : Object.assign(Error('deadline'),{code:grpc.status.DEADLINE_EXCEEDED}), {}), Math.min(delay,budget)); + }}; + const client=load('back/schedule/client.ts', { + '../protos/cron':{CronClient:class{constructor(){return fake}}}, + '../config':{grpcPort:5500}, + '../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})}, + '@grpc/grpc-js':{...grpc,credentials:{createSsl:()=>({})}}, + }).default; + const readiness=new SchedulerReadiness(async()=>{}, 60000); + t.after(()=>clearTimeout(readiness.retry)); + client.readiness=readiness; + readiness.configure(()=>client.addCron(rows,true)); + const recovering=readiness.recover(); + await new Promise(setImmediate); + assert.equal(calls,1); + assert.ok(deadline<=120000); + t.mock.timers.tick(Math.min(delay,deadline)); + assert.equal(await recovering,succeeds); + assert.equal(calls,1,'uncertain writes are not replayed inline'); + assert.equal(await readiness.check(),succeeds); + }); +} diff --git a/test/back/scheduler-concurrency.test.cjs b/test/back/scheduler-concurrency.test.cjs new file mode 100644 index 00000000..99b4ef9f --- /dev/null +++ b/test/back/scheduler-concurrency.test.cjs @@ -0,0 +1,411 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const grpc = require('@grpc/grpc-js'); +const load = require('../helpers/load-security-module.cjs'); +const logger = { info() {}, debug() {}, warn() {}, error() {} }; +const tick = () => new Promise(setImmediate); +function createLimit() { + return load('back/shared/pLimit.ts', { + '../data/system': { + AuthDataType: { systemConfig: 'systemConfig' }, + SystemModel: { + sync: async () => {}, + findOne: async () => ({ info: { cronConcurrency: 1 } }), + }, + }, + '../loaders/logger': logger, + '../services/notify': class {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../config': {}, + '../config/grpcCerts': { getGrpcCerts: () => null }, + '../protos/api': {}, + }).default; +} +function createSystem(client, limit, options = {}) { + const store = options.store || { + state: { + id: 1, + type: 'systemConfig', + info: { cronConcurrency: 1, timezone: 'Asia/Shanghai' }, + }, + }; + const writes = []; + const Service = load('back/services/system.ts', { + '../config': options.config || {}, + '../shared/schedulerMutationLock': options.config + ? load('back/shared/schedulerMutationLock.ts', { + '../config': options.config, + }) + : { withSchedulerMutation: (operation) => operation() }, + '../config/const': {}, + '../config/util': {}, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': { + AuthDataType: { systemConfig: 'systemConfig' }, + SystemModel: { + findOne: async () => ({ + ...store.state, + get: () => ({ ...store.state }), + }), + update: async (values) => { + writes.push(values); + store.state = { ...store.state, ...values }; + }, + }, + }, + '../shared/pLimit': limit, + '../schedule/client': client, + '../shared/i18n': { t: (x) => x }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + }).default; + return { + service: new Service(logger, {}, {}), + writes, + state: () => store.state, + }; +} +async function rpcFixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-concurrency-rpc-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const certs = await load('back/config/grpcCerts.ts', { + './index': { configPath: root }, + './util': { fileExist: async (p) => fs.existsSync(p) }, + '../loaders/logger': logger, + }).initGrpcCerts(); + const schedulerLimit = createLimit(), + apiLimit = createLimit(); + await tick(); + const handler = load('back/schedule/setConcurrency.ts', { + '../shared/pLimit': schedulerLimit, + }); + let port; + const Grpc = load('back/services/grpc.ts', { + '../config': { bindHostGrpc: '127.0.0.1', grpcPort: 0 }, + '../config/grpcCerts': { initGrpcCerts: async () => certs }, + '../loaders/logger': logger, + './metrics': { metricsService: { record() {} } }, + '../schedule/addCron': {}, + '../schedule/delCron': {}, + '../schedule/api': {}, + '../schedule/health': { check: (_call, cb) => cb(null, { status: 1 }) }, + '../schedule/setConcurrency': handler, + '@grpc/grpc-js': { + ...grpc, + Server: class extends grpc.Server { + bindAsync(address, creds, cb) { + super.bindAsync(address, creds, (error, boundPort) => { + port = boundPort; + cb(error, boundPort); + }); + } + }, + }, + }).GrpcServerService; + const server = new Grpc(); + await server.initialize(); + const client = load('back/schedule/client.ts', { + '../config': { grpcPort: port }, + '../config/grpcCerts': { getGrpcCerts: () => certs }, + }).default; + t.after(async () => { + client._client?.close(); + await server.shutdown(); + }); + return { + ...createSystem(client, apiLimit), + schedulerLimit, + apiLimit, + client, + }; +} +function enqueue(t, limit, count) { + const releases = [], + runs = []; + for (let i = 0; i < count; i++) { + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + releases.push(release); + runs.push(limit.runWithCronLimit({ id: String(i) }, () => gate)); + } + t.after(async () => { + releases.forEach((release) => release()); + await Promise.all(runs); + }); + return { releases, runs }; +} + +test('concurrency changes reach the independent scheduler queue over mTLS immediately and preserve running jobs', async (t) => { + const f = await rpcFixture(t); + const { releases, runs } = enqueue(t, f.schedulerLimit, 3); + await tick(); + assert.equal(f.schedulerLimit.cronLimitActiveCount, 1); + assert.equal(f.schedulerLimit.cronLimitPendingCount, 2); + assert.equal( + (await f.service.updateCronConcurrency({ cronConcurrency: 2 })).code, + 200, + ); + await tick(); + assert.equal(f.schedulerLimit.cronLimitActiveCount, 2); + assert.equal(f.schedulerLimit.cronLimitPendingCount, 1); + await f.service.updateCronConcurrency({ cronConcurrency: 1 }); + assert.equal( + f.schedulerLimit.cronLimitActiveCount, + 2, + 'lowering the limit must not stop active jobs', + ); + releases[0](); + await runs[0]; + await tick(); + assert.equal(f.schedulerLimit.cronLimitActiveCount, 1); + assert.equal(f.schedulerLimit.cronLimitPendingCount, 1); + releases[1](); + await runs[1]; + await tick(); + assert.equal(f.schedulerLimit.cronLimitActiveCount, 1); + assert.equal(f.schedulerLimit.cronLimitPendingCount, 0); + releases[2](); + await runs[2]; + assert.equal(f.state().info.cronConcurrency, 1); +}); + +test('zero, null and omitted concurrency restore the default in both API and scheduler queues without a restart', async (t) => { + const f = await rpcFixture(t); + for (const value of [0, null, undefined]) { + await f.service.updateCronConcurrency({ cronConcurrency: 1 }); + const scheduler = enqueue(t, f.schedulerLimit, 4), + manual = enqueue(t, f.apiLimit, 4); + await tick(); + assert.equal(f.schedulerLimit.cronLimitActiveCount, 1); + assert.equal(f.apiLimit.cronLimitActiveCount, 1); + await f.service.updateCronConcurrency( + value === undefined ? {} : { cronConcurrency: value }, + ); + await tick(); + assert.equal( + f.state().info.cronConcurrency, + 0, + 'persist the same default that both queues apply', + ); + assert.equal(f.schedulerLimit.cronLimitActiveCount, 4); + assert.equal(f.apiLimit.cronLimitActiveCount, 4); + [...scheduler.releases, ...manual.releases].forEach((release) => release()); + await Promise.all([...scheduler.runs, ...manual.runs]); + } +}); + +test('invalid concurrency is rejected before configuration writes or queue changes', async () => { + const mutations = []; + const f = createSystem( + { setConcurrency: async (value) => mutations.push(value) }, + { setCustomLimit: async (value) => mutations.push(value) }, + ); + for (const value of [-1, 1.5, NaN, Infinity, 2147483648]) + await assert.rejects( + f.service.updateCronConcurrency({ cronConcurrency: value }), + { status: 400 }, + ); + assert.deepEqual(f.writes, []); + assert.deepEqual(mutations, []); + const handler = load('back/schedule/setConcurrency.ts', { + '../shared/pLimit': { + setCustomLimit: async (value) => mutations.push(value), + }, + }).setConcurrency; + for (const value of [-1, 0.5, 2147483648]) + await handler({ request: { concurrency: value } }, (error) => + assert.equal(error.code, grpc.status.INVALID_ARGUMENT), + ); + assert.deepEqual(mutations, []); +}); + +for (const code of [grpc.status.UNAVAILABLE, grpc.status.DEADLINE_EXCEEDED]) { + test(`concurrency RPC failure ${code} invalidates readiness and is not reported as success or retried`, async () => { + let writes = 0, + invalidations = 0; + const fake = { + waitForReady: (_deadline, callback) => callback(), + setConcurrency: (_request, _metadata, options, callback) => { + assert.ok(options.deadline > Date.now()); + writes++; + callback(Object.assign(new Error('scheduler unavailable'), { code })); + }, + }; + const client = load('back/schedule/client.ts', { + '../protos/cron': { + CronClient: class { + constructor() { + return fake; + } + }, + }, + '../config': { grpcPort: 1 }, + '../config/grpcCerts': { + getGrpcCerts: () => ({ + caCert: 'ca', + clientKey: 'key', + clientCert: 'cert', + }), + }, + '@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } }, + }).default; + client.readiness.invalidate = () => invalidations++; + const f = createSystem(client, { setCustomLimit: async () => {} }); + await assert.rejects( + f.service.updateCronConcurrency({ cronConcurrency: 2 }), + { status: 503 }, + ); + assert.equal(writes, 1); + assert.equal(invalidations, 1); + }); +} + +function mutationConfig(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-concurrency-lock-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return { crontabFile: path.join(root, 'crontab.list') }; +} + +test('independent API service instances serialize database and scheduler updates with the shared filesystem lock', async (t) => { + const config = mutationConfig(t); + const store = { state: { id: 1, info: { cronConcurrency: 1 } } }; + let release, entered; + const firstEntered = new Promise((resolve) => (entered = resolve)); + const gate = new Promise((resolve) => (release = resolve)); + t.after(() => release()); + let scheduler = 1, + api = 1; + const client = { + setConcurrency: async (value) => { + if (value === 10) { + entered(); + await gate; + } + scheduler = value; + }, + }; + const limit = { setCustomLimit: async (value) => (api = value) }; + const a = createSystem(client, limit, { config, store }); + const b = createSystem(client, limit, { config, store }); + const first = a.service.updateCronConcurrency({ cronConcurrency: 10 }); + await firstEntered; + const second = b.service.updateCronConcurrency({ cronConcurrency: 1 }); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal( + b.writes.length, + 0, + 'a second writer cannot persist ahead of the first RPC', + ); + release(); + await Promise.all([first, second]); + assert.deepEqual( + [store.state.info.cronConcurrency, api, scheduler], + [1, 1, 1], + ); +}); + +for (const newerLimit of [undefined, 2]) { + test(`production recovery restores persisted concurrency before health becomes ready (newer limit: ${newerLimit})`, async (t) => { + const config = mutationConfig(t); + const store = { state: { id: 1, info: { cronConcurrency: 1 } } }; + let scheduler = 1, + api = 1, + fail = false, + restoreJobs = 0; + let unblockRecovery, recoveryEntered; + const entered = new Promise((resolve) => (recoveryEntered = resolve)); + const gate = new Promise((resolve) => (unblockRecovery = resolve)); + t.after(() => unblockRecovery()); + const fake = { + waitForReady: (_deadline, callback) => callback(), + makeUnaryRequest: (...args) => args.at(-1)(null, { status: 1 }), + setConcurrency: ({ concurrency }, _metadata, _options, callback) => { + if (fail) { + fail = false; + callback( + Object.assign(new Error('lost RPC'), { + code: grpc.status.UNAVAILABLE, + }), + ); + } else { + scheduler = concurrency; + callback(null, {}); + } + }, + }; + const client = load('back/schedule/client.ts', { + '../protos/cron': { + CronClient: class { + constructor() { + return fake; + } + }, + }, + '../config': { grpcPort: 1 }, + '../config/grpcCerts': { + getGrpcCerts: () => ({ + caCert: 'ca', + clientKey: 'key', + clientCert: 'cert', + }), + }, + '@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } }, + }).default; + const f = createSystem( + client, + { setCustomLimit: async (value) => (api = value) }, + { config, store }, + ); + const source = fs.readFileSync('back/loaders/initData.ts', 'utf8'); + const callback = source.match( + /cronClient\.readiness\.configure\((async \(\) => \{[\s\S]*?\n \})\);/, + )[1]; + const restore = new Function( + 'cronService', + 'systemService', + `return ${callback}`, + )( + { + autosave_crontab: async (strict) => { + assert.equal(strict, true); + restoreJobs++; + if (restoreJobs > 1) { + recoveryEntered(); + await gate; + } + }, + }, + f.service, + ); + client.readiness.configure(restore); + assert.equal(await client.readiness.recover(), true); + fail = true; + await assert.rejects( + f.service.updateCronConcurrency({ cronConcurrency: 10 }), + { status: 503 }, + ); + await entered; + assert.equal(await client.readiness.check(), false); + assert.equal(scheduler, 1); + // A newer successful request must win over the failed request being recovered. + if (newerLimit !== undefined) + await f.service.updateCronConcurrency({ cronConcurrency: newerLimit }); + scheduler = 1; // Simulate the scheduler restarting while recovery is in progress. + const recovered = client.readiness.recover(); + unblockRecovery(); + assert.equal(await recovered, true); + const expected = newerLimit ?? 10; + assert.deepEqual( + [store.state.info.cronConcurrency, api, scheduler], + [expected, expected, expected], + ); + assert.equal(await client.readiness.check(), true); + }); +} diff --git a/test/back/scheduler-file-lock.test.cjs b/test/back/scheduler-file-lock.test.cjs new file mode 100644 index 00000000..b469478b --- /dev/null +++ b/test/back/scheduler-file-lock.test.cjs @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); + +for (const failOperation of [false, true]) { + test(`scheduler lock survives nested file writes and releases after ${ + failOperation ? 'failure' : 'success' + }`, async (t) => { + // Match production's canonical /ql path even when macOS temp is a symlink. + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'ql-nested-lock-')), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const config = { crontabFile: path.join(root, 'crontab.list') }; + await fs.writeFile(config.crontabFile, ''); + const { withSchedulerMutation } = load( + 'back/shared/schedulerMutationLock.ts', + { + '../config': config, + }, + ); + const { writeFileWithLock } = load('back/shared/utils.ts', { + '../config/util': { + fileExist: async (file) => + fs.access(file).then( + () => true, + () => false, + ), + }, + }); + const failure = new Error('registration failed'); + const operation = withSchedulerMutation(async () => { + await writeFileWithLock(config.crontabFile, 'first'); + await writeFileWithLock(config.crontabFile, 'second'); + if (failOperation) throw failure; + }); + if (failOperation) + await assert.rejects(operation, (error) => error === failure); + else await operation; + await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), { + code: 'ENOENT', + }); + await withSchedulerMutation(() => + writeFileWithLock(config.crontabFile, 'recovered'), + ); + assert.equal(await fs.readFile(config.crontabFile, 'utf8'), 'recovered'); + }); +} diff --git a/test/back/scheduler-mutation.test.cjs b/test/back/scheduler-mutation.test.cjs new file mode 100644 index 00000000..36223c2f --- /dev/null +++ b/test/back/scheduler-mutation.test.cjs @@ -0,0 +1,312 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { setTimeout: delay } = require('node:timers/promises'); +const { Sequelize, DataTypes } = require('sequelize'); +const { status } = require('@grpc/grpc-js'); +const load = require('../helpers/load-security-module.cjs'); + +function gate() { + let release; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +async function fixture(t) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-scheduler-mutation-'), + ); + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + const crons = db.define('Cron', { + name: DataTypes.STRING, + command: DataTypes.STRING, + schedule: DataTypes.STRING, + isDisabled: DataTypes.INTEGER, + saved: DataTypes.BOOLEAN, + queued_token: DataTypes.STRING, + }); + await db.sync(); + t.after(async () => { + await db.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + const jobs = new Map(); + const client = { + addCron: async (items, replace) => { + if (replace) jobs.clear(); + items.forEach((item) => jobs.set(item.id, item.command)); + }, + delCron: async (ids) => ids.forEach((id) => jobs.delete(id)), + }; + const CronService = load('back/services/cron.ts', { + '../config': { + crontabFile: path.join(root, 'crontab.list'), + logPath: root, + }, + '../data/cron': { + CrontabModel: crons, + Crontab: class { + constructor(options) { + Object.assign(this, { isDisabled: 0 }, options); + } + }, + }, + '../data/runningInstance': {}, + '../config/util': { isDemoEnv: () => false }, + '../config/const': {}, + '../schedule/client': client, + '../shared/pLimit': {}, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + }).default; + const service = new CronService({ error() {}, warn() {}, info() {} }); + service.getLogName = async () => 'task'; + service.shouldUseCronClient = () => true; + service.makeCommand = (doc) => doc.command; + service.crontabs = async () => ({ data: await crons.findAll({ raw: true }) }); + service.setCrontab = async () => {}; + return { service, crons, jobs, client, root }; +} + +for (const operation of ['create', 'update', 'remove', 'disabled', 'enabled']) { + test(`recovery snapshot cannot overwrite concurrent ${operation}`, async (t) => { + const { service, crons, jobs } = await fixture(t); + await crons.create({ + id: 1, + command: 'old', + schedule: '* * * * *', + isDisabled: operation === 'enabled' ? 1 : 0, + }); + const entered = gate(), + release = gate(); + let first = true; + service.setCrontab = async () => { + if (first) { + first = false; + entered.release(); + await release.promise; + } + }; + const recovery = service.autosave_crontab(true); + await entered.promise; + const before = await crons.findAll({ raw: true }); + const mutation = + operation === 'create' + ? service.create({ command: 'new', schedule: '* * * * *' }) + : operation === 'update' + ? service.update({ id: 1, command: 'new' }) + : service[operation]([1]); + await delay(50); + assert.deepEqual( + await crons.findAll({ raw: true }), + before, + 'DB writes must also wait for recovery', + ); + release.release(); + await Promise.all([recovery, mutation]); + const rows = await crons.findAll({ raw: true }); + assert.deepEqual( + [...jobs.entries()].sort(), + rows + .filter((row) => !row.isDisabled) + .map((row) => [String(row.id), row.command]) + .sort(), + ); + }); +} + +test('recovery waits for an admitted mutation to finish registration before reading the DB', async (t) => { + const { service, client, jobs } = await fixture(t); + const entered = gate(), + release = gate(); + const add = client.addCron; + client.addCron = async (items, replace) => { + if (!replace) { + entered.release(); + await release.promise; + } + return add(items, replace); + }; + const mutation = service.create({ command: 'new', schedule: '* * * * *' }); + await entered.promise; + let reads = 0; + const read = service.crontabs; + service.crontabs = async () => { + reads++; + return read(); + }; + const recovery = service.autosave_crontab(true); + await delay(50); + assert.equal(reads, 0); + release.release(); + await Promise.all([mutation, recovery]); + assert.deepEqual([...jobs.values()], ['new']); +}); + +for (const operation of ['create', 'update', 'enabled']) { + test(`${operation} preserves scheduler 503 after rollback and releases its lock`, async (t) => { + const { service, crons, client } = await fixture(t); + if (operation !== 'create') + await crons.create({ + id: 1, + command: 'old', + schedule: '* * * * *', + isDisabled: operation === 'enabled' ? 1 : 0, + }); + const cause = Object.assign(Error('unavailable'), { status: 503 }); + client.addCron = async () => { + throw cause; + }; + const mutation = + operation === 'create' + ? service.create({ command: 'new', schedule: '* * * * *' }) + : operation === 'update' + ? service.update({ id: 1, command: 'new' }) + : service.enabled([1]); + await assert.rejects( + mutation, + (err) => + err.status === 503 && err.cause === cause && /回滚/.test(err.message), + ); + if (operation === 'create') assert.equal(await crons.count(), 0); + else { + const row = await crons.findByPk(1); + assert.equal(row.command, 'old'); + assert.equal(row.isDisabled, operation === 'enabled' ? 1 : 0); + } + client.addCron = async () => {}; + await service.autosave_crontab(true); + }); +} + +test( + 'scheduler lock excludes a second OS process and leaves no lock after completion', + { timeout: 10000 }, + async (t) => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-scheduler-process-'), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const config = { crontabFile: path.join(root, 'crontab.list') }; + const { withSchedulerMutation } = load( + 'back/shared/schedulerMutationLock.ts', + { '../config': config }, + ); + const entered = gate(), + release = gate(); + const owner = withSchedulerMutation(async () => { + entered.release(); + await release.promise; + }); + await entered.promise; + const child = spawn( + process.execPath, + [ + '-e', + ` + const load = require('./test/helpers/load-security-module.cjs'); + const { withSchedulerMutation } = load('back/shared/schedulerMutationLock.ts', {'../config': JSON.parse(process.argv[1])}); + process.send('attempting'); + withSchedulerMutation(async () => process.send('entered')).then(() => process.disconnect()).catch(err => { console.error(err); process.exit(1); }); + `, + JSON.stringify(config), + ], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + release.release(); + }); + const closed = once(child, 'close'); + assert.equal((await once(child, 'message'))[0], 'attempting'); + let acquired = false; + const acquisition = once(child, 'message').then(([message]) => { + acquired = true; + assert.equal(message, 'entered'); + }); + await delay(150); + assert.equal(acquired, false); + release.release(); + await owner; + await acquisition; + assert.equal((await closed)[0], 0); + await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), { + code: 'ENOENT', + }); + }, +); + +for (const operation of ['update', 'remove', 'disabled']) { + for (const code of [status.DEADLINE_EXCEEDED, status.UNAVAILABLE]) { + for (const applied of [false, true]) { + test(`${operation} aborts on RPC ${code} with deletion applied=${applied} and reconciles the original DB`, async (t) => { + const { service, crons, client, jobs } = await fixture(t); + await crons.create({ + id: 1, command: 'old', schedule: '* * * * *', isDisabled: 0, + }); + jobs.set('1', 'old'); + const before = await crons.findAll({ raw: true }); + const failure = Object.assign(Error('uncertain deletion'), { code, status: 503 }); + let deletes = 0; + client.delCron = async (ids) => { + deletes++; + if (applied) ids.forEach((id) => jobs.delete(id)); + throw failure; + }; + const add = client.addCron; + client.addCron = async () => assert.fail('must not register after failed deletion'); + service.setCrontab = async () => assert.fail('must not publish after failed deletion'); + await assert.rejects( + operation === 'update' + ? service.update({ id: 1, command: 'new' }) + : service[operation]([1]), + (error) => error === failure && error.status === 503, + ); + assert.equal(deletes, 1, 'must not replay an uncertain deletion'); + assert.deepEqual(await crons.findAll({ raw: true }), before); + client.addCron = add; + service.setCrontab = async () => {}; + await service.autosave_crontab(true); + assert.deepEqual([...jobs.entries()], [['1', 'old']]); + }); + } + } +} + +test('configuration rollback cannot restore an obsolete manual queue token', async (t) => { + const { service, crons, client } = await fixture(t); + await crons.create({ + id: 1, + command: 'old', + schedule: '* * * * *', + isDisabled: 0, + queued_token: 'old-token', + }); + let first = true; + client.addCron = async () => { + if (first) { + first = false; + await crons.update({ queued_token: 'new-token' }, { where: { id: 1 } }); + throw Object.assign(Error('unavailable'), { status: 503 }); + } + }; + await assert.rejects( + service.update({ id: 1, command: 'new' }), + (err) => err.status === 503, + ); + const row = await crons.findByPk(1); + assert.equal(row.command, 'old'); + assert.equal(row.queued_token, 'new-token'); +}); diff --git a/test/back/scheduler-readiness.test.cjs b/test/back/scheduler-readiness.test.cjs new file mode 100644 index 00000000..1fe68834 --- /dev/null +++ b/test/back/scheduler-readiness.test.cjs @@ -0,0 +1,85 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const {setTimeout:sleep}=require('node:timers/promises'); +const {SchedulerReadiness}=require('../../back/shared/schedulerReadiness'); +const load=require('../helpers/load-security-module.cjs'); +const fs=require('node:fs'); +const ts=require('typescript'); + +test('readiness stays false through failed restoration and retries without idle polling',async()=>{ + let probes=0,restores=0,fail=true; + const state=new SchedulerReadiness(async()=>{probes++;},10); + state.configure(async()=>{restores++;if(fail)throw Error('registration failed');}); + assert.equal(await state.recover(),false);assert.equal(await state.check(),false); + fail=false;await sleep(40);assert.equal(await state.check(),true);assert.ok(restores>=2); + const before=probes;await sleep(40);assert.equal(probes,before); +}); +test('single-flight recovery cannot mark an invalidated generation ready',async()=>{ + let release,restores=0; + const state=new SchedulerReadiness(async()=>{},10); + state.configure(async()=>{if(++restores===1)await new Promise(r=>release=r);}); + const a=state.recover();assert.equal(state.recover(),a);await sleep(1); + state.invalidate();release();assert.equal(await a,false);assert.equal(await state.check(),false); + await sleep(30);assert.equal(await state.check(),true);assert.equal(restores,2); +}); +test('mutation waiting is bounded, a later recovery can succeed',async()=>{ + let release; + const state=new SchedulerReadiness(async()=>{},10);state.configure(()=>new Promise(r=>release=r)); + await assert.rejects(state.ensureReady(15),e=>e.status===503); + release();await sleep(1);assert.equal(await state.check(),true); +}); +test('probe failure immediately invalidates a previously ready scheduler',async()=>{ + let fail=false; + const state=new SchedulerReadiness(async()=>{if(fail)throw Error('unavailable');},10); + state.configure(async()=>{});assert.equal(await state.recover(),true); + fail=true;assert.equal(await state.check(),false); + fail=false;await sleep(40);assert.equal(await state.check(),true); +}); +test('health uses actual readiness and returns HTTP 503 until recovery',async(t)=>{ + let ready=false;const express=require('express'); + const {HealthService}=load('back/services/health.ts',{ + typedi:{Service:()=>x=>x},'../loaders/logger':{error(){}},'./http':{}, + '../schedule/client':{readiness:{check:async()=>ready}}, + }); + const service=new HealthService({getServer:()=>({})}); + const app=express();load('back/api/health.ts',{ + typedi:{get:()=>service},'../services/health':{HealthService},'../loaders/logger':{error(){}}, + }).default(app); + const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r)); + t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();})); + for(const expected of [503,200,503]){ + ready=expected===200;const r=await fetch(`http://127.0.0.1:${server.address().port}/health`);const b=await r.json(); + assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready); + } +}); +test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{ + const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a); + const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText; + const module={exports:{}};let files=0; + new Function('module','isDemoEnv','cronClient','withSchedulerMutation',js)(module,()=>false,{addCron:async()=>{throw Error('registration unavailable');}},fn=>fn()); + const fixture=new module.exports();fixture.crontabs=async()=>({data:[]});fixture.setCrontab=async()=>{files++;};fixture.logger={warn(){}}; + await fixture.autosave_crontab();assert.equal(files,1); + await assert.rejects(fixture.autosave_crontab(true),/registration unavailable/);assert.equal(files,2); +}); +test('scheduler probe uses the cron channel and failed writes are not replayed',async()=>{ + const calls=[];const fake={ + waitForReady(deadline,cb){calls.push(['wait',deadline]);cb();}, + makeUnaryRequest(path,serialize,deserialize,request,options,cb){calls.push(['probe',path,request,options]);cb(null,{status:1});}, + addCron(request,metadata,options,cb){calls.push(['add',request,options]);cb(Object.assign(Error('invalid'),{code:3}));}, + }; + const client=load('back/schedule/client.ts',{ + '../protos/cron':{CronClient:class{constructor(){return fake;}}}, + '../config':{grpcPort:5500},'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})}, + '@grpc/grpc-js':{...require('@grpc/grpc-js'),credentials:{createSsl:()=>({})},status:{UNAVAILABLE:14},Metadata:class{}}, + }).default; + client.readiness.configure(async()=>{});assert.equal(await client.readiness.recover(),true); + assert.ok(calls.filter(x=>x[0]==='probe').every(x=>x[1]==='/com.ql.health.Health/Check'&&x[2].service==='scheduler'&&x[3].deadline>Date.now())); + await assert.rejects(client.addCron([]),/invalid/);assert.equal(calls.filter(x=>x[0]==='add').length,1); +}); +test('scheduler health probe never calls back into HTTP health',async()=>{ + const {check}=load('back/schedule/health.ts',{ + '../config':{},undici:{request:()=>{throw Error('recursive HTTP health call');}}, + }); + const result=await new Promise(resolve=>check({request:{service:'scheduler'}},(error,response)=>resolve({error,response}))); + assert.equal(result.error,null);assert.deepEqual(result.response,{status:1}); +}); diff --git a/test/back/scheduler-reconciliation.test.cjs b/test/back/scheduler-reconciliation.test.cjs new file mode 100644 index 00000000..566ff1ab --- /dev/null +++ b/test/back/scheduler-reconciliation.test.cjs @@ -0,0 +1,206 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); +const load = require('../helpers/load-security-module.cjs'); +const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness'); +const { AddCronRequest } = require('../../back/protos/cron'); +const { getInvalidCronSchedules } = require('../../back/shared/cronSchedule'); + +function fixture(client) { + const source = fs.readFileSync('back/services/cron.ts', 'utf8'); + const a = source.indexOf(' public async autosave_crontab('); + const z = source.indexOf(' public async bootTask', a); + const js = ts.transpileModule( + 'class Fixture {\n' + source.slice(a, z) + '}\nmodule.exports=Fixture;', + { compilerOptions: { target: ts.ScriptTarget.ES2020 } }, + ).outputText; + const module = { exports: {} }; + new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', 'getInvalidCronSchedules', js)( + module, + () => false, + client, + (fn) => fn(), + getInvalidCronSchedules, + ); + const service = new module.exports(); + service.setCrontab = async () => {}; + service.logger = { warn() {} }; + service.shouldUseCronClient = () => true; + service.makeCommand = () => 'true'; + return service; +} + +test('recovery reconciles a surviving scheduler after missed deletes and disables, including an empty DB', async () => { + const cancelled = []; + const stacks = new Map( + ['removed', 'disabled', 'kept'].map((id) => [ + id, + [{ cancel: () => cancelled.push(id) }], + ]), + ); + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + '../shared/cronScheduler': { + createCronJob: (_schedule, _callback, options) => ({ + start() {}, cancel: () => cancelled.push(options.name.split(':')[0]), + }), + }, + '../shared/runCron': {}, + '../loaders/logger': { info() {}, warn() {} }, + '../shared/i18n': { tf: (s) => s }, + }); + const client = { + addCron: async (crons, replace) => { + // Exercise the real protobuf field, not just a JavaScript-only flag. + const request = AddCronRequest.decode( + AddCronRequest.encode({ crons, replace }).finish(), + ); + await new Promise((resolve, reject) => + addCron({ request }, (err) => (err ? reject(err) : resolve())), + ); + }, + }; + const service = fixture(client); + let rows = [ + { id: 'kept', schedule: '* * * * *', isDisabled: 0 }, + { id: 'disabled', schedule: '* * * * *', isDisabled: 1 }, + ]; + service.crontabs = async () => ({ data: rows }); + const state = new SchedulerReadiness(async () => {}); + state.configure(() => service.autosave_crontab(true)); + assert.equal(await state.recover(), true); + assert.equal(await state.check(), true); + assert.deepEqual([...stacks.keys()], ['kept']); + assert.deepEqual(cancelled.sort(), ['disabled', 'kept', 'removed']); + rows = []; + state.invalidate(); + assert.equal(await state.recover(), true); + assert.equal(stacks.size, 0); + assert.equal(await state.check(), true); +}); + +test('invalid replacement leaves the previous schedule intact', async () => { + const stacks = new Map([ + [ + 'old', + [ + { + cancel: () => { + throw Error('must not cancel'); + }, + }, + ], + ], + ]); + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + '../shared/runCron': {}, + '../loaders/logger': {}, + '../shared/i18n': { tf: (s) => s }, + }); + await assert.rejects( + new Promise((resolve, reject) => + addCron( + { + request: { + replace: true, + crons: [{ id: 'bad', schedule: 'not a cron', extra_schedules: [] }], + }, + }, + (err) => (err ? reject(err) : resolve()), + ), + ), + ); + assert.deepEqual([...stacks.keys()], ['old']); +}); + +test('invalid persisted schedules cannot block recovery, valid jobs or HTTP health', async (t) => { + const stacks = new Map(); + const warnings = []; + const { addCron } = load('back/schedule/addCron.ts', { + './data': { scheduleStacks: stacks }, + '../shared/runCron': {}, + '../loaders/logger': { info() {}, warn() {} }, + '../shared/i18n': { tf: require('node:util').format }, + }); + const client = { addCron: (crons, replace) => new Promise((resolve, reject) => { + addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve()); + }) }; + const service = fixture(client); + let snapshot; + service.setCrontab = async (tabs) => { snapshot = tabs.data; }; + service.logger.warn = (...args) => warnings.push(require('node:util').format(...args)); + let rows = [ + { id: 'good', name: 'valid', schedule: '0 0/30 * * * ?', isDisabled: 0 }, + { id: 'bad-main', name: 'bad main', schedule: 'not a cron', isDisabled: 0 }, + { id: 'bad-extra', name: 'bad extra', schedule: '* * * * *', extra_schedules: [{ schedule: '0 70 * * * ?' }], isDisabled: 0 }, + { id: 'unsupported', name: 'unsupported', schedule: 'H * * * *', isDisabled: 0 }, + { id: 'disabled', name: 'disabled', schedule: 'bad', isDisabled: 1 }, + ]; + service.crontabs = async () => ({ data: rows }); + const state = new SchedulerReadiness(async () => {}, 60000); + t.after(() => { + clearTimeout(state.retry); + for (const jobs of stacks.values()) for (const job of jobs) job.cancel(); + }); + state.configure(() => service.autosave_crontab(true)); + assert.equal(await state.recover(), true); + assert.deepEqual([...stacks.keys()], ['good']); + assert.deepEqual(snapshot.map((x) => x.id), ['good', 'disabled']); + assert.equal(rows.length, 5, 'persisted rows are preserved for editing'); + assert.equal(rows[1].isDisabled, 0); + assert.equal(warnings.length, 3); + assert.match(warnings.join('\n'), /bad-main.*bad main.*not a cron/); + assert.match(warnings.join('\n'), /bad-extra.*0 70/); + const { HealthService } = load('back/services/health.ts', { + typedi: { Service: () => (x) => x }, './http': {}, + '../schedule/client': { readiness: state }, '../loaders/logger': { error() {} }, + }); + const health = new HealthService({ getServer: () => ({}) }); + assert.equal((await health.check()).status, 'ok'); + rows = rows.filter((x) => x.id !== 'good'); + assert.equal(await state.recover(), true, 'even all-invalid snapshots can recover'); + assert.equal(stacks.size, 0); + assert.equal((await health.check()).status, 'ok'); +}); + +test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => { + for (const method of ['addCron', 'delCron']) { + let invalidations = 0, + writes = 0; + const fake = { + waitForReady: (_deadline, cb) => cb(Error('channel unavailable')), + addCron: () => writes++, + delCron: () => writes++, + }; + const client = load('back/schedule/client.ts', { + '../protos/cron': { + CronClient: class { + constructor() { + return fake; + } + }, + }, + '../config': { grpcPort: 5500 }, + '../config/grpcCerts': { + getGrpcCerts: () => ({ + caCert: 'ca', + clientKey: 'key', + clientCert: 'cert', + }), + }, + '@grpc/grpc-js': { + ...require('@grpc/grpc-js'), + credentials: { createSsl: () => ({}) }, + }, + }).default; + client.readiness.invalidate = () => invalidations++; + await assert.rejects( + client[method]([]), + (err) => err.status === 503 && /channel unavailable/.test(err.message), + ); + assert.equal(invalidations, 1); + assert.equal(writes, 0); + } +}); diff --git a/test/back/scheduler-rpc-timeout.test.cjs b/test/back/scheduler-rpc-timeout.test.cjs new file mode 100644 index 00000000..e76d01aa --- /dev/null +++ b/test/back/scheduler-rpc-timeout.test.cjs @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const grpc = require('@grpc/grpc-js'); +const load = require('../helpers/load-security-module.cjs'); + +for (const method of ['addCron', 'delCron']) { + test(`${method} invalidates an uncertain timed-out write without replaying it`, async () => { + let writes = 0; + let invalidations = 0; + const timeout = Object.assign(new Error('response deadline exceeded'), { + code: grpc.status.DEADLINE_EXCEEDED, + }); + const fake = { + waitForReady: (_deadline, callback) => callback(), + [method]: (_request, _metadata, _options, callback) => { + // The server may have applied the write before the response was lost. + writes++; + callback(timeout); + }, + }; + const client = load('back/schedule/client.ts', { + '../protos/cron': { + CronClient: class { + constructor() { + return fake; + } + }, + }, + '../config': { grpcPort: 5500 }, + '../config/grpcCerts': { + getGrpcCerts: () => ({ + caCert: 'ca', + clientKey: 'key', + clientCert: 'cert', + }), + }, + '@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } }, + }).default; + client.readiness.invalidate = () => invalidations++; + await assert.rejects(client[method]([]), (error) => error === timeout); + assert.equal( + invalidations, + 1, + 'reconcile from the DB after an uncertain RPC result', + ); + assert.equal(timeout.status, 503); + assert.equal(writes, 1, 'do not replay an uncertain mutation'); + }); +} diff --git a/test/back/schema-migrations.test.cjs b/test/back/schema-migrations.test.cjs new file mode 100644 index 00000000..e1249aff --- /dev/null +++ b/test/back/schema-migrations.test.cjs @@ -0,0 +1,127 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { Sequelize, QueryTypes } = require('sequelize'); +const { migrateSchema } = require('../../back/shared/schemaMigrations'); +const load = require('../helpers/load-security-module.cjs'); + +async function createLegacy(storage = ':memory:', omitEnvs = false) { + const database = new Sequelize({ + dialect: 'sqlite', + storage, + logging: false, + }); + for (const table of [ + 'CrontabViews', + 'Subscriptions', + 'Crontabs', + ...(omitEnvs ? [] : ['Envs']), + ]) { + await database.query( + `CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`, + ); + } + await database.query( + 'INSERT INTO "Crontabs" (id, name) VALUES (1, \'keep-me\')', + ); + return database; +} + +test('legacy database upgrades without losing rows and repeated migration is idempotent', async (t) => { + const database = await createLegacy(); + t.after(() => database.close()); + await migrateSchema(database); + await migrateSchema(database); + const rows = await database.query('SELECT * FROM "Crontabs"', { + type: QueryTypes.SELECT, + }); + assert.equal(rows[0].name, 'keep-me'); + assert.equal(rows[0].queued_token, null); + assert.ok(Object.hasOwn(rows[0], 'allow_multiple_instances')); + const applied = await database.query('SELECT id FROM "SchemaMigrations"', { + type: QueryTypes.SELECT, + }); + assert.equal(applied.length, 15); +}); + +test('migration failure rolls back added columns and can be retried after repair', async (t) => { + const database = await createLegacy(':memory:', true); + t.after(() => database.close()); + await assert.rejects(migrateSchema(database)); + const columns = await database.getQueryInterface().describeTable('Crontabs'); + assert.equal(Object.hasOwn(columns, 'work_dir'), false); + await database.query( + 'CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT)', + ); + await migrateSchema(database); + assert.ok( + Object.hasOwn( + await database.getQueryInterface().describeTable('Crontabs'), + 'work_dir', + ), + ); +}); + +test('offline pre-upgrade backup restores the legacy schema and data', async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-migration-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'database.sqlite'); + const backup = path.join(dir, 'before.sqlite'); + let database = await createLegacy(file); + await database.close(); + await fs.copyFile(file, backup); + database = new Sequelize({ + dialect: 'sqlite', + storage: file, + logging: false, + }); + await migrateSchema(database); + await database.close(); + await fs.copyFile(backup, file); + database = new Sequelize({ + dialect: 'sqlite', + storage: file, + logging: false, + }); + try { + assert.equal( + Object.hasOwn( + await database.getQueryInterface().describeTable('Crontabs'), + 'work_dir', + ), + false, + ); + const rows = await database.query('SELECT name FROM "Crontabs"', { + type: QueryTypes.SELECT, + }); + assert.equal(rows[0].name, 'keep-me'); + } finally { + await database.close(); + } +}); + +test('database loader rejects initialization failure rather than allowing workers to start', async () => { + const mocks = { './logger': { error() {}, info() {} } }; + for (const [file, model] of [ + ['env', 'EnvModel'], + ['cron', 'CrontabModel'], + ['dependence', 'DependenceModel'], + ['open', 'AppModel'], + ['system', 'SystemModel'], + ['subscription', 'SubscriptionModel'], + ['cronView', 'CrontabViewModel'], + ['cronStats', 'CrontabStatModel'], + ['runningInstance', 'RunningInstanceModel'], + ]) + mocks[`../data/${file}`] = { [model]: { sync: async () => {} } }; + mocks['../data'] = { sequelize: {} }; + mocks['../shared/schemaMigrations'] = { + migrateSchema: async () => { + throw new Error('SQLITE_FULL'); + }, + }; + const initialize = load(path.resolve('back/loaders/db.ts'), mocks).default; + await assert.rejects(initialize(), /SQLITE_FULL/); +}); diff --git a/test/back/script-file-access.test.cjs b/test/back/script-file-access.test.cjs new file mode 100644 index 00000000..b90ba37d --- /dev/null +++ b/test/back/script-file-access.test.cjs @@ -0,0 +1,210 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const express = require('express'); +const load = require('../helpers/load-security-module.cjs'); + +test('script file operations allow token.json while protecting panel configuration', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-script-access-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const dir of [ + 'config/grpc', + 'scripts/ZaiZaiCat-Checkin', + 'scripts/token.json', + 'bak', + 'tmp', + ]) + fs.mkdirSync(path.join(root, dir), { recursive: true }); + const config = { + dataPath: root, + scriptPath: path.join(root, 'scripts'), + configPath: path.join(root, 'config'), + tmpPath: path.join(root, 'tmp'), + bakPath: path.join(root, 'bak'), + blackFileList: ['token.json', 'auth.json', 'grpc'], + }; + config.writePathList = [config.configPath, config.scriptPath]; + const secret = path.join(config.configPath, 'token.json'); + fs.writeFileSync(secret, 'PANEL-SECRET'); + fs.symlinkSync(secret, path.join(config.scriptPath, 'secret-link')); + fs.symlinkSync( + config.configPath, + path.join(config.scriptPath, 'config-link'), + ); + fs.symlinkSync(secret, path.join(config.configPath, 'secret-alias')); + const mocks = { + '../config': config, + '../config/const': {}, + '../config/util': { + getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'), + fileExist: async (p) => fs.existsSync(p), + rmPath: (p) => fs.promises.rm(p, { recursive: true }), + readDir: async () => [], + }, + '../shared/i18n': { t: (x) => x }, + './sock': {}, + './cron': {}, + './schedule': {}, + '../shared/pLimit': {}, + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + }; + const cache = new Map(); + const Script = load( + path.join(__dirname, '../../back/services/script.ts'), + mocks, + cache, + ).default; + const service = new Script(); + mocks['../services/script'] = Script; + const History = load( + path.join(__dirname, '../../back/services/scriptHistory.ts'), + mocks, + cache, + ).default; + const history = new History(); + mocks.typedi = { + ...mocks.typedi, + Container: { get: (type) => (type === History ? history : service) }, + }; + const app = express.Router(); + load(path.join(__dirname, '../../back/api/script.ts'), mocks, cache).default( + app, + ); + const router = app.stack.find((layer) => layer.name === 'router').handle; + const invoke = async (method, url, body = {}, query = {}, file) => { + const route = router.stack.find( + (layer) => layer.route?.path === url && layer.route.methods[method], + ).route; + let result; + await route.stack.at(-1).handle( + { body, query, file }, + { + send: (value) => { + result = value; + }, + download: (p) => { + result = { code: 200, data: fs.readFileSync(p, 'utf8') }; + }, + }, + (error) => { + throw error; + }, + ); + return result; + }; + for (const directory of ['', 'ZaiZaiCat-Checkin']) { + const filename = directory ? 'token.json' : 'auth.json'; + const body = { + path: directory, + filename, + content: '{"account":"initial"}', + }; + assert.equal((await invoke('post', '/', body)).code, 200); + assert.deepEqual( + await invoke('get', '/detail', {}, { path: directory, file: filename }), + { code: 200, data: body.content }, + ); + assert.equal( + (await invoke('put', '/', { ...body, content: '{"account":"updated"}' })) + .code, + 200, + ); + assert.deepEqual(await invoke('post', '/download', body), { + code: 200, + data: '{"account":"updated"}', + }); + assert.equal( + (await invoke('put', '/rename', { ...body, newFilename: 'renamed.json' })) + .code, + 200, + ); + assert.equal( + ( + await invoke('put', '/rename', { + ...body, + filename: 'renamed.json', + newFilename: filename, + }) + ).code, + 200, + ); + assert.equal((await invoke('delete', '/', body)).code, 200); + } + assert.equal( + (await invoke('get', '/', {}, { path: 'token.json' })).code, + 200, + ); + const upload = path.join(config.tmpPath, 'upload'); + fs.writeFileSync(upload, 'uploaded'); + assert.equal( + ( + await invoke( + 'post', + '/', + { filename: 'token.json', path: 'ZaiZaiCat-Checkin' }, + {}, + { path: upload }, + ) + ).code, + 200, + ); + assert.equal( + await service.getFile('ZaiZaiCat-Checkin', 'token.json'), + 'uploaded', + ); + for (const filename of [ + '../config/token.json', + secret, + 'secret-link', + 'config-link/token.json', + ]) { + assert.equal(service.checkFilePath('', filename), '', filename); + assert.equal( + (await invoke('post', '/download', { filename })).code, + 403, + filename, + ); + assert.equal( + (await invoke('put', '/', { filename, content: 'changed' })).code, + 403, + filename, + ); + if (!path.isAbsolute(filename)) + assert.equal( + (await invoke('post', '/', { filename, content: 'changed' })).code, + 403, + filename, + ); + } + for (const filename of [ + 'token.json', + 'auth.json', + 'grpc/client.key', + 'secret-alias', + ]) { + assert.equal( + ( + await invoke('post', '/', { + path: config.configPath, + filename, + content: 'changed', + }) + ).code, + 403, + filename, + ); + } + assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET'); + assert.equal( + ( + await invoke('post', '/', { + path: config.configPath, + filename: 'normal.txt', + content: 'normal', + }) + ).code, + 200, + ); +}); diff --git a/test/back/script-history-api.test.cjs b/test/back/script-history-api.test.cjs new file mode 100644 index 00000000..0a317f24 --- /dev/null +++ b/test/back/script-history-api.test.cjs @@ -0,0 +1,551 @@ +require('reflect-metadata'); +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const express = require('express'); +const { errors } = require('celebrate'); +const { Container } = require('typedi'); +const load = require('../helpers/load-security-module.cjs'); +const { resolveFileAccess } = load( + path.join(__dirname, '../../back/shared/fileAccess.ts'), +); + +test('script routes save, list, preview and restore, with validation and conflict responses', async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-api-')); + const scripts = path.join(root, 'scripts'); + await fs.mkdir(scripts); + await fs.writeFile(path.join(scripts, 'test.js'), 'before'); + const config = { + scriptPath: scripts, + configPath: path.join(root, 'config'), + dataPath: root, + bakPath: path.join(root, 'bak'), + blackFileList: ['auth.json'], + writePathList: [scripts, path.join(root, 'config')], + }; + await fs.mkdir(config.bakPath); + await fs.mkdir(config.configPath); + let failure = ''; + let beforeRealpath; + class ScriptService { + checkFilePath(dir, name) { + return resolveFileAccess(scripts, [dir || '', name]); + } + } + const mocks = { + '../config': { default: config, __esModule: true }, + 'fs/promises': { + ...fs, + realpath: async (file, ...args) => { + if (beforeRealpath) await beforeRealpath(file); + return fs.realpath(file, ...args); + }, + copyFile: async (from, to) => { + if (failure === 'backup' && path.dirname(to) === config.bakPath) + throw Object.assign(new Error('backup denied'), { code: 'EACCES' }); + return fs.copyFile(from, to); + }, + writeFile: async (file, ...args) => { + if (failure === 'history' && String(file).endsWith('.pending.tmp')) + throw Object.assign(new Error('history full'), { code: 'ENOSPC' }); + return fs.writeFile(file, ...args); + }, + }, + '../config/util': { + rmPath: (file) => fs.rm(file, { force: true, recursive: true }), + fileExist: async (file) => + fs.access(file).then( + () => true, + () => false, + ), + }, + '../services/script': { default: ScriptService, __esModule: true }, + '../shared/i18n': { t: (value) => value }, + }; + const cache = new Map(); + const api = load( + path.join(__dirname, '../../back/api/script.ts'), + mocks, + cache, + ); + const { default: ScriptHistoryService } = load( + path.join(__dirname, '../../back/services/scriptHistory.ts'), + mocks, + cache, + ); + Container.set(ScriptService, new ScriptService()); + t.after(() => { + Container.remove(ScriptHistoryService); + Container.remove(ScriptService); + }); + assert.ok(Container.has(ScriptHistoryService)); + const historyService = Container.get(ScriptHistoryService); + assert.strictEqual(Container.get(ScriptHistoryService), historyService); + // An empty history read must not create storage or other persistent resources. + assert.deepEqual((await historyService.list('', 'test.js')).versions, []); + await assert.rejects(fs.stat(path.join(root, 'script-history')), { + code: 'ENOENT', + }); + const app = express(); + app.use(express.json({ limit: '50mb' })); + api.default(app); + app.use(errors()); + app.use((error, _req, res, _next) => + res.status(500).json({ error: error.message }), + ); + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + t.after(async () => { + await new Promise((resolve) => server.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const call = async (url, body, method = 'PUT') => { + const res = await fetch( + base + url, + body && { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + return { status: res.status, body: await res.json() }; + }; + assert.equal( + (await call('/scripts', { filename: 'test.js', content: 'after' })).body + .code, + 200, + ); + const list = await call('/scripts/history?filename=test.js'); + assert.equal(list.status, 200); + assert.equal(list.body.data.versions.length, 1); + const id = list.body.data.versions[0].id; + const preview = await call( + `/scripts/history/detail?filename=test.js&id=${id}`, + ); + assert.equal(preview.body.data.version.content, 'before'); + assert.equal(preview.body.data.current, 'after'); + assert.equal( + (await call('/scripts/history/restore', { filename: 'test.js', id })) + .status, + 400, + ); + assert.equal( + ( + await call('/scripts/history/restore', { + filename: 'test.js', + id, + expectedHash: '0'.repeat(64), + }) + ).status, + 409, + ); + assert.equal( + ( + await call('/scripts/history/restore', { + filename: 'test.js', + id, + expectedHash: preview.body.data.currentHash, + }) + ).status, + 200, + ); + assert.equal( + await fs.readFile(path.join(scripts, 'test.js'), 'utf8'), + 'before', + ); + assert.equal( + (await call('/scripts/history?filename=..%2Foutside')).status, + 403, + ); + assert.equal((await call('/scripts/history?filename=missing')).status, 404); + for (const content of ['debug-C', 'debug-D']) { + const saved = await call( + '/scripts', + { filename: 'test.js', originFilename: 'test.js', path: '', content }, + 'POST', + ); + assert.equal(saved.status, 200); + assert.equal(saved.body.data.historyRecorded, true); + } + const debugList = await call('/scripts/history?filename=test.js'); + const newest = await call( + `/scripts/history/detail?filename=test.js&id=${debugList.body.data.versions[0].id}`, + ); + assert.equal(newest.body.data.version.content, 'debug-C'); + assert.equal(newest.body.data.current, 'debug-D'); + + // develop allows script names that are protected only in the config root. + await fs.writeFile(path.join(scripts, 'auth.json'), 'script-original'); + for (const method of ['PUT', 'POST']) { + const saved = await call( + '/scripts', + { filename: 'auth.json', path: '', content: `script-${method}` }, + method, + ); + assert.equal(saved.status, 200); + assert.equal(saved.body.data.historyRecorded, true); + } + const namedHistory = await call('/scripts/history?filename=auth.json'); + assert.equal(namedHistory.body.data.versions.length, 2); + const namedPreview = await call( + `/scripts/history/detail?filename=auth.json&id=${namedHistory.body.data.versions[0].id}`, + ); + assert.equal(namedPreview.body.data.version.content, 'script-PUT'); + assert.equal(namedPreview.body.data.current, 'script-POST'); + assert.equal( + ( + await call('/scripts/history/restore', { + filename: 'auth.json', + id: namedHistory.body.data.versions[0].id, + expectedHash: namedPreview.body.data.currentHash, + }) + ).status, + 200, + ); + assert.equal( + await fs.readFile(path.join(scripts, 'auth.json'), 'utf8'), + 'script-PUT', + ); + + // Both editor PUT and debugger POST support the same explicit opt-out. + for (const method of ['PUT', 'POST']) { + await fs.writeFile( + path.join(scripts, 'large.js'), + 'x'.repeat(1024 * 1024 + 1), + ); + const payload = { filename: 'large.js', path: '', content: 'small' }; + const rejected = await call('/scripts', payload, method); + assert.equal(rejected.status, 413); + assert.equal(rejected.body.historyUnavailable, true); + assert.equal( + (await call('/scripts', { ...payload, skipHistory: true }, method)) + .status, + 400, + ); + const saved = await call( + '/scripts', + { + ...payload, + skipHistory: true, + expectedHash: rejected.body.currentHash, + }, + method, + ); + assert.equal(saved.status, 200); + assert.equal(saved.body.data.historyRecorded, false); + assert.equal( + await fs.readFile(path.join(scripts, 'large.js'), 'utf8'), + 'small', + ); + } + await t.test( + 'save-as rejects paths outside writable roots before backup or history changes', + async () => { + const outside = path.join(root, 'outside'); + const sibling = `${scripts}-sibling`; + for (const dir of [outside, sibling]) { + await fs.mkdir(dir); + await fs.writeFile(path.join(dir, 'source.js'), 'outside-source'); + await fs.writeFile(path.join(dir, 'destination.js'), 'outside-target'); + } + await fs.writeFile( + path.join(scripts, 'boundary-source.js'), + 'safe-source', + ); + await fs.writeFile( + path.join(scripts, 'boundary-target.js'), + 'safe-target', + ); + await fs.symlink(outside, path.join(scripts, 'outside-link')); + for (const escape of [ + '../outside', + '../scripts-sibling', + 'outside-link', + ]) { + for (const escapedSide of ['source', 'target']) { + const result = await call( + '/scripts', + { + filename: + escapedSide === 'target' + ? `${escape}/destination.js` + : 'boundary-target.js', + originFilename: + escapedSide === 'source' + ? `${escape}/source.js` + : 'boundary-source.js', + content: 'changed', + }, + 'POST', + ); + assert.equal(result.body.code, 403); + } + } + assert.equal( + await fs.readFile(path.join(scripts, 'boundary-source.js'), 'utf8'), + 'safe-source', + ); + assert.equal( + await fs.readFile(path.join(scripts, 'boundary-target.js'), 'utf8'), + 'safe-target', + ); + for (const dir of [outside, sibling]) { + assert.equal( + await fs.readFile(path.join(dir, 'source.js'), 'utf8'), + 'outside-source', + ); + assert.equal( + await fs.readFile(path.join(dir, 'destination.js'), 'utf8'), + 'outside-target', + ); + } + assert.deepEqual(await fs.readdir(config.bakPath), []); + assert.equal( + (await call('/scripts/history?filename=boundary-target.js')).body.data + .versions.length, + 0, + ); + }, + ); + for (const swappedSide of ['source', 'target']) { + await t.test( + `save-as rejects a ${swappedSide} symlink changed during real-path resolution`, + async () => { + const source = path.join(scripts, `${swappedSide}-race-source.js`); + const destination = path.join(scripts, `${swappedSide}-race-target.js`); + const outside = path.join(root, `${swappedSide}-race-outside.js`); + await fs.writeFile(source, 'safe-source'); + await fs.writeFile(destination, 'safe-target'); + await fs.writeFile(outside, 'outside-sentinel'); + const swapped = swappedSide === 'source' ? source : destination; + let swappedOnce = false; + beforeRealpath = async (file) => { + if (file === swapped && !swappedOnce) { + swappedOnce = true; + await fs.unlink(swapped); + await fs.symlink(outside, swapped); + } + }; + try { + const result = await call( + '/scripts', + { + filename: path.basename(destination), + originFilename: path.basename(source), + content: 'changed', + }, + 'POST', + ); + assert.equal(swappedOnce, true); + assert.equal(result.status, 403); + assert.equal(await fs.readFile(outside, 'utf8'), 'outside-sentinel'); + assert.equal( + await fs.readFile( + swappedSide === 'source' ? destination : source, + 'utf8', + ), + swappedSide === 'source' ? 'safe-target' : 'safe-source', + ); + assert.deepEqual(await fs.readdir(config.bakPath), []); + await fs.unlink(swapped); + await fs.writeFile( + swapped, + swappedSide === 'source' ? 'safe-source' : 'safe-target', + ); + assert.equal( + ( + await call( + `/scripts/history?filename=${path.basename(destination)}`, + ) + ).body.data.versions.length, + 0, + ); + } finally { + beforeRealpath = undefined; + } + }, + ); + } + await t.test( + 'save-as rechecks configuration secret names after resolving a changed source link', + async () => { + const secret = path.join(config.configPath, 'auth.json'); + const source = path.join(config.configPath, 'ordinary.js'); + const destination = path.join(scripts, 'config-race-target.js'); + await fs.writeFile(secret, 'config-secret'); + await fs.writeFile(source, 'ordinary-config'); + await fs.writeFile(destination, 'safe-target'); + let swapped = false; + beforeRealpath = async (file) => { + if (file === source && !swapped) { + swapped = true; + await fs.unlink(source); + await fs.symlink(secret, source); + } + }; + try { + const result = await call( + '/scripts', + { + filename: path.basename(destination), + originFilename: '../config/ordinary.js', + content: 'changed', + }, + 'POST', + ); + assert.equal(swapped, true); + assert.equal(result.status, 403); + assert.equal(await fs.readFile(secret, 'utf8'), 'config-secret'); + assert.equal(await fs.readFile(destination, 'utf8'), 'safe-target'); + assert.deepEqual(await fs.readdir(config.bakPath), []); + assert.equal( + (await call('/scripts/history?filename=config-race-target.js')).body + .data.versions.length, + 0, + ); + } finally { + beforeRealpath = undefined; + } + }, + ); + for (const scenario of ['target-link', 'source-link', 'normalized-path']) { + await t.test( + `save-as preserves aliases of the same file: ${scenario}`, + async () => { + const sourceName = `${scenario}-source.js`; + const aliasName = `${scenario}-alias.js`; + const sourcePath = path.join(scripts, sourceName); + await fs.writeFile(sourcePath, 'source-original'); + if (scenario !== 'normalized-path') + await fs.symlink(sourceName, path.join(scripts, aliasName)); + const filename = + scenario === 'target-link' + ? aliasName + : scenario === 'normalized-path' + ? `./${sourceName}` + : sourceName; + const originFilename = + scenario === 'source-link' ? aliasName : sourceName; + const saved = await call( + '/scripts', + { filename, originFilename, path: '', content: 'source-edited' }, + 'POST', + ); + assert.equal(saved.status, 200); + assert.equal(saved.body.code, 200); + assert.equal(await fs.readFile(sourcePath, 'utf8'), 'source-edited'); + assert.equal( + await fs.readFile(path.join(scripts, filename), 'utf8'), + 'source-edited', + ); + if (scenario !== 'normalized-path') { + assert.equal( + await fs.readlink(path.join(scripts, aliasName)), + sourceName, + ); + const sourceHistory = await call( + `/scripts/history?filename=${sourceName}`, + ); + const aliasHistory = await call( + `/scripts/history?filename=${aliasName}`, + ); + assert.deepEqual(aliasHistory.body.data, sourceHistory.body.data); + } + const list = await call( + `/scripts/history?filename=${encodeURIComponent(filename)}`, + ); + assert.equal(list.body.data.versions.length, 1); + const preview = await call( + `/scripts/history/detail?filename=${encodeURIComponent( + filename, + )}&id=${list.body.data.versions[0].id}`, + ); + assert.equal(preview.body.data.version.content, 'source-original'); + assert.equal(preview.body.data.current, 'source-edited'); + }, + ); + } + await t.test( + 'save-as backs up the source and retains destination history before removing the source', + async () => { + await fs.writeFile(path.join(scripts, 'source.js'), 'source-original'); + await fs.writeFile( + path.join(scripts, 'destination.js'), + 'destination-original', + ); + const saved = await call( + '/scripts', + { + filename: 'destination.js', + originFilename: 'source.js', + path: '', + content: 'source-edited', + }, + 'POST', + ); + assert.equal(saved.status, 200); + await assert.rejects(fs.access(path.join(scripts, 'source.js')), { + code: 'ENOENT', + }); + assert.equal( + await fs.readFile(path.join(config.bakPath, 'source.js'), 'utf8'), + 'source-original', + ); + assert.equal( + await fs.readFile(path.join(scripts, 'destination.js'), 'utf8'), + 'source-edited', + ); + const list = await call('/scripts/history?filename=destination.js'); + const preview = await call( + `/scripts/history/detail?filename=destination.js&id=${list.body.data.versions[0].id}`, + ); + assert.equal(preview.body.data.version.content, 'destination-original'); + }, + ); + for (const step of ['backup', 'history']) { + await t.test( + `save-as ${step} failure preserves both original scripts`, + async () => { + const sourceName = `${step}-source.js`, + destinationName = `${step}-destination.js`; + await fs.writeFile(path.join(scripts, sourceName), 'source-original'); + await fs.writeFile( + path.join(scripts, destinationName), + 'destination-original', + ); + failure = step; + try { + const saved = await call( + '/scripts', + { + filename: destinationName, + originFilename: sourceName, + path: '', + content: 'source-edited', + }, + 'POST', + ); + assert.equal(saved.status, 500); + assert.equal( + await fs.readFile(path.join(scripts, sourceName), 'utf8'), + 'source-original', + ); + assert.equal( + await fs.readFile(path.join(scripts, destinationName), 'utf8'), + 'destination-original', + ); + assert.equal( + (await call(`/scripts/history?filename=${destinationName}`)).body + .data.versions.length, + 0, + ); + } finally { + failure = ''; + } + }, + ); + } +}); diff --git a/test/back/script-history.test.cjs b/test/back/script-history.test.cjs new file mode 100644 index 00000000..31e794c4 --- /dev/null +++ b/test/back/script-history.test.cjs @@ -0,0 +1,383 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { randomBytes } = require('node:crypto'); +const load = require('../helpers/load-security-module.cjs'); +const source = path.join(__dirname, '../../back/shared/scriptHistory.ts'); +const mocks = { + '../config/util': { + fileExist: async (file) => + fs.access(file).then( + () => true, + () => false, + ), + }, +}; +const { ScriptHistory, SCRIPT_HISTORY_LIMITS } = load(source, mocks); + +async function fixture(t, limits = {}, History = ScriptHistory) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-')); + t.after(() => fs.rm(root, { force: true, recursive: true })); + const scripts = path.join(root, 'scripts'); + const history = path.join(root, 'history'); + await fs.mkdir(scripts); + const file = path.join(scripts, 'test.js'); + await fs.writeFile(file, 'original\n'); + const service = new History(scripts, history, ['auth.json'], { + ...SCRIPT_HISTORY_LIMITS, + ...limits, + }); + return { root, scripts, history, file, service }; +} + +test('first save, unchanged save, restore and undo restore preserve exact content', async (t) => { + const { service, file, history } = await fixture(t); + assert.deepEqual((await service.list('', 'test.js')).versions, []); + await assert.rejects(fs.stat(history), { code: 'ENOENT' }); + await service.save('', 'test.js', 'second\r\n中文'); + let list = await service.list('', 'test.js'); + assert.equal(list.versions.length, 1); + assert.equal(list.versions[0].source, 'baseline'); + assert.equal( + (await service.detail('', 'test.js', list.versions[0].id)).version.content, + 'original\n', + ); + const archive = path.join(history, (await fs.readdir(history))[0]); + const before = await fs.readFile(archive); + await service.save('', 'test.js', 'second\r\n中文'); + assert.deepEqual( + await fs.readFile(archive), + before, + 'unchanged save performs no archive write', + ); + await service.restore('', 'test.js', list.versions[0].id, list.currentHash); + assert.equal(await fs.readFile(file, 'utf8'), 'original\n'); + list = await service.list('', 'test.js'); + assert.equal(list.versions[0].source, 'save'); + await service.restore('', 'test.js', list.versions[0].id, list.currentHash); + assert.equal(await fs.readFile(file, 'utf8'), 'second\r\n中文'); + assert.equal( + (await service.list('', 'test.js')).versions[0].source, + 'restore', + ); +}); + +test('conflict check refuses to overwrite a file changed after preview, including external edits', async (t) => { + const { service, file } = await fixture(t); + await service.save('', 'test.js', 'saved'); + const list = await service.list('', 'test.js'); + await fs.writeFile(file, 'external'); + await assert.rejects( + service.restore('', 'test.js', list.versions[0].id, list.currentHash), + { status: 409 }, + ); + assert.equal(await fs.readFile(file, 'utf8'), 'external'); + await service.save('', 'test.js', 'after external'); + const current = await service.list('', 'test.js'); + assert.equal(current.versions[0].source, 'external'); + assert.equal( + (await service.detail('', 'test.js', current.versions[0].id)).version + .content, + 'external', + ); +}); + +test('same-name files are isolated, traversal, blacklist and escaping symlinks are forbidden', async (t) => { + const { service, scripts, root } = await fixture(t); + await fs.mkdir(path.join(scripts, 'nested')); + await fs.writeFile(path.join(scripts, 'nested', 'test.js'), 'nested'); + await service.save('nested', 'test.js', 'changed'); + assert.equal((await service.list('', 'test.js')).versions.length, 0); + await fs.writeFile(path.join(root, 'outside.js'), 'secret'); + await fs.symlink( + path.join(root, 'outside.js'), + path.join(scripts, 'escape.js'), + ); + for (const filename of ['../outside.js', 'auth.json', 'escape.js']) { + await assert.rejects(service.save('', filename, 'bad'), { status: 403 }); + await assert.rejects(service.list('', filename), { status: 403 }); + } + await fs.symlink( + path.join(scripts, 'test.js'), + path.join(scripts, 'alias.js'), + ); + await service.save('', 'alias.js', 'via alias'); + assert.equal( + (await service.list('', 'test.js')).versions.length, + 1, + 'safe aliases share one canonical history', + ); +}); + +test('concurrent saves across service instances serialize snapshots without losing content', async (t) => { + const { service, scripts, history, file } = await fixture(t); + const other = new ScriptHistory(scripts, history); + await Promise.all([ + service.save('', 'test.js', 'A'), + other.save('', 'test.js', 'B'), + ]); + const list = await service.list('', 'test.js'); + const contents = await Promise.all( + list.versions.map((v) => + service.detail('', 'test.js', v.id).then((d) => d.version.content), + ), + ); + contents.push(await fs.readFile(file, 'utf8')); + assert.deepEqual(contents.sort(), ['A', 'B', 'original\n'].sort()); +}); + +test('retention trims old revisions and bounds file count and global compressed storage', async (t) => { + const { service, scripts, history } = await fixture(t, { + versions: 3, + files: 2, + totalBytes: 1800, + }); + for (let i = 0; i < 6; i++) await service.save('', 'test.js', `version ${i}`); + const list = await service.list('', 'test.js'); + assert.equal(list.versions.length, 3); + assert.equal( + (await service.detail('', 'test.js', list.versions.at(-1).id)).version + .content, + 'version 2', + ); + for (let i = 0; i < 5; i++) { + const name = `${i}.js`; + await fs.writeFile( + path.join(scripts, name), + randomBytes(300).toString('hex'), + ); + await service.save('', name, 'new'); + } + const files = await fs.readdir(history); + assert.ok(files.length <= 2); + let total = 0; + for (const name of files) + total += (await fs.stat(path.join(history, name))).size; + assert.ok(total <= 1800); + assert.equal((await service.list('', '4.js')).versions.length, 1); +}); + +test('invalid text, oversized files and corrupted archives fail without overwriting scripts', async (t) => { + const { service, file, history } = await fixture(t, { fileBytes: 100 }); + await assert.rejects(service.save('', 'test.js', 'a'.repeat(101)), { + status: 413, + }); + await fs.writeFile(file, Buffer.from([0xff, 0xfe])); + await assert.rejects(service.save('', 'test.js', 'new'), { status: 413 }); + await fs.writeFile(file, 'before'); + await service.save('', 'test.js', 'after'); + await fs.writeFile( + path.join(history, (await fs.readdir(history))[0]), + 'corrupt', + ); + await assert.rejects(service.save('', 'test.js', 'replacement'), { + status: 500, + }); + assert.equal(await fs.readFile(file, 'utf8'), 'after'); +}); + +test('archive failure prevents overwrite; file-write failure retains snapshot and releases locks', async (t) => { + let fail = 'archive'; + const { ScriptHistory: FailingHistory } = load(source, { + ...mocks, + 'fs/promises': { + ...fs, + writeFile: async (file, ...args) => { + if (fail === 'archive' && String(file).endsWith('.pending.tmp')) { + throw Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + } + return fs.writeFile(file, ...args); + }, + rename: async (from, to) => { + if (fail === 'script' && String(to).endsWith('test.js')) + throw Object.assign(new Error('commit failed'), { code: 'ENOSPC' }); + return fs.rename(from, to); + }, + }, + }); + const { service, file } = await fixture(t, {}, FailingHistory); + await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' }); + assert.equal(await fs.readFile(file, 'utf8'), 'original\n'); + fail = 'script'; + await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' }); + assert.equal((await service.list('', 'test.js')).versions.length, 0); + assert.equal(await fs.readFile(file, 'utf8'), 'original\n'); + fail = ''; + await service.save('', 'test.js', 'success'); + assert.equal(await fs.readFile(file, 'utf8'), 'success'); + assert.equal((await service.list('', 'test.js')).versions.length, 1); +}); + +test('empty content is a valid recoverable version and missing versions return 404', async (t) => { + const { service, file } = await fixture(t); + await service.save('', 'test.js', ''); + await service.save('', 'test.js', 'again'); + const list = await service.list('', 'test.js'); + const empty = list.versions[0]; + assert.equal(empty.size, 0); + await service.restore('', 'test.js', empty.id, list.currentHash); + assert.equal(await fs.readFile(file, 'utf8'), ''); + await assert.rejects(service.detail('', 'test.js', 'unknown'), { + status: 404, + }); +}); + +test('quota eviction never happens on failed preparation or failed commit', async (t) => { + let failure = ''; + const { ScriptHistory: Faulty } = load(source, { + ...mocks, + 'fs/promises': { + ...fs, + writeFile: async (file, ...args) => { + if ( + failure === 'archive-write' && + String(file).endsWith('.pending.tmp') + ) + throw Object.assign(new Error('full'), { code: 'ENOSPC' }); + if ( + failure === 'script-write' && + path.basename(file).startsWith('.ql-save-') + ) + throw Object.assign(new Error('full'), { code: 'ENOSPC' }); + return fs.writeFile(file, ...args); + }, + rename: async (from, to) => { + if (failure === 'archive-commit' && String(to).endsWith('.br')) + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + if (failure === 'script-commit' && String(to).endsWith('.js')) + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + return fs.rename(from, to); + }, + }, + }); + const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty); + await service.save('', 'test.js', 'saved'); + const archive = path.join(history, (await fs.readdir(history))[0]); + const original = await fs.readFile(archive); + await fs.writeFile(path.join(scripts, 'other.js'), 'other-original'); + for (const step of [ + 'archive-write', + 'script-write', + 'archive-commit', + 'script-commit', + ]) { + failure = step; + await assert.rejects(service.save('', 'other.js', 'other-changed'), { + code: step.endsWith('write') ? 'ENOSPC' : 'EACCES', + }); + assert.deepEqual(await fs.readFile(archive), original, step); + assert.equal( + await fs.readFile(path.join(scripts, 'other.js'), 'utf8'), + 'other-original', + ); + assert.equal((await fs.readdir(history)).length, 1); + assert.equal( + (await fs.readdir(scripts)).length, + 2, + 'no script staging leftovers', + ); + } + // Updating an existing history also restores its exact prior bytes if the + // script rename fails, including revisions that retention would have pruned. + failure = 'script-commit'; + await assert.rejects(service.save('', 'test.js', 'changed'), { + code: 'EACCES', + }); + assert.deepEqual(await fs.readFile(archive), original); + assert.equal( + await fs.readFile(path.join(scripts, 'test.js'), 'utf8'), + 'saved', + ); + failure = ''; + await service.save('', 'other.js', 'other-changed'); + assert.equal((await service.list('', 'test.js')).versions.length, 0); +}); + +test('cleanup errors report a successful save with a warning and are retried on the next save', async (t) => { + let failure = false; + const { ScriptHistory: Faulty } = load(source, { + ...mocks, + 'fs/promises': { + ...fs, + unlink: async (file) => { + if (failure && String(file).endsWith('.br')) + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + return fs.unlink(file); + }, + }, + }); + const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty); + await service.save('', 'test.js', 'saved'); + await fs.writeFile(path.join(scripts, 'other.js'), 'old'); + failure = true; + const result = await service.save('', 'other.js', 'new'); + assert.equal(result.cleanupPending, true); + assert.equal( + await fs.readFile(path.join(scripts, 'other.js'), 'utf8'), + 'new', + ); + failure = false; + await service.save('', 'other.js', 'next'); + assert.equal((await fs.readdir(history)).length, 1); +}); + +test('large files can be reduced only after explicit historyless confirmation with a matching hash', async (t) => { + const { service, file } = await fixture(t, { fileBytes: 100 }); + await fs.writeFile(file, 'x'.repeat(101)); + let confirmation; + try { + await service.save('', 'test.js', 'small'); + } catch (error) { + confirmation = error; + } + assert.equal(confirmation.status, 413); + assert.match(confirmation.currentHash, /^[a-f0-9]{64}$/); + assert.equal((await fs.stat(file)).size, 101); + await assert.rejects( + service.save('', 'test.js', 'small', { + skipHistory: true, + expectedHash: '0'.repeat(64), + }), + { status: 409 }, + ); + const result = await service.save('', 'test.js', 'small', { + skipHistory: true, + expectedHash: confirmation.currentHash, + }); + assert.equal(result.historyRecorded, false); + assert.equal(await fs.readFile(file, 'utf8'), 'small'); + await service.save('', 'test.js', 'normal'); + assert.equal( + (await service.list('', 'test.js')).versions.length, + 1, + 'normal history resumes', + ); + try { + await service.save('', 'test.js', 'x'.repeat(101)); + } catch (error) { + confirmation = error; + } + await fs.writeFile(file, 'someone else changed it'); + await assert.rejects( + service.save('', 'test.js', 'x'.repeat(101), { + skipHistory: true, + expectedHash: confirmation.currentHash, + }), + { status: 409 }, + ); +}); + +test('atomic saves preserve executable mode and canonical symlink targets', async (t) => { + const { service, scripts, file } = await fixture(t); + await fs.chmod(file, 0o755); + await fs.symlink(file, path.join(scripts, 'alias.js')); + await service.save('', 'alias.js', 'new'); + assert.equal((await fs.stat(file)).mode & 0o777, 0o755); + assert.equal( + (await fs.lstat(path.join(scripts, 'alias.js'))).isSymbolicLink(), + true, + ); + assert.equal(await fs.readFile(file, 'utf8'), 'new'); +}); diff --git a/test/back/shell-api-parsing.test.cjs b/test/back/shell-api-parsing.test.cjs new file mode 100644 index 00000000..c0e60061 --- /dev/null +++ b/test/back/shell-api-parsing.test.cjs @@ -0,0 +1,184 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const source = fs + .readFileSync(path.resolve('shell/api.sh'), 'utf8') + .replace(/\nget_token\s*$/, '\n'); +const realJq = spawnSync('which', ['jq'], { encoding: 'utf8' }).stdout.trim(); +function fixture(t) { + assert.ok(realJq, 'jq is required'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-parse-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'jq'), + '#!/bin/bash\nprintf "call\\n" >> "$JQ_CALLS"\nexec "$REAL_JQ" "$@"\n', + { mode: 0o755 }, + ); + const token = path.join(root, 'token.json'), + response = path.join(root, 'response.json'), + calls = path.join(root, 'calls'); + const run = (code) => + spawnSync( + '/bin/bash', + [ + '-euc', + source + + '\ncreate_token(){ __ql_token__=generated; generated=1; }; curl(){ cat "$RESPONSE_FILE"; };\n' + + code, + ], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: bin + ':' + process.env.PATH, + REAL_JQ: realJq, + JQ_CALLS: calls, + file_auth_token: token, + RESPONSE_FILE: response, + ql_port: '5700', + __ql_token__: 'test-token', + }, + }, + ); + return { + token, + response, + run, + count: () => + fs.existsSync(calls) + ? fs.readFileSync(calls, 'utf8').trim().split('\n').length + : 0, + }; +} +test('valid token cache is read with one jq and reused without generation', (t) => { + const f = fixture(t); + fs.writeFileSync( + f.token, + JSON.stringify({ + value: 'header.payload.signature', + expiration: 4102444800, + }), + ); + const r = f.run( + 'get_token; printf "%s:%s" "$__ql_token__" "${generated:-0}"', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'header.payload.signature:0'); + assert.equal(f.count(), 1); +}); +test('expired, corrupt, missing or header-unsafe token cache refreshes under errexit', (t) => { + const f = fixture(t); + for (const content of [ + undefined, + 'broken', + '{}', + JSON.stringify({ value: 'old', expiration: 1 }), + JSON.stringify({ value: 'old', expiration: '4102444800' }), + JSON.stringify({ value: '', expiration: 4102444800 }), + JSON.stringify({ value: 'bad\ninjected', expiration: 4102444800 }), + JSON.stringify({ value: 'bad\n', expiration: 4102444800 }), + JSON.stringify({ value: 'bad\rinjected', expiration: 4102444800 }), + ]) { + if (content === undefined) fs.rmSync(f.token, { force: true }); + else fs.writeFileSync(f.token, content); + const r = f.run('get_token; printf "%s:%s" "$__ql_token__" "$generated"'); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, 'generated:1'); + } +}); +test('success with a message checks authentication and stays silent', (t) => { + const f = fixture(t); + fs.writeFileSync(f.response, JSON.stringify({ code: 200, message: 'ok' })); + const r = f.run('update_cron 1 0 123 log 1'); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, ''); + assert.equal(f.count(), 2); +}); +test('status and statistics errors preserve multiline Unicode messages', (t) => { + const f = fixture(t); + fs.writeFileSync( + f.response, + JSON.stringify({ code: 500, message: '写入失败\n请重试 "原任务"' }), + ); + for (const command of [ + 'update_cron 1 1 123 log 1 2 7', + 'record_cron_stat 1 7 2', + ]) { + const r = f.run(command); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, '写入失败\n请重试 "原任务"\n'); + } + assert.equal(f.count(), 4); +}); +test('non-JSON errors fall back to raw text and absent messages retain legacy null', (t) => { + const f = fixture(t); + fs.writeFileSync(f.response, 'upstream unavailable'); + let r = f.run('update_cron 1 0 123 log 1'); + assert.equal(r.status, 0); + assert.equal(r.stdout, 'upstream unavailable\n'); + fs.writeFileSync(f.response, '{"code":500}'); + r = f.run('record_cron_stat 1 7 2'); + assert.equal(r.status, 0); + assert.equal(r.stdout, 'null\n'); +}); +test('statistics without a task id perform no JSON parsing', (t) => { + const f = fixture(t); + const r = f.run('record_cron_stat "" 0 1 || true'); + assert.equal(r.status, 0, r.stderr); + assert.equal(f.count(), 0); +}); + +test('canonical success resets prior errors and matches jq missing-message semantics', (t) => { + const f = fixture(t); + fs.writeFileSync(f.response, '{"code":200}'); + const r = f.run( + 'code=500; message=old; ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"; record_cron_stat 1 0 1', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, '200|null'); + assert.equal(f.count(), 0); +}); + +test('noncanonical JSON always uses jq, including whitespace, duplicate keys and embedded success text', (t) => { + const f = fixture(t); + const cases = [ + [' {"code":200} ', '200|null'], + ['{"code": 200}', '200|null'], + ['{"code":200,"message":"完成\\n第二行"}', '200|完成\n第二行'], + ['{"code":200,"data":{"code":500}}', '200|null'], + ['{"code":"200"}', '200|null'], + ['{"code":200,"code":500,"message":"error"}', '500|error'], + ['{"code":500,"message":"{\\"code\\":200}"}', '500|{"code":200}'], + ['{"code":500}', '500|null'], + ]; + for (const [body, expected] of cases) { + fs.writeFileSync(f.response, body); + const r = f.run( + 'ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"', + ); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, expected); + } + assert.equal(f.count(), cases.length); +}); + +test('success-shaped invalid documents retain the raw-response error fallback', (t) => { + const f = fixture(t); + const cases = [ + '{"code":200}trailing', + '{"code":200', + '{"code":200}', + ]; + for (const body of cases) { + fs.writeFileSync(f.response, body); + const r = f.run('update_cron 1 0 123 log 1'); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, body + '\n'); + } + assert.equal(f.count(), cases.length * 2); +}); diff --git a/test/back/shell-translation-output.test.cjs b/test/back/shell-translation-output.test.cjs new file mode 100644 index 00000000..a44edb1f --- /dev/null +++ b/test/back/shell-translation-output.test.cjs @@ -0,0 +1,16 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); + +test('shell translations print leading dashes, placeholders and newlines', () => { + const result = spawnSync('bash', ['-c', String.raw` + source shell/share.sh + t '---> 服务诊断信息' + t '---> 最近的系统日志: %s' '/tmp/test.log' + t 'first\nsecond' + `], { encoding: 'utf8', env: { ...process.env, QL_DIR: process.cwd() } }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, + '---> 服务诊断信息\n---> 最近的系统日志: /tmp/test.log\nfirst\nsecond\n'); +}); diff --git a/test/back/shell-upgrade.test.cjs b/test/back/shell-upgrade.test.cjs new file mode 100644 index 00000000..cc4f75f8 --- /dev/null +++ b/test/back/shell-upgrade.test.cjs @@ -0,0 +1,312 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { spawn, execFileSync } = require('node:child_process'); +const { + writeManifest, + sourceCommit, +} = require('../helpers/upgrade-fixture.cjs'); +const { waitForHealth, withUpgradeLock } = require('../../shell/upgrade.cjs'); + +const share = ` +export dir_root="$QL_DIR" dir_shell="$QL_DIR/shell" dir_tmp="$QL_DIR/.tmp" +export dir_data="$QL_DIR/data" dir_static="$QL_DIR/static" dir_log="$QL_DIR/data/log" dir_config="$QL_DIR/data/config" +load_ql_envs() { :; } +import_config() { :; } +fix_config() { :; } +make_dir() { mkdir -p "$1"; } +format_timestamp() { date +%s; } +format_time() { printf '%s' "$2"; } +t() { printf "$@"; } +npm_install_2() { + printf installed > "$QL_DIR/installed" + exit_status="$DEP_STATUS" + mkdir -p "$1/node_modules" + printf new > "$1/node_modules/dependency" +} +delete_pm2() { printf stopped >> "$QL_DIR/stops"; } +reload_pm2() { + printf started >> "$QL_DIR/starts" + if [[ "$FAIL_START" == 'true' ]] && [[ ! -f "$QL_DIR/failed-once" ]]; then + touch "$QL_DIR/failed-once"; return 1 + fi +} +`; + +function write(root, name, content) { + const filename = path.join(root, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + return filename; +} + +function run(program, args, options) { + return new Promise((resolve, reject) => { + const cp = spawn(program, args, { + ...options, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = '', + stderr = ''; + cp.stdout.on('data', (chunk) => { + stdout += chunk; + }); + cp.stderr.on('data', (chunk) => { + stderr += chunk; + }); + cp.on('error', reject); + cp.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +async function fixture(t, options = {}) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-shell-upgrade-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true })); + const root = path.join(base, 'panel'), + payload = path.join(base, 'payload'), + archives = path.join(base, 'archives'); + const branch = ['develop', 'debian-dev'].includes(options.branch) + ? 'develop' + : 'master'; + const source = path.join(payload, `qinglong-${sourceCommit}`), + staticRoot = path.join(payload, `qinglong-static-${branch}`); + const packageJson = '{"version":"2.22.0"}'; + for (const target of [root, source]) { + write(target, 'shell/update.sh', fs.readFileSync('shell/update.sh')); + write(target, 'shell/upgrade.cjs', fs.readFileSync('shell/upgrade.cjs')); + write( + target, + 'cli/src/internal/maintenance/upgradeArtifacts.cjs', + fs.readFileSync('cli/src/internal/maintenance/upgradeArtifacts.cjs'), + ); + write(target, 'shell/share.sh', share); + write(target, 'shell/api.sh', ''); + write(target, 'shell/env.sh', ''); + write(target, 'sample/config.sample.sh', 'sample'); + write(target, 'package.json', packageJson); + write( + target, + 'pnpm-lock.yaml', + target === source && options.lockChange ? 'new lock' : 'old lock', + ); + write(target, 'back/app.ts', target === source ? 'new' : 'old'); + write( + target, + 'version.yaml', + target === source ? 'version: 2.22.0' : 'version: 2.21.0', + ); + } + write(root, 'node_modules/dependency', 'old'); + write(root, '.env', 'private setting'); + write(root, 'data/config/config.sh', 'custom'); + write(root, 'static/build/app.js', 'old backend'); + write(root, 'static/dist/index.html', 'old frontend'); + write(root, '.tmp/import-in-progress', 'keep'); + write(staticRoot, 'build/app.js', 'new backend'); + write(staticRoot, 'dist/index.html', ''); + write(staticRoot, 'dist/umi.js', 'dashboard'); + write(source, '.env.example', 'new example'); + writeManifest(source, staticRoot); + const manifestFile = path.join(staticRoot, 'build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + if (options.manifest === 'missing') fs.unlinkSync(manifestFile); + else if (options.manifest === 'dirty') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, dirty: true }), + ); + else if (options.manifest === 'lock') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, lockfileSha256: '0'.repeat(64) }), + ); + else if (options.manifest === 'commit') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, sourceCommit: 'b'.repeat(40) }), + ); + else if (options.manifest === 'checksum') + write(staticRoot, 'dist/umi.js', 'stale frontend'); + else if (options.manifest === 'extra') + write(staticRoot, 'build/stale.js', 'stale backend'); + else if (options.manifest === 'frontend') + fs.unlinkSync(path.join(staticRoot, 'dist/index.html')); + fs.mkdirSync(archives); + for (const repo of ['qinglong', 'qinglong-static']) { + const archive = path.join(archives, `${repo}.zip`); + execFileSync( + 'zip', + [ + '-qr', + archive, + `${repo}-${repo === 'qinglong' ? sourceCommit : branch}`, + ], + { cwd: payload }, + ); + if (options.corrupt === repo) fs.writeFileSync(archive, 'invalid zip'); + } + const curl = write( + base, + 'bin/curl', + `#!${process.execPath} +const fs=require('node:fs'),path=require('node:path'),args=process.argv.slice(2); +if(args.includes('--head')) process.exit(7); +const repo=args.at(-1).includes('/qinglong-static/')?'qinglong-static':'qinglong'; +fs.appendFileSync(process.env.TRACE,args.at(-1)+'\\n'); +if(process.env.FAIL_DOWNLOAD===repo) process.exit(22); +fs.copyFileSync(path.join(process.env.ARCHIVES,repo+'.zip'),args[args.indexOf('--output')+1]); +`, + ); + fs.chmodSync(curl, 0o755); + const server = http.createServer((req, res) => + res.end('{"code":200,"data":{"status":"ok"}}'), + ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const env = { + ...process.env, + QL_DIR: root, + QL_BRANCH: options.branch || 'master', + PATH: `${path.join(base, 'bin')}:${process.env.PATH}`, + QlPort: String(server.address().port), + ARCHIVES: archives, + TRACE: path.join(base, 'trace'), + real_time: options.tee ? 'false' : 'true', + FAIL_DOWNLOAD: options.download || '', + DEP_STATUS: options.depFail ? '1' : '0', + FAIL_START: options.failStart ? 'true' : 'false', + }; + return { + root, + env, + execute: (...args) => + run('bash', [path.join(root, 'shell/update.sh'), ...args], { env }), + read: (name) => fs.readFileSync(path.join(root, name), 'utf8'), + }; +} + +for (const options of [ + { download: 'qinglong' }, + { download: 'qinglong-static', tee: true }, + { corrupt: 'qinglong' }, + { corrupt: 'qinglong-static', tee: true }, + ...[ + 'missing', + 'dirty', + 'lock', + 'commit', + 'checksum', + 'extra', + 'frontend', + ].map((manifest) => ({ manifest })), + { lockChange: true, depFail: true, tee: true }, +]) + test(`failed update preserves the running panel: ${JSON.stringify( + options, + )}`, async (t) => { + const f = await fixture(t, options); + const result = await f.execute('update'); + assert.notEqual(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(f.read('node_modules/dependency'), 'old'); + assert.equal(f.read('static/dist/index.html'), 'old frontend'); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); + assert.equal(f.read('.tmp/import-in-progress'), 'keep'); + assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [ + 'import-in-progress', + ]); + }); + +for (const branch of ['master', 'develop', 'debian', 'debian-dev']) + test(`download-only then reload installs matching artifacts and lockfile dependencies: ${branch}`, async (t) => { + const f = await fixture(t, { branch, lockChange: true, tee: true }); + let result = await f.execute('update', 'false'); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); + assert.match( + fs.readFileSync(f.env.TRACE, 'utf8'), + new RegExp(`qinglong/repository/archive/${sourceCommit}\\.zip`), + ); + result = await f.execute('reload', 'system'); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('node_modules/dependency'), 'new'); + assert.equal(f.read('pnpm-lock.yaml'), 'new lock'); + assert.equal(f.read('static/dist/umi.js'), 'dashboard'); + assert.equal(f.read('back/app.ts'), 'new'); + assert.equal(f.read('.env'), 'private setting'); + assert.equal(f.read('.env.example'), 'new example'); + assert.equal(f.read('data/config/config.sh'), 'custom'); + assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [ + 'import-in-progress', + ]); + assert.ok( + !fs.readdirSync(f.root).some((name) => name.includes('ql-backup')), + ); + }); + +test('a failed start rolls code, frontend and node_modules back and exits nonzero through tee', async (t) => { + const f = await fixture(t, { lockChange: true, failStart: true, tee: true }); + const result = await f.execute('update'); + assert.notEqual(result.code, 0); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(f.read('node_modules/dependency'), 'old'); + assert.equal(f.read('static/dist/index.html'), 'old frontend'); + assert.equal(f.read('starts'), 'startedstarted'); + assert.ok(!fs.readdirSync(f.root).some((name) => name.includes('ql-backup'))); +}); + +test('reload revalidates staged files before stopping the old panel', async (t) => { + const f = await fixture(t); + assert.equal((await f.execute('update', 'false')).code, 0); + const directory = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + write( + f.root, + `.tmp/${directory}/qinglong-static-master/dist/umi.js`, + 'corrupt', + ); + assert.notEqual((await f.execute('reload', 'system')).code, 0); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); +}); + +test('a new successful stage retires only the previous ready workspace', async (t) => { + const f = await fixture(t); + assert.equal((await f.execute('update', 'false')).code, 0); + const previous = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + assert.equal((await f.execute('update', 'false')).code, 0); + const current = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + assert.notEqual(previous, current); + assert.equal(fs.existsSync(path.join(f.root, '.tmp', previous)), false); + assert.equal(f.read('.tmp/import-in-progress'), 'keep'); +}); + +test('failed health checks and concurrent upgrades are rejected', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-lock-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + await withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => {}), + /Another upgrade/, + ); + }); + assert.deepEqual(fs.readdirSync(tmp), []); + const server = http.createServer((req, res) => { + res.statusCode = 503; + res.end('{"code":503}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + await assert.rejects( + waitForHealth({ QlPort: String(server.address().port) }, 20), + /health check/, + ); +}); diff --git a/test/back/shellApiToken.test.cjs b/test/back/shellApiToken.test.cjs new file mode 100644 index 00000000..92b02f42 --- /dev/null +++ b/test/back/shellApiToken.test.cjs @@ -0,0 +1,151 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const apiFile = path.resolve(__dirname, '../../shell/api.sh'); + +const endpoints = { + status: { + method: 'PUT', url: /\/open\/crons\/status\?t=\d+$/, + body: { ids: ['37'], status: '1', pid: '4321', log_path: 'task/run.log', + last_execution_time: 1787004000, last_running_time: 90, exit_code: 0 }, + success: '', failure: (message) => message, + }, + statistics: { + method: 'POST', url: /\/open\/dashboard\/record$/, + body: { ref_id: 37, code: 0, elapsed: 90 }, + success: '', failure: (message) => message, + }, + add: { + method: 'POST', url: /\/open\/crons\?t=\d+$/, + body: { name: 'sample job', command: 'task job.py', schedule: '0 * * * *', sub_id: null }, + success: 'sample job -> 添加成功', failure: (message) => `sample job -> 添加失败(${message})`, + }, + update: { + method: 'PUT', url: /\/open\/crons\?t=\d+$/, + body: { name: 'sample job', command: 'task job.py', schedule: '0 * * * *', id: '37' }, + success: 'sample job -> 更新成功', failure: (message) => `sample job -> 更新失败(${message})`, + }, + command: { + method: 'PUT', url: /\/open\/crons\?t=\d+$/, + body: { command: 'task job.py', id: '37' }, + success: 'task job.py -> 更新成功', failure: (message) => `task job.py -> 更新失败(${message})`, + }, + delete: { + method: 'DELETE', url: /\/open\/crons\?t=\d+$/, + body: [37, 38], success: '成功', failure: (message) => `失败(${message})`, + }, + notify: { + method: 'PUT', url: /\/open\/system\/notify\?t=\d+$/, + body: { title: 'title', content: 'body' }, + success: '通知发送成功🎉', failure: (message) => `通知失败(${message})`, + }, + find: { + method: 'GET', url: /\/open\/crons\/detail\?log_path=task\/run.log&t=\d+$/, + body: null, success: 'sample job', failure: () => '', + }, + auth: { + method: 'PUT', url: /\/open\/system\/auth\/reset\?t=\d+$/, + body: { retries: 0 }, success: '重置登录错误次数成功🎉', + failure: (message) => `重置登录错误次数失败(${message})`, + }, +}; + +function runCallback(endpoint, scenario) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-token-')); + try { + const tokenFile = path.join(directory, 'token.json'); + fs.writeFileSync(tokenFile, JSON.stringify({ + value: 'initial-token', expiration: Math.floor(Date.now() / 1000) + 3600, + })); + const child = spawnSync('bash', ['-c', ` + . "$API_FILE" + create_token() { + printf 'refresh\\n' >> "$CAPTURE" + __ql_token__=fresh-token + printf '{"value":"fresh-token","expiration":4102444800}' > "$file_auth_token" + } + case "$SCENARIO" in + expired) printf '{"value":"initial-token","expiration":1}' > "$file_auth_token" ;; + replaced) printf '{"value":"fresh-token","expiration":4102444800}' > "$file_auth_token" ;; + esac + curl() { + local auth body url method arg + while [[ $# -gt 0 ]]; do + arg="$1"; shift + case "$arg" in + -H) [[ "$1" == Authorization:* ]] && auth="$1"; shift ;; + --data-raw) body="$1"; shift ;; + -X) method="$1"; shift ;; + http:*) url="$arg" ;; + esac + done + printf '%s\\n%s\\n%s\\n%s\\n' "$auth" "$method" "$url" "\${body:-null}" >> "$CAPTURE" + if [[ "$SCENARIO" == forbidden ]]; then + printf '{"code":403,"message":"forbidden"}' + elif [[ "$SCENARIO" == unavailable ]]; then + printf '{"code":500,"message":"unavailable"}' + elif [[ "$SCENARIO" == persistent || ( "$SCENARIO" != valid && "$auth" != 'Authorization: Bearer fresh-token' ) ]]; then + printf '{"code":401,"message":"Token 已失效"}' + else + printf '{"code":200,"data":{"name":"sample job"}}' + fi + } + t() { printf "$@"; } + case "$ENDPOINT" in + status) update_cron '"37"' 1 4321 'task/run.log' 1787004000 90 0 execution-37 ;; + statistics) record_cron_stat 37 0 90 ;; + add) add_cron_api '0 * * * *' 'task job.py' 'sample job' ;; + update) update_cron_api '0 * * * *' 'task job.py' 'sample job' 37 ;; + command) update_cron_command_api 'task job.py' 37 ;; + delete) del_cron_api '37,38' ;; + notify) notify_api title body ;; + find) find_cron_api 'log_path=task/run.log' ;; + auth) update_auth_config '"retries":0' '重置登录错误次数' ;; + esac + `], { + env: { ...process.env, PATH: `/usr/bin:/bin:${process.env.PATH}`, API_FILE: apiFile, file_auth_token: tokenFile, + CAPTURE: path.join(directory, 'requests'), SCENARIO: scenario, + ENDPOINT: endpoint, ql_port: '5700' }, + encoding: 'utf8', timeout: 10_000, + }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stderr, ''); + const lines = fs.readFileSync(path.join(directory, 'requests'), 'utf8').trim().split('\n'); + const refreshes = lines.filter((line) => line === 'refresh').length; + const requests = lines.filter((line) => line !== 'refresh'); + const captures = []; + for (let i = 0; i < requests.length; i += 4) { + captures.push({ auth: requests[i], method: requests[i + 1], + url: requests[i + 2], body: JSON.parse(requests[i + 3]) }); + } + return { output: child.stdout, refreshes, captures }; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +for (const [endpoint, expected] of Object.entries(endpoints)) { + for (const scenario of ['valid', 'expired', 'replaced', 'rejected', 'persistent', 'forbidden', 'unavailable']) { + test(`${endpoint} API handles ${scenario} token without unbounded retries`, () => { + const { output, refreshes, captures } = runCallback(endpoint, scenario); + const retries = ['rejected', 'persistent'].includes(scenario); + assert.equal(captures.length, retries ? 2 : 1); + assert.equal(refreshes, ['expired', 'rejected', 'persistent'].includes(scenario) ? 1 : 0); + for (const capture of captures) { + assert.deepEqual(capture.body, expected.body); + assert.equal(capture.method, expected.method); + assert.match(capture.url, expected.url); + } + if (['expired', 'replaced', 'rejected'].includes(scenario)) { + assert.equal(captures.at(-1).auth, 'Authorization: Bearer fresh-token'); + } + const error = scenario === 'persistent' ? 'Token 已失效' + : scenario === 'forbidden' ? 'forbidden' : scenario === 'unavailable' ? 'unavailable' : ''; + assert.equal(output.trim(), error ? expected.failure(error) : expected.success); + }); + } +} diff --git a/test/back/sock-polling.test.cjs b/test/back/sock-polling.test.cjs new file mode 100644 index 00000000..1acb084d --- /dev/null +++ b/test/back/sock-polling.test.cjs @@ -0,0 +1,297 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const ts = require('typescript'); +const jwt = require('jsonwebtoken'); +const { isValidToken } = require('../../back/shared/auth'); + +async function setup() { + const secret = 'polling-test'; + const token = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + let auth = { token }; + let read = async () => auth; + let reads = 0; + const validations = []; + let onConnection; + const clients = new Set(); + const timers = new Set(); + const mocks = { + sockjs: { + createServer: () => ({ + on: (_, fn) => { + onConnection = fn; + }, + installHandlers() {}, + }), + }, + typedi: { + Container: { + get: () => ({ + addClient: (c) => clients.add(c), + removeClient: (c) => clients.delete(c), + }), + }, + }, + '../services/sock': class {}, + '../config/util': { getPlatform: (agent) => agent || 'desktop' }, + '../shared/store': { + shareStore: { + getAuthInfo: () => { + reads++; + return read(); + }, + }, + }, + '../shared/auth': { + isValidToken: (...args) => { + validations.push({ token: args[1], platform: args[2] }); + return isValidToken(...args); + }, + }, + '../config': { baseUrl: '', jwt: { secret } }, + }; + const source = fs.readFileSync( + path.join(__dirname, '../../back/loaders/sock.ts'), + 'utf8', + ); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }); + const module = { exports: {} }; + new Function( + 'require', + 'module', + 'exports', + 'setInterval', + 'clearInterval', + outputText, + )( + (name) => { + assert.ok(Object.hasOwn(mocks, name), name); + return mocks[name]; + }, + module, + module.exports, + (callback, delay) => { + assert.equal(delay, 1000); + const timer = { callback, unref() {} }; + timers.add(timer); + return timer; + }, + (timer) => timers.delete(timer), + ); + await module.exports.default({ server: {} }); + function connection(value = token) { + const conn = new EventEmitter(); + Object.assign(conn, { + headers: {}, + pathname: '/api/ws/a/b/websocket', + url: `/api/ws/a/b/websocket?token=${value}`, + write() {}, + close(code) { + this.closeCode = code; + this.emit('close'); + }, + }); + return conn; + } + return { + token, + secret, + clients, + timers, + validations, + connection, + connect: (c) => onConnection(c), + get reads() { + return reads; + }, + setAuth(value) { + auth = value; + }, + setRead(fn) { + read = fn; + }, + tick: () => Promise.all([...timers].map((t) => t.callback())), + }; +} + +function deferred() { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +} + +test('multiple sockets share one periodic read, revoke together, and stop polling when empty', async () => { + const h = await setup(); + assert.equal(h.timers.size, 0); + const sockets = Array.from({ length: 20 }, () => h.connection()); + await Promise.all(sockets.map(h.connect)); + assert.equal(h.reads, 20, 'initial authentication remains per connection'); + assert.equal(h.timers.size, 1); + await h.tick(); + assert.equal(h.reads, 21); + assert.equal(h.clients.size, 20); + h.setAuth({ token: '' }); + await h.tick(); + assert.ok(sockets.every((c) => c.closeCode === '401')); + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); + const c = h.connection(); + h.setAuth({ token: h.token }); + await h.connect(c); + assert.equal(h.timers.size, 1); + c.close(); + assert.equal(h.timers.size, 0); +}); + +test('slow reads do not overlap and old snapshots do not reject newly accepted sockets', async () => { + const h = await setup(); + const old = h.connection(); + await h.connect(old); + const pending = deferred(); + h.setRead(() => pending.promise); + const tick = h.tick(); + await h.tick(); + assert.equal(h.reads, 2); + old.close(); + h.setRead(async () => ({ token: h.token })); + const fresh = h.connection(); + await h.connect(fresh); + pending.resolve({ token: '' }); + await tick; + assert.equal(h.clients.has(fresh), true); + assert.equal(fresh.closeCode, undefined); + await h.tick(); + assert.equal(h.reads, 4); + fresh.close(); +}); + +test('periodic store failures disconnect the checked sessions', async () => { + const h = await setup(); + const c = h.connection(); + await h.connect(c); + h.setRead(async () => { + throw new Error('database unavailable'); + }); + await h.tick(); + assert.equal(c.closeCode, '401'); + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); +}); + +test('closed connections cannot be added after initial authentication finishes', async () => { + const h = await setup(); + const pending = deferred(); + h.setRead(() => pending.promise); + const c = h.connection(); + const connecting = h.connect(c); + c.close(); + pending.resolve({ token: h.token }); + await connecting; + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); +}); + +test('initial store errors reject the connection without creating a timer', async () => { + const h = await setup(); + h.setRead(async () => { + throw new Error('database unavailable'); + }); + const c = h.connection(); + await h.connect(c); + assert.equal(c.closeCode, '401'); + assert.equal(h.clients.size, 0); + assert.equal(h.timers.size, 0); +}); + +test('each periodic check still validates JWT expiry and platform session membership', async () => { + const h = await setup(); + const expired = h.connection( + jwt.sign({}, h.secret, { algorithm: 'HS384', expiresIn: -1 }), + ); + await h.connect(expired); + assert.equal(expired.closeCode, '404'); + const c = h.connection(); + await h.connect(c); + h.setAuth({ tokens: { desktop: [{ value: h.token, expiration: 1 }] } }); + await h.tick(); + assert.equal(c.closeCode, '401'); + assert.equal(h.timers.size, 0); +}); + +test('an accepted JWT is disconnected when it expires without a store change', async () => { + const h = await setup(); + const c = h.connection(); + await h.connect(c); + const now = Date.now; + try { + Date.now = () => now() + 2 * 60 * 60 * 1000; + await h.tick(); + } finally { + Date.now = now; + } + assert.equal(c.closeCode, '401'); + assert.equal(h.timers.size, 0); +}); + +test('same token and platform share validation only within the current round', async () => { + const h = await setup(); + const sockets = Array.from({ length: 20 }, () => h.connection()); + await Promise.all(sockets.map(h.connect)); + assert.equal( + h.validations.length, + 20, + 'initial authentication stays independent', + ); + h.validations.length = 0; + await h.tick(); + assert.equal(h.validations.length, 1); + await h.tick(); + assert.equal(h.validations.length, 2, 'new rounds revalidate'); + h.setAuth({ token: '' }); + await h.tick(); + assert.equal( + h.validations.length, + 3, + 'invalid results are also shared for this round', + ); + assert.ok(sockets.every((c) => c.closeCode === '401')); + assert.equal(h.timers.size, 0); +}); + +test('validation results never cross tokens or platforms', async () => { + const h = await setup(); + const other = jwt.sign({ account: 'other' }, h.secret, { + algorithm: 'HS384', + expiresIn: '1h', + }); + h.setAuth({ + tokens: { + desktop: [{ value: h.token }, { value: other }], + mobile: [{ value: h.token }], + }, + }); + const desktop = h.connection(); + const mobile = h.connection(); + mobile.headers['user-agent'] = 'mobile'; + const separate = h.connection(other); + await Promise.all([desktop, mobile, separate].map(h.connect)); + h.validations.length = 0; + h.setAuth({ tokens: { desktop: [{ value: h.token }], mobile: [] } }); + await h.tick(); + assert.equal(h.validations.length, 3); + assert.equal(desktop.closeCode, undefined); + assert.equal(mobile.closeCode, '401'); + assert.equal(separate.closeCode, '401'); + desktop.close(); +}); diff --git a/test/back/sock-security.test.cjs b/test/back/sock-security.test.cjs new file mode 100644 index 00000000..e7ba59bf --- /dev/null +++ b/test/back/sock-security.test.cjs @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const jwt = require('jsonwebtoken'); +const load = require('../helpers/load-security-module.cjs'); + +test('WebSocket connections reject expired tokens and close when sessions are revoked', async () => { + const secret = 'sock-test'; + const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' }); + const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 }); + let auth = { token: valid, tokens: { desktop: [{ value: expired }] } }; + let onConnection; + const clients = new Set(); + const Sock = class {}; + const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), { + sockjs: { + createServer: () => ({ + on: (_event, fn) => { + onConnection = fn; + }, + installHandlers() {}, + }), + }, + typedi: { + Container: { + get: () => ({ + addClient: (c) => clients.add(c), + removeClient: (c) => clients.delete(c), + }), + }, + }, + '../services/sock': Sock, + '../config': { jwt: { secret }, baseUrl: '' }, + '../config/util': { getPlatform: () => 'desktop' }, + '../shared/store': { shareStore: { getAuthInfo: async () => auth } }, + }).default; + await loader({ server: {} }); + const connection = (token) => { + const c = new EventEmitter(); + Object.assign(c, { + headers: {}, + pathname: '/api/ws/a/b/websocket', + url: `/api/ws/a/b/websocket?token=${token}`, + close() { + this.closed = true; + this.emit('close'); + }, + write() {}, + }); + return c; + }; + const rejected = connection(expired); + await onConnection(rejected); + assert.equal(rejected.closed, true); + assert.equal(clients.size, 0); + const accepted = connection(valid); + await onConnection(accepted); + assert.equal(clients.size, 1); + auth = { token: '', tokens: {} }; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + accepted.close(); + reject(new Error('revoked socket remained open')); + }, 3000); + accepted.once('close', () => { + clearTimeout(timeout); + resolve(); + }); + }); + assert.equal(clients.size, 0); +}); diff --git a/test/back/ssh-config-home.test.cjs b/test/back/ssh-config-home.test.cjs new file mode 100644 index 00000000..bcc9d146 --- /dev/null +++ b/test/back/ssh-config-home.test.cjs @@ -0,0 +1,113 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const load = require('../helpers/load-security-module.cjs'); + +async function fixture(t, sameHome = false, missingUser = false) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-ssh-home-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const userHome = path.join(directory, 'user'); + const envHome = sameHome ? userHome : path.join(directory, 'env'); + const sshdPath = path.join(directory, 'ssh.d'); + await fs.mkdir(sshdPath); + const util = { + fileExist: async (file) => + fs.stat(file).then( + () => true, + () => false, + ), + rmPath: async (file) => fs.rm(file, { force: true }), + }; + const { writeFileWithLock } = load('back/shared/utils.ts', { + '../config/util': util, + }); + let initialized; + const ready = new Promise((resolve) => (initialized = resolve)); + const SshKeyService = load('back/services/sshKey.ts', { + os: { + ...os, + homedir: () => envHome, + userInfo: () => { + if (missingUser) throw Error('No passwd entry'); + return { homedir: userHome }; + }, + }, + '../config': { sshdPath }, + '../config/util': util, + '../shared/utils': { + writeFileWithLock: async (file, content, options) => { + await writeFileWithLock(file, content, options); + if (content.includes('Include ')) { + initialized(); + } + }, + }, + }).default; + const service = new SshKeyService({ error: assert.fail, warn: assert.fail }); + await ready; + return { service, userHome, envHome, sshdPath }; +} + +for (const sameHome of [false, true]) { + test(`SSH configuration uses the effective user home with ${ + sameHome ? 'matching' : 'overridden' + } HOME`, async (t) => { + const { service, userHome, envHome, sshdPath } = await fixture(t, sameHome); + const configFile = path.join(userHome, '.ssh/config'); + assert.equal( + await fs.readFile(configFile, 'utf8'), + `Include ${sshdPath}/*.config\n\n`, + ); + assert.equal((await fs.stat(path.dirname(configFile))).mode & 0o777, 0o700); + assert.equal((await fs.stat(configFile)).mode & 0o777, 0o600); + if (!sameHome) + await assert.rejects(fs.stat(path.join(envHome, '.ssh/config')), { + code: 'ENOENT', + }); + await fs.appendFile( + configFile, + 'Host existing\n Hostname example.com\n', + ); + await service.initSshConfigFile(); + const content = await fs.readFile(configFile, 'utf8'); + assert.equal(content.split('Include ').length, 2); + assert.match(content, /Host existing/); + }); +} + +test('SSH initialization still works for a custom UID without a passwd entry', async (t) => { + const { envHome, sshdPath } = await fixture(t, true, true); + assert.equal( + await fs.readFile(path.join(envHome, '.ssh/config'), 'utf8'), + `Include ${sshdPath}/*.config\n\n`, + ); +}); + +test('SSH alias changes retain private key permissions and remove old credentials', async (t) => { + const { service, sshdPath } = await fixture(t); + const { + getSubscriptionSshAlias, + } = require('../../back/shared/subscriptionPath'); + const original = 'private/中文@v1'; + const renamed = 'renamed/中文@v2'; + await service.addSSHKey('fixture-private-key', original, '127.0.0.1'); + const first = getSubscriptionSshAlias(original); + assert.equal((await fs.stat(path.join(sshdPath, first))).mode & 0o777, 0o400); + assert.match( + await fs.readFile(path.join(sshdPath, `${first}.config`), 'utf8'), + /Hostname 127\.0\.0\.1/, + ); + await service.removeSSHKey(original, '127.0.0.1'); + await service.addSSHKey('fixture-private-key', renamed, '127.0.0.1'); + await assert.rejects(fs.stat(path.join(sshdPath, first)), { code: 'ENOENT' }); + await assert.rejects(fs.stat(path.join(sshdPath, `${first}.config`)), { + code: 'ENOENT', + }); + assert.equal( + (await fs.stat(path.join(sshdPath, getSubscriptionSshAlias(renamed)))) + .mode & 0o777, + 0o400, + ); +}); diff --git a/test/back/start-env-preservation.test.cjs b/test/back/start-env-preservation.test.cjs new file mode 100644 index 00000000..4ac10be2 --- /dev/null +++ b/test/back/start-env-preservation.test.cjs @@ -0,0 +1,53 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +// Run the actual startup prefix, stopping before service initialization. +const source = fs + .readFileSync('shell/start-simplify.sh', 'utf8') + .split('. "${QL_DIR}/shell/share.sh"')[0]; +for (const command of ['start', 'reload']) { + for (const existing of [false, true]) { + test(`${command} ${ + existing ? 'preserves existing' : 'initializes missing' + } .env`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'shell')); + fs.writeFileSync(path.join(root, 'shell/test.sh'), '# fixture'); + fs.writeFileSync(path.join(root, '.env.example'), 'BACK_PORT=5700\n'); + const custom = 'BACK_PORT=5800\nJWT_SECRET=synthetic-test-only\n'; + if (existing) + fs.writeFileSync(path.join(root, '.env'), custom, { mode: 0o600 }); + const result = spawnSync( + '/bin/bash', + [ + '-c', + 'npm(){ :; }; pip3(){ :; }; apk(){ :; }; sudo(){ "$@"; }; python3(){ printf 3.11; };\n' + + source, + 'startup-test', + command, + ], + { + encoding: 'utf8', + env: { + ...process.env, + QL_DIR: root, + QL_DATA_DIR: root + '/data', + QL_OS_TYPE: 'alpine', + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(root, '.env'), 'utf8'), + existing ? custom : 'BACK_PORT=5700\n', + ); + if (existing) + assert.equal(fs.statSync(path.join(root, '.env')).mode & 0o777, 0o600); + }); + } +} diff --git a/test/back/static-mirror-trigger.test.cjs b/test/back/static-mirror-trigger.test.cjs new file mode 100644 index 00000000..fb4a8936 --- /dev/null +++ b/test/back/static-mirror-trigger.test.cjs @@ -0,0 +1,441 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawn, spawnSync } = require('node:child_process'); +const notify = path.resolve('scripts/wait-for-mirror.sh'); +const verify = notify; +const triggerRef = 'refs/heads/gitee-sync-trigger'; +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + +function fixture(t) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-trigger-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const work = path.join(tmp, 'work'); + const source = path.join(tmp, 'source.git'); + const mirror = path.join(tmp, 'mirror.git'); + for (const repo of [source, mirror]) git('init', '--bare', repo); + git('init', work); + for (const repo of [source, work]) { + git('-C', repo, 'config', 'user.name', 'Fixture'); + git('-C', repo, 'config', 'user.email', 'fixture@example.invalid'); + } + git('-C', work, 'checkout', '-b', 'develop'); + fs.writeFileSync(path.join(work, 'snapshot'), 'static artifact'); + git('-C', work, 'add', '.'); + git('-C', work, 'commit', '-m', 'snapshot'); + git('-C', work, 'push', source, 'develop'); + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + // Model GitHub's Git Data API with real Git objects and non-forced ref updates. + // The fixture's post-receive hook simulates Gitee receiving the push event. + fs.writeFileSync( + path.join(bin, 'gh'), + `#!${process.execPath} +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const args = process.argv.slice(2); +const endpoint = args.find((arg) => arg.startsWith('repos/')); +const method = args.includes('--method') ? args[args.indexOf('--method') + 1] : 'GET'; +const fields = {}; +for (let i = 0; i < args.length; i++) { + if (args[i] === '-f' || args[i] === '-F') { + const field = args[++i]; + const separator = field.indexOf('='); + fields[field.slice(0, separator)] = field.slice(separator + 1); + } +} +fs.appendFileSync(process.env.QL_TEST_API_LOG, JSON.stringify({method, endpoint, fields, time: Date.now()}) + '\\n'); +const source = process.env.QL_TEST_SOURCE; +const git = (...args) => execFileSync('git', ['-C', source, ...args], {encoding: 'utf8', stdio: 'pipe'}).trim(); +const fail = (status) => { console.error('gh: API failure (HTTP ' + status + ')'); process.exit(1); }; +const ref = 'refs/heads/gitee-sync-trigger'; +const isUpdate = method === 'PATCH' || endpoint.endsWith('/git/refs'); +if (process.env.QL_TEST_API_HANG) spawnSync('sleep', ['30'], {stdio: 'ignore'}); +if (process.env.QL_TEST_API_REJECT) fail(500); +if (isUpdate && process.env.QL_TEST_COLLISION && !fs.existsSync(process.env.QL_TEST_COLLISION)) { + const current = git('rev-parse', ref); + const tree = git('rev-parse', current + '^{tree}'); + const peer = git('commit-tree', tree, '-p', current, '-m', 'Concurrent notifier'); + git('update-ref', ref, peer); + fs.writeFileSync(process.env.QL_TEST_COLLISION, peer); +} +if (isUpdate && process.env.QL_TEST_UPDATE_FAILURE && !fs.existsSync(process.env.QL_TEST_UPDATE_FAILURE)) { + fs.writeFileSync(process.env.QL_TEST_UPDATE_FAILURE, 'failed'); + fail(500); +} +try { + if (method === 'GET' && endpoint.includes('/git/ref/')) { + console.log(git('rev-parse', '--verify', 'refs/' + endpoint.split('/git/ref/')[1])); + } else if (method === 'GET' && endpoint.includes('/git/commits/')) { + console.log(git('rev-parse', endpoint.split('/git/commits/')[1] + '^{tree}')); + } else if (endpoint.endsWith('/git/trees')) { + const body = JSON.parse(fs.readFileSync(0, 'utf8')); + const entries = body.tree.map((entry) => { + const blob = execFileSync('git', ['-C', source, 'hash-object', '-w', '--stdin'], {encoding: 'utf8', input: entry.content}).trim(); + return entry.mode + ' blob ' + blob + '\\t' + entry.path + '\\n'; + }).join(''); + console.log(execFileSync('git', ['-C', source, 'mktree'], {encoding: 'utf8', input: entries}).trim()); + } else if (endpoint.endsWith('/git/commits')) { + const parents = fields['parents[]'] ? ['-p', fields['parents[]']] : []; + console.log(git('commit-tree', fields.tree, ...parents, '-m', fields.message)); + } else if (isUpdate) { + const current = spawnSync('git', ['-C', source, 'rev-parse', '--verify', ref], {encoding: 'utf8'}); + const old = current.status === 0 ? current.stdout.trim() : '0'.repeat(40); + if (method === 'PATCH') { + if (fields.force !== 'false' || current.status !== 0 || spawnSync('git', ['-C', source, 'merge-base', '--is-ancestor', old, fields.sha]).status !== 0) fail(422); + } else if (current.status === 0 || fields.ref !== ref) fail(422); + git('update-ref', ref, fields.sha, old); + const hook = path.join(source, 'hooks/post-receive'); + if (fs.existsSync(hook)) execFileSync(hook, {cwd: source, input: old + ' ' + fields.sha + ' ' + ref + '\\n', env: {...process.env, GIT_DIR: source}, stdio: ['pipe', 'pipe', 'pipe']}); + } else fail(400); +} catch (error) { fail(method === 'GET' ? 404 : 422); } +`, + { mode: 0o755 }, + ); + const env = { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + QL_TEST_SOURCE: source, + QL_TEST_API_LOG: path.join(tmp, 'api.log'), + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true', + QL_MIRROR_WAIT_SECONDS: '4', + QL_MIRROR_RETRY_AFTER_SECONDS: '1', + QL_MIRROR_POLL_INTERVAL: '1', + QL_MIRROR_QUERY_TIMEOUT: '1', + }; + return { tmp, work, source, mirror, env }; +} + +test('static notifications use a tiny REST branch with unchanged trees and preserve published refs', (t) => { + const { source, env } = fixture(t); + const snapshot = git('-C', source, 'rev-parse', 'develop'); + execFileSync('bash', [notify, source, source, 'develop', 'branch'], { + stdio: 'pipe', + env, + }); + const first = git('-C', source, 'rev-parse', triggerRef); + execFileSync('bash', [notify, source, source, 'develop', 'branch'], { + stdio: 'pipe', + env, + }); + const second = git('-C', source, 'rev-parse', triggerRef); + assert.notEqual(first, second); + assert.equal(git('-C', source, 'rev-parse', `${second}^`), first); + assert.equal( + git('-C', source, 'ls-tree', '-r', '--name-only', second), + '.gitee-sync-trigger', + ); + assert.equal(git('-C', source, 'diff', '--name-only', first, second), ''); + assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot); + assert.match( + git('-C', source, 'log', '-1', '--format=%s', second), + new RegExp(snapshot), + ); +}); + +test('REST notifications retry a newer parent and GitHub 500s with backoff', (t) => { + const { tmp, source, env } = fixture(t); + execFileSync('bash', [notify, source, source, 'develop'], { + stdio: 'pipe', + env, + }); + const parent = git('-C', source, 'rev-parse', triggerRef); + const flag = path.join(tmp, 'collision'); + const collision = spawnSync('bash', [notify, source, source, 'develop'], { + encoding: 'utf8', + env: { ...env, QL_TEST_COLLISION: flag }, + }); + assert.equal(collision.status, 0, collision.stderr + collision.stdout); + assert.match(collision.stdout, /notification update failed \(attempt 1\)/); + const peer = fs.readFileSync(flag, 'utf8'); + assert.equal(git('-C', source, 'rev-parse', `${peer}^`), parent); + assert.equal(git('-C', source, 'rev-parse', `${triggerRef}^`), peer); + const failed = path.join(tmp, 'failed'); + const recovery = spawnSync('bash', [notify, source, source, 'develop'], { + encoding: 'utf8', + env: { ...env, QL_TEST_UPDATE_FAILURE: failed }, + }); + assert.equal(recovery.status, 0, recovery.stderr + recovery.stdout); + assert.match(recovery.stderr, /HTTP 500/); + const calls = fs + .readFileSync(env.QL_TEST_API_LOG, 'utf8') + .trim() + .split('\n') + .map(JSON.parse); + const updates = calls.filter((call) => call.method === 'PATCH'); + assert.ok(updates.every((call) => call.fields.force === 'false')); + const last = updates.slice(-2); + assert.ok( + last[1].time - last[0].time >= 1900, + 'failed API updates must back off', + ); +}); + +test('missing snapshots fail before sending any notification', (t) => { + const { source, env } = fixture(t); + const missing = spawnSync('bash', [notify, source, source, 'missing'], { + encoding: 'utf8', + env, + }); + assert.equal(missing.status, 1, missing.stderr + missing.stdout); + assert.match(missing.stdout, /Published source ref.*does not exist/); + assert.equal(fs.existsSync(env.QL_TEST_API_LOG), false); +}); + +test('notification failure does not prevent an already synchronized mirror from passing', (t) => { + const { source, mirror, env } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + const result = spawnSync('bash', [verify, source, mirror, 'develop'], { + encoding: 'utf8', + env: { ...env, QL_TEST_API_REJECT: 'true' }, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match( + result.stdout, + /Could not send the mirror notification; continuing/, + ); + assert.match(result.stdout, /Mirror matches the latest source refs/); +}); + +test('a failed delayed notification keeps polling until the mirror updates', async (t) => { + const { source, mirror, env } = fixture(t); + const child = spawn('bash', [verify, source, mirror, 'develop'], { + env: { + ...env, + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_WAIT_SECONDS: '10', + QL_TEST_API_REJECT: 'true', + }, + }); + let output = ''; + let error = ''; + let synchronized = false; + child.stderr.on('data', (data) => { + error += data; + }); + child.stdout.on('data', (data) => { + output += data; + if ( + !synchronized && + output.includes('Could not read notification ref (attempt 1)') + ) { + synchronized = true; + git('-C', source, 'push', mirror, 'develop'); + } + }); + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + assert.ok(synchronized); + assert.equal(status, 0, error + output); + assert.match(output, /Mirror matches the latest source refs/); +}); + +test('failed or hung notifications preserve the mirror polling deadline', (t) => { + const { source, mirror, env } = fixture(t); + for (const failure of ['QL_TEST_API_REJECT', 'QL_TEST_API_HANG']) { + const started = Date.now(); + const result = spawnSync('bash', [verify, source, mirror, 'develop'], { + encoding: 'utf8', + timeout: 6000, + env: { + ...env, + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_WAIT_SECONDS: '3', + [failure]: 'true', + }, + }); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.ok(Date.now() - started < 5000); + assert.match(result.stdout, /::error::Mirror did not match.*within 3s/); + } +}); + +test('static verification retries a small GitHub notification after a missed sync interval', (t) => { + const { tmp, source, mirror, env } = fixture(t); + const snapshot = git('-C', source, 'rev-parse', 'develop'); + git('-C', source, 'update-ref', 'refs/heads/v2.22.0', snapshot); + git('-C', source, 'update-ref', 'refs/tags/v2.22.0', snapshot); + const countFile = path.join(tmp, 'requests'); + fs.writeFileSync( + path.join(source, 'hooks/post-receive'), + `#!/bin/sh +while read before after ref; do + if [ "$ref" = refs/heads/gitee-sync-trigger ]; then + count=0 + if [ -f "$QL_TEST_COUNT" ]; then count=$(cat "$QL_TEST_COUNT"); fi + count=$((count + 1)) + printf '%s' "$count" > "$QL_TEST_COUNT" + if [ "$count" -ge 2 ]; then + git push --quiet "$QL_TEST_MIRROR" refs/heads/v2.22.0:refs/heads/v2.22.0 refs/tags/v2.22.0:refs/tags/v2.22.0 + fi + fi +done +`, + { mode: 0o755 }, + ); + const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], { + encoding: 'utf8', + env: { + ...env, + QL_TEST_COUNT: countFile, + QL_TEST_MIRROR: mirror, + }, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.equal(fs.readFileSync(countFile, 'utf8'), '2'); + assert.match(result.stdout, /Retrying the GitHub notification/); + assert.match(result.stdout, /Mirror matches the latest source refs/); + assert.doesNotMatch(result.stdout, /::error::/); + for (const ref of ['refs/heads/v2.22.0', 'refs/tags/v2.22.0']) { + assert.equal(git('-C', mirror, 'rev-parse', ref), snapshot); + } +}); + +test('static verification still fails when fresh notifications never synchronize the mirror', (t) => { + const { source, mirror, env } = fixture(t); + const result = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { + ...env, + QL_MIRROR_WAIT_SECONDS: '2', + }, + }, + ); + assert.equal(result.status, 1, result.stderr + result.stdout); + // The retry contract is checked above. A short polling deadline can expire + // while the REST fixture is still sending its second notification. + const notifications = Number( + git('-C', source, 'rev-list', '--count', triggerRef), + ); + assert.ok(notifications >= 1 && notifications <= 2); + assert.match(result.stdout, /::error::Mirror did not match/); + const invalid = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { ...env, QL_MIRROR_WAIT_SECONDS: '0' }, + }, + ); + assert.equal(invalid.status, 2); +}); + +test('code mirrors that already match do not send another notification', (t) => { + const { source, mirror, env } = fixture(t); + git('-C', source, 'push', mirror, 'develop'); + const codeEnv = { + ...env, + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false', + }; + const result = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: codeEnv, + }, + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /Mirror matches the latest source refs/); + assert.doesNotMatch(result.stdout, /Sent a small GitHub push event via REST/); + assert.notEqual( + spawnSync('git', ['-C', source, 'rev-parse', '--verify', triggerRef]) + .status, + 0, + ); + const invalid = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { ...codeEnv, QL_MIRROR_NOTIFY_ON_START: 'later' }, + }, + ); + assert.equal(invalid.status, 2); +}); + +test('code mirrors notify after the sync interval and verify tags without version branches', (t) => { + const { tmp, work, source, mirror, env } = fixture(t); + const snapshot = git('-C', source, 'rev-parse', 'develop'); + git('-C', work, 'tag', '-a', 'v2.22.0', '-m', 'release'); + git('-C', work, 'push', source, 'refs/tags/v2.22.0'); + const tag = git('-C', source, 'rev-parse', 'refs/tags/v2.22.0'); + const countFile = path.join(tmp, 'requests'); + fs.writeFileSync( + path.join(source, 'hooks/post-receive'), + `#!/bin/sh +while read before after ref; do + if [ "$ref" = refs/heads/gitee-sync-trigger ]; then + printf 'notification\n' >> "$QL_TEST_COUNT" + git push --quiet "$QL_TEST_MIRROR" refs/tags/v2.22.0:refs/tags/v2.22.0 + fi +done +`, + { mode: 0o755 }, + ); + const started = Date.now(); + const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], { + encoding: 'utf8', + env: { + ...env, + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false', + QL_MIRROR_RETRY_AFTER_SECONDS: '1', + QL_TEST_COUNT: countFile, + QL_TEST_MIRROR: mirror, + }, + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.ok( + Date.now() - started >= 900, + 'notification should wait for the sync interval', + ); + assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n'); + assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1'); + assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot); + assert.equal(git('-C', mirror, 'rev-parse', 'refs/tags/v2.22.0'), tag); + assert.notEqual( + spawnSync('git', [ + '-C', + mirror, + 'rev-parse', + '--verify', + 'refs/heads/v2.22.0', + ]).status, + 0, + ); +}); + +test('code mirrors that never synchronize fail after one delayed notification', (t) => { + const { source, mirror, env } = fixture(t); + const result = spawnSync( + 'bash', + [verify, source, mirror, 'develop', 'branch'], + { + encoding: 'utf8', + env: { + ...env, + QL_MIRROR_NOTIFY_REPO: 'fixture/source', + QL_MIRROR_NOTIFY_ON_START: 'false', + QL_MIRROR_WAIT_SECONDS: '3', + QL_MIRROR_RETRY_AFTER_SECONDS: '1', + }, + }, + ); + assert.equal(result.status, 1, result.stderr + result.stdout); + assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1'); + assert.match(result.stdout, /::error::Mirror did not match/); +}); diff --git a/test/back/static-mirror.test.cjs b/test/back/static-mirror.test.cjs new file mode 100644 index 00000000..47af860d --- /dev/null +++ b/test/back/static-mirror.test.cjs @@ -0,0 +1,179 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { writeManifest } = require('../helpers/upgrade-fixture.cjs'); + +test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'), + target = path.join(tmp, 'target.git'); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', source); + git('init', '--bare', target); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + const script = path.resolve('scripts/sync-static.sh'); + const env = { ...process.env, QL_STATIC_SOURCE: source }; + for (const branch of ['master', 'develop']) { + git('-C', source, 'checkout', '--orphan', branch); + fs.writeFileSync(path.join(source, 'build-info.json'), branch); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', branch); + execFileSync('bash', [script, target, branch], { env, stdio: 'pipe' }); + assert.equal( + git('-C', target, 'rev-parse', branch), + git('-C', source, 'rev-parse', branch), + ); + assert.equal( + git('-C', target, 'rev-parse', '--is-shallow-repository'), + 'false', + ); + } + fs.writeFileSync( + path.join(target, 'hooks/pre-receive'), + '#!/bin/sh\nexit 1\n', + { mode: 0o755 }, + ); + fs.writeFileSync(path.join(source, 'build-info.json'), 'next'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'next'); + assert.notEqual( + spawnSync('bash', [script, target, 'develop'], { env }).status, + 0, + ); +}); + +test('mirror jobs select the built branch, retain hidden artifacts and propagate failures', () => { + const yaml = require('js-yaml'); + const workflow = yaml.load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + for (const name of ['static_gitlab']) { + const job = workflow.jobs[name], + step = job.steps.find((step) => step.run); + assert.equal(job.needs, 'build-static'); + assert.equal(step.env.STATIC_BRANCH, '${{ github.ref_name }}'); + assert.equal(step.env.STATIC_REF_TYPE, '${{ github.ref_type }}'); + assert.match(step.run, /set -euo pipefail/); + assert.match(step.run, /bash scripts\/sync-static.sh.*"\$STATIC_BRANCH"/); + assert.doesNotMatch(step.run, /warning|set \+e|--depth/); + } + const publish = workflow.jobs['build-static'].steps.find( + (step) => step.name === 'copy to static repo', + ); + assert.equal(publish.env.GITHUB_REF_TYPE, '${{ github.ref_type }}'); + assert.match(publish.run, /bash scripts\/publish-static.sh/); + assert.match( + fs.readFileSync('scripts/publish-static.sh', 'utf8'), + /cp -a static\/\./, + ); +}); + +test('version static snapshots retain a matching branch and Git tag on all three remotes', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-version-static-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', source); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + fs.writeFileSync(path.join(source, 'version.yaml'), 'version: 2.22.0\n'); + fs.writeFileSync(path.join(source, '.gitignore'), 'static/\n'); + fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'fixture lock\n'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'source'); + const staticRoot = path.join(source, 'static'); + for (const filename of [ + 'build/app.js', + 'dist/index.html', + 'dist/.well-known/config', + ]) { + fs.mkdirSync(path.dirname(path.join(staticRoot, filename)), { + recursive: true, + }); + fs.writeFileSync(path.join(staticRoot, filename), filename); + } + writeManifest(source, staticRoot); + const manifestFile = path.join(staticRoot, 'build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + manifest.sourceCommit = git('-C', source, 'rev-parse', 'HEAD'); + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + const remotes = ['github', 'gitlab', 'gitee'].map((name) => + path.join(tmp, `${name}.git`), + ); + for (const remote of remotes) git('init', '--bare', remote); + const publish = path.resolve('scripts/publish-static.sh'), + sync = path.resolve('scripts/sync-static.sh'); + const options = { cwd: source, stdio: 'pipe', env: process.env }; + execFileSync('bash', [publish, remotes[0], 'master', 'branch'], options); + execFileSync('bash', [publish, remotes[0], 'v2.22.0', 'tag'], options); + for (const remote of remotes.slice(1)) + execFileSync('bash', [sync, remote, 'v2.22.0', 'tag'], { + ...options, + env: { ...process.env, QL_STATIC_SOURCE: remotes[0] }, + }); + const snapshot = git('-C', remotes[0], 'rev-parse', 'refs/heads/v2.22.0'); + for (const remote of remotes) { + assert.equal( + git('-C', remote, 'rev-parse', 'refs/heads/v2.22.0'), + snapshot, + ); + assert.equal(git('-C', remote, 'rev-parse', 'refs/tags/v2.22.0'), snapshot); + assert.equal( + git('-C', remote, 'show', 'v2.22.0:dist/.well-known/config'), + 'dist/.well-known/config', + ); + } + assert.notEqual( + spawnSync('bash', [publish, remotes[0], 'v2.23.0', 'tag'], options).status, + 0, + ); + assert.equal( + spawnSync('git', [ + '-C', + remotes[0], + 'rev-parse', + '--verify', + 'refs/tags/v2.23.0', + ]).status, + 128, + ); +}); + +test('static backfill uses existing snapshots and skips all source, build and image publication jobs', () => { + const workflow = require('js-yaml').load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + assert.equal( + workflow.on.workflow_dispatch.inputs.static_sync_only.default, + false, + ); + const job = workflow.jobs['sync-existing-static']; + assert.match(job.if, /workflow_dispatch.*inputs.static_sync_only/); + assert.deepEqual(job.strategy.matrix.mirror, ['gitlab']); + const sync = job.steps.find((step) => step.run); + assert.equal(sync.env.STATIC_REF, '${{ inputs.static_ref }}'); + assert.equal(sync.env.STATIC_REF_TYPE, '${{ inputs.static_ref_type }}'); + assert.match(sync.run, /bash scripts\/sync-static.sh/); + assert.doesNotMatch(sync.run, /publish-static|docker|pub.sh/); + for (const name of ['validate', 'code_gitlab', 'build-static']) + assert.match(workflow.jobs[name].if, /!inputs.static_sync_only/); + // Downstream jobs require the skipped build; none bypass dependency failures. + for (const name of [ + 'static_gitlab', + 'verify-gitee-mirrors', + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) { + assert.equal(workflow.jobs[name].needs, 'build-static'); + assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/); + } +}); diff --git a/test/back/stop-failure-feedback.test.cjs b/test/back/stop-failure-feedback.test.cjs new file mode 100644 index 00000000..5b4290af --- /dev/null +++ b/test/back/stop-failure-feedback.test.cjs @@ -0,0 +1,158 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const load = require('../helpers/load-security-module.cjs'); + +const logger = { info() {}, error() {} }; + +function cronFixture(killTask) { + const events = []; + const { runCron } = load('back/shared/runCron.ts', { + 'cross-spawn': { + spawn: () => { + events.push('spawn'); + return {}; + }, + }, + './childProcess': { + observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }), + asError: (e) => e, + }, + './pLimit': { + runWithCronLimit: async (_, fn) => fn(), + removeQueuedCron: () => events.push('release'), + }, + '../loaders/logger': logger, + '../config/util': { killTask }, + '../data/cron': { + CrontabModel: { + findOne: async () => ({ + pid: 100, + status: 0, + allow_multiple_instances: 0, + }), + update: async () => events.push('idle'), + }, + CrontabStatus: { running: 0, idle: 1, queued: 3 }, + }, + '../data/runningInstance': { + RunningInstanceModel: { update: async () => events.push('stopped') }, + InstanceStatus: { running: 0, stopped: 2 }, + }, + }); + return { run: () => runCron('ignored', { id: '1' }), events }; +} + +test('single-instance replacement does not spawn or finalize when termination fails', async () => { + const f = cronFixture(async () => { + throw Error('EPERM'); + }); + await f.run(); + assert.deepEqual(f.events, ['release']); +}); + +test('single-instance replacement waits for verified exit before spawning', async () => { + let finish, entered, verified; + const pending = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + const f = cronFixture(async (pid, wait) => { + assert.equal(pid, 100); + verified = wait; + entered(); + await gate; + }); + const running = f.run(); + await pending; + assert.deepEqual(f.events, []); + finish(); + await running; + assert.equal(verified, true); + assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']); +}); + +function subscriptionFixture(killTask) { + const updates = []; + const Service = load('back/services/subscription.ts', { + '../config': {}, + '../data/cron': {}, + '../config/const': {}, + '../data/subscription': { + SubscriptionModel: { + findAll: async () => [ + { id: 1, pid: 101 }, + { id: 2, pid: 102 }, + ], + update: async (values, query) => updates.push({ values, query }), + }, + SubscriptionStatus: { idle: 1 }, + }, + '../config/util': { killTask }, + '../config/subscription': {}, + '../shared/i18n': {}, + '../shared/pLimit': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + return { service: new Service(logger, {}, {}, {}, {}), updates }; +} + +test('batch subscription stop preserves failed items and reports failure while stopping others', async () => { + const calls = []; + const denied = Error('EPERM'); + const f = subscriptionFixture(async (pid, wait) => { + calls.push([pid, wait]); + if (pid === 101) throw denied; + }); + await assert.rejects(f.service.stop([1, 2]), (e) => e === denied); + assert.deepEqual(calls, [ + [101, true], + [102, true], + ]); + assert.equal(f.updates.length, 1); + assert.equal(f.updates[0].query.where.id, 2); + assert.equal(f.updates[0].values.status, 1); +}); + +function scriptFixture(killTask, pid = 100) { + const Service = load('back/services/script.ts', { + '../config': { scriptPath: '/scripts' }, + '../config/const': { TASK_COMMAND: 'task' }, + '../config/util': { killTask, getPid: async () => pid }, + '../shared/pLimit': { removeQueuedCron() {} }, + './sock': {}, + './cron': {}, + './schedule': {}, + '../shared/fileAccess': {}, + }).default; + return new Service(logger, {}, {}, {}); +} + +test('script stop propagates failure instead of returning success', async () => { + const denied = Error('EPERM'); + const service = scriptFixture(async () => { + throw denied; + }); + await assert.rejects( + service.stopScript('/scripts/test.js', 100), + (e) => e === denied, + ); +}); + +test('script stop verifies exit and treats an absent queued process as already stopped', async () => { + const calls = []; + const service = scriptFixture(async (...args) => calls.push(args), undefined); + assert.equal((await service.stopScript('/scripts/test.js', 100)).code, 200); + assert.deepEqual(calls, [[100, true]]); + const absent = scriptFixture(async () => assert.fail('no PID to stop'), null); + assert.equal( + (await absent.stopScript('/scripts/test.js', undefined)).code, + 200, + ); +}); diff --git a/test/back/stop-race.test.cjs b/test/back/stop-race.test.cjs new file mode 100644 index 00000000..94f5a148 --- /dev/null +++ b/test/back/stop-race.test.cjs @@ -0,0 +1,171 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); +async function fixture(t, onKill = async () => {}) { + const logPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-stop-log-')); + t.after(() => fs.rmSync(logPath, { recursive: true, force: true })); + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const crons = db.define('Cron', { + command: DataTypes.STRING, + status: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + log_path: DataTypes.STRING, + queued_token: DataTypes.STRING, + last_execution_time: DataTypes.INTEGER, + last_running_time: DataTypes.INTEGER, + }); + const instances = db.define('Instance', { + cron_id: DataTypes.INTEGER, + pid: DataTypes.INTEGER, + status: DataTypes.INTEGER, + exit_code: DataTypes.INTEGER, + finished_at: DataTypes.INTEGER, + }); + await db.sync(); + await crons.bulkCreate([ + { id: 1, command: 'task one.js', status: 0, pid: 101, log_path: 'one.log' }, + { id: 2, command: 'task two.js', status: 0, pid: 201, log_path: 'two.log' }, + ]); + await instances.bulkCreate([ + { id: 1, cron_id: 1, pid: 101, status: 0 }, + { id: 2, cron_id: 1, pid: 102, status: 0 }, + { id: 3, cron_id: 1, pid: 99, status: 1, exit_code: 0 }, + { id: 4, cron_id: 1, pid: 98, status: 3, exit_code: 7 }, + { id: 5, cron_id: 2, pid: 201, status: 0 }, + ]); + let service; + const killed = []; + const CronService = load(path.resolve('back/services/cron.ts'), { + '../config': { logPath }, + '../data/cron': { + CrontabModel: crons, + CrontabStatus: { queued: 3, running: 0, idle: 1 }, + }, + '../data/runningInstance': { + RunningInstanceModel: instances, + InstanceStatus: { running: 0, finished: 1, stopped: 2, error: 3 }, + }, + '../config/util': { + killTask: async (pid, wait) => { + assert.equal(wait, true); + killed.push(pid); + await onKill({ pid, service, instances, crons }); + }, + killAllTasks: async () => { + throw Error('command-wide scans must not run'); + }, + }, + '../config/const': {}, + '../schedule/client': {}, + '../shared/pLimit': {}, + '../shared/utils': {}, + '../shared/i18n': { t: (s) => s }, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + '../shared/childProcess': require('../../back/shared/childProcess'), + }).default; + service = new CronService({ info() {}, error() {} }); + service.getDb = ({ id }) => crons.findByPk(id); + return { service, instances, crons, killed }; +} +async function report(service, pid, code) { + await service.status({ + ids: [1], + status: 1, + pid, + log_path: 'one.log', + last_running_time: 1, + last_execution_time: 100, + exit_code: code, + }); +} +test('stop wins when shell exit writes error or success during termination, preserving exit codes', async (t) => { + const f = await fixture(t, async ({ service }) => { + await report(service, 101, 143); + await report(service, 102, 0); + }); + await f.service.stop([1]); + const rows = await f.instances.findAll({ order: [['id', 'ASC']], raw: true }); + assert.deepEqual( + rows.map((r) => [r.id, r.status, r.exit_code]), + [ + [1, 2, 143], + [2, 2, 0], + [3, 1, 0], + [4, 3, 7], + [5, 0, null], + ], + ); +}); +test('late shell completion cannot overwrite stopped instances', async (t) => { + const f = await fixture(t); + await f.service.stop([1]); + await report(f.service, 101, 143); + assert.equal((await f.instances.findByPk(1)).status, 2); + assert.equal((await f.instances.findByPk(2)).status, 2); +}); +test('rows created after the stop snapshot are not relabelled', async (t) => { + const f = await fixture(t, async ({ instances }) => { + if (!(await instances.findByPk(6))) + await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 }); + }); + await f.service.stop([1]); + assert.equal((await f.instances.findByPk(6)).status, 0); + assert.equal((await f.instances.findByPk(1)).status, 2); +}); +test('repeated stop does not rewrite historical rows or their finished timestamps', async (t) => { + const f = await fixture(t); + await f.service.stop([1]); + const before = await f.instances.findAll({ + raw: true, + order: [['id', 'ASC']], + }); + await f.service.stop([1]); + assert.deepEqual( + await f.instances.findAll({ raw: true, order: [['id', 'ASC']] }), + before, + ); +}); +test('batch stop captures running instances from every requested cron', async (t) => { + const f = await fixture(t); + await f.service.stop([1, 2]); + assert.equal((await f.instances.findByPk(5)).status, 2); + assert.equal((await f.instances.findByPk(3)).status, 1); +}); + +test('stop signals every snapshotted PID exactly once and preserves a later running instance', async (t) => { + const f = await fixture(t, async ({ instances, crons }) => { + if (!(await instances.findByPk(6))) { + await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 }); + await crons.update( + { pid: 103, log_path: 'later.log', status: 0 }, + { where: { id: 1 } }, + ); + } + }); + await f.service.stop([1]); + assert.deepEqual(f.killed, [101, 102]); + assert.equal((await f.instances.findByPk(6)).status, 0); + assert.equal((await f.crons.findByPk(1)).pid, 103); + assert.equal((await f.crons.findByPk(1)).status, 0); +}); + +test('failed termination is not finalized as stopped', async (t) => { + const f = await fixture(t, async ({ pid }) => { + if (pid === 102) throw Error('still alive'); + }); + await f.service.stop([1]); + assert.deepEqual(f.killed, [101, 102]); + assert.equal((await f.instances.findByPk(1)).status, 2); + assert.equal((await f.instances.findByPk(2)).status, 0); + assert.equal((await f.crons.findByPk(1)).status, 0); +}); diff --git a/test/back/subscription-cleanup.test.cjs b/test/back/subscription-cleanup.test.cjs new file mode 100644 index 00000000..fb9acf90 --- /dev/null +++ b/test/back/subscription-cleanup.test.cjs @@ -0,0 +1,53 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const dayjs = require('dayjs'); +const load = require('../helpers/load-security-module.cjs'); + +test('subscription becomes idle and closes its log even when completion logging fails', async () => { + const updates = []; + let closed = 0; + let notified = 0; + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': {}, + '../data/subscription': { + SubscriptionModel: { update: async (values) => updates.push(values) }, + SubscriptionStatus: { idle: 1 }, + }, + '../data/cron': {}, + '../config/util': { handleLogPath: async () => '/tmp/unused-log' }, + '../config/const': { LOG_END_SYMBOL: 'end' }, + '../config/subscription': {}, + '../shared/i18n': { t: (s) => s, tf: (s) => s }, + '../shared/pLimit': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': { + logStreamManager: { + write: async () => { + throw new Error('ENOSPC'); + }, + closeStream: async () => { + closed++; + throw new Error('ENOSPC'); + }, + }, + }, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Subscription( + {}, + {}, + { sendMessage: () => notified++ }, + {}, + {}, + ); + service.getDb = async () => ({ id: 1, log_path: 'log' }); + const callbacks = service.taskCallbacks({ id: 1 }); + await assert.rejects(callbacks.onEnd(undefined, dayjs(), 1), /ENOSPC/); + assert.equal(closed, 1); + assert.equal(updates.at(-1).status, 1); + assert.equal(notified, 1); +}); diff --git a/test/back/subscription-mutation-lock.test.cjs b/test/back/subscription-mutation-lock.test.cjs new file mode 100644 index 00000000..fc86d283 --- /dev/null +++ b/test/back/subscription-mutation-lock.test.cjs @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-subscription-lock-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const config = { scriptPath: path.join(root, 'scripts/') }; + const { withSubscriptionMutation } = load( + 'back/shared/subscriptionMutationLock.ts', + { '../config': config }, + ); + return { root, config, withSubscriptionMutation }; +} + +test('subscription ownership mutations exclude a second process until cleanup completes', async (t) => { + const f = fixture(t); + let release, entered; + const gate = new Promise((resolve) => (release = resolve)); + const held = new Promise((resolve) => (entered = resolve)); + t.after(() => release()); + const first = f.withSubscriptionMutation(async () => { + entered(); + await gate; + }); + await held; + const marker = path.join(f.root, 'second-writer'); + const source = ` + const load = require(${JSON.stringify( + path.resolve('test/helpers/load-security-module.cjs'), + )}); + const { withSubscriptionMutation } = load(${JSON.stringify( + path.resolve('back/shared/subscriptionMutationLock.ts'), + )}, { '../config': ${JSON.stringify(f.config)} }); + process.send('attempt'); + withSubscriptionMutation(async () => require('fs').writeFileSync(${JSON.stringify( + marker, + )}, 'done')) + .then(() => process.exit(0), (error) => { console.error(error); process.exit(1); }); + `; + const child = spawn(process.execPath, ['-e', source], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + t.after(() => child.kill()); + let errors = ''; + child.stderr.on('data', (chunk) => (errors += chunk)); + const completed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => + code === 0 + ? resolve() + : reject(new Error(errors || `child exit ${code}`)), + ); + }); + await new Promise((resolve) => child.once('message', resolve)); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(fs.existsSync(marker), false); + release(); + await Promise.all([first, completed]); + assert.equal(fs.readFileSync(marker, 'utf8'), 'done'); + assert.equal( + fs.existsSync(`${path.resolve(f.config.scriptPath)}.subscriptions.lock`), + false, + ); +}); + +test('failed subscription mutations release the lock before the next operation', async (t) => { + const f = fixture(t); + await assert.rejects( + f.withSubscriptionMutation(async () => { + throw new Error('failed cleanup'); + }), + /failed cleanup/, + ); + assert.equal( + await f.withSubscriptionMutation(async () => 'next mutation'), + 'next mutation', + ); +}); diff --git a/test/back/subscription-review-regression.test.cjs b/test/back/subscription-review-regression.test.cjs new file mode 100644 index 00000000..71b56b9a --- /dev/null +++ b/test/back/subscription-review-regression.test.cjs @@ -0,0 +1,607 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const ts = require('typescript'); +const load = require('../helpers/load-security-module.cjs'); +const paths = load(path.resolve('back/shared/subscriptionPath.ts')); +const { formatUrl, formatCommand } = load( + path.resolve('back/config/subscription.ts'), +); + +function temporary(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sub-review-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function privateSubscription(alias, id = 1) { + return { + id, + type: 'private-repo', + pull_type: 'ssh-key', + alias, + url: 'git@example.com:owner/repo.git', + pull_option: { private_key: 'fixture-key' }, + }; +} + +function sshService(root, warnings = []) { + const Ssh = load(path.resolve('back/services/sshKey.ts'), { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../config': { sshdPath: root }, + '../config/util': { + fileExist: async (p) => fs.existsSync(p), + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + }).default; + return Object.assign(Object.create(Ssh.prototype), { + sshPath: root, + logger: { error() {}, warn: (...args) => warnings.push(args) }, + }); +} + +test('aliases generated by the frontend for valid branches remain accepted', () => { + const text = fs.readFileSync( + path.resolve('src/pages/subscription/modal.tsx'), + 'utf8', + ); + const source = text.slice( + text.indexOf('const formatAlias ='), + text.indexOf('\n\n const IntervalSelect'), + ); + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }).outputText; + const formatAlias = new Function( + 'repoUrlRegx', + 'fileUrlRegx', + 'type', + js + '\nreturn formatAlias;', + )( + /([^\/\:]+\/[^\/]+)(?=\.git)/, + /([^\/\:]+\/[^\/\.]+)\.[a-z]+/, + 'public-repo', + ); + for (const branch of [ + 'main', + 'release+hotfix', + 'feature/foo', + 'feature/foo/bar', + 'release@v2', + 'release#v2', + ]) { + const alias = formatAlias('https://example.com/owner/repo.git', branch); + assert.equal(alias, `owner_repo_${branch}`); + assert.doesNotThrow(() => paths.assertSubscriptionAlias(alias)); + } +}); + +test('nested aliases stay inside the root and cannot claim internal SSH names', (t) => { + const root = temporary(t); + const scripts = path.join(root, 'scripts'); + fs.mkdirSync(scripts); + assert.equal( + paths.resolveSubscriptionPath(scripts, 'owner_repo_feature/foo'), + path.join(scripts, 'owner_repo_feature/foo'), + ); + fs.symlinkSync(root, path.join(scripts, 'escape')); + assert.throws(() => paths.resolveSubscriptionPath(scripts, 'escape/outside')); + for (const alias of [ + '.', + '..', + '../outside', + '/outside', + 'x/../outside', + 'x/./a', + 'x//a', + 'x/', + 'x\\a', + 'x\0a', + 'x\nHost *', + 'x y', + '~global_secret', + '~sub_' + 'a'.repeat(64), + ]) { + assert.throws(() => paths.assertSubscriptionAlias(alias), alias); + } + assert.throws(() => paths.assertSubscriptionAlias('a'.repeat(201))); +}); + +test('SSH URL, key and configuration use the same stable name for nested aliases', async (t) => { + const root = temporary(t); + const ssh = sshService(root); + const docs = [ + privateSubscription('owner_repo_release+hotfix'), + privateSubscription('owner_repo_feature/foo', 2), + privateSubscription('owner_repo_feature_foo', 3), + privateSubscription('owner_repo.config', 4), + ]; + const snapshot = structuredClone(docs); + await ssh.setSshConfig(docs); + assert.deepEqual(docs, snapshot); + const mapped = paths.getSubscriptionSshAlias(docs[1].alias); + assert.match(mapped, /^~sub_[a-f0-9]{64}$/); + assert.notEqual(mapped, paths.getSubscriptionSshAlias(docs[2].alias)); + assert.equal(paths.getSubscriptionSshAlias(docs[0].alias), docs[0].alias); + for (const doc of docs) { + const name = paths.getSubscriptionSshAlias(doc.alias); + const key = path.join(root, name); + const config = path.join(root, `${name}.config`); + assert.equal(fs.readFileSync(key, 'utf8').trim(), 'fixture-key'); + assert.equal(fs.statSync(key).mode & 0o777, 0o400); + assert.equal(fs.statSync(config).mode & 0o777, 0o600); + assert.ok(fs.readFileSync(config, 'utf8').startsWith(`Host ${name}\n`)); + assert.equal(formatUrl(doc).url, `git@${name}:owner/repo.git`); + const parsed = execFileSync('ssh', ['-G', '-F', config, name], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.match(parsed, /^hostname example\.com$/m); + assert.ok(parsed.includes(`identityfile ${key}\n`)); + } + await ssh.removeSSHKey(docs[1].alias, 'example.com'); + assert.equal(fs.existsSync(path.join(root, mapped)), false); + assert.equal(fs.existsSync(path.join(root, `${mapped}.config`)), false); + await ssh.addSSHKey('replacement', docs[1].alias, 'example.com'); + assert.equal( + fs.readFileSync(path.join(root, mapped), 'utf8').trim(), + 'replacement', + ); +}); + +test('startup retains historical aliases and isolates bad rows and asynchronous scheduling failures', async (t) => { + const root = temporary(t); + const warnings = []; + const ssh = sshService(root, warnings); + const docs = [ + privateSubscription('owner_repo_release+hotfix', 1), + privateSubscription('owner_repo_feature/foo', 2), + privateSubscription('../outside', 3), + { + id: 4, + type: 'public-repo', + alias: 'failed-job', + url: 'https://example.com/repo.git', + }, + { + id: 5, + type: 'public-repo', + alias: 'healthy-job', + url: 'https://example.com/repo.git', + }, + ]; + const registered = []; + const types = Object.fromEntries( + ['system', 'schedule', 'subscription', 'ssh'].map((key) => [key, class {}]), + ); + const services = new Map([ + [types.system, { getSystemConfig: async () => ({}) }], + [ + types.schedule, + { cancelIntervalTask: async () => {}, createIntervalTask: () => {} }, + ], + [types.ssh, ssh], + [ + types.subscription, + { + setSshConfig: () => ssh.setSshConfig(docs), + list: async () => docs.map((doc) => ({ ...doc, get: () => doc })), + handleTask: async (doc) => { + if (doc.id === 4) throw new Error('unavailable scheduler'); + registered.push(doc.id); + }, + }, + ], + ]); + const initTask = load(path.resolve('back/loaders/initTask.ts'), { + '../config': { rootPath: root }, + '../config/util': { fileExist: async () => false }, + '../shared/i18n': { t: (x) => x }, + './logger': { warn: (...args) => warnings.push(args) }, + '../services/system': types.system, + '../services/schedule': types.schedule, + '../services/subscription': types.subscription, + '../services/sshKey': types.ssh, + typedi: { Container: { get: (key) => services.get(key) } }, + }).default; + await initTask(); + assert.deepEqual(registered, [1, 2, 5]); + assert.equal(warnings.length, 3); + assert.equal( + fs.existsSync(path.join(root, 'owner_repo_release+hotfix.config')), + true, + ); +}); + +test('subscription create and update preserve branch aliases without rewriting records', async (t) => { + const root = temporary(t); + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': { logPath: root, scriptPath: root }, + '../data/subscription': { + Subscription: class { + constructor(value) { + Object.assign(this, value); + } + }, + SubscriptionModel: {}, + }, + '../data/cron': {}, + '../config/util': {}, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Subscription({}, {}, {}, {}, {}); + const tasks = []; + service.insert = async (value) => ({ ...value, get: () => value }); + service.getDb = async () => ({ id: 1, alias: 'owner_repo_release+hotfix' }); + service.updateDb = async (value) => value; + service.handleTask = async (doc) => tasks.push(doc.alias); + service.setSshConfig = async () => {}; + for (const alias of ['owner_repo_release+hotfix', 'owner_repo_feature/foo']) { + await service.create({ id: 1, alias }); + await service.update({ id: 1, alias }); + } + assert.deepEqual(tasks, [ + 'owner_repo_release+hotfix', + 'owner_repo_release+hotfix', + 'owner_repo_feature/foo', + 'owner_repo_feature/foo', + ]); +}); + +function shellFunction(name) { + const source = fs.readFileSync(path.resolve('shell/update.sh'), 'utf8'); + const start = source.indexOf(`${name}() {`); + assert.ok(start >= 0); + return source.slice(start, source.indexOf('\n}', start) + 2); +} + +function updateFixture(t) { + const root = temporary(t); + const template = path.join(root, 'template'); + for (const dir of ['template', 'repo', 'scripts', 'lists']) + fs.mkdirSync(path.join(root, dir)); + for (const file of ['keep.js', 'keep.py', 'omit.js', 'other.txt', 'dep.js']) + fs.writeFileSync(path.join(template, file), file); + for (const file of ['notify.js', 'notify.py', 'crontab']) + fs.writeFileSync(path.join(root, file), ''); + const helpers = [ + 'update_repo', + 'diff_scripts', + 'gen_list_repo', + 'get_uniq_path', + ] + .map(shellFunction) + .join('\n'); + return { + root, + run: (extensions, dependence = 'dep') => { + const doc = { + id: 1, + type: 'public-repo', + alias: 'owner_repo', + url: 'https://example.invalid/owner/repo.git', + whitelist: 'keep', + blacklist: 'omit', + dependences: dependence, + extensions, + autoAddCron: false, + autoDelCron: false, + }; + const code = `dir_repo="$QA_ROOT/repo"; dir_scripts="$QA_ROOT/scripts"; dir_list_tmp="$QA_ROOT/lists" +file_notify_js="$QA_ROOT/notify.js"; file_notify_py="$QA_ROOT/notify.py"; list_crontab_user="$QA_ROOT/crontab" +cmd_task=task; file_extensions='js py' +${helpers} +t() { :; }; make_dir() { mkdir -p "$1"; }; diff_cron() { :; } +git_clone_scripts() { mkdir -p "$2"; cp "$QA_ROOT/template/"* "$2/"; exit_status=0; } +ql() { get_uniq_path "$2" "$6"; update_repo "${'${@:2}'}"; } +${formatCommand(doc)}`; + execFileSync('/bin/bash', ['-c', code], { + env: { ...process.env, QA_ROOT: root }, + encoding: 'utf8', + timeout: 5000, + }); + return path.join(root, 'scripts', 'owner_repo'); + }, + }; +} + +test('real repository update supports extension separators, filtering and dependency copies', (t) => { + for (const extension of ['js|py', 'js py', 'js\tpy', 'js\npy', '']) { + const f = updateFixture(t); + const scripts = f.run(extension); + assert.deepEqual(fs.readdirSync(scripts).sort(), [ + 'dep.js', + 'keep.js', + 'keep.py', + 'notify.js', + 'notify.py', + ]); + assert.deepEqual( + fs + .readFileSync( + path.join(f.root, 'lists', 'owner_repo_scripts.list'), + 'utf8', + ) + .trim() + .split('\n') + .sort(), + ['owner_repo/keep.js', 'owner_repo/keep.py'], + ); + } +}); + +test('real repository update never evaluates extension input, including dependency discovery', (t) => { + const f = updateFixture(t); + const marker = path.join(f.root, 'executed'); + const payloads = [ + 'js|$(touch${IFS}' + marker + ')', + 'js|`touch${IFS}' + marker + '`', + 'js|py";touch${IFS}' + marker + ';#', + 'js|$(touch${IFS}' + marker + ')\npy', + ]; + for (const payload of payloads) { + const scripts = f.run(payload); + assert.equal(fs.existsSync(marker), false, payload); + assert.equal( + fs.readFileSync(path.join(scripts, 'keep.js'), 'utf8'), + 'keep.js', + ); + assert.equal( + fs.readFileSync(path.join(scripts, 'dep.js'), 'utf8'), + 'dep.js', + ); + } +}); + +test('force deletion supports historical nested aliases while preserving outside targets', async (t) => { + const root = temporary(t); + const config = { + scriptPath: path.join(root, 'scripts'), + repoPath: path.join(root, 'repo'), + }; + const alias = 'owner_repo_feature/foo'; + for (const dir of [config.scriptPath, config.repoPath]) { + fs.mkdirSync(path.join(dir, alias), { recursive: true }); + fs.writeFileSync(path.join(dir, alias, 'file'), 'fixture'); + } + const outside = path.join(root, 'outside'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'file'), 'keep'); + let destroyed = 0; + const doc = { id: 1, alias, get: () => doc }; + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': config, + '../data/subscription': { + SubscriptionModel: { + findAll: async () => [doc], + destroy: async () => destroyed++, + }, + }, + '../data/cron': { CrontabModel: { findAll: async () => [] } }, + '../config/util': { + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Subscription({}, {}, {}, {}, {}); + service.handleTask = async () => {}; + service.setSshConfig = async () => {}; + await service.remove([1], { force: true }); + assert.equal(destroyed, 1); + for (const dir of [config.scriptPath, config.repoPath]) + assert.equal(fs.existsSync(path.join(dir, alias)), false); + fs.symlinkSync(outside, path.join(config.scriptPath, 'escape')); + doc.alias = 'escape/file'; + await assert.rejects( + service.remove([1], { force: true }), + /Invalid subscription path/, + ); + assert.equal(destroyed, 1); + assert.equal(fs.readFileSync(path.join(outside, 'file'), 'utf8'), 'keep'); + assert.equal(paths.getSubscriptionSshAlias('仓库-main'), '仓库-main'); +}); + +function cancellationFixture(t, subscriptions) { + const root = temporary(t); + const events = []; + const docs = subscriptions.map((value) => { + const doc = { ...value }; + doc.get = () => ({ ...doc }); + return doc; + }); + const Subscription = load(path.resolve('back/services/subscription.ts'), { + '../config': { scriptPath: root, repoPath: root }, + '../data/subscription': { + SubscriptionModel: { + findAll: async ({ where }) => + docs.filter((doc) => !where.id || where.id.includes(doc.id)), + destroy: async ({ where }) => events.push(['destroy', where.id]), + update: async (value, { where }) => { + for (const doc of docs) { + if (where.id.includes(doc.id)) Object.assign(doc, value); + } + }, + }, + }, + '../data/cron': {}, + '../config/util': { rmPath: async () => events.push(['removeFile']) }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const schedule = { + cancelCronTask: (doc) => events.push(['cancelCron', doc.id]), + cancelIntervalTask: (doc) => events.push(['cancelInterval', doc.id]), + createCronTask: async (doc, callbacks, immediately) => + events.push(['createCron', doc.command, immediately]), + createIntervalTask: async (doc, interval, immediately) => + events.push(['createInterval', doc.command, interval, immediately]), + }; + const service = new Subscription( + {}, schedule, {}, { removeSSHKey: async () => {} }, {}, + ); + service.setSshConfig = async () => {}; + return { service, docs, events }; +} + +async function sshLifecycleFixture(t, initial) { + const root = temporary(t); + const ssh = sshService(root); + let docs = [{ ...initial, get() { return { ...this }; } }]; + const SubscriptionService = load(path.resolve('back/services/subscription.ts'), { + '../config': { scriptPath: root, repoPath: root }, + '../data/subscription': { + Subscription: class { constructor(value) { Object.assign(this, value); } }, + SubscriptionModel: { + findAll: async () => docs, + destroy: async () => { docs = []; }, + }, + }, + '../data/cron': { CrontabModel: { findAll: async () => [] } }, + '../config/util': { + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, './sock': {}, './sshKey': {}, './cron': {}, + }).default; + const service = new SubscriptionService({}, {}, {}, ssh, {}); + service.handleTask = async () => {}; + service.getDb = async () => ({ ...docs[0] }); + service.updateDb = async (value) => { + Object.assign(docs[0], value); + return { ...docs[0] }; + }; + await service.setSshConfig(); + return { service, root }; +} + +test('editing an SSH subscription removes the old alias key and configuration', async (t) => { + const initial = privateSubscription('owner_repo_main'); + const f = await sshLifecycleFixture(t, initial); + const alias = 'owner_repo_feature/中文'; + await f.service.update({ ...initial, alias }); + const mapped = paths.getSubscriptionSshAlias(alias); + assert.equal(fs.existsSync(path.join(f.root, initial.alias)), false); + assert.equal(fs.existsSync(path.join(f.root, initial.alias + '.config')), false); + assert.equal(fs.readFileSync(path.join(f.root, mapped), 'utf8').trim(), 'fixture-key'); + assert.ok(fs.existsSync(path.join(f.root, mapped + '.config'))); +}); + +test('switching an SSH subscription to a public repository removes obsolete credentials', async (t) => { + const initial = privateSubscription('owner_repo.config'); + const f = await sshLifecycleFixture(t, initial); + await f.service.update({ ...initial, type: 'public-repo' }); + assert.deepEqual(fs.readdirSync(f.root), []); +}); + +test('ordinary and forced subscription deletion remove the current SSH key and configuration', async (t) => { + for (const force of [false, true]) { + const initial = privateSubscription('owner_repo_feature/中文'); + const f = await sshLifecycleFixture(t, initial); + await f.service.remove([initial.id], { force }); + assert.deepEqual(fs.readdirSync(f.root), []); + } +}); + +test('ordinary deletion cancels invalid historical SSH subscriptions by ID', async (t) => { + for (const schedule_type of ['crontab', 'interval']) { + const f = cancellationFixture(t, [ + { + ...privateSubscription('../outside'), + schedule_type, + interval_schedule: { type: 'hours', value: 1 }, + }, + ]); + await f.service.remove([1], { force: false }); + assert.deepEqual(f.events, [ + [schedule_type === 'crontab' ? 'cancelCron' : 'cancelInterval', 1], + ['destroy', [1]], + ]); + } +}); + +test('invalid historical SSH rows do not block mixed batch deletion or disabling', async (t) => { + const docs = [ + { ...privateSubscription('healthy', 1), schedule_type: 'crontab' }, + { ...privateSubscription('../outside', 2), schedule_type: 'crontab' }, + { + ...privateSubscription('old alias', 3), + schedule_type: 'interval', + interval_schedule: { type: 'hours', value: 1 }, + }, + ]; + const cancellations = [ + ['cancelCron', 1], + ['cancelCron', 2], + ['cancelInterval', 3], + ]; + const removed = cancellationFixture(t, docs); + await removed.service.remove([1, 2, 3], {}); + assert.deepEqual(removed.events, [...cancellations, ['destroy', [1, 2, 3]]]); + const disabled = cancellationFixture(t, docs); + await disabled.service.disabled([1, 2, 3]); + assert.deepEqual(disabled.events, cancellations); + assert.ok(disabled.docs.every((doc) => doc.is_disabled === 1)); +}); + +test('cancellation bypass leaves alias validation for creation and forced deletion intact', async (t) => { + const doc = { + ...privateSubscription('../outside'), + schedule_type: 'crontab', + }; + const f = cancellationFixture(t, [doc]); + await assert.rejects(f.service.handleTask(doc), /Invalid subscription alias/); + await assert.rejects(f.service.create(doc), /Invalid subscription alias/); + await assert.rejects(f.service.enabled([1]), /Invalid subscription alias/); + await assert.rejects( + f.service.remove([1], { force: true }), + /Invalid subscription alias/, + ); + assert.deepEqual(f.events, []); +}); + +test('valid subscription scheduling still creates quoted commands for both schedule types', async (t) => { + for (const schedule_type of ['crontab', 'interval']) { + const doc = { + ...privateSubscription('owner_repo_feature/foo'), + schedule_type, + interval_schedule: { type: 'hours', value: 1 }, + }; + const f = cancellationFixture(t, [doc]); + await f.service.handleTask(doc, true, true); + const command = formatCommand(doc); + assert.deepEqual( + f.events, + schedule_type === 'crontab' + ? [ + ['cancelCron', 1], + ['createCron', command, true], + ] + : [ + ['cancelInterval', 1], + ['createInterval', command, { hours: 1 }, true], + ], + ); + } +}); diff --git a/test/back/subscription-stop-state.test.cjs b/test/back/subscription-stop-state.test.cjs new file mode 100644 index 00000000..e99fa091 --- /dev/null +++ b/test/back/subscription-stop-state.test.cjs @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { Sequelize, DataTypes } = require('sequelize'); +const load = require('../helpers/load-security-module.cjs'); + +async function fixture(t, onKill = async () => {}) { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const rows = db.define('Subscription', { + pid: DataTypes.INTEGER, + status: DataTypes.INTEGER, + }); + await db.sync(); + await rows.create({ id: 1, pid: 101, status: 0 }); + const Service = load('back/services/subscription.ts', { + '../config': {}, + '../data/cron': {}, + '../config/const': {}, + '../data/subscription': { + SubscriptionModel: rows, + SubscriptionStatus: { idle: 1 }, + }, + '../config/util': { killTask: async () => onKill(rows) }, + '../config/subscription': {}, + '../shared/i18n': {}, + '../shared/pLimit': {}, + '../shared/logReader': {}, + '../shared/logStreamManager': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + return { rows, service: new Service({ error() {} }, {}, {}, {}, {}) }; +} + +test('successful subscription stop clears the stored PID', async (t) => { + const f = await fixture(t); + await f.service.stop([1]); + const row = await f.rows.findByPk(1); + assert.equal(row.status, 1); + assert.equal(row.pid, null); +}); + +test('subscription stop does not overwrite a replacement process started during termination', async (t) => { + const f = await fixture(t, async (rows) => { + await rows.update({ pid: 202, status: 0 }, { where: { id: 1 } }); + }); + await f.service.stop([1]); + const row = await f.rows.findByPk(1); + assert.equal(row.status, 0); + assert.equal(row.pid, 202); +}); + +test('subscription stop cancels a queued row without a PID', async (t) => { + const f = await fixture(t, () => assert.fail('queued row has no process')); + await f.rows.update({ pid: null, status: 3 }, { where: { id: 1 } }); + await f.service.stop([1]); + const row = await f.rows.findByPk(1); + assert.equal(row.status, 1); + assert.equal(row.pid, null); +}); diff --git a/test/back/subscription-storage-regression.test.cjs b/test/back/subscription-storage-regression.test.cjs new file mode 100644 index 00000000..a27230a1 --- /dev/null +++ b/test/back/subscription-storage-regression.test.cjs @@ -0,0 +1,553 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); +const { getSubscriptionStorageNames, getSubscriptionStorageCandidates } = load( + 'back/shared/subscriptionStorage.ts', +); +const { formatUrl } = load('back/config/subscription.ts'); +const cases = [ + { + type: 'public-repo', + url: 'git://127.0.0.1:9418/public.git', + branch: 'main', + }, + { + type: 'public-repo', + url: 'https://github.com/owner/repo.git', + branch: 'feature/release+hotfix', + }, + { type: 'public-repo', url: 'https://github.com/owner/repo.git/' }, + { + type: 'private-repo', + pull_type: 'ssh-key', + url: 'root@127.0.0.1:/qa/fixtures/private.git', + branch: 'main', + }, + { + type: 'private-repo', + pull_type: 'ssh-key', + url: 'git@github.com:owner/repo.git', + branch: 'dev', + }, + { + type: 'private-repo', + pull_type: 'user-pwd', + pull_option: { username: 'tester', password: 'password' }, + url: 'http://127.0.0.1:8788/git/http_private.git', + branch: 'main', + }, + { type: 'file', url: 'http://127.0.0.1:8788/raw.js' }, + { type: 'file', url: 'https://example.com/owner/job.py', branch: 'ignored' }, +].map((doc, index) => ({ + ...doc, + id: index + 1, + alias: `custom/alias+${index}`, +})); +const source = fs.readFileSync('shell/update.sh', 'utf8'); +const bashFunction = source.match(/^get_uniq_path\(\) \{[\s\S]*?^\}/m)[0]; +function shellNames(doc) { + const url = formatUrl(doc).url; + const stem = execFileSync( + 'bash', + [ + '-c', + `${bashFunction}\nget_uniq_path "$1" "$2"\nprintf '%s' "$uniq_path"`, + '--', + url, + doc.type === 'file' ? '' : doc.branch || '', + ], + { encoding: 'utf8' }, + ); + if (doc.type === 'file') { + const raw = `${stem}.${url.slice(url.lastIndexOf('.') + 1)}`; + return { script: `raw_${raw}`, raw }; + } + return { script: stem, repo: stem }; +} + +test('subscription deletion paths match the actual shell URL/branch naming for public, private and raw subscriptions', () => { + for (const doc of cases) + assert.deepEqual( + getSubscriptionStorageNames(doc), + shellNames(doc), + doc.url, + ); +}); + +function fixture(t, values) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-storage-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const config = { + dataPath: root, + scriptPath: path.join(root, 'scripts'), + repoPath: path.join(root, 'repo'), + }; + fs.mkdirSync(config.scriptPath, { recursive: true }); + fs.mkdirSync(config.repoPath, { recursive: true }); + const events = []; + const docs = values.map((value) => ({ + ...value, + get() { + return { ...this }; + }, + })); + const Service = load('back/services/subscription.ts', { + '../config': config, + '../data/subscription': { + Subscription: class { + constructor(value) { + Object.assign(this, value); + } + }, + SubscriptionModel: { + findAll: async ({ where }) => + docs.filter((doc) => !where.id || where.id.includes(doc.id)), + create: async (value) => { + const doc = { + ...value, + get() { + return { ...this }; + }, + }; + docs.push(doc); + return doc; + }, + findOne: async ({ where }) => docs.find((doc) => doc.id === where.id), + update: async (value, { where }) => { + Object.assign( + docs.find((doc) => doc.id === where.id), + value, + ); + }, + destroy: async ({ where }) => { + events.push('destroy'); + for (let i = docs.length - 1; i >= 0; i--) { + if (where.id.includes(docs[i].id)) docs.splice(i, 1); + } + }, + }, + }, + '../data/cron': { CrontabModel: { findAll: async () => [{ id: 99 }] } }, + '../config/util': { + rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }), + }, + '../config/const': {}, + '../shared/i18n': { t: (x) => x, tf: (x) => x }, + '../shared/pLimit': {}, + './schedule': {}, + './sock': {}, + './sshKey': {}, + './cron': {}, + }).default; + const service = new Service( + {}, + {}, + {}, + { removeSSHKey: async (alias) => events.push(['key', alias]) }, + { remove: async (ids) => events.push(['crons', ids]) }, + ); + service.handleTask = async () => events.push('cancel'); + service.setSshConfig = async () => {}; + const paths = (doc) => + Object.entries(shellNames(doc)).map(([kind, name]) => + path.join( + kind === 'script' + ? config.scriptPath + : kind === 'repo' + ? config.repoPath + : path.join(root, 'raw'), + name, + ), + ); + const write = (target) => { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'fixture'); + }; + return { root, config, docs, events, service, paths, write }; +} + +test('force deletion cleans URL-derived clone/script/raw artifacts and associated jobs, preserving unrelated alias folders', async (t) => { + const f = fixture(t, cases); + const artifacts = []; + for (const doc of cases) { + for (const dir of f.paths(doc)) { + const artifact = doc.type === 'file' ? dir : path.join(dir, 'job.js'); + artifacts.push(artifact); + f.write(artifact); + } + f.write(path.join(f.config.scriptPath, doc.alias, 'unrelated.js')); + } + await f.service.remove( + cases.map((doc) => doc.id), + { force: true }, + ); + for (const artifact of artifacts) + assert.equal(fs.existsSync(artifact), false, artifact); + for (const doc of cases) + assert.equal( + fs.readFileSync( + path.join(f.config.scriptPath, doc.alias, 'unrelated.js'), + 'utf8', + ), + 'fixture', + ); + assert.ok(f.events.some((e) => Array.isArray(e) && e[0] === 'crons')); + assert.equal( + f.events.filter((e) => Array.isArray(e) && e[0] === 'key').length, + 2, + ); +}); + +test('ordinary deletion preserves subscription artifacts', async (t) => { + const f = fixture(t, [cases[0]]); + const file = path.join(f.paths(cases[0])[0], 'job.js'); + f.write(file); + await f.service.remove([1], {}); + assert.equal(fs.readFileSync(file, 'utf8'), 'fixture'); +}); + +test('a URL-derived symlink escape rejects the entire force deletion before cancelling jobs or deleting rows', async (t) => { + const f = fixture(t, [cases[0], cases[6]]); + const outside = path.join(f.root, 'outside'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'keep'), 'keep'); + fs.mkdirSync(f.config.repoPath, { recursive: true }); + fs.symlinkSync(outside, f.paths(cases[0])[1]); + await assert.rejects( + f.service.remove([1, 7], { force: true }), + /Invalid subscription path/, + ); + assert.deepEqual(f.events, []); + assert.equal(fs.readFileSync(path.join(outside, 'keep'), 'utf8'), 'keep'); +}); + +const cliCases = [ + { + type: 'public-repo', + url: 'https://github.com/owner/repo.git/', + branch: 'main', + }, + { + type: 'private-repo', + url: 'git@github.com:repo.git', + branch: 'main', + pull_type: 'ssh-key', + }, + { type: 'file', url: 'https://example.com/owner/job.py?version=2' }, + { type: 'file', url: 'https://example.com/owner/job' }, +].map((doc, index) => ({ ...doc, id: index + 1, alias: `cli-${index}` })); + +test('cleanup candidates match both actual CLI naming and legacy Shell naming', () => { + const { repositoryName } = load( + 'cli/src/internal/subscription/subscriptionRunner.ts', + ); + for (const doc of [...cases, ...cliCases]) { + const url = formatUrl(doc).url; + const stem = repositoryName( + url, + doc.type === 'file' ? undefined : doc.branch, + ); + const raw = + doc.type === 'file' + ? stem + path.extname(new URL(url).pathname) + : undefined; + assert.deepEqual( + getSubscriptionStorageNames(doc, 'cli'), + raw === undefined + ? { script: stem, repo: stem } + : { script: `raw_${raw}`, raw }, + ); + assert.deepEqual(getSubscriptionStorageNames(doc), shellNames(doc)); + } +}); + +test('force deletion removes CLI and legacy artifacts for trailing slashes, SCP URLs and raw URL queries', async (t) => { + const f = fixture(t, cliCases); + const artifacts = []; + for (const doc of cliCases) + for (const names of getSubscriptionStorageCandidates(doc)) { + for (const [kind, name] of Object.entries(names)) { + const base = + kind === 'script' + ? f.config.scriptPath + : kind === 'repo' + ? f.config.repoPath + : path.join(f.root, 'raw'); + const target = path.join( + base, + name, + ...(doc.type === 'file' ? [] : ['job.js']), + ); + artifacts.push(target); + f.write(target); + } + } + await f.service.remove( + cliCases.map((doc) => doc.id), + { force: true }, + ); + for (const file of artifacts) assert.equal(fs.existsSync(file), false, file); +}); + +for (const type of ['public-repo', 'file']) { + test(`force deletion retains shared ${type} artifacts until the last subscription is removed`, async (t) => { + const first = { ...cases[type === 'file' ? 6 : 0], id: 1, alias: 'first' }; + const second = { ...first, id: 2, alias: 'second' }; + const f = fixture(t, [first, second]); + const artifacts = f + .paths(first) + .map((p) => (type === 'file' ? p : path.join(p, 'job.js'))); + artifacts.forEach(f.write); + await f.service.remove([1], { force: true }); + for (const file of artifacts) assert.equal(fs.existsSync(file), true, file); + await f.service.remove([2], { force: true }); + for (const file of artifacts) + assert.equal(fs.existsSync(file), false, file); + }); +} + +test('batch deletion of every reference removes shared storage once', async (t) => { + const f = fixture(t, [ + { ...cases[0], id: 1 }, + { ...cases[0], id: 2, alias: 'second' }, + ]); + const artifacts = f.paths(cases[0]).map((p) => path.join(p, 'job.js')); + artifacts.forEach(f.write); + await f.service.remove([1, 2], { force: true }); + for (const file of artifacts) assert.equal(fs.existsSync(file), false, file); +}); + +test('recursive cleanup retains overlapping parent and nested branch directories', async (t) => { + const parent = { ...cases[0], id: 1, branch: 'main', alias: 'parent' }; + const child = { ...parent, id: 2, branch: 'main/nested', alias: 'child' }; + for (const removed of [1, 2]) { + const f = fixture(t, [parent, child]); + const artifacts = [parent, child].flatMap((doc) => + f.paths(doc).map((p) => path.join(p, 'job.js')), + ); + artifacts.forEach(f.write); + await f.service.remove([removed], { force: true }); + for (const file of artifacts) assert.equal(fs.existsSync(file), true, file); + } +}); + +test('unrelated malformed historical rows do not block force deletion, and malformed shared references still retain storage', async (t) => { + const selected = { ...cases[1], id: 1, branch: 'main', alias: 'selected' }; + for (const shared of [false, true]) { + const invalid = { + id: 2, + alias: '../historical', + type: 'private-repo', + pull_type: 'ssh-key', + url: `git@github.com:${shared ? 'owner/repo' : 'other/unrelated'}.git`, + branch: 'main', + }; + const f = fixture(t, [selected, invalid]); + const files = f.paths(selected).map((p) => path.join(p, 'job.js')); + files.forEach(f.write); + await f.service.remove([1], { force: true }); + assert.deepEqual( + f.docs.map((doc) => doc.id), + [2], + ); + for (const file of files) assert.equal(fs.existsSync(file), shared); + } +}); + +test('an unused invalid Shell candidate does not block cleanup of the valid CLI raw files', async (t) => { + const doc = { + id: 1, + alias: 'valid-cli-raw', + type: 'file', + url: 'https://example.com/job.js?source=https://foo/bar', + }; + const f = fixture(t, [doc]); + const names = getSubscriptionStorageNames(doc, 'cli'); + const files = [ + path.join(f.config.scriptPath, names.script), + path.join(f.root, 'raw', names.raw), + ]; + files.forEach(f.write); + await f.service.remove([1], { force: true }); + assert.deepEqual(f.docs, []); + for (const file of files) assert.equal(fs.existsSync(file), false); +}); + +for (const mutation of ['create', 'update']) { + test(`force deletion prevents a concurrent ${mutation} from changing ownership after its reference snapshot`, async (t) => { + const selected = { ...cases[0], id: 1, alias: 'selected' }; + const other = { + ...selected, + id: 2, + alias: 'other', + url: 'https://github.com/other/unrelated.git', + }; + const f = fixture( + t, + mutation === 'update' ? [selected, other] : [selected], + ); + let release, entered; + const gate = new Promise((resolve) => (release = resolve)); + const held = new Promise((resolve) => (entered = resolve)); + t.after(() => release()); + f.service.handleTask = async (doc, needCreate) => { + if (doc.id === 1 && needCreate === false) { + entered(); + await gate; + } + }; + const deletion = f.service.remove([1], { force: true }); + await held; + const change = f.service[mutation]({ ...selected, id: 2, alias: 'other' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + if (mutation === 'create') + assert.deepEqual( + f.docs.map((doc) => doc.id), + [1], + ); + else assert.equal(f.docs.find((doc) => doc.id === 2).url, other.url); + release(); + await Promise.all([deletion, change]); + assert.equal(f.docs.find((doc) => doc.id === 2).url, selected.url); + }); + + test(`force deletion observes shared storage ownership established by an in-flight ${mutation}`, async (t) => { + const selected = { ...cases[0], id: 1, alias: 'selected' }; + const other = { + ...selected, + id: 2, + alias: 'other', + url: 'https://github.com/other/unrelated.git', + }; + const f = fixture( + t, + mutation === 'update' ? [selected, other] : [selected], + ); + const file = path.join(f.paths(selected)[0], 'job.js'); + f.write(file); + let release, entered; + const gate = new Promise((resolve) => (release = resolve)); + const held = new Promise((resolve) => (entered = resolve)); + t.after(() => release()); + f.service.handleTask = async (doc, needCreate) => { + if (doc.id === 2 && needCreate !== false) { + entered(); + await gate; + } + }; + const change = f.service[mutation]({ ...selected, id: 2, alias: 'other' }); + await held; + const deletion = f.service.remove([1], { force: true }); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.ok( + f.docs.some((doc) => doc.id === 1), + 'deletion waits until the ownership mutation is complete', + ); + release(); + await Promise.all([change, deletion]); + assert.deepEqual( + f.docs.map((doc) => doc.id), + [2], + ); + assert.equal(fs.existsSync(file), true); + }); +} + +test('an unrelated dangling or outside symlink reference does not block safe cleanup', async (t) => { + const selected = { ...cases[0], id: 1, alias: 'selected' }; + const other = { + ...selected, + id: 2, + alias: 'other', + url: 'https://github.com/other/repo.git', + }; + const f = fixture(t, [selected, other]); + const outside = path.join(f.root, 'outside'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'keep'), 'keep'); + fs.symlinkSync(outside, f.paths(other)[0]); + const file = path.join(f.paths(selected)[0], 'job.js'); + f.write(file); + await f.service.remove([1], { force: true }); + assert.equal(fs.existsSync(file), false); + assert.equal(fs.readFileSync(path.join(outside, 'keep'), 'utf8'), 'keep'); +}); + +test('URL-derived storage accepts legal directory names longer than user aliases and containing spaces', async (t) => { + const doc = { ...cases[0], id: 1, alias: 'short-alias', url: `/tmp/owner/${'r'.repeat(205)} repo.git` }; + const f = fixture(t, [doc]); + const files = f.paths(doc).map((p) => path.join(p, 'job.js')); + files.forEach(f.write); + await f.service.remove([1], { force: true }); + for (const file of files) assert.equal(fs.existsSync(file), false, file); +}); + +test('an unrepresentable unused Shell filename does not block valid CLI raw cleanup', async (t) => { + const doc = { + id: 1, + alias: 'long-query', + type: 'file', + url: 'https://example.com/job.js?token=' + 'a'.repeat(300), + }; + const f = fixture(t, [doc]); + const names = getSubscriptionStorageNames(doc, 'cli'); + const files = [ + path.join(f.config.scriptPath, names.script), + path.join(f.root, 'raw', names.raw), + ]; + files.forEach(f.write); + await f.service.remove([1], { force: true }); + assert.deepEqual(f.docs, []); + for (const file of files) assert.equal(fs.existsSync(file), false); +}); + +test('cleanup retains each representable path when only its prefixed Shell script name is too long', async (t) => { + const doc = { id: 1, alias: 'partial-raw', type: 'file', url: 'https://example.com/job.js?token=' }; + const prefix = getSubscriptionStorageNames(doc).raw; + doc.url += 'a'.repeat(255 - Buffer.byteLength(prefix)); + const shell = getSubscriptionStorageNames(doc); + assert.equal(Buffer.byteLength(shell.raw), 255); + assert.ok(Buffer.byteLength(shell.script) > 255); + const f = fixture(t, [doc]); + const cli = getSubscriptionStorageNames(doc, 'cli'); + const files = [ + path.join(f.root, 'raw', shell.raw), + path.join(f.root, 'raw', cli.raw), + path.join(f.config.scriptPath, cli.script), + ]; + files.forEach(f.write); + await f.service.remove([1], { force: true }); + assert.deepEqual(f.docs, []); + for (const file of files) assert.equal(fs.existsSync(file), false); +}); + +test('a legal Unicode alias with an unrepresentable unused directory does not block URL-derived cleanup', async (t) => { + const doc = { ...cases[0], id: 1, alias: '中'.repeat(86) }; + const f = fixture(t, [doc]); + const files = f.paths(doc).map((p) => path.join(p, 'job.js')); + files.forEach(f.write); + await f.service.remove([1], { force: true }); + assert.deepEqual(f.docs, []); + for (const file of files) assert.equal(fs.existsSync(file), false); +}); + +test('Unicode storage follows filesystem limits rather than imposing a UTF-8 byte cap', async (t) => { + const doc = { ...cases[0], id: 1, alias: 'short-alias', url: `/tmp/owner/${'中'.repeat(86)}.git` }; + const f = fixture(t, [doc]); + const files = f.paths(doc).map((p) => path.join(p, 'job.js')); + for (const file of files) { + try { + f.write(file); + } catch (error) { + // These names are representable on macOS but not Linux. + assert.equal(error.code, 'ENAMETOOLONG'); + } + } + await f.service.remove([1], { force: true }); + assert.deepEqual(f.docs, []); + for (const file of files) assert.equal(fs.existsSync(file), false); +}); diff --git a/test/back/system-dependence-cache.test.cjs b/test/back/system-dependence-cache.test.cjs new file mode 100644 index 00000000..1df6006d --- /dev/null +++ b/test/back/system-dependence-cache.test.cjs @@ -0,0 +1,127 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +test('clearing a dependency cache marks installed entries for reinstall', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-dependence-')); + const nodeCache = path.join(directory, 'node'); + await fs.mkdir(nodeCache); + await fs.writeFile(path.join(nodeCache, 'package'), 'cached'); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + + const moduleStubs = new Map(); + const stubModule = (modulePath, exports) => { + const resolved = require.resolve(modulePath); + moduleStubs.set(resolved, require.cache[resolved]); + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + children: [], + paths: [], + }; + }; + const updates = []; + const DependenceStatus = { installed: 1, installFailed: 2 }; + const DependenceTypes = { nodejs: 0, python3: 1 }; + + stubModule('../../back/config', { + __esModule: true, + default: { dependenceCachePath: directory }, + }); + stubModule('../../back/config/const', { + NotificationModeStringMap: {}, + TASK_COMMAND: 'task', + }); + stubModule('../../back/config/util', { + getPid: async () => 0, + killTask: async () => {}, + parseContentVersion: () => '', + parseVersion: () => '', + promiseExec: async () => '', + readDirs: async () => [], + rmPath: async () => {}, + setSystemTimezone: async () => true, + updateLinuxMirrorFile: async () => {}, + }); + stubModule('../../back/data/dependence', { + DependenceModel: { + update: async (values, options) => updates.push({ values, options }), + }, + DependenceStatus, + DependenceTypes, + }); + stubModule('../../back/data/notify', {}); + stubModule('../../back/data/system', { + AuthDataType: {}, + SystemModel: {}, + }); + stubModule('../../back/schedule/client', { __esModule: true, default: {} }); + stubModule('../../back/shared/pLimit', { + __esModule: true, + default: {}, + }); + stubModule('../../back/shared/i18n', { + setLang: () => {}, + t: (message) => message, + }); + stubModule('../../back/services/notify', { + __esModule: true, + default: class NotificationService {}, + }); + stubModule('../../back/services/schedule', { + __esModule: true, + default: class ScheduleService {}, + }); + stubModule('../../back/services/sock', { + __esModule: true, + default: class SockService {}, + }); + + const servicePath = require.resolve('../../back/services/system'); + const originalService = require.cache[servicePath]; + delete require.cache[servicePath]; + t.after(() => { + if (originalService) { + require.cache[servicePath] = originalService; + } else { + delete require.cache[servicePath]; + } + for (const [resolved, original] of moduleStubs) { + if (original) { + require.cache[resolved] = original; + } else { + delete require.cache[resolved]; + } + } + }); + + const SystemService = require('../../back/services/system').default; + const service = new SystemService({}, {}, {}); + assert.deepEqual(await service.cleanDependence('node'), { code: 200 }); + assert.deepEqual(await service.cleanDependence('python3'), { code: 200 }); + await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' }); + assert.deepEqual(updates, [ + { + values: { status: DependenceStatus.installFailed }, + options: { + where: { + type: DependenceTypes.nodejs, + status: DependenceStatus.installed, + }, + }, + }, + { + values: { status: DependenceStatus.installFailed }, + options: { + where: { + type: DependenceTypes.python3, + status: DependenceStatus.installed, + }, + }, + }, + ]); +}); diff --git a/test/back/system-export-history.test.cjs b/test/back/system-export-history.test.cjs new file mode 100644 index 00000000..4994a8cb --- /dev/null +++ b/test/back/system-export-history.test.cjs @@ -0,0 +1,119 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); +const { ScriptHistory } = load('back/shared/scriptHistory.ts', { + '../config/util': {}, +}); + +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-export-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const dataPath = path.join(root, 'data'); + for (const dir of ['db', 'upload', 'scripts']) + await fs.mkdir(path.join(dataPath, dir), { recursive: true }); + const archive = path.join(root, 'backup.tgz'); + const Service = load('back/services/system.ts', { + '../config': { dataPath, dataTgzFile: archive }, + '../config/const': {}, + '../config/util': {}, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': {}, + '../shared/pLimit': {}, + '../schedule/client': {}, + '../shared/i18n': { t: (x) => x }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + }).default; + const service = new Service({}, {}, {}); + let downloaded, status, error; + const res = { + download: (file) => { + downloaded = file; + }, + status: (code) => { + status = code; + return res; + }, + send: (value) => { + error = value; + return res; + }, + }; + const exportData = async (types) => { + downloaded = status = error = undefined; + await service.exportData(res, types); + return { downloaded, status, error }; + }; + return { root, dataPath, archive, exportData }; +} + +test('a scripts backup preserves history versions and they remain readable and restorable after extraction', async (t) => { + const f = await fixture(t); + const scripts = path.join(f.dataPath, 'scripts'); + const historyRoot = path.join(f.dataPath, 'script-history'); + await fs.writeFile(path.join(scripts, 'history.js'), 'baseline'); + const history = new ScriptHistory(scripts, historyRoot); + await history.save('', 'history.js', 'second'); + await history.save('', 'history.js', 'third'); + const before = await history.list('', 'history.js'); + assert.ok(before.versions.length >= 2); + assert.deepEqual(await f.exportData(['scripts']), { + downloaded: f.archive, + status: undefined, + error: undefined, + }); + const restored = path.join(f.root, 'restored'); + await fs.mkdir(restored); + execFileSync('tar', ['-zxf', f.archive, '-C', restored]); + const after = new ScriptHistory( + path.join(restored, 'data/scripts'), + path.join(restored, 'data/script-history'), + ); + assert.deepEqual(await after.list('', 'history.js'), before); + const oldest = before.versions.at(-1); + const detail = await after.detail('', 'history.js', oldest.id); + assert.equal(detail.version.content, 'baseline'); + await after.restore('', 'history.js', oldest.id, before.currentHash); + assert.equal( + await fs.readFile(path.join(restored, 'data/scripts/history.js'), 'utf8'), + 'baseline', + ); +}); + +test('fresh installations export scripts without requiring a history directory', async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.dataPath, 'scripts/job.js'), 'fresh'); + assert.equal((await f.exportData(['scripts'])).downloaded, f.archive); + const entries = execFileSync('tar', ['-ztf', f.archive], { + encoding: 'utf8', + }); + assert.match(entries, /data\/scripts\/job.js/); + assert.doesNotMatch(entries, /script-history/); +}); + +test('history can be exported explicitly, while directory traversal is rejected before tar runs', async (t) => { + const f = await fixture(t); + await fs.mkdir(path.join(f.dataPath, 'script-history')); + await fs.writeFile( + path.join(f.dataPath, 'script-history/version.br'), + 'history', + ); + assert.equal((await f.exportData(['script-history'])).downloaded, f.archive); + assert.match( + execFileSync('tar', ['-ztf', f.archive], { encoding: 'utf8' }), + /script-history\/version.br/, + ); + await fs.rm(f.archive); + for (const type of ['../outside', '/etc', 'scripts/../../outside']) { + const result = await f.exportData([type]); + assert.equal(result.status, 400); + assert.equal(result.downloaded, undefined); + } + await assert.rejects(fs.stat(f.archive), { code: 'ENOENT' }); +}); diff --git a/test/back/systemTokenRenewal.test.cjs b/test/back/systemTokenRenewal.test.cjs new file mode 100644 index 00000000..ad185674 --- /dev/null +++ b/test/back/systemTokenRenewal.test.cjs @@ -0,0 +1,497 @@ +require('reflect-metadata'); +require('ts-node/register/transpile-only'); + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const environment = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-env-')); +fs.writeFileSync(path.join(environment, '.env'), ''); +fs.mkdirSync(path.join(environment, 'data/db'), { recursive: true }); +process.env.QL_DIR = environment; +process.env.QL_DATA_DIR = path.join(environment, 'data'); +require('node:test').after(async () => { + await require('../../back/shared/store').keyvStore.disconnect(); + await require('../../back/data').sequelize.close(); + fs.rmSync(environment, { recursive: true, force: true }); +}); +const OpenService = require('../../back/services/open').default; +const { AppModel } = require('../../back/data/open'); +const { shareStore } = require('../../back/shared/store'); +const config = require('../../back/config').default; + +const DAY = 86400; +const START = 1787004000; +const execute = promisify(execFile); + +function fixture(t, initialTokens = []) { + const previousConfigPath = config.configPath; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-renewal-')); + config.configPath = directory; + t.after(() => { + config.configPath = previousConfigPath; + fs.rmSync(directory, { recursive: true, force: true }); + }); + let now = START; + let app = { + name: 'system', client_id: 'system-client', client_secret: 'system-secret', + scopes: ['crons', 'system', 'dashboard'], tokens: initialTokens, + }; + t.mock.method(Date, 'now', () => now * 1000); + t.mock.method(shareStore, 'getApps', async () => [app]); + t.mock.method(shareStore, 'updateApps', async (apps) => { [app] = apps; }); + t.mock.method(AppModel, 'findOne', async ({ where }) => { + if (where.name) assert.equal(where.name, app.name); + else { + assert.equal(where.client_id, app.client_id); + assert.equal(where.client_secret, app.client_secret); + } + return { tokens: app.tokens, get: () => app }; + }); + t.mock.method(AppModel, 'update', async ({ tokens }) => { + app = { ...app, tokens }; + return [1]; + }); + t.mock.method(AppModel, 'findAll', async () => [{ get: () => app }]); + return { + service: new OpenService({}), + atDay(day) { now = START + day * DAY; }, + tokens() { return app.tokens; }, + }; +} + +test('28-day token generation renews ahead of the 30-day expiry', async (t) => { + const state = fixture(t); + const initial = await state.service.generateSystemToken(); + assert.equal(initial.expiration, START + 30 * DAY); + state.atDay(28); + const renewed = await state.service.generateSystemToken(true); + assert.equal(renewed.expiration, START + 58 * DAY); + assert.notEqual(renewed.value, initial.value); + assert.ok(state.tokens().some((token) => token.value === initial.value)); + state.atDay(56); + const next = await state.service.generateSystemToken(true); + assert.equal(next.expiration, START + 86 * DAY); + assert.ok(state.tokens().some((token) => token.value === renewed.value)); + assert.ok(!state.tokens().some((token) => token.value === initial.value)); +}); + +test('startup generation renews before a restarted 28-day interval', async (t) => { + const state = fixture(t); + await state.service.generateSystemToken(); + state.atDay(20); + const restarted = await state.service.generateSystemToken(true); + assert.equal(restarted.expiration, START + 50 * DAY); + state.atDay(48); + const renewed = await state.service.generateSystemToken(true); + assert.equal(renewed.expiration, START + 78 * DAY); + assert.ok(state.tokens().some((token) => token.value === restarted.value)); +}); + +test('generation at the five-token limit extends the reused token without revoking active tokens', async (t) => { + const initial = Array.from({ length: 5 }, (_, index) => ({ + value: `token-${index}`, expiration: START + 2 * DAY, + })); + const state = fixture(t, initial); + const renewed = await state.service.generateSystemToken(true); + assert.equal(renewed.value, 'token-4'); + assert.equal(renewed.expiration, START + 30 * DAY); + assert.equal(state.tokens().length, 5); + for (const token of initial) { + assert.ok(state.tokens().some((current) => current.value === token.value)); + } +}); + +test('ordinary acquisition reuses the newest valid database token and republishes its cache', async (t) => { + const initial = [ + { value: 'older-token', expiration: START + DAY }, + { value: 'newer-token', expiration: START + 20 * DAY }, + ]; + const state = fixture(t, initial); + // A stale process cache must not override the token chosen from the database. + shareStore.getApps = async () => [{ name: 'system', tokens: [] }]; + const token = await state.service.generateSystemToken(); + assert.equal(token.value, 'newer-token'); + assert.deepEqual(state.tokens(), initial); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(config.configPath, 'token.json'), 'utf8')), + token, + ); +}); + +test('a failed generation releases its lock and does not publish a token', async (t) => { + const state = fixture(t); + const update = AppModel.update; + AppModel.update = async () => { throw new Error('generation failed'); }; + await assert.rejects(state.service.generateSystemToken(), /generation failed/); + assert.equal(fs.existsSync(path.join(config.configPath, 'token.json')), false); + assert.equal(fs.existsSync(path.join(config.configPath, 'token.json.refresh.lock')), false); + AppModel.update = update; + const token = await state.service.generateSystemToken(); + assert.ok(state.tokens().some((current) => current.value === token.value)); +}); + +test('acquisition recovers an authentication cache failure after the database commit', async (t) => { + const state = fixture(t); + const updateCache = shareStore.updateApps; + let attempts = 0; + shareStore.updateApps = async (apps) => { + if (++attempts === 1) throw new Error('authentication cache failed'); + return updateCache(apps); + }; + await assert.rejects(state.service.generateSystemToken(), /authentication cache failed/); + assert.equal(fs.existsSync(path.join(config.configPath, 'token.json')), false); + const committed = state.tokens()[0]; + const token = await state.service.generateSystemToken(); + assert.equal(token.value, committed.value); + assert.equal(state.tokens().length, 1); + assert.equal(attempts, 2); +}); + +async function processFixture(t, initialTokens = []) { + const root = path.resolve(__dirname, '../..'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-process-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const databaseFile = path.join(directory, 'database.json'); + fs.writeFileSync(databaseFile, JSON.stringify({ + name: 'system', client_id: 'system-client', client_secret: 'system-secret', + scopes: ['crons', 'system', 'dashboard'], tokens: initialTokens, + })); + const worker = path.join(directory, 'worker.cjs'); + fs.writeFileSync(worker, ` + const fs = require('node:fs/promises'); + const path = require('node:path'); + const root = process.cwd(); + const directory = __dirname; + const config = require(path.join(root, 'back/config')).default; + const { AppModel } = require(path.join(root, 'back/data/open')); + const { shareStore } = require(path.join(root, 'back/shared/store')); + config.configPath = directory; + const databaseFile = path.join(directory, 'database.json'); + const read = async () => JSON.parse(await fs.readFile(databaseFile, 'utf8')); + AppModel.findOne = async () => { + if (process.env.QL_TOKEN_TEST_HOLD === '1') { + await fs.writeFile(path.join(directory, 'locked'), 'ready'); + await new Promise(() => setInterval(() => {}, 1000)); + } + const app = await read(); + // Widen the read/write overlap so the original race is reproducible. + await new Promise(resolve => setTimeout(resolve, 75)); + return { ...app, get: () => app }; + }; + AppModel.update = async ({ tokens }) => { + if (process.env.QL_TOKEN_TEST_FAIL === '1') throw new Error('generation failed'); + const app = await read(); + await fs.writeFile(databaseFile, JSON.stringify({ ...app, tokens })); + return [1]; + }; + AppModel.findAll = async () => { + const app = await read(); + return [{ get: () => app }]; + }; + shareStore.getApps = async () => { throw new Error('stale cache must not be read'); }; + shareStore.updateApps = async () => {}; + require(path.join(root, 'back/token')); + `); + return { + directory, + run(renew = false, fail = false, hold = false) { + return execute(process.execPath, [ + '-r', require.resolve('ts-node/register/transpile-only'), worker, + ...(renew ? ['--renew'] : []), + ], { + cwd: root, timeout: 20_000, + env: { ...process.env, QL_TOKEN_TEST_FAIL: fail ? '1' : '0', QL_TOKEN_TEST_HOLD: hold ? '1' : '0' }, + }); + }, + tokens() { return JSON.parse(fs.readFileSync(databaseFile, 'utf8')).tokens; }, + cached() { return JSON.parse(fs.readFileSync(path.join(directory, 'token.json'), 'utf8')); }, + }; +} + +test('four independent token processes converge on one usable token when the cache is missing', async (t) => { + const state = await processFixture(t); + const results = await Promise.all(Array.from({ length: 4 }, () => state.run())); + const issued = results.map(({ stdout, stderr }) => { + assert.equal(stderr, ''); + return stdout.trim(); + }); + assert.equal(new Set(issued).size, 1); + assert.equal(state.tokens().length, 1); + assert.equal(state.tokens()[0].value, issued[0]); + assert.equal(state.cached().value, issued[0]); + assert.equal(fs.existsSync(path.join(state.directory, 'token.json.refresh.lock')), false); +}); + +test('concurrent scheduled renewal and ordinary acquisition preserve all returned tokens', async (t) => { + const original = { value: 'original-token', expiration: Math.round(Date.now() / 1000) + DAY }; + const state = await processFixture(t, [original]); + const results = await Promise.all([state.run(true), state.run(), state.run(true), state.run()]); + const tokens = state.tokens(); + assert.equal(tokens.length, 3); + assert.ok(tokens.some((token) => token.value === original.value)); + for (const { stdout, stderr } of results) { + assert.equal(stderr, ''); + assert.ok(tokens.some((token) => token.value === stdout.trim())); + } + assert.ok(tokens.some((token) => token.value === state.cached().value)); +}); + +test('token CLI reports generation failure on stderr with a nonzero exit code', async (t) => { + const state = await processFixture(t); + await assert.rejects(state.run(false, true), (error) => { + assert.equal(error.code, 1); + assert.equal(error.stdout, ''); + assert.match(error.stderr, /generation failed/); + return true; + }); + assert.equal(fs.existsSync(path.join(state.directory, 'token.json')), false); + const recovered = await state.run(); + assert.equal(recovered.stdout.trim(), state.cached().value); +}); + +test('startup configures the 28-day token task with explicit renewal', async (t) => { + const { Container } = require('typedi'); + const initialize = require('../../back/loaders/initTask').default; + const SystemService = require('../../back/services/system').default; + const ScheduleService = require('../../back/services/schedule').default; + const SubscriptionService = require('../../back/services/subscription').default; + const SshKeyService = require('../../back/services/sshKey').default; + const scheduled = []; + const services = new Map([ + [SystemService, { getSystemConfig: async () => undefined }], + [ScheduleService, { + cancelIntervalTask: async () => {}, + createIntervalTask: (...args) => scheduled.push(args), + }], + [SubscriptionService, { setSshConfig: async () => {}, list: async () => [] }], + [SshKeyService, {}], + ]); + t.mock.method(Container, 'get', (service) => { + assert.ok(services.has(service)); + return services.get(service); + }); + await initialize(); + assert.equal(scheduled.length, 1); + const [task, interval, immediate] = scheduled[0]; + assert.match(task.command, /(?:token\.ts|token\.js) --renew$/); + assert.equal(task.runOrigin, 'system'); + assert.deepEqual(interval, { days: 28 }); + assert.equal(immediate, true); +}); + +function appsFixture(t) { + const previousConfigPath = config.configPath; + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-apps-concurrency-')); + config.configPath = directory; + t.after(() => { + config.configPath = previousConfigPath; + fs.rmSync(directory, { recursive: true, force: true }); + }); + t.mock.method(Date, 'now', () => START * 1000); + const clone = (value) => JSON.parse(JSON.stringify(value)); + let apps = [ + { id: 1, name: 'system', client_id: 'system-client', client_secret: 'system-secret', + scopes: ['crons', 'system', 'dashboard'], tokens: [{ value: 'system-token', expiration: START + DAY }] }, + { id: 2, name: 'external', client_id: 'external-client', client_secret: 'external-secret', + scopes: ['crons'], tokens: [] }, + ]; + let cached = clone(apps); + const matches = (app, where) => Object.entries(where).every(([key, value]) => + Array.isArray(value) ? value.includes(app[key]) : app[key] === value); + t.mock.method(AppModel, 'findOne', async ({ where }) => { + const app = apps.find((app) => matches(app, where)); + if (!app) return null; + const snapshot = clone(app); + await new Promise((resolve) => setTimeout(resolve, 10)); + return { ...snapshot, get: () => snapshot }; + }); + t.mock.method(AppModel, 'findAll', async () => clone(apps).map((app) => ({ get: () => app }))); + t.mock.method(AppModel, 'update', async (values, { where }) => { + apps = apps.map((app) => matches(app, where) ? { ...app, ...clone(values) } : app); + return [1]; + }); + t.mock.method(AppModel, 'destroy', async ({ where }) => { + apps = apps.filter((app) => !matches(app, where)); + }); + t.mock.method(AppModel, 'create', async (values) => { + const app = { id: Math.max(0, ...apps.map((app) => app.id)) + 1, tokens: [], ...values }; + apps.push(app); + return { get: () => clone(app) }; + }); + t.mock.method(shareStore, 'updateApps', async (values) => { cached = clone(values); }); + t.mock.method(shareStore, 'getApps', async () => clone(cached)); + return { service: new OpenService({}), apps: () => clone(apps), cached: () => clone(cached) }; +} + +for (const operation of ['acquisition', 'startup refresh']) { + test(`${operation} cannot overwrite another application's newly issued token`, async (t) => { + const state = appsFixture(t); + let resume; + let entered; + const paused = new Promise((resolve) => { entered = resolve; }); + const barrier = new Promise((resolve) => { resume = resolve; }); + const findAll = AppModel.findAll; + let first = true; + AppModel.findAll = async (...args) => { + const snapshot = await findAll(...args); + if (first) { + first = false; + entered(); + await barrier; + } + return snapshot; + }; + const publication = operation === 'acquisition' + ? state.service.generateSystemToken() : state.service.refreshApps(); + await paused; + const issuance = state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + resume(); + const [, response] = await Promise.all([publication, issuance]); + assert.equal(response.code, 200); + assert.ok(state.apps()[1].tokens.some((token) => token.value === response.data.token)); + assert.deepEqual(state.cached(), state.apps()); + }); +} + +test('concurrent token API calls and scheduled generation preserve every issued token', async (t) => { + const state = appsFixture(t); + const credentials = { client_id: 'system-client', client_secret: 'system-secret' }; + const results = await Promise.all([ + state.service.authToken(credentials), state.service.generateSystemToken(true), + state.service.authToken(credentials), state.service.generateSystemToken(true), + ]); + const tokens = state.apps()[0].tokens; + assert.equal(tokens.length, 5); + for (const response of results) { + assert.ok(tokens.some((token) => token.value === (response.value || response.data.token))); + } + assert.deepEqual(state.cached(), state.apps()); +}); + +for (const mutation of ['resetSecret', 'remove', 'update', 'create']) { + test(`concurrent cache refresh and ${mutation} retain the latest application state`, async (t) => { + const state = appsFixture(t); + await state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' }); + const change = () => mutation === 'resetSecret' ? state.service.resetSecret(2) + : mutation === 'remove' ? state.service.remove([2]) + : mutation === 'update' ? state.service.update({ id: 2, name: 'external', scopes: ['dashboard'] }) + : state.service.create({ name: 'created', scopes: ['crons'] }); + await Promise.all([state.service.refreshApps(), change(), state.service.generateSystemToken()]); + assert.deepEqual(state.cached(), state.apps()); + if (mutation === 'resetSecret') { + assert.equal(state.cached()[1].tokens.length, 0); + assert.notEqual(state.cached()[1].client_secret, 'external-secret'); + assert.equal((await state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' })).code, 400); + } else if (mutation === 'remove') { + assert.equal(state.cached().length, 1); + } else if (mutation === 'update') { + assert.deepEqual(state.cached()[1].scopes, ['dashboard']); + } else { + assert.equal(state.cached().length, 3); + } + }); +} + +test('system initialization preserves active tokens while migrating dashboard scope', async (t) => { + const state = appsFixture(t); + await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } }); + await Promise.all([state.service.initializeSystemApp(), state.service.generateSystemToken(true)]); + assert.deepEqual(state.cached(), state.apps()); + assert.deepEqual(state.apps()[0].scopes, ['crons', 'system', 'dashboard']); + assert.equal(state.apps()[0].tokens.length, 2); +}); + +test('failed atomic token-file publication releases the application lock for recovery', async (t) => { + const state = appsFixture(t); + const temporary = path.join(config.configPath, `token.json.${process.pid}.tmp`); + fs.mkdirSync(temporary); + await assert.rejects(state.service.generateSystemToken(), /EISDIR/); + fs.rmSync(temporary, { recursive: true }); + const token = await state.service.generateSystemToken(); + assert.equal(token.value, 'system-token'); + assert.equal(fs.existsSync(path.join(config.configPath, '.apps-state.lock')), false); +}); + +test('real SQLite and authentication cache retain concurrent process updates', async (t) => { + fs.mkdirSync(config.configPath, { recursive: true }); + await AppModel.sync({ force: true }); + const service = new OpenService({}); + await service.initializeSystemApp(); + const external = await service.create({ name: 'sqlite-external', scopes: ['crons'] }); + const worker = path.join(environment, 'sqlite-worker.cjs'); + const root = path.resolve(__dirname, '../..'); + fs.writeFileSync(worker, ` + require(${JSON.stringify(require.resolve('reflect-metadata'))}); + const path = require('node:path'); + const root = process.cwd(); + const OpenService = require(path.join(root, 'back/services/open')).default; + const { shareStore, keyvStore } = require(path.join(root, 'back/shared/store')); + const { sequelize } = require(path.join(root, 'back/data')); + const service = new OpenService({}); + (async () => { + const action = process.argv[2]; + const result = action === 'auth' + ? await service.authToken({ client_id: process.argv[3], client_secret: process.argv[4] }) + : action === 'refresh' ? await service.refreshApps() + : await service.generateSystemToken(action === 'renew'); + console.log(JSON.stringify(result || {})); + })().catch(error => { console.error(error); process.exitCode = 1; }) + .finally(async () => { await keyvStore.disconnect(); await sequelize.close(); }); + `); + const actions = ['renew', 'acquire', 'auth', 'refresh', 'renew', 'auth', 'auth']; + const outcomes = await Promise.allSettled(actions.map((action) => execute(process.execPath, [ + '-r', require.resolve('ts-node/register/transpile-only'), worker, action, + external.client_id, external.client_secret, + ], { cwd: root, timeout: 20_000, env: process.env }))); + const results = outcomes.map((outcome) => { + assert.equal(outcome.status, 'fulfilled', outcome.reason?.stderr); + return outcome.value; + }); + const database = await service.findApps(); + const cached = await shareStore.getApps(); + assert.deepEqual(cached, JSON.parse(JSON.stringify(database))); + for (let index = 0; index < results.length; index++) { + assert.equal(results[index].stderr, ''); + const result = JSON.parse(results[index].stdout.trim()); + const value = result.value || result.data?.token; + if (value) assert.ok(cached.some((app) => app.tokens?.some((token) => token.value === value))); + } + assert.equal(database.find((app) => app.name === 'sqlite-external').tokens.length, 3); + const file = JSON.parse(fs.readFileSync(path.join(config.configPath, 'token.json'), 'utf8')); + assert.ok(cached.find((app) => app.name === 'system').tokens.some((token) => token.value === file.value)); +}); + + +test('atomic publication preserves restricted token-file permissions', async (t) => { + const state = fixture(t, [{ value: 'current', expiration: START + DAY }]); + const file = path.join(config.configPath, 'token.json'); + await state.service.generateSystemToken(); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + fs.chmodSync(file, 0o640); + await state.service.generateSystemToken(true); + assert.equal(fs.statSync(file).mode & 0o777, 0o640); +}); + + +test('a SIGKILLed generator leaves a lock that the next acquisition recovers', async (t) => { + const state = await processFixture(t); + const running = state.run(false, false, true); + const stopped = running.catch((error) => error); + const deadline = Date.now() + 10_000; + while (!fs.existsSync(path.join(state.directory, 'locked'))) { + assert.ok(Date.now() < deadline, 'generator did not acquire the lock'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + running.child.kill('SIGKILL'); + assert.equal((await stopped).signal, 'SIGKILL'); + assert.equal(fs.existsSync(path.join(state.directory, '.apps-state.lock')), true); + const recovered = await state.run(); + assert.equal(recovered.stderr, ''); + assert.equal(recovered.stdout.trim(), state.cached().value); + assert.equal(fs.existsSync(path.join(state.directory, '.apps-state.lock')), false); +}); diff --git a/test/back/task-shell-lifecycle.test.cjs b/test/back/task-shell-lifecycle.test.cjs new file mode 100644 index 00000000..6f8ddf2a --- /dev/null +++ b/test/back/task-shell-lifecycle.test.cjs @@ -0,0 +1,194 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +function extract(file, name) { + const text = fs.readFileSync(file, 'utf8'); + const start = text.indexOf(name + '() {'); + assert.ok(start >= 0, name); + return text.slice(start, text.indexOf('\n}', start) + 2); +} +const helpers = [ + fs.readFileSync('shell/task-timeout.sh', 'utf8'), + ...['handle_task_start', 'handle_task_end', 'run_task_before', 'run_task_after', 'get_env_array', 'clear_env'].map(n => extract('shell/share.sh', n)), + ...['run_shell_script', 'define_program', 'format_params'].map(n => extract('shell/task.sh', n)), +].join('\n'); +const taskSource = path.resolve('shell/otask.sh'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-shell-lifecycle-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const write = (name, body) => fs.writeFileSync(path.join(root, name), body); + write('env.sh', 'export QA_PANEL="alpha&beta&gamma"\n'); + write('before.sh', 'export QA_BEFORE=ready\nqa_function() { printf "HOOK_FUNCTION\\n"; }\n'); + write('after.sh', 'printf "AFTER:%s:%s\\n" "$QA_PANEL" "$QA_BEFORE" >> "$QA_ROOT/events"\n'); + const run = (args, timeout = '') => { + const r = spawnSync('/bin/bash', ['-c', helpers + ` + dir_scripts=$QA_ROOT; dir_shell=$QA_ROOT; dir_dep=$QA_ROOT + file_env=$QA_ROOT/env.sh; file_task_before=$QA_ROOT/before.sh; file_task_after=$QA_ROOT/after.sh + ID=42; log_path=fixture.log; is_macos=0 + dir_log=$QA_ROOT; log_dir=.; mtime_format='%Y-%m-%d %H:%M:%S' + format_log_time() { printf fixture; } + time_format='%Y-%m-%d %H:%M:%S'; begin_time=start; begin_timestamp=$(date +%s) + real_time=true; no_delay=true; NODE_OPTIONS=''; PYTHONPATH='' + pnpm() { printf '/fixture/global\\n'; } + t() { :; } + update_cron() { printf 'STATUS:%s:%s\\n' "$2" "$7" >> "$QA_ROOT/events"; } + record_cron_stat() { printf 'STAT:%s\\n' "$2" >> "$QA_ROOT/events"; } + format_params "$@"; define_program "${'${task_shell_params[@]}'}" + . "$QA_TASK_SOURCE" + printf 'WRAPPER_FINISHED\\n' >> "$QA_ROOT/events" + `, 'fixture', ...args], { cwd: root, env: { ...process.env, command_timeout_time: timeout, QA_ROOT: root, QA_TASK_SOURCE: taskSource }, encoding: 'utf8', timeout: 10000 }); + assert.equal(r.status, 0, r.stdout + r.stderr); + return { stdout: r.stdout, events: fs.readFileSync(path.join(root, 'events'), 'utf8').trim().split('\n') }; + }; + return { write, run }; +} + +for (const [label, body, code] of [ + ['normal return', ':', 0], + ['exit zero', 'exit 0', 0], + ['exit failure', 'exit 7', 7], + ['exec replacement', "exec bash -c 'exit 9'", 9], + ['errexit failure', 'set -e\nfalse\necho SHOULD_NOT_RUN', 1], + ['script EXIT trap', `trap 'echo SCRIPT_EXIT_TRAP' EXIT\nexit 4`, 4], +]) { + test(`sourced shell ${label} still runs after hook and reports exactly one completion`, t => { + const f = fixture(t); + f.write('probe.sh', `[[ "$QA_PANEL" == 'alpha&beta&gamma' && "$QA_BEFORE" == ready ]] || exit 99\nqa_function\n${body}\n`); + const r = f.run(['probe.sh']); + assert.match(r.stdout, /HOOK_FUNCTION/); + assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/); + if (label === 'script EXIT trap') assert.match(r.stdout, /SCRIPT_EXIT_TRAP/); + assert.deepEqual(r.events, ['STATUS:0:', 'AFTER:alpha&beta&gamma:ready', `STATUS:1:${code}`, `STAT:${code}`, 'WRAPPER_FINISHED']); + }); +} + +test('shell arguments and before-hook functions survive isolation while script state stays in the child', t => { + const f = fixture(t); + f.write('probe.sh', '[[ "$1" == "arg with spaces" ]] || exit 99\nqa_function\nexport QA_BEFORE=changed\ncd /\nexit 5\n'); + const r = f.run(['probe.sh', '--', 'arg with spaces']); + assert.match(r.stdout, /HOOK_FUNCTION/); + assert.ok(r.events.includes('AFTER:alpha&beta&gamma:ready')); + assert.ok(r.events.includes('STATUS:1:5')); +}); + +for (const [label, args] of [ + ['explicit shell interpreter', ['bash', 'probe.sh', 'argument']], + ['shell script with positional args', ['probe.sh', 'argument']], +]) { + test(`${label} preserves generated environment and reports failure`, t => { + const f = fixture(t); + f.write('probe.sh', '[[ "$QA_PANEL" == "alpha&beta&gamma" && "$QA_BEFORE" == ready && "$1" == argument ]] || exit 99\nexit 6\n'); + const r = f.run(args); + assert.ok(r.events.includes('STATUS:1:6')); + assert.equal(r.events.filter(x => x.startsWith('STATUS:1:')).length, 1); + }); +} + +test('explicit Python interpreter receives panel and before-hook environment', t => { + const f = fixture(t); + f.write('probe.py', 'import os,sys\nassert os.environ["QA_PANEL"] == "alpha&beta&gamma"\nassert os.environ["QA_BEFORE"] == "ready"\nassert sys.argv[1] == "argument"\nsys.exit(8)\n'); + assert.ok(f.run(['python3', 'probe.py', 'argument']).events.includes('STATUS:1:8')); +}); + +test('explicit Node interpreter retains generated environment', t => { + const f = fixture(t); + f.write('probe.cjs', 'if (process.env.QA_PANEL !== "alpha&beta&gamma" || process.env.QA_BEFORE !== "ready") process.exit(99); process.exit(8);\n'); + assert.ok(f.run(['node', 'probe.cjs']).events.includes('STATUS:1:8')); +}); + +test('plain commands retain generated environment', t => { + const f = fixture(t); + assert.match(f.run(['printenv', 'QA_PANEL']).stdout, /alpha&beta&gamma/); +}); + +for (const mode of ['desi', 'conc']) { + test(`shell ${mode} keeps selected account values and completion reporting`, t => { + const f = fixture(t); + f.write('probe.sh', 'printf "ACCOUNT=%s\\n" "$QA_PANEL"\nexit 0\n'); + const r = f.run(['probe.sh', mode, 'QA_PANEL', '2-3']); + assert.match(r.stdout, mode === 'desi' ? /ACCOUNT=beta&gamma/ : /ACCOUNT=beta\nACCOUNT=gamma/); + assert.doesNotMatch(r.stdout, /ACCOUNT=alpha/); + assert.ok(r.events.includes(`AFTER:${mode === 'desi' ? 'beta&gamma' : 'gamma'}:ready`)); + assert.equal(r.events.filter(x => x === 'STATUS:1:0').length, 1); + assert.equal(r.events.filter(x => x.startsWith('AFTER:')).length, 1); + }); +} + +for (const [label, args] of [ + ['sourced shell', ['probe.sh', '--', 'space value']], + ['explicit bash', ['bash', 'probe.sh', 'space value']], +]) { + test(`${label} timeout stops descendants and finalizes once with 124`, t => { + const f = fixture(t); + f.write('before.sh', 'export QA_BEFORE=ready\nprivate_value=private\nprivate_array=(one "two words")\nqa_function() { printf "HOOK_FUNCTION\\n"; }\n'); + f.write('probe.sh', `[[ "$QA_PANEL" == 'alpha&beta&gamma' && "$QA_BEFORE" == ready && "$1" == 'space value' ]] || exit 91\n${label === 'sourced shell' ? '[[ "$private_value" = private && "${private_array[1]}" = "two words" ]] || exit 92\nqa_function\n' : ''}trap 'echo EXIT_TRAP' EXIT\nsleep 3\necho SHOULD_NOT_RUN\n`); + const before = Date.now(); + const r = f.run(args, '0.2s'); + assert.ok(Date.now() - before < 2500, r.stdout); + assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/); + assert.match(r.stdout, /EXIT_TRAP/); + assert.deepEqual(r.events, ['STATUS:0:', 'AFTER:alpha&beta&gamma:ready', 'STATUS:1:124', 'STAT:124', 'WRAPPER_FINISHED']); + }); +} + +test('timeout escalates when a shell and its children ignore TERM', t => { + const f = fixture(t); + f.write('probe.sh', 'trap "" TERM\nsleep 3\necho SHOULD_NOT_RUN\n'); + const before = Date.now(); + const r = f.run(['probe.sh'], '0.1'); + assert.ok(Date.now() - before < 2500); + assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/); + assert.ok(r.events.includes('STATUS:1:124')); +}); + +test('completed timed tasks keep their exit code and do not wait for the timer', t => { + const f = fixture(t); + f.write('probe.sh', 'exit 7\n'); + const before = Date.now(); + const r = f.run(['probe.sh'], '1h'); + assert.ok(Date.now() - before < 1500); + assert.ok(r.events.includes('STATUS:1:7')); +}); + +test('zero disables the deadline and invalid durations never start the script', t => { + const f = fixture(t); + f.write('probe.sh', 'echo DID_RUN\n'); + assert.match(f.run(['probe.sh'], '0s').stdout, /DID_RUN/); + f.write('events', ''); + const r = f.run(['probe.sh'], 'wrong'); + assert.doesNotMatch(r.stdout, /DID_RUN/); + assert.ok(r.events.includes('STATUS:1:125')); +}); + +for (const [runtime, filename, body] of [ + ['python3', 'timed.py', 'import time\nprint("STARTED", flush=True)\ntime.sleep(3)\nprint("SHOULD_NOT_RUN", flush=True)\n'], + ['node', 'timed.cjs', 'console.log("STARTED"); setTimeout(() => console.log("SHOULD_NOT_RUN"), 3000);\n'], +]) { + test(`${runtime} timeout stops execution and reports 124`, t => { + const f = fixture(t); + f.write(filename, body); + const r = f.run([runtime, filename], '0.5s'); + assert.match(r.stdout, /STARTED/); + assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/); + assert.ok(r.events.includes('STATUS:1:124')); + assert.equal(r.events.filter(x => x.startsWith('AFTER:')).length, 1); + }); +} + +for (const mode of ['desi', 'conc']) { + test(`timed shell ${mode} preserves selected accounts and terminates every worker`, t => { + const f = fixture(t); + f.write('probe.sh', 'echo "ACCOUNT=$QA_PANEL"\nsleep 3\necho SHOULD_NOT_RUN\n'); + const r = f.run(['probe.sh', mode, 'QA_PANEL', '2-3'], '0.2s'); + assert.match(r.stdout, mode === 'desi' ? /ACCOUNT=beta&gamma/ : /ACCOUNT=beta\nACCOUNT=gamma/); + assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/); + assert.equal(r.events.filter(x => x.startsWith('AFTER:')).length, 1); + // Concurrent mode retains its existing aggregate wait status behavior. + assert.ok(r.events.includes(`STATUS:1:${mode === 'desi' ? 124 : 0}`)); + }); +} diff --git a/test/back/task-time.test.cjs b/test/back/task-time.test.cjs new file mode 100644 index 00000000..449252f5 --- /dev/null +++ b/test/back/task-time.test.cjs @@ -0,0 +1,116 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +function extract(file, name) { + const text = fs.readFileSync(file, 'utf8'); + const start = text.indexOf(name + '() {'); + assert.ok(start >= 0); + return text.slice(start, text.indexOf('\n}', start) + 2); +} +const source = + ['handle_log_path', 'init_begin_time'] + .map((n) => extract('shell/task.sh', n)) + .join('\n') + + '\n' + + ['format_time', 'format_log_time', 'format_timestamp', 'handle_task_end'] + .map((n) => extract('shell/share.sh', n)) + .join('\n'); +function run(t, body) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-task-time-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const result = spawnSync( + '/bin/bash', + [ + '-ec', + source + + ` +is_macos=0 +mtime_format='%Y-%m-%d %H:%M:%S.%3N' +time_format='%Y-%m-%d %H:%M:%S' +ID=42; dir_log=/tmp; log_name=''; real_log_path=''; no_tee=''; real_time='' +make_dir() { :; } +t() { printf '%s\\n' "$*" >> "$TEST_DIR/messages"; } +update_cron() { printf '%s\\n' "$*" >> "$TEST_DIR/status"; } +record_cron_stat() { printf '%s\\n' "$*" >> "$TEST_DIR/stats"; } +date() { + printf '%s\\n' "$*" >> "$TEST_DIR/dates" + case "$1" in + '+%Y-%m-%d %H:%M:%S.%3N|%Y-%m-%d-%H-%M-%S-%3N') printf '%s\\n' '2026-09-12 23:59:59.999|2026-09-12-23-59-59-999';; + '+%Y-%m-%d %H:%M:%S|%s') printf '%s\\n' '2026-09-13 00:00:00|101';; + '-d') [[ "$3" == '+%s' ]] && echo 100;; + *) echo raw-time;; + esac +} +` + + body, + ], + { encoding: 'utf8', env: { ...process.env, TEST_DIR: dir } }, + ); + assert.equal(result.status, 0, result.stderr + result.stdout); + return { + dir, + text: (name) => + fs.existsSync(path.join(dir, name)) + ? fs.readFileSync(path.join(dir, name), 'utf8') + : '', + }; +} +test('Linux task timestamps share a snapshot and retain millisecond log names across midnight', (t) => { + const r = run( + t, + `handle_log_path 'folder/example.js' +init_begin_time +[[ "$log_path" == 'folder_example_42/2026-09-12-23-59-59-999.log' ]] +[[ "$time" == '2026-09-12 23:59:59.999' && "$begin_time" == '2026-09-12 23:59:59' && "$begin_timestamp" == 100 ]] +handle_task_end`, + ); + assert.equal(r.text('dates').trim().split('\n').length, 3); + assert.match(r.text('stats'), /^42 0 1\n$/); + assert.match(r.text('messages'), /2026-09-13 00:00:00/); +}); +test('explicit log paths and output mode remain intact', (t) => { + run( + t, + `real_log_path='custom/output.log'; real_time=true +handle_log_path 'folder/example.js' +[[ "$log_path" == 'custom/output.log' && "$cmd" == '' ]]`, + ); +}); +test('task failure keeps exit code and minimum one-second runtime', (t) => { + const r = run( + t, + `begin_timestamp=101; _task_exit_code=7; log_path=test.log +handle_task_end`, + ); + assert.equal(r.text('stats'), '42 7 1\n'); + assert.match(r.text('messages'), /失败/); + assert.match(r.text('status'), /101 1 7/); +}); +test('manual stop retains stopped message and exit status reporting', (t) => { + const r = run( + t, + `begin_timestamp=99; _task_exit_code=1; log_path=test.log; MANUAL=true +handle_task_end`, + ); + assert.equal(r.text('stats'), '42 1 2\n'); + assert.match(r.text('messages'), /已停止/); +}); +for (const mode of ['macos', 'custom']) + test(`${mode} keeps legacy time conversion helpers`, (t) => { + run( + t, + ` +${mode === 'macos' ? 'is_macos=1' : 'mtime_format=custom; time_format=custom'} +format_log_time() { echo legacy-log; } +format_time() { echo legacy-time; } +format_timestamp() { echo 100; } +handle_log_path example.js +init_begin_time +[[ "$log_time" == legacy-log && "$begin_time" == legacy-time && "$begin_timestamp" == 100 ]] +handle_task_end +`, + ); + }); diff --git a/test/back/unique-constraint-error.test.cjs b/test/back/unique-constraint-error.test.cjs new file mode 100644 index 00000000..69de47fb --- /dev/null +++ b/test/back/unique-constraint-error.test.cjs @@ -0,0 +1,72 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { Sequelize, DataTypes } = require('sequelize'); +const i18n = require.resolve('../../back/shared/i18n'); +require.cache[i18n] = { + id: i18n, + filename: i18n, + loaded: true, + exports: { t: (x) => x }, +}; +const handler = require('../../back/middlewares/uniqueConstraintError').default; + +test('database uniqueness conflicts return 409 without values and preserve existing rows', async () => { + const db = new Sequelize('sqlite::memory:', { logging: false }); + try { + const App = db.define('App', { + name: { type: DataTypes.STRING, unique: true }, + }); + const Env = db.define('Env', { + name: { type: DataTypes.STRING, unique: 'pair' }, + value: { type: DataTypes.STRING, unique: 'pair' }, + }); + await db.sync(); + const first = await App.create({ name: 'private-app' }); + const second = await App.create({ name: 'other' }); + await Env.create({ name: 'TOKEN', value: 'private-value' }); + const env = await Env.create({ name: 'TOKEN', value: 'other-value' }); + for (const write of [ + () => App.create({ name: first.name }), + () => second.update({ name: first.name }), + () => env.update({ value: 'private-value' }), + ]) { + await assert.rejects(write, (err) => { + let status, body; + handler( + err, + {}, + { + status: (x) => { + status = x; + return { + json: (x) => { + body = x; + }, + }; + }, + }, + () => assert.fail('constraint passed through'), + ); + assert.equal(status, 409); + assert.equal(body.code, 409); + assert.doesNotMatch( + JSON.stringify(body), + /private-|TOKEN|INSERT|UPDATE/, + ); + return true; + }); + } + assert.equal((await second.reload()).name, 'other'); + assert.equal((await env.reload()).value, 'other-value'); + const writes = await Promise.allSettled([ + App.create({ name: 'race' }), + App.create({ name: 'race' }), + ]); + assert.equal(writes.filter((x) => x.status === 'fulfilled').length, 1); + assert.equal(await App.count({ where: { name: 'race' } }), 1); + const unrelated = new Error('unrelated'); + handler(unrelated, {}, {}, (err) => assert.equal(err, unrelated)); + } finally { + await db.close(); + } +}); diff --git a/test/back/upgrade-guards.test.cjs b/test/back/upgrade-guards.test.cjs new file mode 100644 index 00000000..247368c8 --- /dev/null +++ b/test/back/upgrade-guards.test.cjs @@ -0,0 +1,285 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { randomUUID } = require('node:crypto'); +const { spawn } = require('node:child_process'); +const { withUpgradeLock, waitForHealth } = require('../../shell/upgrade.cjs'); + +async function temporary(t) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-upgrade-guards-')); + t.after(() => fs.rm(tmp, { recursive: true, force: true })); + return tmp; +} + +for (const legacy of [true, false]) + test( + `concurrent reclamation of a ${ + legacy ? 'legacy lock' : 'unique owner lock' + } cannot remove a new owner`, + { timeout: 5000 }, + async (t) => { + const tmp = await temporary(t); + const lock = path.join(tmp, 'upgrade.lock'); + const owner = legacy ? 'pid' : `owner-99999999-${randomUUID()}`; + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, owner), legacy ? '99999999' : ''); + const original = { + readFile: fs.readFile, + readdir: fs.readdir, + rm: fs.rm, + unlink: fs.unlink, + }; + let readCount = 0, + removeCount = 0, + active = 0, + maximum = 0; + let readyReaders, firstEntered, release; + const readers = new Promise((resolve) => { + readyReaders = resolve; + }); + const entered = new Promise((resolve) => { + firstEntered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const synchronize = async () => { + if (++readCount === 2) readyReaders(); + await readers; + }; + t.mock.method(fs, 'readFile', async (...args) => { + const value = await original.readFile(...args); + if (legacy && args[0] === path.join(lock, owner) && readCount < 2) + await synchronize(); + return value; + }); + t.mock.method(fs, 'readdir', async (...args) => { + const value = await original.readdir(...args); + if (!legacy && args[0] === lock && readCount < 2) await synchronize(); + return value; + }); + t.mock.method(fs, 'rm', async (...args) => { + if (args[0] === lock && ++removeCount === 2) await entered; + return original.rm(...args); + }); + t.mock.method(fs, 'unlink', async (...args) => { + if (args[0] === path.join(lock, owner) && ++removeCount === 2) + await entered; + return original.unlink(...args); + }); + const operation = async () => { + maximum = Math.max(maximum, ++active); + firstEntered(); + if (active === 1) await held; + active--; + }; + const attempts = [ + withUpgradeLock(tmp, operation), + withUpgradeLock(tmp, operation), + ].map((attempt) => + attempt.then( + () => ({ ok: true }), + (error) => ({ error }), + ), + ); + try { + await entered; + const contender = await Promise.race(attempts); + assert.match(contender.error?.message || '', /Another upgrade/); + assert.equal(maximum, 1); + assert.equal((await original.readdir(lock)).length, 1); + } finally { + release(); + await Promise.all(attempts); + } + assert.deepEqual(await original.readdir(tmp), []); + }, + ); + +test('an empty abandoned lock is acquired atomically and a failed operation releases its owner', async (t) => { + const tmp = await temporary(t); + await fs.mkdir(path.join(tmp, 'upgrade.lock')); + await assert.rejects( + withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => assert.fail('concurrent operation')), + /Another upgrade/, + ); + throw new Error('operation failed'); + }), + /operation failed/, + ); + await withUpgradeLock(tmp, async () => {}); + assert.deepEqual(await fs.readdir(tmp), []); +}); + +test('release never deletes another owner after the original lock was replaced', async (t) => { + const tmp = await temporary(t); + const lock = path.join(tmp, 'upgrade.lock'); + const replacement = `owner-${process.pid}-${randomUUID()}`; + await withUpgradeLock(tmp, async () => { + await fs.rm(lock, { recursive: true }); + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, replacement), ''); + }); + assert.deepEqual(await fs.readdir(lock), [replacement]); +}); + +test( + 'a lock left by a killed process can be reclaimed by a new process', + { timeout: 5000 }, + async (t) => { + const tmp = await temporary(t); + const worker = path.join(tmp, 'worker.cjs'); + await fs.writeFile( + worker, + ` +const { withUpgradeLock } = require(${JSON.stringify( + path.resolve('shell/upgrade.cjs'), + )}); +withUpgradeLock(process.argv[2], async () => { + process.stdout.write('owned\\n'); + await new Promise(() => setInterval(() => {}, 1000)); +}).catch(error => { process.stderr.write(error.message); process.exitCode = 1; }); +`, + ); + const child = spawn(process.execPath, [worker, tmp], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + await new Promise((resolve, reject) => { + child.stdout.once('data', resolve); + child.once('error', reject); + child.once('exit', () => + reject(new Error('worker exited before acquiring its lock')), + ); + }); + child.kill('SIGKILL'); + await closed; + await withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => {}), + /Another upgrade/, + ); + }); + assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']); + }, +); + +async function healthServer(t, handler) { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }), + ); + return String(server.address().port); +} + +test('health checks normalize supported URL prefixes exactly like the backend', async (t) => { + const requests = []; + const port = await healthServer(t, (req, res) => { + requests.push(req.url); + if ( + ['/api/health', '/panel/api/health', '/panel//api/health'].includes( + req.url, + ) + ) + res.end('{"code":200,"data":{"status":"ok"}}'); + else { + res.statusCode = 404; + res.end('{}'); + } + }); + for (const prefix of [ + '', + '/', + 'panel', + '/panel', + 'panel/', + '/panel/', + 'panel//', + '/panel//', + ]) + await waitForHealth({ QlPort: port, QlBaseUrl: prefix }, 100); + assert.deepEqual(requests, [ + '/api/health', + '/api/health', + ...Array(4).fill('/panel/api/health'), + ...Array(2).fill('/panel//api/health'), + ]); +}); + +test( + 'health timeout bounds a continuously streaming response', + { timeout: 3000 }, + async (t) => { + const port = await healthServer(t, (req, res) => { + const timer = setInterval(() => res.write(' '), 10); + res.once('close', () => clearInterval(timer)); + }); + const began = Date.now(); + await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/); + assert.ok( + Date.now() - began < 1000, + 'deadline must not depend on socket inactivity', + ); + }, +); + +for (const streaming of [false, true]) + test( + `cancellation interrupts ${ + streaming ? 'an active response' : 'the retry delay' + } and releases the upgrade lock`, + { timeout: 3000 }, + async (t) => { + const tmp = await temporary(t); + const controller = new AbortController(); + const reason = new Error('cancelled health check'); + const port = await healthServer(t, (req, res) => { + if (streaming) res.write('{'); + else { + res.statusCode = 503; + res.end('{}'); + } + setTimeout(() => controller.abort(reason), 30); + }); + const began = Date.now(); + await assert.rejects( + withUpgradeLock(tmp, () => + waitForHealth({ QlPort: port }, 5000, controller.signal), + ), + (error) => error === reason, + ); + assert.ok( + Date.now() - began < 400, + 'cancel must interrupt the current wait', + ); + await withUpgradeLock(tmp, async () => {}); + assert.deepEqual(await fs.readdir(tmp), []); + }, + ); + +test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => { + for (const body of ['{}', '{', ' '.repeat(65537)]) { + const port = await healthServer(t, (req, res) => res.end(body)); + await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/); + } +}); diff --git a/test/back/upgrade-lifecycle.test.cjs b/test/back/upgrade-lifecycle.test.cjs new file mode 100644 index 00000000..2fe9bc1d --- /dev/null +++ b/test/back/upgrade-lifecycle.test.cjs @@ -0,0 +1,288 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const { + runProcess, + withLifecycleHooks, + withUpgradeLock, +} = require('../../shell/upgrade.cjs'); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function assertStopped(pid) { + for (let attempt = 0; attempt < 50; attempt++) { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === 'ESRCH') return; + throw error; + } + const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { + encoding: 'utf8', + }); + // Linux PID 1 can retain a dead orphan until it is reaped. It can also + // disappear between kill(0) and ps; neither state is a running installer. + if ( + (state.status === 1 && !state.stdout.trim()) || + /^Z/.test(state.stdout.trim()) + ) + return; + assert.equal(state.status, 0, state.stderr); + await delay(20); + } + assert.fail(`test process ${pid} is still running`); +} + +async function fixture(t, directory) { + const base = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-upgrade-lifecycle-'), + ); + t.after(() => fs.rm(base, { recursive: true, force: true })); + const root = directory ? path.join(base, directory) : base; + const tmp = path.join(root, '.tmp'); + await fs.mkdir(tmp, { recursive: true }); + await fs.mkdir(path.join(root, 'shell/lang'), { recursive: true }); + await fs.writeFile(path.join(root, 'shell/lang/zh.sh'), ''); + await fs.writeFile( + path.join(root, 'shell/env.sh'), + await fs.readFile('shell/env.sh'), + ); + return { root, tmp, env: { ...process.env, QL_DIR: root } }; +} + +test('installed stop/start hooks do not match their own pkill and survive shell replacement', async (t) => { + const f = await fixture(t); + // Only the test's random path can match; never signal a running panel. + const marker = `review/${randomUUID()}/app.js`; + const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll( + 'static/build/app.js', + marker, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `${helpers} +pm2() { printf '%s\\n' "$*" >> "$QL_DIR/pm2-calls"; } +${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''} +`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => { + await fs.writeFile(path.join(f.root, 'shell/share.sh'), 'exit 99'); + await hook('stop'); + await hook('start'); + assert.match( + await fs.readFile(path.join(f.root, 'pm2-calls'), 'utf8'), + /startOrGracefulReload/, + ); + }), + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); + +test( + 'the installed Node fallback starts successfully and its stop hook kills only the test backend', + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + const marker = `review/${randomUUID()}/app.js`; + let pid; + t.after(() => { + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }); + const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll( + 'static/build/app.js', + marker, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `${helpers} +pm2() { return 1; } +${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''} +`, + ); + await fs.mkdir(path.dirname(path.join(f.root, marker)), { + recursive: true, + }); + await fs.mkdir(path.join(f.root, 'data/log'), { recursive: true }); + await fs.writeFile( + path.join(f.root, marker), + `require('node:fs').writeFileSync(process.env.QL_DIR + '/backend-pid', String(process.pid)); setInterval(() => {}, 1000);`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => { + await hook('start'); + for (let attempt = 0; attempt < 150; attempt++) { + try { + pid = Number( + await fs.readFile(path.join(f.root, 'backend-pid'), 'utf8'), + ); + break; + } catch { + await delay(20); + } + } + assert.ok(pid, 'fallback backend started'); + process.kill(pid, 0); + await hook('stop'); + await assertStopped(pid); + }), + ); + }, +); + +for (const { redirect, ignoreShell } of [ + { redirect: false, ignoreShell: false }, + { redirect: true, ignoreShell: false }, + { redirect: false, ignoreShell: true }, +]) + test( + `cancelled installation stops descendants before releasing its lock (${JSON.stringify( + { redirect, ignoreShell }, + )})`, + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + let pid; + t.after(() => { + controller.abort(); + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }); + const worker = path.join(f.root, 'installer.cjs'); + await fs.writeFile( + worker, + ` +const fs = require('node:fs'), path = require('node:path'); +process.on('SIGTERM', () => {}); +fs.writeFileSync(path.join(process.env.QL_DIR, 'installer-pid'), String(process.pid)); +setInterval(() => fs.appendFileSync(path.join(process.env.QL_DIR, 'heartbeat'), 'x'), 20); +`, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `import_config() { :; } +npm_install_2() { + ${ignoreShell ? "trap '' TERM" : ':'} + "$TEST_NODE" "$1"${redirect ? ' >/dev/null 2>&1' : ''} + exit_status=$? +} +`, + ); + const operation = withUpgradeLock(f.tmp, () => + withLifecycleHooks( + f.root, + f.tmp, + { ...f.env, TEST_NODE: process.execPath }, + (hook) => hook('install', [worker], controller.signal), + ), + ); + // Attach rejection handling before aborting the in-flight operation. + const rejection = assert.rejects(operation, /cancelled installation/); + for (let attempt = 0; attempt < 150; attempt++) { + try { + pid = Number( + await fs.readFile(path.join(f.root, 'installer-pid'), 'utf8'), + ); + await fs.access(path.join(f.root, 'heartbeat')); + break; + } catch { + await delay(20); + } + } + assert.ok(pid, 'installer started'); + await fs.access(path.join(f.tmp, 'upgrade.lock')); + controller.abort(new Error('cancelled installation')); + await rejection; + assert.deepEqual(await fs.readdir(f.tmp), []); + const before = await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'); + await delay(100); + assert.equal( + await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'), + before, + ); + await assertStopped(pid); + }, + ); + +test('an already cancelled command does not spawn and a spawn failure releases the upgrade lock', async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + controller.abort(new Error('cancelled before spawn')); + const script = path.join(f.root, 'never-run.sh'); + await fs.writeFile(script, 'touch "$QL_DIR/spawned"'); + await assert.rejects( + runProcess(f.env, 'bash', ['--', script], false, controller.signal), + /cancelled before spawn/, + ); + await assert.rejects(fs.access(path.join(f.root, 'spawned')), { + code: 'ENOENT', + }); + await assert.rejects( + withUpgradeLock(f.tmp, () => + runProcess(f.env, path.join(f.root, 'missing-program'), []), + ), + { code: 'ENOENT' }, + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); + +test('fixed lifecycle dispatch treats spaces, quotes and command substitutions in paths as data', async (t) => { + const special = + 'panel spaces \u0027"; $(touch "$TEST_MARKER"); `touch "$TEST_MARKER"`'; + const f = await fixture(t, special); + const marker = path.join(f.root, 'injected'); + const source = path.join(f.root, 'source ' + special); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + ` +import_config() { :; } +delete_pm2() { printf stopped > "$QL_DIR/stopped"; } +reload_pm2() { printf started > "$QL_DIR/started"; } +npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; } +`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks( + f.root, + f.tmp, + { ...f.env, TEST_MARKER: marker }, + async (hook) => { + await hook('stop'); + await hook('start'); + await hook('install', [source]); + assert.equal( + await fs.readFile(path.join(f.root, 'stopped'), 'utf8'), + 'stopped', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'started'), 'utf8'), + 'started', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'installed'), 'utf8'), + source, + ); + await assert.rejects(fs.access(marker), { code: 'ENOENT' }); + await assert.rejects(hook('touch "$TEST_MARKER"'), /exit 2/); + await assert.rejects(fs.access(marker), { code: 'ENOENT' }); + }, + ), + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); diff --git a/test/back/upgrade-transaction.test.cjs b/test/back/upgrade-transaction.test.cjs new file mode 100644 index 00000000..3df18f2f --- /dev/null +++ b/test/back/upgrade-transaction.test.cjs @@ -0,0 +1,133 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { replaceAndReload } = require('../../shell/upgrade.cjs'); + +async function fixture(t) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-upgrade-transaction-'), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const source = path.join(root, 'source'), + target = path.join(root, 'target'); + for (const directory of [source, target]) await fs.mkdir(directory); + await fs.writeFile(path.join(source, 'version'), 'new'); + await fs.writeFile(path.join(target, 'version'), 'old'); + return { root, source, target }; +} + +for (const failure of ['backup', 'remove', 'replacement', 'start']) + test(`Bash transaction restores the full original after an overlay ${failure} failure`, async (t) => { + const f = await fixture(t); + const rename = fs.rename, + cp = fs.cp, + rm = fs.rm; + t.mock.method(fs, 'rename', async (source, target) => { + if (source === f.target || source.includes('.ql-backup-')) { + const error = new Error('overlay rename'); + error.code = 'EXDEV'; + throw error; + } + return rename(source, target); + }); + let injected = false; + t.mock.method(fs, 'cp', async (source, target, options) => { + if ( + !injected && + ((failure === 'backup' && source === f.target) || + (failure === 'replacement' && source === f.source)) + ) { + injected = true; + await fs.mkdir(target, { recursive: true }); + await fs.writeFile(path.join(target, 'partial'), 'partial'); + throw new Error('copy failed'); + } + return cp(source, target, options); + }); + t.mock.method(fs, 'rm', async (target, options) => { + if (!injected && failure === 'remove' && target === f.target) { + injected = true; + await fs.unlink(path.join(target, 'version')); + throw new Error('remove failed'); + } + return rm(target, options); + }); + let starts = 0; + await assert.rejects( + replaceAndReload([f], { + stop: async () => {}, + start: async () => { + if (++starts === 1 && failure === 'start') + throw new Error('start failed'); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'old', + ); + }, + }), + /failed/, + ); + assert.deepEqual(await fs.readdir(f.target), ['version']); + assert.ok( + !(await fs.readdir(f.root)).some((name) => name.includes('.ql-backup-')), + ); + }); + +test('interrupted upgrade recovers the previous service outside cancellation', async (t) => { + const f = await fixture(t), + controller = new AbortController(); + let starts = 0; + await assert.rejects( + replaceAndReload([f], { + signal: controller.signal, + stop: async () => {}, + start: async (recover) => { + if (++starts === 1) { + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'new', + ); + controller.abort(new Error('interrupted')); + } else { + assert.equal(recover, true); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'old', + ); + } + }, + }), + /interrupted/, + ); + assert.equal(starts, 2); +}); + +test('failed stop during rollback retains backups without overwriting running files', async (t) => { + const f = await fixture(t); + let stops = 0; + await assert.rejects( + replaceAndReload([f], { + stop: async () => { + if (++stops === 2) throw new Error('stop failed'); + }, + start: async () => { + throw new Error('start failed'); + }, + }), + /stop failed/, + ); + const backup = (await fs.readdir(f.root)).find((name) => + name.includes('.ql-backup-'), + ); + assert.ok(backup); + assert.equal( + await fs.readFile(path.join(f.root, backup, 'version'), 'utf8'), + 'old', + ); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'new', + ); +}); diff --git a/test/back/user-api.test.cjs b/test/back/user-api.test.cjs new file mode 100644 index 00000000..f0c275a6 --- /dev/null +++ b/test/back/user-api.test.cjs @@ -0,0 +1,99 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const express = require('express'); +const { Container } = require('typedi'); + +test('initialization returns the username and password validation result', async (t) => { + const originalGet = Container.get; + const userServicePath = require.resolve('../../back/services/user'); + const originalUserService = require.cache[userServicePath]; + const i18nPath = require.resolve('../../back/shared/i18n'); + const originalI18n = require.cache[i18nPath]; + const utilPath = require.resolve('../../back/config/util'); + const originalUtil = require.cache[utilPath]; + const authPath = require.resolve('../../back/shared/auth'); + const originalAuth = require.cache[authPath]; + require.cache[userServicePath] = { + id: userServicePath, + filename: userServicePath, + loaded: true, + exports: { __esModule: true, default: class UserService {} }, + children: [], + paths: [], + }; + require.cache[utilPath] = { + id: utilPath, + filename: utilPath, + loaded: true, + exports: { getToken: () => '', isDemoEnv: () => false }, + children: [], + paths: [], + }; + require.cache[authPath] = { + id: authPath, + filename: authPath, + loaded: true, + exports: { isDefaultAuthInfo: () => false }, + children: [], + paths: [], + }; + require.cache[i18nPath] = { + id: i18nPath, + filename: i18nPath, + loaded: true, + exports: { t: (message) => message }, + children: [], + paths: [], + }; + Container.get = () => ({ + initializeUser: async () => ({ + code: 400, + message: 'password rejected', + }), + }); + t.after(() => { + Container.get = originalGet; + if (originalUserService) { + require.cache[userServicePath] = originalUserService; + } else { + delete require.cache[userServicePath]; + } + if (originalI18n) { + require.cache[i18nPath] = originalI18n; + } else { + delete require.cache[i18nPath]; + } + if (originalUtil) { + require.cache[utilPath] = originalUtil; + } else { + delete require.cache[utilPath]; + } + if (originalAuth) { + require.cache[authPath] = originalAuth; + } else { + delete require.cache[authPath]; + } + }); + + const app = express.Router(); + require('../../back/api/user').default(app); + const userRouter = app.stack.find((layer) => layer.name === 'router').handle; + const initRoute = userRouter.stack.find( + (layer) => layer.route?.path === '/init', + ); + const handler = initRoute.route.stack.at(-1).handle; + let responseBody; + + await handler( + { body: { username: 'admin', password: 'admin' } }, + { send: (body) => (responseBody = body) }, + (error) => { + throw error; + }, + ); + + assert.deepEqual(responseBody, { + code: 400, + message: 'password rejected', + }); +}); diff --git a/test/back/user-security.test.cjs b/test/back/user-security.test.cjs new file mode 100644 index 00000000..3fcdd5a8 --- /dev/null +++ b/test/back/user-security.test.cjs @@ -0,0 +1,349 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const jwt = require('jsonwebtoken'); +const { authenticator } = require('@otplib/preset-default'); +const load = require('../helpers/load-security-module.cjs'); +const { verifyPassword, isPasswordHash } = load( + path.join(__dirname, '../../back/shared/password.ts'), +); +const req = { + platform: 'desktop', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, +}; + +function fixture(initial) { + let auth = structuredClone(initial); + let closed = 0; + const model = { + findOne: async () => ({ id: 1 }), + update: async () => [1, [{}]], + create: async (x) => x, + findAll: async () => [], + destroy: async () => {}, + }; + const mocks = { + typedi: { Service: () => (x) => x, Inject: () => () => {} }, + '../config': { jwt: { secret: 'test-secret' }, maxTokensPerPlatform: 10 }, + '../data/system': { + AuthDataType: { authConfig: 'authConfig', loginLog: 'loginLog' }, + LoginStatus: { fail: 0, success: 1 }, + SystemModel: model, + }, + '../config/util': { + createRandomString: () => + require('node:crypto').randomBytes(32).toString('hex'), + }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + '../shared/store': { + shareStore: { + getAuthInfo: async () => auth, + updateAuthInfo: async (x) => { + auth = x; + }, + }, + }, + '../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) }, + '../shared/clientIp': { + getClientIp: (request) => request.socket.remoteAddress, + normalizeClientIp: (x) => x, + }, + ip2region: class { + search() { + return null; + } + }, + }; + const User = load( + path.join(__dirname, '../../back/services/user.ts'), + mocks, + ).default; + const user = new User( + { warn() {}, info() {} }, + {}, + { getClients: () => [{ close: () => closed++ }] }, + ); + user.notificationService = { notify() {} }; + return { + user, + get auth() { + return auth; + }, + get closed() { + return closed; + }, + }; +} +const initialized = () => ({ + username: 'owner', + password: 'old-password', + token: 'stolen-token', + tokens: { desktop: [{ value: 'stolen-token' }] }, + retries: 0, + lastlogon: 0, +}); + +test('initialization checks state inside a serialized mutation', async () => { + const f = fixture({ username: 'admin', password: 'admin' }); + assert.equal( + (await f.user.login({ username: 'admin', password: 'admin' }, req)).code, + 450, + ); + assert.deepEqual(f.auth, { username: 'admin', password: 'admin' }); + const results = await Promise.all([ + f.user.initializeUser({ username: 'owner', password: 'first-password' }), + f.user.initializeUser({ + username: 'attacker', + password: 'second-password', + }), + ]); + assert.deepEqual( + results.map((x) => x.code), + [200, 450], + ); + assert.equal(f.auth.username, 'owner'); + assert.equal(await verifyPassword('first-password', f.auth.password), true); + assert.equal( + ( + await f.user.initializeUser({ + username: 'attacker', + password: 'third-password', + }) + ).code, + 450, + ); +}); + +test('password change revokes sessions and closes connected clients', async () => { + const f = fixture(initialized()); + await f.user.updateUsernameAndPassword({ + username: 'owner', + password: 'new-password', + }); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(f.closed, 1); + assert.equal(await verifyPassword('new-password', f.auth.password), true); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 400, + ); + const login = await f.user.login( + { username: 'owner', password: 'new-password' }, + req, + ); + assert.equal(login.code, 200); + jwt.verify(login.data.token, 'test-secret', { algorithms: ['HS384'] }); +}); + +test('a concurrent old-password login cannot restore a session after reset', async () => { + const f = fixture(initialized()); + const results = await Promise.all([ + f.user.login({ username: 'owner', password: 'old-password' }, req), + f.user.resetAuthInfo({ password: 'new-password' }), + ]); + assert.equal(results[0].code, 200); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(await verifyPassword('new-password', f.auth.password), true); +}); + +test('legacy plaintext migrates after a successful login', async () => { + const f = fixture(initialized()); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 200, + ); + assert.equal(isPasswordHash(f.auth.password), true); + assert.equal(await verifyPassword('old-password', f.auth.password), true); +}); + +test('TOTP failures are counted serially and further attempts are throttled', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, req)) + .code, + 420, + ); + const badCode = + authenticator.generate(secret) === '000000' ? '111111' : '000000'; + const results = await Promise.all( + Array.from({ length: 120 }, () => + f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: badCode }, + req, + ), + ), + ); + assert.equal(f.auth.retries, 3); + assert.equal(results.filter((x) => x.code === 430).length, 3); + assert.equal(results.filter((x) => x.code === 410).length, 117); +}); + +test('TOTP challenge expires and valid codes cannot be reused in the same step', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + f.auth.twoFactorExpiresAt = Date.now() - 1; + const payload = { + username: 'owner', + password: 'old-password', + code: authenticator.generate(secret), + }; + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 450); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 200); + await f.user.login({ username: 'owner', password: 'old-password' }, req); + assert.equal((await f.user.twoFactorLogin(payload, req)).code, 430); +}); + +test('active two-factor secret cannot be silently replaced and disabling revokes sessions', async () => { + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: authenticator.generateSecret(), + }); + await assert.rejects(f.user.initTwoFactor()); + await f.user.deactivateTwoFactor(); + assert.equal(f.auth.token, ''); + assert.deepEqual(f.auth.tokens, {}); + assert.equal(f.auth.twoFactorSecret, ''); +}); + +test('default credentials with historical metadata still require initialization', async () => { + const f = fixture({ + username: 'admin', + password: 'admin', + retries: 1, + token: 'old-default-session', + }); + assert.equal( + (await f.user.login({ username: 'admin', password: 'admin' }, req)).code, + 450, + ); + assert.equal( + ( + await f.user.initializeUser({ + username: 'owner', + password: 'new-password', + }) + ).code, + 200, + ); + assert.equal(f.auth.token, ''); +}); + +test('one source cannot lock out another or revive a persisted global lock', async () => { + const f = fixture({ ...initialized(), retries: 40, lastlogon: Date.now() }); + const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } }; + const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } }; + for (let i = 0; i < 3; i++) { + assert.equal( + (await f.user.login({ username: 'owner', password: 'wrong' }, attacker)) + .code, + 400, + ); + } + assert.equal( + ( + await f.user.login( + { username: 'owner', password: 'old-password' }, + attacker, + ) + ).code, + 410, + ); + assert.equal( + (await f.user.login({ username: 'owner', password: 'old-password' }, owner)) + .code, + 200, + ); + // Another client's successful login does not reset the attacker's own budget. + assert.equal( + (await f.user.login({ username: 'owner', password: 'wrong' }, attacker)) + .code, + 410, + ); + await f.user.resetAuthInfo({ retries: 0 }); + assert.equal( + ( + await f.user.login( + { username: 'owner', password: 'old-password' }, + attacker, + ) + ).code, + 200, + ); +}); + +test('TOTP guesses from another source do not lock the owner out', async () => { + const secret = authenticator.generateSecret(); + const f = fixture({ + ...initialized(), + twoFactorActivated: true, + twoFactorSecret: secret, + }); + const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } }; + const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } }; + await f.user.login({ username: 'owner', password: 'old-password' }, owner); + const good = authenticator.generate(secret); + const bad = good === '000000' ? '111111' : '000000'; + for (let i = 0; i < 3; i++) { + assert.equal( + ( + await f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: bad }, + attacker, + ) + ).code, + 430, + ); + } + assert.equal( + ( + await f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: bad }, + attacker, + ) + ).code, + 410, + ); + assert.equal( + ( + await f.user.twoFactorLogin( + { username: 'owner', password: 'old-password', code: good }, + owner, + ) + ).code, + 200, + ); +}); + +test('source backoff is capped and expires after inactivity', () => { + const { LoginThrottle } = load(path.resolve('back/shared/loginThrottle.ts')); + const throttle = new LoginThrottle(); + let now = 1; + for (let i = 0; i < 20; i++) { + throttle.fail('attacker', now); + const wait = throttle.retryAfter('attacker', now); + assert.ok(wait <= 300); + now += (wait + 1) * 1000; + } + assert.equal(throttle.retryAfter('owner', now), 0); + assert.equal(throttle.retryAfter('attacker', now + 15 * 60 * 1000), 0); + assert.equal(throttle.fail('attacker', now + 15 * 60 * 1000), 1); +}); diff --git a/test/back/worker-apm.test.cjs b/test/back/worker-apm.test.cjs new file mode 100644 index 00000000..595188d3 --- /dev/null +++ b/test/back/worker-apm.test.cjs @@ -0,0 +1,84 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); +const source = fs.readFileSync('back/app.ts', 'utf8'); +const entry = source.indexOf('\nconst app = new Application();'); +assert.ok(entry > 0, 'application entry point exists'); +const compiled = ts.transpileModule( + source.slice(0, entry) + '\nmodule.exports = Application;', + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }, +).outputText; +function create(env) { + const calls = []; + const cluster = { + fork(options) { + const worker = { + id: calls.length + 1, + process: { pid: 100 + calls.length }, + }; + calls.push({ options, inherited: { ...env, ...options }, worker }); + return worker; + }, + }; + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + process: { env }, + require(name) { + if (name === 'cluster') return cluster; + if (name === 'express') return () => ({ use() {} }); + return {}; + }, + }; + vm.runInNewContext(compiled, sandbox); + return { app: new module.exports(), calls }; +} +test('PM2-managed primary disables only inherited worker APM and keeps worker metadata', () => { + const env = { pm_id: '0', pmx: 'true', CUSTOM_VALUE: 'kept' }; + const { app, calls } = create(env); + for (const role of ['grpc', 'http']) app.forkWorker(role); + assert.deepEqual( + calls.map((x) => ({ ...x.options })), + [ + { SERVICE_TYPE: 'grpc', pmx: 'false' }, + { SERVICE_TYPE: 'http', pmx: 'false' }, + ], + ); + assert.equal(env.pmx, 'true'); + assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept'); + for (const call of calls) { + const entry = app.workerMetadataMap.get(call.worker.id); + assert.equal(entry.pid, call.worker.process.pid); + assert.equal(entry.serviceType, call.options.SERVICE_TYPE); + } +}); +test('worker APM opt-in restores inherited PM2 settings, including explicit disable', () => { + for (const pmx of ['true', 'false']) { + const { app, calls } = create({ pm_id: '0', pmx, QL_WORKER_APM: 'true' }); + app.forkWorker('http'); + assert.equal(Object.hasOwn(calls[0].options, 'pmx'), false); + assert.equal(calls[0].inherited.pmx, pmx); + } +}); +test('standalone startup does not introduce a PM2-specific override', () => { + const { app, calls } = create({ CUSTOM_VALUE: 'kept' }); + app.forkWorker('grpc'); + assert.deepEqual({ ...calls[0].options }, { SERVICE_TYPE: 'grpc' }); + assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept'); +}); +test('replacement workers receive the same monitoring policy', () => { + const { app, calls } = create({ pm_id: '0', pmx: 'true' }); + app.forkWorker('grpc'); + app.forkWorker('grpc'); + assert.equal(calls[1].inherited.pmx, 'false'); + assert.notEqual(calls[0].worker.id, calls[1].worker.id); +}); diff --git a/test/back/wpush-grpc.test.cjs b/test/back/wpush-grpc.test.cjs new file mode 100644 index 00000000..3229882d --- /dev/null +++ b/test/back/wpush-grpc.test.cjs @@ -0,0 +1,116 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); +const load = require('../helpers/load-security-module.cjs'); + +const back = (file) => path.join(__dirname, '../../back', file); +const protocol = load(back('protos/api.ts')); +const constants = load(back('config/const.ts'), { + './container': { maybeSudo: (x) => x }, +}); + +test('WPUSH survives protobuf and JSON round trips without changing existing modes', () => { + assert.equal(protocol.NotificationMode.wpush, 22); + for (let value = 0; value <= 22; value++) { + const name = protocol.notificationModeToJSON(value); + assert.equal(protocol.notificationModeFromJSON(name), value); + assert.equal(constants.NotificationModeStringMap[value], name); + } + const request = protocol.SystemNotifyRequest.create({ + title: 'title', + content: 'content', + notificationInfo: { + type: 22, + wpushApiKey: 'test-key', + wpushChannel: 'feishu', + wpushTopicCode: 'test-topic', + }, + }); + const decoded = protocol.SystemNotifyRequest.decode( + protocol.SystemNotifyRequest.encode(request).finish(), + ); + assert.equal(decoded.notificationInfo.wpushApiKey, 'test-key'); + assert.equal(decoded.notificationInfo.wpushChannel, 'feishu'); + assert.equal(decoded.notificationInfo.wpushTopicCode, 'test-topic'); + assert.deepEqual(decoded, request); + assert.deepEqual( + protocol.SystemNotifyRequest.fromJSON( + protocol.SystemNotifyRequest.toJSON(request), + ), + request, + ); +}); + +test('decoded SystemNotify requests reach WPUSH with explicit or saved configuration', async () => { + const calls = []; + const decorators = { Service: () => (x) => x, Inject: () => () => {} }; + const NotificationService = load(back('services/notify.ts'), { + typedi: decorators, + './user': {}, + '../config/util': {}, + '../shared/i18n': { t: (x) => x }, + '../config/http': { + httpClient: { + post: async (url, options) => { + calls.push({ url, body: options.json }); + return { code: 0 }; + }, + }, + }, + }).default; + const SystemService = load(back('services/system.ts'), { + typedi: decorators, + '../config': {}, + '../config/const': constants, + '../config/util': {}, + '../data/dependence': {}, + '../data/system': {}, + '../shared/pLimit': {}, + '../schedule/client': {}, + '../shared/i18n': { t: (x) => x }, + './notify': {}, + './schedule': {}, + './sock': {}, + }).default; + const notification = new NotificationService(); + notification.userService = { + getNotificationMode: async () => ({ + type: 'wpush', + wpushApiKey: 'saved-key', + }), + }; + const system = new SystemService(); + system.notificationService = notification; + + for (const info of [ + { + type: 22, + wpushApiKey: 'explicit-key', + wpushChannel: 'feishu', + wpushTopicCode: 'topic', + }, + { type: 22, wpushApiKey: 'explicit-key' }, + undefined, + ]) { + const decoded = protocol.SystemNotifyRequest.decode( + protocol.SystemNotifyRequest.encode( + protocol.SystemNotifyRequest.create({ + title: 'title', + content: 'content', + notificationInfo: info, + }), + ).finish(), + ); + assert.equal((await system.notify(decoded)).code, 200); + assert.deepEqual(calls.at(-1), { + url: 'https://api.wpush.cn/api/v1/send', + body: { + apikey: info ? 'explicit-key' : 'saved-key', + title: 'title', + content: 'content', + channel: info?.wpushChannel || 'wechat', + ...(info?.wpushTopicCode ? { topic_code: 'topic' } : {}), + }, + }); + } +}); diff --git a/test/fixtures/legacy-cron.json b/test/fixtures/legacy-cron.json new file mode 100644 index 00000000..4f8844b0 --- /dev/null +++ b/test/fixtures/legacy-cron.json @@ -0,0 +1,1371 @@ +{ + "source": "node-schedule 2.1.1 / cron-parser 4.9.0, captured before migration", + "validation": [ + { + "schedule": "*", + "accepted": true + }, + { + "schedule": "0", + "accepted": true + }, + { + "schedule": "?", + "accepted": true + }, + { + "schedule": "0 0", + "accepted": false + }, + { + "schedule": "0 0 *", + "accepted": false + }, + { + "schedule": "0 0 * *", + "accepted": false + }, + { + "schedule": "@yearly", + "accepted": true + }, + { + "schedule": "@annually", + "accepted": false + }, + { + "schedule": "@monthly", + "accepted": true + }, + { + "schedule": "@weekly", + "accepted": true + }, + { + "schedule": "@daily", + "accepted": true + }, + { + "schedule": "@midnight", + "accepted": false + }, + { + "schedule": "@hourly", + "accepted": true + }, + { + "schedule": "@secondly", + "accepted": false + }, + { + "schedule": "@minutely", + "accepted": false + }, + { + "schedule": "@weekdays", + "accepted": false + }, + { + "schedule": "@weekends", + "accepted": false + }, + { + "schedule": "@reboot", + "accepted": false + }, + { + "schedule": "0 0 1 1 * 2027", + "accepted": false + }, + { + "schedule": "0 0 0 L * *", + "accepted": true + }, + { + "schedule": "0 0 0 * * 5L", + "accepted": true + }, + { + "schedule": "0 0 0 * * MON#2", + "accepted": true + }, + { + "schedule": "0 0 12 LW * *", + "accepted": false + }, + { + "schedule": "0 0 12 15W * *", + "accepted": false + }, + { + "schedule": "0 0 0 ? * MON", + "accepted": true + }, + { + "schedule": "0 0/30 * * * ?", + "accepted": true + }, + { + "schedule": "0 /5 * * * *", + "accepted": false + }, + { + "schedule": "* 0 0 * * *", + "accepted": true + }, + { + "schedule": "? 0 0 * * *", + "accepted": false + }, + { + "schedule": "/5 0 0 * * *", + "accepted": false + }, + { + "schedule": "*/5 0 0 * * *", + "accepted": true + }, + { + "schedule": "0/5 0 0 * * *", + "accepted": true + }, + { + "schedule": "0 0 0 * * *", + "accepted": true + }, + { + "schedule": "01 0 0 * * *", + "accepted": true + }, + { + "schedule": "1-5 0 0 * * *", + "accepted": true + }, + { + "schedule": "1,3 0 0 * * *", + "accepted": true + }, + { + "schedule": "5-1 0 0 * * *", + "accepted": false + }, + { + "schedule": "H 0 0 * * *", + "accepted": false + }, + { + "schedule": "H/5 0 0 * * *", + "accepted": false + }, + { + "schedule": "H(0-10) 0 0 * * *", + "accepted": false + }, + { + "schedule": "L 0 0 * * *", + "accepted": false + }, + { + "schedule": "0 * 0 * * *", + "accepted": true + }, + { + "schedule": "0 ? 0 * * *", + "accepted": false + }, + { + "schedule": "0 /5 0 * * *", + "accepted": false + }, + { + "schedule": "0 */5 0 * * *", + "accepted": true + }, + { + "schedule": "0 0/5 0 * * *", + "accepted": true + }, + { + "schedule": "0 01 0 * * *", + "accepted": true + }, + { + "schedule": "0 1-5 0 * * *", + "accepted": true + }, + { + "schedule": "0 1,3 0 * * *", + "accepted": true + }, + { + "schedule": "0 5-1 0 * * *", + "accepted": false + }, + { + "schedule": "0 H 0 * * *", + "accepted": false + }, + { + "schedule": "0 H/5 0 * * *", + "accepted": false + }, + { + "schedule": "0 H(0-10) 0 * * *", + "accepted": false + }, + { + "schedule": "0 L 0 * * *", + "accepted": false + }, + { + "schedule": "0 0 * * * *", + "accepted": true + }, + { + "schedule": "0 0 ? * * *", + "accepted": false + }, + { + "schedule": "0 0 /2 * * *", + "accepted": false + }, + { + "schedule": "0 0 */2 * * *", + "accepted": true + }, + { + "schedule": "0 0 0/2 * * *", + "accepted": true + }, + { + "schedule": "0 0 01 * * *", + "accepted": true + }, + { + "schedule": "0 0 1-5 * * *", + "accepted": true + }, + { + "schedule": "0 0 1,3 * * *", + "accepted": true + }, + { + "schedule": "0 0 23-2 * * *", + "accepted": false + }, + { + "schedule": "0 0 H * * *", + "accepted": false + }, + { + "schedule": "0 0 L * * *", + "accepted": false + }, + { + "schedule": "0 0 0 ? * *", + "accepted": true + }, + { + "schedule": "0 0 0 /2 * *", + "accepted": false + }, + { + "schedule": "0 0 0 */2 * *", + "accepted": true + }, + { + "schedule": "0 0 0 1/2 * *", + "accepted": true + }, + { + "schedule": "0 0 0 1 * *", + "accepted": true + }, + { + "schedule": "0 0 0 01 * *", + "accepted": true + }, + { + "schedule": "0 0 0 1-5 * *", + "accepted": true + }, + { + "schedule": "0 0 0 1,3 * *", + "accepted": true + }, + { + "schedule": "0 0 0 L-1 * *", + "accepted": false + }, + { + "schedule": "0 0 0 LW * *", + "accepted": false + }, + { + "schedule": "0 0 0 15W * *", + "accepted": false + }, + { + "schedule": "0 0 0 H * *", + "accepted": false + }, + { + "schedule": "0 0 0 * ? *", + "accepted": false + }, + { + "schedule": "0 0 0 * /2 *", + "accepted": false + }, + { + "schedule": "0 0 0 * */2 *", + "accepted": true + }, + { + "schedule": "0 0 0 * 1/2 *", + "accepted": true + }, + { + "schedule": "0 0 0 * 1 *", + "accepted": true + }, + { + "schedule": "0 0 0 * 01 *", + "accepted": true + }, + { + "schedule": "0 0 0 * 1-5 *", + "accepted": true + }, + { + "schedule": "0 0 0 * 1,3 *", + "accepted": true + }, + { + "schedule": "0 0 0 * JAN *", + "accepted": true + }, + { + "schedule": "0 0 0 * jan *", + "accepted": true + }, + { + "schedule": "0 0 0 * JAN-MAR *", + "accepted": true + }, + { + "schedule": "0 0 0 * DEC-FEB *", + "accepted": false + }, + { + "schedule": "0 0 0 * H *", + "accepted": false + }, + { + "schedule": "0 0 0 * * ?", + "accepted": true + }, + { + "schedule": "0 0 0 * * /2", + "accepted": false + }, + { + "schedule": "0 0 0 * * */2", + "accepted": true + }, + { + "schedule": "0 0 0 * * 0/2", + "accepted": true + }, + { + "schedule": "0 0 0 * * 0", + "accepted": true + }, + { + "schedule": "0 0 0 * * 7", + "accepted": true + }, + { + "schedule": "0 0 0 * * 01", + "accepted": true + }, + { + "schedule": "0 0 0 * * 1-5", + "accepted": true + }, + { + "schedule": "0 0 0 * * 1,3", + "accepted": true + }, + { + "schedule": "0 0 0 * * MON", + "accepted": true + }, + { + "schedule": "0 0 0 * * mon", + "accepted": true + }, + { + "schedule": "0 0 0 * * MON-FRI", + "accepted": true + }, + { + "schedule": "0 0 0 * * FRI-MON", + "accepted": false + }, + { + "schedule": "0 0 0 * * L", + "accepted": false + }, + { + "schedule": "0 0 0 * * 1#5", + "accepted": true + }, + { + "schedule": "0 0 0 * * H", + "accepted": false + }, + { + "schedule": " 0 0 1 1 * 2027 ", + "accepted": false + }, + { + "schedule": "0 1 1 * 2027", + "accepted": false + }, + { + "schedule": " 0 0 0 L * * ", + "accepted": true + }, + { + "schedule": "0 0 L * *", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 5L ", + "accepted": true + }, + { + "schedule": "0 0 * * 5L", + "accepted": true + }, + { + "schedule": " 0 0 0 * * MON#2 ", + "accepted": true + }, + { + "schedule": "0 0 * * MON#2", + "accepted": true + }, + { + "schedule": " 0 0 12 LW * * ", + "accepted": false + }, + { + "schedule": "0 12 LW * *", + "accepted": false + }, + { + "schedule": " 0 0 12 15W * * ", + "accepted": false + }, + { + "schedule": "0 12 15W * *", + "accepted": false + }, + { + "schedule": " 0 0 0 ? * MON ", + "accepted": true + }, + { + "schedule": "0 0 ? * MON", + "accepted": true + }, + { + "schedule": " 0 0/30 * * * ? ", + "accepted": true + }, + { + "schedule": "0/30 * * * ?", + "accepted": true + }, + { + "schedule": " 0 /5 * * * * ", + "accepted": false + }, + { + "schedule": "/5 * * * *", + "accepted": false + }, + { + "schedule": " * 0 0 * * * ", + "accepted": true + }, + { + "schedule": "0 0 * * *", + "accepted": true + }, + { + "schedule": " ? 0 0 * * * ", + "accepted": false + }, + { + "schedule": " /5 0 0 * * * ", + "accepted": false + }, + { + "schedule": " */5 0 0 * * * ", + "accepted": true + }, + { + "schedule": " 0/5 0 0 * * * ", + "accepted": true + }, + { + "schedule": " 0 0 0 * * * ", + "accepted": true + }, + { + "schedule": " 01 0 0 * * * ", + "accepted": true + }, + { + "schedule": " 1-5 0 0 * * * ", + "accepted": true + }, + { + "schedule": " 1,3 0 0 * * * ", + "accepted": true + }, + { + "schedule": " 5-1 0 0 * * * ", + "accepted": false + }, + { + "schedule": " H 0 0 * * * ", + "accepted": false + }, + { + "schedule": " H/5 0 0 * * * ", + "accepted": false + }, + { + "schedule": " H(0-10) 0 0 * * * ", + "accepted": false + }, + { + "schedule": " L 0 0 * * * ", + "accepted": false + }, + { + "schedule": " 0 * 0 * * * ", + "accepted": true + }, + { + "schedule": "* 0 * * *", + "accepted": true + }, + { + "schedule": " 0 ? 0 * * * ", + "accepted": false + }, + { + "schedule": "? 0 * * *", + "accepted": false + }, + { + "schedule": " 0 /5 0 * * * ", + "accepted": false + }, + { + "schedule": "/5 0 * * *", + "accepted": false + }, + { + "schedule": " 0 */5 0 * * * ", + "accepted": true + }, + { + "schedule": "*/5 0 * * *", + "accepted": true + }, + { + "schedule": " 0 0/5 0 * * * ", + "accepted": true + }, + { + "schedule": "0/5 0 * * *", + "accepted": true + }, + { + "schedule": " 0 01 0 * * * ", + "accepted": true + }, + { + "schedule": "01 0 * * *", + "accepted": true + }, + { + "schedule": " 0 1-5 0 * * * ", + "accepted": true + }, + { + "schedule": "1-5 0 * * *", + "accepted": true + }, + { + "schedule": " 0 1,3 0 * * * ", + "accepted": true + }, + { + "schedule": "1,3 0 * * *", + "accepted": true + }, + { + "schedule": " 0 5-1 0 * * * ", + "accepted": false + }, + { + "schedule": "5-1 0 * * *", + "accepted": false + }, + { + "schedule": " 0 H 0 * * * ", + "accepted": false + }, + { + "schedule": "H 0 * * *", + "accepted": false + }, + { + "schedule": " 0 H/5 0 * * * ", + "accepted": false + }, + { + "schedule": "H/5 0 * * *", + "accepted": false + }, + { + "schedule": " 0 H(0-10) 0 * * * ", + "accepted": false + }, + { + "schedule": "H(0-10) 0 * * *", + "accepted": false + }, + { + "schedule": " 0 L 0 * * * ", + "accepted": false + }, + { + "schedule": "L 0 * * *", + "accepted": false + }, + { + "schedule": " 0 0 * * * * ", + "accepted": true + }, + { + "schedule": "0 * * * *", + "accepted": true + }, + { + "schedule": " 0 0 ? * * * ", + "accepted": false + }, + { + "schedule": "0 ? * * *", + "accepted": false + }, + { + "schedule": " 0 0 /2 * * * ", + "accepted": false + }, + { + "schedule": "0 /2 * * *", + "accepted": false + }, + { + "schedule": " 0 0 */2 * * * ", + "accepted": true + }, + { + "schedule": "0 */2 * * *", + "accepted": true + }, + { + "schedule": " 0 0 0/2 * * * ", + "accepted": true + }, + { + "schedule": "0 0/2 * * *", + "accepted": true + }, + { + "schedule": " 0 0 01 * * * ", + "accepted": true + }, + { + "schedule": "0 01 * * *", + "accepted": true + }, + { + "schedule": " 0 0 1-5 * * * ", + "accepted": true + }, + { + "schedule": "0 1-5 * * *", + "accepted": true + }, + { + "schedule": " 0 0 1,3 * * * ", + "accepted": true + }, + { + "schedule": "0 1,3 * * *", + "accepted": true + }, + { + "schedule": " 0 0 23-2 * * * ", + "accepted": false + }, + { + "schedule": "0 23-2 * * *", + "accepted": false + }, + { + "schedule": " 0 0 H * * * ", + "accepted": false + }, + { + "schedule": "0 H * * *", + "accepted": false + }, + { + "schedule": " 0 0 L * * * ", + "accepted": false + }, + { + "schedule": "0 L * * *", + "accepted": false + }, + { + "schedule": " 0 0 0 ? * * ", + "accepted": true + }, + { + "schedule": "0 0 ? * *", + "accepted": true + }, + { + "schedule": " 0 0 0 /2 * * ", + "accepted": false + }, + { + "schedule": "0 0 /2 * *", + "accepted": false + }, + { + "schedule": " 0 0 0 */2 * * ", + "accepted": true + }, + { + "schedule": "0 0 */2 * *", + "accepted": true + }, + { + "schedule": " 0 0 0 1/2 * * ", + "accepted": true + }, + { + "schedule": "0 0 1/2 * *", + "accepted": true + }, + { + "schedule": " 0 0 0 1 * * ", + "accepted": true + }, + { + "schedule": "0 0 1 * *", + "accepted": true + }, + { + "schedule": " 0 0 0 01 * * ", + "accepted": true + }, + { + "schedule": "0 0 01 * *", + "accepted": true + }, + { + "schedule": " 0 0 0 1-5 * * ", + "accepted": true + }, + { + "schedule": "0 0 1-5 * *", + "accepted": true + }, + { + "schedule": " 0 0 0 1,3 * * ", + "accepted": true + }, + { + "schedule": "0 0 1,3 * *", + "accepted": true + }, + { + "schedule": " 0 0 0 L-1 * * ", + "accepted": false + }, + { + "schedule": "0 0 L-1 * *", + "accepted": false + }, + { + "schedule": " 0 0 0 LW * * ", + "accepted": false + }, + { + "schedule": "0 0 LW * *", + "accepted": false + }, + { + "schedule": " 0 0 0 15W * * ", + "accepted": false + }, + { + "schedule": "0 0 15W * *", + "accepted": false + }, + { + "schedule": " 0 0 0 H * * ", + "accepted": false + }, + { + "schedule": "0 0 H * *", + "accepted": false + }, + { + "schedule": " 0 0 0 * ? * ", + "accepted": false + }, + { + "schedule": "0 0 * ? *", + "accepted": false + }, + { + "schedule": " 0 0 0 * /2 * ", + "accepted": false + }, + { + "schedule": "0 0 * /2 *", + "accepted": false + }, + { + "schedule": " 0 0 0 * */2 * ", + "accepted": true + }, + { + "schedule": "0 0 * */2 *", + "accepted": true + }, + { + "schedule": " 0 0 0 * 1/2 * ", + "accepted": true + }, + { + "schedule": "0 0 * 1/2 *", + "accepted": true + }, + { + "schedule": " 0 0 0 * 1 * ", + "accepted": true + }, + { + "schedule": "0 0 * 1 *", + "accepted": true + }, + { + "schedule": " 0 0 0 * 01 * ", + "accepted": true + }, + { + "schedule": "0 0 * 01 *", + "accepted": true + }, + { + "schedule": " 0 0 0 * 1-5 * ", + "accepted": true + }, + { + "schedule": "0 0 * 1-5 *", + "accepted": true + }, + { + "schedule": " 0 0 0 * 1,3 * ", + "accepted": true + }, + { + "schedule": "0 0 * 1,3 *", + "accepted": true + }, + { + "schedule": " 0 0 0 * JAN * ", + "accepted": true + }, + { + "schedule": "0 0 * JAN *", + "accepted": true + }, + { + "schedule": " 0 0 0 * jan * ", + "accepted": true + }, + { + "schedule": "0 0 * jan *", + "accepted": true + }, + { + "schedule": " 0 0 0 * JAN-MAR * ", + "accepted": true + }, + { + "schedule": "0 0 * JAN-MAR *", + "accepted": true + }, + { + "schedule": " 0 0 0 * DEC-FEB * ", + "accepted": false + }, + { + "schedule": "0 0 * DEC-FEB *", + "accepted": false + }, + { + "schedule": " 0 0 0 * H * ", + "accepted": false + }, + { + "schedule": "0 0 * H *", + "accepted": false + }, + { + "schedule": " 0 0 0 * * ? ", + "accepted": true + }, + { + "schedule": "0 0 * * ?", + "accepted": true + }, + { + "schedule": " 0 0 0 * * /2 ", + "accepted": false + }, + { + "schedule": "0 0 * * /2", + "accepted": false + }, + { + "schedule": " 0 0 0 * * */2 ", + "accepted": true + }, + { + "schedule": "0 0 * * */2", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 0/2 ", + "accepted": true + }, + { + "schedule": "0 0 * * 0/2", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 0 ", + "accepted": true + }, + { + "schedule": "0 0 * * 0", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 7 ", + "accepted": true + }, + { + "schedule": "0 0 * * 7", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 01 ", + "accepted": true + }, + { + "schedule": "0 0 * * 01", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 1-5 ", + "accepted": true + }, + { + "schedule": "0 0 * * 1-5", + "accepted": true + }, + { + "schedule": " 0 0 0 * * 1,3 ", + "accepted": true + }, + { + "schedule": "0 0 * * 1,3", + "accepted": true + }, + { + "schedule": " 0 0 0 * * MON ", + "accepted": true + }, + { + "schedule": "0 0 * * MON", + "accepted": true + }, + { + "schedule": " 0 0 0 * * mon ", + "accepted": true + }, + { + "schedule": "0 0 * * mon", + "accepted": true + }, + { + "schedule": " 0 0 0 * * MON-FRI ", + "accepted": true + }, + { + "schedule": "0 0 * * MON-FRI", + "accepted": true + }, + { + "schedule": " 0 0 0 * * FRI-MON ", + "accepted": false + }, + { + "schedule": "0 0 * * FRI-MON", + "accepted": false + }, + { + "schedule": " 0 0 0 * * L ", + "accepted": false + }, + { + "schedule": "0 0 * * L", + "accepted": false + }, + { + "schedule": " 0 0 0 * * 1#5 ", + "accepted": true + }, + { + "schedule": "0 0 * * 1#5", + "accepted": true + }, + { + "schedule": " 0 0 0 * * H ", + "accepted": false + }, + { + "schedule": "0 0 * * H", + "accepted": false + } + ], + "times": [ + { + "schedule": "0 0 1 * MON", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-02T00:00:00.000Z", + "2026-02-09T00:00:00.000Z", + "2026-02-16T00:00:00.000Z", + "2026-02-23T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + "2026-03-02T00:00:00.000Z", + "2026-03-09T00:00:00.000Z", + "2026-03-16T00:00:00.000Z", + "2026-03-23T00:00:00.000Z", + "2026-03-30T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * MON", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-09-28T00:00:00.000Z", + "2026-10-01T00:00:00.000Z", + "2026-10-05T00:00:00.000Z", + "2026-10-12T00:00:00.000Z", + "2026-10-19T00:00:00.000Z", + "2026-10-26T00:00:00.000Z", + "2026-11-01T00:00:00.000Z", + "2026-11-02T00:00:00.000Z", + "2026-11-09T00:00:00.000Z", + "2026-11-16T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1,15 * MON", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-02T00:00:00.000Z", + "2026-02-09T00:00:00.000Z", + "2026-02-15T00:00:00.000Z", + "2026-02-16T00:00:00.000Z", + "2026-02-23T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + "2026-03-02T00:00:00.000Z", + "2026-03-09T00:00:00.000Z", + "2026-03-15T00:00:00.000Z", + "2026-03-16T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1,15 * MON", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-09-28T00:00:00.000Z", + "2026-10-01T00:00:00.000Z", + "2026-10-05T00:00:00.000Z", + "2026-10-12T00:00:00.000Z", + "2026-10-15T00:00:00.000Z", + "2026-10-19T00:00:00.000Z", + "2026-10-26T00:00:00.000Z", + "2026-11-01T00:00:00.000Z", + "2026-11-02T00:00:00.000Z", + "2026-11-09T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1-30 * MON", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-02T00:00:00.000Z", + "2026-02-09T00:00:00.000Z", + "2026-02-16T00:00:00.000Z", + "2026-02-23T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + "2026-03-02T00:00:00.000Z", + "2026-03-03T00:00:00.000Z", + "2026-03-04T00:00:00.000Z", + "2026-03-05T00:00:00.000Z", + "2026-03-06T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1-30 * MON", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-09-28T00:00:00.000Z", + "2026-10-01T00:00:00.000Z", + "2026-10-02T00:00:00.000Z", + "2026-10-03T00:00:00.000Z", + "2026-10-04T00:00:00.000Z", + "2026-10-05T00:00:00.000Z", + "2026-10-06T00:00:00.000Z", + "2026-10-07T00:00:00.000Z", + "2026-10-08T00:00:00.000Z", + "2026-10-09T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * 0-6", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-02T00:00:00.000Z", + "2026-02-03T00:00:00.000Z", + "2026-02-04T00:00:00.000Z", + "2026-02-05T00:00:00.000Z", + "2026-02-06T00:00:00.000Z", + "2026-02-07T00:00:00.000Z", + "2026-02-08T00:00:00.000Z", + "2026-02-09T00:00:00.000Z", + "2026-02-10T00:00:00.000Z", + "2026-02-11T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * 0-6", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-09-28T00:00:00.000Z", + "2026-09-29T00:00:00.000Z", + "2026-09-30T00:00:00.000Z", + "2026-10-01T00:00:00.000Z", + "2026-10-02T00:00:00.000Z", + "2026-10-03T00:00:00.000Z", + "2026-10-04T00:00:00.000Z", + "2026-10-05T00:00:00.000Z", + "2026-10-06T00:00:00.000Z", + "2026-10-07T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * MON#2", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-09T00:00:00.000Z", + "2026-03-09T00:00:00.000Z", + "2026-04-13T00:00:00.000Z", + "2026-05-11T00:00:00.000Z", + "2026-06-08T00:00:00.000Z", + "2026-07-13T00:00:00.000Z", + "2026-08-10T00:00:00.000Z", + "2026-09-14T00:00:00.000Z", + "2026-10-12T00:00:00.000Z", + "2026-11-09T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * MON#2", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-10-12T00:00:00.000Z", + "2026-11-09T00:00:00.000Z", + "2026-12-14T00:00:00.000Z", + "2027-01-11T00:00:00.000Z", + "2027-02-08T00:00:00.000Z", + "2027-03-08T00:00:00.000Z", + "2027-04-12T00:00:00.000Z", + "2027-05-10T00:00:00.000Z", + "2027-06-14T00:00:00.000Z", + "2027-07-12T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * 5L", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-27T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + "2026-03-27T00:00:00.000Z", + "2026-04-01T00:00:00.000Z", + "2026-04-24T00:00:00.000Z", + "2026-05-01T00:00:00.000Z", + "2026-05-29T00:00:00.000Z", + "2026-06-01T00:00:00.000Z", + "2026-06-26T00:00:00.000Z", + "2026-07-01T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 1 * 5L", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-10-01T00:00:00.000Z", + "2026-10-30T00:00:00.000Z", + "2026-11-01T00:00:00.000Z", + "2026-11-27T00:00:00.000Z", + "2026-12-01T00:00:00.000Z", + "2026-12-25T00:00:00.000Z", + "2027-01-01T00:00:00.000Z", + "2027-01-29T00:00:00.000Z", + "2027-02-01T00:00:00.000Z", + "2027-02-26T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 0 1,15,L * 5L", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-15T00:00:00.000Z", + "2026-02-27T00:00:00.000Z", + "2026-02-28T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + "2026-03-15T00:00:00.000Z", + "2026-03-27T00:00:00.000Z", + "2026-03-31T00:00:00.000Z", + "2026-04-01T00:00:00.000Z", + "2026-04-15T00:00:00.000Z", + "2026-04-24T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 0 1,15,L * 5L", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-09-30T00:00:00.000Z", + "2026-10-01T00:00:00.000Z", + "2026-10-15T00:00:00.000Z", + "2026-10-30T00:00:00.000Z", + "2026-10-31T00:00:00.000Z", + "2026-11-01T00:00:00.000Z", + "2026-11-15T00:00:00.000Z", + "2026-11-27T00:00:00.000Z", + "2026-11-30T00:00:00.000Z", + "2026-12-01T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 0 * * 7L", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2026-02-22T00:00:00.000Z", + "2026-03-29T00:00:00.000Z", + "2026-04-26T00:00:00.000Z", + "2026-05-31T00:00:00.000Z", + "2026-06-28T00:00:00.000Z", + "2026-07-26T00:00:00.000Z", + "2026-08-30T00:00:00.000Z", + "2026-09-27T00:00:00.000Z", + "2026-10-25T00:00:00.000Z", + "2026-11-29T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 0 * * 7L", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2026-10-25T00:00:00.000Z", + "2026-11-29T00:00:00.000Z", + "2026-12-27T00:00:00.000Z", + "2027-01-31T00:00:00.000Z", + "2027-02-28T00:00:00.000Z", + "2027-03-28T00:00:00.000Z", + "2027-04-25T00:00:00.000Z", + "2027-05-30T00:00:00.000Z", + "2027-06-27T00:00:00.000Z", + "2027-07-25T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 29 2 *", + "currentDate": "2026-02-01T00:00:00Z", + "next": [ + "2028-02-29T00:00:00.000Z", + "2032-02-29T00:00:00.000Z", + "2036-02-29T00:00:00.000Z", + "2040-02-29T00:00:00.000Z", + "2044-02-29T00:00:00.000Z", + "2048-02-29T00:00:00.000Z", + "2052-02-29T00:00:00.000Z", + "2056-02-29T00:00:00.000Z", + "2060-02-29T00:00:00.000Z", + "2064-02-29T00:00:00.000Z" + ] + }, + { + "schedule": "0 0 29 2 *", + "currentDate": "2026-09-27T00:00:00Z", + "next": [ + "2028-02-29T00:00:00.000Z", + "2032-02-29T00:00:00.000Z", + "2036-02-29T00:00:00.000Z", + "2040-02-29T00:00:00.000Z", + "2044-02-29T00:00:00.000Z", + "2048-02-29T00:00:00.000Z", + "2052-02-29T00:00:00.000Z", + "2056-02-29T00:00:00.000Z", + "2060-02-29T00:00:00.000Z", + "2064-02-29T00:00:00.000Z" + ] + } + ] +} diff --git a/test/front/cron-log-pagination.test.cjs b/test/front/cron-log-pagination.test.cjs new file mode 100644 index 00000000..5b1e78e8 --- /dev/null +++ b/test/front/cron-log-pagination.test.cjs @@ -0,0 +1,214 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { readLogChunk } = require('../../back/shared/logReader'); + +const source = fs.readFileSync( + path.join(__dirname, '../../src/pages/crontab/logModal.tsx'), + 'utf8', +); +const ast = ts.createSourceFile( + 'logModal.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const declarations = new Map(); +function visit(node) { + if ( + ts.isVariableDeclaration(node) && + ['getCronLog', 'cancel'].includes(node.name.getText(ast)) + ) + declarations.set(node.name.getText(ast), node.initializer.getText(ast)); + ts.forEachChild(node, visit); +} +visit(ast); + +function fixture(get) { + const timers = new Map(); + const storage = new Map([['logCron', '1']]); + const values = { content: '', executing: true }; + const pending = []; + let timerId = 0; + const context = { + logUrl: undefined, + config: { apiPrefix: '/api/' }, + cron: { id: 1 }, + uniqPath: '1', + LOG_CHUNK_BYTES: 256 * 1024, + MAX_LOG_VIEW_CHARS: 1024 * 1024, + logOffsetRef: { current: undefined }, + valueRef: { current: '' }, + pollTimerRef: {}, + request: { + get(url) { + const result = get(url); + pending.push(result); + return result; + }, + }, + localStorage: { + getItem: (key) => storage.get(key), + removeItem: (key) => storage.delete(key), + }, + intl: { get: (value) => value }, + setLoading() {}, + autoScroll() {}, + handleCancel() {}, + setValue(value) { + values.content = value; + }, + setExecuting(value) { + values.executing = value; + }, + setTimeout(fn, delay) { + timers.set(++timerId, { fn, delay }); + return timerId; + }, + clearTimeout(id) { + timers.delete(id); + }, + }; + vm.createContext(context); + for (const [name, initializer] of declarations) + vm.runInContext( + ts.transpile(`var ${name} = ${initializer}`, { + target: ts.ScriptTarget.ES2020, + }), + context, + ); + async function settle() { + await Promise.all(pending.splice(0)); + await new Promise((resolve) => setImmediate(resolve)); + } + return { + values, + timers, + start() { + context.getCronLog(true); + }, + settle, + async read() { + context.getCronLog(true); + await settle(); + }, + async tick() { + const [id, timer] = timers.entries().next().value; + timers.delete(id); + timer.fn(); + await settle(); + }, + close() { + context.cancel(); + }, + }; +} + +test('completed tasks drain unread chunks and display the final exit marker', async (t) => { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ql-log-pagination-')); + t.after(() => fsp.rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'task.log'); + await fsp.writeFile(file, 'started\n'); + let running = true; + const offsets = []; + const view = fixture(async (url) => { + const query = new URL(url, 'http://localhost').searchParams; + const offset = query.has('offset') + ? Number(query.get('offset')) + : undefined; + offsets.push(offset); + const chunk = await readLogChunk(file, { + offset, + tail: query.get('tail') === 'true', + limit: Number(query.get('limit')), + }); + return { + code: 200, + data: chunk.content, + logStatus: running ? 'running' : 'completed', + ...chunk, + }; + }); + await view.read(); + assert.equal(view.timers.values().next().value.delay, 2000); + await fsp.appendFile(file, 'x'.repeat(600 * 1024) + '\nFINAL_EXIT_CODE=1\n'); + running = false; + await view.tick(); + assert.equal(view.values.executing, false); + assert.equal(view.timers.values().next().value.delay, 0); + await view.tick(); + await view.tick(); + assert.ok(view.values.content.endsWith('FINAL_EXIT_CODE=1\n')); + assert.equal(view.timers.size, 0); + assert.deepEqual(offsets, [undefined, 8, 8 + 256 * 1024, 8 + 512 * 1024]); +}); + +test('completed tail does not refetch discarded historical data', async () => { + const view = fixture(async () => ({ + code: 200, + data: 'tail', + logStatus: 'completed', + nextOffset: 900000, + total: 900000, + truncated: true, + })); + await view.read(); + assert.equal(view.values.content, 'tail'); + assert.equal(view.timers.size, 0); +}); + +test('running tasks poll without new bytes and closing cancels polling', async () => { + const view = fixture(async () => ({ + code: 200, + data: '', + logStatus: 'running', + nextOffset: 0, + total: 0, + })); + await view.read(); + assert.equal(view.values.executing, true); + assert.equal(view.timers.values().next().value.delay, 2000); + view.close(); + assert.equal(view.timers.size, 0); +}); + +test('closing ignores an in-flight response with unread data', async () => { + let resolve; + const view = fixture( + () => + new Promise((done) => { + resolve = done; + }), + ); + view.start(); + view.close(); + resolve({ + code: 200, + data: 'late', + logStatus: 'completed', + nextOffset: 4, + total: 100, + }); + await view.settle(); + assert.equal(view.values.content, ''); + assert.equal(view.timers.size, 0); +}); + +test('legacy and non-advancing completed responses do not loop', async () => { + for (const pagination of [{}, { nextOffset: 0, total: 100 }]) { + const view = fixture(async () => ({ + code: 200, + data: '', + logStatus: 'completed', + ...pagination, + })); + await view.read(); + assert.equal(view.timers.size, 0); + } +}); diff --git a/test/front/diff-file-routing.test.cjs b/test/front/diff-file-routing.test.cjs new file mode 100644 index 00000000..bbb69ef9 --- /dev/null +++ b/test/front/diff-file-routing.test.cjs @@ -0,0 +1,126 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); + +const source = fs.readFileSync( + path.join(__dirname, '../../src/pages/diff/index.tsx'), + 'utf8', +); +const ast = ts.createSourceFile( + 'diff.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +const callbacks = new Map(); +function visit(node) { + if ( + ts.isVariableDeclaration(node) && + ['getConfig', 'updateConfig', 'getSample'].includes(node.name.getText(ast)) + ) { + callbacks.set(node.name.getText(ast), node.initializer.getText(ast)); + } + ts.forEachChild(node, visit); +} +visit(ast); + +function fixture(current, editorContent) { + const calls = []; + const values = {}; + const context = { + current, + origin: 'sample/notify.js', + currentValue: '', + editorRef: { + current: + editorContent === undefined + ? null + : { + getModel: () => ({ modified: { getValue: () => editorContent } }), + }, + }, + config: { apiPrefix: '/panel/api/' }, + request: { + get: async (url) => { + calls.push({ method: 'GET', url }); + return { code: 200, data: 'file content' }; + }, + post: async (url, body) => { + calls.push({ method: 'POST', url, body }); + return { code: 200 }; + }, + }, + setCurrentValue: (value) => { + values.current = value; + }, + setOriginValue: (value) => { + values.origin = value; + }, + message: { + success: (value) => { + values.message = value; + }, + }, + intl: { get: (value) => value }, + }; + vm.createContext(context); + for (const [name, initializer] of callbacks) { + vm.runInContext( + ts.transpile(`var ${name} = ${initializer}`, { + target: ts.ScriptTarget.ES2020, + }), + context, + ); + } + return { context, calls, values }; +} + +for (const [target, filename] of [ + ['config.sh', null], + ['data/scripts/sendNotify.js', 'sendNotify.js'], + ['data/scripts/notify.py', 'notify.py'], + ['data/scripts/子目录/a b.js', '子目录/a b.js'], +]) { + test(`diff tool reads and saves ${target} through its resource API`, async () => { + const { context, calls, values } = fixture(target, 'updated\r\n'); + context.getConfig(); + context.updateConfig(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + calls[0].url, + filename + ? `/panel/api/scripts/detail?file=${encodeURIComponent(filename)}` + : '/panel/api/configs/detail?path=config.sh', + ); + assert.equal( + calls[1].url, + filename ? '/panel/api/scripts' : '/panel/api/configs/save', + ); + assert.equal( + calls[1].body[filename ? 'filename' : 'name'], + filename || target, + ); + assert.equal(calls[1].body.content, 'updated\n'); + assert.equal(values.current, 'file content'); + assert.equal(values.message, '保存成功'); + }); +} + +test('mobile diff tool can save an empty notification script and load its sample', async () => { + const { context, calls, values } = fixture('data/scripts/sendNotify.js'); + context.updateConfig(); + context.getSample(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls[0].url, '/panel/api/scripts'); + assert.equal(calls[0].body.filename, 'sendNotify.js'); + assert.equal(calls[0].body.content, ''); + assert.equal( + calls[1].url, + '/panel/api/configs/detail?path=sample%2Fnotify.js', + ); + assert.equal(values.origin, 'file content'); +}); diff --git a/test/front/http-auth.test.cjs b/test/front/http-auth.test.cjs new file mode 100644 index 00000000..caaaf6c5 --- /dev/null +++ b/test/front/http-auth.test.cjs @@ -0,0 +1,152 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const axios = require('axios'); + +function fixture(pathname = '/login', baseUrl = '/') { + const storage = new Map([['token', 'expired-token']]); + const pushes = []; + const history = { location: { pathname }, push: (p) => pushes.push(p) }; + const config = { authKey: 'token', baseUrl }; + const mocks = { + axios, + 'react-intl-universal': { get: (s) => s }, + antd: { + message: { config() {}, error() {} }, + notification: { error() {} }, + }, + './config': config, + '@umijs/max': { history }, + './httpError': { getErrorDetails: () => [] }, + }; + const source = fs.readFileSync( + path.join(__dirname, '../../src/utils/http.tsx'), + 'utf8', + ); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + }, + }); + const module = { exports: {} }; + new Function('require', 'module', 'exports', 'localStorage', outputText)( + (name) => { + assert.ok(Object.hasOwn(mocks, name), name); + return mocks[name]; + }, + module, + module.exports, + { + getItem: (k) => storage.get(k) ?? null, + removeItem: (k) => storage.delete(k), + }, + ); + return { request: module.exports.request, storage, pushes }; +} + +for (const pathname of ['/login', '/dashboard']) { + test(`401 clears expired credentials on ${pathname}`, async () => { + const { request, storage, pushes } = fixture(pathname); + await assert.rejects( + request.get('/api/user', { + adapter: async (config) => { + throw new axios.AxiosError( + 'Unauthorized', + 'ERR_BAD_REQUEST', + config, + null, + { status: 401, data: { message: 'expired' }, config }, + ); + }, + }), + ); + assert.equal(storage.has('token'), false); + assert.deepEqual(pushes, pathname === '/login' ? [] : ['/login']); + }); +} +for (const baseUrl of ['/', '/panel/']) { + test(`health is anonymous while protected requests keep credentials (${baseUrl})`, async () => { + const { request } = fixture('/login', baseUrl); + const headers = []; + const adapter = async (config) => { + headers.push(config.headers.get('Authorization')); + return { status: 200, data: { code: 200 }, config }; + }; + await request.get(`${baseUrl}api/health`, { adapter }); + await request.get(`${baseUrl}api/user`, { adapter }); + assert.deepEqual(headers, [undefined, 'Bearer expired-token']); + }); +} + +function healthFixture(get) { + const source = fs.readFileSync( + path.join(__dirname, '../../src/layouts/index.tsx'), + 'utf8', + ); + const ast = ts.createSourceFile( + 'index.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + let initializer; + function visit(node) { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(ast) === 'getHealthStatus' + ) + initializer = node.initializer; + ts.forEachChild(node, visit); + } + visit(ast); + assert.ok(initializer, 'layout health bootstrap exists'); + const { outputText } = ts.transpileModule( + `const bootstrap = ${initializer.getText(ast)};`, + { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }, + ); + const events = []; + const bootstrap = new Function( + 'request', + 'config', + 'history', + 'window', + 'getSystemInfo', + 'setInitLoading', + `${outputText}; return bootstrap;`, + )( + { get }, + { apiPrefix: '/api/' }, + { push: (p) => events.push(p) }, + { location: { reload: () => events.push('reload') } }, + () => events.push('system'), + (value) => events.push(['loading', value]), + ); + return { bootstrap, events }; +} +for (const status of [401, 503, undefined]) { + test(`health failure ${ + status ?? 'network' + } settles without reloading`, async () => { + const { bootstrap, events } = healthFixture(async () => { + throw status ? { response: { status } } : new Error('Network Error'); + }); + bootstrap(); + await new Promise(setImmediate); + assert.deepEqual(events, ['/error', ['loading', false]]); + }); +} +test('healthy bootstrap continues loading system information', async () => { + const { bootstrap, events } = healthFixture(async () => ({ + data: { status: 'ok' }, + })); + bootstrap(); + await new Promise(setImmediate); + assert.deepEqual(events, ['system', ['loading', false]]); +}); diff --git a/test/front/http-error.test.cjs b/test/front/http-error.test.cjs new file mode 100644 index 00000000..32cbd332 --- /dev/null +++ b/test/front/http-error.test.cjs @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { getErrorDetails } = require('../../src/utils/httpError'); + +test('validation errors include the failing field names', () => { + const details = getErrorDetails({ + message: 'Validation failed', + validation: { + body: { + source: 'body', + keys: ['labels', 'allow_multiple_instances'], + message: 'request body contains invalid values', + }, + }, + }); + + assert.deepEqual(details, [ + 'labels: request body contains invalid values', + 'allow_multiple_instances: request body contains invalid values', + ]); +}); + +test('existing API error details remain visible', () => { + assert.deepEqual( + getErrorDetails({ errors: [{ message: 'duplicate value', value: 'foo' }] }), + ['duplicate value (foo)'], + ); +}); diff --git a/test/front/script-history-save.test.cjs b/test/front/script-history-save.test.cjs new file mode 100644 index 00000000..1a9a5761 --- /dev/null +++ b/test/front/script-history-save.test.cjs @@ -0,0 +1,121 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const path = require('node:path'); +const load = require('../helpers/load-security-module.cjs'); + +function fixture(send) { + const dialogs = [], + notices = [], + calls = []; + const request = {}; + for (const method of ['put', 'post']) + request[method] = async (url, body, options) => { + calls.push({ method, body }); + const result = await send(body); + if (result.status) return options.onError(result); + return result; + }; + const { saveWithHistory } = load( + path.resolve(__dirname, '../../src/pages/script/saveWithHistory.ts'), + { + '@/utils/http': { request }, + antd: { + Modal: { + confirm: (dialog) => { + dialogs.push(dialog); + return { update: (options) => Object.assign(dialog, options) }; + }, + }, + message: Object.fromEntries( + ['error', 'warning', 'success'].map((type) => [ + type, + (text) => notices.push({ type, text }), + ]), + ), + }, + 'react-intl-universal': { + __esModule: true, + default: { get: (key) => key }, + }, + }, + ); + return { saveWithHistory, dialogs, notices, calls }; +} +const unavailable = { + status: 413, + data: { historyUnavailable: true, currentHash: 'a'.repeat(64) }, +}; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test('oversized editor save requires explicit confirmation and cancellation keeps the draft unsaved', async () => { + const f = fixture(() => unavailable); + const pending = f.saveWithHistory('put', '/api/scripts', { + filename: 'large.js', + content: 'small', + }); + await tick(); + assert.equal(f.calls.length, 1); + assert.equal(f.dialogs.length, 1); + assert.match(f.dialogs[0].content, /不会保留/); + f.dialogs[0].onCancel(); + assert.equal(await pending, undefined); + assert.equal(f.calls.length, 1); + assert.equal(f.notices.length, 0); +}); + +test('debugger confirmation retries POST with the exact preview hash and displays a warning', async () => { + const f = fixture((body) => + body.skipHistory + ? { code: 200, data: { historyRecorded: false } } + : unavailable, + ); + const pending = f.saveWithHistory('post', '/api/scripts', { + content: 'small', + }); + await tick(); + await f.dialogs[0].onOk(); + assert.equal((await pending).code, 200); + assert.equal(f.dialogs[0].cancelButtonProps.disabled, true); + assert.equal(f.dialogs[0].keyboard, false); + assert.equal(f.calls[1].method, 'post'); + assert.equal(f.calls[1].body.expectedHash, unavailable.data.currentHash); + assert.equal(f.calls[1].body.skipHistory, true); + assert.deepEqual( + f.notices.map((v) => v.type), + ['warning'], + ); +}); + +test('a stale confirmation reports the conflict without marking the editor saved', async () => { + const f = fixture((body) => + body.skipHistory + ? { status: 409, data: { message: 'changed' } } + : unavailable, + ); + const pending = f.saveWithHistory('put', '/api/scripts', {}); + await tick(); + await f.dialogs[0].onOk(); + assert.equal(await pending, undefined); + assert.deepEqual(f.notices, [{ type: 'error', text: 'changed' }]); +}); + +test('unrelated errors never offer history bypass, and post-commit cleanup failures remain successful saves', async () => { + const failed = fixture(() => ({ + status: 500, + data: { message: 'storage failed' }, + })); + assert.equal( + await failed.saveWithHistory('put', '/api/scripts', {}), + undefined, + ); + assert.equal(failed.dialogs.length, 0); + const committed = fixture(() => ({ + code: 200, + data: { cleanupPending: true }, + })); + assert.equal( + (await committed.saveWithHistory('put', '/api/scripts', {})).code, + 200, + ); + assert.equal(committed.notices[0].type, 'warning'); +}); diff --git a/test/helpers/load-security-module.cjs b/test/helpers/load-security-module.cjs new file mode 100644 index 00000000..fd286ab6 --- /dev/null +++ b/test/helpers/load-security-module.cjs @@ -0,0 +1,42 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const ts = require('typescript'); + +module.exports = function loadSecurityModule( + file, + mocks = {}, + cache = new Map(), +) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const module = { exports: {} }; + cache.set(file, module); + const localRequire = createRequire(file); + const requireModule = (name) => { + if (Object.hasOwn(mocks, name)) return mocks[name]; + if (name.startsWith('.')) { + const target = path.resolve(path.dirname(file), name); + if (fs.existsSync(`${target}.ts`)) { + return loadSecurityModule(`${target}.ts`, mocks, cache); + } + } + return localRequire(name); + }; + const { outputText } = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + experimentalDecorators: true, + esModuleInterop: true, + }, + }); + // Use the host realm: express-unless checks RegExp with instanceof. + new Function('require', 'module', 'exports', '__dirname', outputText)( + requireModule, + module, + module.exports, + path.dirname(file), + ); + return module.exports; +}; diff --git a/test/helpers/upgrade-fixture.cjs b/test/helpers/upgrade-fixture.cjs new file mode 100644 index 00000000..251174fd --- /dev/null +++ b/test/helpers/upgrade-fixture.cjs @@ -0,0 +1,30 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { collectBuildFiles } = require('../../docker/build-manifest.cjs'); +const sourceCommit = 'a'.repeat(40); + +function writeManifest(source, staticRoot) { + const lockfile = path.join(source, 'pnpm-lock.yaml'); + if (!fs.existsSync(lockfile)) fs.writeFileSync(lockfile, 'fixture lock\n'); + fs.mkdirSync(path.join(staticRoot, 'dist'), { recursive: true }); + if (!fs.existsSync(path.join(staticRoot, 'dist/index.html'))) + fs.writeFileSync( + path.join(staticRoot, 'dist/index.html'), + '
', + ); + fs.writeFileSync( + path.join(staticRoot, 'build-info.json'), + JSON.stringify({ + version: 1, + dirty: false, + sourceCommit, + lockfileSha256: createHash('sha256') + .update(fs.readFileSync(lockfile)) + .digest('hex'), + files: collectBuildFiles(staticRoot), + }), + ); +} + +module.exports = { writeManifest, sourceCommit }; diff --git a/version.yaml b/version.yaml index 65648012..3f69801a 100644 --- a/version.yaml +++ b/version.yaml @@ -1,6 +1,15 @@ -version: 2.20.2 -changeLogLink: https://t.me/jiao_long/434 -publishTime: 2026-03-01 1800 +version: 2.23.0 +changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.23.0.md +publishTime: "2026-10-11 12:27:02" changeLog: | - 1. 修复 path 安全漏洞(重要) - \ No newline at end of file + 1. 新增脚本历史版本查看、对比与恢复,支持 CLI 和 OpenAPI;恢复前检查当前内容哈希,避免覆盖并发修改 + 2. 普通任务与订阅调度迁移到 node-cron,保留旧规则兼容及日期/星期 OR 语义,补充宏和夏令时说明 + 3. 修复已失效排队任务仍被执行的问题,完善任务进程超时、停止和 Shell 环境传递 + 4. 修复系统令牌主动续期与认证缓存并发更新;Shell API 遇到 401 时刷新令牌并重试一次 + 5. 降低任务启动开销,JS/Python 通知依赖改为首次调用时加载 + 6. 加固环境变量、订阅参数、文件访问和 SSH 配置输入,改善资源冲突与 CLI 错误反馈 + 7. 修复页面升级、ql update 与静态资源版本同步,完善离线备份和升级产物校验 + 8. 修复 Debian 依赖安装、通知与调度回归,补齐前端中英文翻译 + 9. 最小修补 HTTP 压缩、上传、gRPC、代理 IP、WebSocket 和 JSON 查询相关依赖漏洞 + 10. 远程 CLI 更新至 0.1.2,新增脚本历史命令,最低 Node.js 版本仍为 22.12 + 11. 本次为 Python 3.10 兼容镜像的最后一个版本,发布后停止维护;升级前备份完整数据并验证脚本、依赖和通知