diff --git a/.env.example b/.env.example index 28b3a5a8..1db7e42f 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,8 @@ DB_DEBUG=false # ----------------------------------------------------------------------------- # Redis # ----------------------------------------------------------------------------- +# Local development default; a deployment points REDIS_URL at its own Redis (rediss:// for TLS). +# nosemgrep: trailofbits.generic.redis-unencrypted-transport.redis-unencrypted-transport REDIS_URL=redis://localhost:6379 # Redis authentication (REQUIRED for production) @@ -94,10 +96,11 @@ REDIS_TLS_REJECT_UNAUTHORIZED=true # ----------------------------------------------------------------------------- # Tracker Ports # ----------------------------------------------------------------------------- -TRACKER_PORT=8000 TRACKER_HTTP_PORT=8080 -TRACKER_UDP_PORT=8081 -TRACKER_WS_PORT=8082 +# UDP (BEP 15) is off unless enabled. The production compose file serves it on +# 6969/udp and hands the same flag to the API, which only then writes the UDP +# tier into `.torrent` files. +TRACKER_UDP_ENABLED=false # Debug mode for verbose tracker logging (logs raw announce params) TRACKER_DEBUG=false @@ -107,8 +110,8 @@ TRACKER_DEBUG=false # image can be reused by anyone with their own domain (no rebuild needed). # ⚠️ PRODUCTION: Configure these with your actual domain NUXT_PUBLIC_TRACKER_HTTP_URL=https://tracker.your-domain.com/announce -NUXT_PUBLIC_TRACKER_UDP_URL=udp://tracker.your-domain.com:8081/announce -NUXT_PUBLIC_TRACKER_WS_URL=wss://tracker.your-domain.com/ws +# Only used when TRACKER_UDP_ENABLED=true. +NUXT_PUBLIC_TRACKER_UDP_URL=udp://tracker.your-domain.com:6969/announce # ----------------------------------------------------------------------------- # Security diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index f2371167..761ecc37 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -64,17 +64,22 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - # Matches `node:24-alpine` in apps/api/Dockerfile, so CI checks the + # Matches `node:26-alpine` in apps/api/Dockerfile, so CI checks the # runtime we actually ship. - node-version: 24 + node-version: 26 - # corepack rather than a third-party setup action: the pinned pnpm - # version already lives in package.json's `packageManager` field, and the - # Dockerfiles activate it exactly this way. - - name: Enable corepack - run: corepack enable + # Still no third-party setup action, but no corepack either: Node 25 + # stopped shipping it. The version is read from package.json's + # `packageManager` field, which stays the one place it is pinned — and + # pnpm 12 checks that pin against pnpm-lock.yaml on a frozen install. + # That pin is the lock zizmor's `adhoc-packages` cannot see, hence its + # ignore on each of these steps. + # `--allow-scripts`: pnpm's install script only hard-links its native + # binary, and npm is moving to blocking scripts it was not told about. + - name: Install pnpm + run: npm install --global --allow-scripts=pnpm "$(node -p 'require("./package.json").packageManager')" # zizmor: ignore[adhoc-packages] - name: Install dependencies run: pnpm install --frozen-lockfile @@ -105,7 +110,8 @@ jobs: POSTGRES_USER: parity POSTGRES_DB: postgres ports: - - 5432:5432 + # Service containers of a throwaway runner. + - 5432:5432 # nosemgrep: trailofbits.yaml.docker-compose.port-all-interfaces.port-all-interfaces options: >- --health-cmd "pg_isready -U parity" --health-interval 5s @@ -117,12 +123,12 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version: 26 - - name: Enable corepack - run: corepack enable + - name: Install pnpm + run: npm install --global --allow-scripts=pnpm "$(node -p 'require("./package.json").packageManager')" # zizmor: ignore[adhoc-packages] - name: Install dependencies run: pnpm install --frozen-lockfile @@ -158,16 +164,17 @@ jobs: POSTGRES_PASSWORD: tracker POSTGRES_DB: trackarr ports: - - 5432:5432 + # Service containers of a throwaway runner. + - 5432:5432 # nosemgrep: trailofbits.yaml.docker-compose.port-all-interfaces.port-all-interfaces options: >- --health-cmd "pg_isready -U tracker -d trackarr" --health-interval 5s --health-timeout 5s --health-retries 20 redis: - image: redis:8.10.1-alpine + image: redis:8.10.2-alpine ports: - - 6379:6379 + - 6379:6379 # nosemgrep: trailofbits.yaml.docker-compose.port-all-interfaces.port-all-interfaces options: >- --health-cmd "redis-cli ping" --health-interval 5s @@ -177,26 +184,26 @@ jobs: # Pinned rather than `:latest`. The suite it serves asserts byte-exact # SigV4 behaviour, so a floating tag is a test that can start failing # for a reason that is not in this repository. - image: rustfs/rustfs:1.0.0-rc.3 + image: rustfs/rustfs:1.0.3 env: RUSTFS_ACCESS_KEY: trackarritest RUSTFS_SECRET_KEY: trackarritestsecret RUSTFS_VOLUMES: /data RUSTFS_ADDRESS: ':9000' ports: - - 9000:9000 + - 9000:9000 # nosemgrep: trailofbits.yaml.docker-compose.port-all-interfaces.port-all-interfaces steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version: 26 - - name: Enable corepack - run: corepack enable + - name: Install pnpm + run: npm install --global --allow-scripts=pnpm "$(node -p 'require("./package.json").packageManager')" # zizmor: ignore[adhoc-packages] - name: Install dependencies run: pnpm install --frozen-lockfile @@ -243,7 +250,8 @@ jobs: env: DATABASE_URL: postgres://tracker:tracker@localhost:5432/trackarr DB_SSL: 'false' - REDIS_URL: redis://localhost:6379 + # The job's own Redis service container. + REDIS_URL: redis://localhost:6379 # nosemgrep: trailofbits.generic.redis-unencrypted-transport.redis-unencrypted-transport # Explicitly empty. A service container runs `redis-server` with no # `--requirepass`, and the integration vitest config defaults this to # a fake password when it is UNSET — which would make the client send @@ -307,12 +315,12 @@ jobs: persist-credentials: false - name: Set up Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # Single-arch and loaded into the local daemon: the check has to `docker # run` it, which a `--push`-only multi-arch build cannot do. - name: Build the API image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: apps/api/Dockerfile diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index dcde7725..a5e7ed7e 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -45,15 +45,15 @@ jobs: persist-credentials: false - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - # Node 24, comme `doc/Dockerfile` et comme les autres workflows. + # Node 26, comme `doc/Dockerfile` et comme les autres workflows. # Ce travail était resté sur 20, en fin de vie depuis avril 2026 : # la même documentation était donc construite sur deux runtimes, # et le déploiement — qui a `pages: write` — tournait sur celui # qui ne reçoit plus de correctif. Vérifié : `npm ci` puis - # `npm run build` passent en 24 (vitepress 1.6.4 demande >= 20). - node-version: 24 + # `npm run build` passent en 26 (vitepress 1.6.4 demande >= 20). + node-version: 26 cache: 'npm' cache-dependency-path: './doc/package-lock.json' @@ -86,4 +86,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5624dc76..dde3181b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -112,11 +112,11 @@ jobs: # que sur `release`, `workflow_call` et `workflow_dispatch`, jamais sur une # pull request. `web-ci.yml` et `api-ci.yml` montent bien buildx de leur # côté, ce qui rendait leurs propres builds verts et masquait le manque. - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push api image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./ file: ./apps/api/Dockerfile @@ -192,11 +192,11 @@ jobs: # que sur `release`, `workflow_call` et `workflow_dispatch`, jamais sur une # pull request. `web-ci.yml` et `api-ci.yml` montent bien buildx de leur # côté, ce qui rendait leurs propres builds verts et masquait le manque. - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push tracker image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./ file: ./apps/tracker/Dockerfile @@ -278,11 +278,11 @@ jobs: # que sur `release`, `workflow_call` et `workflow_dispatch`, jamais sur une # pull request. `web-ci.yml` et `api-ci.yml` montent bien buildx de leur # côté, ce qui rendait leurs propres builds verts et masquait le manque. - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push relay image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./ file: ./apps/relay/Dockerfile @@ -358,11 +358,11 @@ jobs: # que sur `release`, `workflow_call` et `workflow_dispatch`, jamais sur une # pull request. `web-ci.yml` et `api-ci.yml` montent bien buildx de leur # côté, ce qui rendait leurs propres builds verts et masquait le manque. - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push front-ssr image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./ file: ./apps/web/Dockerfile @@ -437,11 +437,11 @@ jobs: # que sur `release`, `workflow_call` et `workflow_dispatch`, jamais sur une # pull request. `web-ci.yml` et `api-ci.yml` montent bien buildx de leur # côté, ce qui rendait leurs propres builds verts et masquait le manque. - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push front image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: ./ file: ./apps/web/Dockerfile.static @@ -520,11 +520,11 @@ jobs: # que sur `release`, `workflow_call` et `workflow_dispatch`, jamais sur une # pull request. `web-ci.yml` et `api-ci.yml` montent bien buildx de leur # côté, ce qui rendait leurs propres builds verts et masquait le manque. - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push docs image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: # The doc bundle is a self-contained npm project; scope # the build context to ./doc so the COPY in Dockerfile diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index 02d215df..4d764f5b 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -43,15 +43,15 @@ jobs: persist-credentials: false - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - # Node 24, comme `doc/Dockerfile` et comme les autres workflows. + # Node 26, comme `doc/Dockerfile` et comme les autres workflows. # Ce travail était resté sur 20, en fin de vie depuis avril 2026 : # la même documentation était donc construite sur deux runtimes, # et le déploiement — qui a `pages: write` — tournait sur celui # qui ne reçoit plus de correctif. Vérifié : `npm ci` puis - # `npm run build` passent en 24 (vitepress 1.6.4 demande >= 20). - node-version: 24 + # `npm run build` passent en 26 (vitepress 1.6.4 demande >= 20). + node-version: 26 cache: 'npm' cache-dependency-path: './doc/package-lock.json' diff --git a/.github/workflows/relay-ci.yml b/.github/workflows/relay-ci.yml index 6d89d930..b86c8f32 100644 --- a/.github/workflows/relay-ci.yml +++ b/.github/workflows/relay-ci.yml @@ -59,12 +59,12 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: # The minor, not `go-version-file`. The `go` directive in go.mod is a - # minimum language version (`go 1.26.0`) and setup-go treats a full - # x.y.z as an exact pin — which on the tracker built CI on go1.26.0 - # and failed govulncheck on 19 stdlib vulnerabilities fixed in - # 1.26.1. Tracking the minor matches `golang:1.26-alpine` in the + # minimum language version (`go 1.27.0`) and setup-go treats a full + # x.y.z as an exact pin — which on the tracker once built CI on + # go1.26.0 and failed govulncheck on 19 stdlib vulnerabilities fixed + # in 1.26.1. Tracking the minor matches `golang:1.27-alpine` in the # Dockerfile. Bump alongside the Dockerfile and the go.mod directive. - go-version: '1.26' + go-version: '1.27' check-latest: true cache-dependency-path: apps/relay/go.sum @@ -113,7 +113,7 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: - go-version: '1.26' + go-version: '1.27' check-latest: true cache-dependency-path: apps/relay/go.sum diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 0d993bb9..b182020c 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -81,41 +81,57 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Extracted version: $VERSION" + # The version reaches the scripts below through `env`, never as + # `${{ }}` inside `run:`: an expression there is pasted into the + # script text before the shell sees it. The step above already + # refuses anything but digits and dots, so this is the second + # wall, not the first. - name: Check if tag exists id: check_tag + env: + VERSION: ${{ steps.version.outputs.version }} run: | # Quoted: `$GITHUB_OUTPUT` is a path, and an unquoted one # word-splits on any space in the runner's temp directory. # SC2086 flagged it, and the workflow that creates release # tags is not the place to leave a redirection target to # chance. - if git rev-parse "${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + if git rev-parse "$VERSION" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" - echo "Tag ${{ steps.version.outputs.version }} already exists" + echo "Tag $VERSION already exists" else echo "exists=false" >> "$GITHUB_OUTPUT" fi - name: Create and push tag if: steps.check_tag.outputs.exists == 'false' + env: + VERSION: ${{ steps.version.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ steps.version.outputs.version }}" -m "Release ${{ steps.version.outputs.version }}" - git push origin "${{ steps.version.outputs.version }}" - echo "✅ Created tag: ${{ steps.version.outputs.version }}" + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + echo "✅ Created tag: $VERSION" + # `gh`, already on the runner, rather than a third-party action: + # one dependency fewer on the one job that holds a write token. + # `--notes` is prepended to the generated notes, as the action's + # `body` was; `--verify-tag` refuses to run against a tag the step + # above did not push, where `gh` would otherwise create one. - name: Create GitHub Release if: steps.check_tag.outputs.exists == 'false' - # v1 runs on a Node runtime GitHub no longer supports. - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 - with: - tag_name: ${{ steps.version.outputs.version }} - name: Release ${{ steps.version.outputs.version }} - body: ${{ github.event.pull_request.body }} - generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + BODY: ${{ github.event.pull_request.body }} + run: | + gh release create "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "Release $VERSION" \ + --notes "$BODY" \ + --generate-notes - name: Delete release branch env: @@ -127,7 +143,7 @@ jobs: # Build and push the images by calling docker.yml directly. # # This is NOT redundant with docker.yml's own `release: published` trigger. - # The Release above is created by action-gh-release using the default + # The Release above is created by `gh` using the default # GITHUB_TOKEN, and GitHub does not raise workflow-triggering events for # anything done with that token — the recursion guard. So the release event # never reaches docker.yml, and before this job existed every automated @@ -138,7 +154,10 @@ jobs: publish-images: needs: create-tag if: needs.create-tag.outputs.created == 'true' - uses: ./.github/workflows/docker.yml + # zizmor prefers GitHub's `$/` same-repository syntax (July 2026), but + # actionlint 1.7.12 rejects it as a malformed call. Switch once + # actionlint parses it; until then `./` resolves to this same commit. + uses: ./.github/workflows/docker.yml # zizmor: ignore[self-repository] with: version: ${{ needs.create-tag.outputs.version }} # docker.yml pushes to ghcr and signs with cosign; a called workflow can diff --git a/.github/workflows/tracker-ci.yml b/.github/workflows/tracker-ci.yml index 54d2f8f0..02af0d58 100644 --- a/.github/workflows/tracker-ci.yml +++ b/.github/workflows/tracker-ci.yml @@ -47,14 +47,15 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: # Deliberately the minor, not `go-version-file`. The `go` directive - # in go.mod is a *minimum language version* (`go 1.26.0`), but - # setup-go treats a full x.y.z as an exact pin — so reading it built - # CI on go1.26.0, the initial release, which govulncheck correctly - # flagged for 19 reachable stdlib vulnerabilities all fixed in - # 1.26.1. Tracking the minor matches `golang:1.26-alpine` in the - # Dockerfile, so CI tests the toolchain we actually ship. Bump this - # alongside the Dockerfile and the go.mod directive. - go-version: '1.26' + # in go.mod is a *minimum language version* (`go 1.27.0`), but + # setup-go treats a full x.y.z as an exact pin — so reading it, back + # when the directive said `go 1.26.0`, built CI on that initial + # release, which govulncheck correctly flagged for 19 reachable + # stdlib vulnerabilities all fixed in 1.26.1. Tracking the minor + # matches `golang:1.27-alpine` in the Dockerfile, so CI tests the + # toolchain we actually ship. Bump this alongside the Dockerfile and + # the go.mod directive. + go-version: '1.27' check-latest: true cache-dependency-path: apps/tracker/go.sum @@ -100,7 +101,7 @@ jobs: # Same reasoning as the test job: track the minor, not the go.mod # directive. It matters most here — pinning to go1.26.0 is what made # this job fail on 19 stdlib vulnerabilities fixed in 1.26.1. - go-version: '1.26' + go-version: '1.27' check-latest: true cache-dependency-path: apps/tracker/go.sum diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index f78a1a2f..e6bc494f 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -75,12 +75,12 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version: 26 - - name: Enable corepack - run: corepack enable + - name: Install pnpm + run: npm install --global --allow-scripts=pnpm "$(node -p 'require("./package.json").packageManager')" # zizmor: ignore[adhoc-packages] # `nuxt prepare` runs as apps/web's postinstall, so the generated # `.nuxt` declarations — auto-imports, typed routes, component types — @@ -101,17 +101,20 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - # Matches `node:24-alpine` in apps/web/Dockerfile, so CI checks the + # Matches `node:26-alpine` in apps/web/Dockerfile, so CI checks the # runtime we actually ship. - node-version: 24 + node-version: 26 - # corepack rather than a third-party setup action: the pinned pnpm - # version already lives in package.json's `packageManager` field, and the - # Dockerfiles activate it exactly this way. - - name: Enable corepack - run: corepack enable + # Still no third-party setup action, but no corepack either: Node 25 + # stopped shipping it. The version is read from package.json's + # `packageManager` field, which stays the one place it is pinned — and + # pnpm 12 checks that pin against pnpm-lock.yaml on a frozen install. + # `--allow-scripts`: pnpm's install script only hard-links its native + # binary, and npm is moving to blocking scripts it was not told about. + - name: Install pnpm + run: npm install --global --allow-scripts=pnpm "$(node -p 'require("./package.json").packageManager')" # zizmor: ignore[adhoc-packages] - name: Install dependencies run: pnpm install --frozen-lockfile @@ -128,14 +131,14 @@ jobs: persist-credentials: false - name: Set up Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # Neither image is loaded or run: the question this job answers is # whether the SFCs still compile, and a failed `nuxt build` fails the # step. The gha cache keeps the repeat cost down; drop the job if that # trade stops being worth it. - name: SSR image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: apps/web/Dockerfile @@ -144,7 +147,7 @@ jobs: cache-to: type=gha,mode=max,scope=web-ssr - name: Static image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: apps/web/Dockerfile.static diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..8c9bc938 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,50 @@ +# Every line below is a test fixture or a placeholder, not a secret: fake +# passkeys (`abcdef0123…`), the throwaway `0123…` key the CI and the +# integration script hand the API, test-only IP hash secrets, a made-up +# did:key, two base64 strings standing in for legacy verifiers, a test that +# checks a generated PEM *starts with* `-----BEGIN PRIVATE KEY-----`, and an +# import list of two PBKDF2 constants the generic rule mistook for one. +# Checked one by one on 2026-10-25 (gitleaks over all 696 commits). A new +# fingerprint here needs the same look first. + +# .github/workflows/api-ci.yml +1b5517e2cc107e3f9eddf41efac4c4ab61e42bd0:.github/workflows/api-ci.yml:generic-api-key:251 +# apps/api/scripts/run-integration-tests.sh +61d3b4dd07cf7d133640a0ff361eada67869298b:apps/api/scripts/run-integration-tests.sh:generic-api-key:96 +# apps/api/test/integration/credentialRewrap.itest.ts +a8d8ee96c37690a712eb278f441ef6612815e8de:apps/api/test/integration/credentialRewrap.itest.ts:generic-api-key:33 +a8d8ee96c37690a712eb278f441ef6612815e8de:apps/api/test/integration/credentialRewrap.itest.ts:generic-api-key:55 +# apps/api/test/integration/identityRecord.itest.ts +6a27481799149ebaabb53acb0fd6c10168b4312d:apps/api/test/integration/identityRecord.itest.ts:generic-api-key:640 +# apps/api/test/messagingToken.test.ts +f44bc361b27539d85a0f9c1dec1106c236076317:apps/api/test/messagingToken.test.ts:generic-api-key:15 +# apps/tracker/internal/config/config_test.go +5f2fa7a746a288b0adc90a89258eb54329f237ba:apps/tracker/internal/config/config_test.go:generic-api-key:48 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:49 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:58 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:80 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:120 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:139 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:151 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:167 +d4c480bb4f89e1299054082efe229e57e4b0fb13:apps/tracker/internal/config/config_test.go:generic-api-key:223 +# apps/tracker/internal/db/cache_test.go +2938fd0a8e04ca235f75791460398610803c4eca:apps/tracker/internal/db/cache_test.go:generic-api-key:25 +# apps/tracker/internal/server/logging_test.go +c3ed12a160fec40488f201b05a5660430517ba7a:apps/tracker/internal/server/logging_test.go:generic-api-key:27 +c3ed12a160fec40488f201b05a5660430517ba7a:apps/tracker/internal/server/logging_test.go:generic-api-key:64 +c3ed12a160fec40488f201b05a5660430517ba7a:apps/tracker/internal/server/logging_test.go:generic-api-key:99 +c3ed12a160fec40488f201b05a5660430517ba7a:apps/tracker/internal/server/logging_test.go:generic-api-key:108 +c3ed12a160fec40488f201b05a5660430517ba7a:apps/tracker/internal/server/logging_test.go:generic-api-key:120 +c3ed12a160fec40488f201b05a5660430517ba7a:apps/tracker/internal/server/logging_test.go:generic-api-key:129 +# apps/tracker/internal/udp/parse_test.go +cd3622debb27cbd47d49842b37a78060aa5211d7:apps/tracker/internal/udp/parse_test.go:generic-api-key:107 +cd3622debb27cbd47d49842b37a78060aa5211d7:apps/tracker/internal/udp/parse_test.go:generic-api-key:130 +3e6d848cacaa4d57f0a6cc306beba442802e9954:apps/tracker/internal/udp/parse_test.go:generic-api-key:538 +# apps/web/test/identityKey.test.ts +36f897023afec1dac417f138f5537bc1a873b02d:apps/web/test/identityKey.test.ts:private-key:121 +# deploy/helm/trackarr/check-render.sh +ab215f4eff21d6d39269f809875b6203e41735b9:deploy/helm/trackarr/check-render.sh:generic-api-key:139 +ab215f4eff21d6d39269f809875b6203e41735b9:deploy/helm/trackarr/check-render.sh:generic-api-key:140 +# tests/security/authentication.test.ts +ab71a8aa7bb3e5eaf4170b462d02b07a60cf847b:tests/security/authentication.test.ts:generic-api-key:18 diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 00000000..27d149f3 --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,32 @@ +# A .semgrepignore REPLACES Semgrep's built-in list rather than adding to it, +# so that list comes first, as documented +# (https://docs.semgrep.dev/ignoring-files-folders-code). +.git +.svn +.hg +_darcs +CVS +build/ +vendor/ +dist/ +*.min.js +.env/ +.tox/ +node_modules/ +.npm/ +.yarn/ +.venv/ +_opam/ +_build/ +_cargo/ +test/ +tests/ +testsuite/ +*_test.go + +# What git does not track is not ours to scan either. +:include .gitignore + +# Prose, not code. The shell in a README is an example to read: a throwaway k3s +# needs `--privileged`, a port-forwarded test LoadBalancer answers plain HTTP. +*.md diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 00000000..b0ac9a82 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,79 @@ +# Accepted findings, each with its reason. trivy.yaml points trivy here when it +# runs from the repository root, which is also what `paths` are relative to. +# `paths` scope an entry to the files it was checked on; `expired_at` makes it +# lapse so it gets looked at again. Dockerfile findings are not here but at +# their line, next to the reason (apps/web/Dockerfile.static, doc/Dockerfile). + +vulnerabilities: + # node-forge (GHSA-86w9-cpqp-85rv, a.k.a. CVE-2026-33894 / GHSA-ppp5-5v6c-4jwp): + # RSA PKCS#1 v1.5 signature verification accepts extra nested DigestAlgorithm + # elements. No fixed release exists, 1.4.0 being both the latest and the last + # affected. It reaches the tree only through nitropack -> listhen, the dev + # server's self-signed-certificate helper, which never verifies a signature; + # no production image ships it (checked on 2026-10-25 against the api, web + # and spa images). + - id: CVE-2026-85393 + expired_at: 2027-01-31 + statement: node-forge, dev server only, no fixed release (see above) + - id: CVE-2026-33894 + expired_at: 2027-01-31 + statement: node-forge, dev server only, no fixed release (see above) + - id: GHSA-86w9-cpqp-85rv + expired_at: 2027-01-31 + statement: node-forge, dev server only, no fixed release (see above) + +# The Helm chart, which trivy renders with its default values. It does so only +# once the subcharts are vendored (`helm dependency update deploy/helm/trackarr`): +# without charts/*.tgz it skips the whole chart with a warning and reports +# nothing, which reads like a clean result. +misconfigurations: + - id: KSV-0011 + paths: + - "deploy/helm/trackarr/**" + statement: >- + No CPU limit, on purpose and everywhere in the chart: a CFS quota + throttles a burst even on an idle node. CPU requests and memory limits + are set (see the comment above web.resources in values.yaml). + - id: KSV-0110 + paths: + - "deploy/helm/trackarr/**" + statement: >- + Render artifact. The templates leave the namespace to `helm install -n`; + trivy renders without one and reads that as `default`. + - id: KSV-0125 + paths: + - "deploy/helm/trackarr/**" + statement: >- + A registry allow-list is the cluster's policy to set, not the chart's. + The images are the project's own (ghcr.io) and Valkey's official one. + - id: KSV-01010 + paths: + - "deploy/helm/trackarr/templates/configmap.yaml" + statement: >- + Matched on the name REDIS_KEY_PREFIX, the namespace of the app's Redis + keys. Credentials all live in Secrets, which check-render.sh verifies. + - id: KSV-01010 + paths: + - "deploy/helm/trackarr/charts/valkey/templates/configmap.yaml" + - "deploy/helm/trackarr/charts/valkey/templates/init_config.yaml" + statement: >- + Valkey's subchart. valkey.conf holds the maxmemory and persistence + settings; init.sh reads the credential from the mounted Secret at start + and writes only its SHA-256 to the ACL file. + - id: KSV-0109 + paths: + - "deploy/helm/trackarr/charts/valkey/templates/init_config.yaml" + statement: >- + The same init.sh: what matched is the name of a shell variable that + holds what the mounted Secret contains, not a value. + - id: KSV-0020 + paths: + - "deploy/helm/trackarr/charts/valkey/templates/*.yaml" + statement: >- + Valkey's subchart runs as 1000:1000. Moving it above 10000 changes the + owner of an existing data volume, an upgrade nobody has tested; the + chart's own images run as 65532. + - id: KSV-0021 + paths: + - "deploy/helm/trackarr/charts/valkey/templates/*.yaml" + statement: Same as KSV-0020, for the group. diff --git a/README.md b/README.md index 7524a199..78f8f203 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ Three containers — Nuxt 4 web · Nitro API · Go tracker — backed by PostgreSQL and Redis. -[![Node.js](https://img.shields.io/badge/Node.js-24+-339933?style=flat&logo=nodedotjs&logoColor=white)](https://nodejs.org/) +[![Node.js](https://img.shields.io/badge/Node.js-26+-339933?style=flat&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![Nuxt](https://img.shields.io/badge/Nuxt-4-00DC82?style=flat&logo=nuxtdotjs&logoColor=white)](https://nuxt.com/) -[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-6+-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License](https://img.shields.io/badge/License-MIT-blue?style=flat)](LICENSE) [Features](#-features) • [Architecture](#-architecture) • [Quick Start](#-quick-start) • [Static deployment](#-static-deployment-no-ssr) • [Documentation](https://dim145.github.io/opentracker/) • [Live Demo](https://tracker.florianargaud.com/) @@ -95,7 +95,7 @@ Three independent containers behind Caddy, plus the usual Postgres + Redis. They ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ apps/web │ │ apps/api │ │ apps/tracker │ - │ Nuxt 4 SSR │ │ Nitro 4 │ │ Go 1.25 │ + │ Nuxt 4 SSR │ │ Nitro 4 │ │ Go 1.27 │ │ Vue 3 / TS │ │ Drizzle ORM │ │ sqlc │ │ (stateless) │ │ Zod, h3 │ │ HTTP + UDP │ └──────────────┘ └──────┬───────┘ └──────┬───────┘ @@ -120,7 +120,7 @@ Three independent containers behind Caddy, plus the usual Postgres + Redis. They - **The tracker is its own thing.** It's the hot path — every BitTorrent client in the swarm hits `/announce` every few minutes. A static Go binary on `scratch` (~10 MB image, sub-ms p99) means a single broken Nuxt deploy can't take down announces. Two transports — BEP 3 over HTTP/8080 and BEP 15 over UDP/6969 — share one wire-agnostic processor. - **API and web are split.** `apps/api` (Nitro standalone) owns every `/api/*` route, upload endpoints, metadata lookups, admin tools. `apps/web` is Nuxt SSR — rendered shell + page chunks. They scale and redeploy independently. -- **Distroless everywhere.** `apps/web` + `apps/api` run on `gcr.io/distroless/nodejs24-debian13:nonroot`; `apps/tracker` runs on `scratch`; the static frontend runs on `cgr.dev/chainguard/nginx`. No shells, no package managers, non-root by default. +- **Distroless everywhere.** `apps/web` + `apps/api` run on `gcr.io/distroless/nodejs26-debian13:nonroot`; `apps/tracker` runs on `scratch`; the static frontend runs on `cgr.dev/chainguard/nginx`. No shells, no package managers, non-root by default. More details: [Architecture guide](doc/guide/architecture.md). @@ -197,7 +197,7 @@ from **distroless Chainguard nginx**: | Image size | 254 MB | **28 MB** | | Idle RSS | ~120 MB | **~7 MB** | | Cold start | ~2 s | **<100 ms** | -| Base image | `distroless/nodejs24` | **`chainguard/nginx`** | +| Base image | `distroless/nodejs26` | **`chainguard/nginx`** | | First paint | server-rendered HTML | SPA shell, hydrates client-side | ```bash @@ -244,8 +244,8 @@ change end to end. | Layer | Technology | Notes | | ---------------- | ----------------------------------------- | -------------------------------------------------- | | Frontend | Nuxt 4, Vue 3, Tailwind CSS, Tiptap | SSR by default, opt-in static SPA build | -| API | Nitro 4 (Node 24), Drizzle ORM, Zod | Standalone container, distroless runtime | -| Tracker | Go 1.25, sqlc | `scratch`-based image, sub-ms announce p99 | +| API | Nitro 4 (Node 26), Drizzle ORM, Zod | Standalone container, distroless runtime | +| Tracker | Go 1.27, sqlc | `scratch`-based image, sub-ms announce p99 | | Database | PostgreSQL 18 | `gin_trgm_ops` full-text, migrations at API boot | | Connection pool | PgBouncer | Transaction-mode pooling | | Cache / queue | Redis 8 | Peer hashes, sessions, rate-limit windows, pub/sub | diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 7b9e9a76..2d7bdacc 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.28 # Trackarr API — Nitro standalone, distroless runtime. # # The runtime image carries: @@ -15,14 +15,21 @@ # corepack + the api's build deps just to expose a single CLI invocation; # this trims it down to what's actually needed at boot. # -# Base is gcr.io/distroless/nodejs24-debian13:nonroot — no /bin/sh, no apt, +# Base is gcr.io/distroless/nodejs26-debian13:nonroot — no /bin/sh, no apt, # runs as uid 65532, signed by Google. # ----------------------------------------------------------------------------- # Install dependencies for the API build (cached on package.json + lockfile) # ----------------------------------------------------------------------------- -FROM node:24-alpine AS deps -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS deps +# npm rather than corepack: Node 25 stopped shipping corepack, so the +# node:26 images have none. The version must match `packageManager` in the +# root package.json — pnpm 12 records it in pnpm-lock.yaml and a frozen +# install refuses a lockfile whose pin it does not match. `--allow-scripts` +# names the one install script we let run: pnpm's own, which only hard-links +# its native binary over the bins. npm 11.20 runs unlisted scripts with a +# warning today and is moving to blocking them. +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/api/package.json ./apps/api/ @@ -33,8 +40,8 @@ RUN pnpm install --filter "@trackarr/api..." --frozen-lockfile # ----------------------------------------------------------------------------- # Build Nitro (produces apps/api/.output, fully self-contained) # ----------------------------------------------------------------------------- -FROM node:24-alpine AS builder -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS builder +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules @@ -54,8 +61,8 @@ RUN pnpm --filter @trackarr/api build # `drizzle-kit push --force` against schema.ts. Hoisted into /db-tools so it # stays isolated from the Nitro bundle's own (inlined) node_modules. # ----------------------------------------------------------------------------- -FROM node:24-alpine AS db-tools -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS db-tools +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /db-tools COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY packages/db ./packages/db @@ -82,7 +89,7 @@ RUN mkdir -p /seed/uploads # Final runtime — distroless nonroot # Entrypoint of this base image is `/nodejs/bin/node`; CMD is just the script. # ----------------------------------------------------------------------------- -FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runner +FROM gcr.io/distroless/nodejs26-debian13:nonroot AS runner WORKDIR /app COPY --from=builder --chown=nonroot:nonroot /app/apps/api/.output ./.output @@ -112,15 +119,25 @@ COPY --chown=nonroot:nonroot apps/api/scripts/migrationOutcome.mjs ./migrationOu COPY --chown=nonroot:nonroot apps/api/scripts/migrate.mjs /app/db-tools/packages/db/migrate.mjs COPY --chown=nonroot:nonroot apps/api/scripts/healthcheck.mjs ./healthcheck.mjs -USER nonroot +# Numeric, as in the tracker and relay images: `nonroot` is the same 65532 in +# this distroless base, but the kubelet refuses to verify a user NAME against +# `runAsNonRoot` ("image has non-numeric user") unless runAsUser is set. +USER 65532:65532 EXPOSE 4000 # Memory tuning: # --max-old-space-size=512 cap the V8 old-generation heap at 512 MB. -# Without this, Node 24 derives the cap from the +# Without this, Node derives the cap from the # host's RAM (often 2+ GB on dev machines), which # defers GC and lets RSS balloon under load. # --max-semi-space-size=8 smaller young-generation chunks → more frequent # minor GCs, lower transient peak. +# --no-experimental-webstorage +# Node 25 turned Web Storage on by default. On a +# server that means ONE `sessionStorage` shared +# by every request the process ever serves, and +# a warning each time something probes +# `localStorage`. Off, as it was on Node 24: +# neither global exists. # MALLOC_ARENA_MAX=2 glibc malloc on distroless pre-allocates one # memory arena per CPU; capping at 2 saves # ~30-60 MB of RSS on multi-core hosts. @@ -128,7 +145,7 @@ EXPOSE 4000 ENV NODE_ENV=production \ NITRO_PORT=4000 \ NITRO_HOST=0.0.0.0 \ - NODE_OPTIONS="--max-old-space-size=512 --max-semi-space-size=8" \ + NODE_OPTIONS="--max-old-space-size=512 --max-semi-space-size=8 --no-experimental-webstorage" \ MALLOC_ARENA_MAX=2 \ NITRO_ENV_PREFIX=NUXT_ \ TRACKARR_RUNTIME=docker diff --git a/apps/api/middleware/panic-lock.ts b/apps/api/middleware/panic-lock.ts new file mode 100644 index 00000000..3373451e --- /dev/null +++ b/apps/api/middleware/panic-lock.ts @@ -0,0 +1,29 @@ +/** + * Panic mode makes the API read-only. + * + * Panic encrypts the catalogue and the members' credentials in place, but it + * does not end the sessions already open — on purpose, so staff keep a way in. + * Those sessions could go on uploading, posting, resetting keys, registering + * members: every such row landed in clear in a database announced as encrypted, + * and the restore, which only knew how to decrypt, failed on each of them. + * + * The restore now keeps a row that is already clear (`decryptFieldOrKeep`), so + * the lockout is gone either way; this is the other half — nothing new is + * written in clear while the panic stands. A middleware rather than a check per + * route, for the reason the audit log is a hook: a writing route added tomorrow + * is covered before anyone remembers it should be. + * + * What still writes: the panic itself (resuming an interrupted encrypt, the + * restore) and logging out. + */ +import { allowedDuringPanic, panicActive } from '~~/utils/panicLock'; + +export default defineEventHandler(async (event) => { + if (allowedDuringPanic(event.method, event.path || '')) return; + if (!(await panicActive())) return; + throw createError({ + statusCode: 503, + message: 'The site is locked while panic mode is active.', + data: { reason: 'panic-lock' }, + }); +}); diff --git a/apps/api/middleware/security.ts b/apps/api/middleware/security.ts index 9b313224..a5a41a6b 100644 --- a/apps/api/middleware/security.ts +++ b/apps/api/middleware/security.ts @@ -267,9 +267,12 @@ export default defineEventHandler(async (event) => { const banned = await getUserSession(event); const live = banned.user ? await readLiveRoles(banned.user.id) : null; if (!live?.isAdmin && !live?.isModerator) { + // Never the stored reason: the ban route writes "Banned user: . + // Reason: …" into it, and this answer goes to whoever shares the address + // — a VPN exit, a carrier NAT — not only to the banned member. throw createError({ statusCode: 403, - message: `Access denied: ${ipBanReason}`, + message: 'Access denied: this address is banned.', }); } console.warn( diff --git a/apps/api/nitro.config.ts b/apps/api/nitro.config.ts index 47ace6c9..6779a4f5 100644 --- a/apps/api/nitro.config.ts +++ b/apps/api/nitro.config.ts @@ -31,17 +31,18 @@ export default defineNitroConfig({ // esbuild target. Nitro's default is `es2019` — far older than what // we actually run, and old enough to refuse BigInt literal syntax // (`0n` / `1024n`) at parse time even though the runtime supports - // them fine. Pinning to `node24` matches the production runtime - // (distroless `gcr.io/distroless/nodejs24-debian13:nonroot`, see - // apps/api/Dockerfile) and unlocks every ES2025 feature V8 13.6 - // ships — iterator helpers, Set methods (`union` / `intersection` / …), - // `Promise.try`, `RegExp.escape`, JSON modules. Targeting a Node + // them fine. Pinning to `node26` matches the production runtime + // (distroless `gcr.io/distroless/nodejs26-debian13:nonroot`, see + // apps/api/Dockerfile) and unlocks every ES2025 feature — iterator + // helpers, Set methods (`union` / `intersection` / …), `Promise.try`, + // `RegExp.escape`, JSON modules — plus what V8 14.6 adds on top: + // `Map.prototype.getOrInsert`, `Iterator.concat`, `Temporal`. Targeting a Node // version rather than an ES year is the recommended idiom for // server-only bundles since esbuild keeps a per-runtime feature // table that accounts for V8-specific quirks. esbuild: { options: { - target: 'node24', + target: 'node26', }, }, @@ -52,28 +53,16 @@ export default defineNitroConfig({ '~': root, }, + // Only what the routes read. `databaseUrl`, `redisUrl` and `sessionPassword` + // stood here unread — the database, Redis and session code take their + // environment directly — and, evaluated at BUILD time, they would have baked + // whatever those variables held during a build into the bundle. runtimeConfig: { - databaseUrl: (() => { - const baseUrl = - process.env.DATABASE_URL || - 'postgres://tracker:tracker@localhost:5432/trackarr'; - if ( - process.env.NODE_ENV === 'production' && - !baseUrl.includes('sslmode=') - ) { - return ( - baseUrl + (baseUrl.includes('?') ? '&' : '?') + 'sslmode=require' - ); - } - return baseUrl; - })(), - redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', - sessionPassword: process.env.NUXT_SESSION_SECRET || '', public: { // Tracker URLs are read at runtime from NUXT_PUBLIC_TRACKER_*_URL env vars. trackerHttpUrl: 'http://localhost:8080/announce', - trackerUdpUrl: 'udp://localhost:8081/announce', - trackerWsUrl: 'ws://localhost:8082', + // Empty unless configured: no UDP tier is advertised by default. + trackerUdpUrl: '', }, }, diff --git a/apps/api/package.json b/apps/api/package.json index a068a855..8c707881 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,36 +15,34 @@ }, "dependencies": { "@iconify-json/ph": "^1.2.2", - "@iconify/utils": "^3.1.4", - "@simplewebauthn/server": "^13.3.3", + "@iconify/utils": "^3.1.7", + "@prometheus-io/client": "^0.16.1", + "@simplewebauthn/server": "^14.0.3", "@trackarr/db": "workspace:*", "@trackarr/shared": "workspace:*", "bencode": "^4.0.1", - "create-torrent": "^6.1.3", "css-tree": "^3.2.1", - "drizzle-orm": "^0.45.2", + "drizzle-orm": "^0.45.4", "h3": "^1.15.11", "ioredis": "^6.0.0", - "marked": "^18.0.9", - "nitropack": "^2.13.4", + "marked": "^18.1.1", + "nitropack": "^2.13.5", "nodemailer": "^10.2.1", "otplib": "^13.4.1", "parse-torrent": "^11.0.24", - "pino": "^10.3.1", - "prom-client": "^15.1.3", + "pino": "^10.4.0", "qrcode": "^1.5.4", - "uuid": "^14.0.1", + "uuid": "^14.0.3", "web-push": "^3.6.7", - "zod": "^4.6.6" + "zod": "^4.6.7" }, "devDependencies": { "@types/bencode": "^4.0.0", - "@types/create-torrent": "^6.1.1", "@types/css-tree": "^3.2.0", "@types/parse-torrent": "^11.0.1", "@types/qrcode": "^1.5.6", "@types/web-push": "^3.6.4", - "pino-pretty": "^13.1.3", - "vitest": "^4.1.12" + "pino-pretty": "^13.2.0", + "vitest": "^5.0.5" } } diff --git a/apps/api/plugins/00.0-secret-files.ts b/apps/api/plugins/00.0-secret-files.ts new file mode 100644 index 00000000..35f73bda --- /dev/null +++ b/apps/api/plugins/00.0-secret-files.ts @@ -0,0 +1,28 @@ +/** + * Docker secrets, read once and exposed under their plain names. + * + * `*_FILE` is how a secret arrives from a Docker or Kubernetes secret mount, + * and `utils/secrets` reads that form — but only the boot validation went + * through it. The session, the credential and channel keys, and the login's + * fake-salt key all read the plain variable, so a deployment using + * `NUXT_SESSION_SECRET_FILE` passed the validation and then failed at its first + * session. Materialised here, before any other plugin runs (the file name sorts + * first), every reader sees the same value. + */ +import { readOptionalSecret } from '~~/utils/secrets'; + +const FILE_BACKED = [ + 'NUXT_SESSION_SECRET', + 'IP_HASH_SECRET', + 'CREDENTIAL_ENCRYPTION_KEY', + 'CREDENTIAL_ENCRYPTION_KEY_PREVIOUS', + 'CHANNEL_ENCRYPTION_KEY', +] as const; + +export default defineNitroPlugin(() => { + for (const key of FILE_BACKED) { + if (process.env[key] || !process.env[`${key}_FILE`]) continue; + const value = readOptionalSecret(key); + if (value) process.env[key] = value; + } +}); diff --git a/apps/api/plugins/00.secrets.ts b/apps/api/plugins/00.secrets.ts index 3d095c08..0b6f4330 100644 --- a/apps/api/plugins/00.secrets.ts +++ b/apps/api/plugins/00.secrets.ts @@ -15,11 +15,17 @@ * DATABASE_URL / REDIS are validated by their own clients (and carry * dev defaults), so they're intentionally not listed here. */ -import { validateRequiredSecrets } from '~~/utils/secrets'; +import { readOptionalSecret, validateRequiredSecrets } from '~~/utils/secrets'; export default defineNitroPlugin(() => { try { validateRequiredSecrets(['NUXT_SESSION_SECRET', 'IP_HASH_SECRET']); + // The session refuses a secret under 32 characters, but only when the + // first session is opened: a short one booted cleanly and then failed every + // sign-in. Refused here, where the operator is looking. + if ((readOptionalSecret('NUXT_SESSION_SECRET') ?? '').length < 32) { + throw new Error('NUXT_SESSION_SECRET must be at least 32 characters (`openssl rand -hex 32`).'); + } } catch (err) { // Re-throw so the process exits non-zero — a missing security // secret must not boot into a half-working state. diff --git a/apps/api/plugins/backfill-content-roots.ts b/apps/api/plugins/backfill-content-roots.ts index 19740d36..6a8feb40 100644 --- a/apps/api/plugins/backfill-content-roots.ts +++ b/apps/api/plugins/backfill-content-roots.ts @@ -34,6 +34,7 @@ import { and, asc, eq, gt, isNotNull } from 'drizzle-orm'; import { extractV2 } from '~~/utils/bittorrentV2'; import { getSetting, setSetting } from '~~/utils/server'; import { withCronLock } from '~~/utils/cronLock'; +import { panicActive } from '~~/utils/panicLock'; const SWEEP_INTERVAL_MS = 5 * 60 * 1000; const FIRST_RUN_DELAY_MS = 75 * 1000; @@ -43,6 +44,9 @@ const BATCH_SIZE = 50; const CURSOR_KEY = 'content_root_v2_backfill_cursor_v2'; async function tick(): Promise<{ processed: number; v2: number }> { + // Panic encrypts `torrent_data`: walking it then would leave both columns null + // for every row the cursor passed, and never come back for them. + if (await panicActive()) return { processed: 0, v2: 0 }; const cursor = (await getSetting(CURSOR_KEY)) ?? ''; const rows = await db diff --git a/apps/api/plugins/backfill-content-signatures.ts b/apps/api/plugins/backfill-content-signatures.ts index 230b5260..efde4c56 100644 --- a/apps/api/plugins/backfill-content-signatures.ts +++ b/apps/api/plugins/backfill-content-signatures.ts @@ -23,12 +23,16 @@ import { eq, isNull } from 'drizzle-orm'; import { parseTorrentFile } from '~~/utils/parseTorrentFile'; import { computeContentSignature } from '~~/utils/contentSignature'; import { withCronLock } from '~~/utils/cronLock'; +import { panicActive } from '~~/utils/panicLock'; const SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes const FIRST_RUN_DELAY_MS = 60 * 1000; // 1 minute after boot const BATCH_SIZE = 50; async function tick(): Promise<{ processed: number; signed: number }> { + // Panic encrypts `torrent_data`: every row parsed now would fail and be + // stamped empty below, and never be signed after the restore. + if (await panicActive()) return { processed: 0, signed: 0 }; const rows = await db .select({ id: schema.torrents.id, diff --git a/apps/api/plugins/backfill-credential-rewrap.ts b/apps/api/plugins/backfill-credential-rewrap.ts new file mode 100644 index 00000000..4c63fd32 --- /dev/null +++ b/apps/api/plugins/backfill-credential-rewrap.ts @@ -0,0 +1,30 @@ +/** + * Drives `utils/credentialRewrap`: account secrets still in plaintext, or only + * under the previous key, re-encrypted under the current one without waiting + * for their owners to log in. The module says why that matters. + */ +import { rewrapCredentialsTick } from '~~/utils/credentialRewrap'; +import { withCronLock } from '~~/utils/cronLock'; + +const SWEEP_INTERVAL_MS = 60 * 1000; +const FIRST_RUN_DELAY_MS = 60 * 1000; + +export default defineNitroPlugin(() => { + const run = async () => { + try { + await withCronLock('credential_rewrap:lock', 5 * 60, async () => { + const { processed, rewritten } = await rewrapCredentialsTick(); + if (rewritten > 0) { + console.log(`[Credentials] re-encrypted ${rewritten} secret(s) over ${processed} account(s)`); + } + }); + } catch (err) { + console.warn('[Credentials] re-encryption sweep failed:', (err as Error).message); + } + }; + + setTimeout(() => { + void run(); + setInterval(run, SWEEP_INTERVAL_MS); + }, FIRST_RUN_DELAY_MS); +}); diff --git a/apps/api/plugins/backfill-series-position.ts b/apps/api/plugins/backfill-series-position.ts index 968c449d..fca630d3 100644 --- a/apps/api/plugins/backfill-series-position.ts +++ b/apps/api/plugins/backfill-series-position.ts @@ -24,6 +24,7 @@ import { sql } from 'drizzle-orm'; import { parseReleaseName } from '@trackarr/shared/releaseParse'; import { getSetting, setSetting } from '~~/utils/server'; import { withCronLock } from '~~/utils/cronLock'; +import { panicActive } from '~~/utils/panicLock'; // Adaptive pacing. A tick that filled its batch means there is more table to // walk, so the next one follows almost immediately; a short tick means we are @@ -46,6 +47,9 @@ interface TickResult { } async function tick(): Promise { + // Panic encrypts the names: the cursor would walk the whole catalogue finding + // no series, then mark itself done for good. + if (await panicActive()) return { scanned: 0, written: 0, finished: false }; const cursor = (await getSetting(CURSOR_KEY)) ?? ''; if (cursor === DONE) return { scanned: 0, written: 0, finished: true }; diff --git a/apps/api/plugins/backfill-torrent-files.ts b/apps/api/plugins/backfill-torrent-files.ts new file mode 100644 index 00000000..2c799bdc --- /dev/null +++ b/apps/api/plugins/backfill-torrent-files.ts @@ -0,0 +1,34 @@ +/** + * Drives `utils/torrentFileCleanup`: the sweep that drops the uploader's fields + * and repairs the piece layers of `.torrent` files stored before the upload did + * either. The module explains both faults. + */ +import { cleanupTorrentFilesTick } from '~~/utils/torrentFileCleanup'; +import { withCronLock } from '~~/utils/cronLock'; + +// Faster than the content-roots sweep: a row costs a byte scan, not a parse +// (only a v2 torrent is decoded, to read its roots), and every hour a passkey +// stays at rest is an hour it can leave in a backup. 200 rows a minute walks a +// 100 000-torrent catalogue in under nine hours. +const SWEEP_INTERVAL_MS = 60 * 1000; +const FIRST_RUN_DELAY_MS = 90 * 1000; + +export default defineNitroPlugin(() => { + const run = async () => { + try { + await withCronLock('torrent_file_cleanup:lock', 5 * 60, async () => { + const { processed, cleaned } = await cleanupTorrentFilesTick(); + if (processed > 0) { + console.log(`[TorrentFiles] cleanup tick: processed=${processed}, cleaned=${cleaned}`); + } + }); + } catch (err) { + console.warn('[TorrentFiles] cleanup tick failed:', (err as Error).message); + } + }; + + setTimeout(() => { + void run(); + setInterval(run, SWEEP_INTERVAL_MS); + }, FIRST_RUN_DELAY_MS); +}); diff --git a/apps/api/plugins/ban-expiry.ts b/apps/api/plugins/ban-expiry.ts index 6c74c108..b8277620 100644 --- a/apps/api/plugins/ban-expiry.ts +++ b/apps/api/plugins/ban-expiry.ts @@ -119,7 +119,8 @@ export default defineNitroPlugin(async () => { ); console.log( - `[Ban Expiry] Unbanned ${expired.length} user(s):`, + '[Ban Expiry] Unbanned %d user(s): %s', + expired.length, expired.map((u) => u.username).join(', '), ); } catch (err) { diff --git a/apps/api/plugins/bonus-collector.ts b/apps/api/plugins/bonus-collector.ts index 6efeef7d..b1ef67cc 100644 --- a/apps/api/plugins/bonus-collector.ts +++ b/apps/api/plugins/bonus-collector.ts @@ -75,6 +75,12 @@ const LOCK_KEY = 'bonus:collector:lock'; * of stretching the time-to-credit for the tail. */ const SCAN_CURSOR_KEY = 'bonus:collector:scan_cursor'; +/** + * The tracker's own notion of "in the swarm" (`activeListWindow` in + * apps/tracker/internal/peers): a peer quiet for longer is no longer handed + * out to other clients, and is no longer paid as a seeder either. + */ +const ACTIVE_PEER_WINDOW_MS = 60 * 60 * 1000; /** Lock TTL — generous so a 30 s SCAN budget doesn't trip the * lock, but short enough that a crashed replica releases the slot * before the next scheduled tick. */ @@ -154,13 +160,20 @@ export default defineNitroPlugin(async () => { const infoHash = key.slice('peers:'.length); const peers = await redis.hgetall(key); let set = torrentSeederUsers.get(infoHash); + const activeSince = Date.now() - ACTIVE_PEER_WINDOW_MS; for (const json of Object.values(peers)) { try { const peer = JSON.parse(json as string) as { userId?: string; isSeeder?: boolean; + updatedAt?: number; }; if (!peer.isSeeder || !peer.userId) continue; + // Only peers still announcing. An entry lives 24 h in Redis + // (`TRACKER_PEER_TTL`), so one `left=0` announce a day on a + // torrent nobody seeds used to be paid as a full day of seeding + // — with the rarity and first-seeder multipliers on top. + if (!peer.updatedAt || peer.updatedAt < activeSince) continue; if (!set) { set = new Set(); torrentSeederUsers.set(infoHash, set); diff --git a/apps/api/plugins/bonus-event-sync.ts b/apps/api/plugins/bonus-event-sync.ts new file mode 100644 index 00000000..3cfe814d --- /dev/null +++ b/apps/api/plugins/bonus-event-sync.ts @@ -0,0 +1,38 @@ +/** + * Keeps the tracker's view of the running bonus event current. + * + * The tracker reads the active event from `bonus:active`, a snapshot written + * with a five-minute TTL. Nothing rewrote it on a schedule: only admin edits, + * the freeleech pool's own transitions, and a read that found it expired — + * `/api/bonus/active` from a browser, a Torznab search, an IRC announce. Five + * quiet minutes, a night on a small instance, and the tracker went back to + * 1×/1× in the middle of a freeleech; and a scheduled event only started at + * the first page view after its start time, its start notification with it. + * + * Once a minute, under a cross-replica lock. `syncActiveSnapshot` is idempotent + * and its start broadcast is guarded per event, so a replica that misses a tick + * costs nothing. + */ +import { syncActiveSnapshot } from '~~/utils/bonusEvents'; +import { withCronLock } from '~~/utils/cronLock'; + +const SYNC_INTERVAL_MS = 60 * 1000; + +export default defineNitroPlugin(() => { + const run = async () => { + try { + await withCronLock('bonus_event_sync:lock', 50, async () => { + await syncActiveSnapshot(); + }); + } catch (err) { + console.warn('[BonusEvents] snapshot sync failed:', (err as Error).message); + } + }; + + // Soon after boot: a restart in the middle of an event must not leave the + // tracker without it until somebody browses. + setTimeout(() => { + void run(); + setInterval(run, SYNC_INTERVAL_MS); + }, 5 * 1000); +}); diff --git a/apps/api/plugins/hnr-sweep.ts b/apps/api/plugins/hnr-sweep.ts new file mode 100644 index 00000000..a5114e7a --- /dev/null +++ b/apps/api/plugins/hnr-sweep.ts @@ -0,0 +1,35 @@ +/** + * Hit-and-run sweep — flags the downloads whose grace period ran out before + * their seeding requirement was met, and notifies the member. + * + * `checkAndMarkHnrs()` used to run only when a moderator opened the H&R page + * with `?check=true`, although the guide described a loop: on an instance + * nobody moderated by hand, nobody was ever flagged. It runs here on its own + * now, idempotent (it only flips rows still `is_hnr = false`), under a + * cross-replica lock so the notifications go out once. + */ +import { checkAndMarkHnrs } from '~~/utils/hnr'; +import { withCronLock } from '~~/utils/cronLock'; + +// Grace periods are counted in hours; ten minutes is late enough to cost +// nothing and early enough that the notification reads as "now". +const SWEEP_INTERVAL_MS = 10 * 60 * 1000; +const FIRST_RUN_DELAY_MS = 2 * 60 * 1000; + +export default defineNitroPlugin(() => { + const run = async () => { + try { + await withCronLock('hnr_sweep:lock', 5 * 60, async () => { + const flagged = await checkAndMarkHnrs(); + if (flagged > 0) console.log(`[HnR] sweep flagged ${flagged} download(s)`); + }); + } catch (err) { + console.warn('[HnR] sweep failed:', (err as Error).message); + } + }; + + setTimeout(() => { + void run(); + setInterval(run, SWEEP_INTERVAL_MS); + }, FIRST_RUN_DELAY_MS); +}); diff --git a/apps/api/plugins/metrics.ts b/apps/api/plugins/metrics.ts index 7de75aa5..755d182b 100644 --- a/apps/api/plugins/metrics.ts +++ b/apps/api/plugins/metrics.ts @@ -116,7 +116,7 @@ export default defineNitroPlugin((nitroApp) => { }); server.on('error', (err) => { - console.error(`[metrics] server error on ${host}:${port}:`, err); + console.error('[metrics] server error on %s:%s:', host, port, err); }); server.listen(port, host, () => { diff --git a/apps/api/redis/client.ts b/apps/api/redis/client.ts index 2bcaa6f0..31a3d64c 100644 --- a/apps/api/redis/client.ts +++ b/apps/api/redis/client.ts @@ -6,6 +6,8 @@ import { readOptionalSecret } from '../utils/secrets'; * Supports authentication, TLS, and connection security with Docker secrets */ +// Development fallback; deployments pass REDIS_URL, rediss:// included. +// nosemgrep: trailofbits.generic.redis-unencrypted-transport.redis-unencrypted-transport const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; const isProduction = process.env.NODE_ENV === 'production'; @@ -52,6 +54,12 @@ function buildRedisOptions(): RedisOptions { // Prevent command queue overflow (DDoS protection) maxLoadingRetryTime: 5000, enableOfflineQueue: false, + // A socket that is open but no longer answers (a paused container, a + // half-dead NAT entry) otherwise holds every command — and the request + // behind it — until the kernel gives up on the TCP connection. The tracker + // pins 3 s for the same reason; nothing in the API issues a blocking + // command, so no legitimate reply takes this long. + commandTimeout: 5000, // Key prefix for namespace isolation keyPrefix: process.env.REDIS_KEY_PREFIX || 'ot:', }; @@ -89,17 +97,19 @@ function buildRedisOptions(): RedisOptions { options.tls = tlsConfig; } - // Connection pool limits (prevent resource exhaustion) - if (isProduction) { - options.retryStrategy = (times: number) => { - if (times > 10) { - console.error('[Redis] Max retries exceeded, giving up'); - return null; // Stop retrying - } - // Exponential backoff with max 3 seconds - return Math.min(times * 200, 3000); - }; - } + // Reconnect for as long as it takes. This used to return `null` after ten + // attempts — about eleven seconds — and a non-number tells ioredis to close + // the connection for good: nothing called `connect()` again, so any Redis + // outage longer than that (a container recreated for an upgrade, an OOM kill, + // a slow AOF load) left the API without Redis until it was restarted. Logins + // live in Redis, so nobody could sign in, `/api/health` stayed 503, and under + // Helm every pod sat NotReady with a liveness probe that never restarts it. + options.retryStrategy = (times: number) => { + if (times === 10 || times % 100 === 0) { + console.error(`[Redis] still unreachable after ${times} attempts, retrying every 3 s`); + } + return Math.min(times * 200, 3000); + }; return options; } @@ -112,10 +122,12 @@ redis.on('connect', () => { }); redis.on('error', (err) => { - // Sanitize error messages to avoid leaking sensitive info + // Sanitize error messages to avoid leaking sensitive info. `rediss://` as + // well: a TLS URL carries the same password, and a mask that only knew the + // plain scheme let it through whole. const sanitizedMessage = err.message.replace( - /redis:\/\/[^@]+@/g, - 'redis://***@' + /(rediss?):\/\/[^@]+@/g, + '$1://***@' ); console.error('[Redis] Error:', sanitizedMessage); }); diff --git a/apps/api/routes/api/admin/debug/tracker-status.get.ts b/apps/api/routes/api/admin/debug/tracker-status.get.ts index 02a89c97..b03d2774 100644 --- a/apps/api/routes/api/admin/debug/tracker-status.get.ts +++ b/apps/api/routes/api/admin/debug/tracker-status.get.ts @@ -5,6 +5,7 @@ import { redis } from '~~/utils/server'; import { getGlobalStats } from '~~/utils/server'; import { requireAdminSession } from '~~/utils/adminAuth'; +import { trackerUdpEnabled } from '~~/utils/announceUrls'; export default defineEventHandler(async (event) => { // Admin only — live-role-revalidating gate (finding L9). @@ -25,7 +26,7 @@ export default defineEventHandler(async (event) => { // process state from here. Report the static protocol matrix and let the // operator check `docker compose ps tracker` for liveness. const trackerStatus = 'separate-process'; - const trackerProtocols = { http: true, udp: false, ws: false }; + const trackerProtocols = { http: true, udp: trackerUdpEnabled() }; // Get global stats from cache let globalStats: diff --git a/apps/api/routes/api/admin/panic/encrypt.post.ts b/apps/api/routes/api/admin/panic/encrypt.post.ts index 0b1d408d..7912558b 100644 --- a/apps/api/routes/api/admin/panic/encrypt.post.ts +++ b/apps/api/routes/api/admin/panic/encrypt.post.ts @@ -8,6 +8,7 @@ import { torrentComments, } from '@trackarr/db/schema'; import { requireAdminSession } from '~~/utils/adminAuth'; +import { invalidatePanicCache } from '~~/utils/panicLock'; import { auditDetail } from '~~/utils/audit'; import { z } from 'zod'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; @@ -159,6 +160,9 @@ export default defineEventHandler(async (event) => { phase: 'encrypting', }, }); + // From here on the API is read-only (`middleware/panic-lock`): every replica + // must see it now, not when its cache lapses. + await invalidatePanicCache(); // ── Encrypt sensitive user data ────────────────────────────── const allUsers = await db.select().from(users); @@ -258,6 +262,7 @@ export default defineEventHandler(async (event) => { phase: 'encrypted', }) .where(eq(panicState.id, 'singleton')); + await invalidatePanicCache(); return { success: true, @@ -275,11 +280,18 @@ export default defineEventHandler(async (event) => { * Restent EN CLAIR : `messages.body` et `room_messages.body` — soit tout le * texte des conversations privées et du salon public, le chiffrement de * bout en bout étant optionnel et par conversation —, les tickets et leurs - * messages, les titres de sujets de forum, la bio et le nom affiché, - * `totp_secret`, la configuration serveur des canaux, le journal de - * connexions (adresses, agents), les signalements de l'anti-triche, le - * journal d'audit, le miroir des torrents distants et la clé privée de - * fédération. + * messages, les titres de sujets de forum, la bio et le nom affiché, le + * NFO et les identifiants externes (IMDb, TMDb…) qui nomment le contenu + * aussi bien que le titre chiffré, les enregistrements signés du + * catalogue, les signalements, demandes, avertissements et IP bannies, le + * journal de connexions (adresses, agents), les signalements de + * l'anti-triche, le journal d'audit et le miroir des torrents distants. + * + * `totp_secret`, la configuration serveur des canaux et la clé privée de + * fédération figuraient dans cette liste ; ils sont en fait chiffrés au + * repos, mais par une clé tirée de l'environnement et non du mot de passe + * de panique : hors de portée d'un dump seul, à portée de qui tient le + * serveur. D'où une troisième liste. * * Étendre la couverture ferait de ces boucles quelque chose de bien plus * long, ce qui rend la reprise (voir `phase`) d'autant plus nécessaire. @@ -298,9 +310,22 @@ export default defineEventHandler(async (event) => { 'messages.body', 'room_messages.body', 'tickets.*', 'ticket_messages.*', 'forum_topics.title', 'users.bio', 'users.display_name', - 'users.totp_secret', 'notification_channels.server_config', + 'torrents.nfo', 'torrents.imdb_id', 'torrents.tmdb_id', 'torrents.tvdb_id', + 'torrents.igdb_id', 'torrents.openlibrary_id', + 'catalog_records.body', 'work_titles.*', + 'banned_ips.*', 'reports.*', 'upload_requests.*', 'upload_request_comments.*', + 'torrent_moderation_messages.*', 'user_warnings.*', + 'notifications.payload', 'saved_searches.*', 'search_misses.*', + 'messaging_broadcasts.*', 'login_events.*', 'anticheat_flags.ip', 'anticheat_flags.user_agent', - 'audit_log.*', 'remote_torrents.*', 'federation_config.private_key', + 'audit_log.*', 'remote_torrents.*', + ], + // Encrypted at rest already, but under a key from the environment, not + // under the panic password: out of reach of a database dump alone, within + // reach of whoever holds the server. They were listed as cleartext. + encryptedAtRestOnly: [ + 'users.totp_secret', 'notification_channels.server_config', + 'federation_config.private_key_enc', ], }; }); diff --git a/apps/api/routes/api/admin/panic/index.get.ts b/apps/api/routes/api/admin/panic/index.get.ts index a7105d1d..68ab8a48 100644 --- a/apps/api/routes/api/admin/panic/index.get.ts +++ b/apps/api/routes/api/admin/panic/index.get.ts @@ -14,5 +14,9 @@ export default defineEventHandler(async (event) => { return { isEncrypted: state?.isEncrypted ?? false, encryptedAt: state?.encryptedAt ?? null, + // `isEncrypted` is false for the whole encrypt pass and for an interrupted + // one; only the phase tells an admin that a pass needs resuming, or that a + // restore stopped half way. + phase: state?.phase ?? 'idle', }; }); diff --git a/apps/api/routes/api/admin/panic/restore.post.ts b/apps/api/routes/api/admin/panic/restore.post.ts index d1e85bbd..229fbd2f 100644 --- a/apps/api/routes/api/admin/panic/restore.post.ts +++ b/apps/api/routes/api/admin/panic/restore.post.ts @@ -7,9 +7,15 @@ import { forumPosts, torrentComments, } from '@trackarr/db/schema'; -import { deriveKey, decryptField, decrypt } from '~~/utils/panic'; +import { + deriveKey, + decryptFieldOrKeep, + decryptTorrentDataOrKeep, + decrypt, +} from '~~/utils/panic'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { redis } from '~~/utils/server'; +import { invalidatePanicCache } from '~~/utils/panicLock'; import { z } from 'zod'; /** @@ -165,19 +171,22 @@ export default defineEventHandler(async (event) => { * d'annonce étaient morts, sans qu'aucun compteur ne le dise. * * Tant qu'il reste un échec, le sel et la phase sont conservés : la - * restauration reste possible après correction, et elle est reprenable - * puisque les lignes déjà déchiffrées ne déchiffrent plus. + * restauration reste possible après correction, et elle est reprenable : + * une ligne déjà rendue ne se déchiffre plus, et `decryptFieldOrKeep` (ou + * `decryptTorrentDataOrKeep` pour le `.torrent`) la garde telle quelle au + * lieu de la compter en échec. */ const failures: string[] = []; const fail = (what: string, id: string, err: unknown) => { failures.push(`${what}:${id}`); - console.error(`[panic] restore failed for ${what} ${id}:`, err); + console.error('[panic] restore failed for %s %s:', what, id, err); }; await db .update(panicState) .set({ phase: 'restoring' }) .where(eq(panicState.id, 'singleton')); + await invalidatePanicCache(); // ── Decrypt user data ──────────────────────────────────────── const allUsers = await db.select().from(users); @@ -186,12 +195,12 @@ export default defineEventHandler(async (event) => { await db .update(users) .set({ - authSalt: decryptField(user.authSalt, key, legacyIv)!, - authVerifier: decryptField(user.authVerifier, key, legacyIv)!, - passkey: decryptField(user.passkey, key, legacyIv)!, - rssKey: decryptField(user.rssKey, key, legacyIv) ?? undefined, - apiKey: decryptField(user.apiKey, key, legacyIv) ?? undefined, - lastIp: decryptField(user.lastIp, key, legacyIv) ?? undefined, + authSalt: decryptFieldOrKeep(user.authSalt, key, legacyIv)!, + authVerifier: decryptFieldOrKeep(user.authVerifier, key, legacyIv)!, + passkey: decryptFieldOrKeep(user.passkey, key, legacyIv)!, + rssKey: decryptFieldOrKeep(user.rssKey, key, legacyIv) ?? undefined, + apiKey: decryptFieldOrKeep(user.apiKey, key, legacyIv) ?? undefined, + lastIp: decryptFieldOrKeep(user.lastIp, key, legacyIv) ?? undefined, }) .where(eq(users.id, user.id)); } catch (err) { @@ -223,34 +232,35 @@ export default defineEventHandler(async (event) => { } catch { // La taille et la catégorie sont dans ces métadonnées, et le // chiffrement a mis `size` à 0 : les perdre en silence était pire - // que de refuser de terminer. + // que de refuser de terminer. La ligne reste donc telle quelle : + // récrire sa description effacerait le seul exemplaire de ces + // métadonnées, et la tentative suivante n'aurait plus rien à relire. fail('torrent-meta', torrent.id, 'metadata'); + continue; } decryptedDesc = encryptedDescPart - ? decryptField(encryptedDescPart, key, legacyIv) + ? decryptFieldOrKeep(encryptedDescPart, key, legacyIv) : null; } else { - decryptedDesc = decryptField(torrent.description, key, legacyIv); + decryptedDesc = decryptFieldOrKeep(torrent.description, key, legacyIv); } // .torrent payload → ascii string (matches encrypt-side wrapping) - // → decrypt → base64 → Buffer. - let decryptedTorrentData: Buffer | null = null; + // → decrypt → base64 → Buffer, or the clear file a previous attempt + // already gave back. What is neither is counted and left as stored — + // never overwritten with null. + let decryptedTorrentData = torrent.torrentData; if (torrent.torrentData) { - try { - const encryptedStr = torrent.torrentData.toString('ascii'); - const decryptedBase64 = decrypt(encryptedStr, key, legacyIv); - decryptedTorrentData = Buffer.from(decryptedBase64, 'base64'); - } catch { - fail('torrent-data', torrent.id, 'torrentData'); - } + const restored = decryptTorrentDataOrKeep(torrent.torrentData, key, legacyIv); + if (restored) decryptedTorrentData = restored; + else fail('torrent-data', torrent.id, 'torrentData'); } await db .update(torrents) .set({ - name: decryptField(torrent.name, key, legacyIv) ?? torrent.name, + name: decryptFieldOrKeep(torrent.name, key, legacyIv) ?? torrent.name, description: decryptedDesc, torrentData: decryptedTorrentData, size: originalSize, @@ -269,7 +279,7 @@ export default defineEventHandler(async (event) => { await db .update(forumPosts) .set({ - content: decryptField(post.content, key, legacyIv) ?? post.content, + content: decryptFieldOrKeep(post.content, key, legacyIv) ?? post.content, }) .where(eq(forumPosts.id, post.id)); } catch (err) { @@ -284,7 +294,7 @@ export default defineEventHandler(async (event) => { await db .update(torrentComments) .set({ - content: decryptField(comment.content, key, legacyIv) ?? comment.content, + content: decryptFieldOrKeep(comment.content, key, legacyIv) ?? comment.content, }) .where(eq(torrentComments.id, comment.id)); } catch (err) { @@ -303,6 +313,7 @@ export default defineEventHandler(async (event) => { .update(panicState) .set({ phase: 'restoring' }) .where(eq(panicState.id, 'singleton')); + await invalidatePanicCache(); throw createError({ statusCode: 500, @@ -325,6 +336,7 @@ export default defineEventHandler(async (event) => { phase: 'idle', }) .where(eq(panicState.id, 'singleton')); + await invalidatePanicCache(); return { success: true, diff --git a/apps/api/routes/api/admin/stats.get.ts b/apps/api/routes/api/admin/stats.get.ts index 3b7ef380..15e2c584 100644 --- a/apps/api/routes/api/admin/stats.get.ts +++ b/apps/api/routes/api/admin/stats.get.ts @@ -5,6 +5,7 @@ import { redis } from '~~/utils/server'; import { requireAdminSession } from '~~/utils/adminAuth'; import { rollupActivePeerCounts } from '~~/utils/peerStats'; import { readTrackerHealth } from '~~/utils/trackerHealth'; +import { trackerUdpEnabled } from '~~/utils/announceUrls'; export default defineEventHandler(async (event) => { // Require admin authentication @@ -55,10 +56,9 @@ export default defineEventHandler(async (event) => { } // Protocol matrix mirrored from the tracker container's env. HTTP - // is always on; UDP toggles via `TRACKER_UDP_ENABLED` (default true, - // see apps/tracker/internal/config/config.go). The api process and - // tracker process share the same env so this read is authoritative - // without an internal RPC. + // is always on; UDP toggles via `TRACKER_UDP_ENABLED`, read exactly as the + // tracker reads it (`trackerUdpEnabled`). Authoritative only because the + // deployment hands both processes the same value. // L'état vient de la MÊME sonde que le badge de la page d'accueil. // // C'était `status: 'running'` en dur. Le tableau de bord affichait donc @@ -85,8 +85,7 @@ export default defineEventHandler(async (event) => { }, protocols: { http: true, - udp: process.env.TRACKER_UDP_ENABLED !== 'false', - ws: false, + udp: trackerUdpEnabled(), }, }; }); diff --git a/apps/api/routes/api/admin/users/[id]/ban.post.ts b/apps/api/routes/api/admin/users/[id]/ban.post.ts index 414c78a3..d58fd0dc 100644 --- a/apps/api/routes/api/admin/users/[id]/ban.post.ts +++ b/apps/api/routes/api/admin/users/[id]/ban.post.ts @@ -11,7 +11,7 @@ * sibling unban route can refuse a moderator trying to lift an * admin-issued ban. */ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { db } from '@trackarr/db'; import { users, bannedIps, torrents } from '@trackarr/db/schema'; import { @@ -76,12 +76,20 @@ export default defineEventHandler(async (event) => { isBanned: true, bannedById: actor.id, bannedByRole: actor.isAdmin ? 'admin' : 'moderator', + // Every session the member holds ends here, not only while the ban + // lasts. The ban gate refuses each request during the ban; without the + // epoch, a cookie nobody presented in the meantime — a copy taken from a + // shared machine, a device left offline — worked again the moment the + // ban was lifted or ran out. + sessionEpoch: sql`${users.sessionEpoch} + 1`, }) .where(eq(users.id, userId)); // Drop the cached `isBanned` so the lockout takes effect on the - // very next request rather than waiting up to 60 s for the TTL. + // very next request rather than waiting up to 60 s for the TTL, and the + // cached epoch with it. await invalidateBanCache(userId); + await invalidateRoleCache(userId); // A banned owner is an owner who cannot act, and the decisions reserved to // them are the ones an instance cannot get on without. Ownership moves to the diff --git a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts index f48e8c6b..798b10f2 100644 --- a/apps/api/routes/api/auth/2fa/passkey-verify.post.ts +++ b/apps/api/routes/api/auth/2fa/passkey-verify.post.ts @@ -23,6 +23,7 @@ import { markFreshAuth, } from '~~/utils/twoFactor'; import { issueTrustedDevice } from '~~/utils/trustedDevices'; +import { issueKnownDevice } from '~~/utils/knownDevices'; import { validateBody } from '~~/utils/schemas'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { recordLogin } from '~~/utils/account/loginLog'; @@ -153,10 +154,13 @@ export default defineEventHandler(async (event) => { isAdmin: true, isModerator: true, isOwner: true, + sessionEpoch: true, uploaded: true, downloaded: true, bonusPoints: true, trustDevicesEnabled: true, + // What the known-device cookie is bound to, with the epoch. + authVerifier: true, }, }); if (!user) throw createError({ statusCode: 401, message: 'User not found' }); @@ -179,12 +183,25 @@ export default defineEventHandler(async (event) => { isAdmin: user.isAdmin, isModerator: user.isModerator, isOwner: user.isOwner, + // The epoch `requireUserSession` compares on every request. Without it + // the session read as epoch 0: once a member had used "sign out + // everywhere", every sign-in through a passkey second factor produced a + // cookie revoked on its first request — the password and TOTP paths + // set it, this one did not. + sessionEpoch: user.sessionEpoch ?? 0, uploaded: user.uploaded, downloaded: user.downloaded, bonusPoints: user.bonusPoints, }, loggedInAt: Date.now(), }); + // The sign-in is complete only now, second factor met: this is where the + // browser becomes a known device (see `utils/knownDevices`). + issueKnownDevice(event, { + userId: user.id, + sessionEpoch: user.sessionEpoch, + authVerifier: user.authVerifier, + }); // Real h3 session id, not the session-data object (finding H1). await markFreshAuth(await getSessionId(event)); diff --git a/apps/api/routes/api/auth/2fa/verify-totp.post.ts b/apps/api/routes/api/auth/2fa/verify-totp.post.ts index 1dc6585b..edb734d9 100644 --- a/apps/api/routes/api/auth/2fa/verify-totp.post.ts +++ b/apps/api/routes/api/auth/2fa/verify-totp.post.ts @@ -18,6 +18,7 @@ import { verifyTotp, } from '~~/utils/twoFactor'; import { issueTrustedDevice } from '~~/utils/trustedDevices'; +import { issueKnownDevice } from '~~/utils/knownDevices'; import { validateBody } from '~~/utils/schemas'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { notify } from '~~/utils/notify'; @@ -71,6 +72,8 @@ export default defineEventHandler(async (event) => { totpSecret: true, totpEnabled: true, trustDevicesEnabled: true, + // What the known-device cookie is bound to, with the epoch. + authVerifier: true, }, }); if (!user) { @@ -170,6 +173,13 @@ export default defineEventHandler(async (event) => { }, loggedInAt: Date.now(), }); + // The sign-in is complete only now, second factor met: this is where the + // browser becomes a known device (see `utils/knownDevices`). + issueKnownDevice(event, { + userId: user.id, + sessionEpoch: user.sessionEpoch, + authVerifier: user.authVerifier, + }); // Real h3 session id, not the session-data object (finding H1). await markFreshAuth(await getSessionId(event)); diff --git a/apps/api/routes/api/auth/challenge.get.ts b/apps/api/routes/api/auth/challenge.get.ts index f1677764..266f459f 100644 --- a/apps/api/routes/api/auth/challenge.get.ts +++ b/apps/api/routes/api/auth/challenge.get.ts @@ -9,15 +9,21 @@ import { db } from '@trackarr/db'; import { users } from '@trackarr/db/schema'; import { redis } from '~~/utils/server'; import { rateLimit } from '~~/utils/rateLimit'; +import { UNKNOWN_ACCOUNT_CHALLENGE } from '~~/utils/loginChallenge'; const CHALLENGE_TTL = 300; // 5 minutes -/** 20 challenges per 5 min per IP, with the progressive penalty. */ +/** + * 20 challenges per 5 min per address (an IPv6 /64), WITHOUT the progressive + * penalty. That penalty puts the whole address on the blacklist, every route + * included: on a VPN exit or a carrier NAT, twenty-one requests from one + * stranger signed everybody else out of the site — the reason the sign-in + * itself left it (`RATE_LIMITS.signin`). + */ const CHALLENGE_LIMIT = { windowSec: 300, maxRequests: 20, prefix: 'challenge', - progressive: true, } as const; // Stable per-process key for deriving the *fake* salt of non-existent @@ -47,9 +53,9 @@ function fakeSaltFor(username: string): string { export default defineEventHandler(async (event) => { // The only auth route that had no limit. Enumeration is already closed by // the deterministic fake salt, but every call still costs a `SELECT` on - // `users` AND writes a Redis key with a 5-minute TTL — including the - // `login:fake:*` keys, which nothing ever consumes. Unbounded, that is a - // slow memory drain on Redis for the price of an unauthenticated GET. + // `users` AND writes a Redis key with a 5-minute TTL, for unknown usernames + // too. Unbounded, that is a slow memory drain on Redis for the price of an + // unauthenticated GET. // // More generous than RATE_LIMITS.auth: a challenge is not an attempt, and a // legitimate client fetches one per login screen, sometimes twice if the @@ -80,10 +86,15 @@ export default defineEventHandler(async (event) => { if (!user || !user.authSalt) { // Return a DETERMINISTIC fake salt (stable per username) so an // attacker can't tell real from fake by requesting twice and - // diffing the salt (finding M1). Still do the same Redis write - // for timing parity. + // diffing the salt (finding M1). + // + // Stored under the SAME key as a real challenge. It used to go to + // `login:fake:*`, which the login never reads: an unknown username was + // answered "Invalid or expired challenge" and a known one with a wrong + // proof "Invalid credentials" — the roster the fake salt was hiding, read + // off the error message instead. const fakeSalt = fakeSaltFor(username); - await redis.set(`login:fake:${challenge}`, '1', 'EX', CHALLENGE_TTL); + await redis.set(`login:${challenge}`, UNKNOWN_ACCOUNT_CHALLENGE, 'EX', CHALLENGE_TTL); return { salt: fakeSalt, challenge }; } diff --git a/apps/api/routes/api/auth/login.post.ts b/apps/api/routes/api/auth/login.post.ts index 0225eab0..1a19e7ea 100644 --- a/apps/api/routes/api/auth/login.post.ts +++ b/apps/api/routes/api/auth/login.post.ts @@ -4,37 +4,83 @@ import { db } from '@trackarr/db'; import { users, bannedIps, webauthnCredentials } from '@trackarr/db/schema'; import { validateBody, loginSchema } from '~~/utils/schemas'; import { redis } from '~~/utils/server'; -import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { + rateLimit, + RATE_LIMITS, + assertAccountNotLocked, + recordAccountFailure, + clearAccountFailures, +} from '~~/utils/rateLimit'; +import { UNKNOWN_ACCOUNT_CHALLENGE } from '~~/utils/loginChallenge'; import { mintChallengeToken, markFreshAuth } from '~~/utils/twoFactor'; import { consumeTrustedDevice } from '~~/utils/trustedDevices'; +import { + readKnownDevice, + recordKnownDeviceFailure, + issueKnownDevice, +} from '~~/utils/knownDevices'; import { notify } from '~~/utils/notify'; import { liftExpiredBan } from '~~/utils/banExpiry'; import { decryptSecret, encryptSecretRequired, needsRewrite } from '~~/utils/credentialSecrets'; import { recordLogin } from '~~/utils/account/loginLog'; +/** + * How long refusing credentials takes, at least. + * + * The answer to a username with no account and to a wrong proof is the same + * status and message, but not the same work: the second reads the account, + * decrypts its verifier and hashes, and was a few milliseconds slower — which + * enumerates members as surely as a different message, given enough samples. + * Both now end no sooner than this after the request arrived. Well above what + * either path costs; unnoticeable after a 600 000-iteration PBKDF2 in the + * browser. + */ +const REFUSAL_FLOOR_MS = 300; + /** * POST /api/auth/login * Authenticate user with Zero Knowledge proof * User proves knowledge of password without sending it */ export default defineEventHandler(async (event) => { - // 5 tries per 5 minutes per IP, with progressive penalties — keeps - // online brute-force of ZK proofs out of reach. - await rateLimit(event, RATE_LIMITS.auth); + const arrivedAt = Date.now(); + // 10 tries per 5 minutes per address (an IPv6 /64), WITHOUT the progressive + // blacklist — see `RATE_LIMITS.signin` for why a sign-in failure must not + // shut a whole address out of the site. Guessing spread over many addresses + // meets the per-account counter instead. + await rateLimit(event, RATE_LIMITS.signin); const body = await validateBody(event, loginSchema); + // A browser that has already signed in to this account answers to its own + // failure counter instead of the account's: whoever fails on purpose from + // elsewhere can hold the account shut to new devices, not to the member's + // own. See `utils/knownDevices`. + const knownDevice = await readKnownDevice(event, body.username); + if (!knownDevice) await assertAccountNotLocked(body.username); - // Verify challenge is valid and get associated user ID - const userId = await redis.get(`login:${body.challenge}`); + // Consumed atomically: two concurrent requests with the same challenge used + // to both read it before either deleted it. + const userId = await redis.getdel(`login:${body.challenge}`); if (!userId) { throw createError({ statusCode: 401, message: 'Invalid or expired challenge', }); } - - // Delete challenge immediately to prevent reuse - await redis.del(`login:${body.challenge}`); - + + // A username with no account takes the same exit as a wrong proof — same + // status, same message, same failure count. See `utils/loginChallenge`. + const rejectCredentials = async () => { + if (knownDevice) await recordKnownDeviceFailure(knownDevice); + else await recordAccountFailure(body.username); + const wait = REFUSAL_FLOOR_MS - (Date.now() - arrivedAt); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + return createError({ + statusCode: 401, + message: 'Invalid credentials', + }); + }; + if (userId === UNKNOWN_ACCOUNT_CHALLENGE) throw await rejectCredentials(); + // Find user by ID from challenge const user = await db .select() @@ -43,12 +89,7 @@ export default defineEventHandler(async (event) => { .limit(1) .then((r) => r[0]); - if (!user || user.username !== body.username) { - throw createError({ - statusCode: 401, - message: 'Invalid credentials', - }); - } + if (!user || user.username !== body.username) throw await rejectCredentials(); // ── Verify ZKE proof FIRST ────────────────────────────────── // Username/account-status checks (banned, etc.) deliberately run @@ -57,12 +98,7 @@ export default defineEventHandler(async (event) => { // banned accounts without ever solving the SRP challenge. With the // proof gate on top, those branches are unreachable to anyone // without the password. - if (!user.authVerifier) { - throw createError({ - statusCode: 401, - message: 'Invalid credentials', - }); - } + if (!user.authVerifier) throw await rejectCredentials(); // The verifier is encrypted at rest. `decryptSecret` returns a legacy // plaintext value unchanged, so accounts created before the rollout keep @@ -81,29 +117,31 @@ export default defineEventHandler(async (event) => { .digest('hex'); if (!secureCompare(body.proof, expectedProof)) { - // Recorded before the throw. There is no per-account lockout on this site — - // throttling is entirely per IP — so an attempt spread across addresses - // meets nothing at all, and this row is the only trace it leaves. + // Recorded before the throw: the member and staff read failed attempts + // here, whatever the per-account counter makes of them. void recordLogin(event, { userId: user.id, username: user.username, method: 'password', outcome: 'failed', }); - throw createError({ - statusCode: 401, - message: 'Invalid credentials', - }); + throw await rejectCredentials(); } + // Not after a known device's success: the account's failures came from + // somewhere else, and wiping them would hand whoever made them a fresh ten. + if (!knownDevice) await clearAccountFailures(body.username); // Lazy migration: a proven-correct login is the safe moment to write the // verifier back under the current key — whether it was legacy plaintext or // only readable through CREDENTIAL_ENCRYPTION_KEY_PREVIOUS. Fire-and-forget: // a failure here must never cost the user their login, the next one retries. + // The known-device cookie issued below is bound to the column as it WILL be. + let storedAuthVerifier = user.authVerifier; if (needsRewrite(user.authVerifier)) { + storedAuthVerifier = encryptSecretRequired(storedVerifier); void db .update(users) - .set({ authVerifier: encryptSecretRequired(storedVerifier) }) + .set({ authVerifier: storedAuthVerifier }) .where(eq(users.id, user.id)) .catch(() => {}); } @@ -230,6 +268,11 @@ export default defineEventHandler(async (event) => { }, loggedInAt: Date.now(), }); + issueKnownDevice(event, { + userId: user.id, + sessionEpoch: user.sessionEpoch, + authVerifier: storedAuthVerifier, + }); // `trusted-device` when the second factor was skipped by a remembered // browser, `password` otherwise. The distinction is the point: a member diff --git a/apps/api/routes/api/auth/passkey.get.ts b/apps/api/routes/api/auth/passkey.get.ts index 5554de4e..6b57930a 100644 --- a/apps/api/routes/api/auth/passkey.get.ts +++ b/apps/api/routes/api/auth/passkey.get.ts @@ -1,3 +1,7 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@trackarr/db'; +import { users } from '@trackarr/db/schema'; + /** * GET /api/auth/passkey * Returns the current user's passkey (private, only accessible to the user themselves) @@ -20,8 +24,15 @@ export default defineEventHandler(async (event) => { const { user } = await requireAuthSession(event); - // The passkey is stored in the session, which is only accessible to the authenticated user + // Read from the account, not from the session: the cookie keeps the passkey + // of the moment the member signed in, and after a rotation on another device + // that copy no longer announces. + const [row] = await db + .select({ passkey: users.passkey }) + .from(users) + .where(eq(users.id, user.id)) + .limit(1); return { - passkey: user.passkey, + passkey: row?.passkey ?? user.passkey, }; }); diff --git a/apps/api/routes/api/auth/password.put.ts b/apps/api/routes/api/auth/password.put.ts index f3948aae..1c8dcd73 100644 --- a/apps/api/routes/api/auth/password.put.ts +++ b/apps/api/routes/api/auth/password.put.ts @@ -66,15 +66,15 @@ export default defineEventHandler(async (event) => { // Verify the challenge belongs to this user (prevents a hijacker who // swapped a session cookie from reusing someone else's challenge). - const challengedUserId = await redis.get(`login:${body.challenge}`); + // One-shot, and atomically so: burning it in a second step left a window in + // which two requests could both use it, and a leaked proof be replayed. + const challengedUserId = await redis.getdel(`login:${body.challenge}`); if (!challengedUserId || challengedUserId !== user.id) { throw createError({ statusCode: 401, message: 'Invalid or expired challenge', }); } - // One-shot: burn the challenge so a leaked proof can't be replayed. - await redis.del(`login:${body.challenge}`); const row = await db.query.users.findFirst({ where: eq(schema.users.id, user.id), diff --git a/apps/api/routes/api/auth/register.post.ts b/apps/api/routes/api/auth/register.post.ts index 214daee3..4dca0a73 100644 --- a/apps/api/routes/api/auth/register.post.ts +++ b/apps/api/routes/api/auth/register.post.ts @@ -16,6 +16,7 @@ import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { markFreshAuth } from '~~/utils/twoFactor'; import { encryptSecretRequired } from '~~/utils/credentialSecrets'; import { recordLogin } from '~~/utils/account/loginLog'; +import { issueKnownDevice } from '~~/utils/knownDevices'; /** * Postgres advisory lock id used to serialise the "first user gets @@ -193,6 +194,12 @@ export default defineEventHandler(async (event) => { // the invite (if any) in the same transaction. Anything that can // fail here rolls back as a unit — no partially-created admin // accounts, no consumed-but-unattributed invites. + // Encrypted at rest: the verifier IS the login credential, not a hash of it, + // so a database dump would otherwise be an account takeover. Computed once, + // here, because the known-device cookie issued below is bound to the exact + // stored value and the encryption is not deterministic. + const storedAuthVerifier = encryptSecretRequired(body.authVerifier); + const finalIsFirstUser = await db.transaction(async (tx) => { await tx.execute( sql`SELECT pg_advisory_xact_lock(${REGISTER_FIRST_USER_LOCK_ID}::bigint)` @@ -209,9 +216,7 @@ export default defineEventHandler(async (event) => { id: userId, username: body.username, authSalt: body.authSalt, - // Encrypted at rest: the verifier IS the login credential, not a hash - // of it, so a database dump would otherwise be an account takeover. - authVerifier: encryptSecretRequired(body.authVerifier), + authVerifier: storedAuthVerifier, passkey, isAdmin: settledFirstUser, isModerator: false, @@ -323,6 +328,9 @@ export default defineEventHandler(async (event) => { }, loggedInAt: Date.now(), }); + // The browser that just chose the password is the first known device: a + // lockout started before its first sign-in must not shut it out too. + issueKnownDevice(event, { userId, sessionEpoch: 0, authVerifier: storedAuthVerifier }); // Open the fresh-auth window, exactly as `login.post.ts` does and at the same // point — after the session exists, keyed on the real h3 session id rather diff --git a/apps/api/routes/api/auth/status.get.ts b/apps/api/routes/api/auth/status.get.ts index 54f351f9..74ae7b50 100644 --- a/apps/api/routes/api/auth/status.get.ts +++ b/apps/api/routes/api/auth/status.get.ts @@ -48,14 +48,19 @@ export default defineEventHandler(async (event) => { isAdmin: users.isAdmin, isModerator: users.isModerator, isOwner: users.isOwner, + sessionEpoch: users.sessionEpoch, + passkey: users.passkey, }) .from(users) .where(eq(users.id, session.user.id)) .limit(1); if (dbUser) { - // Check if user is banned - if (dbUser.isBanned) { + // A banned member, or a session revoked by "sign out everywhere" (or by + // the ban itself): the same answer `requireUserSession` gives on every + // other route. Without the epoch here the page kept showing a member + // signed in whose every action then failed with 401. + if (dbUser.isBanned || (session.user.sessionEpoch ?? 0) !== dbUser.sessionEpoch) { await clearUserSession(event); publicUser = null; } else { @@ -103,7 +108,8 @@ export default defineEventHandler(async (event) => { dbUser.displayName !== sessionDisplayName || dbUser.theme !== sessionTheme || dbUser.language !== sessionLanguage || - dbUser.bonusPoints !== sessionBonusPoints + dbUser.bonusPoints !== sessionBonusPoints || + dbUser.passkey !== session.user.passkey ) { await setUserSession(event, { ...session, @@ -118,6 +124,11 @@ export default defineEventHandler(async (event) => { isAdmin: dbUser.isAdmin, isModerator: dbUser.isModerator, isOwner: dbUser.isOwner, + // The announce passkey the download routes write into every + // `.torrent`. Rotated on one device, it stayed stale in the + // cookie of every other one, whose downloads then announced + // with a dead key. + passkey: dbUser.passkey, }, }); } diff --git a/apps/api/routes/api/branding.get.ts b/apps/api/routes/api/branding.get.ts index 208706c1..bdc4a960 100644 --- a/apps/api/routes/api/branding.get.ts +++ b/apps/api/routes/api/branding.get.ts @@ -21,7 +21,7 @@ import { type ServableTheme, } from '~~/utils/themes'; import { resolveTokens } from '@trackarr/shared/theme'; -import { getCatalogueSettings } from '~~/utils/settings'; +import { getCatalogueSettings, getSiteRules } from '~~/utils/settings'; /** * GET /api/branding @@ -107,6 +107,8 @@ export default defineEventHandler(async (event) => { : allThemes; const themeDefault = await getDefaultTheme(); const catalogue = await getCatalogueSettings(); + // Whether the footer links to `/rules`: a cached read like the rest. + const hasRules = !!(await getSiteRules()).trim(); return { siteName, @@ -123,6 +125,7 @@ export default defineEventHandler(async (event) => { // False when federation was never configured, which is the same answer as // configured-and-off for anything the browser does with it. federationEnabled, + hasRules, // Les réglages du catalogue : la page en a besoin avant tout appel signé, // et ils sont publics à tout membre — même enveloppe que le reste. catalogue, diff --git a/apps/api/routes/api/me/favorites.get.ts b/apps/api/routes/api/me/favorites.get.ts index 97bb80c8..3462a72c 100644 --- a/apps/api/routes/api/me/favorites.get.ts +++ b/apps/api/routes/api/me/favorites.get.ts @@ -18,6 +18,7 @@ import { and, asc, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getStats } from '~~/utils/server'; import { validateQuery } from '~~/utils/schemas'; +import { visibilityConditions } from '~~/utils/torrentListing'; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -44,9 +45,32 @@ export default defineEventHandler(async (event) => { ? [asc(schema.torrents.name)] : [desc(schema.torrentFavorites.createdAt)]; + // A favorite outlives the torrent's visibility — rejected since, deactivated, + // or adult content the member has since hidden — and this listing used to + // serve it whatever it had become. Same conditions as the catalogue. + const vis = await visibilityConditions({ + id: user.id, + isAdmin: !!user.isAdmin, + isModerator: !!user.isModerator, + }); + const where = and(eq(schema.torrentFavorites.userId, user.id), ...vis); + const rows = await db .select({ - torrent: schema.torrents, + // The columns the rows below are built from, not the whole row: that + // carried the raw `.torrent` of every favorite. + torrent: { + id: schema.torrents.id, + infoHash: schema.torrents.infoHash, + name: schema.torrents.name, + size: schema.torrents.size, + createdAt: schema.torrents.createdAt, + imdbId: schema.torrents.imdbId, + tmdbId: schema.torrents.tmdbId, + tvdbId: schema.torrents.tvdbId, + igdbId: schema.torrents.igdbId, + openlibraryId: schema.torrents.openlibraryId, + }, category: schema.categories, favoritedAt: schema.torrentFavorites.createdAt, }) @@ -59,7 +83,7 @@ export default defineEventHandler(async (event) => { schema.categories, eq(schema.torrents.categoryId, schema.categories.id), ) - .where(eq(schema.torrentFavorites.userId, user.id)) + .where(where) .orderBy(...orderClause) .limit(query.limit) .offset(offset); @@ -67,7 +91,8 @@ export default defineEventHandler(async (event) => { const [{ value: total } = { value: 0 }] = await db .select({ value: sql`count(*)::int` }) .from(schema.torrentFavorites) - .where(eq(schema.torrentFavorites.userId, user.id)); + .innerJoin(schema.torrents, eq(schema.torrentFavorites.torrentId, schema.torrents.id)) + .where(where); // Enrich with Redis swarm stats. Tolerant of partial failure — // a missing key falls back to zeroes rather than torpedoing the diff --git a/apps/api/routes/api/me/sessions/revoke-all.post.ts b/apps/api/routes/api/me/sessions/revoke-all.post.ts index eafe96df..9920e34d 100644 --- a/apps/api/routes/api/me/sessions/revoke-all.post.ts +++ b/apps/api/routes/api/me/sessions/revoke-all.post.ts @@ -29,6 +29,7 @@ import { db, schema } from '@trackarr/db'; import { eq, sql } from 'drizzle-orm'; import { requireAuthSession, requireFreshAuth } from '~~/utils/adminAuth'; import { invalidateRoleCache } from '~~/utils/liveRoles'; +import { revokeAllForUser } from '~~/utils/trustedDevices'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { auditDetail } from '~~/utils/audit'; @@ -52,6 +53,11 @@ export default defineEventHandler(async (event) => { // Le cache de rôles porte l'époque : sans cette purge, l'instance // continuerait d'accepter les sessions périmées jusqu'à 60 s. await invalidateRoleCache(session.user.id); + // And the trusted devices: a device let through 2FA once stays trusted for + // its own cookie's lifetime, so a compromised one kept skipping the second + // factor at its next password sign-in. A password change already revokes + // them; this is the same gesture, made for the same reason. + await revokeAllForUser(session.user.id); // On ne vide pas le cookie ici : la prochaine requête le fera, avec le motif // `session-revoked` que le front sait présenter. Le vider maintenant diff --git a/apps/api/routes/api/rss/latest.get.ts b/apps/api/routes/api/rss/latest.get.ts index 03f5d800..9b70b42c 100644 --- a/apps/api/routes/api/rss/latest.get.ts +++ b/apps/api/routes/api/rss/latest.get.ts @@ -109,6 +109,8 @@ function buildRSSFeed(feed: RSSFeed): string { // are independent. ES2021 `replaceAll` makes the literal-string // intent explicit (no global-regex / per-char-class confusion). const escapeXml = (str: string) => + // Complete for XML: & first, then < > " '. + // nosemgrep: javascript.audit.detect-replaceall-sanitization.detect-replaceall-sanitization str .replaceAll('&', '&') .replaceAll('<', '<') diff --git a/apps/api/routes/api/rules.get.ts b/apps/api/routes/api/rules.get.ts new file mode 100644 index 00000000..362fc368 --- /dev/null +++ b/apps/api/routes/api/rules.get.ts @@ -0,0 +1,16 @@ +/** + * GET /api/rules — the site rules, as staff wrote them in Admin → Branding. + * + * The admin form has long described them as "displayed to users", and nothing + * served them: they lived in the editor and nowhere else. Public, like + * `/api/privacy`: somebody holding an invitation reads the rules before + * deciding to register, and nothing in them is about any member. Rich text + * from the editor, stored as HTML; the page sanitises it. + */ +import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; +import { getSiteRules } from '~~/utils/settings'; + +export default defineEventHandler(async (event) => { + await rateLimit(event, RATE_LIMITS.public); + return { rules: await getSiteRules() }; +}); diff --git a/apps/api/routes/api/runtime-config.get.ts b/apps/api/routes/api/runtime-config.get.ts index 22d52438..2f56133e 100644 --- a/apps/api/routes/api/runtime-config.get.ts +++ b/apps/api/routes/api/runtime-config.get.ts @@ -3,7 +3,7 @@ * * Public endpoint that surfaces the small set of *runtime* config values * the browser bundle needs to know — primarily the public tracker URLs - * (HTTP / UDP / WebSocket). + * (HTTP / UDP). * * Why an endpoint instead of build-time substitution? When the web app * is served as a fully static bundle (the new Dockerfile.static path, @@ -30,7 +30,6 @@ export default defineEventHandler((event) => { return { trackerHttpUrl: (cfg.public.trackerHttpUrl as string) ?? '', trackerUdpUrl: (cfg.public.trackerUdpUrl as string) ?? '', - trackerWsUrl: (cfg.public.trackerWsUrl as string) ?? '', // `appVersion` was read off `cfg.public`, where it was never declared — // Nitro only maps env vars onto declared runtime-config keys, so this // always served an empty string and the web fell back to whatever version diff --git a/apps/api/routes/api/stats/public.get.ts b/apps/api/routes/api/stats/public.get.ts index 8e904109..fb09de8d 100644 --- a/apps/api/routes/api/stats/public.get.ts +++ b/apps/api/routes/api/stats/public.get.ts @@ -2,6 +2,7 @@ import { db, schema } from '@trackarr/db'; import { sql } from 'drizzle-orm'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { rollupActivePeerCounts } from '~~/utils/peerStats'; +import { trackerUdpEnabled } from '~~/utils/announceUrls'; /** * GET /api/stats/public @@ -41,15 +42,15 @@ interface PublicStats { peers: number; }; /** - * Static protocol matrix mirrored from /api/admin/stats. The Go tracker - * only implements HTTP — UDP and WebSocket are not built in. We expose - * the same shape on the public endpoint so the homepage can render the + * Protocol matrix mirrored from /api/admin/stats. The Go tracker serves + * HTTP, and UDP when `TRACKER_UDP_ENABLED` is on. (A `ws` entry, always + * false, listed a WebSocket transport that never existed.) We expose the + * same shape on the public endpoint so the homepage can render the * "Protocol health" tile without needing admin credentials. */ protocols: { http: boolean; udp: boolean; - ws: boolean; }; } @@ -107,8 +108,7 @@ async function computeStats(): Promise { // history — the cost/value ratio doesn't justify it). protocols: { http: true, - udp: process.env.TRACKER_UDP_ENABLED !== 'false', - ws: false, + udp: trackerUdpEnabled(), }, }; } diff --git a/apps/api/routes/api/torrents/[hash]/comments.post.ts b/apps/api/routes/api/torrents/[hash]/comments.post.ts index 48f8bd21..f51250d4 100644 --- a/apps/api/routes/api/torrents/[hash]/comments.post.ts +++ b/apps/api/routes/api/torrents/[hash]/comments.post.ts @@ -3,6 +3,7 @@ import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { db, schema } from '@trackarr/db'; import { torrents, torrentComments } from '@trackarr/db/schema'; import { canComment } from '~~/utils/commentPolicy'; +import { assertVisibleTorrent } from '~~/utils/torrentListing'; import { requireAuthSession } from '~~/utils/adminAuth'; import { validateParam, @@ -22,6 +23,12 @@ export default defineEventHandler(async (event) => { // Validate request body const body = await validateBody(event, torrentCommentSchema); + // Reading the comments goes through the listing's visibility; posting one + // did not. Any member could comment on a pending, inactive or adult-hidden + // torrent — notifying its uploader — and the 200 against the 404 confirmed + // that the torrent existed. + await assertVisibleTorrent(hash.toLowerCase(), session.user); + // Find torrent by hash to get its UUID const torrent = await db.query.torrents.findFirst({ where: eq(torrents.infoHash, hash.toLowerCase()), diff --git a/apps/api/routes/api/torrents/[hash]/download.get.ts b/apps/api/routes/api/torrents/[hash]/download.get.ts index 585acd80..e0c5b852 100644 --- a/apps/api/routes/api/torrents/[hash]/download.get.ts +++ b/apps/api/routes/api/torrents/[hash]/download.get.ts @@ -1,6 +1,7 @@ import { db, schema } from '@trackarr/db'; import { eq } from 'drizzle-orm'; -import bencode from 'bencode'; +import { personalizeTorrent } from '~~/utils/torrentBytes'; +import { personalAnnounceUrls, trackerUdpEnabled } from '~~/utils/announceUrls'; import { recordDownloadClick } from '~~/utils/hnr'; export default defineEventHandler(async (event) => { @@ -84,83 +85,36 @@ export default defineEventHandler(async (event) => { console.warn('[download] failed to record download click', err); } - // Inject passkey into torrent data - let decoded: any; + // The announce passkey as it stands now. The session cookie holds the one + // the member had at sign-in: rotated since on another device, that copy is + // dead, and every `.torrent` downloaded here would announce with it. + const [owner] = await db + .select({ passkey: schema.users.passkey }) + .from(schema.users) + .where(eq(schema.users.id, user.id)) + .limit(1); + if (!owner?.passkey) { + throw createError({ statusCode: 401, message: 'Unauthorized' }); + } + + // The member's announce URLs, written into the stored bytes without + // decoding them: `utils/torrentBytes` says why a decode → encode round trip + // corrupted every v2 torrent's piece layers. + const { announce, tiers } = personalAnnounceUrls(owner.passkey, { + httpUrl: config.public.trackerHttpUrl as string, + udpUrl: (config.public.trackerUdpUrl as string) || '', + udpEnabled: trackerUdpEnabled(), + }); + let personalizedData: Buffer; try { - decoded = bencode.decode(torrent.torrentData); - } catch (err) { + personalizedData = personalizeTorrent(Buffer.from(torrent.torrentData), announce, tiers); + } catch { throw createError({ statusCode: 500, message: 'Failed to decode torrent file', }); } - // Personalize announce URL(s). - // - // HTTP (always present): `…/announce?passkey=…` — the canonical form - // every BT client understands. - // - // UDP (BEP 15, only if NUXT_PUBLIC_TRACKER_UDP_URL is set): we encode - // the passkey as a trailing path segment — `udp://host:6969/announce/PASSKEY` - // — because BEP 15 has no native passkey field and the UDP tracker - // pulls it out of the BEP 41 URL_DATA option that libtorrent / - // qBittorrent / Transmission send for the path. Embedding the - // passkey straight into the URL means the user never has to copy- - // paste it into qBittorrent's tracker list manually. - const httpUrl = new URL(config.public.trackerHttpUrl as string); - httpUrl.searchParams.set('passkey', user.passkey); - const personalizedHttpUrl = httpUrl.toString(); - - // Only emit the UDP entry when the tracker is *actually* serving - // UDP. Two conditions must both hold: - // 1. `NUXT_PUBLIC_TRACKER_UDP_URL` is configured (otherwise we - // have no idea what host:port to advertise). - // 2. `TRACKER_UDP_ENABLED` isn't explicitly set to `false` — - // same env the tracker process reads in `internal/config`. - // Both processes share the same env in our compose setup, - // so this is authoritative without an internal RPC. - // - // Without this guard, an operator who disabled UDP would still - // ship `.torrent` files pointing clients at a dead udp:// endpoint; - // BT clients that prefer UDP would then back off the working HTTP - // tracker for a while every announce, breaking ratio in subtle ways. - let personalizedUdpUrl: string | null = null; - const udpEnabled = process.env.TRACKER_UDP_ENABLED !== 'false'; - const rawUdpUrl = (config.public.trackerUdpUrl as string) || ''; - if (udpEnabled && rawUdpUrl) { - try { - const udpUrl = new URL(rawUdpUrl); - // Strip any trailing slash on the configured path so the - // passkey segment doesn't double up. - const basePath = udpUrl.pathname.replace(/\/+$/, '') || '/announce'; - udpUrl.pathname = `${basePath}/${user.passkey}`; - personalizedUdpUrl = udpUrl.toString(); - } catch { - // A misconfigured tracker URL shouldn't tank the .torrent - // download. Log and fall back to HTTP-only. - console.warn('[download] invalid trackerUdpUrl, skipping UDP announce-list entry:', rawUdpUrl); - } - } - - // BEP 12 announce-list — each tier is a list of equivalent trackers - // tried in order. We put HTTP and UDP in their own tiers so a client - // hammers both independently rather than treating them as fallbacks - // of each other; that mirrors how every tracker we've cribbed off of - // (opentrackr, AnimeBytes, etc.) advertises a multi-protocol setup. - decoded.announce = Buffer.from(personalizedHttpUrl); - const tiers: Buffer[][] = [[Buffer.from(personalizedHttpUrl)]]; - if (personalizedUdpUrl) { - tiers.push([Buffer.from(personalizedUdpUrl)]); - } - decoded['announce-list'] = tiers; - - // Set private flag to 1 - if (decoded.info) { - decoded.info.private = 1; - } - - const personalizedData = bencode.encode(decoded); - // Sanitize filename const filename = torrent.name.replace(/[^a-zA-Z0-9._-]/g, '_') + '.torrent'; diff --git a/apps/api/routes/api/torrents/[hash]/favorite.post.ts b/apps/api/routes/api/torrents/[hash]/favorite.post.ts index b8b8af42..23e615cc 100644 --- a/apps/api/routes/api/torrents/[hash]/favorite.post.ts +++ b/apps/api/routes/api/torrents/[hash]/favorite.post.ts @@ -10,9 +10,9 @@ * private, and surfacing "X starred your upload" would tilt the * feature into a popularity contest the tracker doesn't need. */ -import { eq } from 'drizzle-orm'; import { db } from '@trackarr/db'; -import { torrents, torrentFavorites } from '@trackarr/db/schema'; +import { torrentFavorites } from '@trackarr/db/schema'; +import { assertVisibleTorrent } from '~~/utils/torrentListing'; import { requireAuthSession } from '~~/utils/adminAuth'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; import { validateParam, infoHashSchema } from '~~/utils/schemas'; @@ -22,14 +22,10 @@ export default defineEventHandler(async (event) => { await rateLimit(event, RATE_LIMITS.mutation); const hash = validateParam(event, 'hash', infoHashSchema); - const torrent = await db.query.torrents.findFirst({ - where: eq(torrents.infoHash, hash.toLowerCase()), - columns: { id: true }, - }); - - if (!torrent) { - throw createError({ statusCode: 404, message: 'Torrent not found' }); - } + // The listing's visibility, not mere existence: knowing an info hash was + // enough to favorite a pending, inactive or adult-hidden torrent — and then + // read its name, size and ids back from the favorites page. + const torrent = await assertVisibleTorrent(hash.toLowerCase(), session.user); await db .insert(torrentFavorites) diff --git a/apps/api/routes/api/torrents/check.post.ts b/apps/api/routes/api/torrents/check.post.ts index 3cea2f91..c429edca 100644 --- a/apps/api/routes/api/torrents/check.post.ts +++ b/apps/api/routes/api/torrents/check.post.ts @@ -15,6 +15,7 @@ */ import { db } from '@trackarr/db'; import { parseTorrentFile } from '~~/utils/parseTorrentFile'; +import { normalizeUploadedTorrent } from '~~/utils/torrentBytes'; import { rateLimit, RATE_LIMITS } from '~~/utils/rateLimit'; export default defineEventHandler(async (event) => { @@ -48,9 +49,13 @@ export default defineEventHandler(async (event) => { }); } + // Hashed after the upload's own normalisation, not on the raw file: a + // torrent uploaded without the private flag is stored with it, under a + // different infohash, so hashing the raw bytes answered "not on the tracker" + // for a release that already was. let parsed: Awaited>; try { - parsed = await parseTorrentFile(file.data); + parsed = await parseTorrentFile(normalizeUploadedTorrent(file.data)); } catch { throw createError({ statusCode: 400, message: 'Invalid torrent file' }); } diff --git a/apps/api/routes/api/torrents/index.post.ts b/apps/api/routes/api/torrents/index.post.ts index 9c2449ce..165c0ea8 100644 --- a/apps/api/routes/api/torrents/index.post.ts +++ b/apps/api/routes/api/torrents/index.post.ts @@ -2,7 +2,7 @@ import { db, schema } from '@trackarr/db'; import { eq } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { parseTorrentFile } from '~~/utils/parseTorrentFile'; -import bencode from 'bencode'; +import { normalizeUploadedTorrent } from '~~/utils/torrentBytes'; import { parseReleaseName } from '@trackarr/shared/releaseParse'; import { computeContentSignature } from '~~/utils/contentSignature'; import { extractV2 } from '~~/utils/bittorrentV2'; @@ -185,9 +185,9 @@ export default defineEventHandler(async (event) => { }); } - // Normalise the torrent to a PRIVATE one at upload time: decode, - // force info.private = 1, re-encode, and treat THOSE bytes as - // canonical. We then compute the info_hash and store the normalised + // Normalise the torrent to a PRIVATE one at upload time: force + // info.private = 1, drop the uploader's own fields, and treat THOSE bytes + // as canonical. We then compute the info_hash and store the normalised // blob — so the hash in the DB equals the hash a client derives from // the .torrent we serve (the download route also sets private = 1). // The previous code stored the raw bytes but the download route @@ -197,34 +197,10 @@ export default defineEventHandler(async (event) => { // info_hash drift). Normalising once here keeps all three in sync. let normalizedData: Buffer; try { - const decoded = bencode.decode(file.data) as Record; - const info = decoded?.info as Record | undefined; - if (!info || typeof info !== 'object') { - throw new Error('missing info dict'); - } - info.private = 1; - // Les champs HORS `info`, qui portent les secrets de l'uploadeur. - // - // Un `.torrent` de tracker privé porte la passkey de son propriétaire dans - // `announce` (et dans chaque niveau d'`announce-list`). Les stocker verbatim - // laissait cette passkey au repos dans `torrent_data` : la route de sortie - // les réécrit de toute façon (`[hash]/download.get.ts`), donc la conserver - // n'apportait rien et suffisait à ce qu'un autre membre la lise — le cas - // courant en cross-seed étant que le fichier vienne d'un AUTRE tracker - // privé, dont la passkey n'a rien à faire ici. - // - // `url-list` (web seeds) sort aussi : un hôte tiers y ferait sortir le - // client de chaque téléchargeur vers lui. `comment` et `created by` sont du - // texte libre venu d'un fichier étranger, sans usage chez nous. - // - // Aucun de ces champs n'est dans `info`, donc l'infohash ne change pas et - // les lignes déjà stockées restent valides. - delete decoded.announce; - delete decoded['announce-list']; - delete decoded['url-list']; - delete decoded.comment; - delete decoded['created by']; - normalizedData = Buffer.from(bencode.encode(decoded)); + // Byte-level, never decode → encode: `bencode` re-spells in hex any key that + // is not valid UTF-8, which re-keyed the `piece layers` of every v2 or + // hybrid torrent. `utils/torrentBytes` says why each dropped field goes. + normalizedData = normalizeUploadedTorrent(file.data); } catch (_err) { throw createError({ statusCode: 400, message: 'Invalid torrent file' }); } diff --git a/apps/api/routes/api/torznab/api/index.get.ts b/apps/api/routes/api/torznab/api/index.get.ts index 04fb28ae..bc99a175 100644 --- a/apps/api/routes/api/torznab/api/index.get.ts +++ b/apps/api/routes/api/torznab/api/index.get.ts @@ -425,6 +425,9 @@ async function performSearch( // Fetch torrents const torrents = await db.query.torrents.findMany({ where: whereClause, + // The item builder below never reads the raw .torrent; loading it put a + // whole file per result in memory on every *arr search. + columns: { torrentData: false }, with: { category: true, }, diff --git a/apps/api/routes/api/torznab/download.get.ts b/apps/api/routes/api/torznab/download.get.ts index 78f15b1b..e18c8651 100644 --- a/apps/api/routes/api/torznab/download.get.ts +++ b/apps/api/routes/api/torznab/download.get.ts @@ -7,7 +7,8 @@ import { db, schema } from '@trackarr/db'; import { eq } from 'drizzle-orm'; -import bencode from 'bencode'; +import { personalizeTorrent } from '~~/utils/torrentBytes'; +import { personalAnnounceUrls, trackerUdpEnabled } from '~~/utils/announceUrls'; import { authenticateTorznab } from './utils/auth'; import { TORZNAB_ERRORS, buildErrorXml } from './utils/xml'; import { rateLimit, getClientIP } from '~~/utils/rateLimit'; @@ -128,11 +129,18 @@ export default defineEventHandler(async (event) => { }); } - // Inject passkey into torrent data - let decoded: any; + // The member's announce URLs — the same tiers as a download from the site, + // through the same byte-level writer (see `utils/torrentBytes` for why not + // decode → encode). + const { announce, tiers } = personalAnnounceUrls(user.passkey, { + httpUrl: config.public.trackerHttpUrl as string, + udpUrl: (config.public.trackerUdpUrl as string) || '', + udpEnabled: trackerUdpEnabled(), + }); + let personalizedData: Buffer; try { - decoded = bencode.decode(torrent.torrentData); - } catch (err) { + personalizedData = personalizeTorrent(Buffer.from(torrent.torrentData), announce, tiers); + } catch { setHeader(event, 'Content-Type', 'application/xml; charset=utf-8'); throw createError({ statusCode: 500, @@ -141,25 +149,6 @@ export default defineEventHandler(async (event) => { }); } - // Personalize announce URL - const trackerUrl = new URL(config.public.trackerHttpUrl as string); - trackerUrl.searchParams.set('passkey', user.passkey); - const personalizedUrl = trackerUrl.toString(); - - decoded.announce = Buffer.from(personalizedUrl); - - // Also update announce-list if it exists - if (decoded['announce-list']) { - decoded['announce-list'] = [[Buffer.from(personalizedUrl)]]; - } - - // Set private flag to 1 - if (decoded.info) { - decoded.info.private = 1; - } - - const personalizedData = bencode.encode(decoded); - // Sanitize filename const filename = torrent.name.replace(/[^a-zA-Z0-9._-]/g, '_') + '.torrent'; diff --git a/apps/api/routes/api/users/[id]/uploads.get.ts b/apps/api/routes/api/users/[id]/uploads.get.ts index 54bf0438..46d189bc 100644 --- a/apps/api/routes/api/users/[id]/uploads.get.ts +++ b/apps/api/routes/api/users/[id]/uploads.get.ts @@ -88,6 +88,11 @@ export default defineEventHandler(async (event) => { // Get user's uploads const torrents = await db.query.torrents.findMany({ where, + // Never the raw .torrent: every row below is spread into the response, + // and a file stored before the upload dropped its `announce` still carried + // its uploader's passkey — any member could read it off a profile. The + // detail and listing routes exclude it for the same reason (finding M4). + columns: { torrentData: false }, with: { category: true, }, diff --git a/apps/api/routes/api/welcome.get.ts b/apps/api/routes/api/welcome.get.ts new file mode 100644 index 00000000..7e928493 --- /dev/null +++ b/apps/api/routes/api/welcome.get.ts @@ -0,0 +1,14 @@ +/** + * GET /api/welcome — the staff's welcome note, for signed-in members. + * + * Admin → Branding has edited this note for a long time, and the branding + * guide promised it to members; nothing served it, so nothing showed it. The + * home page now renders it as a card a member can dismiss. Behind a session + * because it is written for members, not for whoever reaches the API. + */ +import { getWelcomeMessage } from '~~/utils/settings'; + +export default defineEventHandler(async (event) => { + await requireUserSession(event); + return { message: await getWelcomeMessage() }; +}); diff --git a/apps/api/scripts/run-integration-tests.sh b/apps/api/scripts/run-integration-tests.sh index 0380433f..beb6b715 100755 --- a/apps/api/scripts/run-integration-tests.sh +++ b/apps/api/scripts/run-integration-tests.sh @@ -10,7 +10,7 @@ # migration. Measurably not equivalent either: a push of this schema onto # a database one version behind stops on an interactive rename prompt and # exits 0, applying nothing. -# 3. installs deps + runs the integration suite inside node:24-alpine +# 3. installs deps + runs the integration suite inside node:26-alpine # 4. tears the containers + network down on exit (success or failure) # # RustFS is here for objectStorage.itest.ts, which exercises the S3 driver @@ -29,11 +29,13 @@ PG=trackarr-itest-pg RD=trackarr-itest-redis S3=trackarr-itest-rustfs PG_IMAGE=postgres:18.6-alpine -RD_IMAGE=redis:8.10.1-alpine +RD_IMAGE=redis:8.10.2-alpine S3_IMAGE=rustfs/rustfs:latest -NODE_IMAGE=node:24-alpine +NODE_IMAGE=node:26-alpine DB_URL="postgres://tracker:tracker@${PG}:5432/trackarr" RD_PASS=itest-redis-password +# The test's own Redis container on a private network. +# nosemgrep: trailofbits.generic.redis-unencrypted-transport.redis-unencrypted-transport RD_URL="redis://${RD}:6379" S3_URL="http://${S3}:9000" S3_KEY=trackarritest @@ -136,7 +138,7 @@ docker run --rm --network "$NET" \ -e S3_TEST_BUCKET="$S3_BUCKET" \ "$NODE_IMAGE" sh -c " set -e - corepack enable + npm install --global --silent --allow-scripts=pnpm \"\$(node -p \"require('./package.json').packageManager\")\" pnpm config set store-dir /pnpm-store >/dev/null pnpm install --filter '@trackarr/api...' --filter '@trackarr/db...' echo '=== migrations ===' diff --git a/apps/api/scripts/run-unit-tests.sh b/apps/api/scripts/run-unit-tests.sh index 84c9bd41..d99f4d3d 100755 --- a/apps/api/scripts/run-unit-tests.sh +++ b/apps/api/scripts/run-unit-tests.sh @@ -18,9 +18,9 @@ docker run --rm \ -v trackarr-itest-nm-api:/app/apps/api/node_modules \ -v trackarr-itest-nm-db:/app/packages/db/node_modules \ -v trackarr-itest-nm-shared:/app/packages/shared/node_modules \ - node:24-alpine sh -c " + node:26-alpine sh -c " set -e - corepack enable + npm install --global --silent --allow-scripts=pnpm \"\$(node -p \"require('./package.json').packageManager\")\" pnpm config set store-dir /pnpm-store >/dev/null pnpm install --filter '@trackarr/api...' --filter '@trackarr/db...' --prefer-offline pnpm --filter @trackarr/api exec vitest run $* diff --git a/apps/api/test/didProof.test.ts b/apps/api/test/didProof.test.ts index fe282e23..7de8694c 100644 --- a/apps/api/test/didProof.test.ts +++ b/apps/api/test/didProof.test.ts @@ -137,7 +137,10 @@ describe('a browser signature the server accepts', () => { // The member's half, in the browser's terms. const config = proofConfigFor(memberDid, endorsedAt); - const digest = async (bytes: Uint8Array) => + // `Uint8Array`, not the default `ArrayBufferLike`: since + // @types/node 26, WebCrypto's `BufferSource` refuses a view that could sit + // on a SharedArrayBuffer — the same rule the browser's lib.dom applies. + const digest = async (bytes: Uint8Array) => new Uint8Array(await subtle.digest('SHA-256', bytes)); const input = composeSigningInput( await digest(canonicalUtf8(config)), @@ -175,7 +178,7 @@ describe('a browser signature the server accepts', () => { createHash('sha256').update(canonicalBytes(config)).digest(), createHash('sha256').update(canonicalBytes(doc)).digest(), ); - const browserDigest = async (bytes: Uint8Array) => + const browserDigest = async (bytes: Uint8Array) => new Uint8Array(await subtle.digest('SHA-256', bytes)); const browserInput = composeSigningInput( await browserDigest(canonicalUtf8(config)), diff --git a/apps/api/test/integration/credentialRewrap.itest.ts b/apps/api/test/integration/credentialRewrap.itest.ts new file mode 100644 index 00000000..4c778f50 --- /dev/null +++ b/apps/api/test/integration/credentialRewrap.itest.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { rewrapCredentialsTick, rewrapCursorKey } from '../../utils/credentialRewrap'; +import { + decryptSecret, + encryptSecretRequired, + looksEncrypted, +} from '../../utils/credentialSecrets'; +import { setSetting } from '../../utils/settings'; +import { invalidatePanicCache } from '../../utils/panicLock'; +import { makeUser } from './helpers'; + +// Account secrets left in plaintext — or readable only through the previous +// key — used to wait for their owner's next login to be re-encrypted. A dormant +// account's verifier, the credential itself, sat in clear in every dump. + +async function secretsOf(id: string) { + const [row] = await db + .select({ authVerifier: schema.users.authVerifier, totpSecret: schema.users.totpSecret }) + .from(schema.users) + .where(eq(schema.users.id, id)); + return row!; +} + +describe('rewrapCredentialsTick', () => { + beforeEach(async () => { + await setSetting(rewrapCursorKey(), ''); + }); + + it('encrypts plaintext verifiers and TOTP seeds, and leaves encrypted ones be', async () => { + const legacy = await makeUser({ + authVerifier: 'cGxhaW50ZXh0LXZlcmlmaWVyLWZyb20tYmVmb3Jl', + totpSecret: 'JBSWY3DPEHPK3PXP', + }); + const encrypted = encryptSecretRequired('YWxyZWFkeS1lbmNyeXB0ZWQ='); + const current = await makeUser({ authVerifier: encrypted }); + + const r = await rewrapCredentialsTick(); + expect(r.rewritten).toBe(2); + + const after = await secretsOf(legacy); + expect(looksEncrypted(after.authVerifier)).toBe(true); + expect(decryptSecret(after.authVerifier)).toBe('cGxhaW50ZXh0LXZlcmlmaWVyLWZyb20tYmVmb3Jl'); + expect(looksEncrypted(after.totpSecret)).toBe(true); + expect(decryptSecret(after.totpSecret)).toBe('JBSWY3DPEHPK3PXP'); + + expect((await secretsOf(current)).authVerifier).toBe(encrypted); + + // The walk is done: nothing left to do. + expect((await rewrapCredentialsTick()).processed).toBe(0); + }); + + it('stands still during a panic — its ciphertext has the same shape', async () => { + const legacy = await makeUser({ authVerifier: 'cGxhaW4tZHVyaW5nLXBhbmlj' }); + await db + .insert(schema.panicState) + .values({ id: 'singleton', isEncrypted: true, phase: 'encrypted' }) + .onConflictDoUpdate({ target: schema.panicState.id, set: { isEncrypted: true, phase: 'encrypted' } }); + await invalidatePanicCache(); + + expect(await rewrapCredentialsTick()).toEqual({ processed: 0, rewritten: 0 }); + expect((await secretsOf(legacy)).authVerifier).toBe('cGxhaW4tZHVyaW5nLXBhbmlj'); + }); +}); diff --git a/apps/api/test/integration/federationPanic.itest.ts b/apps/api/test/integration/federationPanic.itest.ts index 2f21f322..a4bc2c04 100644 --- a/apps/api/test/integration/federationPanic.itest.ts +++ b/apps/api/test/integration/federationPanic.itest.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '@trackarr/db'; import { federationSuspended } from '../../utils/federation/config'; import { redis } from '../../utils/server'; +import { invalidatePanicCache } from '../../utils/panicLock'; // Panic mode must stop federation. It encrypts the catalogue in place, and the // whole point is to stop exfiltration during an incident — yet nothing in the @@ -11,19 +12,18 @@ import { redis } from '../../utils/server'; // torrent; the S2S endpoints kept serving. `federationSuspended()` is the one // gate that now sits in front of the loops and the serving surface. -async function setPanic(on: boolean): Promise { +async function setPanic( + on: boolean, + phase: 'idle' | 'encrypting' | 'encrypted' | 'restoring' = on ? 'encrypted' : 'idle', +): Promise { await db .insert(schema.panicState) - .values({ id: 'singleton', isEncrypted: on }) - .onConflictDoUpdate({ target: schema.panicState.id, set: { isEncrypted: on } }); + .values({ id: 'singleton', isEncrypted: on, phase }) + .onConflictDoUpdate({ target: schema.panicState.id, set: { isEncrypted: on, phase } }); // The gate caches through Redis; clear it so the test reads the new state. // Best-effort: if Redis is briefly unavailable the gate itself falls back to // the database, which is the property under test. - try { - await redis.del('federation:panic:suspended'); - } catch { - /* the gate reads through to the DB anyway */ - } + await invalidatePanicCache(); } describe('federation while the instance is in panic', () => { @@ -61,7 +61,17 @@ describe('federation while the instance is in panic', () => { // A stale or missing cache must never read as "not in panic" — panic is // exactly when a cache miss is most likely and most dangerous. await setPanic(true); - await redis.del('federation:panic:suspended'); + await invalidatePanicCache(); + expect(await federationSuspended()).toBe(true); + }); + + it('is suspended from the first encrypted row, not only once the flag is set', async () => { + // `is_encrypted` stays false for the whole encrypt pass — and an interrupted + // pass can stay there — while the mint loop would publish what it already + // encrypted. Same for a restore that stopped half way. + await setPanic(false, 'encrypting'); + expect(await federationSuspended()).toBe(true); + await setPanic(true, 'restoring'); expect(await federationSuspended()).toBe(true); }); }); diff --git a/apps/api/test/integration/hnrSweep.itest.ts b/apps/api/test/integration/hnrSweep.itest.ts new file mode 100644 index 00000000..c6f3a112 --- /dev/null +++ b/apps/api/test/integration/hnrSweep.itest.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { checkAndMarkHnrs } from '../../utils/hnr'; +import { setSetting } from '../../utils/settings'; +import { makeUser } from './helpers'; + +// The sweep now runs on its own (`plugins/hnr-sweep`), so what it flags is what +// members get notified about without a moderator in the loop. A row is born at +// the Download click; only one where bytes actually moved can be a hit-and-run. + +const DAY = 24 * 3600 * 1000; + +async function makeTorrent(): Promise { + const id = randomUUID(); + await db.insert(schema.torrents).values({ + id, + infoHash: `${randomUUID().replace(/-/g, '')}00000000`, + name: 'Release', + size: 1000, + moderationStatus: 'accepted', + }); + return id; +} + +async function track(over: Partial): Promise { + const id = randomUUID(); + await db.insert(schema.hnrTracking).values({ + id, + userId: await makeUser(), + torrentId: await makeTorrent(), + downloadedAt: new Date(Date.now() - 5 * DAY), + ...over, + }); + return id; +} + +async function isHnr(id: string): Promise { + const [row] = await db + .select({ isHnr: schema.hnrTracking.isHnr }) + .from(schema.hnrTracking) + .where(eq(schema.hnrTracking.id, id)); + return row!.isHnr; +} + +describe('checkAndMarkHnrs', () => { + beforeEach(async () => { + await setSetting('hnr_enabled', 'true'); + await setSetting('hnr_grace_period', String(3 * 24 * 3600)); + }); + + it('flags a download past its grace period, and only that', async () => { + const leeched = await track({ downloaded: 700 }); + const clickedOnly = await track({ downloaded: 0 }); + const inGrace = await track({ downloaded: 700, downloadedAt: new Date(Date.now() - DAY) }); + const seeded = await track({ downloaded: 700, completedAt: new Date() }); + const exempt = await track({ downloaded: 700, isExempt: true }); + + expect(await checkAndMarkHnrs()).toBe(1); + expect(await isHnr(leeched)).toBe(true); + expect(await isHnr(clickedOnly)).toBe(false); + expect(await isHnr(inGrace)).toBe(false); + expect(await isHnr(seeded)).toBe(false); + expect(await isHnr(exempt)).toBe(false); + + // Idempotent: a second pass flags nothing new. + expect(await checkAndMarkHnrs()).toBe(0); + }); + + it('does nothing while hit-and-run is disabled', async () => { + await setSetting('hnr_enabled', 'false'); + const leeched = await track({ downloaded: 700 }); + expect(await checkAndMarkHnrs()).toBe(0); + expect(await isHnr(leeched)).toBe(false); + }); +}); diff --git a/apps/api/test/integration/knownDevices.itest.ts b/apps/api/test/integration/knownDevices.itest.ts new file mode 100644 index 00000000..f02d1f79 --- /dev/null +++ b/apps/api/test/integration/knownDevices.itest.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { redis } from '../../utils/server'; +import { + encodeKnownDevice, + knownDeviceFor, + recordKnownDeviceFailure, +} from '../../utils/knownDevices'; +import { makeUser } from './helpers'; + +// A known-device cookie exempts a browser from the per-account sign-in lockout. +// What makes it safe to hand out is checked here against the real account row +// and Redis: it names one account, dies with "sign out everywhere" and with a +// password change, and a device that keeps failing loses it for good. + +const VERIFIER = 'v1:cipher-a'; +const now = () => Math.floor(Date.now() / 1000); +const newDevice = () => randomBytes(16).toString('base64url'); + +async function member(): Promise<{ id: string; username: string }> { + const id = await makeUser({ sessionEpoch: 0, authVerifier: VERIFIER }); + const row = await db.query.users.findFirst({ + where: eq(schema.users.id, id), + columns: { username: true }, + }); + return { id, username: row!.username }; +} + +const cookieFor = (userId: string, device: string) => + encodeKnownDevice({ userId, sessionEpoch: 0, authVerifier: VERIFIER }, device, now()); + +describe('known devices', () => { + it('recognises a cookie for the account it was issued to, under that username only', async () => { + const a = await member(); + const b = await member(); + const device = newDevice(); + const value = cookieFor(a.id, device); + + expect(await knownDeviceFor(value, a.username)).toBe(device); + expect(await knownDeviceFor(value, b.username)).toBeNull(); + expect(await knownDeviceFor(value, 'no-such-member-anywhere')).toBeNull(); + expect(await knownDeviceFor(undefined, a.username)).toBeNull(); + }); + + it('forgets the browser when the account signs out everywhere or changes password', async () => { + const a = await member(); + const device = newDevice(); + const value = cookieFor(a.id, device); + expect(await knownDeviceFor(value, a.username)).toBe(device); + + // What "sign out everywhere" and a ban do. + await db.update(schema.users).set({ sessionEpoch: 1 }).where(eq(schema.users.id, a.id)); + expect(await knownDeviceFor(value, a.username)).toBeNull(); + + // What a password change does to the stored verifier. + await db + .update(schema.users) + .set({ sessionEpoch: 0, authVerifier: 'v1:cipher-b' }) + .where(eq(schema.users.id, a.id)); + expect(await knownDeviceFor(value, a.username)).toBeNull(); + }); + + it('revokes a device after ten failures in the window, and only that device', async () => { + const a = await member(); + const noisy = newDevice(); + const quiet = newDevice(); + + for (let i = 0; i < 9; i++) await recordKnownDeviceFailure(noisy); + expect(await knownDeviceFor(cookieFor(a.id, noisy), a.username)).toBe(noisy); + await recordKnownDeviceFailure(noisy); + expect(await knownDeviceFor(cookieFor(a.id, noisy), a.username)).toBeNull(); + // Re-signed or not, the id stays revoked: the cookie's age is not what failed. + expect(await knownDeviceFor(cookieFor(a.id, noisy), a.username)).toBeNull(); + expect(await knownDeviceFor(cookieFor(a.id, quiet), a.username)).toBe(quiet); + + await redis.del(`known-device:revoked:${noisy}`, `ratelimit:signin-fail-device:${noisy}`); + }); + + it('does not count failures older than the window', async () => { + const a = await member(); + const device = newDevice(); + const old = Date.now() - 16 * 60 * 1000; + for (let i = 0; i < 9; i++) { + await redis.zadd(`ratelimit:signin-fail-device:${device}`, old + i, `old-${i}`); + } + await recordKnownDeviceFailure(device); // the tenth entry, nine of them stale + expect(await knownDeviceFor(cookieFor(a.id, device), a.username)).toBe(device); + + await redis.del(`ratelimit:signin-fail-device:${device}`); + }); +}); diff --git a/apps/api/test/integration/setup.ts b/apps/api/test/integration/setup.ts index dd8ed06d..43542ce6 100644 --- a/apps/api/test/integration/setup.ts +++ b/apps/api/test/integration/setup.ts @@ -82,9 +82,21 @@ beforeEach(async () => { federation_config, federation_peers, catalog_records, themes, uploaded_fonts, + panic_state, + revoked_identities, users RESTART IDENTITY CASCADE`, ); + // `panic_state` is named because a test that leaves the instance in panic + // turns every later one read-only — the write lock, the federation gate and + // the catalogue sweeps all stop on it — and only in the files that run after + // it. The gate caches in Redis for a few seconds, hence the second line. + // + // `revoked_identities` holds DIDs, not foreign keys, so no cascade reaches + // it, and the succession tests reuse fixed DIDs: a run left its revocations + // behind and the next run of `identityRecord.itest.ts` found an alias too + // many. Green on a fresh database, red on every re-run. + await redis.del('panic:active').catch(() => {}); }); afterAll(async () => { diff --git a/apps/api/test/integration/signinLockout.itest.ts b/apps/api/test/integration/signinLockout.itest.ts new file mode 100644 index 00000000..72762845 --- /dev/null +++ b/apps/api/test/integration/signinLockout.itest.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { redis } from '../../utils/server'; +import { + assertAccountNotLocked, + recordAccountFailure, + clearAccountFailures, +} from '../../utils/rateLimit'; + +// Failed sign-ins counted per username, so a guesser spread over many addresses +// still meets a limit. Every username counts, existing or not: a lockout must +// never be how somebody learns that an account exists. + +const locked = async (u: string) => + assertAccountNotLocked(u).then( + () => false, + (err: { statusCode?: number }) => { + if (err.statusCode !== 429) throw err; + return true; + }, + ); + +describe('per-account sign-in lockout', () => { + beforeEach(async () => { + for (const u of ['alice', 'nobody-at-all', 'bob']) await clearAccountFailures(u); + }); + + it('locks a username after ten failures in the window', async () => { + for (let i = 0; i < 9; i++) await recordAccountFailure('alice'); + expect(await locked('alice')).toBe(false); + await recordAccountFailure('alice'); + expect(await locked('alice')).toBe(true); + // Case does not open a second counter. + expect(await locked('ALICE')).toBe(true); + // Another account is untouched. + expect(await locked('bob')).toBe(false); + }); + + it('counts a username that has no account the same way', async () => { + for (let i = 0; i < 10; i++) await recordAccountFailure('nobody-at-all'); + expect(await locked('nobody-at-all')).toBe(true); + }); + + it('forgets failures older than the window, and on a successful sign-in', async () => { + for (let i = 0; i < 10; i++) await recordAccountFailure('alice'); + expect(await locked('alice')).toBe(true); + await clearAccountFailures('alice'); + expect(await locked('alice')).toBe(false); + + // Ten failures stamped sixteen minutes ago no longer count. + const old = Date.now() - 16 * 60 * 1000; + for (let i = 0; i < 10; i++) { + await redis.zadd('ratelimit:signin-fail:alice', old + i, `old-${i}`); + } + expect(await locked('alice')).toBe(false); + }); +}); diff --git a/apps/api/test/integration/torrentFileCleanup.itest.ts b/apps/api/test/integration/torrentFileCleanup.itest.ts new file mode 100644 index 00000000..ee6fdd71 --- /dev/null +++ b/apps/api/test/integration/torrentFileCleanup.itest.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import bencode from 'bencode'; +import { eq } from 'drizzle-orm'; +import { db, schema } from '@trackarr/db'; +import { cleanupTorrentFilesTick, CLEANUP_CURSOR_KEY } from '../../utils/torrentFileCleanup'; +import { setSetting } from '../../utils/settings'; +import { invalidatePanicCache } from '../../utils/panicLock'; +import { infoDictRange } from '../../utils/bittorrentV2'; + +// The sweep that cleans the `.torrent` files stored before the upload dropped +// the uploader's fields and stopped re-keying piece layers. What has to hold: +// the passkey leaves the row, the piece layers get their raw roots back, and the +// info dict — the infohash — does not move by a single byte. + +const raw = (s: string) => Buffer.from(s, 'latin1'); +const bstr = (b: Buffer | string) => { + const buf = typeof b === 'string' ? Buffer.from(b) : b; + return Buffer.concat([raw(`${buf.length}:`), buf]); +}; +const dictOf = (pairs: Array<[Buffer | string, Buffer]>) => + Buffer.concat([raw('d'), ...pairs.flatMap(([k, v]) => [bstr(k), v]), raw('e')]); +const enc = (v: unknown) => Buffer.from(bencode.encode(v as never)); +const infoOf = (file: Buffer) => { + const r = infoDictRange(file)!; + return file.subarray(r.start, r.end); +}; + +const ROOT = Buffer.alloc(32, 0xc3); +const LAYER = Buffer.alloc(64, 0x11); +const PASSKEY = 'a'.repeat(40); + +async function storeTorrent(torrentData: Buffer): Promise { + const id = randomUUID(); + await db.insert(schema.torrents).values({ + id, + infoHash: `${randomUUID().replace(/-/g, '')}00000000`, + name: 'Release', + size: 1000, + torrentData, + moderationStatus: 'accepted', + }); + return id; +} + +async function storedBytes(id: string): Promise { + const [row] = await db + .select({ torrentData: schema.torrents.torrentData }) + .from(schema.torrents) + .where(eq(schema.torrents.id, id)); + return Buffer.from(row!.torrentData!); +} + +describe('cleanupTorrentFilesTick', () => { + // The setup TRUNCATEs `settings`, but `getSetting` keeps an in-process cache + // for a minute: without this the second test resumes from the first's cursor. + beforeEach(async () => { + await setSetting(CLEANUP_CURSOR_KEY, ''); + }); + + it('drops the uploader passkey and repairs piece layers without moving info', async () => { + const v1Info = enc({ length: 5, name: Buffer.from('a'), 'piece length': 16384, pieces: Buffer.alloc(20), private: 1 }); + const legacy = dictOf([ + ['announce', bstr(`https://tracker.example/announce?passkey=${PASSKEY}`)], + ['announce-list', enc([[Buffer.from(`https://tracker.example/announce?passkey=${PASSKEY}`)]])], + ['info', v1Info], + ]); + + const hybridInfo = enc({ + 'file tree': { 'movie.mkv': { '': { length: 40000, 'pieces root': ROOT } } }, + length: 40000, + 'meta version': 2, + name: Buffer.from('movie.mkv'), + 'piece length': 16384, + pieces: Buffer.alloc(60, 0x22), + private: 1, + }); + // What the old upload stored: piece layers keyed by the hex of the root. + const reKeyed = dictOf([ + ['info', hybridInfo], + ['piece layers', dictOf([[Buffer.from(ROOT.toString('hex')), bstr(LAYER)]])], + ]); + + const clean = dictOf([['info', v1Info]]); + + const legacyId = await storeTorrent(legacy); + const reKeyedId = await storeTorrent(reKeyed); + const cleanId = await storeTorrent(clean); + + const first = await cleanupTorrentFilesTick(); + expect(first).toEqual({ processed: 3, cleaned: 2 }); + + const legacyAfter = await storedBytes(legacyId); + expect(legacyAfter.toString('latin1')).not.toContain(PASSKEY); + expect(infoOf(legacyAfter).equals(v1Info)).toBe(true); + + const reKeyedAfter = await storedBytes(reKeyedId); + expect(reKeyedAfter.includes(Buffer.concat([raw('32:'), ROOT, bstr(LAYER)]))).toBe(true); + expect(infoOf(reKeyedAfter).equals(hybridInfo)).toBe(true); + + expect((await storedBytes(cleanId)).equals(clean)).toBe(true); + + // The cursor walked past all three: a second tick has nothing to do. + expect(await cleanupTorrentFilesTick()).toEqual({ processed: 0, cleaned: 0 }); + }); + + it('moves past a file it cannot walk without writing it', async () => { + const junkId = await storeTorrent(raw('not bencode at all')); + expect(await cleanupTorrentFilesTick()).toEqual({ processed: 1, cleaned: 0 }); + expect((await storedBytes(junkId)).toString('latin1')).toBe('not bencode at all'); + }); + + it('stands still during a panic, so the encrypted rows are not walked past', async () => { + const legacy = dictOf([ + ['announce', bstr(`https://tracker.example/announce?passkey=${PASSKEY}`)], + ['info', enc({ name: Buffer.from('a'), private: 1 })], + ]); + const id = await storeTorrent(legacy); + const setPhase = async (phase: 'idle' | 'encrypting') => { + await db + .insert(schema.panicState) + .values({ id: 'singleton', isEncrypted: false, phase }) + .onConflictDoUpdate({ target: schema.panicState.id, set: { isEncrypted: false, phase } }); + await invalidatePanicCache(); + }; + try { + await setPhase('encrypting'); + expect(await cleanupTorrentFilesTick()).toEqual({ processed: 0, cleaned: 0 }); + expect((await storedBytes(id)).equals(legacy)).toBe(true); + } finally { + await setPhase('idle'); + } + // Once the panic is over, the row is still ahead of the cursor. + expect(await cleanupTorrentFilesTick()).toEqual({ processed: 1, cleaned: 1 }); + }); +}); diff --git a/apps/api/test/knownDevices.test.ts b/apps/api/test/knownDevices.test.ts new file mode 100644 index 00000000..db7d4aae --- /dev/null +++ b/apps/api/test/knownDevices.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + encodeKnownDevice, + verifyKnownDevice, + KNOWN_DEVICE_TTL_S, +} from '../utils/knownDevices'; + +// The known-device cookie exempts a browser from the per-account sign-in +// lockout, so everything it is bound to must void it when it changes: the +// account, the session epoch ("sign out everywhere", a ban), the stored +// verifier (a password change). And nothing about it may be forged or stretched. + +const subject = { + userId: 'a8e2c0de-0000-4000-8000-000000000001', + sessionEpoch: 3, + authVerifier: 'v1:cipher', +}; +const DEVICE = 'AAAAAAAAAAAAAAAAAAAAAA'; // 22 base64url characters, as issued +const NOW = 1_800_000_000; + +describe('known-device cookie', () => { + const originalSecret = process.env.NUXT_SESSION_SECRET; + afterEach(() => { + process.env.NUXT_SESSION_SECRET = originalSecret; + }); + + it('round-trips to its device id', () => { + const value = encodeKnownDevice(subject, DEVICE, NOW); + expect(value).toMatch(/^v1\.[A-Za-z0-9_-]{22}\.\d+\.[A-Za-z0-9_-]{43}$/); + expect(verifyKnownDevice(value, subject, NOW)).toBe(DEVICE); + }); + + it('is void for another account, epoch or credential', () => { + const value = encodeKnownDevice(subject, DEVICE, NOW); + const other = 'a8e2c0de-0000-4000-8000-000000000002'; + expect(verifyKnownDevice(value, { ...subject, userId: other }, NOW)).toBeNull(); + expect(verifyKnownDevice(value, { ...subject, sessionEpoch: 4 }, NOW)).toBeNull(); + expect(verifyKnownDevice(value, { ...subject, authVerifier: 'v1:other' }, NOW)).toBeNull(); + }); + + it('reads a missing epoch as 0, the way sessions do', () => { + const value = encodeKnownDevice({ ...subject, sessionEpoch: null }, DEVICE, NOW); + expect(verifyKnownDevice(value, { ...subject, sessionEpoch: 0 }, NOW)).toBe(DEVICE); + }); + + it('refuses any part edited', () => { + const value = encodeKnownDevice(subject, DEVICE, NOW); + const [v, device, issued, mac] = value.split('.') as [string, string, string, string]; + const flip = (s: string) => (s[0] === 'A' ? 'B' : 'A') + s.slice(1); + expect(verifyKnownDevice([v, flip(device), issued, mac].join('.'), subject, NOW)).toBeNull(); + expect(verifyKnownDevice([v, device, String(Number(issued) + 1), mac].join('.'), subject, NOW)).toBeNull(); + expect(verifyKnownDevice([v, device, issued, flip(mac)].join('.'), subject, NOW)).toBeNull(); + expect(verifyKnownDevice(['v2', device, issued, mac].join('.'), subject, NOW)).toBeNull(); + }); + + it('expires, and takes no issue time from the future beyond clock skew', () => { + const at = (issuedAt: number) => encodeKnownDevice(subject, DEVICE, issuedAt); + expect(verifyKnownDevice(at(NOW - KNOWN_DEVICE_TTL_S), subject, NOW)).toBe(DEVICE); + expect(verifyKnownDevice(at(NOW - KNOWN_DEVICE_TTL_S - 1), subject, NOW)).toBeNull(); + expect(verifyKnownDevice(at(NOW + 299), subject, NOW)).toBe(DEVICE); + expect(verifyKnownDevice(at(NOW + 301), subject, NOW)).toBeNull(); + }); + + it('rejects anything that is not the issued shape', () => { + const mac = 'A'.repeat(43); + for (const bad of [ + undefined, + null, + '', + 'v1', + 'v1..1.x', + `v1.${DEVICE}.1`, + `v1.${DEVICE}.1.x.y`, + `v1.${DEVICE.slice(1)}.1.${mac}`, // device id one character short + `v1.${DEVICE}.-1.${mac}`, + `v1.${DEVICE}.1.${mac.slice(1)}=`, // padding is not base64url + ]) { + expect(verifyKnownDevice(bad, subject, NOW)).toBeNull(); + } + }); + + it('dies with the session secret', () => { + const value = encodeKnownDevice(subject, DEVICE, NOW); + process.env.NUXT_SESSION_SECRET = 'another-session-secret-0123456789abcdef'; + expect(verifyKnownDevice(value, subject, NOW)).toBeNull(); + }); +}); diff --git a/apps/api/test/panic.test.ts b/apps/api/test/panic.test.ts index 8dd72973..8c63e0f4 100644 --- a/apps/api/test/panic.test.ts +++ b/apps/api/test/panic.test.ts @@ -7,7 +7,10 @@ import { generateSalt, encryptField, decryptField, + decryptFieldOrKeep, + decryptTorrentDataOrKeep, } from '../utils/panic'; +import { allowedDuringPanic } from '../utils/panicLock'; // Panic-mode encryption. This module is the project's only use of AES-GCM, and // it serves two paths: sealing the database, and the notification channel @@ -175,3 +178,84 @@ describe('encryptField / decryptField', () => { } }); }); + +describe('decryptFieldOrKeep', () => { + // What the restore runs on every column. The password is verified before the + // key is derived, so a value that does not decrypt is clear text: a row + // written while the database was encrypted, or one an interrupted restore had + // already given back. Refusing those made a single write during the panic — + // or a single crash mid-restore — enough to lose the instance. + it('decrypts what this key encrypted', async () => { + const k = await key(); + expect(decryptFieldOrKeep(encryptField('secret', k), k)).toBe('secret'); + }); + + it('keeps a value that is already clear, colons and all', async () => { + const k = await key(); + for (const v of ['3f1a9c', 'plain text', 'a:b:c', '2001:db8::1', '']) { + expect(decryptFieldOrKeep(v, k)).toBe(v); + } + }); + + it('makes a second restore pass a no-op on what the first restored', async () => { + const k = await key(); + const once = decryptFieldOrKeep(encryptField('passkey-value', k), k); + expect(decryptFieldOrKeep(once, k)).toBe('passkey-value'); + }); + + it('passes null and undefined through', async () => { + const k = await key(); + expect(decryptFieldOrKeep(null, k)).toBeNull(); + expect(decryptFieldOrKeep(undefined, k)).toBeNull(); + }); +}); + +describe('decryptTorrentDataOrKeep', () => { + // The same rule for `torrent_data`, which the encrypt pass wraps as base64 and + // then stores the `iv:ct:tag` as ASCII bytes. A second restore attempt meets the + // clear file the first one gave back; the route used to write null over it. + const file = Buffer.from('d8:announce3:url4:infod6:lengthi1e4:name1:xee', 'latin1'); + const wrap = (raw: Buffer, k: Buffer) => + Buffer.from(encrypt(raw.toString('base64'), k), 'ascii'); + + it('unwraps what the encrypt pass stored', async () => { + const k = await key(); + expect(decryptTorrentDataOrKeep(wrap(file, k), k)).toEqual(file); + }); + + it('keeps the clear file a previous attempt gave back', async () => { + const k = await key(); + const once = decryptTorrentDataOrKeep(wrap(file, k), k)!; + expect(decryptTorrentDataOrKeep(once, k)).toEqual(file); + }); + + it('answers null for bytes that are neither, so the restore counts them', async () => { + const k = await key(); + expect(decryptTorrentDataOrKeep(Buffer.from('not a torrent'), k)).toBeNull(); + expect(decryptTorrentDataOrKeep(Buffer.from('d4:name1:xe'), k)).toBeNull(); + expect(decryptTorrentDataOrKeep(wrap(file, await key()), k)).toBeNull(); + }); +}); + +describe('allowedDuringPanic', () => { + it('lets every read through', () => { + expect(allowedDuringPanic('GET', '/api/torrents')).toBe(true); + expect(allowedDuringPanic('HEAD', '/api/admin/panic')).toBe(true); + }); + + it('lets the panic itself and logging out write', () => { + expect(allowedDuringPanic('POST', '/api/admin/panic/restore')).toBe(true); + expect(allowedDuringPanic('POST', '/api/admin/panic/encrypt/')).toBe(true); + expect(allowedDuringPanic('POST', '/api/auth/logout?next=/')).toBe(true); + }); + + it('refuses every other write, however it is spelled', () => { + expect(allowedDuringPanic('POST', '/api/torrents')).toBe(false); + expect(allowedDuringPanic('put', '/api/auth/password')).toBe(false); + expect(allowedDuringPanic('DELETE', '/API/torrents/abc')).toBe(false); + expect(allowedDuringPanic('POST', '/%61pi/torrents')).toBe(false); + expect(allowedDuringPanic('POST', '/uploads/x')).toBe(false); + expect(allowedDuringPanic('POST', '/api/admin/panic/restore%')).toBe(false); + }); +}); + diff --git a/apps/api/test/rateLimitSubject.test.ts b/apps/api/test/rateLimitSubject.test.ts new file mode 100644 index 00000000..1f4912bb --- /dev/null +++ b/apps/api/test/rateLimitSubject.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { rateLimitSubject } from '../utils/rateLimit'; + +// What an address-based limit — and the blacklist built on it — counts. An IPv6 +// host routinely holds a whole /64 and can use a fresh address per request, so +// keying on the full address limited nobody who rotated inside their prefix. + +describe('rateLimitSubject', () => { + it('keeps an IPv4 address whole, IPv4-mapped included', () => { + expect(rateLimitSubject('203.0.113.7')).toBe('203.0.113.7'); + expect(rateLimitSubject('::ffff:203.0.113.7')).toBe('203.0.113.7'); + }); + + it('folds every address of an IPv6 /64 onto one subject', () => { + const a = rateLimitSubject('2001:db8:aa:bb:1:2:3:4'); + const b = rateLimitSubject('2001:0db8:00aa:00bb:ffff:ffff:ffff:ffff'); + const c = rateLimitSubject('2001:db8:aa:bb::1'); + expect(a).toBe('2001:db8:aa:bb::/64'); + expect(b).toBe(a); + expect(c).toBe(a); + }); + + it('keeps two /64s apart', () => { + expect(rateLimitSubject('2001:db8:aa:bb::1')).not.toBe(rateLimitSubject('2001:db8:aa:bc::1')); + }); + + it('reads compressed, zoned and IPv4-tailed spellings', () => { + expect(rateLimitSubject('::1')).toBe('0:0:0:0::/64'); + expect(rateLimitSubject('fe80::1%eth0')).toBe('fe80:0:0:0::/64'); + expect(rateLimitSubject('64:ff9b::192.0.2.1')).toBe('64:ff9b:0:0::/64'); + }); + + it('passes anything else through untouched', () => { + expect(rateLimitSubject('unknown')).toBe('unknown'); + expect(rateLimitSubject('')).toBe(''); + }); +}); diff --git a/apps/api/test/torrentBytes.test.ts b/apps/api/test/torrentBytes.test.ts new file mode 100644 index 00000000..e177994f --- /dev/null +++ b/apps/api/test/torrentBytes.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import bencode from 'bencode'; +import { dictEntries, skipValue } from '../utils/bencodeScan'; +import { + cleanStoredTorrent, + normalizeUploadedTorrent, + personalizeTorrent, +} from '../utils/torrentBytes'; +import { infoDictRange } from '../utils/bittorrentV2'; +import { personalAnnounceUrls, trackerUdpEnabled } from '../utils/announceUrls'; + +// The files are built by hand, byte by byte where it matters: the faults these +// tests pin down live in keys a JS object cannot hold (raw 32-byte roots), so +// `bencode.encode` can only build the parts whose keys are plain text. + +const raw = (s: string) => Buffer.from(s, 'latin1'); +const bstr = (b: Buffer | string) => { + const buf = typeof b === 'string' ? Buffer.from(b) : b; + return Buffer.concat([raw(`${buf.length}:`), buf]); +}; +/** A bencoded dict with the entries in the order given, keys as raw bytes. */ +function dictOf(pairs: Array<[Buffer | string, Buffer]>): Buffer { + return Buffer.concat([raw('d'), ...pairs.flatMap(([k, v]) => [bstr(k), v]), raw('e')]); +} +const enc = (v: unknown) => Buffer.from(bencode.encode(v as never)); + +/** The info dict's bytes, located the way the infohash is computed. */ +function infoBytes(file: Buffer): Buffer { + const r = infoDictRange(file)!; + return file.subarray(r.start, r.end); +} + +const ROOT = Buffer.alloc(32, 0xc3); // 0xc3 alone is invalid UTF-8: a realistic root +const LAYER = Buffer.alloc(64, 0x11); // two 32-byte hashes + +/** A hybrid info dict (v1 `pieces` + v2 `file tree`) whose single file has ROOT. */ +function hybridInfo(extra: Record = {}): Buffer { + return enc({ + 'file tree': { 'movie.mkv': { '': { length: 40000, 'pieces root': ROOT } } }, + length: 40000, + 'meta version': 2, + name: Buffer.from('movie.mkv'), + 'piece length': 16384, + pieces: Buffer.alloc(60, 0x22), + ...extra, + }); +} + +/** A whole hybrid `.torrent`, piece layers keyed by `layerKey`. */ +function hybridFile(layerKey: Buffer, info = hybridInfo()): Buffer { + return dictOf([ + ['announce', bstr('http://other.tracker/announce?passkey=deadbeef')], + ['info', info], + ['piece layers', dictOf([[layerKey, bstr(LAYER)]])], + ]); +} + +describe('bencodeScan', () => { + it('lists dict entries with their raw keys and ranges', () => { + const file = dictOf([['a', raw('i1e')], [ROOT, bstr('x')]]); + const d = dictEntries(file)!; + expect(d.end).toBe(file.length); + expect(d.entries.map((e) => e.key)).toEqual([Buffer.from('a'), ROOT]); + expect(file.subarray(d.entries[0]!.valueStart, d.entries[0]!.end).toString()).toBe('i1e'); + }); + + it('rejects what is not a well-formed dict', () => { + expect(dictEntries(Buffer.alloc(0))).toBeNull(); + expect(dictEntries(raw('l1:ae'))).toBeNull(); // list root + expect(dictEntries(raw('di1ei2ee'))).toBeNull(); // integer key + expect(dictEntries(raw('d1:a'))).toBeNull(); // value missing + expect(dictEntries(raw('d1:ai1e'))).toBeNull(); // unterminated + expect(skipValue(raw('5:ab'), 0)).toBe(-1); // length past EOF + expect(skipValue(raw('-1:a'), 0)).toBe(-1); + expect(skipValue(raw('ixyze'), 0)).toBe(-1); // not a number + expect(skipValue(raw('ie'), 0)).toBe(-1); + expect(skipValue(raw('i1-2e'), 0)).toBe(-1); + }); + + it('reads integers, tolerating the leading zeros an old file may carry', () => { + expect(skipValue(raw('i42e'), 0)).toBe(4); + expect(skipValue(raw('i-7e'), 0)).toBe(4); + expect(skipValue(raw('i007e'), 0)).toBe(5); + }); + + it('terminates on deeply nested junk', () => { + const started = Date.now(); + expect(dictEntries(raw('d1:a' + 'l'.repeat(5000)))).toBeNull(); + expect(Date.now() - started).toBeLessThan(1000); + }); +}); + +describe('the bencode round trip these helpers replace', () => { + it('re-keys piece layers in hex — the fault being fixed', () => { + // Pins the library behaviour the module comment describes, so a future + // bencode that keeps raw keys shows up here rather than silently. + // Key AND value: `32:` alone also occurs inside `info`, as the file's + // `pieces root` — a value, which the decoder keeps raw. + const file = hybridFile(ROOT); + const roundTrip = enc(bencode.decode(file)); + expect(file.includes(Buffer.concat([raw('32:'), ROOT, bstr(LAYER)]))).toBe(true); + expect(roundTrip.includes(Buffer.concat([raw('32:'), ROOT, bstr(LAYER)]))).toBe(false); + expect(roundTrip.includes(Buffer.concat([raw(`64:${ROOT.toString('hex')}`), bstr(LAYER)]))).toBe(true); + }); +}); + +describe('normalizeUploadedTorrent', () => { + it('drops the uploader fields, keeps the rest, forces private', () => { + const info = enc({ length: 5, name: Buffer.from('a'), 'piece length': 16384, pieces: Buffer.alloc(20) }); + const file = dictOf([ + ['announce', bstr('http://t/announce?passkey=secret')], + ['announce-list', enc([[Buffer.from('http://t/announce?passkey=secret')]])], + ['comment', bstr('hi')], + ['created by', bstr('mktorrent')], + ['creation date', raw('i1700000000e')], + ['info', info], + ['url-list', enc([Buffer.from('http://webseed')])], + ]); + const out = normalizeUploadedTorrent(file); + expect(out.toString('latin1')).not.toContain('secret'); + expect(Object.keys(bencode.decode(out) as object)).toEqual(['creation date', 'info']); + // `private` lands where a sorted dict holds it: after `pieces`. + expect(infoBytes(out).equals(enc({ length: 5, name: Buffer.from('a'), 'piece length': 16384, pieces: Buffer.alloc(20), private: 1 }))).toBe(true); + }); + + it('leaves a private info dict byte for byte, and fixes private=0', () => { + const p1 = enc({ name: Buffer.from('a'), private: 1 }); + expect(infoBytes(normalizeUploadedTorrent(dictOf([['info', p1]]))).equals(p1)).toBe(true); + const p0 = enc({ name: Buffer.from('a'), private: 0 }); + expect(infoBytes(normalizeUploadedTorrent(dictOf([['info', p0]]))).equals(p1)).toBe(true); + }); + + it('keeps the piece layers of a v2 torrent intact', () => { + const file = hybridFile(ROOT, hybridInfo({ private: 1 })); + const out = normalizeUploadedTorrent(file); + expect(out.includes(Buffer.concat([raw('32:'), ROOT, bstr(LAYER)]))).toBe(true); + expect(infoBytes(out).equals(infoBytes(file))).toBe(true); + }); + + it('keeps a file tree path that is not valid UTF-8', () => { + // Inside `info`: a re-encode would re-spell it, and move both infohashes. + const badPath = Buffer.from([0x66, 0xff, 0x6f]); + const tree = dictOf([[badPath, dictOf([['', enc({ length: 1, 'pieces root': ROOT })]])]]); + const info = dictOf([['file tree', tree], ['meta version', raw('i2e')], ['name', bstr('x')], ['private', raw('i1e')]]); + const out = normalizeUploadedTorrent(dictOf([['info', info]])); + expect(infoBytes(out).equals(info)).toBe(true); + }); + + it('sorts the keys a careless encoder left out of order, at every depth', () => { + // The decode → encode it replaces did this as a side effect; without it the + // same release hashed apart from the row stored before, and dodged the + // duplicate check. + const sortedInfo = enc({ length: 5, name: Buffer.from('a'), 'piece length': 16384, pieces: Buffer.alloc(20), private: 1 }); + const unsortedInfo = dictOf([ + ['private', raw('i1e')], + ['pieces', bstr(Buffer.alloc(20))], + ['name', bstr('a')], + ['piece length', raw('i16384e')], + ['length', raw('i5e')], + ]); + const out = normalizeUploadedTorrent(dictOf([['info', unsortedInfo], ['creation date', raw('i1e')]])); + expect(infoBytes(out).equals(sortedInfo)).toBe(true); + expect(out.equals(enc(bencode.decode(out)))).toBe(true); // canonical as a whole + }); + + it('sorts binary piece layer keys by their raw bytes, and keeps them raw', () => { + const low = Buffer.alloc(32, 0x81); + const high = Buffer.alloc(32, 0xf1); + const file = dictOf([ + ['info', hybridInfo({ private: 1 })], + ['piece layers', dictOf([[high, bstr(LAYER)], [low, bstr(LAYER)]])], + ]); + const out = normalizeUploadedTorrent(file); + const layers = dictEntries(out, dictEntries(out)!.entries.find((e) => e.key.toString() === 'piece layers')!.valueStart)!; + expect(layers.entries.map((e) => e.key)).toEqual([low, high]); + }); + + it('keeps the last value of a repeated key, as the decoder did', () => { + const info = dictOf([['name', bstr('first')], ['name', bstr('second')], ['private', raw('i1e')]]); + const out = normalizeUploadedTorrent(dictOf([['info', info]])); + expect(infoBytes(out).equals(enc({ name: Buffer.from('second'), private: 1 }))).toBe(true); + }); + + it('throws on anything it cannot walk', () => { + expect(() => normalizeUploadedTorrent(raw('not bencode'))).toThrow(); + expect(() => normalizeUploadedTorrent(dictOf([['name', bstr('no info')]]))).toThrow(); + expect(() => normalizeUploadedTorrent(dictOf([['info', bstr('not a dict')]]))).toThrow(); + expect(() => normalizeUploadedTorrent(raw('d4:infod4:name1:a'))).toThrow(); + }); +}); + +describe('personalizeTorrent', () => { + it('writes the member announce and tiers without touching info or piece layers', () => { + const file = hybridFile(ROOT, hybridInfo({ private: 1 })); + const out = personalizeTorrent(file, 'http://us/announce?passkey=mine', [ + ['http://us/announce?passkey=mine'], + ['udp://us:6969/announce/mine'], + ]); + const d = bencode.decode(out) as Record; + expect(Buffer.from(d.announce as Uint8Array).toString()).toBe('http://us/announce?passkey=mine'); + expect((d['announce-list'] as Uint8Array[][]).map((t) => t.map((u) => Buffer.from(u).toString()))).toEqual([ + ['http://us/announce?passkey=mine'], + ['udp://us:6969/announce/mine'], + ]); + expect(out.toString('latin1')).not.toContain('deadbeef'); + expect(infoBytes(out).equals(infoBytes(file))).toBe(true); + expect(out.includes(Buffer.concat([raw('32:'), ROOT, bstr(LAYER)]))).toBe(true); + }); + + it('strips the uploader fields of a row stored before the upload did', () => { + const file = dictOf([ + ['comment', bstr('x')], + ['info', enc({ name: Buffer.from('a'), private: 1 })], + ['url-list', enc([Buffer.from('http://webseed')])], + ]); + const out = personalizeTorrent(file, 'http://us/a', [['http://us/a']]); + expect(Object.keys(bencode.decode(out) as object)).toEqual(['announce', 'announce-list', 'info']); + }); +}); + +describe('cleanStoredTorrent', () => { + it('turns hex-spelled piece layer keys back into the roots they name', () => { + const stored = hybridFile(Buffer.from(ROOT.toString('hex')), hybridInfo({ private: 1 })); + const out = cleanStoredTorrent(stored)!; + expect(out).not.toBeNull(); + expect(out.includes(Buffer.concat([raw('32:'), ROOT, bstr(LAYER)]))).toBe(true); + expect(out.toString('latin1')).not.toContain('passkey'); + expect(infoBytes(out).equals(infoBytes(stored))).toBe(true); + }); + + it('leaves a 64-character key that is no root of this torrent', () => { + const stranger = Buffer.from(Buffer.alloc(32, 0x7e).toString('hex')); + const stored = dictOf([ + ['info', hybridInfo({ private: 1 })], + ['piece layers', dictOf([[stranger, bstr(LAYER)]])], + ]); + expect(cleanStoredTorrent(stored)).toBeNull(); + }); + + it('returns null for a row that needs nothing, or cannot be walked', () => { + expect(cleanStoredTorrent(dictOf([['info', enc({ name: Buffer.from('a'), private: 1 })]]))).toBeNull(); + expect(cleanStoredTorrent(raw('garbage'))).toBeNull(); + }); + + it('never touches info, not even a missing private flag', () => { + const info = enc({ name: Buffer.from('a') }); + const out = cleanStoredTorrent(dictOf([['announce', bstr('http://x')], ['info', info]]))!; + expect(infoBytes(out).equals(info)).toBe(true); + }); +}); + +describe('personalAnnounceUrls', () => { + const cfg = { httpUrl: 'https://t.example/announce', udpUrl: 'udp://t.example:6969/announce/', udpEnabled: true }; + + it('puts HTTP and UDP in their own tiers', () => { + const r = personalAnnounceUrls('pk', cfg); + expect(r.announce).toBe('https://t.example/announce?passkey=pk'); + expect(r.tiers).toEqual([['https://t.example/announce?passkey=pk'], ['udp://t.example:6969/announce/pk']]); + }); + + it('leaves UDP out when the tracker does not serve it, or no URL is set', () => { + expect(personalAnnounceUrls('pk', { ...cfg, udpEnabled: false }).tiers).toHaveLength(1); + expect(personalAnnounceUrls('pk', { ...cfg, udpUrl: '' }).tiers).toHaveLength(1); + }); +}); + +describe('trackerUdpEnabled', () => { + const saved = process.env.TRACKER_UDP_ENABLED; + afterEach(() => { + if (saved === undefined) delete process.env.TRACKER_UDP_ENABLED; + else process.env.TRACKER_UDP_ENABLED = saved; + }); + + it('reads the variable exactly as the tracker does', () => { + delete process.env.TRACKER_UDP_ENABLED; + expect(trackerUdpEnabled()).toBe(true); + process.env.TRACKER_UDP_ENABLED = ''; + expect(trackerUdpEnabled()).toBe(true); + process.env.TRACKER_UDP_ENABLED = 'true'; + expect(trackerUdpEnabled()).toBe(true); + process.env.TRACKER_UDP_ENABLED = 'false'; + expect(trackerUdpEnabled()).toBe(false); + process.env.TRACKER_UDP_ENABLED = '1'; + expect(trackerUdpEnabled()).toBe(false); + }); +}); diff --git a/apps/api/utils/account/loginLog.ts b/apps/api/utils/account/loginLog.ts index b169bdf5..b03d6c0b 100644 --- a/apps/api/utils/account/loginLog.ts +++ b/apps/api/utils/account/loginLog.ts @@ -7,10 +7,10 @@ * * ## Why failures are recorded * - * The failed attempt is the more useful half. There is no per-account lockout - * on this site — throttling is entirely per IP, so an attempt spread across - * addresses meets nothing at all — and this table is what makes such an attempt - * visible afterwards even though nothing stopped it at the time. + * The failed attempt is the more useful half. A guesser spread across many + * addresses meets only the per-account counter (`assertAccountNotLocked`), + * which slows them without saying who they are; this table is what makes such + * an attempt visible afterwards. * * ## The User-Agent is truncated * diff --git a/apps/api/utils/announceUrls.ts b/apps/api/utils/announceUrls.ts new file mode 100644 index 00000000..c940b44c --- /dev/null +++ b/apps/api/utils/announceUrls.ts @@ -0,0 +1,71 @@ +/** + * The announce URLs a member's `.torrent` carries — one builder for both + * download routes, so a file grabbed through Torznab and one clicked on the + * site advertise the same trackers. Torznab used to write `announce` alone, so a + * torrent fetched by an *arr app never got the UDP tier even where it was served. + */ + +/** + * Whether the tracker serves BEP 15, read exactly as the tracker reads it + * (`getEnvDefault("TRACKER_UDP_ENABLED", "true") == "true"` in + * `apps/tracker/internal/config`): unset or empty means on, and anything but + * the literal `true` means off. The API, the public stats and the `.torrent` + * files only agree with the listener if the deployment hands this process the + * same variable — the packaged Compose file and Helm chart now do. + */ +export function trackerUdpEnabled(): boolean { + const v = process.env.TRACKER_UDP_ENABLED; + return (v ? v : 'true') === 'true'; +} + +export interface AnnounceConfig { + /** `NUXT_PUBLIC_TRACKER_HTTP_URL`. */ + httpUrl: string; + /** `NUXT_PUBLIC_TRACKER_UDP_URL`, possibly empty. */ + udpUrl: string; + /** `trackerUdpEnabled()`. */ + udpEnabled: boolean; +} + +/** + * `announce` and the BEP 12 `announce-list` for one member. + * + * HTTP (always present): `…/announce?passkey=…` — the canonical form every BT + * client understands. + * + * UDP (BEP 15), only when the tracker serves it AND a URL is configured: the + * passkey goes in as a trailing path segment — `udp://host:6969/announce/PASSKEY` + * — because BEP 15 has no passkey field and the UDP tracker pulls it out of the + * BEP 41 URL_DATA option that libtorrent, qBittorrent and Transmission send for + * the path. Advertising a UDP endpoint nobody serves is worse than leaving it + * out: a client that prefers UDP backs off the working HTTP tracker every + * announce, which breaks ratio in subtle ways. + * + * HTTP and UDP sit in their own tiers so a client announces to both rather than + * treating one as the other's fallback — how every multi-protocol tracker we + * cribbed from (opentrackr, AnimeBytes…) advertises itself. + */ +export function personalAnnounceUrls( + passkey: string, + cfg: AnnounceConfig, +): { announce: string; tiers: string[][] } { + const httpUrl = new URL(cfg.httpUrl); + httpUrl.searchParams.set('passkey', passkey); + const announce = httpUrl.toString(); + const tiers: string[][] = [[announce]]; + + if (cfg.udpEnabled && cfg.udpUrl) { + try { + const udpUrl = new URL(cfg.udpUrl); + // A trailing slash on the configured path would double the separator. + const basePath = udpUrl.pathname.replace(/\/+$/, '') || '/announce'; + udpUrl.pathname = `${basePath}/${passkey}`; + tiers.push([udpUrl.toString()]); + } catch { + // A misconfigured UDP URL must not cost the member their download. + console.warn('[download] invalid trackerUdpUrl, skipping UDP announce-list entry:', cfg.udpUrl); + } + } + + return { announce, tiers }; +} diff --git a/apps/api/utils/bencodeScan.ts b/apps/api/utils/bencodeScan.ts new file mode 100644 index 00000000..2e0d8e81 --- /dev/null +++ b/apps/api/utils/bencodeScan.ts @@ -0,0 +1,111 @@ +/** + * A bencode scanner that walks structure without interpreting it: it does not + * need to understand a single value, only where each one ends. + * + * Two kinds of caller need exactly that. An infohash — v1 or v2 — is the hash of + * the ORIGINAL bytes of the info dict, so `infoDictRange` locates them rather + * than re-encoding a decoded value. And every rewrite of a `.torrent` + * (`torrentBytes`) splices byte ranges instead of decoding and re-encoding, + * because `bencode@4` re-spells in hex any dictionary key that is not valid + * UTF-8, and the keys of a BEP 52 `piece layers` dict never are. + * + * Everything is bounded by the buffer length on every path, so a hostile file + * cannot make it loop. + */ + +/** + * Nothing legitimate nests past a handful of levels. The walk cannot loop — + * every step is forward — but it recurses once per level, and a file nesting + * fifteen thousand containers used to blow the stack on the upload path, where + * the `RangeError` reached the member as a 500 instead of "this file cannot be + * read". + */ +export const MAX_DEPTH = 64; + +const D = 0x64; // d +const L = 0x6c; // l +const I = 0x69; // i +const E = 0x65; // e +const COLON = 0x3a; + +/** + * Index one past the bencoded value that starts at `i`, or -1 when it is + * malformed, truncated or nested too deep. + */ +export function skipValue(bytes: Buffer, i: number, depth = 0): number { + if (i >= bytes.length || depth > MAX_DEPTH) return -1; + const c = bytes[i]!; + + // Integer: `ie`, optionally negative. Leading zeros and `-0` are + // tolerated — BEP 3 forbids them, but a file stored years ago may carry one, + // and refusing it here would make that torrent undownloadable — while + // anything that is not a digit is malformed. + if (c === I) { + const e = bytes.indexOf(E, i + 1); + if (e === -1) return -1; + return /^-?[0-9]+$/.test(bytes.toString('latin1', i + 1, e)) ? e + 1 : -1; + } + + // Dict or list: walk until the matching `e`. + if (c === D || c === L) { + let j = i + 1; + while (j < bytes.length && bytes[j] !== E) { + const next = skipValue(bytes, j, depth + 1); + if (next <= j) return -1; // no progress, or too deep: malformed + j = next; + } + return j < bytes.length ? j + 1 : -1; + } + + // Byte string: `:`. Digits only — a leading `-` or a missing + // colon is malformed, not a negative length. + if (c >= 0x30 && c <= 0x39) { + const colon = bytes.indexOf(COLON, i); + if (colon === -1) return -1; + const digits = bytes.toString('latin1', i, colon); + if (!/^[0-9]+$/.test(digits)) return -1; + // A 20-digit length parses to something past any real buffer; the bound + // below rejects it either way. + const end = colon + 1 + Number.parseInt(digits, 10); + return end <= bytes.length ? end : -1; + } + + return -1; +} + +export interface DictEntry { + /** The key's raw bytes — never decoded, so never re-spelled. */ + key: Buffer; + /** Where the entry starts: the key's length prefix. */ + start: number; + /** Where its value starts, one past the key. */ + valueStart: number; + /** One past its value. */ + end: number; +} + +/** + * The entries of the dict that starts at `start`, in file order, and the index + * one past its closing `e`. Null for anything that is not a well-formed dict. + */ +export function dictEntries( + bytes: Buffer, + start = 0, +): { entries: DictEntry[]; end: number } | null { + if (start >= bytes.length || bytes[start] !== D) return null; + const entries: DictEntry[] = []; + let i = start + 1; + while (i < bytes.length && bytes[i] !== E) { + // Every key in a bencoded dict is a byte string. + if (bytes[i]! < 0x30 || bytes[i]! > 0x39) return null; + const keyEnd = skipValue(bytes, i); + if (keyEnd <= i) return null; + const colon = bytes.indexOf(COLON, i); + if (colon === -1 || colon >= keyEnd) return null; + const end = skipValue(bytes, keyEnd); + if (end <= keyEnd) return null; + entries.push({ key: bytes.subarray(colon + 1, keyEnd), start: i, valueStart: keyEnd, end }); + i = end; + } + return i < bytes.length ? { entries, end: i + 1 } : null; +} diff --git a/apps/api/utils/bittorrentV2.ts b/apps/api/utils/bittorrentV2.ts index a0602c0c..191223b6 100644 --- a/apps/api/utils/bittorrentV2.ts +++ b/apps/api/utils/bittorrentV2.ts @@ -37,6 +37,7 @@ */ import bencode from 'bencode'; import { createHash } from 'node:crypto'; +import { skipValue } from '~~/utils/bencodeScan'; export interface V2Content { /** SHA-256 of the bencoded `info` dict, hex. Torrent-specific (hybrid announce). */ @@ -135,10 +136,10 @@ function collectRoots( * a member with an unusual client whose hybrid torrent announces into a swarm * that does not exist, on a site where every other hybrid torrent works. * - * So the bytes are located instead. This is a bencode scanner that walks - * structure without interpreting it — it does not need to understand a single - * value, only where each one ends — and returns the half-open range of the - * top-level `info` value. + * So the bytes are located instead, with the scanner in `bencodeScan`, which + * walks structure without interpreting it — it does not need to understand a + * single value, only where each one ends — and this returns the half-open range + * of the top-level `info` value. * * Returns null for anything it cannot walk: a truncated file, a non-dict root, * no `info` key. Bounded by the buffer length on every path, so a hostile file @@ -147,68 +148,19 @@ function collectRoots( export function infoDictRange( bytes: Buffer ): { start: number; end: number } | null { - /** - * `skip` returns the index one past the value that starts at `i`, or -1. - * - * `depth` is the bound the comment above did not have. The walk cannot loop — - * every step is forward — but it recurses once per nesting level, so a file - * with fifteen thousand nested containers before the `info` key blew the - * stack, and `extractV2` is called without a try/catch on the upload path: a - * `RangeError` reached the member as a 500 instead of "this file cannot be - * read". Nothing legitimate nests past a handful of levels. - */ - const MAX_DEPTH = 64; - const skip = (i: number, depth = 0): number => { - if (i >= bytes.length || depth > MAX_DEPTH) return -1; - const c = bytes[i]!; - - // Integer: `ie`. - if (c === 0x69 /* i */) { - const e = bytes.indexOf(0x65 /* e */, i + 1); - return e === -1 ? -1 : e + 1; - } - - // Dict or list: recurse until the matching `e`. - if (c === 0x64 /* d */ || c === 0x6c /* l */) { - let j = i + 1; - while (j < bytes.length && bytes[j] !== 0x65 /* e */) { - const next = skip(j, depth + 1); - if (next <= j) return -1; // no progress, or too deep: malformed - j = next; - } - return j < bytes.length ? j + 1 : -1; - } - - // Byte string: `:`. Digits only — a leading `-` or a - // missing colon is malformed, not a negative length. - if (c >= 0x30 && c <= 0x39) { - const colon = bytes.indexOf(0x3a /* : */, i); - if (colon === -1) return -1; - const digits = bytes.toString('latin1', i, colon); - if (!/^[0-9]+$/.test(digits)) return -1; - const len = Number.parseInt(digits, 10); - // `Number.parseInt` on a 20-digit length yields something past any real - // buffer; the bound below rejects it either way. - const end = colon + 1 + len; - return end <= bytes.length ? end : -1; - } - - return -1; - }; - if (bytes.length < 2 || bytes[0] !== 0x64 /* d */) return null; let i = 1; while (i < bytes.length && bytes[i] !== 0x65 /* e */) { // Every key in a bencoded dict is a byte string. const keyStart = i; - const keyEnd = skip(keyStart); + const keyEnd = skipValue(bytes, keyStart); if (keyEnd <= keyStart) return null; const colon = bytes.indexOf(0x3a /* : */, keyStart); if (colon === -1 || colon >= keyEnd) return null; const key = bytes.toString('latin1', colon + 1, keyEnd); - const valEnd = skip(keyEnd); + const valEnd = skipValue(bytes, keyEnd); if (valEnd <= keyEnd) return null; if (key === 'info') return { start: keyEnd, end: valEnd }; diff --git a/apps/api/utils/bonusEvents.ts b/apps/api/utils/bonusEvents.ts index f6aebe5b..e9ef24a6 100644 --- a/apps/api/utils/bonusEvents.ts +++ b/apps/api/utils/bonusEvents.ts @@ -56,11 +56,11 @@ export interface ActiveBonusEventSnapshot { } const ACTIVE_KEY = 'bonus:active'; -// Long TTL — the snapshot is the canonical source for the tracker -// between mutations, but we re-stamp on every admin write and on every -// /api/bonus/active read, so a stale value can only persist if both -// fail. 5 minutes balances "self-heal quickly" against "don't thrash -// Redis on a quiet site". +// The snapshot is the canonical source for the tracker between mutations. +// `plugins/bonus-event-sync` re-stamps it every minute; admin writes and a +// read that finds it expired do too. The TTL only bounds how long a snapshot +// outlives an API that stopped re-stamping it — reads alone never kept it +// alive, which is how an event used to lapse on a quiet night. const ACTIVE_TTL_S = 5 * 60; /** diff --git a/apps/api/utils/channels/smtp.ts b/apps/api/utils/channels/smtp.ts index 4435d0a8..62635c27 100644 --- a/apps/api/utils/channels/smtp.ts +++ b/apps/api/utils/channels/smtp.ts @@ -56,6 +56,8 @@ async function getTransporter(server: SmtpServer) { // interpolated into `href="…"` below, and an unescaped quote would end // the attribute early and let the rest be read as markup. function escapeHtml(s: string): string { + // Complete for HTML text and double-quoted attributes. + // nosemgrep: javascript.audit.detect-replaceall-sanitization.detect-replaceall-sanitization return s .replaceAll('&', '&') .replaceAll('<', '<') diff --git a/apps/api/utils/channels/telegram.ts b/apps/api/utils/channels/telegram.ts index cac5b24a..3b1c1ddd 100644 --- a/apps/api/utils/channels/telegram.ts +++ b/apps/api/utils/channels/telegram.ts @@ -27,11 +27,17 @@ interface TelegramUser { chatId: string; } +// `"` too: the result also lands in `href="…"` below, where an unescaped quote +// ends the attribute early. `"` is one of the four named entities +// Telegram's HTML mode accepts (with `<`, `>`, `&`); the apostrophe +// needs none, the only attribute below being double-quoted. function escapeHtml(s: string): string { + // nosemgrep: javascript.audit.detect-replaceall-sanitization.detect-replaceall-sanitization return s .replaceAll('&', '&') .replaceAll('<', '<') - .replaceAll('>', '>'); + .replaceAll('>', '>') + .replaceAll('"', '"'); } function renderHtml(payload: NotificationPayload): string { diff --git a/apps/api/utils/credentialRewrap.ts b/apps/api/utils/credentialRewrap.ts new file mode 100644 index 00000000..8d3469bc --- /dev/null +++ b/apps/api/utils/credentialRewrap.ts @@ -0,0 +1,84 @@ +/** + * Re-encrypts the account secrets that are not under the current key yet: + * login verifiers and TOTP seeds still in plaintext from before encryption at + * rest, and values readable only through `CREDENTIAL_ENCRYPTION_KEY_PREVIOUS`. + * + * Those used to move only when their owner next logged in. A dormant account + * therefore kept its verifier in clear — and the verifier is the credential: + * a database dump alone was enough to sign in as that member, the very thing + * encryption at rest was added to stop (SECURITY-REVIEW.md, H1). A key rotation + * waited on the same logins before the previous key could be dropped. + * + * The cursor is keyed on the current key's check value, so a rotation starts a + * fresh walk on its own. Each write is conditional on the value it read, so a + * password change or a TOTP enrolment landing in between is never overwritten. + * Suspended during panic: panic ciphertext has the same `iv:ct:tag` shape, and + * re-encrypting it under another key would leave the restore nothing to + * decrypt. + */ +import { db, schema } from '@trackarr/db'; +import { and, asc, eq, gt } from 'drizzle-orm'; +import { + decryptSecret, + encryptSecret, + encryptSecretRequired, + keyCheckValue, + needsRewrite, +} from '~~/utils/credentialSecrets'; +import { getSetting, setSetting } from '~~/utils/server'; +import { panicActive } from '~~/utils/panicLock'; + +const BATCH_SIZE = 500; + +export function rewrapCursorKey(): string { + return `credential_rewrap_cursor:${keyCheckValue()}`; +} + +/** One batch of the sweep. Exported for the integration suite. */ +export async function rewrapCredentialsTick(): Promise<{ processed: number; rewritten: number }> { + if (await panicActive()) return { processed: 0, rewritten: 0 }; + const cursorKey = rewrapCursorKey(); + const cursor = (await getSetting(cursorKey)) ?? ''; + + const rows = await db + .select({ + id: schema.users.id, + authVerifier: schema.users.authVerifier, + totpSecret: schema.users.totpSecret, + }) + .from(schema.users) + .where(gt(schema.users.id, cursor)) + .orderBy(asc(schema.users.id)) + .limit(BATCH_SIZE); + + if (!rows.length) return { processed: 0, rewritten: 0 }; + + let rewritten = 0; + for (const row of rows) { + if (row.authVerifier && needsRewrite(row.authVerifier)) { + const plain = decryptSecret(row.authVerifier); + // Null: under no key this instance holds. Left alone — the login fails + // closed on it already, and rewriting it would destroy the evidence. + if (plain) { + await db + .update(schema.users) + .set({ authVerifier: encryptSecretRequired(plain) }) + .where(and(eq(schema.users.id, row.id), eq(schema.users.authVerifier, row.authVerifier))); + rewritten += 1; + } + } + if (row.totpSecret && needsRewrite(row.totpSecret)) { + const plain = decryptSecret(row.totpSecret); + if (plain) { + await db + .update(schema.users) + .set({ totpSecret: encryptSecret(plain) }) + .where(and(eq(schema.users.id, row.id), eq(schema.users.totpSecret, row.totpSecret))); + rewritten += 1; + } + } + } + + await setSetting(cursorKey, rows[rows.length - 1]!.id); + return { processed: rows.length, rewritten }; +} diff --git a/apps/api/utils/federation/config.ts b/apps/api/utils/federation/config.ts index 01dc51fc..dbaa61ae 100644 --- a/apps/api/utils/federation/config.ts +++ b/apps/api/utils/federation/config.ts @@ -16,8 +16,8 @@ import { type FederationConfig, } from '@trackarr/db/schema'; import { encryptJson, decryptJson } from '../channelSecrets'; -import { redis } from '../server'; import { generateInstanceKeypair } from './keys'; +import { panicActive } from '../panicLock'; const SINGLETON = 'singleton'; @@ -158,32 +158,11 @@ export function syncIntervalMs(): number { * every partner then ingested over good data), while the S2S endpoints kept * serving the catalogue. * - * This is the one flag that gates all of it. Read through a short Redis cache - * because it is now consulted on the hot serving path; a stale value costs at - * most `PANIC_CACHE_TTL_S` seconds of the very thing panic is racing to stop, - * which is why the window is small. On any Redis error it falls back to the - * database rather than failing open — panic must not be defeated by a cache - * miss. + * This is the one flag that gates all of it — and it is panic itself, phases + * included, as `utils/panicLock` defines it: `is_encrypted` alone stayed false + * for the whole `encrypting` phase, during which the mint loop published records + * of torrents already encrypted. */ -const PANIC_CACHE_KEY = 'federation:panic:suspended'; -const PANIC_CACHE_TTL_S = 5; - export async function federationSuspended(): Promise { - try { - const cached = await redis.get(PANIC_CACHE_KEY); - if (cached !== null) return cached === '1'; - } catch { - /* fall through to the database */ - } - const [row] = await db - .select({ isEncrypted: schema.panicState.isEncrypted }) - .from(schema.panicState) - .limit(1); - const suspended = !!row?.isEncrypted; - try { - await redis.setex(PANIC_CACHE_KEY, PANIC_CACHE_TTL_S, suspended ? '1' : '0'); - } catch { - /* best effort */ - } - return suspended; + return panicActive(); } diff --git a/apps/api/utils/hnr.ts b/apps/api/utils/hnr.ts index d6f49d9c..d25ed922 100644 --- a/apps/api/utils/hnr.ts +++ b/apps/api/utils/hnr.ts @@ -1,5 +1,5 @@ import { db, schema } from '@trackarr/db'; -import { eq, and, lt, sql } from 'drizzle-orm'; +import { eq, and, gt, lt, sql } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { isHnrEnabled, @@ -119,6 +119,11 @@ export async function checkAndMarkHnrs(): Promise { // - Not yet completed // - Not exempt // - Not already marked as HnR + // - Something was actually downloaded. The row is born when the member + // clicks Download (`recordDownloadClick`), before any byte moves; a member + // who clicked and never started the torrent took nothing from the swarm, + // and this sweep now runs on its own (`plugins/hnr-sweep`), not only when + // a moderator asks. const result = await db .update(schema.hnrTracking) .set({ isHnr: true }) @@ -127,7 +132,8 @@ export async function checkAndMarkHnrs(): Promise { eq(schema.hnrTracking.isHnr, false), eq(schema.hnrTracking.isExempt, false), sql`${schema.hnrTracking.completedAt} IS NULL`, - lt(schema.hnrTracking.downloadedAt, cutoffDate) + lt(schema.hnrTracking.downloadedAt, cutoffDate), + gt(schema.hnrTracking.downloaded, 0) ) ) .returning(); diff --git a/apps/api/utils/irc/format.ts b/apps/api/utils/irc/format.ts index 3fe09e26..13f1856c 100644 --- a/apps/api/utils/irc/format.ts +++ b/apps/api/utils/irc/format.ts @@ -331,6 +331,8 @@ export function announcePattern(template: string): AnnouncePattern { * shipped pattern instead of a lookalike. */ export function toJsRegExp(pattern: string): RegExp { + // Built by announcePattern from escaped literals and named groups, never typed in as a regex. + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp return new RegExp(pattern.replace(/\(\?P `ratelimit:signin-fail-device:${deviceId}`; +const revokedKey = (deviceId: string) => `known-device:revoked:${deviceId}`; + +/** What a cookie is bound to. Any of these changing voids it. */ +export interface KnownDeviceSubject { + userId: string; + sessionEpoch: number | null | undefined; + /** The stored column as is, ciphertext included: it changes when the password does. */ + authVerifier: string | null | undefined; +} + +const VALUE = /^v1\.([A-Za-z0-9_-]{22})\.(\d{1,12})\.([A-Za-z0-9_-]{43})$/; + +let cachedKey: { secret: string; key: Buffer } | null = null; + +/** A key of its own, derived from the session secret rather than reusing it as is. */ +function signingKey(): Buffer { + const secret = process.env.NUXT_SESSION_SECRET; + if (!secret || secret.length < 32) { + throw new Error('NUXT_SESSION_SECRET must be set and at least 32 characters long'); + } + if (cachedKey?.secret !== secret) { + cachedKey = { + secret, + key: createHmac('sha256', secret).update('trackarr known-device cookie v1').digest(), + }; + } + return cachedKey.key; +} + +function mac(subject: KnownDeviceSubject, deviceId: string, issuedAt: number): string { + const credential = createHash('sha256') + .update(subject.authVerifier ?? '') + .digest('base64url'); + return createHmac('sha256', signingKey()) + .update( + `known-device|${subject.userId}|${subject.sessionEpoch ?? 0}|${credential}|${deviceId}|${issuedAt}`, + ) + .digest('base64url'); +} + +/** The cookie value for `subject`. Exported for the tests. */ +export function encodeKnownDevice( + subject: KnownDeviceSubject, + deviceId: string, + issuedAt: number, +): string { + return `v1.${deviceId}.${issuedAt}.${mac(subject, deviceId, issuedAt)}`; +} + +/** + * The device id `value` carries, if it was issued for `subject` and has not + * outlived its life. Format, signature and age only: revocation is Redis's + * business, see `knownDeviceFor`. + */ +export function verifyKnownDevice( + value: string | null | undefined, + subject: KnownDeviceSubject, + now: number = Math.floor(Date.now() / 1000), +): string | null { + const m = value ? VALUE.exec(value) : null; + if (!m) return null; + const [, deviceId = '', issued = '', given = ''] = m; + const issuedAt = Number(issued); + if (issuedAt > now + FUTURE_SKEW_S || now - issuedAt > KNOWN_DEVICE_TTL_S) return null; + const expected = Buffer.from(mac(subject, deviceId, issuedAt)); + const actual = Buffer.from(given); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null; + return deviceId; +} + +/** + * The device id of a valid, unrevoked known-device cookie for `username`, or + * null. Reads the account only when there is a cookie to check, so a sign-in + * without one costs nothing more than before. + * + * Fails closed on a Redis error: a device whose revocation cannot be checked is + * treated as unknown, which is what every browser was before this existed. + */ +export async function knownDeviceFor( + value: string | null | undefined, + username: string, +): Promise { + if (!value || !VALUE.test(value)) return null; + const user = await db + .select({ id: users.id, sessionEpoch: users.sessionEpoch, authVerifier: users.authVerifier }) + .from(users) + .where(eq(users.username, username)) + .limit(1) + .then((r) => r[0]); + if (!user) return null; + const deviceId = verifyKnownDevice(value, { + userId: user.id, + sessionEpoch: user.sessionEpoch, + authVerifier: user.authVerifier, + }); + if (!deviceId) return null; + try { + if (await redis.exists(revokedKey(deviceId))) return null; + } catch { + return null; + } + return deviceId; +} + +/** `knownDeviceFor` on the cookie this request carries. */ +export function readKnownDevice(event: H3Event, username: string): Promise { + return knownDeviceFor(getCookie(event, KNOWN_DEVICE_COOKIE), username); +} + +/** + * One failed sign-in from a known device. The tenth inside the window revokes + * its id for good: from then on the browser is unknown, and answers to the + * account's counter like any other. + */ +export async function recordKnownDeviceFailure(deviceId: string): Promise { + const key = failureKey(deviceId); + const now = Date.now(); + try { + const results = await redis + .multi() + .zadd(key, now, `${now}:${randomBytes(6).toString('hex')}`) + .zremrangebyscore(key, 0, now - DEVICE_FAILURE_WINDOW_S * 1000) + .expire(key, DEVICE_FAILURE_WINDOW_S) + .zcard(key) + .exec(); + // [error, reply] per command; the fourth is the count after the trim. + if (Number(results?.[3]?.[1] ?? 0) >= DEVICE_MAX_FAILURES) { + await redis.set(revokedKey(deviceId), '1', 'EX', KNOWN_DEVICE_TTL_S); + } + } catch { + /* best effort, like the account's counter */ + } +} + +/** + * Hand this browser a fresh known-device cookie once a sign-in is complete — + * session open, second factor met. A new device id each time, so an id revoked + * on this browser is never carried forward. + */ +export function issueKnownDevice(event: H3Event, subject: KnownDeviceSubject): void { + const deviceId = randomBytes(16).toString('base64url'); + setCookie( + event, + KNOWN_DEVICE_COOKIE, + encodeKnownDevice(subject, deviceId, Math.floor(Date.now() / 1000)), + { + httpOnly: true, + sameSite: 'lax', + // As the session cookie: a plain-HTTP development stack would never get it back. + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: KNOWN_DEVICE_TTL_S, + }, + ); +} diff --git a/apps/api/utils/loginChallenge.ts b/apps/api/utils/loginChallenge.ts new file mode 100644 index 00000000..7b0f3bad --- /dev/null +++ b/apps/api/utils/loginChallenge.ts @@ -0,0 +1,10 @@ +/** + * What `login:` holds for a username that has no account. + * + * A real challenge holds the account id — a UUID, so this can never collide with + * one. The challenge route stores it under the same key as a real challenge, and + * the login turns it into the same "Invalid credentials" as a wrong proof: + * the two branches have to be indistinguishable from outside, or the error + * message enumerates the members the fake salt is there to hide. + */ +export const UNKNOWN_ACCOUNT_CHALLENGE = '!unknown'; diff --git a/apps/api/utils/mediaIds.ts b/apps/api/utils/mediaIds.ts index e6d57831..6c60e7c3 100644 --- a/apps/api/utils/mediaIds.ts +++ b/apps/api/utils/mediaIds.ts @@ -33,22 +33,6 @@ export function normalizeImdbId(input: unknown): string | null { return null; } -function normalizeNumericId( - input: unknown, - hostPattern: RegExp, - minDigits: number, - maxDigits: number -): string | null { - if (typeof input !== 'string') return null; - const trimmed = input.trim(); - if (!trimmed) return null; - const fromUrl = trimmed.match(hostPattern); - if (fromUrl) return fromUrl[1]!; - const re = new RegExp(`^\\d{${minDigits},${maxDigits}}$`); - if (re.test(trimmed)) return trimmed; - return null; -} - export function normalizeTmdbId(input: unknown): string | null { // TMDb has TWO independent id namespaces — `/movie/{n}` and `/tv/{n}` // — and the same integer can resolve in both. Without a type hint diff --git a/apps/api/utils/metadata/igdb.ts b/apps/api/utils/metadata/igdb.ts index 5d0c6a1c..c63ef208 100644 --- a/apps/api/utils/metadata/igdb.ts +++ b/apps/api/utils/metadata/igdb.ts @@ -208,7 +208,7 @@ async function igdbPost(path: string, query: string): Promise return (await res.json()) as T; } catch (err) { if (err instanceof UpstreamUnavailableError) throw err; - console.warn(`[metadata:igdb] fetch failed for ${path}:`, err); + console.warn('[metadata:igdb] fetch failed for %s:', path, err); throw new UpstreamUnavailableError('igdb', null); } } diff --git a/apps/api/utils/metadata/openlibrary.ts b/apps/api/utils/metadata/openlibrary.ts index fdf2a422..2f8317be 100644 --- a/apps/api/utils/metadata/openlibrary.ts +++ b/apps/api/utils/metadata/openlibrary.ts @@ -73,7 +73,7 @@ async function olGet( return (await res.json()) as T; } catch (err) { if (err instanceof UpstreamUnavailableError) throw err; - console.warn(`[metadata:openlibrary] fetch failed for ${path}:`, err); + console.warn('[metadata:openlibrary] fetch failed for %s:', path, err); throw new UpstreamUnavailableError('openlibrary', null); } } @@ -98,7 +98,7 @@ async function gbGet( return (await res.json()) as T; } catch (err) { if (err instanceof UpstreamUnavailableError) throw err; - console.warn(`[metadata:googlebooks] fetch failed for ${path}:`, err); + console.warn('[metadata:googlebooks] fetch failed for %s:', path, err); throw new UpstreamUnavailableError('googlebooks', null); } } diff --git a/apps/api/utils/metadata/tmdb.ts b/apps/api/utils/metadata/tmdb.ts index e92f6235..d7d0277d 100644 --- a/apps/api/utils/metadata/tmdb.ts +++ b/apps/api/utils/metadata/tmdb.ts @@ -103,7 +103,7 @@ async function tmdbGet( signal: AbortSignal.timeout(8000), }); } catch (err) { - console.warn(`[metadata:tmdb] fetch failed for ${path}:`, err); + console.warn('[metadata:tmdb] fetch failed for %s:', path, err); throw new UpstreamUnavailableError('tmdb', null); } if (res.status === 404) return null; @@ -114,7 +114,7 @@ async function tmdbGet( try { return (await res.json()) as T; } catch (err) { - console.warn(`[metadata:tmdb] unreadable body on ${path}:`, err); + console.warn('[metadata:tmdb] unreadable body on %s:', path, err); throw new UpstreamUnavailableError('tmdb', res.status); } } diff --git a/apps/api/utils/metrics.ts b/apps/api/utils/metrics.ts index fb8b6d1e..fc7b0fc5 100644 --- a/apps/api/utils/metrics.ts +++ b/apps/api/utils/metrics.ts @@ -13,29 +13,32 @@ * keyspace. */ /* - * `prom-client` est marqué déprécié (« replaced by @prometheus-io/client »). - * On y reste, délibérément, et voici les chiffres qui portent ce choix — pris - * le 2026-09-02 : + * `@prometheus-io/client`, et non plus `prom-client` : c'est le même code, + * devenu sous-projet de Prometheus en août 2026 (même auteur, Simon Nyberg) et + * renommé au passage. `prom-client` est marqué « replaced by + * @prometheus-io/client » et ne recevra plus rien. * - * - `prom-client@15.1.3` n'a AUCUNE alerte de sécurité (audit npm en masse - * sur les 1101 paquets du verrou : 0). Déprécié n'est pas vulnérable. - * - `@prometheus-io/client` en est à `0.16.1`, avec quatre versions publiées - * en tout, la première non-alpha le 2026-08-24. Le dépôt s'impose - * `minimumReleaseAge: 1440` justement pour ne pas dépendre du frais ; un - * 0.x de six jours est très en dessous de cette barre. - * - Il ajoute une dépendance d'exécution, `@opentelemetry/api`. + * La bascule avait été repoussée le 2026-09-02 : la 0.16.1 avait six jours, + * très en dessous de la barre que pose `minimumReleaseAge`. Faite le + * 2026-10-24, sur la même 0.16.1, et voici ce que la 0.16 change pour nous : * - * La surface à migrer est pourtant minuscule — ce fichier est le seul - * appelant, et il n'utilise que les quatre symboles ci-dessous. Le jour où le - * successeur atteint une majeure stable, la bascule tient en un import. - * D'ici là, changer de bibliothèque de métriques ne corrigerait rien. + * - le nom du paquet — ce fichier est le seul appelant, d'où un seul import ; + * - Node 22 minimum — nous tournons en 26 ; + * - `MetricType` devient une union de chaînes, `AggregatorRegistry` s'appelle + * `ClusterRegistry` et les messages IPC du mode cluster sont renommés : + * rien de cela n'est utilisé ici ; + * - deux métriques par défaut de plus, `trackarr_nodejs_eventloop_utilization_*`. + * + * À surveiller à la version suivante : une jauge ne démarrera plus à zéro. + * Toutes celles-ci sont posées à chaque collecte, sauf quand leur requête + * échoue — elles seraient alors absentes d'un premier scrape au lieu de valoir 0. */ import { Registry, Gauge, collectDefaultMetrics, type MetricObjectWithValues, -} from 'prom-client'; +} from '@prometheus-io/client'; import { db, schema } from '@trackarr/db'; import { recordStore } from './federation/storeCounts'; import { sql, eq, isNull, and, or, gt, ne, lte } from 'drizzle-orm'; @@ -54,10 +57,10 @@ export const registry = new Registry(); collectDefaultMetrics({ register: registry, prefix: 'trackarr_' }); // `Gauge` config takes `registers: Registry[]`; `register` (singular) is -// silently ignored, which would route every gauge to prom-client's default +// silently ignored, which would route every gauge to the library's default // global registry instead of our custom one. Spent an embarrassing amount // of time staring at /metrics wondering where my counts went. -// Typed rather than `as const`: prom-client's configs declare +// Typed rather than `as const`: the library's configs declare // `registers: Registry[]`, and a readonly tuple is not assignable to a // mutable array — that single `as const` produced 55 of the API's type // errors, one per metric spread. diff --git a/apps/api/utils/panic.ts b/apps/api/utils/panic.ts index 1ef628b3..af427d9e 100644 --- a/apps/api/utils/panic.ts +++ b/apps/api/utils/panic.ts @@ -22,6 +22,7 @@ import { type ScryptOptions, } from 'crypto'; import { promisify } from 'util'; +import { dictEntries } from '~~/utils/bencodeScan'; /** * `promisify` ne retient qu'une des surcharges de `scrypt`, celle sans options. @@ -192,3 +193,55 @@ export function decryptField( if (value == null) return null; return decrypt(value, key, legacyIv); } + +/** + * Déchiffrer une valeur qui ne l'est peut-être plus — le pendant de + * `encryptFieldOnce`, avec la même règle : on juge à l'authentification, pas à + * la forme. + * + * La restauration vérifie le mot de passe AVANT de dériver la clé ; une valeur + * qui ne se déchiffre pas sous cette clé n'est donc pas « une mauvaise clé », + * c'est du clair : une ligne écrite pendant que la base était chiffrée, ou une + * ligne qu'une restauration interrompue avait déjà rendue. `decryptField` + * levait sur les deux, et chaque nouvelle tentative échouait sur toutes les + * lignes que la précédente avait déjà déchiffrées : une seule écriture pendant + * la panique suffisait à rendre la base irrécupérable sans SQL à la main. + */ +export function decryptFieldOrKeep( + value: string | null | undefined, + key: Buffer, + legacyIv?: Buffer +): string | null { + if (value == null) return null; + try { + return decrypt(value, key, legacyIv); + } catch { + return value; + } +} + +/** + * La même règle pour le `.torrent` d'une ligne, que le chiffrement enveloppe en + * base64 puis en `iv:ct:tag` stocké comme octets ASCII. + * + * Une ligne qu'une restauration interrompue a déjà rendue porte de nouveau le + * fichier bencodé, qui ne se déchiffre pas. La route écrivait alors `null` à sa + * place et comptait un échec : chaque nouvelle tentative effaçait le fichier de + * toutes les lignes que la précédente avait rendues. + * + * Rend les octets stockés tels quels quand ils forment déjà un dictionnaire + * bencodé muni d'`info`. `null` quand ils ne sont ni un chiffré sous cette clé ni + * un `.torrent` : la route le compte en échec, sans rien écrire à la place. + */ +export function decryptTorrentDataOrKeep( + stored: Buffer, + key: Buffer, + legacyIv?: Buffer +): Buffer | null { + try { + return Buffer.from(decrypt(stored.toString('ascii'), key, legacyIv), 'base64'); + } catch { + const top = dictEntries(stored); + return top?.entries.some((e) => e.key.toString('latin1') === 'info') ? stored : null; + } +} diff --git a/apps/api/utils/panicLock.ts b/apps/api/utils/panicLock.ts new file mode 100644 index 00000000..9128e158 --- /dev/null +++ b/apps/api/utils/panicLock.ts @@ -0,0 +1,82 @@ +/** + * Whether panic mode is under way: from the moment the encrypt pass writes its + * salt until a restore completes — every phase but `idle`, and `is_encrypted` + * for databases encrypted before phases existed. + * + * `is_encrypted` alone is not that. It stays false for the whole `encrypting` + * phase, and an interrupted pass can sit there indefinitely, so everything that + * read only the flag — the federation gate first — ran on through a database + * already half encrypted. + * + * Several things stop on this answer: federation (`federationSuspended`), every + * write that is not part of the panic itself (`middleware/panic-lock`), and the + * catalogue sweeps, which would otherwise walk past encrypted rows and never come + * back for them. + * + * Read through a short Redis cache because the write lock consults it on every + * mutating request; the panic routes clear it as they change the state, so the + * window only covers other replicas. On any Redis error it reads the database + * rather than failing open: panic must not be defeated by a cache miss. + */ +import { db, schema } from '@trackarr/db'; +import { redis } from '~~/utils/server'; + +const CACHE_KEY = 'panic:active'; +const CACHE_TTL_S = 5; + +export async function panicActive(): Promise { + try { + const cached = await redis.get(CACHE_KEY); + if (cached !== null) return cached === '1'; + } catch { + /* fall through to the database */ + } + const [row] = await db + .select({ isEncrypted: schema.panicState.isEncrypted, phase: schema.panicState.phase }) + .from(schema.panicState) + .limit(1); + const active = !!row && (row.isEncrypted || row.phase !== 'idle'); + try { + await redis.setex(CACHE_KEY, CACHE_TTL_S, active ? '1' : '0'); + } catch { + /* best effort */ + } + return active; +} + +/** Called by the panic routes as they change the state. */ +export async function invalidatePanicCache(): Promise { + try { + await redis.del(CACHE_KEY); + } catch { + /* the TTL bounds it */ + } +} + +const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** The writes that are part of the panic itself, and leaving. */ +const ALLOWED_DURING_PANIC = new Set([ + '/api/admin/panic/encrypt', + '/api/admin/panic/restore', + '/api/auth/logout', +]); + +/** + * Whether a request may run while panic mode stands (`middleware/panic-lock`). + * Every read may; a write only if it resumes the encrypt pass, restores, or logs + * out. Fail-closed on the spelling: the path is decoded and lower-cased before + * the exact comparison, and a write that does not name an allowed route — + * whatever prefix it claims — is refused. Every writing route lives under + * `/api/`, so nothing legitimate is lost by not trusting the prefix. + */ +export function allowedDuringPanic(method: string, rawPath: string): boolean { + if (!WRITE_METHODS.has(method.toUpperCase())) return true; + let path = rawPath.split('?')[0] ?? ''; + try { + path = decodeURIComponent(path); + } catch { + return false; + } + return ALLOWED_DURING_PANIC.has(path.toLowerCase().replace(/\/+$/, '')); +} diff --git a/apps/api/utils/rateLimit.ts b/apps/api/utils/rateLimit.ts index cb9660f5..877f5351 100644 --- a/apps/api/utils/rateLimit.ts +++ b/apps/api/utils/rateLimit.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { redis } from '~~/utils/server'; import { isIP } from 'node:net'; +import { createError } from 'h3'; // ============================================================================ // Types & Configuration @@ -231,6 +232,54 @@ function normalizeIP(ip: string): string { return ip; } +/** The eight 16-bit groups of an IPv6 address, or null if it is not one. */ +function ipv6Groups(ip: string): number[] | null { + const bare = ip.split('%')[0]!; // zone index + const halves = bare.split('::'); + if (halves.length > 2) return null; + const parse = (part: string): number[] | null => { + if (!part) return []; + const out: number[] = []; + const pieces = part.split(':'); + for (let i = 0; i < pieces.length; i++) { + const piece = pieces[i]!; + // An embedded IPv4 tail (`64:ff9b::192.0.2.1`) is two groups. + if (i === pieces.length - 1 && piece.includes('.')) { + if (isIP(piece) !== 4) return null; + const [a, b, c, d] = piece.split('.').map(Number) as [number, number, number, number]; + out.push((a << 8) | b, (c << 8) | d); + continue; + } + if (!/^[0-9a-f]{1,4}$/i.test(piece)) return null; + out.push(parseInt(piece, 16)); + } + return out; + }; + const head = parse(halves[0]!); + const tail = halves.length === 2 ? parse(halves[1]!) : []; + if (!head || !tail) return null; + if (halves.length === 1) return head.length === 8 ? head : null; + const missing = 8 - head.length - tail.length; + return missing < 1 ? null : [...head, ...new Array(missing).fill(0), ...tail]; +} + +/** + * What an address-based limit counts: the address for IPv4, its /64 for IPv6. + * + * One IPv6 host is routinely handed a whole /64 and can pick a fresh address + * for every request, so a limit keyed on the full address — and the blacklist + * built on top of it — stopped nobody who rotated inside their own prefix. A + * /64 is the smallest block a subscriber is assigned, so it is the narrowest + * unit that still means "one customer". + */ +export function rateLimitSubject(raw: string): string { + const ip = normalizeIP(raw); + if (isIP(ip) !== 6) return ip; + const groups = ipv6Groups(ip); + if (!groups) return ip; + return `${groups.slice(0, 4).map((g) => g.toString(16)).join(':')}::/64`; +} + // ============================================================================ // Blacklist Management // ============================================================================ @@ -265,7 +314,7 @@ const MAX_BLACKLIST_DURATION = 86400; // 24 hours max * Check if an IP is blacklisted */ export async function isBlacklisted(ip: string): Promise { - const normalizedIP = normalizeIP(ip); + const normalizedIP = rateLimitSubject(ip); try { if ((await redis.exists(blacklistKey(normalizedIP))) === 1) return true; @@ -299,7 +348,7 @@ export async function blacklistIP( reason: string, previousViolations: number = 0 ): Promise { - const normalizedIP = normalizeIP(ip); + const normalizedIP = rateLimitSubject(ip); const violations = previousViolations + 1; // Progressive penalty: doubles each time, up to max @@ -335,7 +384,7 @@ export async function blacklistIP( * Get violation count for an IP */ async function getViolationCount(ip: string): Promise { - const normalizedIP = normalizeIP(ip); + const normalizedIP = rateLimitSubject(ip); try { const data = @@ -480,10 +529,18 @@ export async function rateLimit( } = options; const ip = getClientIP(event); - const normalizedIP = normalizeIP(ip); + const normalizedIP = rateLimitSubject(ip); + + // Our own server-rendering layer speaking for nobody (`isInternalOrigin`): + // `middleware/security.ts` keeps it off the blacklist, and this function has + // to agree with it — it repeated the check, and the blacklisting below, with + // no such exemption, so a burst of anonymous page renders could put the web + // container's own address on the list. The bucket itself still counts it: + // a network neighbour stays subject to the per-route limits. + const internal = isInternalOrigin(event); // Check blacklist first - if (await isBlacklisted(normalizedIP)) { + if (!internal && (await isBlacklisted(normalizedIP))) { throw createError({ statusCode: 403, message: 'Access temporarily blocked', @@ -541,7 +598,7 @@ export async function rateLimit( // Jamais pour un compte : la sanction doit viser le sujet compté. Mettre // une adresse en liste noire parce qu'un compte a dépassé son seau est // précisément ce qui permettait d'expulser ses voisins. - if (progressive && !actorId) { + if (progressive && !actorId && !internal) { const violations = await getViolationCount(normalizedIP); if (violations >= 3) { await blacklistIP( @@ -581,7 +638,7 @@ const DDOS_THRESHOLD = 100; // 100 requests in 10 seconds = attack */ export async function detectDDoS(event: any): Promise { const ip = getClientIP(event); - const normalizedIP = normalizeIP(ip); + const normalizedIP = rateLimitSubject(ip); if (await isBlacklisted(normalizedIP)) { throw createError({ @@ -671,6 +728,15 @@ export const RATE_LIMITS = { // Auth endpoints - strict to prevent brute force auth: { windowSec: 300, maxRequests: 5, prefix: 'auth', progressive: true }, + // Password sign-in. Its own bucket, and NOT progressive: on `auth`, the sixth + // failed login within five minutes put the whole address on the blacklist — + // every request refused with a 403, signed-in members included — so on a VPN + // exit, a campus or a carrier NAT one stranger mistyping a password locked + // everyone else out of the site. Guessing is held back by this limit per + // address and by the per-account counter below, neither of which punishes + // the neighbours. + signin: { windowSec: 300, maxRequests: 10, prefix: 'signin' }, + // Search - moderate search: { windowSec: 60, maxRequests: 30, prefix: 'search' }, @@ -686,6 +752,74 @@ export const RATE_LIMITS = { stream: { windowSec: 60, maxRequests: 12, prefix: 'sse', progressive: false }, }; +// ============================================================================ +// Per-account sign-in failures +// ============================================================================ + +/** + * Limits per address do not stop a guesser spread over many addresses, so + * failures are also counted per username — every username, existing or not, so + * a lockout never tells whether an account exists. The cost — anyone can hold + * an account out of password sign-in for the window — falls only on browsers + * that have never signed in to it: a known device answers to its own count + * instead (`knownDevices.ts`). A member's sessions are untouched either way. + */ +const ACCOUNT_FAILURE_WINDOW_S = 15 * 60; +const ACCOUNT_MAX_FAILURES = 10; + +const accountFailureKey = (username: string) => + `ratelimit:signin-fail:${username.trim().toLowerCase()}`; + +/** Throws 429 once this username has too many recent failed sign-ins. */ +export async function assertAccountNotLocked(username: string): Promise { + const key = accountFailureKey(username); + let failures = 0; + try { + await redis.zremrangebyscore(key, 0, Date.now() - ACCOUNT_FAILURE_WINDOW_S * 1000); + failures = await redis.zcard(key); + } catch { + // Redis down: the per-address limit still applies. + return; + } + if (failures >= ACCOUNT_MAX_FAILURES) { + throw createError({ + statusCode: 429, + // Told apart from the per-address limit because the way out differs: a + // browser that has signed in to this account before is not held here. + // Says nothing about whether the account exists — every username counts. + data: { reason: 'account-locked' }, + message: 'Too many failed attempts for this account. Try again later.', + }); + } +} + +/** One failed sign-in for this username, whether or not it exists. */ +export async function recordAccountFailure(username: string): Promise { + const key = accountFailureKey(username); + const now = Date.now(); + try { + await redis + .multi() + .zadd(key, now, `${now}:${randomUUID()}`) + // Only the newest entries can matter; a flood spread over thousands of + // addresses must not grow one key without bound. + .zremrangebyrank(key, 0, -(ACCOUNT_MAX_FAILURES * 4) - 1) + .expire(key, ACCOUNT_FAILURE_WINDOW_S) + .exec(); + } catch { + /* best effort */ + } +} + +/** A successful sign-in starts the count again. */ +export async function clearAccountFailures(username: string): Promise { + try { + await redis.del(accountFailureKey(username)); + } catch { + /* best effort: the window lapses anyway */ + } +} + // ============================================================================ // Middleware Helper // ============================================================================ diff --git a/apps/api/utils/releaseTags.ts b/apps/api/utils/releaseTags.ts index 1d4322bf..6d93f00a 100644 --- a/apps/api/utils/releaseTags.ts +++ b/apps/api/utils/releaseTags.ts @@ -59,6 +59,8 @@ const EMPTY: ReleaseFacets = { extras: [] }; * inside an NFO sentence. */ function token(pattern: string): RegExp { + // The patterns are this file's constant rule tables. + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp return new RegExp(`(?:^|[^a-z0-9])(?:${pattern})(?=[^a-z0-9]|$)`, 'i'); } diff --git a/apps/api/utils/savedSearchFanout.ts b/apps/api/utils/savedSearchFanout.ts index 0d07018a..ef2114d4 100644 --- a/apps/api/utils/savedSearchFanout.ts +++ b/apps/api/utils/savedSearchFanout.ts @@ -147,6 +147,8 @@ async function runFanout(torrent: SavedSearchCandidate): Promise { } if (f.season != null && f.season !== (torrent.season ?? null)) return false; if (f.episode != null && f.episode !== (torrent.episode ?? null)) return false; + // An integer year from the database, not a pattern. + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp if (f.year != null && !new RegExp(`(^|[^0-9])${f.year}([^0-9]|$)`).test(torrent.name)) return false; if (f.uploaderId && f.uploaderId !== torrent.uploaderId) return false; // A filter with no free text, no category, no tag and no id would match diff --git a/apps/api/utils/secrets.ts b/apps/api/utils/secrets.ts index ef1fc799..ed240ac0 100644 --- a/apps/api/utils/secrets.ts +++ b/apps/api/utils/secrets.ts @@ -22,7 +22,8 @@ export function readSecret(envKey: string, defaultValue?: string): string { } } catch (error) { console.error( - `[Secrets] Error reading secret file ${secretFilePath}:`, + '[Secrets] Error reading secret file %s:', + secretFilePath, error ); } @@ -74,7 +75,7 @@ export function validateRequiredSecrets(requiredKeys: string[]): void { if (missing.length > 0) { throw new Error( `Missing required secrets: ${missing.join(', ')}\n` + - 'Generate secrets with: ./scripts/generate-secrets.sh' + 'Generate each one with `openssl rand -hex 32` — see doc/guide/getting-started.md' ); } diff --git a/apps/api/utils/settings.ts b/apps/api/utils/settings.ts index 7644f776..81ca7559 100644 --- a/apps/api/utils/settings.ts +++ b/apps/api/utils/settings.ts @@ -546,9 +546,19 @@ export async function getHeroSubtitle(): Promise { ); } +/** + * The admin's label for the home page's live pill — empty when they set none, + * so the page keeps its own translated "Live". + * + * This used to default to an English sentence that nothing displayed; the + * admin form then saved that default back on any branding change. Treated as + * unset, so wiring the label up does not swap a translated pill for an English + * sentence nobody chose. + */ +const LEGACY_STATUS_BADGE_DEFAULT = 'Tracker Online & Operational'; export async function getStatusBadgeText(): Promise { - const value = await getSetting(SETTINGS_KEYS.STATUS_BADGE_TEXT); - return value || 'Tracker Online & Operational'; + const value = (await getSetting(SETTINGS_KEYS.STATUS_BADGE_TEXT)) ?? ''; + return value === LEGACY_STATUS_BADGE_DEFAULT ? '' : value; } export async function getFeature1Title(): Promise { @@ -573,7 +583,7 @@ export async function getFeature2Desc(): Promise { const value = await getSetting(SETTINGS_KEYS.FEATURE_2_DESC); return ( value || - 'Supports HTTP, UDP, and WebSocket protocols for maximum compatibility with all BitTorrent clients.' + 'Supports HTTP and UDP announces for maximum compatibility with all BitTorrent clients.' ); } diff --git a/apps/api/utils/storage/fsDriver.ts b/apps/api/utils/storage/fsDriver.ts index e88df5b3..3f3b2434 100644 --- a/apps/api/utils/storage/fsDriver.ts +++ b/apps/api/utils/storage/fsDriver.ts @@ -57,6 +57,8 @@ export class FilesystemStorage implements ObjectStorage { const baseReal = mustExist ? await realpath(this.baseDir) : resolve(this.baseDir); + // Checked on the next line: anything resolving outside the base is refused. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal const candidate = resolve(baseReal, key); if (candidate !== baseReal && !candidate.startsWith(baseReal + sep)) { throw new Error(`Refusing to address "${key}" outside ${baseReal}`); diff --git a/apps/api/utils/torrentBytes.ts b/apps/api/utils/torrentBytes.ts new file mode 100644 index 00000000..fda0d0c5 --- /dev/null +++ b/apps/api/utils/torrentBytes.ts @@ -0,0 +1,271 @@ +/** + * Byte-level edits to a `.torrent`, for every place that rewrites one: the + * upload (drops the uploader's own fields, forces `private`), the two download + * routes (write the member's announce URLs) and the backfill that cleans the + * rows stored before the upload did either. + * + * ## Why not decode, mutate, encode + * + * `bencode@4` decodes every dictionary KEY to a string, and a key that is not + * valid UTF-8 comes back spelled in hex (`decode.dictionary` in + * bencode/lib/decode.js). Encoding then writes the hex — 64 ASCII bytes where + * there were 32 raw ones. The keys of a BEP 52 `piece layers` dict are raw + * 32-byte Merkle roots, so every v2 or hybrid `.torrent` that went through the + * upload, and again through each download, came out with its piece layers + * re-keyed: a v2 client looks a layer up by its file's `pieces root` and finds + * nothing. A `file tree` path that is not valid UTF-8 suffers the same, inside + * `info`, where it also moves the infohash. + * + * So nothing here decodes a value it does not edit. The scanner (`bencodeScan`) + * only finds where each value ends, and an edit splices byte ranges: everything + * it does not touch is copied verbatim. + */ +import { MAX_DEPTH, dictEntries, skipValue, type DictEntry } from '~~/utils/bencodeScan'; +import { extractV2 } from '~~/utils/bittorrentV2'; + +/** A bencoded byte string. */ +function str(value: Buffer | string): Buffer { + const b = typeof value === 'string' ? Buffer.from(value) : value; + return Buffer.concat([Buffer.from(`${b.length}:`, 'latin1'), b]); +} + +/** A bencoded list of lists of strings — the shape of `announce-list`. */ +function tierList(tiers: string[][]): Buffer { + const parts: Buffer[] = [Buffer.from('l')]; + for (const tier of tiers) { + parts.push(Buffer.from('l'), ...tier.map((url) => str(url)), Buffer.from('e')); + } + parts.push(Buffer.from('e')); + return Buffer.concat(parts); +} + +/** + * Rewrites the dict at `start`. A key named in `edits` takes the new value, or + * disappears for `null`; a named key the dict lacks is inserted where a sorted + * dict would hold it (bencode requires sorted keys, compared as raw bytes). + * Every other entry is copied byte for byte, key included. + */ +function rewriteDict( + bytes: Buffer, + start: number, + edits: Map, +): Buffer | null { + const dict = dictEntries(bytes, start); + if (!dict) return null; + + // Edit keys are ASCII, so `latin1` compares them with the raw key bytes + // exactly — a non-ASCII key maps to characters no edit key contains. + const name = (e: DictEntry) => e.key.toString('latin1'); + const present = new Set(dict.entries.map(name)); + const missing = [...edits] + .filter(([k, v]) => v !== null && !present.has(k)) + .map(([k, v]) => ({ key: Buffer.from(k, 'latin1'), value: v! })) + .sort((a, b) => Buffer.compare(a.key, b.key)); + + const parts: Buffer[] = [Buffer.from('d')]; + const insertBefore = (key: Buffer | null) => { + while (missing.length && (key === null || Buffer.compare(missing[0]!.key, key) < 0)) { + const m = missing.shift()!; + parts.push(str(m.key), m.value); + } + }; + for (const e of dict.entries) { + insertBefore(e.key); + const k = name(e); + if (!edits.has(k)) { + parts.push(bytes.subarray(e.start, e.end)); + continue; + } + const value = edits.get(k)!; + if (value !== null) parts.push(bytes.subarray(e.start, e.valueStart), value); + } + insertBefore(null); + parts.push(Buffer.from('e')); + return Buffer.concat(parts); +} + +/** + * The value at `start` with the entries of every dictionary in sorted key + * order, at every depth; strings and integers are copied verbatim, and a value + * that is already canonical comes back as the very same bytes. + * + * The upload got this for free from decode → encode, which sorted every + * dictionary (and re-spelled every binary key). A file from a careless encoder, + * keys out of order, was therefore stored canonical, under the canonical + * infohash; kept as it came, the same release uploaded again hashed apart from + * the row stored before, and the duplicate check missed it. Sorting by raw key + * bytes is the order bencode defines, and a key that occurs twice keeps its + * last value, as the decoder did. + */ +function canonical( + bytes: Buffer, + start: number, + depth = 0, +): { out: Buffer; end: number; changed: boolean } | null { + if (depth > MAX_DEPTH) return null; + if (bytes[start] === 0x64) { + const dict = dictEntries(bytes, start); + if (!dict) return null; + let changed = false; + let previous: Buffer | null = null; + // `latin1` maps bytes to characters one to one: distinct raw keys stay distinct. + const byKey = new Map(); + for (const e of dict.entries) { + const v = canonical(bytes, e.valueStart, depth + 1); + if (!v) return null; + // A value rebuilt below, or a key out of order or repeated: rebuild this one. + if (v.changed || (previous && Buffer.compare(previous, e.key) >= 0)) changed = true; + previous = e.key; + byKey.set(e.key.toString('latin1'), { key: e.key, value: v.out }); + } + if (!changed) return { out: bytes.subarray(start, dict.end), end: dict.end, changed }; + const entries = [...byKey.values()].sort((a, b) => Buffer.compare(a.key, b.key)); + return { + out: Buffer.concat([Buffer.from('d'), ...entries.flatMap((e) => [str(e.key), e.value]), Buffer.from('e')]), + end: dict.end, + changed, + }; + } + if (bytes[start] === 0x6c) { + let changed = false; + const parts: Buffer[] = []; + let i = start + 1; + while (i < bytes.length && bytes[i] !== 0x65) { + const v = canonical(bytes, i, depth + 1); + if (!v) return null; + changed ||= v.changed; + parts.push(v.out); + i = v.end; + } + if (i >= bytes.length) return null; + const out = changed + ? Buffer.concat([Buffer.from('l'), ...parts, Buffer.from('e')]) + : bytes.subarray(start, i + 1); + return { out, end: i + 1, changed }; + } + const end = skipValue(bytes, start); + return end > start ? { out: bytes.subarray(start, end), end, changed: false } : null; +} + +/** + * The top-level fields that carry the uploader's own announce URLs, or text from + * a foreign file that has no use here. + * + * A private tracker's `.torrent` holds its owner's passkey in `announce` and in + * every tier of `announce-list`, and in a cross-seed the file usually comes from + * ANOTHER private tracker, whose passkey has no business being stored here. + * `url-list` (web seeds) would point every downloader's client at a third-party + * host. `comment` and `created by` are free text with no use here. None of them + * is inside `info`, so dropping them never moves the infohash. + */ +export const UPLOADER_FIELDS = ['announce', 'announce-list', 'url-list', 'comment', 'created by'] as const; + +const PRIVATE = Buffer.from('i1e'); + +/** The `info` value with `private` forced to 1; null when there is no info dict. */ +function privateInfo(bytes: Buffer, top: { entries: DictEntry[] }): Buffer | null { + const info = top.entries.find((e) => e.key.toString('latin1') === 'info'); + if (!info) return null; + return rewriteDict(bytes, info.valueStart, new Map([['private', PRIVATE]])); +} + +/** + * The upload's normalisation: `info.private` forced to 1 and the uploader's + * fields dropped. The result is what the site stores, serves and hashes. + * + * Throws on anything it cannot walk; the caller answers 400. + */ +export function normalizeUploadedTorrent(upload: Buffer): Buffer { + const raw = canonical(upload, 0)?.out; + const top = raw && dictEntries(raw); + if (!raw || !top) throw new Error('not a bencoded dictionary'); + const info = privateInfo(raw, top); + if (!info) throw new Error('missing info dict'); + const edits = new Map(UPLOADER_FIELDS.map((k) => [k, null])); + edits.set('info', info); + const out = rewriteDict(raw, 0, edits); + if (!out) throw new Error('not a bencoded dictionary'); + return out; +} + +/** + * A member's copy: their announce URL and tier list, `private` forced to 1, + * and none of the uploader's fields, so a row stored before the upload dropped + * them still serves without them. `info` and `piece layers` leave as stored. + * + * Throws on a stored file it cannot walk. + */ +export function personalizeTorrent(raw: Buffer, announce: string, tiers: string[][]): Buffer { + const top = dictEntries(raw); + if (!top) throw new Error('not a bencoded dictionary'); + const info = privateInfo(raw, top); + if (!info) throw new Error('missing info dict'); + const edits = new Map(UPLOADER_FIELDS.map((k) => [k, null])); + edits.set('announce', str(announce)); + edits.set('announce-list', tierList(tiers)); + edits.set('info', info); + const out = rewriteDict(raw, 0, edits); + if (!out) throw new Error('not a bencoded dictionary'); + return out; +} + +/** + * The piece layers of a v2 or hybrid torrent with their keys put back to raw + * 32-byte roots, after the upload re-spelled them in hex (see the module + * comment). Null when there is nothing to repair, or anything to doubt. + * + * A key is only converted back when its hex spelling names the `pieces root` of + * a file in this torrent's own `file tree`: a real 32-byte root is never 64 + * bytes long, and a 64-character key that is not one of our roots is left as + * found. Hex preserves byte order, so the repaired dict comes out sorted; it is + * sorted again anyway, and a repaired key that collides with a key already + * there aborts the repair. + */ +function repairedPieceLayers(raw: Buffer, top: { entries: DictEntry[] }): Buffer | null { + const layers = top.entries.find((e) => e.key.toString('latin1') === 'piece layers'); + if (!layers) return null; + const dict = dictEntries(raw, layers.valueStart); + if (!dict) return null; + const roots = new Set(extractV2(raw)?.fileRoots.map((f) => f.root) ?? []); + if (!roots.size) return null; + + let changed = false; + const entries = dict.entries.map((e) => { + const k = e.key.toString('latin1'); + if (k.length === 64 && /^[0-9a-f]+$/.test(k) && roots.has(k)) { + changed = true; + return { key: Buffer.from(k, 'hex'), value: raw.subarray(e.valueStart, e.end) }; + } + return { key: e.key, value: raw.subarray(e.valueStart, e.end) }; + }); + if (!changed) return null; + + entries.sort((a, b) => Buffer.compare(a.key, b.key)); + for (let i = 1; i < entries.length; i++) { + if (Buffer.compare(entries[i - 1]!.key, entries[i]!.key) === 0) return null; + } + return Buffer.concat([ + Buffer.from('d'), + ...entries.flatMap((e) => [str(e.key), e.value]), + Buffer.from('e'), + ]); +} + +/** + * What the backfill applies to a stored row: the uploader's fields dropped and + * the piece layers re-keyed. `info` is copied verbatim — not even `private` is + * touched — so the infohash cannot move. Null when the row needs nothing, or + * cannot be walked. + */ +export function cleanStoredTorrent(raw: Buffer): Buffer | null { + const top = dictEntries(raw); + if (!top) return null; + const names = new Set(top.entries.map((e) => e.key.toString('latin1'))); + const edits = new Map( + UPLOADER_FIELDS.filter((k) => names.has(k)).map((k) => [k, null]), + ); + const layers = repairedPieceLayers(raw, top); + if (layers) edits.set('piece layers', layers); + if (!edits.size) return null; + return rewriteDict(raw, 0, edits); +} diff --git a/apps/api/utils/torrentFileCleanup.ts b/apps/api/utils/torrentFileCleanup.ts new file mode 100644 index 00000000..4c5e6abd --- /dev/null +++ b/apps/api/utils/torrentFileCleanup.ts @@ -0,0 +1,84 @@ +/** + * Cleans the `.torrent` files stored before the upload learnt to. + * + * Two things the upload now does were never applied to the rows already there: + * + * - **The uploader's own fields.** Until 2026-09-02 the upload kept `announce` + * and `announce-list`, so a file made with the uploader's personal announce + * URL — the normal case — kept their passkey at rest in `torrent_data`. The + * route listing a member's uploads then served those bytes to anyone who + * opened the profile. The route no longer does, but the passkeys are still in + * the column, one SELECT from the next leak. + * - **Re-keyed piece layers.** The upload decoded and re-encoded the file, and + * `bencode` re-spells in hex any key that is not valid UTF-8 — every key of a + * BEP 52 `piece layers` dict. `utils/torrentBytes` explains the fault and how + * the hex is turned back into the 32-byte roots. + * + * `cleanStoredTorrent` never touches `info`, and this checks it: the info dict + * of what is written must be byte-identical to the one read, so neither + * infohash can move under a swarm. A row that fails the check is logged and + * left alone. + * + * Same shape as `backfill-content-roots`: a cursor over `torrents.id` walks the + * catalogue once, in capped batches, under a cross-replica lock, then idles at + * the end. New uploads are already clean. + */ +import { db, schema } from '@trackarr/db'; +import { and, asc, eq, gt, isNotNull } from 'drizzle-orm'; +import { cleanStoredTorrent } from '~~/utils/torrentBytes'; +import { infoDictRange } from '~~/utils/bittorrentV2'; +import { getSetting, setSetting } from '~~/utils/server'; +import { panicActive } from '~~/utils/panicLock'; + +const BATCH_SIZE = 200; +// Bump (and say why above) whenever `cleanStoredTorrent` learns something new, +// so the sweep walks the catalogue once more. +export const CLEANUP_CURSOR_KEY = 'torrent_file_cleanup_cursor_v1'; + +function sameInfo(before: Buffer, after: Buffer): boolean { + const a = infoDictRange(before); + const b = infoDictRange(after); + return !!a && !!b && before.subarray(a.start, a.end).equals(after.subarray(b.start, b.end)); +} + +/** One batch of the sweep. Exported for the integration suite. */ +export async function cleanupTorrentFilesTick(): Promise<{ processed: number; cleaned: number }> { + // Panic encrypts `torrent_data`, and an encrypted row cannot be walked: a + // sweep during the panic would move its cursor past every one of them, and + // nothing would come back for them after the restore. + if (await panicActive()) return { processed: 0, cleaned: 0 }; + const cursor = (await getSetting(CLEANUP_CURSOR_KEY)) ?? ''; + + const rows = await db + .select({ id: schema.torrents.id, torrentData: schema.torrents.torrentData }) + .from(schema.torrents) + .where(and(gt(schema.torrents.id, cursor), isNotNull(schema.torrents.torrentData))) + .orderBy(asc(schema.torrents.id)) + .limit(BATCH_SIZE); + + if (!rows.length) return { processed: 0, cleaned: 0 }; + + let cleaned = 0; + for (const row of rows) { + if (!row.torrentData || row.torrentData.length === 0) continue; + const before = Buffer.from(row.torrentData); + // Deterministic: a file that cannot be walked now never will be, so the + // cursor moves past it. The UPDATE is the only part that can fail + // transiently, and it is outside any catch: a failure aborts the tick + // WITHOUT advancing the cursor, and the batch is retried next tick. + const after = cleanStoredTorrent(before); + if (!after) continue; + if (!sameInfo(before, after)) { + console.warn('[TorrentFiles] info dict would change, row left alone:', row.id); + continue; + } + await db + .update(schema.torrents) + .set({ torrentData: after }) + .where(eq(schema.torrents.id, row.id)); + cleaned += 1; + } + + await setSetting(CLEANUP_CURSOR_KEY, rows[rows.length - 1]!.id); + return { processed: rows.length, cleaned }; +} diff --git a/apps/api/utils/trackerHealth.ts b/apps/api/utils/trackerHealth.ts index 6d517e09..3faaf506 100644 --- a/apps/api/utils/trackerHealth.ts +++ b/apps/api/utils/trackerHealth.ts @@ -19,7 +19,11 @@ * pouvaient déjà se contredire pendant leurs dix secondes respectives. */ -/** Ce que le tracker répond sur `/health` : 200 seulement si Postgres ET Redis répondent. */ +/** + * Ce que le tracker répond sur `/health` : 200 tant que Postgres répond — sans + * Redis il répond encore aux annonces, en mode dégradé, et le corps le dit + * (`"status":"degraded"`) ; 503 seulement quand Postgres manque. + */ export interface TrackerHealth { online: boolean; /** Millisecondes unix de la dernière sonde ; l'interface affiche « vérifié il y a Xs ». */ diff --git a/apps/api/utils/uploadRules.ts b/apps/api/utils/uploadRules.ts index 467d558e..37fb5564 100644 --- a/apps/api/utils/uploadRules.ts +++ b/apps/api/utils/uploadRules.ts @@ -310,6 +310,8 @@ export function evaluateUpload( // have been rejected on size/NFO/etc. if (snapshot.titleBlocklist) { try { + // Staff-written on purpose; a slow pattern only costs the admin who wrote it (fail open, above). + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp const re = new RegExp(snapshot.titleBlocklist, 'i'); if (re.test(input.title)) { return { @@ -337,6 +339,8 @@ export function evaluateUpload( const pattern = snapshot.effectiveCategoryPatterns[input.categoryId]; if (pattern) { try { + // Staff-written on purpose; a slow pattern only costs the admin who wrote it (fail open, above). + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp const re = new RegExp(pattern, 'i'); if (!re.test(input.title)) { return { @@ -376,6 +380,8 @@ export function anchorPattern(raw: string): string { export function validateAndAnchor(raw: string): string | null { const candidate = anchorPattern(raw); try { + // Staff-written on purpose: this is the validation that compiles it. + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp new RegExp(candidate, 'i'); return candidate; } catch { diff --git a/apps/relay/Dockerfile b/apps/relay/Dockerfile index 60e0b92f..7f038966 100644 --- a/apps/relay/Dockerfile +++ b/apps/relay/Dockerfile @@ -8,7 +8,7 @@ # No /etc/passwd: USER takes a numeric uid:gid, which the kernel applies # without a name lookup. -FROM golang:1.26-alpine AS builder +FROM golang:1.27-alpine AS builder WORKDIR /src COPY apps/relay/go.mod apps/relay/go.sum ./ diff --git a/apps/relay/cmd/relay/main.go b/apps/relay/cmd/relay/main.go index f4b32575..366e3ced 100644 --- a/apps/relay/cmd/relay/main.go +++ b/apps/relay/cmd/relay/main.go @@ -56,7 +56,7 @@ func runHealthcheck() int { // boucle locale, donc seul le port compte. port := addr[strings.LastIndex(addr, ":")+1:] client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Get("http://127.0.0.1:" + port + "/healthz") + resp, err := client.Get("http://127.0.0.1:" + port + "/healthz") // #nosec G704 -- boucle locale ; seul le port vient de l'environnement if err != nil { return 1 } @@ -141,6 +141,8 @@ func main() { mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") cfg := live.Get() + // Texte Prometheus fait de compteurs entiers : rien de la requête. + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter _, _ = w.Write([]byte( "# HELP relay_connections Open SSE connections on this node.\n" + "# TYPE relay_connections gauge\n" + @@ -168,7 +170,7 @@ func main() { srv := &http.Server{ Addr: static.Addr, - Handler: mux, + Handler: noSniff(mux), ReadHeaderTimeout: 10 * time.Second, // No WriteTimeout: these responses are meant to stay open. The // heartbeat is what detects a reader that went away. @@ -196,6 +198,16 @@ func main() { _ = srv.Shutdown(shutdown) } +// noSniff : aucune réponse du relais n'est à deviner. Le flux SSE est +// `text/event-stream`, `/healthz` et `/metrics` du texte brut ; le Caddyfile +// livré pose l'en-tête, un ingress devant le chart ne le pose pas. +func noSniff(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + func itoa(v int64) string { if v == 0 { return "0" diff --git a/apps/relay/cmd/relay/nosniff_test.go b/apps/relay/cmd/relay/nosniff_test.go new file mode 100644 index 00000000..1951985c --- /dev/null +++ b/apps/relay/cmd/relay/nosniff_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// Le relais pose nosniff lui-même, sans compter sur ce qui le précède. +func TestNoSniff_SetOnEveryResponse(t *testing.T) { + h := noSniff(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/messaging/events", nil)) + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } + if got := rec.Header().Get("Content-Type"); got != "text/event-stream" { + t.Fatalf("le Content-Type du handler a été perdu : %q", got) + } +} diff --git a/apps/relay/go.mod b/apps/relay/go.mod index f932ff78..e31107f7 100644 --- a/apps/relay/go.mod +++ b/apps/relay/go.mod @@ -1,11 +1,11 @@ module github.com/florianjs/trackarr/apps/relay -go 1.26.0 +go 1.27.0 -require github.com/redis/go-redis/v9 v9.22.0 +require github.com/redis/go-redis/v9 v9.23.0 require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sys v0.44.0 // indirect + go.uber.org/atomic v1.12.0 // indirect + golang.org/x/sys v0.49.0 // indirect ) diff --git a/apps/relay/go.sum b/apps/relay/go.sum index 4f35daa9..edc2c001 100644 --- a/apps/relay/go.sum +++ b/apps/relay/go.sum @@ -4,19 +4,17 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s= +github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/apps/relay/internal/sse/handler.go b/apps/relay/internal/sse/handler.go index 7ff95920..1f584f6a 100644 --- a/apps/relay/internal/sse/handler.go +++ b/apps/relay/internal/sse/handler.go @@ -139,6 +139,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err != nil { continue } + // Un tableau JSON dans un flux text/event-stream (nosniff posé par + // le serveur) : du JSON, pas du balisage. + // nosemgrep: go.lang.security.audit.xss.no-fprintf-to-responsewriter.no-fprintf-to-responsewriter if _, err := fmt.Fprintf(w, "data: %s\n\n", encoded); err != nil { return } diff --git a/apps/tracker/Dockerfile b/apps/tracker/Dockerfile index cf294115..aa302b45 100644 --- a/apps/tracker/Dockerfile +++ b/apps/tracker/Dockerfile @@ -11,7 +11,7 @@ # We also skip /etc/passwd: USER takes a numeric uid:gid, so the kernel # applies it without ever needing to look up a username. -FROM golang:1.26-alpine AS builder +FROM golang:1.27-alpine AS builder WORKDIR /src # Cache deps independently of source changes. diff --git a/apps/tracker/cmd/tracker/main.go b/apps/tracker/cmd/tracker/main.go index 06b249d8..59ce2922 100644 --- a/apps/tracker/cmd/tracker/main.go +++ b/apps/tracker/cmd/tracker/main.go @@ -187,7 +187,7 @@ func runHealthcheck() int { url := "http://127.0.0.1:" + port + "/health" client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Get(url) + resp, err := client.Get(url) // #nosec G704 -- always 127.0.0.1; only the port comes from the environment if err != nil { return 1 } diff --git a/apps/tracker/go.mod b/apps/tracker/go.mod index 823b2f7b..0fd10fe3 100644 --- a/apps/tracker/go.mod +++ b/apps/tracker/go.mod @@ -1,21 +1,21 @@ module github.com/florianjs/trackarr/apps/tracker -go 1.26.0 +go 1.27.0 require ( - github.com/alicebob/miniredis/v2 v2.38.0 - github.com/jackc/pgx/v5 v5.10.0 - github.com/redis/go-redis/v9 v9.22.0 + github.com/alicebob/miniredis/v2 v2.40.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/redis/go-redis/v9 v9.23.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/yuin/gopher-lua v1.1.1 // indirect - go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + github.com/jackc/puddle/v2 v2.2.3 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect + go.uber.org/atomic v1.12.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect ) diff --git a/apps/tracker/go.sum b/apps/tracker/go.sum index 3e94c635..be24b4e2 100644 --- a/apps/tracker/go.sum +++ b/apps/tracker/go.sum @@ -1,5 +1,5 @@ -github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= -github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc= +github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -7,40 +7,37 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= +github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s= +github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/tracker/internal/anticheat/detect.go b/apps/tracker/internal/anticheat/detect.go index f837b76d..eac078a6 100644 --- a/apps/tracker/internal/anticheat/detect.go +++ b/apps/tracker/internal/anticheat/detect.go @@ -16,6 +16,8 @@ import ( "encoding/hex" "encoding/json" "log/slog" + "os" + "strconv" "strings" "time" @@ -34,12 +36,22 @@ type Config struct { MaxUploadBytesPerSecond int64 } -// DefaultConfig returns the operator-friendly defaults. The handler -// can override individual fields without copy-pasting the rest. +// DefaultConfig returns the operator-friendly defaults, with +// `ANTICHEAT_MAX_BYTES_PER_SECOND` (bytes/s) overriding the velocity +// threshold. The guide documented that variable long before anything read it; +// it is read once, at start-up. func DefaultConfig() Config { - return Config{MaxUploadBytesPerSecond: 80 * 1024 * 1024} + return defaultConfig } +var defaultConfig = func() Config { + cfg := Config{MaxUploadBytesPerSecond: 80 * 1024 * 1024} + if v, err := strconv.ParseInt(os.Getenv("ANTICHEAT_MAX_BYTES_PER_SECOND"), 10, 64); err == nil && v > 0 { + cfg.MaxUploadBytesPerSecond = v + } + return cfg +}() + // Flag is one detection event. The handler builds a slice per // announce; `Store` persists the whole slice in a single batch. type Flag struct { diff --git a/apps/tracker/internal/peers/breaker.go b/apps/tracker/internal/peers/breaker.go new file mode 100644 index 00000000..acc58c80 --- /dev/null +++ b/apps/tracker/internal/peers/breaker.go @@ -0,0 +1,186 @@ +package peers + +import ( + "context" + "errors" + "net" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9" +) + +// ErrRedisUnavailable is what every command returns, without reaching Redis, +// while the breaker is open. +var ErrRedisUnavailable = errors.New("redis unavailable: circuit open") + +const ( + // breakerCooldown is how long the breaker stays open before one command is + // let through to probe. + breakerCooldown = time.Second + // breakerThreshold is how many timeouts in a row, with no reply from the + // server in between, open it. + breakerThreshold = 3 +) + +// breaker fails Redis commands at once while Redis cannot be reached. +// +// Finding out that Redis is down is what made an outage slow, more than the +// outage itself. Against a host that does not resolve or refuses connections, +// go-redis dials five times a hundred milliseconds apart and retries the +// command three times on top: two seconds per command. Against one that +// accepts connections and never answers, each command waits out its read +// timeout. An announce issues half a dozen commands, so the degraded mode +// (answer without a swarm rather than refuse) took one to five seconds per +// announce, and one that spent its whole deadline on Redis failed anyway on the +// Postgres query that came after. +// +// What opens it: a connection refused or a host that does not resolve, at +// once — go-redis has already made some twenty attempts before it reports one — +// or `breakerThreshold` timeouts in a row. Any reply from the server resets the count, whatever it says: +// redis.Nil, WRONGTYPE and a wrong password all prove Redis is up. So a latency +// spike, where timeouts come between successes, does not turn into a second of +// degraded announces, while a Redis that accepts connections and never answers +// still opens it. A caller that gave up (context.Canceled), or whose deadline +// had passed before the command even started, says nothing about Redis and is +// not counted. +// +// Open, a command fails without touching the connection pool. After +// `breakerCooldown`, one command at a time goes through as a probe; a reply +// closes the breaker, a failure keeps it open for another cooldown. +type breaker struct { + cooldown time.Duration + threshold int32 + timeouts atomic.Int32 + openUntil atomic.Int64 // unix nanoseconds; 0 when closed + probing atomic.Bool +} + +func newBreaker() *breaker { + return &breaker{cooldown: breakerCooldown, threshold: breakerThreshold} +} + +// admit reports whether a command may go to Redis, and whether it is the probe. +func (b *breaker) admit(now time.Time) (ok, probe bool) { + until := b.openUntil.Load() + if until == 0 { + return true, false + } + if now.UnixNano() < until { + return false, false + } + if b.probing.CompareAndSwap(false, true) { + return true, true + } + return false, false +} + +func (b *breaker) record(err error, probe bool) { + switch classify(err) { + case replied: + b.timeouts.Store(0) + if b.openUntil.Load() != 0 { + b.openUntil.Store(0) + } + case unreachable: + b.open() + case timedOut: + if b.timeouts.Add(1) >= b.threshold || probe { + b.open() + } + } +} + +func (b *breaker) open() { + b.openUntil.Store(time.Now().Add(b.cooldown).UnixNano()) +} + +// admittedKey marks the context of a command the breaker let through. +type admittedKey struct{} + +// do runs one command, or one pipeline, through the breaker. +// +// A command go-redis issues while serving one already admitted belongs to it: +// the handshake of a connection it opens — HELLO, AUTH, CLIENT SETINFO — runs +// through the same hooks, with the same context. Refusing it kept the probe, +// the one command allowed through an open breaker, from ever connecting: the +// breaker never closed again. Counting it would count one failure twice. +func (b *breaker) do(ctx context.Context, run func(context.Context) error, refuse func()) error { + if ctx.Value(admittedKey{}) != nil { + return run(ctx) + } + ok, probe := b.admit(time.Now()) + if !ok { + refuse() + return ErrRedisUnavailable + } + if probe { + defer b.probing.Store(false) + } + inner := context.WithValue(ctx, admittedKey{}, struct{}{}) + if ctx.Err() != nil { + return run(inner) + } + err := run(inner) + b.record(err, probe) + return err +} + +type outcome int + +const ( + inconclusive outcome = iota + replied + unreachable + timedOut +) + +func classify(err error) outcome { + if err == nil { + return replied + } + var rerr redis.Error // a reply from the server, redis.Nil included + if errors.As(err, &rerr) { + return replied + } + if errors.Is(err, context.Canceled) || errors.Is(err, ErrRedisUnavailable) { + return inconclusive + } + // A deadline that fell while the command was under way, whoever set it. A + // connection attempt that timed out lands here too: it may only mean the + // caller had little time left. go-redis also reports the caller's deadline + // as such when it falls in a pause between two attempts, even when the + // attempt before it was refused. + var op *net.OpError + isOp := errors.As(err, &op) + if errors.Is(err, context.DeadlineExceeded) || (isOp && op.Timeout()) { + return timedOut + } + // Refused, unresolvable, unroutable: Redis is not there. + if isOp && op.Op == "dial" { + return unreachable + } + return inconclusive +} + +func (b *breaker) DialHook(next redis.DialHook) redis.DialHook { return next } + +func (b *breaker) ProcessHook(next redis.ProcessHook) redis.ProcessHook { + return func(ctx context.Context, cmd redis.Cmder) error { + return b.do(ctx, + func(ctx context.Context) error { return next(ctx, cmd) }, + func() { cmd.SetErr(ErrRedisUnavailable) }) + } +} + +func (b *breaker) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { + return func(ctx context.Context, cmds []redis.Cmder) error { + return b.do(ctx, + func(ctx context.Context) error { return next(ctx, cmds) }, + func() { + for _, cmd := range cmds { + cmd.SetErr(ErrRedisUnavailable) + } + }) + } +} diff --git a/apps/tracker/internal/peers/breaker_test.go b/apps/tracker/internal/peers/breaker_test.go new file mode 100644 index 00000000..421906c6 --- /dev/null +++ b/apps/tracker/internal/peers/breaker_test.go @@ -0,0 +1,205 @@ +package peers + +import ( + "context" + "errors" + "net" + "os" + "syscall" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +var ( + errRefused = &net.OpError{Op: "dial", Net: "tcp", + Err: os.NewSyscallError("connect", syscall.ECONNREFUSED)} + errReadTimeout = &net.OpError{Op: "read", Net: "tcp", Err: os.ErrDeadlineExceeded} +) + +func TestClassify(t *testing.T) { + mr := miniredis.RunT(t) + c := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer c.Close() + ctx := context.Background() + if err := c.HSet(ctx, "h", "f", "v").Err(); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + err error + want outcome + }{ + {"success", nil, replied}, + {"redis.Nil", c.Get(ctx, "missing").Err(), replied}, + {"WRONGTYPE", c.Get(ctx, "h").Err(), replied}, + {"connection refused", errRefused, unreachable}, + {"no such host", &net.OpError{Op: "dial", Net: "tcp", + Err: &net.DNSError{Err: "no such host", Name: "redis", IsNotFound: true}}, unreachable}, + {"dial timeout", &net.OpError{Op: "dial", Net: "tcp", Err: os.ErrDeadlineExceeded}, timedOut}, + {"read timeout", errReadTimeout, timedOut}, + {"caller deadline", context.DeadlineExceeded, timedOut}, + {"caller gave up", context.Canceled, inconclusive}, + {"connection reset", &net.OpError{Op: "read", Net: "tcp", + Err: os.NewSyscallError("read", syscall.ECONNRESET)}, inconclusive}, + {"breaker open", ErrRedisUnavailable, inconclusive}, + } { + if got := classify(tc.err); got != tc.want { + t.Errorf("%s (%v): got %d, want %d", tc.name, tc.err, got, tc.want) + } + } +} + +// call runs one fake command through the breaker and reports whether it +// reached "Redis". +func call(b *breaker, ctx context.Context, result error) (reached bool, err error) { + err = b.do(ctx, func(context.Context) error { reached = true; return result }, func() {}) + return reached, err +} + +func TestBreaker_TimeoutsOpenItOnlyInARow(t *testing.T) { + b := &breaker{cooldown: time.Hour, threshold: 3} + ctx := context.Background() + call(b, ctx, errReadTimeout) + call(b, ctx, errReadTimeout) + call(b, ctx, nil) // a reply in between: a latency spike, not an outage + call(b, ctx, errReadTimeout) + call(b, ctx, errReadTimeout) + if reached, _ := call(b, ctx, errReadTimeout); !reached { + t.Fatal("two timeouts after a reply opened the breaker") + } + if reached, err := call(b, ctx, nil); reached || !errors.Is(err, ErrRedisUnavailable) { + t.Fatalf("three timeouts in a row: reached=%v err=%v, want refused", reached, err) + } +} + +func TestBreaker_RefusedOpensItAtOnceAndAProbeClosesIt(t *testing.T) { + const cooldown = 30 * time.Millisecond + b := &breaker{cooldown: cooldown, threshold: 3} + ctx := context.Background() + + call(b, ctx, errRefused) + if reached, err := call(b, ctx, nil); reached || !errors.Is(err, ErrRedisUnavailable) { + t.Fatalf("after a refused connection: reached=%v err=%v, want refused", reached, err) + } + + // After the cooldown, one probe at a time. + time.Sleep(cooldown + 10*time.Millisecond) + release := make(chan error) + probeDone := make(chan error) + go func() { + probeDone <- b.do(ctx, func(context.Context) error { return <-release }, func() {}) + }() + deadline := time.Now().Add(time.Second) + for !b.probing.Load() { + if time.Now().After(deadline) { + t.Fatal("the probe never started") + } + time.Sleep(time.Millisecond) + } + if reached, _ := call(b, ctx, nil); reached { + t.Fatal("a second command went through while the probe was in flight") + } + release <- errRefused + <-probeDone + if reached, _ := call(b, ctx, nil); reached { + t.Fatal("a failed probe did not keep the breaker open") + } + + time.Sleep(cooldown + 10*time.Millisecond) + if reached, err := call(b, ctx, redis.Nil); !reached || !errors.Is(err, redis.Nil) { + t.Fatalf("probe: reached=%v err=%v", reached, err) + } + for i := 0; i < 3; i++ { + if reached, _ := call(b, ctx, nil); !reached { + t.Fatalf("command %d refused after a probe got a reply", i) + } + } +} + +// go-redis opens the probe's connection with a handshake that runs through the +// same hooks: refused, it kept every probe from connecting, and the breaker, +// once open, never closed again. +func TestBreaker_LetsThroughWhatAnAdmittedCommandIssues(t *testing.T) { + const cooldown = 10 * time.Millisecond + b := &breaker{cooldown: cooldown, threshold: 3} + ctx := context.Background() + call(b, ctx, errRefused) + time.Sleep(cooldown + 5*time.Millisecond) + + nested := false + err := b.do(ctx, func(inner context.Context) error { + // the probe, opening its connection + return b.do(inner, func(context.Context) error { nested = true; return nil }, func() {}) + }, func() {}) + if err != nil || !nested { + t.Fatalf("the probe's handshake: nested=%v err=%v", nested, err) + } + if reached, _ := call(b, ctx, nil); !reached { + t.Fatal("the breaker did not close after the probe connected") + } +} + +func TestBreaker_IgnoresTheCallersOwnContext(t *testing.T) { + b := &breaker{cooldown: time.Hour, threshold: 3} + expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + for i := 0; i < 10; i++ { + // A deadline already gone before the command started: Postgres ate the + // announce's budget, Redis did nothing wrong. + call(b, expired, context.DeadlineExceeded) + call(b, context.Background(), context.Canceled) + } + if reached, _ := call(b, context.Background(), nil); !reached { + t.Fatal("the callers' own contexts opened the breaker") + } +} + +// The client the tracker builds: once Redis is gone, the first command finds +// out the slow way and every one after it fails at once, pipelines included; +// once it is back, the first probe after the cooldown closes the breaker. +func TestNewClientFromURL_FailsFastWhileRedisIsGone(t *testing.T) { + mr := miniredis.RunT(t) + c, err := NewClientFromURL("redis://"+mr.Addr(), "") + if err != nil { + t.Fatal(err) + } + defer c.Close() + ctx := context.Background() + if err := c.Set(ctx, "k", "v", 0).Err(); err != nil { + t.Fatal(err) + } + + mr.Close() + if err := c.Get(ctx, "k").Err(); err == nil { + t.Fatal("GET succeeded against a closed server") + } + start := time.Now() + for i := 0; i < 50; i++ { + if err := c.Get(ctx, "k").Err(); !errors.Is(err, ErrRedisUnavailable) { + t.Fatalf("GET %d: %v, want ErrRedisUnavailable", i, err) + } + } + _, err = c.Pipelined(ctx, func(p redis.Pipeliner) error { + p.Get(ctx, "k") + p.Incr(ctx, "n") + return nil + }) + if !errors.Is(err, ErrRedisUnavailable) { + t.Fatalf("pipeline: %v, want ErrRedisUnavailable", err) + } + if d := time.Since(start); d > 200*time.Millisecond { + t.Fatalf("fifty-one refused commands took %v", d) + } + + if err := mr.Restart(); err != nil { + t.Fatal(err) + } + time.Sleep(breakerCooldown + 100*time.Millisecond) + if v, err := c.Get(ctx, "k").Result(); err != nil || v != "v" { + t.Fatalf("after the restart: %q, %v", v, err) + } +} diff --git a/apps/tracker/internal/peers/peers.go b/apps/tracker/internal/peers/peers.go index 4d937d90..20522476 100644 --- a/apps/tracker/internal/peers/peers.go +++ b/apps/tracker/internal/peers/peers.go @@ -68,6 +68,12 @@ type PeerData struct { Left int64 `json:"left"` IsSeeder bool `json:"isSeeder"` UpdatedAt int64 `json:"updatedAt"` // unix millis + + // Field is the hash field the entry lives under — its peer_id, or + // `peer_id:user_id` when another member already holds that peer_id (see + // `Slot`). Filled by `Slot` and `List`, read by `Set` and by whoever removes + // a listed entry; never stored. + Field string `json:"-"` } // countsCacheTTL bounds how stale `Counts` is allowed to go. Each @@ -232,11 +238,18 @@ const staleWriteWindowMs = 5000 // cjson is used to READ the two counters for comparison and nothing else — the // value written is the payload Go marshalled, byte for byte. Re-encoding in // Lua would round int64 byte counts through a double. +// +// It also refuses, with 2, to write over an entry another member owns — the +// atomic half of `Slot`, for two members claiming one free peer_id at once. var setPeerScript = redis.NewScript(` local cur = redis.call('HGET', KEYS[1], ARGV[1]) if cur then local ok, d = pcall(cjson.decode, cur) if ok and type(d) == 'table' then + if ARGV[8] ~= '' and type(d.userId) == 'string' and d.userId ~= '' + and d.userId ~= ARGV[8] then + return 2 + end local age = tonumber(ARGV[5]) - (tonumber(d.updatedAt) or 0) if age >= 0 and age <= tonumber(ARGV[6]) then if (tonumber(d.uploaded) or 0) > tonumber(ARGV[3]) @@ -258,6 +271,10 @@ return 1 // a TTL (which previously caused slow memory leaks on swarms that went idle // right after their first peer). The same script is what drops a stale // concurrent write. +// +// The entry goes under `p.Field`, or the bare peer_id when that is empty. If +// another member took that peer_id in the meantime, it goes under this +// member's own field instead, as `Slot` would have chosen. func (s *Store) Set(ctx context.Context, infoHashHex string, p *PeerData) error { now := time.Now().UnixMilli() p.UpdatedAt = now @@ -265,15 +282,32 @@ func (s *Store) Set(ctx context.Context, infoHashHex string, p *PeerData) error if err != nil { return err } + field := p.Field + if field == "" { + field = p.PeerID + } key := s.peerKey(infoHashHex) - written, err := setPeerScript.Run(ctx, s.client, - []string{key}, - p.PeerID, data, p.Uploaded, p.Downloaded, - now, staleWriteWindowMs, int(s.peerTTL.Seconds()), - ).Int() + run := func(field string) (int, error) { + return setPeerScript.Run(ctx, s.client, + []string{key}, + field, data, p.Uploaded, p.Downloaded, + now, staleWriteWindowMs, int(s.peerTTL.Seconds()), p.UserID, + ).Int() + } + written, err := run(field) + if err == nil && written == 2 && field == p.PeerID && p.UserID != "" { + field = memberField(p.PeerID, p.UserID) + written, err = run(field) + } if err != nil { return err } + p.Field = field + if written == 2 { + slog.Debug("peers: entry held by another member, not written", + "info_hash", infoHashHex, "field", field) + return nil + } if written == 0 { // Not an error: a concurrent announce already stored a fresher // snapshot for this peer, so ours is the one to drop. @@ -310,9 +344,63 @@ func (s *Store) Get(ctx context.Context, infoHashHex, peerIDHex string) (*PeerDa return p, nil } -// Remove deletes a single peer from the swarm. -func (s *Store) Remove(ctx context.Context, infoHashHex, peerIDHex string) error { - err := s.client.HDel(ctx, s.peerKey(infoHashHex), peerIDHex).Err() +// memberField is where a member's entry goes when another member holds its +// peer_id. A peer_id is forty hex characters, so the colon cannot clash with a +// bare one. +func memberField(peerIDHex, userID string) string { return peerIDHex + ":" + userID } + +// Slot finds this member's entry for this peer_id, and the field it lives (or +// will live) under. +// +// Entries are keyed by (info_hash, peer_id), and a peer_id is no secret: every +// client in the swarm reads it in the BitTorrent handshake. Overwriting +// another member's entry is how a stranger could delete their baseline (a +// `stopped`) or replace it with their own counters and address. Refusing the +// newcomer outright was no better: a stranger who announced a peer_id first — +// after its owner paused the torrent, say — kept the owner out of the swarm +// and out of credit for as long as they kept announcing, and two clients that +// genuinely share a peer_id could never both be served. +// +// So a member whose peer_id is held by someone else gets a field of its own, +// `peer_id:user_id`, and keeps it while it lives — it does not move back to the +// bare field when that frees up, which would cost it its baseline. Both fields +// come back in one HMGET. An entry with no owner predates `userId` and belongs +// to whoever announces it. +func (s *Store) Slot(ctx context.Context, infoHashHex, peerIDHex, userID string) (*PeerData, string, error) { + own := memberField(peerIDHex, userID) + vals, err := s.client.HMGet(ctx, s.peerKey(infoHashHex), own, peerIDHex).Result() + if err != nil { + return nil, peerIDHex, err + } + if p := s.fresh(ctx, infoHashHex, own, vals[0]); p != nil { + return p, own, nil + } + p := s.fresh(ctx, infoHashHex, peerIDHex, vals[1]) + if p != nil && p.UserID != "" && p.UserID != userID { + return nil, own, nil + } + return p, peerIDHex, nil +} + +// fresh decodes one HMGET value: nil when absent, and the entry is dropped +// when unreadable or older than the peer TTL, as `Get` does. +func (s *Store) fresh(ctx context.Context, infoHashHex, field string, v any) *PeerData { + raw, ok := v.(string) + if !ok { + return nil + } + p := &PeerData{} + if json.Unmarshal([]byte(raw), p) != nil || time.Since(time.UnixMilli(p.UpdatedAt)) > s.peerTTL { + _ = s.client.HDel(ctx, s.peerKey(infoHashHex), field).Err() + return nil + } + p.Field = field + return p +} + +// Remove deletes a single entry from the swarm, by its field. +func (s *Store) Remove(ctx context.Context, infoHashHex, field string) error { + err := s.client.HDel(ctx, s.peerKey(infoHashHex), field).Err() s.invalidateCounts(infoHashHex) return err } @@ -334,7 +422,7 @@ func (s *Store) List(ctx context.Context, infoHashHex string) ([]*PeerData, erro stale := make([]string, 0) activeCutoffMs := activeListWindow.Milliseconds() for pid, raw := range all { - p := &PeerData{} + p := &PeerData{Field: pid} if err := json.Unmarshal([]byte(raw), p); err != nil { stale = append(stale, pid) continue @@ -584,6 +672,56 @@ func (s *Store) creditBudgetKey(u string) string { return s.prefix + "credit_budget:" + u } +/* + * seedTimeScript — jusqu'où le temps de seed d'un compte est déjà crédité, par + * torrent. + * + * Le temps de seed se crédite en temps réel : un seedeur qui réannonce toutes + * les `interval` doit voir son compteur avancer d'autant. Le correctif de + * 3e6d848 — cent peer_id versaient chacun le même intervalle dans la même ligne + * `hnr_tracking` — plafonnait `elapsed` à `minAnnounceInterval` (900 s) sous + * une fenêtre de déduplication de même durée. Mais les clients réannoncent + * toutes les 1 800 s : chaque annonce n'en créditait que 900, la moitié du temps + * réel, et toute exigence H&R s'en trouvait doublée. + * + * On garde à la place l'instant jusqu'où le compte a été crédité sur ce + * torrent. Une annonce crédite de `max(marque, ligne de base du peer)` à + * maintenant, plafonné, puis avance la marque à maintenant. Les intervalles + * crédités sont donc disjoints quel que soit le nombre de peer_id — leur somme + * ne dépasse jamais le temps réel — et un peer seul qui suit l'intervalle est + * crédité de tout son temps. Atomique, donc aussi juste entre réplicas. + * + * KEYS[1] la marque · ARGV[1] maintenant (ms) · ARGV[2] ligne de base du peer + * (ms) · ARGV[3] plafond (ms) · ARGV[4] durée de vie de la marque (s) + */ +var seedTimeScript = redis.NewScript(` +local last = tonumber(redis.call('GET', KEYS[1]) or '0') +local now = tonumber(ARGV[1]) +local from = math.max(last, tonumber(ARGV[2])) +if now <= from then return 0 end +local credit = math.min(now - from, tonumber(ARGV[3])) +redis.call('SET', KEYS[1], now, 'EX', tonumber(ARGV[4])) +return math.floor(credit / 1000) +`) + +// ClaimSeedTime réserve, en secondes, le temps de seed que cette annonce peut +// créditer au compte sur ce torrent : de la ligne de base du peer (`since`), ou +// de la marque si elle est plus récente, jusqu'à `now`, plafonné à `maxCredit`. +// Un intervalle déjà crédité par un autre peer_id du même compte ne l'est pas +// deux fois. +// +// La marque vit aussi longtemps qu'une entrée de peer : passé ce délai, la +// ligne de base du peer redevient le seul point de départ, et elle a expiré +// avec lui. +func (s *Store) ClaimSeedTime( + ctx context.Context, torrentID, userID string, now, since time.Time, maxCredit time.Duration, +) (int64, error) { + return seedTimeScript.Run(ctx, s.client, + []string{s.prefix + "seedtime:" + torrentID + ":" + userID}, + now.UnixMilli(), since.UnixMilli(), maxCredit.Milliseconds(), int64(s.peerTTL/time.Second), + ).Int64() +} + func (s *Store) peerKey(h string) string { return s.prefix + "peers:" + h } func (s *Store) statsKey(h string) string { return s.prefix + "stats:" + h } @@ -609,5 +747,8 @@ func NewClientFromURL(redisURL, password string) (*redis.Client, error) { if opts.WriteTimeout == 0 { opts.WriteTimeout = 3 * time.Second } - return redis.NewClient(opts), nil + // Fail fast while Redis is out of reach — see breaker.go. + client := redis.NewClient(opts) + client.AddHook(newBreaker()) + return client, nil } diff --git a/apps/tracker/internal/peers/peers_test.go b/apps/tracker/internal/peers/peers_test.go index 50417944..b8657062 100644 --- a/apps/tracker/internal/peers/peers_test.go +++ b/apps/tracker/internal/peers/peers_test.go @@ -769,3 +769,110 @@ func TestTakeCreditBudget_LetsAnHonestSeederThrough(t *testing.T) { } } } + +// ---------------------------------------------------------------------------- +// ClaimSeedTime — le temps de seed se crédite en temps réel, une seule fois +// ---------------------------------------------------------------------------- + +const seedCap = 3600 * time.Second + +// Un seedeur qui réannonce toutes les 1 800 s doit voir 1 800 s à chaque +// annonce — pas 900, ce que créditait le plafond à `minAnnounceInterval`. +func TestClaimSeedTime_CreditsAnHonestSeederInFull(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 24*time.Hour) + defer mr.Close() + ctx := context.Background() + + t0 := time.Unix(1_700_000_000, 0) + for i := 1; i <= 4; i++ { + now := t0.Add(time.Duration(i) * 1800 * time.Second) + prev := now.Add(-1800 * time.Second) // la ligne de base du peer + got, err := s.ClaimSeedTime(ctx, "torrent", "user", now, prev, seedCap) + if err != nil { + t.Fatal(err) + } + if got != 1800 { + t.Fatalf("annonce %d : %d s crédités, 1800 attendus", i, got) + } + } +} + +// Cent peer_id du même compte sur le même torrent, à la même seconde : un seul +// intervalle de temps réel au total. +func TestClaimSeedTime_ManyPeerIDsNeverBeatRealTime(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 24*time.Hour) + defer mr.Close() + ctx := context.Background() + + now := time.Unix(1_700_000_000, 0) + var total int64 + for i := 0; i < 100; i++ { + got, err := s.ClaimSeedTime(ctx, "torrent", "user", now, now.Add(-1800*time.Second), seedCap) + if err != nil { + t.Fatal(err) + } + total += got + } + if total != 1800 { + t.Fatalf("%d s crédités pour 1800 s de temps réel", total) + } + + // Deux peer_id décalés : les intervalles crédités se suivent sans se + // recouvrir, et leur somme est le temps écoulé. + later := now.Add(900 * time.Second) + got, err := s.ClaimSeedTime(ctx, "torrent", "user", later, now.Add(-900*time.Second), seedCap) + if err != nil { + t.Fatal(err) + } + if got != 900 { + t.Fatalf("le second peer_id devait recevoir les 900 s écoulées depuis la marque, a reçu %d", got) + } +} + +// Une ligne de base vieille de vingt heures ne réclame pas vingt heures. +func TestClaimSeedTime_CapsAnOldBaseline(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 24*time.Hour) + defer mr.Close() + + now := time.Unix(1_700_000_000, 0) + got, err := s.ClaimSeedTime(context.Background(), "torrent", "user", now, now.Add(-20*time.Hour), seedCap) + if err != nil { + t.Fatal(err) + } + if got != 3600 { + t.Fatalf("%d s crédités, le plafond est 3600", got) + } +} + +// Rien n'est crédité à l'envers (horloges décalées entre réplicas), et la +// marque n'est pas reculée. +func TestClaimSeedTime_NothingForAnIntervalAlreadyCredited(t *testing.T) { + t.Parallel() + s, mr := newTestStore(t, 24*time.Hour) + defer mr.Close() + ctx := context.Background() + + now := time.Unix(1_700_000_000, 0) + if _, err := s.ClaimSeedTime(ctx, "torrent", "user", now, now.Add(-60*time.Second), seedCap); err != nil { + t.Fatal(err) + } + got, err := s.ClaimSeedTime(ctx, "torrent", "user", now.Add(-30*time.Second), now.Add(-90*time.Second), seedCap) + if err != nil { + t.Fatal(err) + } + if got != 0 { + t.Fatalf("%d s crédités pour un intervalle déjà payé", got) + } + + // Un autre torrent, ou un autre compte, a sa propre marque. + other, _ := s.ClaimSeedTime(ctx, "torrent-2", "user", now, now.Add(-60*time.Second), seedCap) + if other != 60 { + t.Fatalf("la marque d'un torrent a débordé sur un autre : %d", other) + } + if ttl := mr.TTL("ot:seedtime:torrent:user"); ttl != 24*time.Hour { + t.Fatalf("la marque doit vivre comme une entrée de peer, TTL %v", ttl) + } +} diff --git a/apps/tracker/internal/peers/slot_test.go b/apps/tracker/internal/peers/slot_test.go new file mode 100644 index 00000000..0c1af56a --- /dev/null +++ b/apps/tracker/internal/peers/slot_test.go @@ -0,0 +1,103 @@ +package peers + +import ( + "context" + "testing" + "time" +) + +func memberPeer(peerID, userID string, port uint16) *PeerData { + return &PeerData{PeerID: peerID, UserID: userID, IP: "203.0.113.9", Port: port} +} + +// A peer_id is public. A stranger announcing under one a member holds must get +// an entry of their own — never the member's baseline, never a refusal that +// would let them keep the member out. +func TestSlot_AnotherMembersPeerIDGetsItsOwnField(t *testing.T) { + s, _ := newTestStore(t, time.Hour) + ctx := context.Background() + if err := s.Set(ctx, "h", memberPeer("p1", "alice", 1)); err != nil { + t.Fatal(err) + } + + prev, field, err := s.Slot(ctx, "h", "p1", "mallory") + if err != nil || prev != nil || field != "p1:mallory" { + t.Fatalf("stranger: prev=%v field=%q err=%v, want nil, p1:mallory", prev, field, err) + } + stranger := memberPeer("p1", "mallory", 2) + stranger.Field = field + if err := s.Set(ctx, "h", stranger); err != nil { + t.Fatal(err) + } + + prev, field, _ = s.Slot(ctx, "h", "p1", "alice") + if prev == nil || prev.UserID != "alice" || prev.Port != 1 || field != "p1" { + t.Fatalf("owner after the stranger wrote: %+v field=%q", prev, field) + } + + // The stranger's `stopped` removes their own entry and nothing else. + if err := s.Remove(ctx, "h", "p1:mallory"); err != nil { + t.Fatal(err) + } + if got, _ := s.Get(ctx, "h", "p1"); got == nil || got.UserID != "alice" { + t.Fatalf("owner's entry after the stranger's stop: %+v", got) + } +} + +// Once on a field of its own, a member stays there while it lives: moving back +// to the bare peer_id when that frees up would cost it its baseline. +func TestSlot_StaysOnTheMembersFieldWhenTheBareOneFreesUp(t *testing.T) { + s, _ := newTestStore(t, time.Hour) + ctx := context.Background() + _ = s.Set(ctx, "h", memberPeer("p1", "alice", 1)) + own := memberPeer("p1", "bob", 2) + own.Field = "p1:bob" + _ = s.Set(ctx, "h", own) + _ = s.Remove(ctx, "h", "p1") // alice leaves + + prev, field, _ := s.Slot(ctx, "h", "p1", "bob") + if prev == nil || prev.Port != 2 || field != "p1:bob" { + t.Fatalf("bob after alice left: %+v field=%q, want his own entry", prev, field) + } +} + +// Two members claiming one free peer_id at the same moment both see it free; +// the script settles it, and the second write lands on its own field. +func TestSet_NeverOverwritesAnotherMembersEntry(t *testing.T) { + s, _ := newTestStore(t, time.Hour) + ctx := context.Background() + _ = s.Set(ctx, "h", memberPeer("p1", "alice", 1)) + + late := memberPeer("p1", "bob", 2) + late.Field = "p1" // what a racing Slot would have answered + if err := s.Set(ctx, "h", late); err != nil { + t.Fatal(err) + } + if late.Field != "p1:bob" { + t.Fatalf("written under %q, want p1:bob", late.Field) + } + if got, _ := s.Get(ctx, "h", "p1"); got == nil || got.UserID != "alice" || got.Port != 1 { + t.Fatalf("alice's entry: %+v", got) + } + list, _ := s.List(ctx, "h") + fields := map[string]string{} + for _, p := range list { + fields[p.Field] = p.UserID + } + if len(fields) != 2 || fields["p1"] != "alice" || fields["p1:bob"] != "bob" { + t.Fatalf("listed fields: %v", fields) + } +} + +// An entry written before `userId` existed has no owner, and is taken over by +// whoever announces it, as before. +func TestSlot_AdoptsAnEntryWithNoOwner(t *testing.T) { + s, mr := newTestStore(t, time.Hour) + ctx := context.Background() + injectPeer(t, mr, "h", PeerData{PeerID: "p1", Port: 1, UpdatedAt: time.Now().UnixMilli()}) + + prev, field, _ := s.Slot(ctx, "h", "p1", "alice") + if prev == nil || field != "p1" { + t.Fatalf("legacy entry: %+v field=%q", prev, field) + } +} diff --git a/apps/tracker/internal/server/dedup.go b/apps/tracker/internal/server/dedup.go index 3c2e1c2d..1e017794 100644 --- a/apps/tracker/internal/server/dedup.go +++ b/apps/tracker/internal/server/dedup.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "sync" + "sync/atomic" "time" "github.com/redis/go-redis/v9" @@ -54,9 +55,9 @@ const dedupRedisTimeout = 150 * time.Millisecond // bonus and hit-and-run. // // The same guard covers all three call sites — the per-event announce key, -// the `:credit` key that gates the byte delta (finding M5) and the -// `:seedtime` key that gates seed-time accrual (finding M7) — so all three -// become correct across instances at once. +// the `:credit` key that gates the byte delta (finding M5) and the anti-cheat +// flags — so all three are correct across instances at once. (Seed time, +// finding M7, used to be one of them; `peers.ClaimSeedTime` books it now.) // // Note what is NOT fixed here: the rate ceiling on the delta is derived from // `prev.UpdatedAt`, which lives in Redis and is therefore already shared, so @@ -71,6 +72,11 @@ type dedup struct { // single-instance deployment loses nothing by it. rdb *redis.Client prefix string + + // redisErrors counts the checks that fell back to the local map. Sampled + // in the log like the tracker's other outage counters: through a Redis + // outage every announce lands here, and one line each would bury the log. + redisErrors atomic.Uint64 } func newDedup(rdb *redis.Client, keyPrefix string) *dedup { @@ -98,9 +104,10 @@ func (d *dedup) CheckAndMark(ctx context.Context, key string) bool { // // The 2-second default is right for what it was written for: one announce // arriving on IPv4, IPv6 and localhost within milliseconds. It is wrong for -// anything that books a QUANTITY per period — seed time, most of all. There, -// the window has to be the period itself, or N concurrent peer_ids each claim -// the same stretch of wall-clock time and the total is N times the truth. +// what must be recorded at most once per period — the anti-cheat flags, keyed +// per member and per kind over the minimum announce interval. (Seed time, the +// first such quantity, moved to `peers.ClaimSeedTime`: a window can refuse a +// second claim, it cannot credit a stretch of time exactly once.) func (d *dedup) CheckAndMarkFor(ctx context.Context, key string, window time.Duration) bool { if !d.checkLocalFor(key, window) { return false @@ -131,49 +138,15 @@ func (d *dedup) checkRedisFor(ctx context.Context, key string, window time.Durat ok, err := d.rdb.SetNX(ctx, d.prefix+"dedup:"+key, 1, window).Result() if err != nil { - slog.Warn("dedup: redis unreachable, falling back to the local window", - "err", err) + if n := d.redisErrors.Add(1); n%1_000 == 1 { + slog.Warn("dedup: redis unreachable, falling back to the local window", + "count", n, "err", err) + } return true } return ok } -// Release lève un marqueur posé par `CheckAndMarkFor`. -// -// `CheckAndMark*` pose la marque AVANT que l'effet de bord qu'elle protège ait -// eu lieu — c'est ce qui la rend atomique entre instances, et c'est aussi ce qui -// laisse la marque en place quand cet effet échoue. Pour le crédit de temps de -// seed, la fenêtre est de 900 s : une écriture Postgres ratée n'était pas -// seulement perdue, elle interdisait toute reprise pendant un quart d'heure. -// -// Lever la marque rend la place à l'annonce suivante. Ce n'est PAS une -// annulation exacte — entre l'échec et la levée, une annonce concurrente a pu -// passer son tour — mais le coût d'un intervalle manqué n'a rien à voir avec -// celui d'un quart d'heure aveugle. -// -// Les deux couches sont levées. Ne lever que Redis laisserait la carte locale -// refuser jusqu'à la fin de la fenêtre sur l'instance qui a échoué, c'est-à-dire -// exactement celle vers laquelle le client va se réannoncer. -// -// Aucune erreur n'est remontée : si Redis ne répond pas, `checkRedisFor` échoue -// déjà OUVERT (il rend `true` sans poser de marque), donc il n'y a rien à lever -// dans ce cas de figure. -func (d *dedup) Release(ctx context.Context, key string) { - d.mu.Lock() - delete(d.seen, key) - d.mu.Unlock() - - if d.rdb == nil { - return - } - ctx, cancel := context.WithTimeout(ctx, dedupRedisTimeout) - defer cancel() - if err := d.rdb.Del(ctx, d.prefix+"dedup:"+key).Err(); err != nil { - slog.Warn("dedup: could not release a marker, the next credit will wait out its window", - "err", err) - } -} - // Stop signals the cleanup goroutine to exit. func (d *dedup) Stop() { close(d.stop) } diff --git a/apps/tracker/internal/server/dedup_release_test.go b/apps/tracker/internal/server/dedup_release_test.go deleted file mode 100644 index 27796f7f..00000000 --- a/apps/tracker/internal/server/dedup_release_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package server - -import ( - "context" - "testing" - "time" -) - -// Une marque rendue laisse repasser tout de suite. -// -// `CheckAndMarkFor` pose la marque AVANT l'effet de bord qu'elle protège, et -// rien ne la retirait quand cet effet n'avait pas lieu. Pour le crédit de temps -// de seed la fenêtre est de 900 s : une écriture Postgres ratée n'était pas -// seulement perdue, elle interdisait toute reprise pendant un quart d'heure — -// le hit-and-run cessait de mesurer, en silence. -func TestReleaseLetsTheNextAttemptThrough(t *testing.T) { - d := newDedup(nil, "ot:") - defer d.Stop() - - ctx := context.Background() - const key = "hash:user:seedtime" - const window = 900 * time.Second - - if !d.CheckAndMarkFor(ctx, key, window) { - t.Fatal("la première tentative doit passer") - } - if d.CheckAndMarkFor(ctx, key, window) { - t.Fatal("la deuxième doit être refusée : c'est le rôle de la marque") - } - - // L'écriture a échoué : on rend la place. - d.Release(ctx, key) - - if !d.CheckAndMarkFor(ctx, key, window) { - t.Fatal("après Release, la reprise doit passer — sinon la perte dure toute la fenêtre") - } - if d.CheckAndMarkFor(ctx, key, window) { - t.Fatal("Release ne doit pas désarmer la marque pour de bon") - } -} - -// Rendre une marque qui n'existe pas ne casse rien, et n'en crée pas une. -func TestReleaseOfAnUnknownKeyIsHarmless(t *testing.T) { - d := newDedup(nil, "ot:") - defer d.Stop() - - ctx := context.Background() - d.Release(ctx, "jamais-posee") - if !d.CheckAndMarkFor(ctx, "jamais-posee", time.Minute) { - t.Fatal("Release ne doit pas laisser d'état derrière lui") - } -} diff --git a/apps/tracker/internal/server/handler.go b/apps/tracker/internal/server/handler.go index 6c81f397..4661c1ac 100644 --- a/apps/tracker/internal/server/handler.go +++ b/apps/tracker/internal/server/handler.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "runtime" + "sort" "strings" "sync" "sync/atomic" @@ -86,6 +87,13 @@ type Server struct { // le journal comme les compteurs UDP : ce qui compte est la PENTE, pas la // valeur. seedTimeDropped atomic.Uint64 + // swarmUnavailable compte les annonces servies sans essaim parce que Redis + // ne répondait pas (voir `degradedOutcome`). Un compteur plutôt qu'une + // ligne par annonce : une panne de Redis ne doit pas se doubler d'une + // avalanche de journaux. + swarmUnavailable atomic.Uint64 + // lookups answers from memory the announces that cannot succeed. + lookups *lookupGuard } // New builds a Server. It does not start listening — callers wire it into @@ -110,6 +118,7 @@ func New(appCtx context.Context, db *dbpkg.DB, rclient *redis.Client, store *pee peers: store, bonus: bonus.New(rclient, redisKeyPrefix), dedup: newDedup(rclient, redisKeyPrefix), + lookups: newLookupGuard(), stats: stats.New(rclient, db.Q, redisKeyPrefix, statsFlushInterval, statsFlushChunk), ipHashSecret: ipHashSecret, debug: debug, @@ -125,7 +134,19 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("/announce", s.handleAnnounce) mux.HandleFunc("/scrape", s.handleScrape) mux.HandleFunc("/health", s.handleHealth) - return mux + return noSniff(mux) +} + +// noSniff stops a browser from guessing a type for these bodies. They are +// declared text/plain or JSON, and a bencoded failure reason or peer list read +// as HTML is what a sniffing browser could make of them. The shipped Caddyfile +// adds the header on the tracker host; an ingress in front of the chart does +// not, so the tracker sets it itself. +func noSniff(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) } // Stop tears the server down: stops the dedup eviction loop, then @@ -160,6 +181,15 @@ func (s *Server) Stop() { // give up on it. const bgDrainTimeout = 8 * time.Second +// maxSeedTimeCredit bounds what one announce may credit as seed time: two +// announce intervals, so a client that missed one announce is not +// short-changed, while a baseline left hours ago cannot claim the whole gap. +const maxSeedTimeCredit = 2 * announceInterval * time.Second + +// announceDeadline bounds one announce end to end, under the HTTP server's +// 10 s write timeout. +const announceDeadline = 5 * time.Second + // ---------------------------------------------------------------------------- // /announce // ---------------------------------------------------------------------------- @@ -229,7 +259,9 @@ func (s *Server) handleAnnounce(w http.ResponseWriter, r *http.Request) { return } - body := buildAnnounceResponse(out.Seeders, out.Leechers, out.Peers, req.Compact, req.PeerID, req.NumWant) + body := buildAnnounceResponse(out.Seeders, out.Leechers, out.Interval, out.MinInterval, out.Peers, req.Compact, req.PeerID, req.NumWant) + // Bencode, served as text/plain with nosniff (noSniff): not markup. + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter _, _ = w.Write(body) } @@ -284,10 +316,31 @@ type AnnounceOutcome struct { func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, clientIP, userAgent string) AnnounceOutcome { infoHashHex := hexBytes(req.InfoHash[:]) + // A deadline for everything below, connection-pool waits included. Over + // HTTP the request context has none, and over UDP the processor gets the + // process's: a query plan gone wrong held a pooled connection with nothing + // able to cancel it, and twenty such announces froze every other one in + // `Acquire`. Background writes take `s.appCtx`, not this. + ctx, cancel := context.WithTimeout(ctx, announceDeadline) + defer cancel() + + // 0. Announces that cannot succeed, answered without a lookup: an address + // that has used up its failures for the minute, or a passkey of a shape + // the site never mints. See lookupGuard. + now := time.Now() + if s.lookups.blocked(clientIP, now) { + return AnnounceOutcome{Failure: "Too many invalid requests, retry later"} + } + if !plausiblePasskey(req.Passkey) { + s.lookups.fail(clientIP, now) + return AnnounceOutcome{Failure: "Invalid passkey"} + } + // 1. Resolve & validate the user user, err := s.db.UserByPasskey(ctx, req.Passkey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { + s.lookups.fail(clientIP, now) return AnnounceOutcome{Failure: "Invalid passkey"} } slog.Error("internal error", "where", "find user", "err", err) @@ -333,8 +386,11 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli return AnnounceOutcome{Failure: "Access denied"} } - // 2. Ratio check (only when leeching: left > 0) - if req.Left > 0 { + // 2. Ratio check (only when leeching: left > 0) — and not while a + // site-wide event counts no download at all. Under a freeleech nothing a + // member downloads moves their ratio, so refusing them for it held the + // members the event was meant for out of it. + if req.Left > 0 && s.bonus.Get(ctx).Download != 0 { minRatio, err := s.db.GetMinRatio(ctx) if err == nil && minRatio > 0 && user.Downloaded > 0 { ratio := float64(user.Uploaded) / float64(user.Downloaded) @@ -358,6 +414,8 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli resolved, err := s.db.ResolveAnnouncedTorrent(ctx, infoHashHex) if err != nil { if errors.Is(err, pgx.ErrNoRows) { + // Not counted by the lookup guard: the passkey was valid, so this is + // a member's client still announcing a torrent the site removed. return AnnounceOutcome{Failure: "Torrent not found or inactive"} } slog.Error("internal error", "where", "find torrent", "err", err) @@ -373,9 +431,15 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli infoHashHex = resolved.SwarmKey } - // 4. Dedup window — skip if same {hash,peer,event} fired within 2 seconds + // 4. Dedup window — skip if same {hash,peer,event} fired within 2 seconds. + // + // The member is part of every key below. A peer_id is public — any client + // in the swarm reads it in the handshake — so a key made of it alone let a + // stranger announcing under someone else's peer_id every second hold that + // member's window shut, and their announces with it. peerHex := hexBytes(req.PeerID[:]) - dedupKey := infoHashHex + ":" + peerHex + ":" + req.Event.String() + peerKey := peerHex + ":" + user.ID + dedupKey := infoHashHex + ":" + peerKey + ":" + req.Event.String() if !s.dedup.CheckAndMark(ctx, dedupKey) { seeders, leechers, _ := s.peers.Counts(ctx, infoHashHex) return AnnounceOutcome{ @@ -411,7 +475,12 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // forfeits at most one announce-interval of credit — identical to // the long-standing TTL-expiry behaviour, and the only direction // that cannot be replayed. - prev, _ := s.peers.Get(ctx, infoHashHex, peerHex) + // + // The entry is this member's own: another member announcing under the same + // peer_id gets a field of its own, and neither can reach the other's + // baseline (see peers.Slot). + prev, slot, _ := s.peers.Slot(ctx, infoHashHex, peerHex, user.ID) + var deltaUp, deltaDown int64 if prev != nil { if d := req.Uploaded - prev.Uploaded; d > 0 { @@ -475,17 +544,23 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // partial unique index it upserts against; every other kind was // a plain INSERT. // - // Keyed per KIND, and on the same 2 s window as the credit: two - // announces milliseconds apart are one event and should be one - // flag, while the same detector firing again ten minutes later is - // new evidence and must still be recorded. + // Keyed per KIND and per MEMBER, over the minimum announce interval. + // It was per peer over 2 s: two announces milliseconds apart made + // one flag, but a client announcing every few seconds — or rotating + // its peer_id — added a row each time, and a single cheater could + // bury the moderation queue. No honest client announces faster than + // `minAnnounceInterval`, so evidence arriving at a normal cadence is + // still recorded every time. // // The filter lives here rather than in the anticheat package so // that package stays free of Redis and remains a pure detector. if len(acFlags) > 0 { fresh := acFlags[:0] for _, f := range acFlags { - if s.dedup.CheckAndMark(ctx, infoHashHex+":"+peerHex+":acflag:"+f.Kind) { + if s.dedup.CheckAndMarkFor( + ctx, infoHashHex+":"+user.ID+":acflag:"+f.Kind, + time.Duration(minAnnounceInterval)*time.Second, + ) { fresh = append(fresh, f) } } @@ -620,7 +695,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli * L'écrêtage garde sa place d'origine — avant les multiplicateurs de bonus, * qui s'appliquent ensuite au delta déjà borné. */ - creditKey := infoHashHex + ":" + peerHex + ":credit" + creditKey := infoHashHex + ":" + peerKey + ":credit" bookCredit := (deltaUp > 0 || deltaDown > 0) && s.dedup.CheckAndMark(ctx, creditKey) @@ -757,7 +832,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // response — the client just left, so they don't need a fresh // swarm snapshot, but they do still want the current counts. if req.Event == announce.EventStopped { - _ = s.peers.Remove(ctx, infoHashHex, peerHex) + _ = s.peers.Remove(ctx, infoHashHex, slot) seeders, leechers, _ := s.peers.Counts(ctx, infoHashHex) return AnnounceOutcome{ Seeders: seeders, @@ -768,7 +843,11 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli } } - // 8. Upsert the peer + // 8. Upsert the peer — after making room for it if this member already + // holds as many live entries on this torrent as one member may. + if prev == nil { + s.capMemberPeers(ctx, infoHashHex, user.ID) + } ipHash := cryptohash.HashIP(clientIP, s.ipHashSecret) pdata := &peers.PeerData{ PeerID: peerHex, @@ -780,10 +859,13 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli Downloaded: req.Downloaded, Left: req.Left, IsSeeder: req.IsSeeder(), + Field: slot, } if err := s.peers.Set(ctx, infoHashHex, pdata); err != nil { - slog.Error("internal error", "where", "store peer", "err", err) - return AnnounceOutcome{Failure: "Internal tracker error"} + // Redis is down: the swarm can be neither stored nor read, but the rest + // of the announce still runs — the credit already fell back to a direct + // write — and step 11 answers in degraded mode. + s.noteSwarmUnavailable("store peer", err) } // 9. event=completed: bump the counter and create the HnR entry. @@ -800,11 +882,7 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli go s.recordHnrCompletion(user.ID, torrentID, infoHashHex) } - // 10. Seeders contribute to seed-time tracking. Gate on an - // event-independent dedup key (mirroring the byte-credit creditKey) so - // concurrent announces with distinct events (started/completed/update, - // all left=0) can't each book the same `elapsed` — an N× over-credit - // the per-event dedup at step 4 doesn't stop (finding M7). + // 10. Seeders contribute to seed-time tracking. // // A BEP 21 partial seed is excluded by `IsSeeder()` and that is the // intended reading: hit-and-run asks a member to seed what they took, and @@ -813,43 +891,51 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli // still in the swarm — they simply do not bank seed time towards a // requirement they cannot meet. if req.IsSeeder() && prev != nil { - elapsed := (time.Now().UnixMilli() - prev.UpdatedAt) / 1000 /* - * La clé porte sur (torrent, UTILISATEUR), et la fenêtre est - * l'intervalle d'annonce. + * Le temps crédité est réservé par (torrent, UTILISATEUR), pas par peer. * * `AddSeedTime` additionne dans UNE ligne `hnr_tracking` par - * (user, torrent) — la clé contenait `peerHex`, donc cent peer_id - * concurrents sur le même torrent versaient chacun le même intervalle - * de temps réel dans la même ligne : cent secondes de seed par seconde - * écoulée. Les 24 h exigées se soldaient en un quart d'heure, et le - * hit-and-run cessait de mesurer quoi que ce soit. + * (user, torrent) : cent peer_id concurrents sur le même torrent + * versaient chacun le même intervalle de temps réel dans la même ligne, + * et les 24 h exigées se soldaient en un quart d'heure. Le premier + * correctif bornait chaque crédit à `minAnnounceInterval` sous une + * fenêtre de même durée — ce qui ne créditait que la moitié du temps + * d'un seedeur qui réannonce toutes les `announceInterval`. + * + * `ClaimSeedTime` garde la marque jusqu'où le compte est crédité : + * chaque annonce prend l'intervalle qui la sépare de la marque (ou de + * sa propre ligne de base, si elle est plus récente), puis l'avance. + * Disjoints quel que soit le nombre de peer_id ou de réplicas, donc + * jamais plus que le temps réel ; entiers pour un seedeur honnête. + * Concurrent announces with distinct events (finding M7) are covered + * the same way: the second one finds the mark already at now. * - * La fenêtre de deux secondes était l'autre moitié du défaut : elle est - * faite pour dédupliquer UNE annonce arrivée sur trois interfaces, pas - * pour borner une quantité par période. À `minAnnounceInterval`, on - * crédite au plus un intervalle par intervalle — et le plafond sur - * `elapsed` empêche une ligne de base ancienne d'en réclamer plus. + * Le plafond borne ce qu'une ligne de base ancienne peut réclamer : un + * client resté muet plus de deux intervalles n'a sans doute pas seedé + * tout ce temps. Une erreur Redis ne crédite rien. */ - seedKey := infoHashHex + ":" + user.ID + ":seedtime" - if elapsed > int64(minAnnounceInterval) { - elapsed = int64(minAnnounceInterval) - } - if elapsed > 0 && s.dedup.CheckAndMarkFor( - ctx, seedKey, time.Duration(minAnnounceInterval)*time.Second, - ) { + now := time.Now() + if secs, err := s.peers.ClaimSeedTime( + ctx, torrentID, user.ID, now, time.UnixMilli(prev.UpdatedAt), maxSeedTimeCredit, + ); err == nil && secs > 0 { s.bgTasks.Add(1) - // `seedKey` suit jusqu'à l'écriture : la marque vient d'être posée, - // et c'est à celui qui échoue de la rendre. Voir `recordSeedTime`. - go s.recordSeedTime(user.ID, torrentID, infoHashHex, seedKey, int32(elapsed)) + go s.recordSeedTime(user.ID, torrentID, infoHashHex, int32(secs)) // #nosec G115 -- capped at maxSeedTimeCredit (two intervals) by ClaimSeedTime + } else if err != nil { + slog.Warn("claim seed time", "info_hash", infoHashHex, "err", err) } } // 11. Build the announce response with the current swarm state + return s.swarmOutcome(ctx, infoHashHex, req.IsSeeder()) +} + +// swarmOutcome is the reply to an announce that is not a stop: the current +// swarm, as much of it as this client should be told about. +func (s *Server) swarmOutcome(ctx context.Context, infoHashHex string, announcerIsSeeder bool) AnnounceOutcome { peerList, err := s.peers.List(ctx, infoHashHex) if err != nil { - slog.Error("internal error", "where", "list peers", "err", err) - return AnnounceOutcome{Failure: "Internal tracker error"} + s.noteSwarmUnavailable("list peers", err) + return degradedOutcome() } // 11b. Federation cross-announce (Phase 4): mix in peers cached from // partner instances for this torrent. Off unless TRACKER_FEDERATION_SWARM. @@ -867,15 +953,79 @@ func (s *Server) ProcessAnnounce(ctx context.Context, req *announce.Request, cli leechers++ } } + // A seeder has nothing to fetch from another seeder, so it is only told + // about leechers — what every tracker does, and it closes the cheap half + // of the `left=0` bypass: the ratio gate (step 2) only looks at members + // still downloading, so one under the minimum ratio who announces as a + // seeder used to be handed every seeder of the swarm to download from. The + // counts above still describe the whole swarm. return AnnounceOutcome{ Seeders: seeders, Leechers: leechers, - Peers: peerList, + Peers: peersForAnnouncer(peerList, announcerIsSeeder), Interval: announceInterval, MinInterval: minAnnounceInterval, } } +// maxPeersPerMember is how many live entries one member may hold on one +// torrent: a seedbox, a home client, a laptop, with room for a restart or two. +const maxPeersPerMember = 5 + +// capMemberPeers evicts this member's oldest live entries on the torrent until +// a new one fits under `maxPeersPerMember`. +// +// Nothing bounded them. One passkey announcing random peer_ids grew the +// swarm's hash without end — each write pushed its 24 h TTL further — and every +// announce on that torrent then read the whole bloated hash, while other +// clients were handed peers that did not exist. Evicting rather than refusing, +// because the usual way to cross the line is innocent: qBittorrent picks a new +// peer_id every session, and a client restarted a few times in an hour leaves +// its previous entries live behind it. The oldest are the ones it left. +// +// Best effort: called only for a peer_id the swarm has not seen, so the swarm +// read it costs is paid once per client session, and an error skips the cap. +func (s *Server) capMemberPeers(ctx context.Context, infoHashHex, userID string) { + list, err := s.peers.List(ctx, infoHashHex) + if err != nil { + return + } + mine := make([]*peers.PeerData, 0, maxPeersPerMember) + for _, p := range list { + if p.UserID == userID { + mine = append(mine, p) + } + } + if len(mine) < maxPeersPerMember { + return + } + sort.Slice(mine, func(i, j int) bool { return mine[i].UpdatedAt < mine[j].UpdatedAt }) + for _, p := range mine[:len(mine)-maxPeersPerMember+1] { + _ = s.peers.Remove(ctx, infoHashHex, p.Field) + } +} + +// degradedInterval is how soon a client answered without a swarm comes back: +// short, so announces resume within minutes of Redis returning. +const degradedInterval = 300 + +// degradedOutcome answers an announce while Redis is down. Refusing it — what +// the tracker did — turned a Redis outage into a tracker outage: every client +// showed the torrent's tracker as failing and backed off on its own schedule, +// although Postgres could still authenticate them and the credit had already +// fallen back to a direct write. An empty peer list and a short interval keep +// the clients' existing connections going and bring them back soon. +func degradedOutcome() AnnounceOutcome { + return AnnounceOutcome{Interval: degradedInterval, MinInterval: 60} +} + +func (s *Server) noteSwarmUnavailable(where string, err error) { + if n := s.swarmUnavailable.Add(1); n%1_000 == 1 { + slog.Error("swarm store unavailable, announces answered in degraded mode", + "where", where, "count", n, "err", err) + } +} + // mergePeers appends remote (partner-instance) peers to the local set, // de-duplicating by ip:port so a peer present in both swarms isn't listed // twice. Local peers keep priority (kept first); remote ones fill in. @@ -989,40 +1139,20 @@ func (s *Server) recordHnrCompletion(userID, torrentID, infoHashHex string) { // Mêmes raisons que `recordHnrCompletion` : par les identifiants. // -// `seedKey` est la marque de déduplication posée par l'appelant, et cette -// fonction la REND si elle n'écrit pas. -// -// La marque est posée avant l'écriture — c'est ce qui la rend atomique entre -// instances — mais rien ne la retirait quand l'écriture n'avait pas lieu. Or -// cette fonction peut renoncer en silence de trois façons : le sémaphore à huit -// places saturé pendant 5 s, une erreur Postgres (hoquet, `statement_timeout`, -// pool épuisé), ou une panique rattrapée. Dans les trois cas la marque restait -// posée pour 900 secondes, et toute annonce suivante du même couple -// (membre, torrent) passait son tour — y compris celle qui aurait rattrapé. -// -// Un hoquet Postgres de trois secondes pendant une rafale perdait donc -// l'intervalle de TOUS les seedeurs concernés et interdisait la reprise pendant -// un quart d'heure : le hit-and-run cessait de mesurer, sans que rien ne le -// dise. Le plafond sur `elapsed` (un intervalle d'annonce) rend d'ailleurs la -// perte irrattrapable une fois la fenêtre passée — une annonce ultérieure ne -// peut pas créditer deux intervalles pour en compenser un. -// -// Rendre la marque ramène le coût d'un échec de quinze minutes à un intervalle. -// Ce n'est pas la réparation complète — la base ne détient toujours pas la -// vérité, un `last_seed_credit_at` la rendrait auto-réparante — mais c'est celle -// qui ne demande ni migration ni changement du chemin chaud. -func (s *Server) recordSeedTime(userID, torrentID, infoHashHex, seedKey string, secondsToAdd int32) { +// L'intervalle est déjà réservé quand cette fonction tourne : `ClaimSeedTime` +// a avancé la marque du compte. Si l'écriture n'a pas lieu — sémaphore saturé +// pendant 5 s, erreur Postgres, panique rattrapée —, cet intervalle-là est +// perdu, et l'annonce suivante crédite le sien normalement, depuis la marque. +// Elle n'est pas reculée pour autant : entre-temps, un autre peer_id du même +// compte a pu l'avancer à son tour, et la reculer lui ferait créditer deux fois +// le même temps. Les pertes sont comptées dans `seedTimeDropped`. +func (s *Server) recordSeedTime(userID, torrentID, infoHashHex string, secondsToAdd int32) { defer s.bgTasks.Done() credited := false // Enregistré AVANT le `recover` ci-dessous, donc exécuté APRÈS lui : les - // defer se déroulent en ordre inverse. Une panique est donc rattrapée, puis - // la marque est rendue — sans quoi le seul chemin qui ne rend rien serait - // celui qui en a le plus besoin. - // - // `context.Background()` et non `s.appCtx` : rendre une marque est une - // compensation qui doit aboutir même pendant l'arrêt, où `appCtx` est déjà - // annulé. `Release` porte sa propre échéance courte. + // defer se déroulent en ordre inverse, et une panique est comptée comme les + // autres pertes. defer func() { if credited { return @@ -1033,7 +1163,6 @@ func (s *Server) recordSeedTime(userID, torrentID, infoHashHex, seedKey string, "info_hash", infoHashHex, "seconds", secondsToAdd) } - s.dedup.Release(context.Background(), seedKey) }() // See `recordHnrCompletion` for the rationale — without this @@ -1168,7 +1297,22 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { writeFailure(w, "Passkey required") return } + // The announce's lookup guard, here too: a scrape costs the same passkey + // query, and a scanner turned away from /announce would come here instead. + clientIP, now := s.clientIP(r), time.Now() + if s.lookups.blocked(clientIP, now) { + writeFailure(w, "Too many invalid requests, retry later") + return + } + if !plausiblePasskey(passkey) { + s.lookups.fail(clientIP, now) + writeFailure(w, "Invalid passkey") + return + } if _, err := s.db.UserByPasskey(r.Context(), passkey); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + s.lookups.fail(clientIP, now) + } // Le même message dans les deux cas : une passkey absente et une // passkey invalide ne se distinguent pas depuis l'extérieur. writeFailure(w, "Invalid passkey") @@ -1207,6 +1351,8 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) { Completed: completed, }) } + // Bencode, served as text/plain with nosniff (noSniff): not markup. + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter _, _ = w.Write(scrapeResponse(stats)) } @@ -1225,10 +1371,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { // le `Content-Type` posé après était purement et simplement ignoré sur le // chemin dégradé. w.Header().Set("Content-Type", "application/json") - if !dbOK || !redisOK { + // Only Postgres makes the tracker unable to answer: without Redis it still + // authenticates and credits, and announces get the degraded reply. A 503 + // here pulled every replica out of the Service on a Redis outage — no + // announce answered at all, which is what degraded mode exists to avoid. + status := "healthy" + if !dbOK { + status = "unhealthy" w.WriteHeader(http.StatusServiceUnavailable) + } else if !redisOK { + status = "degraded" } - body := `{"status":"healthy","db":` + boolStr(dbOK) + `,"redis":` + boolStr(redisOK) + `}` + body := `{"status":"` + status + `","db":` + boolStr(dbOK) + `,"redis":` + boolStr(redisOK) + `}` + // Fixed JSON built from two booleans: nothing from the request. + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter _, _ = w.Write([]byte(body)) } @@ -1326,5 +1482,7 @@ func (s *Server) clientIP(r *http.Request) string { func writeFailure(w http.ResponseWriter, reason string) { w.WriteHeader(http.StatusOK) // BT trackers MUST return 200 with bencode failure - _, _ = w.Write(bencode.FailureResponse(reason)) + // Bencode, served as text/plain with nosniff (noSniff): not markup. + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter + _, _ = w.Write(bencode.FailureResponse(reason)) // #nosec G705 -- bencode in text/plain with nosniff } diff --git a/apps/tracker/internal/server/lookupguard.go b/apps/tracker/internal/server/lookupguard.go new file mode 100644 index 00000000..d72aa6d3 --- /dev/null +++ b/apps/tracker/internal/server/lookupguard.go @@ -0,0 +1,113 @@ +package server + +import ( + "net" + "sync" + "time" +) + +// lookupGuard refuses, without touching Postgres or Redis, a client that keeps +// presenting passkeys that match no account — on /announce, over HTTP and UDP, +// and on the HTTP /scrape, which costs the same query. +// +// Each such announce used to cost a Redis GET and a Postgres query (misses are +// deliberately not cached, so a member created a second ago can announce). A +// scanner cycling through random passkeys was therefore a database load +// generator. After `maxLookupFailures` failures in a minute, the address is +// answered from memory for the rest of that minute. Counted per replica, which +// is enough: the point is the database, and each replica guards its own share +// of it. +// +// An unknown info hash under a VALID passkey is not counted. That is a member's +// client still announcing a torrent the site removed; after a purge, a single +// restart sends hundreds of them at once, and counting them locked the member — +// and everyone sharing the address, a seedbox host's other customers say — out +// of the torrents that do exist. +// +// IPv6 is counted per /64, the smallest block a subscriber is handed: a host +// that can pick a fresh address for every request would otherwise never meet +// the limit. +type lookupGuard struct { + mu sync.Mutex + windows map[string]*lookupWindow +} + +type lookupWindow struct { + start time.Time + failures int +} + +const ( + maxLookupFailures = 120 + lookupWindowSpan = time.Minute + // A flood of distinct addresses must not grow the map without bound: past + // this size the stale windows are swept, and if every window is live the + // map is reset — the guard forgets, the database is still protected by the + // shape check and the per-announce cost. + maxLookupWindows = 100_000 +) + +func newLookupGuard() *lookupGuard { + return &lookupGuard{windows: make(map[string]*lookupWindow)} +} + +// subject is the address for IPv4, its /64 for IPv6. +func lookupSubject(ip string) string { + parsed := net.ParseIP(ip) + if parsed == nil || parsed.To4() != nil { + return ip + } + return parsed.Mask(net.CIDRMask(64, 128)).String() + "/64" +} + +// blocked reports whether this address has used up its failures for the +// current window. +func (g *lookupGuard) blocked(ip string, now time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + w, ok := g.windows[lookupSubject(ip)] + return ok && now.Sub(w.start) < lookupWindowSpan && w.failures >= maxLookupFailures +} + +// fail records one announce that could not succeed. +func (g *lookupGuard) fail(ip string, now time.Time) { + g.mu.Lock() + defer g.mu.Unlock() + key := lookupSubject(ip) + w, ok := g.windows[key] + if !ok || now.Sub(w.start) >= lookupWindowSpan { + if !ok && len(g.windows) >= maxLookupWindows { + g.sweep(now) + } + g.windows[key] = &lookupWindow{start: now, failures: 1} + return + } + w.failures++ +} + +func (g *lookupGuard) sweep(now time.Time) { + for k, w := range g.windows { + if now.Sub(w.start) >= lookupWindowSpan { + delete(g.windows, k) + } + } + if len(g.windows) >= maxLookupWindows { + g.windows = make(map[string]*lookupWindow) + } +} + +// plausiblePasskey is the shape check that runs before any lookup: lowercase or +// uppercase hex, 32 to 64 characters. The site mints 40; the range keeps any +// older key format working. +func plausiblePasskey(p string) bool { + if len(p) < 32 || len(p) > 64 { + return false + } + for i := 0; i < len(p); i++ { + c := p[i] + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + return false + } + } + return true +} diff --git a/apps/tracker/internal/server/lookupguard_test.go b/apps/tracker/internal/server/lookupguard_test.go new file mode 100644 index 00000000..263a4d17 --- /dev/null +++ b/apps/tracker/internal/server/lookupguard_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "strings" + "testing" + "time" +) + +// A client that keeps announcing with a passkey nobody holds, or for a hash no +// torrent has, is answered from memory once it has used up its failures — the +// database is not its load generator. +func TestLookupGuard_BlocksAfterTheLimitForTheRestOfTheWindow(t *testing.T) { + g := newLookupGuard() + now := time.Unix(1_700_000_000, 0) + for i := 0; i < maxLookupFailures-1; i++ { + g.fail("203.0.113.7", now) + } + if g.blocked("203.0.113.7", now) { + t.Fatal("bloqué avant d'avoir atteint la limite") + } + g.fail("203.0.113.7", now) + if !g.blocked("203.0.113.7", now.Add(30*time.Second)) { + t.Fatal("devait être bloqué pour le reste de la minute") + } + if g.blocked("203.0.113.8", now) { + t.Fatal("une autre adresse ne doit pas payer pour la première") + } + if g.blocked("203.0.113.7", now.Add(61*time.Second)) { + t.Fatal("la fenêtre suivante repart de zéro") + } +} + +// Every address of one IPv6 /64 counts as one client. +func TestLookupGuard_FoldsAnIPv6Slash64(t *testing.T) { + g := newLookupGuard() + now := time.Unix(1_700_000_000, 0) + for i := 0; i < maxLookupFailures; i++ { + g.fail("2001:db8:aa:bb::"+strings.Repeat("1", 1+i%4), now) + } + if !g.blocked("2001:db8:aa:bb:ffff::1", now) { + t.Fatal("tourner dans son /64 ne doit pas échapper au compte") + } + if g.blocked("2001:db8:aa:bc::1", now) { + t.Fatal("un autre /64 n'est pas concerné") + } +} + +func TestPlausiblePasskey(t *testing.T) { + for _, ok := range []string{strings.Repeat("a", 40), strings.Repeat("F", 32), strings.Repeat("0", 64)} { + if !plausiblePasskey(ok) { + t.Fatalf("%q devait passer", ok) + } + } + for _, bad := range []string{"", "short", strings.Repeat("a", 31), strings.Repeat("a", 65), strings.Repeat("g", 40), strings.Repeat("a", 39) + "'"} { + if plausiblePasskey(bad) { + t.Fatalf("%q devait être refusé", bad) + } + } +} diff --git a/apps/tracker/internal/server/nosniff_test.go b/apps/tracker/internal/server/nosniff_test.go new file mode 100644 index 00000000..626a7066 --- /dev/null +++ b/apps/tracker/internal/server/nosniff_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Every response of the tracker's own mux carries nosniff, the bencoded +// failures included, whatever sits (or does not) in front of it. +func TestNoSniff_SetOnEveryResponse(t *testing.T) { + h := noSniff(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("d14:failure reason15:Invalid passkeye")) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/announce", nil)) + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } + if got := rec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("the handler's own Content-Type was lost: %q", got) + } +} + +// And Routes() is what puts it there. A scrape without a passkey is refused +// before the database or Redis is touched, and with a plain Write: a 404 would +// prove nothing, http.Error setting nosniff on its own. +func TestRoutes_WrapsTheMuxInNoSniff(t *testing.T) { + rec := httptest.NewRecorder() + (&Server{}).Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/scrape", nil)) + if body := rec.Body.String(); !strings.Contains(body, "failure reason") { + t.Fatalf("body = %q, want a bencoded failure", body) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) + } +} diff --git a/apps/tracker/internal/server/response.go b/apps/tracker/internal/server/response.go index 1bfb82aa..5500c407 100644 --- a/apps/tracker/internal/server/response.go +++ b/apps/tracker/internal/server/response.go @@ -29,7 +29,17 @@ const ( // the peer list — the `To4()` filter in `splitCompactPeers` excludes them // and the requesting client ends up with an empty `peers` string, which is // indistinguishable from a dead swarm. -func buildAnnounceResponse(seeders, leechers int, peers []*peers.PeerData, _ bool, excludePeerID [20]byte, numWant int) []byte { +// +// `interval` and `minInterval` come from the announce's outcome: the degraded +// reply asks clients back sooner than a normal one. Zero falls back to the +// normal values rather than telling clients to re-announce at once. +func buildAnnounceResponse(seeders, leechers, interval, minInterval int, peers []*peers.PeerData, _ bool, excludePeerID [20]byte, numWant int) []byte { + if interval <= 0 { + interval = announceInterval + } + if minInterval <= 0 { + minInterval = minAnnounceInterval + } v4, v6 := splitCompactPeers(peers, excludePeerID, numWant) enc := bencode.NewEncoder(256 + len(v4) + len(v6)) @@ -43,10 +53,10 @@ func buildAnnounceResponse(seeders, leechers int, peers []*peers.PeerData, _ boo enc.Int(int64(leechers)) enc.String("interval") - enc.Int(announceInterval) + enc.Int(int64(interval)) enc.String("min interval") - enc.Int(minAnnounceInterval) + enc.Int(int64(minInterval)) enc.String("peers") enc.ByteString(v4) @@ -64,6 +74,22 @@ func buildAnnounceResponse(seeders, leechers int, peers []*peers.PeerData, _ boo return enc.Bytes() } +// peersForAnnouncer is the part of the swarm worth telling this client +// about: everyone to a leecher, only leechers to a seeder. See step 11 of the +// announce handler for why that also matters beyond bandwidth. +func peersForAnnouncer(list []*peers.PeerData, announcerIsSeeder bool) []*peers.PeerData { + if !announcerIsSeeder { + return list + } + out := make([]*peers.PeerData, 0, len(list)) + for _, p := range list { + if !p.IsSeeder { + out = append(out, p) + } + } + return out +} + // splitCompactPeers walks the swarm once and returns two compact peer // strings ready for bencoding: // diff --git a/apps/tracker/internal/server/response_test.go b/apps/tracker/internal/server/response_test.go index 68030054..e830ef7f 100644 --- a/apps/tracker/internal/server/response_test.go +++ b/apps/tracker/internal/server/response_test.go @@ -159,7 +159,7 @@ func TestBuildAnnounceResponse_OmitsPeers6WhenEmpty(t *testing.T) { {PeerID: "aa", IP: "203.0.113.5", Port: 6881}, } var exclude [20]byte - body := buildAnnounceResponse(1, 0, peerList, true, exclude, 50) + body := buildAnnounceResponse(1, 0, announceInterval, minAnnounceInterval, peerList, true, exclude, 50) // `6:peers6` is the bencoded key marker — distinct from the // substring `peers6` that can show up as `5:peers` followed by // a 6-byte compact peer entry whose first byte happens to be 6. @@ -176,14 +176,14 @@ func TestBuildAnnounceResponse_IncludesPeers6WhenIPv6(t *testing.T) { {PeerID: "aa", IP: "2001:db8::1", Port: 6881}, } var exclude [20]byte - body := buildAnnounceResponse(1, 0, peerList, true, exclude, 50) + body := buildAnnounceResponse(1, 0, announceInterval, minAnnounceInterval, peerList, true, exclude, 50) if !bytes.Contains(body, []byte("6:peers6")) { t.Fatalf("response missing peers6 key for IPv6 swarm: %q", body) } } func TestBuildAnnounceResponse_ContainsRequiredKeys(t *testing.T) { - body := buildAnnounceResponse(42, 17, nil, true, [20]byte{}, 50) + body := buildAnnounceResponse(42, 17, announceInterval, minAnnounceInterval, nil, true, [20]byte{}, 50) for _, key := range []string{"8:complete", "10:incomplete", "8:interval", "12:min interval", "5:peers"} { if !bytes.Contains(body, []byte(key)) { t.Errorf("body missing key %q: %q", key, body) @@ -195,6 +195,24 @@ func TestBuildAnnounceResponse_ContainsRequiredKeys(t *testing.T) { } } +// The degraded reply (Redis down) asks clients back in five minutes. The HTTP +// response wrote the constants whatever the outcome said, so only UDP clients +// ever saw it — HTTP clients were sent away for half an hour with no peers. +func TestBuildAnnounceResponse_CarriesTheOutcomeInterval(t *testing.T) { + body := buildAnnounceResponse(0, 0, 300, 60, nil, true, [20]byte{}, 50) + for _, want := range []string{"8:intervali300e", "12:min intervali60e"} { + if !bytes.Contains(body, []byte(want)) { + t.Errorf("body missing %q: %q", want, body) + } + } + body = buildAnnounceResponse(0, 0, 0, 0, nil, true, [20]byte{}, 50) + for _, want := range []string{"8:intervali1800e", "12:min intervali900e"} { + if !bytes.Contains(body, []byte(want)) { + t.Errorf("zero intervals should fall back to the normal ones, missing %q: %q", want, body) + } + } +} + func TestHexBytes_RoundTrip(t *testing.T) { // Known 20-byte info hash → 40 lowercase hex chars. raw := []byte{0x00, 0xff, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, @@ -273,3 +291,22 @@ func TestScrapeResponse_MultipleHashes(t *testing.T) { t.Fatalf("missing entries from multi-hash scrape: %q", body) } } + +// A seeder is told about leechers only. Besides the bandwidth, this is what +// keeps a member under the minimum ratio — whom the ratio gate lets through +// when they claim `left=0` — from being handed every seeder to download from. +func TestPeersForAnnouncer_SeederGetsLeechersOnly(t *testing.T) { + swarm := []*peers.PeerData{ + {PeerID: "s1", IsSeeder: true}, + {PeerID: "l1", IsSeeder: false}, + {PeerID: "s2", IsSeeder: true}, + {PeerID: "l2", IsSeeder: false}, + } + got := peersForAnnouncer(swarm, true) + if len(got) != 2 || got[0].PeerID != "l1" || got[1].PeerID != "l2" { + t.Fatalf("un seeder devait recevoir l1 et l2, a reçu %v", got) + } + if all := peersForAnnouncer(swarm, false); len(all) != 4 { + t.Fatalf("un leecher devait recevoir tout l'essaim, a reçu %d peers", len(all)) + } +} diff --git a/apps/tracker/internal/stats/stats.go b/apps/tracker/internal/stats/stats.go index c46f4cf2..1e75e1b5 100644 --- a/apps/tracker/internal/stats/stats.go +++ b/apps/tracker/internal/stats/stats.go @@ -358,6 +358,8 @@ func (a *Accumulator) Flush(ctx context.Context) (int, error) { // Le tri sert à ce que deux versements concurrents, s'ils se recouvraient, // prennent leurs verrous de ligne dans le même ordre. func (a *Accumulator) pendingIDs(ctx context.Context) ([]string, error) { + // Filled by the HSCAN loop below before anything ranges over it. + // nosemgrep: trailofbits.go.iterate-over-empty-map.iterate-over-empty-map seen := make(map[string]struct{}) for _, key := range []string{a.upKey, a.downKey} { var cursor uint64 diff --git a/apps/tracker/internal/udp/connid.go b/apps/tracker/internal/udp/connid.go index 2c6986c9..779e3931 100644 --- a/apps/tracker/internal/udp/connid.go +++ b/apps/tracker/internal/udp/connid.go @@ -90,7 +90,7 @@ func (i *ConnIDIssuer) derive(ip net.IP, minute int64) uint64 { // in-memory layouts (4-byte vs 4-in-16) hashes the same way. mac.Write(ipBytes(ip)) var minBuf [8]byte - binary.BigEndian.PutUint64(minBuf[:], uint64(minute)) + binary.BigEndian.PutUint64(minBuf[:], uint64(minute)) // #nosec G115 -- bytes fed to the HMAC; the sign does not matter mac.Write(minBuf[:]) sum := mac.Sum(nil) id := binary.BigEndian.Uint64(sum[0:8]) ^ binary.BigEndian.Uint64(sum[8:16]) diff --git a/apps/tracker/internal/udp/encode.go b/apps/tracker/internal/udp/encode.go index 907d6656..50ef1a22 100644 --- a/apps/tracker/internal/udp/encode.go +++ b/apps/tracker/internal/udp/encode.go @@ -46,9 +46,11 @@ func EncodeAnnounceResponse( out = out[:announceRespHdr] binary.BigEndian.PutUint32(out[0:4], ActionAnnounce) binary.BigEndian.PutUint32(out[4:8], transactionID) - binary.BigEndian.PutUint32(out[8:12], uint32(interval)) - binary.BigEndian.PutUint32(out[12:16], uint32(leechers)) - binary.BigEndian.PutUint32(out[16:20], uint32(seeders)) + // The interval is configuration and the two counts are peers in one swarm: + // none is negative, none comes near 2^32. + binary.BigEndian.PutUint32(out[8:12], uint32(interval)) // #nosec G115 -- see above + binary.BigEndian.PutUint32(out[12:16], uint32(leechers)) // #nosec G115 -- see above + binary.BigEndian.PutUint32(out[16:20], uint32(seeders)) // #nosec G115 -- see above written := 0 for _, p := range peerList { @@ -104,9 +106,11 @@ func EncodeScrapeResponse(out []byte, transactionID uint32, stats []ScrapeStat) binary.BigEndian.PutUint32(out[4:8], transactionID) for i, s := range stats { off := scrapeRespHdr + scrapeEntrySize*i - binary.BigEndian.PutUint32(out[off+0:off+4], uint32(s.Seeders)) - binary.BigEndian.PutUint32(out[off+4:off+8], uint32(s.Completed)) - binary.BigEndian.PutUint32(out[off+8:off+12], uint32(s.Leechers)) + // Counts of one torrent's peers and snatches: never negative, nowhere + // near 2^32, and BEP 15 fixes the field at 32 bits anyway. + binary.BigEndian.PutUint32(out[off+0:off+4], uint32(s.Seeders)) // #nosec G115 -- see above + binary.BigEndian.PutUint32(out[off+4:off+8], uint32(s.Completed)) // #nosec G115 -- see above + binary.BigEndian.PutUint32(out[off+8:off+12], uint32(s.Leechers)) // #nosec G115 -- see above } return out } diff --git a/apps/tracker/internal/udp/parse.go b/apps/tracker/internal/udp/parse.go index 73b04a60..0f570266 100644 --- a/apps/tracker/internal/udp/parse.go +++ b/apps/tracker/internal/udp/parse.go @@ -147,13 +147,13 @@ func ParseAnnounce(data []byte) (*AnnounceRequestUDP, error) { r := &AnnounceRequestUDP{ ConnectionID: binary.BigEndian.Uint64(data[0:8]), TransactionID: binary.BigEndian.Uint32(data[12:16]), - Downloaded: int64(binary.BigEndian.Uint64(data[56:64])), + Downloaded: int64(binary.BigEndian.Uint64(data[56:64])), // #nosec G115 -- a wrapped negative is floored by nonNeg in ToAnnounceRequest Left: clampLeft(binary.BigEndian.Uint64(data[64:72])), - Uploaded: int64(binary.BigEndian.Uint64(data[72:80])), + Uploaded: int64(binary.BigEndian.Uint64(data[72:80])), // #nosec G115 -- a wrapped negative is floored by nonNeg in ToAnnounceRequest Event: binary.BigEndian.Uint32(data[80:84]), IPv4: binary.BigEndian.Uint32(data[84:88]), Key: binary.BigEndian.Uint32(data[88:92]), - NumWant: int32(binary.BigEndian.Uint32(data[92:96])), + NumWant: int32(binary.BigEndian.Uint32(data[92:96])), // #nosec G115 -- BEP 15 num_want IS a signed int32 (0xFFFFFFFF = -1, "default"); clamped to [0,100] later Port: binary.BigEndian.Uint16(data[96:98]), } // action sanity — caller already routes by action, but if the diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index f3ddda44..0395b3e5 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,18 +1,25 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.28 # Trackarr web — Nuxt SSR, distroless runtime. # # The Nitro server bundle in `.output/server/` is fully self-contained # (Nuxt inlines its node_modules at build time), so the runtime image # carries only that bundle and a small Node-based healthcheck. # -# Base is gcr.io/distroless/nodejs24-debian13:nonroot — no /bin/sh, no apt, +# Base is gcr.io/distroless/nodejs26-debian13:nonroot — no /bin/sh, no apt, # no package manager, runs as uid 65532, signed by Google. # ----------------------------------------------------------------------------- # Install dependencies (cached on package.json + lockfile) # ----------------------------------------------------------------------------- -FROM node:24-alpine AS deps -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS deps +# npm rather than corepack: Node 25 stopped shipping corepack, so the +# node:26 images have none. The version must match `packageManager` in the +# root package.json — pnpm 12 records it in pnpm-lock.yaml and a frozen +# install refuses a lockfile whose pin it does not match. `--allow-scripts` +# names the one install script we let run: pnpm's own, which only hard-links +# its native binary over the bins. npm 11.20 runs unlisted scripts with a +# warning today and is moving to blocking them. +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/web/package.json ./apps/web/ @@ -22,8 +29,8 @@ RUN pnpm install --filter "@trackarr/web..." --frozen-lockfile # ----------------------------------------------------------------------------- # Build the Nuxt app (produces apps/web/.output) # ----------------------------------------------------------------------------- -FROM node:24-alpine AS builder -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS builder +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules @@ -43,31 +50,45 @@ COPY tsconfig.base.json ./ # mode from "the build breaks" into "the first build of the day is the only one # that needs Google". # +# The target is the WEB package's node_modules, not the workspace root's: the +# module resolves its cache against Nuxt's `rootDir` (apps/web) — @nuxt/fonts +# 1.x says so, and 0.14 got the same path from the working directory pnpm +# runs the script in. Until 2026-10 this mounted `/app/node_modules/.cache/nuxt`, +# which nothing writes to, so the cache this comment promises never existed: +# measured, the faces land in apps/web/node_modules/.cache/nuxt/fonts (3 MB). +# # It does not help a cold CI runner. If a release pipeline cannot tolerate a # retry, switch the families to `provider: 'local'` — see apps/web/nuxt.config.ts. -RUN --mount=type=cache,target=/app/node_modules/.cache/nuxt,sharing=locked \ +RUN --mount=type=cache,target=/app/apps/web/node_modules/.cache/nuxt/fonts,sharing=locked \ pnpm --filter @trackarr/web build # ----------------------------------------------------------------------------- # Final runtime — distroless nonroot # Entrypoint of this base image is `/nodejs/bin/node`; CMD is just the script. # ----------------------------------------------------------------------------- -FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runner +FROM gcr.io/distroless/nodejs26-debian13:nonroot AS runner WORKDIR /app COPY --from=builder --chown=nonroot:nonroot /app/apps/web/.output ./.output COPY --chown=nonroot:nonroot apps/web/scripts/healthcheck.mjs ./healthcheck.mjs -USER nonroot +# Numeric for the same reason as apps/api/Dockerfile: `runAsNonRoot` cannot +# check a name. +USER 65532:65532 EXPOSE 3000 # Memory tuning (see apps/api/Dockerfile for rationale). # Web SSR retains compiled Vue templates / component state; without an -# explicit V8 heap cap, sustained traffic on Node 24 grows RSS into the -# hundreds of MB before V8 bothers to GC. 384 MB is plenty for SSR. +# explicit V8 heap cap, sustained traffic grows RSS into the hundreds of +# MB before V8 bothers to GC. 384 MB is plenty for SSR. +# +# `--no-experimental-webstorage` matters more here than in the API: this +# process renders pages for every visitor, and Node 25+ would hand that +# render a `sessionStorage` shared by all of them. Same flag, same reason +# — see apps/api/Dockerfile. ENV NODE_ENV=production \ NUXT_HOST=0.0.0.0 \ NUXT_PORT=3000 \ - NODE_OPTIONS="--max-old-space-size=384 --max-semi-space-size=8" \ + NODE_OPTIONS="--max-old-space-size=384 --max-semi-space-size=8 --no-experimental-webstorage" \ MALLOC_ARENA_MAX=2 \ TRACKARR_RUNTIME=docker STOPSIGNAL SIGTERM diff --git a/apps/web/Dockerfile.static b/apps/web/Dockerfile.static index 45ec1d90..4f481aa3 100644 --- a/apps/web/Dockerfile.static +++ b/apps/web/Dockerfile.static @@ -1,4 +1,6 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.28 +# hadolint global ignore=DL3007 +# (the one `:latest` in this file is Chainguard's: see the runner stage) # Trackarr web — static SPA build, distroless nginx runtime. # # This image is the no-SSR alternative to apps/web/Dockerfile. Memory @@ -19,8 +21,15 @@ # ----------------------------------------------------------------------------- # Install workspace dependencies (cached on package.json + lockfile) # ----------------------------------------------------------------------------- -FROM node:24-alpine AS deps -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS deps +# npm rather than corepack: Node 25 stopped shipping corepack, so the +# node:26 images have none. The version must match `packageManager` in the +# root package.json — pnpm 12 records it in pnpm-lock.yaml and a frozen +# install refuses a lockfile whose pin it does not match. `--allow-scripts` +# names the one install script we let run: pnpm's own, which only hard-links +# its native binary over the bins. npm 11.20 runs unlisted scripts with a +# warning today and is moving to blocking them. +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/web/package.json ./apps/web/ @@ -32,8 +41,8 @@ RUN pnpm install --filter "@trackarr/web..." --frozen-lockfile # turn `ssr: false`, and `nuxi generate` writes the prerendered static # tree to apps/web/.output/public/. # ----------------------------------------------------------------------------- -FROM node:24-alpine AS builder -RUN corepack enable && corepack prepare pnpm@11.22.0 --activate +FROM node:26-alpine AS builder +RUN npm install --global --allow-scripts=pnpm pnpm@12.15.0 WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules @@ -56,9 +65,16 @@ ENV NUXT_STATIC_BUILD=true \ # mode from "the build breaks" into "the first build of the day is the only one # that needs Google". # +# The target is the WEB package's node_modules, not the workspace root's: the +# module resolves its cache against Nuxt's `rootDir` (apps/web) — @nuxt/fonts +# 1.x says so, and 0.14 got the same path from the working directory pnpm +# runs the script in. Until 2026-10 this mounted `/app/node_modules/.cache/nuxt`, +# which nothing writes to, so the cache this comment promises never existed: +# measured, the faces land in apps/web/node_modules/.cache/nuxt/fonts (3 MB). +# # It does not help a cold CI runner. If a release pipeline cannot tolerate a # retry, switch the families to `provider: 'local'` — see apps/web/nuxt.config.ts. -RUN --mount=type=cache,target=/app/node_modules/.cache/nuxt,sharing=locked \ +RUN --mount=type=cache,target=/app/apps/web/node_modules/.cache/nuxt/fonts,sharing=locked \ pnpm --filter @trackarr/web exec nuxi generate # ----------------------------------------------------------------------------- @@ -67,6 +83,9 @@ RUN --mount=type=cache,target=/app/node_modules/.cache/nuxt,sharing=locked \ # - Reads its config from /etc/nginx/nginx.conf # - Serves /usr/share/nginx/html # ----------------------------------------------------------------------------- +# `:latest` is the only tag Chainguard's free tier publishes (the image already +# runs as 65532). +# trivy:ignore:AVD-DS-0001 FROM cgr.dev/chainguard/nginx:latest AS runner # nginx config (SPA fallback, gzip, asset caching, /healthz) @@ -101,6 +120,10 @@ STOPSIGNAL SIGQUIT # La sonde revient donc à l'orchestrateur, qui sait le faire depuis l'extérieur : # `readinessProbe: httpGet: { path: /healthz }` côté Kubernetes, ou un # `healthcheck:` dans le compose si l'on veut la garder ici. +# +# `HEALTHCHECK NONE` le dit aux analyseurs (trivy DS-0026) sans rien changer : +# l'image de base n'en déclare aucun. +HEALTHCHECK NONE # The Chainguard image's default ENTRYPOINT runs `nginx -g 'daemon off;'` # from the right user — no overrides needed. diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css index 860f88a6..df4cd57e 100644 --- a/apps/web/app/assets/css/main.css +++ b/apps/web/app/assets/css/main.css @@ -1,20 +1,187 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +/* Tailwind 4. `source()` keeps the class scan where tailwind.config.ts's + `content` had it — app/ — rather than the whole package. */ +@import 'tailwindcss' source('../..'); + +/* `darkMode: ['selector', '[data-theme="dark"]']`, as Tailwind 4 spells it. */ +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + +/* Tailwind 4 makes `hover:` apply only where the primary pointer can hover, + so a tap no longer triggers it. Kept as Tailwind 3 had it: some controls here + are revealed on hover, and a touch screen would otherwise never show them. */ +@custom-variant hover (&:hover); + +/* ============================================================================= + * Theme — what tailwind.config.ts used to say, for Tailwind 4 + * + * `inline`, and that is load-bearing. This file's own tokens share Tailwind's + * theme namespace: `--font-sans`, `--radius-md`, `--ease-standard` and the rest + * are both the design tokens below and Tailwind theme variables. Without + * `inline`, `rounded-sm` would compile to `var(--radius-sm)` — the TOKEN, 4 px — + * where Tailwind 3 compiled the config's `var(--radius-xs)`, 2 px. With it, + * each utility carries the value written here. + * + * The variables Tailwind also emits from this block live in `@layer theme`, + * and the tokens below are unlayered, so where a name collides — including the + * self-references (`--font-sans: var(--font-sans)`) — the token wins the + * cascade and the theme's copy never applies. Operator themes + * (`/api/theme.css`) are unlayered too. + * + * The keys are Tailwind 3's (`sm`, the bare `rounded`/`shadow`), not renamed + * to Tailwind 4's scale, so every class in the templates keeps its meaning. + * ============================================================================= */ +@theme inline { + /* Surfaces */ + --color-bg-primary: rgb(var(--bg-base)); + --color-bg-secondary: rgb(var(--bg-surface)); + --color-bg-tertiary: rgb(var(--bg-elevated)); + --color-bg-hover: rgb(var(--bg-hover)); + --color-bg-inset: rgb(var(--bg-inset)); + + /* Foreground (text *and* opacity-aware fills) */ + --color-text-primary: rgb(var(--fg-default)); + --color-text-secondary: rgb(var(--fg-muted)); + --color-text-muted: rgb(var(--fg-subtle)); + --color-text-faint: rgb(var(--fg-faint)); + --color-text-strong: rgb(var(--fg-strong)); + + /* `fg.*` is the same set under bg/border-friendly names, so + `bg-fg-default/10` and `border-fg-default/20` read without ambiguity. */ + --color-fg-default: rgb(var(--fg-default)); + --color-fg-strong: rgb(var(--fg-strong)); + --color-fg-muted: rgb(var(--fg-muted)); + --color-fg-subtle: rgb(var(--fg-subtle)); + --color-fg-faint: rgb(var(--fg-faint)); + + /* `border-border` is the decorative hairline; `border-border-field` is the + 3:1 edge WCAG 1.4.11 asks of a control (see `.input`). */ + --color-border: rgb(var(--line-default)); + --color-border-hover: rgb(var(--line-strong)); + --color-border-field: rgb(var(--line-field)); + + --color-line: rgb(var(--line-default)); + --color-line-strong: rgb(var(--line-strong)); + + --color-accent: rgb(var(--accent)); + --color-accent-hover: rgb(var(--accent-hover)); + --color-accent-fg: rgb(var(--accent-fg)); + --color-accent-muted: rgb(var(--fg-subtle)); + + --color-success: rgb(var(--online)); + --color-online: rgb(var(--online)); + --color-warning: rgb(var(--warning)); + --color-error: rgb(var(--danger)); + --color-danger: rgb(var(--danger)); + --color-info: rgb(var(--info)); + + /* The stacks live in the tokens below; the utilities only point at them. + No `display` entry: `.font-display` is written by hand further down. */ + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + + --text-2xs: 0.65rem; + --text-2xs--line-height: 0.85rem; + + /* Tailwind 3's steps, mapped onto `--radius`'s derived scale exactly as the + config had them: 2 / 4 / 6 / 8 / 12 px, nothing shifts by a pixel. */ + --radius-none: 0; + --radius-sm: var(--radius-xs); + --radius: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + --radius-2xl: calc(var(--radius) * 2.6667); + --radius-3xl: calc(var(--radius) * 4); + --radius-full: var(--radius-pill); + + /* Durations carry `--motion-scale`, so `motion-scale: 0` stops the + utility-driven transitions as well as the hand-written ones. */ + --default-transition-duration: var(--dur-2); + --transition-duration-75: calc(75ms * var(--motion-scale)); + --transition-duration-100: calc(100ms * var(--motion-scale)); + --transition-duration-150: var(--dur-2); + --transition-duration-200: var(--dur-4); + --transition-duration-300: var(--dur-slow); + --transition-duration-500: calc(500ms * var(--motion-scale)); + --transition-duration-700: calc(700ms * var(--motion-scale)); + --transition-duration-1000: calc(1000ms * var(--motion-scale)); + --default-transition-timing-function: var(--ease-standard); + --ease-standard: var(--ease-standard); + --ease-emphasis: var(--ease-emphasis); + + /* Shadows a theme can tint and flatten, through the same two levers. */ + --shadow-sm: 0 1px 2px 0 rgb(var(--shadow-color) / calc(0.05 * var(--shadow-strength))); + --shadow: 0 1px 3px 0 rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))), + 0 1px 2px -1px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))); + --shadow-md: 0 4px 6px -1px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))), + 0 2px 4px -2px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))); + --shadow-lg: 0 10px 15px -3px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))), + 0 4px 6px -4px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))); + --shadow-xl: 0 20px 25px -5px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))), + 0 8px 10px -6px rgb(var(--shadow-color) / calc(0.1 * var(--shadow-strength))); + --shadow-2xl: 0 25px 50px -12px rgb(var(--shadow-color) / calc(0.25 * var(--shadow-strength))); + --shadow-inner: inset 0 2px 4px 0 rgb(var(--shadow-color) / calc(0.05 * var(--shadow-strength))); + --shadow-none: none; +} + +/* Tailwind 4 redrew its default palette in oklch, more saturated than Tailwind + 3's hex values: sky-400 loses 56 of red, amber-400 36 of blue. The templates + use eight of those shades (menu icons, the favourite and rating stars, the + bonus-pool marker, the register notice, the Torznab chips and gauges) and the + placeholder rule below a ninth; they keep Tailwind 3's values. Delete this + block to adopt Tailwind 4's palette. gray-200, the bare border's colour, is + identical in both. */ +@theme { + --color-amber-400: #fbbf24; + --color-amber-500: #f59e0b; + --color-yellow-500: #eab308; + --color-sky-400: #38bdf8; + --color-sky-500: #0ea5e9; + --color-emerald-500: #10b981; + --color-purple-500: #a855f7; + --color-pink-500: #ec4899; + --color-gray-400: #9ca3af; +} + +/* Three Tailwind 3 defaults that Tailwind 4 changed, restored: elements that + relied on them would otherwise change look without a class changing. + - a bare `border` was gray-200, not `currentColor`; + - a placeholder was gray-400, not the text colour at half opacity + (`.input` sets its own, further down, and wins over this); + - a button showed the pointer cursor. */ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + + input::placeholder, + textarea::placeholder { + color: var(--color-gray-400); + } + + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } +} /* ============================================================================= * Design tokens * * Two themes (`data-theme="dark"` default, `data-theme="light"`) driven from - * CSS variables. The Tailwind config maps `bg-bg-primary`, `text-text-muted`, - * `border-border` etc. to these vars so the existing markup picks both - * themes up without churn. + * CSS variables. The `@theme inline` block above maps `bg-bg-primary`, + * `text-text-muted`, `border-border` etc. to these vars so the existing markup + * picks both themes up without churn. * * Colour vars are stored as **space-separated RGB triplets** ("250 250 250") - * rather than hex literals, so Tailwind's `` placeholder can - * compose them: `rgb(var(--bg-base) / 0.5)` is valid; the previous - * `rgba(var(--bg-base), 0.5)` form silently dropped to the user-agent default - * and gave us white inputs in dark mode. + * rather than hex literals, so `rgb(var(--bg-base) / 0.5)` is valid wherever + * this file writes it by hand; the previous `rgba(var(--bg-base), 0.5)` form + * silently dropped to the user-agent default and gave us white inputs in dark + * mode. Utility opacity modifiers (`bg-bg-secondary/50`) no longer go through + * the triplet: Tailwind 4 composes them with `color-mix()`. * ============================================================================= */ :root, @@ -172,8 +339,8 @@ instead of the app's face, and the 21 in FreeleechPool.vue wrote `var(--font-mono)` with no fallback at all, which makes the whole declaration invalid — those elements were not monospace, they were Inter. - Kept identical to tailwind.config.ts `fontFamily.mono` so the token and - the `font-mono` utility can never drift apart. */ + The `font-mono` utility compiles to this very token (`@theme inline`, at + the top of this file), so the two cannot drift apart. */ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; /* The other two roles, named for the same reason. `--font-sans` had never existed even though `tailwind.config.ts` declared the stack, so a theme had @@ -181,9 +348,9 @@ FreeleechPool.vue, pointing at 'Iowan Old Style' — a face this site never loads — while the `.font-display` utility below and some eighty declarations across a dozen pages spell Fraunces out by hand with four - different fallback stacks. All three are now one name each, kept identical - to what `tailwind.config.ts` resolves so a token and its utility cannot - drift. + different fallback stacks. All three are now one name each, and the + utilities compile to these tokens (`@theme inline`), so a token and its + utility cannot drift. These hold the STACK. What a theme stores is a short name — `manrope`, `fira-code` — and the emitter looks the stack up in `FONT_STACKS` (packages/shared/src/theme.ts), for the same reason the background pattern @@ -725,7 +892,11 @@ * `.eyebrow` is for actual eyebrow labels (above a section title, in form * field labels, inside table headers). NEVER use it as a heading. * --------------------------------------------------------------------------- */ - .eyebrow { + /* One rule for both, rather than `@apply eyebrow` in the mono one: Tailwind 4 + only applies utilities, and making `.eyebrow` one would move it out of this + layer. The mono variant's own declarations come after and win. */ + .eyebrow, + .eyebrow-mono { font-size: 0.6875rem; font-weight: 600; color: rgb(var(--fg-subtle)); @@ -734,7 +905,6 @@ line-height: 1.4; } .eyebrow-mono { - @apply eyebrow; font-family: var(--font-mono); font-weight: 500; } diff --git a/apps/web/app/components/CategorySidebar.vue b/apps/web/app/components/CategorySidebar.vue index 89e2f0ea..eccfb3a6 100644 --- a/apps/web/app/components/CategorySidebar.vue +++ b/apps/web/app/components/CategorySidebar.vue @@ -47,7 +47,7 @@ >
diff --git a/apps/web/app/components/CategorySidebarItems.vue b/apps/web/app/components/CategorySidebarItems.vue index 9054090d..d3aa3432 100644 --- a/apps/web/app/components/CategorySidebarItems.vue +++ b/apps/web/app/components/CategorySidebarItems.vue @@ -31,13 +31,13 @@ @click="$emit('toggle', category)" > - + {{ category.name }} diff --git a/apps/web/app/components/CommandPalette.vue b/apps/web/app/components/CommandPalette.vue index dfc57b66..49bc3211 100644 --- a/apps/web/app/components/CommandPalette.vue +++ b/apps/web/app/components/CommandPalette.vue @@ -24,7 +24,7 @@
diff --git a/apps/web/app/components/DescriptionRender.vue b/apps/web/app/components/DescriptionRender.vue index 44ce2d72..fc057965 100644 --- a/apps/web/app/components/DescriptionRender.vue +++ b/apps/web/app/components/DescriptionRender.vue @@ -7,6 +7,8 @@ WysiwygEditor's preview mode, the upload/edit pages' preview panels — should route through this component so the rendered output is byte-for-byte the same everywhere. --> + +
+ + + - {{ form.statusBadgeText || $t('admin.homepage.statusBadgePlaceholder') }} + {{ form.statusBadgeText || $t('home.live') }} + +

+ +

[ icon: 'ph:lightning-bold', url: (config.public.trackerUdpUrl as string) || '', }, - { - kind: 'ws' as const, - label: t('admin.endpoints.websocket'), - icon: 'ph:plugs-connected-bold', - url: (config.public.trackerWsUrl as string) || '', - }, ]); // Briefly swap the copy icon for a check so the user gets a visual // confirmation without us having to wire a toast. -const copied = ref<'http' | 'udp' | 'ws' | null>(null); +const copied = ref<'http' | 'udp' | null>(null); let copyTimer: ReturnType | null = null; -async function copy(kind: 'http' | 'udp' | 'ws', url: string) { +async function copy(kind: 'http' | 'udp', url: string) { if (!url) return; try { await navigator.clipboard.writeText(url); diff --git a/apps/web/app/components/admin/HomepageContent.vue b/apps/web/app/components/admin/HomepageContent.vue index 7f881ccc..e07a9997 100644 --- a/apps/web/app/components/admin/HomepageContent.vue +++ b/apps/web/app/components/admin/HomepageContent.vue @@ -136,7 +136,7 @@ const features = ref([ { title: 'Multi-Protocol', description: - 'Supports HTTP, UDP, and WebSocket protocols for maximum compatibility with all BitTorrent clients.', + 'Supports HTTP and UDP announces for maximum compatibility with all BitTorrent clients.', }, { title: 'Open Source', diff --git a/apps/web/app/components/admin/Protocols.vue b/apps/web/app/components/admin/Protocols.vue index 9cc86d9e..c2a29319 100644 --- a/apps/web/app/components/admin/Protocols.vue +++ b/apps/web/app/components/admin/Protocols.vue @@ -37,7 +37,7 @@ const props = defineProps<{ protocols: Record | undefined; }>(); -const defaultProtocols = { http: false, udp: false, ws: false }; +const defaultProtocols = { http: false, udp: false }; const activeCount = computed(() => { const p = props.protocols ?? defaultProtocols; @@ -48,7 +48,7 @@ const totalCount = computed(() => { return Object.keys(p).length; }); -const ORDER: Record = { http: 0, udp: 1, ws: 2 }; +const ORDER: Record = { http: 0, udp: 1 }; function index(name: string | number): number { return ORDER[String(name).toLowerCase()] ?? 99; } @@ -57,7 +57,6 @@ function protocolIcon(name: string | number): string { const k = String(name).toLowerCase(); if (k === 'http') return 'ph:globe-bold'; if (k === 'udp') return 'ph:lightning-bold'; - if (k === 'ws') return 'ph:plugs-connected-bold'; return 'ph:plug-bold'; } diff --git a/apps/web/app/components/admin/Shop.vue b/apps/web/app/components/admin/Shop.vue index 7f1418bc..ec76727e 100644 --- a/apps/web/app/components/admin/Shop.vue +++ b/apps/web/app/components/admin/Shop.vue @@ -372,7 +372,7 @@ role="switch" :aria-label="$t('admin.shop.fields.enabled')" :aria-checked="form.enabled" - class="relative w-9 h-5 rounded-full transition-colors flex-shrink-0" + class="relative w-9 h-5 rounded-full transition-colors shrink-0" :class="form.enabled ? 'bg-accent' : 'bg-fg-default/15'" @click="form.enabled = !form.enabled" > @@ -388,7 +388,7 @@ class="flex items-center gap-2 px-3 py-2 rounded bg-error/10 border border-error/30 text-xs text-error" role="alert" > - + {{ formError }}

diff --git a/apps/web/app/components/admin/System.vue b/apps/web/app/components/admin/System.vue index 16681c88..1e5dbe2a 100644 --- a/apps/web/app/components/admin/System.vue +++ b/apps/web/app/components/admin/System.vue @@ -54,7 +54,7 @@

-
+
diff --git a/apps/web/app/components/admin/TemplateSettings.vue b/apps/web/app/components/admin/TemplateSettings.vue index 11e875e3..40606fce 100644 --- a/apps/web/app/components/admin/TemplateSettings.vue +++ b/apps/web/app/components/admin/TemplateSettings.vue @@ -36,7 +36,7 @@ change every admin panel. The name has to come from somewhere, and duplicating the group's own string is the smallest honest way to say it. - `focus:outline-none` is deliberately absent — the app's global + `focus:outline-hidden` is deliberately absent — the app's global focus-visible ring (main.css) is the visible one, and replacing it with a 20%-opacity border left the field with no perceptible focus state at all. --> diff --git a/apps/web/app/components/admin/TorznabRateLimiting.vue b/apps/web/app/components/admin/TorznabRateLimiting.vue index 30435107..d4fcb9b8 100644 --- a/apps/web/app/components/admin/TorznabRateLimiting.vue +++ b/apps/web/app/components/admin/TorznabRateLimiting.vue @@ -59,8 +59,9 @@
+
@@ -255,8 +255,8 @@

{{ $t('components.templateEditor.previewTooLarge') }}

- -
+ +

{{ $t('components.templateEditor.previewEmpty') }}

diff --git a/apps/web/app/composables/useBranding.ts b/apps/web/app/composables/useBranding.ts index 7b130ac5..c5d273b6 100644 --- a/apps/web/app/composables/useBranding.ts +++ b/apps/web/app/composables/useBranding.ts @@ -23,6 +23,8 @@ export interface BrandingPayload { pageTitleSuffix: string | null; /** Gates the federation nav items. False when federation was never set up. */ federationEnabled: boolean; + /** Whether staff wrote site rules — the footer links to `/rules` then. */ + hasRules?: boolean; /** Les réglages du catalogue choisis par l'opérateur (voir `getCatalogueSettings`). */ catalogue?: { defaultView: 'grouped' | 'simple'; diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index 82fdbf44..6ab2b2d4 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -39,7 +39,7 @@ >
+ + + + @@ -94,7 +98,7 @@ son icône et son `title`. La recherche, elle, n'a pas de repli dans cette barre — le commentaire du formulaire l'appelle « the centerpiece ». --> -