diff --git a/.github/workflows/cf.yml b/.github/workflows/cf.yml index 77b8c83db1..6a30eb7396 100644 --- a/.github/workflows/cf.yml +++ b/.github/workflows/cf.yml @@ -1,5 +1,9 @@ name: β›… CF on: + # github.com/serverless-dns/blocklists/blob/6021f80f/.github/workflows/createUploadBlocklistFilter.yml#L4-L6 + # schedule: + # at 7:53 on 3rd, 10th, 18th, 26th of every month + # - cron: '53 7 3,10,18,26 * *' # docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow # docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#workflow_dispatch # docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_dispatchinputs @@ -48,6 +52,7 @@ on: env: GIT_REF: ${{ github.event.inputs.commit || github.ref }} + WRANGLER_VER: '3.56.0' # default is 'dev' which is really empty/no env WORKERS_ENV: '' @@ -57,8 +62,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - name: Checkout - uses: actions/checkout@v3.3.0 + - name: πŸ›’ Checkout + uses: actions/checkout@v4 with: ref: ${{ env.GIT_REF }} fetch-depth: 0 @@ -86,20 +91,33 @@ jobs: WENV: 'prod' COMMIT_SHA: ${{ github.sha }} + - name: 🌽 Cron? + if: github.event.schedule == '53 7 3,10,18,26 * *' + run: | + echo "WORKERS_ENV=${WENV}" >> $GITHUB_ENV + echo "COMMIT_SHA=${COMMIT_SHA}" >> $GITHUB_ENV + shell: bash + env: + # cron deploys always deploy to prod + WENV: 'prod' + COMMIT_SHA: ${{ github.sha }} + # npm (and node16) are installed by wrangler-action in a pre-job setup - name: πŸ— Get dependencies run: npm i - name: πŸ“š Wrangler publish # github.com/cloudflare/wrangler-action - uses: cloudflare/wrangler-action@2.0.0 + uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CF_API_TOKEN }} # input overrides env-defaults, regardless environment: ${{ env.WORKERS_ENV }} + wranglerVersion: ${{ env.WRANGLER_VER }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} - GIT_COMMIT_ID: ${{ env.GIT_REF }} + # setting CLOUDFLARE_ACCOUNT_ID no longer works + GIT_COMMIT_ID: ${{ env.COMMIT_SHA }} - name: 🎀 Notice run: | diff --git a/.github/workflows/deno-deploy.yml b/.github/workflows/deno-deploy.yml index 8e4b9622b0..5fb1f57fa5 100644 --- a/.github/workflows/deno-deploy.yml +++ b/.github/workflows/deno-deploy.yml @@ -64,7 +64,7 @@ jobs: contents: read steps: - name: 🚚 Fetch code - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.git-ref || github.ref }} fetch-depth: 0 @@ -85,17 +85,17 @@ jobs: git reset git merge origin/${BUILD_BRANCH} || : - - name: πŸ¦• Install Deno @1.29 + - name: πŸ¦• Install Deno@2.x uses: denoland/setup-deno@main with: - deno-version: 1.29.3 + deno-version: 2.x - name: πŸ“¦ Bundle up if: ${{ env.DEPLOY_MODE == 'action' }} run: | echo "::notice::do not forget to set DENO_PROJECT_NAME via github secrets!" deno task prepare - deno bundle ${IN_FILE} ${OUT_FILE} + # todo: deno bundle ${IN_FILE} ${OUT_FILE} shell: bash # github.com/denoland/deployctl/blob/febd898/action.yml @@ -103,16 +103,18 @@ jobs: - name: 🀸🏼 Deploy to deno.com id: dd if: ${{ env.DEPLOY_MODE == 'action' }} - uses: denoland/deployctl@1.4.0 + uses: denoland/deployctl@v1 with: project: ${{ env.PROJECT_NAME }} - entrypoint: ${{ env.OUT_FILE }} + # todo: if bundling, replace IN_FILE w/ OUT_FILE + entrypoint: ${{ env.IN_FILE }} - name: 🚒 Merge latest code into deploy-branch if: ${{ env.DEPLOY_MODE == 'auto' }} run: | git config --local user.name 'github-actions[bot]' git config --local user.email 'github-actions[bot]@users.noreply.github.com' + # todo: deno bundle has been deprecated git add ${OUT_FILE} git commit -m "Update bundle for ${GITHUB_SHA}" && \ echo "::notice::Pushing to ${BUILD_BRANCH}" || \ diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml index 088db6ab6f..f1628bef29 100644 --- a/.github/workflows/fly.yml +++ b/.github/workflows/fly.yml @@ -1,6 +1,10 @@ name: πŸͺ‚ Fly on: + # github.com/serverless-dns/blocklists/blob/6021f80f/.github/workflows/createUploadBlocklistFilter.yml#L4-L6 + schedule: + # at 7:53 on 2nd, 9th, 17th, 25th of every month + - cron: '53 7 2,9,17,25 * *' push: branches: - "main" @@ -77,7 +81,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🚚 Checkout - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v4 with: ref: ${{ env.GIT_REF }} fetch-depth: 0 @@ -121,6 +125,15 @@ jobs: env: COMMIT_SHA: ${{ github.sha }} + - name: 🚨🌽 Prod via cron? + if: github.event.schedule == '53 7 2,9,17,25 * *' + run: | + echo "FLY_APP=${FLY_PROD_APP}" >> $GITHUB_ENV + echo "::notice::Deploying PROD / ${GIT_REF} @ ${COMMIT_SHA}" + shell: bash + env: + COMMIT_SHA: ${{ github.sha }} + - name: πŸšœπŸ‘¨β€πŸš’ Onebox via dispatch? if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.deployment-type == 'onebox' }} diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 0000000000..5fb5c22bb9 --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -0,0 +1,107 @@ +name: πŸ”„ runc + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: "ghcr.io" + IMAGE_NAME: ${{ github.repository }} + IMAGE_NAME_BUN: ${{ github.repository }}-bun + GIT_REF: ${{ github.event.inputs.git-ref || github.ref }} + +# docs.github.com/en/actions/publishing-packages/publishing-docker-images +jobs: + nodejs: + name: πŸš€ Node on Alpine + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: 🚚 Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.GIT_REF }} + fetch-depth: 0 + + - name: πŸ” Login + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Metadata + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: πŸ›  Build + id: push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + file: ./node.Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: πŸ“• Attest + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + bunjs: + name: πŸš€ Bun on Alpine + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: 🚚 Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.GIT_REF }} + fetch-depth: 0 + + - name: πŸ” Login + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Metadata + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BUN }} + + - name: πŸ›  Build + id: push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + file: ./bun.Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: πŸ“• Attest + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BUN }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d5ab340d78..77d7712996 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🚚 Get latest code - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v4 with: # Checkout base (target) repo ref: ${{ env.GH_REF }} @@ -89,7 +89,9 @@ jobs: - name: 🚒 Push to origin? if: ${{ env.BASE_REPO == env.PR_HEAD_REPO }} run: | - git push origin HEAD:${{ env.PR_HEAD_REF }} + git push origin HEAD:$PR_REF + env: + PR_REF: ${{ env.PR_HEAD_REF }} # `GITHUB_TOKEN` owned by `github-actions[bot]` has write access to # origin in this `PR-target` workflow but not the write access to fork @@ -99,5 +101,9 @@ jobs: - name: 🚒 Push to fork? if: ${{ env.BASE_REPO != env.PR_HEAD_REPO }} run: | - git remote add fork ${{ env.PR_HEAD_REPO }} - git push fork HEAD:${{ env.PR_HEAD_REF }} + git remote add fork $PR_REPO + git push fork HEAD:$PR_REF + env: + # ref: docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + PR_REF: ${{ env.PR_HEAD_REF }} + PR_REPO: ${{ env.PR_HEAD_REPO }} diff --git a/.github/workflows/profiler.yml b/.github/workflows/profiler.yml index 03abc64574..447198d17d 100644 --- a/.github/workflows/profiler.yml +++ b/.github/workflows/profiler.yml @@ -15,13 +15,14 @@ on: default: 'main' # docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatchinputs js-runtime: - description: "proc: deno/node" + description: "proc: deno/node/bun" required: false default: 'node' type: choice options: - node - deno + - bun mode: description: "p1 (fetch) / p2 (http2) / p3 (udp/tcp)" required: false @@ -40,8 +41,9 @@ env: GIT_REF: ${{ github.event.inputs.git-ref || github.ref }} JS_RUNTIME: 'node' MAXTIME_SEC: '30s' - NODE_VER: '19.x' - DENO_VER: '1.29.3' + NODE_VER: '22.x' + DENO_VER: '2.x' + BUN_VER: '1.x' MODE: 'p1' QDOH: 'q' @@ -52,7 +54,7 @@ jobs: steps: - name: 🍌 Checkout - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v4 with: ref: ${{ env.GIT_REF }} fetch-depth: 0 @@ -66,9 +68,9 @@ jobs: JSR: ${{ github.event.inputs.js-runtime || env.JS_RUNTIME }} # docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs-or-python - - name: 🐎 Setup Node @v19 + - name: 🐎 Setup Node @v22 if: env.JS_RUNTIME == 'node' - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VER }} @@ -80,7 +82,7 @@ jobs: npm run build --if-present # deno.com/blog/deploy-static-files#example-a-statically-generated-site - - name: πŸ¦• Setup Deno @1.29.3 + - name: πŸ¦• Setup Deno @2.x if: env.JS_RUNTIME == 'deno' uses: denoland/setup-deno@main with: @@ -89,8 +91,22 @@ jobs: - name: πŸ₯ Deno deps if: env.JS_RUNTIME == 'deno' run: | + deno task prepare deno cache ./src/server-deno.ts + # bun.sh/docs/cli/install#ci-cd + - name: πŸ‡ Setup Bun @ latest + if: env.JS_RUNTIME == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # github.com/oven-sh/setup-bun + - name: πŸ₯• Bun deps + if: env.JS_RUNTIME == 'bun' + run: | + bun i + # if non-interactive, prefer apt-get: unix.stackexchange.com/a/590703 # github.com/natesales/repo # docs.github.com/en/actions/using-github-hosted-runners/customizing-github-hosted-runners#installing-software-on-ubuntu-runners diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 38c85d8126..e2c1eb66ee 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,14 +1,10 @@ -# This workflow uses actions that are not certified by GitHub. They are provided -# by a third-party and are governed by separate terms of service, privacy -# policy, and support documentation. - -name: supply-chain scorecard +name: supply-chain scorecard on: # For Branch-Protection check. Only the default branch is supported. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + # github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection branch_protection_rule: # To guarantee Maintained check is occasionally updated. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + # github.com/ossf/scorecard/blob/main/docs/checks.md#maintained schedule: - cron: '53 21 * * 2' push: @@ -30,27 +26,28 @@ jobs: # contents: read # actions: read + # ref: github.com/ossf/scorecard/blob/main/.github/workflows/scorecard-analysis.yml steps: - name: "Checkout code" - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 + uses: actions/checkout@v4 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@e38b1902ae4f44df626f11ba0734b14fb91f8f86 # v2.1.2 + uses: ossf/scorecard-action@v2.4.1 with: results_file: results.sarif results_format: sarif # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: # - you want to enable the Branch-Protection check on a *public* repository, or # - you are installing Scorecard on a *private* repository - # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # To create the PAT, follow the steps in github.com/ossf/scorecard-action#authentication-with-pat. # repo_token: ${{ secrets.SCORECARD_TOKEN }} # Public repositories: # - Publish results to OpenSSF REST API for easy access by consumers # - Allows the repository to include the Scorecard badge. - # - See https://github.com/ossf/scorecard-action#publishing-results. + # - See github.com/ossf/scorecard-action#publishing-results. # For private repositories: # - `publish_results` will always be set to `false`, regardless # of the value entered here. @@ -59,7 +56,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0 + uses: actions/upload-artifact@v4 with: name: SARIF file path: results.sarif @@ -67,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2.2.4 + uses: github/codeql-action/upload-sarif@v3.28.16 with: sarif_file: results.sarif diff --git a/.vscode/settings.json b/.vscode/settings.json index b25b899e22..a9e384c85d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,6 @@ ], "cSpell.enableFiletypes": [ "env" - ] + ], + "deno.enable": true } diff --git a/README.md b/README.md index 3329779cf2..b7b6309150 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ RethinkDNS runs `serverless-dns` in production at these endpoints: Server-side processing takes from 0 milliseconds (ms) to 2ms (median), and end-to-end latency (varies across regions and networks) is between 10ms to 30ms (median). +[FOSS United](https://fossunited.org/grants)  + +The *Rethink DNS* resolver on Fly.io is sponsored by [FOSS United](https://fossunited.org/grants). + ### Self-host Cloudflare Workers is the easiest platform to setup `serverless-dns`: @@ -57,7 +63,7 @@ cd ./serverless-dns Node: ```bash -# install node v19+ via nvm, if required +# install node v22+ via nvm, if required # https://github.com/nvm-sh/nvm#installing-and-updating wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash nvm install --lts @@ -77,7 +83,7 @@ npm update Deno: ```bash -# install deno.land v1.22+ +# install deno.land v2+ # https://github.com/denoland/deno/#install curl -fsSL https://deno.land/install.sh | sh @@ -87,7 +93,7 @@ curl -fsSL https://deno.land/install.sh | sh Fastly: ```bash -# install node v18+ via nvm, if required +# install node v22+ via nvm, if required # install the Fastly CLI # https://developer.fastly.com/learning/tools/cli diff --git a/bun.Dockerfile b/bun.Dockerfile index 91f4cde21a..8d54600ea7 100644 --- a/bun.Dockerfile +++ b/bun.Dockerfile @@ -4,7 +4,7 @@ COPY . . RUN bun build ./src/server-node.js --target node --outdir ./dist --entry-naming bun.mjs --format esm RUN export BLOCKLIST_DOWNLOAD_ONLY=true && node ./dist/bun.mjs -FROM oven/bun AS runner +FROM oven/bun:alpine AS runner # env vals persist even at run-time: archive.is/QpXp2 # and overrides fly.toml env values # get working dir in order diff --git a/deno.Dockerfile b/deno.Dockerfile index a7a7d65e6c..d356478342 100644 --- a/deno.Dockerfile +++ b/deno.Dockerfile @@ -1,6 +1,6 @@ # Based on github.com/denoland/deno_docker/blob/main/alpine.dockerfile -ARG DENO_VERSION=1.29.2 +ARG DENO_VERSION=1.44.4 ARG BIN_IMAGE=denoland/deno:bin-${DENO_VERSION} FROM ${BIN_IMAGE} AS bin diff --git a/deno.json b/deno.json index 37dbb01bac..64670e2022 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,8 @@ "allowJs": true }, "importMap": "import_map.json", - "nodeModulesDir": true, + "nodeModulesDir": "auto", + "lock": false, "lint": { "files": { "exclude": ["**/**"] diff --git a/fly.tls.toml b/fly.tls.toml index b2fbd69fc4..30240c3e91 100644 --- a/fly.tls.toml +++ b/fly.tls.toml @@ -2,7 +2,8 @@ app = "" kill_signal = "SIGINT" kill_timeout = "15s" -swap_size_mb = 152 +# swap must be disabled when using "suspend" +# swap_size_mb = 152 [build] dockerfile = "node.Dockerfile" @@ -20,11 +21,18 @@ swap_size_mb = 152 [experimental] auto_rollback = true +# community.fly.io/t/19180 +# fly.io/docs/machines/guides-examples/machine-restart-policy +[[restart]] + policy = "on-failure" + retries = 3 + # DNS over HTTPS (well, h2c and http1.1) [[services]] internal_port = 8055 protocol = "tcp" - auto_stop_machines = true + # community.fly.io/t/20672 + auto_stop_machines = "suspend" auto_start_machines = true [services.concurrency] @@ -57,7 +65,7 @@ auto_rollback = true [[services]] internal_port = 10555 protocol = "tcp" - auto_stop_machines = true + auto_stop_machines = "suspend" auto_start_machines = true [services.concurrency] diff --git a/fly.toml b/fly.toml index 40fd0455ba..c23a31e2e1 100644 --- a/fly.toml +++ b/fly.toml @@ -2,7 +2,9 @@ app = "" kill_signal = "SIGINT" kill_timeout = "15s" -swap_size_mb = 152 +# swap cannot be used with "suspend" +# community.fly.io/t/20672 +# swap_size_mb = 152 [build] dockerfile = "node.Dockerfile" @@ -16,12 +18,20 @@ swap_size_mb = 152 DENO_ENV = "production" NODE_ENV = "production" LOG_LEVEL = "info" + # in weeks + AUTO_RENEW_BLOCKLISTS_OLDER_THAN = "2" + +# community.fly.io/t/19180 +# fly.io/docs/machines/guides-examples/machine-restart-policy +[[restart]] + policy = "on-failure" + retries = 3 # DNS over HTTPS [[services]] protocol = "tcp" internal_port = 8080 - auto_stop_machines = true + auto_stop_machines = "suspend" auto_start_machines = true [[services.ports]] @@ -33,7 +43,7 @@ swap_size_mb = 152 [services.concurrency] type = "connections" hard_limit = 775 - soft_limit = 700 + soft_limit = 600 [[services.tcp_checks]] # super aggressive interval and timeout because @@ -50,7 +60,7 @@ swap_size_mb = 152 [[services]] protocol = "tcp" internal_port = 10000 - auto_stop_machines = true + auto_stop_machines = "suspend" auto_start_machines = true [[services.ports]] @@ -62,7 +72,7 @@ swap_size_mb = 152 [services.concurrency] type = "connections" hard_limit = 775 - soft_limit = 700 + soft_limit = 600 [[services.tcp_checks]] # super aggressive interval and timeout because diff --git a/import_map.json b/import_map.json index ac37fef4f6..f68cf57a01 100644 --- a/import_map.json +++ b/import_map.json @@ -1,11 +1,11 @@ { "imports": { - "buffer": "https://deno.land/std@0.177.0/node/buffer.ts", - "node:buffer": "https://deno.land/std@0.177.0/node/buffer.ts", - "os" : "https://deno.land/std@0.177.0/node/os.ts", - "process": "https://deno.land/std@0.177.0/node/process.ts", + "buffer": "node:buffer", + "os" : "node:os", + "process": "node:process", "@serverless-dns/dns-parser": "https://github.com/serverless-dns/dns-parser/raw/v2.1.2/index.js", - "@serverless-dns/lfu-cache": "https://github.com/serverless-dns/lfu-cache/raw/v3.4.1/lfu.js", - "@serverless-dns/trie/": "https://github.com/serverless-dns/trie/raw/v0.0.13/src/" + "@serverless-dns/lfu-cache": "https://github.com/serverless-dns/lfu-cache/raw/v3.5.2/lfu.js", + "@serverless-dns/trie/": "https://github.com/serverless-dns/trie/raw/v0.0.17/src/", + "@riaskov/mmap-io": "https://github.com/ARyaskov/mmap-io/raw/v1.4.3/src/" } } diff --git a/node.Dockerfile b/node.Dockerfile index 0a499f772d..6f91660e13 100644 --- a/node.Dockerfile +++ b/node.Dockerfile @@ -1,27 +1,34 @@ -FROM node:20 as setup +FROM node:22 as setup # git is required if any of the npm packages are git[hub] packages RUN apt-get update && apt-get install git -yq --no-install-suggests --no-install-recommends -WORKDIR /node-dir +WORKDIR /app COPY . . # get deps, build, bundle RUN npm i +# webpack externalizes native modules (@riaskov/mmap-io) RUN npm run build:fly # or RUN npx webpack --config webpack.fly.cjs # download blocklists and bake them in the img RUN export BLOCKLIST_DOWNLOAD_ONLY=true && node ./dist/fly.mjs +# or RUN export BLOCKLIST_DOWNLOAD_ONLY=true && node ./src/server-node.js # stage 2 -FROM node:alpine AS runner +# pin to node22 for native deps (@ariaskov/mmap-io) +FROM node:22-alpine AS runner # env vals persist even at run-time: archive.is/QpXp2 # and overrides fly.toml env values ENV NODE_ENV production -ENV NODE_OPTIONS="--max-old-space-size=320 --heapsnapshot-signal=SIGUSR2" +ENV NODE_OPTIONS="--max-old-space-size=200 --heapsnapshot-signal=SIGUSR2" # get working dir in order WORKDIR /app -COPY --from=setup /node-dir/dist ./ -COPY --from=setup /node-dir/blocklists__ ./blocklists__ -COPY --from=setup /node-dir/dbip__ ./dbip__ +# external deps not bundled by webpack +RUN npm i @riaskov/mmap-io@v1.4.3 + +COPY --from=setup /app/dist ./ +COPY --from=setup /app/blocklists__ ./blocklists__ +COPY --from=setup /app/dbip__ ./dbip__ + # print files in work dir, must contain blocklists RUN ls -Fla # run with the default entrypoint (usually, bash or sh) diff --git a/package.json b/package.json index c8374d148c..71229e3d9b 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,11 @@ "node": ">=16" }, "dependencies": { + "@riaskov/mmap-io": "^1.4.3", "@serverless-dns/dns-parser": "github:serverless-dns/dns-parser#v2.1.2", "@serverless-dns/lfu-cache": "github:serverless-dns/lfu-cache#v3.5.2", "@serverless-dns/trie": "github:serverless-dns/trie#v0.0.17", - "httpx-server": "^1.4.4", + "httpx-server": "^2.0.0", "node-polyfill-webpack-plugin": "^2.0.1", "proxy-protocol-js": "^4.0.5" }, @@ -49,10 +50,12 @@ "eslint-plugin-prettier": "^4.0.0", "husky": "^7.0.4", "lint-staged": "^12.1.4", + "node-loader": "^2.0.0", "prettier": "2.5.1", - "webpack": "^5.65.0", + "webpack": "^5.92.1", "webpack-cli": "^4.10.0", - "wrangler": "^2.1.15" + "why-is-node-running": "^3.2.0", + "wrangler": "^3.0.0" }, "lint-staged": { "*.?(m|c)js": "eslint --cache --fix", diff --git a/run b/run index f2ea1f8c30..beec07c3c9 100755 --- a/run +++ b/run @@ -40,7 +40,7 @@ cleanup() { jobs -p > jobfile j=$(cat jobfile) echo "kill... $j" - kill -INT $j + kill -INT $j || true # does not work... jobs -p | xargs -r kill -9 rm jobfile } @@ -94,21 +94,26 @@ greqs() { if [ $runtime = "help" ] || [ $runtime = "h" ]; then echo "note: make sure node / deno / wrangler are in path"; - echo "usage: $0 [node|deno|workers] [[p1|p2] [waitsec]]"; + echo "usage: $0 [node|bun|deno|workers|fly] [[p1|p2|p3] [waitsec]]"; exit 0; fi -if [ $runtime = "deno" ] || [ $runtime = "d" ]; then - echo "note: deno v1.17+ required"; +if [ $runtime = "bun" ] || [ $runtime = "b" ]; then + echo "note: bun v1+ required"; + echo "using `which bun`"; + start="bun run src/server-node.js"; +elif [ $runtime = "deno" ] || [ $runtime = "d" ]; then + echo "note: deno v2+ required"; echo "using `which deno`"; start="deno run --unstable \ + --allow-import \ --allow-env \ --allow-net \ --allow-read \ --allow-write \ src/server-deno.ts"; elif [ $runtime = "workers" ] || [ $runtime = "w" ]; then - echo "note: wrangler v1.16+ required"; + echo "note: wrangler v1.40+ required"; echo "using `which wrangler`"; start="npx wrangler dev --local"; elif [ $runtime = "fastly" ] || [ $runtime = "f" ]; then @@ -121,7 +126,7 @@ elif [ $runtime = "fly" ] || [ $runtime = "ff" ]; then export NODE_OPTIONS="--trace-warnings --max-old-space-size=320 --heapsnapshot-signal=SIGUSR2 --heapsnapshot-near-heap-limit=2" start="node ./dist/fly.mjs" else - echo "note: nodejs v19+ required"; + echo "note: nodejs v22+ required"; echo "using `which node`"; # verbose: NODE_DEBUG=http2,http,tls,net... ref: stackoverflow.com/a/46858827 export NODE_OPTIONS="--trace-warnings --max-old-space-size=320 --heapsnapshot-signal=SIGUSR2 --heapsnapshot-near-heap-limit=2" @@ -208,8 +213,8 @@ elif [ $profiler = "profile3" ] || [ $profiler = "p3" ]; then echo "Specify env QDOH path" exit 1 fi - if [ $runtime != "node" ] && [ $runtime != "n" ]; then - echo "Profile3 only valid on Node" + if [ $runtime != "node" ] && [ $runtime != "n" ] && [ $runtime != "bun" ] && [ $runtime != "b" ]; then + echo "Profile3 only valid on Node & Bun" exit 1 fi diff --git a/src/build/pre.sh b/src/build/pre.sh index 2651b110e8..24c2a8315d 100755 --- a/src/build/pre.sh +++ b/src/build/pre.sh @@ -76,7 +76,7 @@ do # TODO: check if the timestamp within the json file is more recent # file/symlink exists? stackoverflow.com/a/44679975 if [ -f "${out}" ] || [ -L "${out}" ]; then - echo "=x== pre.sh: no op" + echo "=x== pre.sh: no op ${out}" exit 0 else wget $wgetopts -q "${burl}/${yyyy}/${dir}/${mm}-${wk}/${codec}/${f}" -O "${out}" diff --git a/src/commons/bufutil.js b/src/commons/bufutil.js index 56eb33625c..e7de6eb069 100644 --- a/src/commons/bufutil.js +++ b/src/commons/bufutil.js @@ -52,7 +52,7 @@ export function hex(b) { */ export function len(b) { if (emptyBuf(b)) return 0; - return b.byteLength; + return b.byteLength || 0; } export function bytesToBase64Url(b) { @@ -112,11 +112,19 @@ export function decodeFromBinaryArray(b) { return decodeFromBinary(b, u8); } +/** + * @param {ArrayBufferLike} b + * @returns {boolean} + */ export function emptyBuf(b) { return !b || b.byteLength <= 0; } -// returns underlying buffer prop when b is TypedArray or node:Buffer +/** + * Returns underlying buffer prop when b is TypedArray or node:Buffer + * @param {Uint8Array|Buffer} b + * @returns {ArrayBufferLike} + */ export function raw(b) { if (!b || b.buffer == null) b = ZERO; @@ -169,11 +177,19 @@ export function bufferOf(arrayBuf) { return Buffer.from(new Uint8Array(arrayBuf)); } +/** + * @param {Buffer} b + * @returns {int} + */ export function recycleBuffer(b) { b.fill(0); return 0; } +/** + * @param {int} size + * @returns {Buffer} + */ export function createBuffer(size) { return Buffer.allocUnsafe(size); } diff --git a/src/commons/dnsutil.js b/src/commons/dnsutil.js index 4449b6e4ee..ca872915f8 100644 --- a/src/commons/dnsutil.js +++ b/src/commons/dnsutil.js @@ -7,9 +7,9 @@ */ import * as dnslib from "@serverless-dns/dns-parser"; +import * as bufutil from "./bufutil.js"; import * as envutil from "./envutil.js"; import * as util from "./util.js"; -import * as bufutil from "./bufutil.js"; // dns packet constants (in bytes) // tcp msgs prefixed with 2-octet headers indicating request len in bytes @@ -61,7 +61,7 @@ export function servfailQ(q) { try { const p = decode(q); return servfail(p.id, p.questions); - } catch (e) { + } catch (_) { return bufutil.ZEROAB; } } @@ -220,12 +220,12 @@ export function optAnswer(a) { return a.type.toUpperCase() === "OPT"; } -export function decode(arrayBuffer) { - if (!validResponseSize(arrayBuffer)) { - throw new Error("failed decoding an invalid dns-packet"); +export function decode(arrbuf) { + if (!validResponseSize(arrbuf)) { + throw new Error("decoding oversized dns-packet: " + bufutil.len(arrbuf)); } - const b = bufutil.bufferOf(arrayBuffer); + const b = bufutil.bufferOf(arrbuf); return dnslib.decode(b); } @@ -377,9 +377,19 @@ export function isAnswerQuad0(packet) { return isAnswerBlocked(packet.answers); } +export function ttl(packet) { + if (!hasAnswers(packet)) return 0; + return packet.answers[0].ttl || 0; +} + +/** + * @param {any} dnsPacket + * @returns {string[]} + */ export function extractDomains(dnsPacket) { if (!hasSingleQuestion(dnsPacket)) return []; + /** @type {string} */ const names = new Set(); const answers = dnsPacket.answers; @@ -416,7 +426,7 @@ export function extractDomains(dnsPacket) { export function getInterestingAnswerData(packet, maxlen = 80, delim = "|") { if (!hasAnswers(packet)) { - return !util.emptyObj(packet) ? packet.rcode || "WTF" : "WTF"; + return !util.emptyObj(packet) ? packet.rcode || "WTF1" : "WTF2"; } // set to true if at least one ip has been captured from ans @@ -535,6 +545,10 @@ export function getQueryType(packet) { return util.emptyString(qt) ? false : qt; } +/** + * @param {string?} n + * @returns {string} + */ export function normalizeName(n) { if (util.emptyString(n)) return n; diff --git a/src/commons/envutil.js b/src/commons/envutil.js index b2489caf9c..1284713c29 100644 --- a/src/commons/envutil.js +++ b/src/commons/envutil.js @@ -8,6 +8,12 @@ // musn't import /depend on anything. +export function isProd() { + if (!envManager) return false; + + return envManager.determineEnvStage() === "production"; +} + export function onFly() { if (!envManager) return false; @@ -45,6 +51,11 @@ export function hasDisk() { return onFly() || onLocal(); } +export function useMmap() { + // got disk on fly and local deploys + return (onFly() || onLocal()) && (isNode() || isBun()); +} + export function hasDynamicImports() { if (onDenoDeploy() || onCloudflare() || onFastly()) return false; return true; @@ -84,6 +95,9 @@ export function isDeno() { return envManager.r() === "deno"; } +/** + * in milliseconds + */ export function workersTimeout(missing = 0) { if (!envManager) return missing; return envManager.get("WORKER_TIMEOUT") || missing; @@ -191,14 +205,14 @@ export function isCleartext() { // sysctl get net.ipv4.tcp_syn_backlog export function tcpBacklog() { - if (!envManager) return 100; + if (!envManager) return 600; // same as fly.service soft_limit - return envManager.get("TCP_BACKLOG") || 200; + return envManager.get("TCP_BACKLOG") || 600; } // don't forget to update the fly.toml too export function maxconns() { - if (!envManager) return 1000; + if (!envManager) return 1000; // 25% higher than fly.service hard_limit return envManager.get("MAXCONNS") || 1000; } @@ -222,12 +236,10 @@ export function shutdownTimeoutMs() { } export function measureHeap() { - // disable; webpack can't bundle memwatch; see: server-node.js - return false; if (!envManager) return false; const reg = region(); if ( - reg === "maa" || + reg === "bom" || reg === "sin" || reg === "fra" || reg === "ams" || @@ -248,6 +260,12 @@ export function blocklistDownloadOnly() { return envManager.get("BLOCKLIST_DOWNLOAD_ONLY"); } +export function renewBlocklistsThresholdInWeeks() { + if (!envManager) return false; + + return envManager.get("AUTO_RENEW_BLOCKLISTS_OLDER_THAN") || -1; +} + // Ports which the services are exposed on. Corresponds to fly.toml ports. export function dohBackendPort() { return 8080; diff --git a/src/commons/util.js b/src/commons/util.js index 05cbafdb23..517942b41d 100644 --- a/src/commons/util.js +++ b/src/commons/util.js @@ -13,7 +13,8 @@ // musn't import any non-std modules export function fromBrowser(ua) { - return ua && ua.startsWith("Mozilla/5.0"); + if (emptyString(ua)) return false; + return ua.startsWith("Mozilla/5.0") || ua.startsWith("dohjs/"); } export function jsonHeaders() { @@ -83,6 +84,34 @@ export function regionFromCf(req) { return req.cf.colo || ""; } +/** + * returns true if tstamp is of form yyyy/epochMs + * ex: 2025/1740866164283 + * @param {string} tstamp + * @returns {boolean} + */ +function isValidFullTimestamp(tstamp) { + if (typeof tstamp !== "string") return false; + return tstamp.indexOf("/") === 4; +} + +/** + * from: github.com/celzero/downloads/blob/main/src/timestamp.js + * @param {string} tstamp is of form epochMs ("1740866164283") or yyyy/epochMs ("2025/1740866164283") + * @returns {int} blocklist create time (unix epoch) in millis (-1 on errors) + */ +export function bareTimestampFrom(tstamp) { + // strip out "/" if tstamp is of form yyyy/epochMs: "2025/1740866164283" + if (isValidFullTimestamp(tstamp)) { + tstamp = tstamp.split("/")[1]; + } + const t = parseInt(tstamp); + if (isNaN(t)) { + return -1; + } + return t; +} + /** * @param {Request} request - Request * @return {Object} - Headers @@ -117,7 +146,13 @@ export function objOf(map) { return map.entries ? Object.fromEntries(map) : {}; } -export function timedOp(op, ms, cleanup = () => {}) { +/** + * @param {(function((out, err) => void))} op + * @param {int} ms + * @param {function(any)} cleanup + * @returns {Promise} + */ +export function timedOp(op, ms, cleanup = (x) => {}) { return new Promise((resolve, reject) => { let timedout = false; const tid = timeout(ms, () => { @@ -142,6 +177,7 @@ export function timedOp(op, ms, cleanup = () => {}) { } }); } catch (e) { + clearTimeout(tid); if (!timedout) reject(e); } }); @@ -149,6 +185,13 @@ export function timedOp(op, ms, cleanup = () => {}) { // TODO: Use AbortSignal.timeout (supported on Node and Deno, too)? // developers.cloudflare.com/workers/platform/changelog#2021-12-10 +/** + * + * @param {(...args: any[]) => Promise<*>} promisedOp + * @param {number} ms + * @param {(...args: any[]) => Promise<*>} defaultOp + * @returns + */ export function timedSafeAsyncOp(promisedOp, ms, defaultOp) { // aggregating promises is a valid use-case for the otherwise // "deferred promise anti-pattern". That is, using promise @@ -179,13 +222,20 @@ export function timedSafeAsyncOp(promisedOp, ms, defaultOp) { resolve(out); } }) - .catch((ignored) => { + .catch((_) => { + clearTimeout(tid); if (!timedout) deferredOp(); // else: handled by timeout }); }); } +/** + * + * @param {number} ms + * @param {(...args: any[]) => void} fn + * @returns + */ export function timeout(ms, fn) { if (typeof fn !== "function") return -1; const timer = setTimeout(fn, ms); @@ -195,12 +245,24 @@ export function timeout(ms, fn) { export function repeat(ms, fn) { if (typeof fn !== "function") return -1; - setImmediate(fn); + + next(fn); + const timer = setInterval(fn, ms); if (typeof timer.unref === "function") timer.unref(); + return timer; } +export function next(...fns) { + for (const fn of fns) { + if (typeof fn === "function") { + if (typeof setImmediate === "function") setImmediate(fn); + else timeout(0, fn); + } + } +} + // min inclusive, max exclusive export function rand(min, max) { return Math.floor(Math.random() * (max - min)) + min; @@ -210,6 +272,11 @@ export function rolldice(sides = 6) { return rand(1, sides + 1); } +export function yyyymm() { + const d = new Date(); + return d.getUTCFullYear() + "/" + (d.getUTCMonth() + 1); +} + // stackoverflow.com/a/8084248 export function uid(prefix = "") { // ex: ".ww8ja208it" diff --git a/src/core/cfg.js b/src/core/cfg.js index cc24ae7e82..5d30d00221 100644 --- a/src/core/cfg.js +++ b/src/core/cfg.js @@ -7,8 +7,9 @@ */ /* eslint-disabled */ // eslint, no import-assert: github.com/eslint/eslint/discussions/15305 -import u6cfg from "../u6-basicconfig.json" assert { type: 'json' }; -import u6filetag from "../u6-filetag.json" assert { type: 'json' }; +import u6cfg from "../u6-basicconfig.json" with { type: 'json' }; +import u6filetag from "../u6-filetag.json" with { type: 'json' }; +// nodejs.org/docs/latest-v22.x/api/esm.html#json-modules export function timestamp() { return u6cfg.timestamp; @@ -17,7 +18,7 @@ export function timestamp() { export function tdNodeCount() { return u6cfg.nodecount; } - + export function tdParts() { return u6cfg.tdparts; } @@ -25,7 +26,7 @@ export function tdParts() { export function tdCodec6() { return u6cfg.useCodec6; } - + export function orig() { return u6cfg; } diff --git a/src/core/deno/blocklists.ts b/src/core/deno/blocklists.ts index e7b5074cef..db2c7c7986 100644 --- a/src/core/deno/blocklists.ts +++ b/src/core/deno/blocklists.ts @@ -13,56 +13,58 @@ import { BlocklistWrapper } from "../../plugins/rethinkdns/main.js"; const blocklistsDir = "blocklists__"; const tdFile = "td.txt"; const rdFile = "rd.txt"; +const bcFile = "basicconfig.json"; +const ftFile = "filetag.json"; -export async function setup(bw: any) { +export async function setup(bw: BlocklistWrapper) { if (!bw || !envutil.hasDisk()) return false; - const now = Date.now(); - const timestamp = cfg.timestamp() as string; - const url = envutil.blocklistUrl() + timestamp + "/"; - const nodecount = cfg.tdNodeCount() as number; - const tdparts = cfg.tdParts() as number; - const tdcodec6 = cfg.tdCodec6() as boolean; - const codec = tdcodec6 ? "u6" : "u8"; - - const ok = setupLocally(bw, timestamp, codec); + const ok = setupLocally(bw); if (ok) { - console.info("bl setup locally tstamp/nc", timestamp, nodecount); return true; } - console.info("dowloading bl url/codec?", url, codec); - await bw.initBlocklistConstruction( - /* rxid*/ "bl-download", - now, - url, - nodecount, - tdparts, - tdcodec6 - ); + console.info("dowloading blocklists"); + await bw.init(/* rxid*/ "bl-download", /* wait */ true); - save(bw, timestamp, codec); + return save(bw); } -function save(bw: BlocklistWrapper, timestamp: string, codec: string) { +function save(bw: BlocklistWrapper) { if (!bw.isBlocklistFilterSetup()) return false; + const timestamp = bw.timestamp(); + const codec = bw.codec(); + mkdirsIfNeeded(timestamp, codec); - const [tdfp, rdfp] = getFilePaths(timestamp, codec); + const [tdfp, rdfp, bcfp, ftfp] = getFilePaths(timestamp, codec); const td = bw.triedata(); const rd = bw.rankdata(); + const bc = bw.basicconfig(); + const ft = bw.filetag(); // Deno only writes uint8arrays to disk, never raw arraybuffers Deno.writeFileSync(tdfp, new Uint8Array(td)); Deno.writeFileSync(rdfp, new Uint8Array(rd)); + // write the basic config and file tag as json; may overwrite existing + Deno.writeTextFileSync(bcfp, JSON.stringify(bc)); + Deno.writeTextFileSync(ftfp, JSON.stringify(ft)); - console.info("blocklists written to disk"); + console.info("blocklist files written to disk", tdfp, rdfp, bcfp, ftfp); return true; } -function setupLocally(bw: any, ts: string, codec: string) { +/** + * Loads the blocklist files & configuration from disk, if any. + * TODO: return false if blocklists age > AUTO_RENEW_BLOCKLISTS_OLDER_THAN + */ +function setupLocally(bw: BlocklistWrapper) { + const ts = cfg.timestamp() as string; + const tdcodec6 = cfg.tdCodec6() as boolean; + const codec = tdcodec6 ? "u6" : "u8"; + if (!hasBlocklistFiles(ts, codec)) return false; const [td, rd] = getFilePaths(ts, codec); @@ -102,18 +104,26 @@ function hasBlocklistFiles(timestamp: string, codec: string) { const rdinfo = Deno.statSync(rd); return tdinfo.isFile && rdinfo.isFile; - } catch (ignored) {} + } catch (_) { + /* no-op */ + } return false; } -function getFilePaths(t: string, c: string) { +/** + * Returns the file paths for the blocklists. + * @returns {string[]} [td, rd, bc, ft] + */ +function getFilePaths(t: string, c: string): string[] { const cwd = Deno.cwd(); const td = cwd + "/" + blocklistsDir + "/" + t + "/" + c + "/" + tdFile; const rd = cwd + "/" + blocklistsDir + "/" + t + "/" + c + "/" + rdFile; + const bc = cwd + "/" + c + "-" + bcFile; + const ft = cwd + "/" + c + "-" + ftFile; - return [td, rd]; + return [td, rd, bc, ft]; } function getDirPaths(t: string, c: string) { @@ -138,7 +148,9 @@ function mkdirsIfNeeded(timestamp: string, codec: string) { dinfo1 = Deno.statSync(dir1); dinfo2 = Deno.statSync(dir2); dinfo3 = Deno.statSync(dir3); - } catch (ignored) {} + } catch (_) { + /* no-op */ + } if (!dinfo1 || !dinfo1.isDirectory) { console.info("creating dir", dir1); diff --git a/src/core/deno/config.ts b/src/core/deno/config.ts index e8829a9955..42ee59494e 100644 --- a/src/core/deno/config.ts +++ b/src/core/deno/config.ts @@ -1,21 +1,19 @@ +// deno-lint-ignore-file no-var import * as system from "../../system.js"; import * as blocklists from "./blocklists.ts"; import * as dbip from "./dbip.ts"; import { services, stopAfter } from "../svc.js"; import Log, { LogLevels } from "../log.js"; import EnvManager from "../env.js"; -import { signal } from "https://deno.land/std@0.171.0/signal/mod.ts"; // In global scope. declare global { // TypeScript must know type of every var / property. Extend Window // (globalThis) with declaration merging (archive.is/YUWh2) to define types // Ref: www.typescriptlang.org/docs/handbook/declaration-merging.html - interface Window { - envManager?: EnvManager; - log?: Log; - env?: any; - } + var envManager: EnvManager | null; + var log: Log | null; + var env: any | null; } ((main) => { @@ -23,25 +21,18 @@ declare global { system.when("steady").then(up); })(); -async function sigctrl() { - const sigs = signal("SIGINT"); - for await (const _ of sigs) { - stopAfter(); - } -} - -async function prep() { +function prep() { // if this file execs... assume we're on deno. if (!Deno) throw new Error("failed loading deno-specific config"); - const isProd = Deno.env.get("DENO_ENV") === "production"; + const isProd = Deno.env.get("DENO_ENV_DOMAIN") === "production"; const onDenoDeploy = Deno.env.get("CLOUD_PLATFORM") === "deno-deploy"; const profiling = Deno.env.get("PROFILE_DNS_RESOLVES") === "true"; - window.envManager = new EnvManager(); + globalThis.envManager = new EnvManager(); - window.log = new Log({ - level: window.envManager.get("LOG_LEVEL") as LogLevels, + globalThis.log = new Log({ + level: globalThis.envManager.get("LOG_LEVEL") as LogLevels, levelize: isProd || profiling, // levelize if prod or profiling withTimestamps: !onDenoDeploy, // do not log ts on deno-deploy }); @@ -72,7 +63,12 @@ async function up() { } else { console.warn("Config", "logpusher unavailable"); } - sigctrl(); + + // docs.deno.com/runtime/tutorials/os_signals + Deno.addSignalListener("SIGINT", () => { + stopAfter(); + }); + // signal all system are-a go system.pub("go"); } diff --git a/src/core/dns/conns.js b/src/core/dns/conns.js index d79b5c92d9..af022cee7b 100644 --- a/src/core/dns/conns.js +++ b/src/core/dns/conns.js @@ -13,20 +13,36 @@ import * as util from "../../commons/util.js"; */ export class TcpConnPool { + /** + * @param {int} size + * @param {int} ttl + */ constructor(size, ttl) { + /** @type {int} */ this.size = size; - // max sweeps per give/take + /** + * max sweeps per give/take + * @type {int} + */ this.maxsweep = Math.max((size / 4) | 0, 20); + /** @type {int} */ this.ttl = ttl; // ms const quarterttl = (ttl / 4) | 0; + /** @type {int} */ this.keepalive = Math.min(/* 60s*/ 60000, quarterttl); // ms + /** @type {int} */ this.lastSweep = 0; + /** @type {int} */ this.sweepGapMs = Math.max(/* 10s*/ 10000, quarterttl); // ms /** @type {Map} */ this.pool = new Map(); log.d("tcp-pool psz:", size, "msw:", this.maxsweep, "t:", ttl); } + /** + * @param {AnySock} socket + * @returns {boolean} + */ give(socket) { if (socket.pending) return false; if (!socket.writable) return false; @@ -40,6 +56,9 @@ export class TcpConnPool { return this.checkin(socket); } + /** + * @returns {AnySock?} + */ take() { const thres = this.maxsweep / 2; let out = null; @@ -68,9 +87,9 @@ export class TcpConnPool { } /** - * @param {import("net").Socket} sock + * @param {AnySock} sock * @param {Report} report - * @returns {import("net").Socket} + * @returns {AnySock} */ checkout(sock, report) { log.d(report.id, "checkout, size:", this.pool.size); @@ -88,6 +107,10 @@ export class TcpConnPool { return sock; } + /** + * @param {AnySock} socket + * @returns {boolean} + */ checkin(sock) { const report = this.mkreport(); @@ -102,6 +125,10 @@ export class TcpConnPool { return true; } + /** + * @param {boolean} clear + * @returns {boolean} + */ sweep(clear = false) { const sz = this.pool.size; if (sz <= 0) return false; @@ -122,11 +149,21 @@ export class TcpConnPool { return sz > this.pool.size; // size decreased post-sweep? } + /** + * @param {AnySock?} socket + * @returns {boolean} + */ ready(sock) { - return sock.readyState === "open"; + return sock && sock.readyState === "open"; } + /** + * @param {AnySock?} sock + * @param {Report} report + * @returns {boolean} + */ healthy(sock, report) { + if (!sock) return false; const destroyed = !sock.writable; const open = this.ready(sock); const fresh = report.fresh(this.ttl); @@ -136,10 +173,18 @@ export class TcpConnPool { return fresh; // healthy if not expired } + /** + * @param {AnySock} sock + * @param {Report} report + * @returns {boolean} + */ dead(sock, report) { return !this.healthy(sock, report); } + /** + * @param {AnySock?} sock + */ evict(sock) { this.pool.delete(sock); @@ -148,6 +193,7 @@ export class TcpConnPool { } catch (ignore) {} } + /** @return {Report} */ mkreport() { return new Report(util.uid("tcp")); } @@ -164,24 +210,39 @@ class Report { this.lastuse = Date.now(); } + /** @param {number} since */ fresh(since) { return this.lastuse + since >= Date.now(); } } export class UdpConnPool { + /** + * @param {int} size + * @param {int} ttl + */ constructor(size, ttl) { + /** @type {int} */ this.size = size; + /** @type {int} */ this.maxsweep = Math.max((size / 4) | 0, 20); + /** @type {int} */ this.ttl = Math.max(/* 60s*/ 60000, ttl); // no more than 60s + /** @type {int} */ this.lastSweep = 0; + /** @type {int} */ this.sweepGapMs = Math.max(/* 10s*/ 10000, (ttl / 2) | 0); // ms /** @type {Map} */ this.pool = new Map(); log.d("udp-pool psz:", size, "msw:", this.maxsweep, "t:", ttl); } + /** + * @param {AnySock?} socket + * @returns {boolean} + */ give(socket) { + if (!socket) return false; if (this.pool.has(socket)) return true; const free = this.pool.size < this.size || this.sweep(); @@ -190,6 +251,9 @@ export class UdpConnPool { return this.checkin(socket); } + /** + * @returns {AnySock?} + */ take() { const thres = this.maxsweep / 2; let out = null; @@ -217,9 +281,9 @@ export class UdpConnPool { } /** - * @param {import("dgram").Socket} sock + * @param {AnySock} sock * @param {Report} report - * @returns {import("dgram").Socket} + * @returns {AnySock} */ checkout(sock, report) { log.d(report.id, "checkout, size:", this.pool.size); @@ -231,6 +295,10 @@ export class UdpConnPool { return sock; } + /** + * @param {AnySock} socket + * @returns {boolean} + */ checkin(sock) { const report = this.mkreport(); @@ -243,6 +311,10 @@ export class UdpConnPool { return true; } + /** + * @param {boolean} clear + * @returns {boolean} + */ sweep(clear = false) { const sz = this.pool.size; if (sz <= 0) return false; @@ -263,6 +335,10 @@ export class UdpConnPool { return sz > this.pool.size; // size decreased post-sweep? } + /** + * @param {Report} report + * @returns {boolean} + */ healthy(report) { const fresh = report.fresh(this.ttl); const id = report.id; @@ -270,10 +346,17 @@ export class UdpConnPool { return fresh; // healthy if not expired } + /** + * @param {Report} report + * @returns {boolean} + */ dead(report) { return !this.healthy(report); } + /** + * @param {AnySock?} sock + */ evict(sock) { if (!sock) return; this.pool.delete(sock); @@ -282,6 +365,7 @@ export class UdpConnPool { sock.close(); } + /** @return {Report} */ mkreport() { return new Report(util.uid("udp")); } diff --git a/src/core/dns/transact.js b/src/core/dns/transact.js index 53debb5db9..cb525b6503 100644 --- a/src/core/dns/transact.js +++ b/src/core/dns/transact.js @@ -9,25 +9,44 @@ import * as bufutil from "../../commons/bufutil.js"; import * as util from "../../commons/util.js"; import * as dnsutil from "../../commons/dnsutil.js"; +/** + * @typedef {import("net").Socket} TcpSock + * @typedef {import("dgram").Socket} UdpSock + * @typedef {import("net").AddressInfo} AddrInfo + */ + // TcpTx implements a single DNS question-answer exchange over TCP. It doesn't // multiplex multiple DNS questions over the same socket. It doesn't take the // ownership of the socket, but requires exclusive use of it. The socket may // close itself on errors, however. export class TcpTx { + /** @param {TcpSock} socket */ constructor(socket) { + /** @type {TcpSock} */ this.sock = socket; - // only one transaction allowed - // then done gates all other requests - this.done = false; + /** @type {boolean} */ + this.done = false || socket == null; // done gates all other requests + /** @type {ScratchBuffer} */ // reads from the socket is buffered into scratch this.readBuffer = this.makeReadBuffer(); + /** @type {function(Buffer)} */ + this.resolve = null; + /** @type {function(string?)} */ + this.reject = null; this.log = log.withTags("TcpTx"); } + /** @param {TcpSock} sock */ static begin(sock) { return new TcpTx(sock); } + /** + * @param {string} rxid + * @param {Buffer} query + * @param {int} timeout + * @returns {Promise|null} + */ async exchange(rxid, query, timeout) { if (this.done) { this.log.w(rxid, "no exchange, tx is done"); @@ -44,7 +63,7 @@ export class TcpTx { this.onTimeout(rxid); }; const onError = (err) => { - this.onError(rxid); + this.onError(rxid, err); }; try { @@ -68,16 +87,22 @@ export class TcpTx { } } - // TODO: Same code as in server.js, merge them + /** + * @param {string} rxid + * @param {Buffer} chunk + * @returns + */ onData(rxid, chunk) { + const cl = bufutil.len(chunk); + + // TODO: Same code as in server.js, merge them if (this.done) { - this.log.w(rxid, "on reads, tx is closed for business"); + this.log.w(rxid, "on reads, tx closed; discard", cl); return chunk; } const sb = this.readBuffer; - const cl = chunk.byteLength; if (cl <= 0) return; // read header first which contains length(dns-query) @@ -130,22 +155,23 @@ export class TcpTx { } // continue reading from socket } - onClose(err) { + onClose(rxid, err) { if (this.done) return; // no-op - return err ? this.no("error") : this.no("close"); + return err ? this.no(err.message) : this.no("close"); } - onError(err) { + onError(rxid, err) { if (this.done) return; // no-op this.log.e(rxid, "udp err", err.message); this.no(err.message); } - onTimeout() { + onTimeout(rxid) { if (this.done) return; // no-op this.no("timeout"); } + /** @returns {Promise} */ promisedRead() { const that = this; return new Promise((resolve, reject) => { @@ -154,44 +180,62 @@ export class TcpTx { }); } + /** + * @param {string} rxid + * @param {Buffer} query + */ write(rxid, query) { + const qlen = bufutil.len(query); if (this.done) { - this.log.w(rxid, "no writes, tx is done working"); + this.log.w(rxid, "no writes, tx is done; discard", qlen); return query; } const header = bufutil.createBuffer(dnsutil.dnsHeaderSize); + const hlen = bufutil.len(header); bufutil.recycleBuffer(header); - header.writeUInt16BE(query.byteLength); + header.writeUInt16BE(qlen); this.sock.write(header, () => { - this.log.d(rxid, "len(header):", header.byteLength); + this.log.d(rxid, "tcp write hdr:", hlen); }); this.sock.write(query, () => { - this.log.d(rxid, "len(query):", query.byteLength); + this.log.d(rxid, "tcp write q:", qlen); }); } + /** + * @param {Buffer} val + */ yes(val) { this.done = true; this.resolve(val); } + /** + * @param {string?|Error} reason + */ no(reason) { this.done = true; this.reject(reason); } + /** @returns {ScratchBuffer} */ makeReadBuffer() { - const qlenBuf = bufutil.createBuffer(dnsutil.dnsHeaderSize); - const qlenBufOffset = bufutil.recycleBuffer(qlenBuf); - - return { - qlenBuf: qlenBuf, - qlenBufOffset: qlenBufOffset, - qBuf: null, - qBufOffset: 0, - }; + return new ScratchBuffer(); + } +} + +class ScratchBuffer { + constructor() { + /** @type {Buffer} */ + this.qlenBuf = bufutil.createBuffer(dnsutil.dnsHeaderSize); + /** @type {int} */ + this.qlenBufOffset = bufutil.recycleBuffer(this.qlenBuf); + /** @type {Buffer} */ + this.qBuf = null; + /** @type {int} */ + this.qBufOffset = 0; } } @@ -200,19 +244,32 @@ export class TcpTx { // ownership of the socket, but requires exclusive access to it. The socket // may close itself on errors, however. export class UdpTx { + /** @param {UdpSock} socket */ constructor(socket) { + /** @type {UdpSock} */ this.sock = socket; - // only one transaction allowed - this.done = false; - // ticks socket io timeout - this.timeoutTimerId = null; + /** @type {boolean} */ + this.done = false || socket == null; // only one transaction allowed + /** @type {NodeJS.Timeout|-1} */ + this.timeoutTimerId = null; // ticks socket io timeout + /** @type {function(Buffer)} */ + this.resolve = null; + /** @type {function(string)} */ + this.reject = null; this.log = log.withTags("UdpTx"); } + /** @param {UdpSock} sock */ static begin(sock) { return new UdpTx(sock); } + /** + * @param {string} rxid + * @param {Buffer} query + * @param {int} timeout + * @returns {Promise|null} + */ async exchange(rxid, query, timeout) { if (this.done) { this.log.w(rxid, "no exchange, tx is done"); @@ -246,15 +303,26 @@ export class UdpTx { } } + /** + * @param {string} rxid + * @param {Buffer} query + * @returns + */ write(rxid, query) { if (this.done) return; // discard - this.log.d(rxid, "udp write"); + this.log.d(rxid, "udp write", bufutil.len(query)); this.sock.send(query); // err-on-write handled by onError } + /** + * @param {string} rxid + * @param {Buffer} b + * @param {AddrInfo} addrinfo + * @returns + */ onMessage(rxid, b, addrinfo) { if (this.done) return; // discard - this.log.d(rxid, "udp read"); + this.log.d(rxid, "udp read", bufutil.len(b)); this.yes(b); } @@ -270,6 +338,10 @@ export class UdpTx { return err ? this.no("error") : this.no("close"); } + /** + * @param {int} timeout + * @returns {Promise} + */ promisedRead(timeout = 0) { const that = this; if (timeout > 0) { @@ -283,6 +355,7 @@ export class UdpTx { }); } + /** @param {Buffer} val */ yes(val) { if (this.done) return; @@ -291,6 +364,7 @@ export class UdpTx { this.resolve(val); } + /** @param {string|Error} reason */ no(reason) { if (this.done) return; diff --git a/src/core/doh.js b/src/core/doh.js index 619815c3af..f3ab3481d7 100644 --- a/src/core/doh.js +++ b/src/core/doh.js @@ -6,11 +6,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import RethinkPlugin from "./plugin.js"; -import * as pres from "../plugins/plugin-response.js"; -import * as util from "../commons/util.js"; import * as dnsutil from "../commons/dnsutil.js"; +import * as util from "../commons/util.js"; +import * as pres from "../plugins/plugin-response.js"; import IOState from "./io-state.js"; +import RethinkPlugin from "./plugin.js"; // TODO: define FetchEventLike /** @@ -45,9 +45,9 @@ async function proxyRequest(event) { } await util.timedSafeAsyncOp( - /* op*/ async () => plugin.execute(), + /* op*/ () => plugin.execute(), /* waitMs*/ dnsutil.requestTimeout(), - /* onTimeout*/ async () => errorResponse(io) + /* onTimeout*/ () => Promise.resolve(errorResponse(io)) ); } catch (err) { log.e("doh", "proxy-request error", err.stack); @@ -61,11 +61,20 @@ function optionsRequest(request) { return request.method === "OPTIONS"; } +/** + * @param {IOState} io + * @param {Error} err + */ function errorResponse(io, err = null) { const eres = pres.errResponse("doh.js", err); io.dnsExceptionResponse(eres); } +/** + * @param {IOState} io + * @param {string} ua + * @returns {Response} + */ function withCors(io, ua) { if (util.fromBrowser(ua)) io.setCorsHeadersIfNeeded(); return io.httpResponse; diff --git a/src/core/env.js b/src/core/env.js index b2823ba928..b6b48f807c 100644 --- a/src/core/env.js +++ b/src/core/env.js @@ -31,8 +31,9 @@ const defaults = new Map( type: "string", default: "development", }, - // the env stage deno is running in - DENO_ENV: { + // the env stage deno is running in; "deno_env" seems to name-conflict + // github.com/serverless-dns/serverless-dns/issues/185 + DENO_ENV_DOMAIN: { type: "string", default: "development", }, @@ -46,6 +47,11 @@ const defaults = new Map( type: "string", default: "development", }, + // the env stage bun is running in + BUN_ENV: { + type: "string", + default: "development", + }, // the cloud-platform code is deployed on (cloudflare, fly, deno-deploy, fastly) CLOUD_PLATFORM: { type: "string", @@ -145,6 +151,11 @@ const defaults = new Map( type: "boolean", default: false, }, + // auto renew blocklists if they are older than these many weeks + AUTO_RENEW_BLOCKLISTS_OLDER_THAN: { + type: "number", + default: 42, // in weeks; negative or 0 means, never auto-renew + }, // courtesy db-ip.com/db/download/ip-to-country-lite GEOIP_URL: { type: "string", @@ -292,7 +303,7 @@ export default class EnvManager { if (this.runtime === "node") return this.get("NODE_ENV"); if (this.runtime === "bun") return this.get("BUN_ENV"); if (this.runtime === "worker") return this.get("WORKER_ENV"); - if (this.runtime === "deno") return this.get("DENO_ENV"); + if (this.runtime === "deno") return this.get("DENO_ENV_DOMAIN"); if (this.runtime === "fastly") return this.get("FASTLY_ENV"); return null; } diff --git a/src/core/io-state.js b/src/core/io-state.js index 1c56ff5494..bd33e9f7a2 100644 --- a/src/core/io-state.js +++ b/src/core/io-state.js @@ -8,22 +8,36 @@ import * as bufutil from "../commons/bufutil.js"; import * as dnsutil from "../commons/dnsutil.js"; +import * as envutil from "../commons/envutil.js"; import * as util from "../commons/util.js"; export default class IOState { constructor() { + /** @type {string} */ this.flag = ""; + /** @type {any} */ this.decodedDnsPacket = this.emptyDecodedDnsPacket(); - /** @type {Response} */ - this.httpResponse = undefined; + /** @type {Response?} */ + this.httpResponse = null; + /** @type {boolean} */ + this.isProd = envutil.isProd(); + /** @type {boolean} */ this.isException = false; - this.exceptionStack = undefined; + /** @type {string} */ + this.exceptionStack = null; + /** @type {string} */ this.exceptionFrom = ""; + /** @type {boolean} */ this.isDnsBlock = false; + /** @type {boolean} */ this.alwaysGatewayAnswer = false; + /** @type {string} */ this.gwip4 = ""; + /** @type {string} */ this.gwip6 = ""; + /** @type {string} */ this.region = ""; + /** @type {boolean} */ this.stopProcessing = false; this.log = log.withTags("IOState"); } @@ -80,11 +94,13 @@ export default class IOState { exceptionFrom: this.exceptionFrom, exceptionStack: this.exceptionStack, }; + this.decodedDnsPacket = dnsutil.decode(servfail); + this.logDnsPkt(); this.httpResponse = new Response(servfail, { headers: util.concatHeaders( this.headers(servfail), - this.additionalHeader(JSON.stringify(ex)) + this.debugHeaders(JSON.stringify(ex)) ), status: servfail ? 200 : 408, // rfc8484 section-4.2.1 }); @@ -123,11 +139,24 @@ export default class IOState { this.decodedDnsPacket = dnsPacket || dnsutil.decode(arrayBuffer); } + this.logDnsPkt(); this.httpResponse = new Response(arrayBuffer, { headers: this.headers(arrayBuffer), }); } + logDnsPkt() { + if (this.isProd) return; + this.log.d( + "domains", + dnsutil.extractDomains(this.decodedDnsPacket), + dnsutil.getQueryType(this.decodedDnsPacket) || "", + "data", + dnsutil.getInterestingAnswerData(this.decodedDnsPacket), + dnsutil.ttl(this.decodedDnsPacket) + ); + } + dnsBlockResponse(blockflag) { this.initDecodedDnsPacketIfNeeded(); this.stopProcessing = true; @@ -148,7 +177,7 @@ export default class IOState { this.httpResponse = new Response(null, { headers: util.concatHeaders( this.headers(), - this.additionalHeader(JSON.stringify(this.exceptionStack)) + this.debugHeaders(JSON.stringify(this.exceptionStack)) ), status: 503, }); @@ -174,7 +203,7 @@ export default class IOState { this.httpResponse = new Response(null, { headers: util.concatHeaders( this.headers(), - this.additionalHeader(JSON.stringify(this.exceptionStack)) + this.debugHeaders(JSON.stringify(this.exceptionStack)) ), status: 503, }); @@ -182,8 +211,11 @@ export default class IOState { } headers(b = null) { - const xNileFlags = this.isDnsBlock ? { "x-nile-flags": this.flag } : null; - const xNileFlagsOk = !xNileFlags ? { "x-nile-flags-dn": this.flag } : null; + const hasBlockFlag = !util.emptyString(this.flag); + const isBlocked = hasBlockFlag && this.isDnsBlock; + const couldBlock = hasBlockFlag && !this.isDnsBlock; + const xNileFlags = isBlocked ? { "x-nile-flags": this.flag } : null; + const xNileFlagsOk = couldBlock ? { "x-nile-flags-dn": this.flag } : null; const xNileRegion = !util.emptyString(this.region) ? { "x-nile-region": this.region } : null; @@ -191,13 +223,15 @@ export default class IOState { return util.concatHeaders( util.dnsHeaders(), util.contentLengthHeader(b), + this.cacheHeaders(), xNileRegion, xNileFlags, xNileFlagsOk ); } - additionalHeader(json) { + debugHeaders(json) { + if (this.isProd) return null; if (!json) return null; return { @@ -215,6 +249,16 @@ export default class IOState { } } + // set cache from ttl in decoded-dns-packet + cacheHeaders() { + const ttl = dnsutil.ttl(this.decodedDnsPacket); + if (ttl <= 0) return null; + + return { + "cache-control": "public, max-age=" + ttl, + }; + } + assignBlockResponse() { let done = this.initFlagsAndAnswers(); done = done && this.addData(); diff --git a/src/core/linux/swap.js b/src/core/linux/swap.js deleted file mode 100644 index 724cfa0e12..0000000000 --- a/src/core/linux/swap.js +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2021 RethinkDNS and its authors. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -import { spawnSync } from "node:child_process"; - -const swapfile = "swap__"; -const swapsize = "152M"; - -// linuxize.com/post/create-a-linux-swap-file -export function mkswap() { - return ( - !hasanyswap() && - sh("fallocate", ["-l", swapsize, swapfile]) && - sh("chmod", ["600", swapfile]) && - sh("mkswap", [swapfile]) && - sh("swapon", [swapfile]) && - sh("sysctl", ["vm.swappiness=20"]) - ); -} - -export function rmswap() { - return hasswap() && sh("swapoff", ["-v", swapfile]) && sh("rm", [swapfile]); -} - -function hasanyswap() { - // cat /proc/swaps - // Filename Type Size Used Priority - // /swap__ file 155644 99968 -2 - const pswaps = shout("cat", ["/proc/swaps"]); - const lines = pswaps && pswaps.split("\n"); - return lines && lines.length > 1; -} - -// stackoverflow.com/a/53222213 -function hasswap() { - return sh("test", ["-e", swapfile]); -} - -function shout(cmd, args) { - return shx(cmd, args, true); -} - -function sh(cmd, args) { - return shx(cmd, args) === 0; -} - -function shx(cmd, args, out = false) { - if (!cmd) return false; - args = args || []; - const opts = { - cwd: "/", - uid: 0, - shell: true, - encoding: "utf8", - }; - const proc = spawnSync(cmd, args, opts); - if (proc.error) log.i(cmd, args, opts, "error", proc.error); - if (proc.stderr) log.e(cmd, args, opts, proc.stderr); - if (proc.stdout) log.l(proc.stdout); - return !out ? proc.status : proc.stdout; -} diff --git a/src/core/log.js b/src/core/log.js index 7a22b580a8..a1daa79b3b 100644 --- a/src/core/log.js +++ b/src/core/log.js @@ -9,7 +9,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { uid, stub } from "../commons/util.js"; +import { stub } from "../commons/util.js"; /** * @typedef {'error'|'logpush'|'warn'|'info'|'timer'|'debug'} LogLevels @@ -82,9 +82,6 @@ export default class Log { _resetLevel() { this.d = stub(); this.debug = stub(); - this.lapTime = stub(); - this.startTime = stub(); - this.endTime = stub(); this.i = stub(); this.info = stub(); this.w = stub(); @@ -94,42 +91,29 @@ export default class Log { } withTags(...tags) { - const that = this; return { - lapTime: (n, ...r) => { - return that.lapTime(n, ...tags, ...r); - }, - startTime: (n, ...r) => { - const tid = that.startTime(n); - that.d(that.now() + " T", ...tags, "create", tid, ...r); - return tid; - }, - endTime: (n, ...r) => { - that.d(that.now() + " T", ...tags, "end", n, ...r); - return that.endTime(n); - }, d: (...args) => { - that.d(that.now() + " D", ...tags, ...args); + this.d(this.now() + " D", ...tags, ...args); }, i: (...args) => { - that.i(that.now() + " I", ...tags, ...args); + this.i(this.now() + " I", ...tags, ...args); }, w: (...args) => { - that.w(that.now() + " W", ...tags, ...args); + this.w(this.now() + " W", ...tags, ...args); }, e: (...args) => { - that.e(that.now() + " E", ...tags, ...args); + this.e(this.now() + " E", ...tags, ...args); }, q: (...args) => { - that.l(that.now() + " Q", ...tags, ...args); + this.l(this.now() + " Q", ...tags, ...args); }, qStart: (...args) => { - that.l(that.now() + " Q", ...tags, that.border()); - that.l(that.now() + " Q", ...tags, ...args); + this.l(this.now() + " Q", ...tags, this.border()); + this.l(this.now() + " Q", ...tags, ...args); }, qEnd: (...args) => { - that.l(that.now() + " Q", ...tags, ...args); - that.l(that.now() + " Q", ...tags, that.border()); + this.l(this.now() + " Q", ...tags, ...args); + this.l(this.now() + " Q", ...tags, this.border()); }, tag: (t) => { tags.push(t); @@ -163,13 +147,7 @@ export default class Log { this.d = console.debug; this.debug = console.debug; case "timer": - this.lapTime = console.timeLog || stub(); // Stubbing required for Fastly as they do not currently support this method. - this.startTime = function (name) { - name = uid(name); - if (console.time) console.time(name); - return name; - }; - this.endTime = console.timeEnd || stub(); // Stubbing required for Fastly as they do not currently support this method. + // deprecated; fallthrough case "info": this.i = console.info; this.info = console.info; diff --git a/src/core/node/blocklists.js b/src/core/node/blocklists.js index 111914f53d..1bd3cbe2c8 100644 --- a/src/core/node/blocklists.js +++ b/src/core/node/blocklists.js @@ -5,74 +5,128 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + import * as fs from "node:fs"; import * as path from "node:path"; import * as bufutil from "../../commons/bufutil.js"; import * as envutil from "../../commons/envutil.js"; import * as cfg from "../../core/cfg.js"; +import { BlocklistWrapper } from "../../plugins/rethinkdns/main.js"; +// import mmap from "@riaskov/mmap-io"; const blocklistsDir = "./blocklists__"; const tdFile = "td.txt"; const rdFile = "rd.txt"; +const bcFile = "basicconfig.json"; +const ftFile = "filetag.json"; +/** + * + * @param {BlocklistWrapper} bw + * @returns + */ export async function setup(bw) { if (!bw || !envutil.hasDisk()) return false; - const now = Date.now(); - // timestamp is of form yyyy/epochMs - const timestamp = cfg.timestamp(); - const url = envutil.blocklistUrl() + timestamp + "/"; - const nodecount = cfg.tdNodeCount(); - const tdparts = cfg.tdParts(); - const tdcodec6 = cfg.tdCodec6(); - const codec = tdcodec6 ? "u6" : "u8"; - - const ok = setupLocally(bw, timestamp, codec); + const ok = await setupLocally(bw); if (ok) { - log.i("bl setup locally tstamp/nc", timestamp, nodecount); return true; } - log.i("dowloading bl u/u6?/nc/parts", url, tdcodec6, nodecount, tdparts); - await bw.initBlocklistConstruction( - /* rxid*/ "bl-download", - now, - url, - nodecount, - tdparts, - tdcodec6 - ); + log.i("dowloading blocklists"); + await bw.init(/* rxid */ "bl-download", /* wait */ true); - return save(bw, timestamp, codec); + return save(bw); } -function save(bw, timestamp, codec) { +/** + * @param {BlocklistWrapper} bw + * @returns {boolean} + */ +function save(bw) { if (!bw.isBlocklistFilterSetup()) return false; + const timestamp = bw.timestamp(); + const codec = bw.codec(); mkdirsIfNeeded(timestamp, codec); - const [tdfp, rdfp] = getFilePaths(timestamp, codec); + const [tdfp, rdfp, bcfp, ftfp] = getFilePaths(timestamp, codec); const td = bw.triedata(); const rd = bw.rankdata(); + const filetag = bw.filetag(); + const basicconfig = bw.basicconfig(); // write out array-buffers to disk fs.writeFileSync(tdfp, bufutil.bufferOf(td)); fs.writeFileSync(rdfp, bufutil.bufferOf(rd)); + // write out json objects to disk; may overwrite existing files + fs.writeFileSync(ftfp, JSON.stringify(filetag)); + fs.writeFileSync(bcfp, JSON.stringify(basicconfig)); - log.i("blocklists written to disk"); + log.i("blocklist files written to disk", tdfp, rdfp, bcfp, ftfp); return true; } -function setupLocally(bw, timestamp, codec) { +/** + * fmmap mmaps file at fp for random reads, returns a Buffer backed by the file. + * @param {string} fp + * @returns {Buffer?} + */ +async function fmmap(fp) { + const dynimports = envutil.hasDynamicImports(); + const isNode = envutil.isNode(); + const isBun = envutil.isBun(); + const isDeno = envutil.isDeno(); + + if (dynimports && isNode) { + try { + const mmap = (await import("@riaskov/mmap-io")).default; + const fd = fs.openSync(fp, "r+"); + const fsize = fs.fstatSync(fd).size; + const rxprot = mmap.PROT_READ; // protection + const mpriv = mmap.MAP_SHARED; // privacy + const madv = mmap.MADV_RANDOM; // madvise + const offset = 0; + log.i("mmap f:", fp, "size:", fsize, "\nNOTE: md5 checks will fail"); + return mmap.map(fsize, rxprot, mpriv, fd, offset, madv); + } catch (ex) { + log.e("mmap f:", fp, "import failed", ex); + return null; + } + } else if (isBun) { + log.i("mmap f:", fp, "on bun"); + return Bun.mmap(fp); + } else if (isDeno) { + log.i("mmap f:", fp, "unavailable on deno"); + } + return null; +} + +/** + * setupLocally loads blocklist files and configurations from disk. + * TODO: return false if blocklists age > AUTO_RENEW_BLOCKLISTS_OLDER_THAN + * @param {BlocklistWrapper} bw + * @returns + */ +async function setupLocally(bw) { + // timestamp is of form yyyy/epochMs + const timestamp = cfg.timestamp(); + const tdcodec6 = cfg.tdCodec6(); + const codec = tdcodec6 ? "u6" : "u8"; + const useMmap = envutil.useMmap(); + const ok = hasBlocklistFiles(timestamp, codec); log.i(timestamp, codec, "has bl files?", ok); if (!ok) return false; const [td, rd] = getFilePaths(timestamp, codec); - log.i("on-disk codec/td/rd", codec, td, rd); + log.i("on-disk ts/codec/td/rd", timestamp, codec, td, rd, "mmap?", useMmap); - const tdbuf = fs.readFileSync(td); + let tdbuf = useMmap ? await fmmap(td) : null; + if (bufutil.emptyBuf(tdbuf)) { + tdbuf = fs.readFileSync(td); + } const rdbuf = fs.readFileSync(rd); // TODO: file integrity checks @@ -98,11 +152,24 @@ function hasBlocklistFiles(timestamp, codec) { return fs.existsSync(td) && fs.existsSync(rd); } +/** + * + * @param {string} t + * @param {string} codec + * @returns {string[]} array of file paths [td, rd, bc, ft] + */ function getFilePaths(t, codec) { const td = blocklistsDir + "/" + t + "/" + codec + "/" + tdFile; const rd = blocklistsDir + "/" + t + "/" + codec + "/" + rdFile; - - return [path.normalize(td), path.normalize(rd)]; + const bc = codec + "-" + bcFile; + const ft = codec + "-" + ftFile; + + return [ + path.normalize(td), + path.normalize(rd), + path.normalize(bc), + path.normalize(ft), + ]; } function getDirPaths(t, codec) { diff --git a/src/core/node/config.js b/src/core/node/config.js index 59efdfa4fa..89520bd851 100644 --- a/src/core/node/config.js +++ b/src/core/node/config.js @@ -13,15 +13,14 @@ */ import { atob, btoa } from "node:buffer"; import process from "node:process"; -import * as util from "./util.js"; -import * as blocklists from "./blocklists.js"; -import * as dbip from "./dbip.js"; -import Log from "../log.js"; +import * as dnst from "../../core/node/dns-transport.js"; import * as system from "../../system.js"; -import { services, stopAfter } from "../svc.js"; import EnvManager from "../env.js"; -import * as swap from "../linux/swap.js"; -import * as dnst from "../../core/node/dns-transport.js"; +import Log from "../log.js"; +import { services, stopAfter } from "../svc.js"; +import * as blocklists from "./blocklists.js"; +import * as dbip from "./dbip.js"; +import * as util from "./util.js"; // some of the cjs node globals aren't available in esm // nodejs.org/docs/latest/api/globals.html @@ -31,7 +30,7 @@ import * as dnst from "../../core/node/dns-transport.js"; // globalThis.__filename = fileURLToPath(import.meta.url); // globalThis.__dirname = path.dirname(__filename); -(async (main) => { +((main) => { system.when("prepare").then(prep); system.when("steady").then(up); })(); @@ -102,7 +101,7 @@ async function prep() { log.i("dev (local) tls setup from tls_key_path", l1, l2); } catch (ex) { // this can happen when running server in BLOCKLIST_DOWNLOAD_ONLY mode - log.w("Skipping TLS: test TLS crt/key missing; enable TLS offload"); + log.w("Skipping TLS: test TLS crt/key missing; enable TLS offload", ex); tlsoffload = true; } } @@ -120,18 +119,10 @@ async function prep() { // TODO: move dns* related settings to env // flydns is always ipv6 (fdaa::53) const plainOldDnsIp = onFly ? "fdaa::3" : "1.1.1.2"; - let dns53 = null; - /** swap space and recursive resolver on Fly */ - if (onFly || true) { - const ok = swap.mkswap(); - log.i("mkswap done?", ok); - dns53 = dnst.makeTransport(plainOldDnsIp); - log.i("imported udp/tcp dns transport", plainOldDnsIp); - } else { - log.i("no swap required"); - } + const dns53 = dnst.makeTransport(plainOldDnsIp); + log.i("imported udp/tcp dns transport", plainOldDnsIp); - /** signal ready */ + // signal ready system.pub("ready", [dns53]); } @@ -165,6 +156,8 @@ async function up() { process.on("SIGINT", (sig) => stopAfter()); + process.on("warning", (e) => console.warn(e.stack)); + // signal all system are-a go system.pub("go"); } diff --git a/src/core/node/dns-transport.js b/src/core/node/dns-transport.js index 12ff0067ae..490c181060 100644 --- a/src/core/node/dns-transport.js +++ b/src/core/node/dns-transport.js @@ -11,6 +11,19 @@ import * as util from "../../commons/util.js"; import { TcpConnPool, UdpConnPool } from "../dns/conns.js"; import { TcpTx, UdpTx } from "../dns/transact.js"; +/** + * @typedef {import("net").Socket | import("dgram").Socket} AnySock + * @typedef {import("net").Socket} TcpSock + * @typedef {import("dgram").Socket} UdpSock + */ + +/** + * + * @param {string} host + * @param {int} port + * @param {any} opts + * @returns {Transport} + */ export function makeTransport(host, port = 53, opts = {}) { return new Transport(host, port, opts); } @@ -25,14 +38,21 @@ export function makeTransport(host, port = 53, opts = {}) { export class Transport { constructor(host, port, opts = {}) { if (util.emptyString(host)) throw new Error("invalid host" + host); + /** @type {string} */ this.host = host; + /** @type {int} */ this.port = port || 53; + /** @type {int} */ this.connectTimeout = opts.connectTimeout || 3000; // 3s + /** @type {int} */ this.ioTimeout = opts.ioTimeout || 10000; // 10s + /** @type {int} */ this.ipproto = net.isIP(host); // 4, 6, or 0 const sz = opts.poolSize || 500; // conns const ttl = opts.poolTtl || 60000; // 1m + /** @type {TcpConnPool} */ this.tcpconns = new TcpConnPool(sz, ttl); + /** @type {UdpConnPool} */ this.udpconns = new UdpConnPool(sz, ttl); this.log = log.withTags("DnsTransport"); @@ -45,52 +65,55 @@ export class Transport { this.log.i("transport teardown (tcp | udp) done?", r1, "|", r2); } + /** + * @param {string} rxid + * @param {Buffer} q + * @returns {Promise|null} + */ async udpquery(rxid, q) { let sock = this.udpconns.take(); this.log.d(rxid, "udp pooled?", sock !== null); - const t = this.log.startTime("udp-query"); + /** @type {Buffer?} */ let ans = null; try { sock = sock || (await this.makeConn("udp")); - this.log.lapTime(t, rxid, "make-conn"); - ans = await UdpTx.begin(sock).exchange(rxid, q, this.ioTimeout); - this.log.lapTime(t, rxid, "get-ans"); - this.parkConn(sock, "udp"); } catch (ex) { this.closeUdp(sock); this.log.e(rxid, ex); } - this.log.endTime(t); - return ans; } + /** + * @param {string} rxid + * @param {Buffer} q + * @returns {Promise|null} + */ async tcpquery(rxid, q) { let sock = this.tcpconns.take(); - this.log.d(rxid, "tcp pooled?", sock !== null); + this.log.d(rxid, "tcp pooled?", sock != null); - const t = this.log.startTime("tcp-query"); + /** @type {Buffer?} */ let ans = null; try { sock = sock || (await this.makeConn("tcp")); - log.lapTime(t, rxid, "make-conn"); - ans = await TcpTx.begin(sock).exchange(rxid, q, this.ioTimeout); - log.lapTime(t, rxid, "get-ans"); - this.parkConn(sock, "tcp"); } catch (ex) { this.closeTcp(sock); this.log.e(rxid, ex); } - this.log.endTime(t); return ans; } + /** + * @param {AnySock} sock + * @param {string} proto + */ parkConn(sock, proto) { if (proto === "tcp") { const ok = this.tcpconns.give(sock); @@ -101,6 +124,11 @@ export class Transport { } } + /** + * @param {string} proto + * @returns {Promise} + * @throws {Error} + */ makeConn(proto) { if (proto === "tcp") { const tcpconnect = (cb) => { @@ -128,17 +156,18 @@ export class Transport { } /** - * @param {import("net").Socket} sock + * @param {TcpSock?} sock */ closeTcp(sock) { + if (!sock) return; // the socket is not expected to have any error-listeners // so we add one to avoid unhandled errors sock.on("error", util.stub); - if (sock && !sock.destroyed) sock.destroySoon(); + if (!sock.destroyed) sock.destroySoon(); } /** - * @param {import("dgram").Socket} sock + * @param {UdpSock?} sock */ closeUdp(sock) { if (!sock || sock.destroyed) return; diff --git a/src/core/plugin.js b/src/core/plugin.js index 12c6bdbeea..a64d05dd8e 100644 --- a/src/core/plugin.js +++ b/src/core/plugin.js @@ -53,7 +53,7 @@ export default class RethinkPlugin { this.registerPlugin( "userOp", services.userOp, - ["rxid", "request", "isDnsMsg"], + ["rxid", "request", "requestDecodedDnsPacket", "isDnsMsg"], this.userOpCallback ); @@ -142,10 +142,7 @@ export default class RethinkPlugin { async execute() { const io = this.io; - const rxid = this.ctx.get("rxid"); - - const t = this.log.startTime("exec-plugin-" + rxid); - + // const rxid = this.ctx.get("rxid"); for (const p of this.plugin) { if (io.stopProcessing && !p.continueOnStopProcess) { continue; @@ -154,19 +151,12 @@ export default class RethinkPlugin { continue; } - this.log.lapTime(t, rxid, p.name, "send-io"); - const res = await p.module.exec(makectx(this.ctx, p.pctx)); - this.log.lapTime(t, rxid, p.name, "got-res"); - if (typeof p.callback === "function") { await p.callback.call(this, res, io); } - - this.log.lapTime(t, rxid, p.name, "post-callback"); } - this.log.endTime(t); } /** @@ -187,7 +177,7 @@ export default class RethinkPlugin { /** * Adds "userBlocklistInfo", "userBlocklistInfo", and "dnsResolverUrl" * to RethinkPlugin ctx. - * @param {RResp} response - Contains data: userBlocklistInfo / userBlockstamp + * @param {RResp} response * @param {IOState} io */ async userOpCallback(response, io) { diff --git a/src/core/workers/config.js b/src/core/workers/config.js index 34c920f71d..b765a5bce0 100644 --- a/src/core/workers/config.js +++ b/src/core/workers/config.js @@ -5,8 +5,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import EnvManager from "../env.js"; import * as system from "../../system.js"; +import EnvManager from "../env.js"; import Log from "../log.js"; import { services } from "../svc.js"; diff --git a/src/plugins/cache-util.js b/src/plugins/cache-util.js index 5bdc5800c7..4590eeea9b 100644 --- a/src/plugins/cache-util.js +++ b/src/plugins/cache-util.js @@ -5,10 +5,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import * as cfg from "../core/cfg.js"; -import * as util from "../commons/util.js"; import * as dnsutil from "../commons/dnsutil.js"; import * as envutil from "../commons/envutil.js"; +import * as util from "../commons/util.js"; import * as pres from "./plugin-response.js"; const minTtlSec = 30; // 30s @@ -118,7 +117,7 @@ function updateTtl(packet, end) { } } -function makeId(packet) { +export function makeId(packet) { // multiple questions are kind of an undefined behaviour // stackoverflow.com/a/55093896 if (!dnsutil.hasSingleQuestion(packet)) return null; @@ -164,13 +163,15 @@ export function makeHttpCacheValue(data) { /** * @param {any} packet + * @param {ts} string * @returns {URL} */ -export function makeHttpCacheKey(packet) { +export function makeHttpCacheKey(packet, ts) { const id = makeId(packet); // ex: domain.tld:A:dnssec if (util.emptyString(id)) return null; + if (util.emptyString(ts)) ts = util.yyyymm(); - return new URL(_cacheurl + cfg.timestamp() + "/" + id); + return new URL(_cacheurl + ts + "/" + id); } /** diff --git a/src/plugins/command-control/cc.js b/src/plugins/command-control/cc.js index cc08580deb..801a2223ad 100644 --- a/src/plugins/command-control/cc.js +++ b/src/plugins/command-control/cc.js @@ -5,21 +5,19 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import * as cfg from "../../core/cfg.js"; -import * as util from "../../commons/util.js"; -import * as rdnsutil from "../rdns-util.js"; +import { flagsToTags, tagsToFlags } from "@serverless-dns/trie/stamp.js"; import * as dnsutil from "../../commons/dnsutil.js"; +import * as util from "../../commons/util.js"; +import { DNSResolver } from "../dns-op/dns-op.js"; +import { LogPusher } from "../observability/log-pusher.js"; import * as pres from "../plugin-response.js"; -import { flagsToTags, tagsToFlags } from "@serverless-dns/trie/stamp.js"; -import * as token from "../users/auth-token.js"; +import * as rdnsutil from "../rdns-util.js"; import { BlocklistFilter } from "../rethinkdns/filter.js"; -import { LogPusher } from "../observability/log-pusher.js"; import { BlocklistWrapper } from "../rethinkdns/main.js"; -import { DNSResolver } from "../dns-op/dns-op.js"; +import * as token from "../users/auth-token.js"; export class CommandControl { constructor(blocklistWrapper, resolver, logPusher) { - this.latestTimestamp = rdnsutil.bareTimestampFrom(cfg.timestamp()); this.log = log.withTags("CommandControl"); /** @type {BlocklistWrapper} */ this.bw = blocklistWrapper; @@ -125,9 +123,8 @@ export class CommandControl { // blocklistFilter may not have been setup, so set it up await this.bw.init(rxid, /* force-wait */ true); const blf = this.bw.getBlocklistFilter(); - const isBlfSetup = rdnsutil.isBlocklistFilterSetup(blf); - - if (!isBlfSetup) throw new Error("no blocklist-filter"); + if (!rdnsutil.isBlocklistFilterSetup(blf)) throw new Error("no blf"); + const blfts = this.bw.timestamp(); // throws err if basicconfig is not set if (command === "listtob64") { // convert blocklists (tags) to blockstamp (b64) @@ -140,10 +137,10 @@ export class CommandControl { response.data.httpResponse = await domainNameToList( rxid, this.resolver, + blfts, req, queryString, - blf, - this.latestTimestamp + blf ); } else if (command === "dntouint") { // convert names to flags @@ -177,7 +174,7 @@ export class CommandControl { response.data.httpResponse = configRedirect( b64UserFlag, reqUrl.origin, - this.latestTimestamp, + rdnsutil.bareTimestampFrom(blfts), !isDnsCmd ); } else { @@ -282,21 +279,22 @@ async function analytics(lp, reqUrl, auth, lid) { /** * @param {string} rxid * @param {DNSResolver} resolver + * @param {string} ts * @param {Request} req * @param {string} queryString * @param {BlocklistFilter} blocklistFilter - * @param {number} latestTimestamp * @returns {Promise} */ async function domainNameToList( rxid, resolver, + ts, req, queryString, - blocklistFilter, - latestTimestamp + blocklistFilter ) { const domainName = queryString.get("dn") || ""; + const latestTimestamp = util.bareTimestampFrom(ts); const r = { domainName: domainName, version: latestTimestamp, @@ -318,6 +316,7 @@ async function domainNameToList( const rmax = resolver.determineDohResolvers(resolver.ofMax(), forcedoh); const res = await resolver.resolveDnsUpstream( rxid, + ts, req, rmax, query, diff --git a/src/plugins/dns-op/blocker.js b/src/plugins/dns-op/blocker.js index 2265250898..0852f74520 100644 --- a/src/plugins/dns-op/blocker.js +++ b/src/plugins/dns-op/blocker.js @@ -15,6 +15,12 @@ export class DnsBlocker { this.log = log.withTags("DnsBlocker"); } + /** + * @param {string} rxid + * @param {pres.RespData} req + * @param {pres.BlockstampInfo} blockInfo + * @returns {pres.RespData} + */ blockQuestion(rxid, req, blockInfo) { const dnsPacket = req.dnsPacket; const stamps = req.stamps; @@ -40,6 +46,12 @@ export class DnsBlocker { return pres.copyOnlyBlockProperties(req, bres); } + /** + * @param {string} rxid + * @param {pres.RespData} res + * @param {pres.BlockstampInfo} blockInfo + * @returns {pres.RespData} + */ blockAnswer(rxid, res, blockInfo) { const dnsPacket = res.dnsPacket; const stamps = res.stamps; @@ -71,6 +83,12 @@ export class DnsBlocker { return pres.copyOnlyBlockProperties(res, bres); } + /** + * @param {string[]} names + * @param {pres.BlockstampInfo} blockInfo + * @param {pres.BStamp} blockstamps + * @returns {pres.RespData} + */ block(names, blockInfo, blockstamps) { let r = pres.rdnsNoBlockResponse(); for (const n of names) { diff --git a/src/plugins/dns-op/cache-resolver.js b/src/plugins/dns-op/cache-resolver.js index 61240e1870..8e4f9a0033 100644 --- a/src/plugins/dns-op/cache-resolver.js +++ b/src/plugins/dns-op/cache-resolver.js @@ -6,12 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DnsBlocker } from "./blocker.js"; -import * as cacheutil from "../cache-util.js"; -import * as rdnsutil from "../rdns-util.js"; -import * as pres from "../plugin-response.js"; import * as dnsutil from "../../commons/dnsutil.js"; import * as util from "../../commons/util.js"; +import * as cacheutil from "../cache-util.js"; +import * as pres from "../plugin-response.js"; +import * as rdnsutil from "../rdns-util.js"; +import { DnsBlocker } from "./blocker.js"; export class DNSCacheResponder { constructor(blocklistWrapper, cache) { @@ -48,6 +48,12 @@ export class DNSCacheResponder { return response; } + /** + * @param {string} rxid + * @param {any} packet + * @param {pres.BStamp} blockInfo + * @returns {Promise} + */ async resolveFromCache(rxid, packet, blockInfo) { const noAnswer = pres.rdnsNoBlockResponse(); // if blocklist-filter is setup, then there's no need to query http-cache @@ -61,16 +67,19 @@ export class DNSCacheResponder { // on Cloudflare, which not only has "free" egress, but also different // runtime (faster hw and sw) and deployment model (v8 isolates). const blf = this.bw.getBlocklistFilter(); - const onlyLocal = - this.bw.disabled() || rdnsutil.isBlocklistFilterSetup(blf); + const hasblf = rdnsutil.isBlocklistFilterSetup(blf); + const onlyLocal = this.bw.disabled() || hasblf; + const ts = hasblf ? this.bw.timestamp(util.yyyymm()) : util.yyyymm(); - const k = cacheutil.makeHttpCacheKey(packet); + const k = cacheutil.makeHttpCacheKey(packet, ts); if (!k) return noAnswer; const cr = await this.cache.get(k, onlyLocal); - this.log.d(rxid, onlyLocal, "cache k/m", k.href, cr && cr.metadata); + const hascr = !util.emptyObj(cr); + const hasm = hascr && cr.metadata != null; + this.log.d(rxid, "l/b?", onlyLocal, hasblf, "cache k/m", k.href, hasm); - if (util.emptyObj(cr)) return noAnswer; + if (!hascr) return noAnswer; // note: stamps in cr may be out-of-date; for ex, consider a // scenario where v6.example.com AAAA to fda3:: today, @@ -101,10 +110,16 @@ export class DNSCacheResponder { return pres.dnsResponse(res.dnsPacket, reencoded, res.stamps); } + /** + * @param {string} rxid + * @param {pres.RespData} r + * @param {pres.BStamp} blockInfo + * @returns {pres.RespData} + */ makeCacheResponse(rxid, r, blockInfo) { // check incoming dns request against blocklists in cache-metadata this.blocker.blockQuestion(rxid, /* out*/ r, blockInfo); - this.log.d(rxid, blockInfo, "question blocked?", r.isBlocked); + this.log.d(rxid, blockInfo, "q block?", r.isBlocked); if (r.isBlocked) { return r; } @@ -117,7 +132,7 @@ export class DNSCacheResponder { // check outgoing cached dns-packet against blocklists this.blocker.blockAnswer(rxid, /* out*/ r, blockInfo); - this.log.d(rxid, "answer block?", r.isBlocked); + this.log.d(rxid, "a block?", r.isBlocked); return r; } diff --git a/src/plugins/dns-op/prefilter.js b/src/plugins/dns-op/prefilter.js index 806f9c81f0..122dea2b6f 100644 --- a/src/plugins/dns-op/prefilter.js +++ b/src/plugins/dns-op/prefilter.js @@ -197,7 +197,8 @@ export class DNSPrefilter { const subdomains = d.split("."); do { if (util.emptyArray(subdomains)) break; - if (undelegated.has(subdomains.join("."))) { + const fqdn = subdomains.join("."); + if (undelegated.has(fqdn)) { return block; } } while (subdomains.shift() != null); diff --git a/src/plugins/dns-op/resolver.js b/src/plugins/dns-op/resolver.js index edf04cae9a..f5ba2a72af 100644 --- a/src/plugins/dns-op/resolver.js +++ b/src/plugins/dns-op/resolver.js @@ -5,15 +5,16 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DnsBlocker } from "./blocker.js"; -import * as pres from "../plugin-response.js"; -import * as rdnsutil from "../rdns-util.js"; -import * as cacheutil from "../cache-util.js"; -import * as dnsutil from "../../commons/dnsutil.js"; import * as bufutil from "../../commons/bufutil.js"; -import * as util from "../../commons/util.js"; +import * as dnsutil from "../../commons/dnsutil.js"; import * as envutil from "../../commons/envutil.js"; +import * as util from "../../commons/util.js"; +import * as system from "../../system.js"; +import * as cacheutil from "../cache-util.js"; +import * as pres from "../plugin-response.js"; +import * as rdnsutil from "../rdns-util.js"; import { BlocklistFilter } from "../rethinkdns/filter.js"; +import { DnsBlocker } from "./blocker.js"; export default class DNSResolver { /** @@ -33,9 +34,11 @@ export default class DNSResolver { this.log = log.withTags("DnsResolver"); this.measurements = []; + this.coalstats = { tot: 0, pub: 0, empty: 0, try: 0 }; this.profileResolve = envutil.profileDnsResolves(); // only valid on nodejs this.forceDoh = envutil.forceDoh(); + this.timeout = (envutil.workersTimeout() / 2) | 0; // only valid on workers // bg-bw-init results in higher io-wait, not lower @@ -137,7 +140,7 @@ export default class DNSResolver { * @param {Request} ctx.request * @param {ArrayBuffer} ctx.requestBodyBuffer * @param {Object} ctx.requestDecodedDnsPacket - * @param {Object} ctx.userBlocklistInfo + * @param {pres.BlockstampInfo} ctx.userBlocklistInfo * @param {String} ctx.userDnsResolverUrl * @param {string} ctx.userBlockstamp * @param {pres.BStamp?} ctx.domainBlockstamp @@ -151,6 +154,7 @@ export default class DNSResolver { const rawpacket = ctx.requestBodyBuffer; const decodedpacket = ctx.requestDecodedDnsPacket; const userDns = ctx.userDnsResolverUrl; + const forceUserDns = this.forceDoh || !util.emptyString(userDns); const dispatcher = ctx.dispatcher; const userBlockstamp = ctx.userBlockstamp; // may be null or empty-obj (stamp then needs to be got from blf) @@ -160,16 +164,17 @@ export default class DNSResolver { let blf = this.bw.getBlocklistFilter(); const isBlfDisabled = this.bw.disabled(); let isBlfSetup = rdnsutil.isBlocklistFilterSetup(blf); + const ts = this.bw.timestamp(util.yyyymm()); // if both blocklist-filter (blf) and stamps are not setup, question-block // is a no-op, while we expect answer-block to catch the block regardless. - const q = await this.makeRdnsResponse(rxid, rawpacket, blf, stamps); + const q = this.makeRdnsResponse(rxid, rawpacket, blf, stamps); this.blocker.blockQuestion(rxid, /* out*/ q, blInfo); - this.log.d(rxid, "q block?", q.isBlocked, "blf?", isBlfSetup); + this.log.d(rxid, "q block?", q.isBlocked, "blf?", isBlfSetup, "ts?", ts); if (q.isBlocked) { - this.primeCache(rxid, q, dispatcher); + this.primeCache(rxid, ts, q, dispatcher); return q; } @@ -190,6 +195,7 @@ export default class DNSResolver { Promise.resolve(), // placeholder promise that never rejects this.resolveDnsUpstream( rxid, + ts, req, this.determineDohResolvers(alt, /* forceDoh */ true), rawpacket, @@ -211,8 +217,9 @@ export default class DNSResolver { this.bw.init(rxid), this.resolveDnsUpstream( rxid, + ts, req, - this.determineDohResolvers(userDns), + this.determineDohResolvers(userDns, forceUserDns), rawpacket, decodedpacket ), @@ -232,6 +239,7 @@ export default class DNSResolver { } // developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled#return_value + /** @type{Response} */ const res = promisedTasks[1].value; if (fromMax) { @@ -251,24 +259,24 @@ export default class DNSResolver { if (!res.ok) { const txt = res.text && (await res.text()); - this.log.d(rxid, "!OK", res.status, txt); - throw new Error(txt + " http err: " + res); + this.log.w(rxid, "!OK", res.status, txt); + throw new Error(txt + " http err: " + res.status + " " + res.statusText); } const ans = await res.arrayBuffer(); - const r = await this.makeRdnsResponse(rxid, ans, blf, stamps); + const r = this.makeRdnsResponse(rxid, ans, blf, stamps); // blockAnswer is a no-op if the ans is already quad0 // check outgoing cached dns-packet against blocklists this.blocker.blockAnswer(rxid, /* out*/ r, blInfo); const fromCache = cacheutil.hasCacheHeader(res.headers); - this.log.d(rxid, "ans block?", r.isBlocked, "from cache?", fromCache); + this.log.d(rxid, "a block?", r.isBlocked, "c?", fromCache, "max?", fromMax); // if res was got from caches or if res was got from max doh (ie, blf // wasn't used to retrieve stamps), then skip hydrating the cache if (!fromCache && !fromMax) { - this.primeCache(rxid, r, dispatcher); + this.primeCache(rxid, ts, r, dispatcher); } return r; } @@ -278,9 +286,10 @@ export default class DNSResolver { * @param {ArrayBuffer} raw * @param {BlocklistFilter} blf * @param {pres.BStamp?} stamps - * @returns + * @returns {pres.RespData} + * @throws if raw is a malformed dns packet or not a dns packet. */ - async makeRdnsResponse(rxid, raw, blf, stamps = null) { + makeRdnsResponse(rxid, raw, blf, stamps = null) { if (!raw) throw new Error(rxid + " mk-res no upstream result"); const dnsPacket = dnsutil.decode(raw); @@ -297,24 +306,21 @@ export default class DNSResolver { /** * @param {string} rxid + * @param {string} ts * @param {pres.RespData} r * @param {function(function):void} dispatcher * @returns {Promise} */ - async primeCache(rxid, r, dispatcher) { + primeCache(rxid, ts, r, dispatcher) { const blocked = r.isBlocked; - - const k = cacheutil.makeHttpCacheKey(r.dnsPacket); - - this.log.d(rxid, "primeCache: block?", blocked, "k", k.href); - + const k = cacheutil.makeHttpCacheKey(r.dnsPacket, ts); if (!k) { - this.log.d(rxid, "no cache-key, url/query missing?", k, r.stamps); + this.log.d(rxid, "primeCache: no key, url/query missing?", k, r.stamps); return; } + this.log.d(rxid, "primeCache: block?", blocked, "k", k.href); const v = cacheutil.cacheValueOf(r); - this.cache.put(k, v, dispatcher); } @@ -327,37 +333,54 @@ export default class DNSResolver { /** * @param {String} rxid + * @param {String} ts * @param {Request} request - * @param {Array} resolverUrls + * @param {String[]} resolverUrls * @param {ArrayBuffer} query * @param {any} packet * @returns {Promise} */ DNSResolver.prototype.resolveDnsUpstream = async function ( rxid, + ts, request, resolverUrls, query, packet ) { - // Promise.any on promisedPromises[] only works if there are - // zero awaits in this function or any of its downstream calls. - // Otherwise, the first reject in promisedPromises[], before - // any statement in the call-stack awaits, would throw unhandled - // error, since the event loop would have 'ticked' and Promise.any - // on promisedPromises[] would still not have been executed, as it - // is the last statement of this function (which would have eaten up - // all rejects as long as there was one resolved promise). - const promisedPromises = []; - // if no doh upstreams set, resolve over plain-old dns if (util.emptyArray(resolverUrls)) { + const eid = cacheutil.makeId(packet); + /** @type {ArrayBuffer[]?} */ + let parcel = null; + + try { + const g = await system.when(eid, this.timeout); + this.coalstats.tot += 1; + if (!util.emptyArray(g) && g[0] != null) { + const sz = bufutil.len(g[0]); + this.log.d(rxid, "coalesced", eid, sz, this.coalstats); + if (sz > 0) return Promise.resolve(new Response(g[0])); + } + this.coalstats.empty += 1; + this.log.e(rxid, "empty coalesced", eid, this.coalstats); + return Promise.resolve(util.respond503()); + } catch (reason) { + // happens on timeout or if new event, eid + this.coalstats.try += 1; + this.log.d(rxid, "not coalesced", eid, reason, this.coalstats); + } + if (this.transport == null) { this.log.e(rxid, "plain dns transport not set"); + this.coalstats.pub += 1; + system.pub(eid, parcel); return Promise.reject(new Error("plain dns transport not set")); } - // do not let exceptions passthrough to the caller + + let promisedResponse = null; try { + // do not let exceptions passthrough to the caller const q = bufutil.bufferOf(query); let ans = await this.transport.udpquery(rxid, q); @@ -367,28 +390,40 @@ DNSResolver.prototype.resolveDnsUpstream = async function ( } if (ans) { - const r = new Response(bufutil.arrayBufferOf(ans)); - promisedPromises.push(Promise.resolve(r)); + const ab = bufutil.arrayBufferOf(ans); + parcel = [ab]; + promisedResponse = Promise.resolve(new Response(ab)); } else { - promisedPromises.push(Promise.resolve(util.respond503())); + promisedResponse = Promise.resolve(util.respond503()); } } catch (e) { this.log.e(rxid, "err when querying plain old dns", e.stack); - promisedPromises.push(Promise.reject(e)); + promisedResponse = Promise.reject(e); } - return Promise.any(promisedPromises); + this.coalstats.pub += 1; + system.pub(eid, parcel); + return promisedResponse; } + // Promise.any on promisedPromises[] only works if there are + // zero awaits in this function or any of its downstream calls. + // Otherwise, the first reject in promisedPromises[], before + // any statement in the call-stack awaits, would throw unhandled + // error, since the event loop would have 'ticked' and Promise.any + // on promisedPromises[] would still not have been executed, as it + // is the last statement of this function (which would have eaten up + // all rejects as long as there was one resolved promise). + const promisedPromises = []; try { // upstream to cache this.log.d(rxid, "upstream cache"); - promisedPromises.push(this.resolveDnsFromCache(rxid, packet)); + promisedPromises.push(this.resolveDnsFromCache(rxid, ts, packet)); // upstream to resolvers for (const rurl of resolverUrls) { if (util.emptyString(rurl)) { - this.log.w(rxid, "missing resolver url", rurl); + this.log.w(rxid, "missing resolver url", rurl, "among", resolverUrls); continue; } @@ -432,12 +467,19 @@ DNSResolver.prototype.resolveDnsUpstream = async function ( return Promise.any(promisedPromises); }; -DNSResolver.prototype.resolveDnsFromCache = async function (rxid, packet) { - const k = cacheutil.makeHttpCacheKey(packet); +/** + * resolveDnsFromCache answers query requested by packet from local or remote cache. + * @param {string} rxid + * @param {string} ts + * @param {any} packet + * @returns {Promise} with the answer as buffer of the dns packet or error + */ +DNSResolver.prototype.resolveDnsFromCache = async function (rxid, ts, packet) { + const k = cacheutil.makeHttpCacheKey(packet, ts); if (!k) throw new Error("resolver: no cache-key"); const cr = await this.cache.get(k); - const isAns = cr && dnsutil.isAnswer(cr.dnsPacket); + const isAns = cr != null && dnsutil.isAnswer(cr.dnsPacket); const hasAns = isAns && dnsutil.hasAnswers(cr.dnsPacket); // if cr has answers, use probablistic expiry; otherwise prefer actual ttl const fresh = isAns && cacheutil.isAnswerFresh(cr.metadata, hasAns ? 0 : 6); diff --git a/src/plugins/plugin-response.js b/src/plugins/plugin-response.js index c11c185fad..adebaf2cb0 100644 --- a/src/plugins/plugin-response.js +++ b/src/plugins/plugin-response.js @@ -9,6 +9,8 @@ import * as util from "../commons/util.js"; import * as bufutil from "../commons/bufutil.js"; +/** @typedef {import("./users/auth-token.js").Outcome} AuthOutcome */ + export class RResp { constructor(data = null, hasex = false, exfrom = "", exstack = "") { /** @type {RespData?} */ @@ -30,10 +32,27 @@ export class RespData { this.flag = flag || ""; /** @type {Object} */ this.dnsPacket = packet || null; - /** @type {ArrayBuffer} */ + /** @type {ArrayBuffer?} */ this.dnsBuffer = raw || null; - /** @type {BStamp?} */ + /** @type {BStamp|boolean} */ this.stamps = stamps || {}; + /** @type {AuthOutcome?} */ + this.userAuth = null; + /** @type {BlockstampInfo?} */ + this.userBlocklistInfo = null; + /** @type {String} */ + this.dnsResolverUrl = ""; + /** @type {string} */ + this.userBlocklistFlag = ""; + } +} + +export class BlockstampInfo { + constructor() { + /** @type {Uint16Array} */ + this.userBlocklistFlagUint = null; + /** @type {String} - mosty 0 or 1 */ + this.flagVersion = "0"; } } diff --git a/src/plugins/rdns-util.js b/src/plugins/rdns-util.js index f9d93677c7..2c80bfe48d 100644 --- a/src/plugins/rdns-util.js +++ b/src/plugins/rdns-util.js @@ -5,15 +5,15 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as trie from "@serverless-dns/trie/stamp.js"; import { rbase32 } from "../commons/b32.js"; -import * as util from "../commons/util.js"; import * as bufutil from "../commons/bufutil.js"; import * as dnsutil from "../commons/dnsutil.js"; import * as envutil from "../commons/envutil.js"; +import * as util from "../commons/util.js"; +import { DnsCacheData } from "./cache-util.js"; import * as pres from "./plugin-response.js"; -import * as trie from "@serverless-dns/trie/stamp.js"; import { BlocklistFilter } from "./rethinkdns/filter.js"; -import { DnsCacheData } from "./cache-util.js"; // doh uses b64url encoded blockstamp, while dot uses lowercase b32. const _b64delim = ":"; @@ -27,11 +27,6 @@ const emptystr = ""; // delim, version, blockstamp (flag), accesskey const emptystamp = [emptystr, emptystr, emptystr, emptystr]; -// deprecated: all lists are trreated as wildcards -const _wildcardUint16 = new Uint16Array([ - 64544, 18431, 8191, 65535, 64640, 1, 128, 16320, -]); - // pec: parental control, rec: recommended, sec: security const recBlockstamps = new Map(); // oisd, 1hosts:mini, cpbl:light, anudeep, yhosts, tiuxo, adguard @@ -57,28 +52,55 @@ recBlockstamps.set("pr", "1:eMYB-ACgAQAgARAwIABhUgCA"); // pec, sec recBlockstamps.set("ps", "1:GNwB-ACgeQKr7cg3YXoAgA=="); +/** + * @param {BlocklistFilter} blf + * @returns {boolean} true if blf is setup + */ export function isBlocklistFilterSetup(blf) { return blf && !util.emptyObj(blf.ftrie); } +/** + * alias for util#bareTimestampFrom + * @type {string} tstamp is of form epochMs ("1740866164283") or yyyy/epochMs ("2025/1740866164283") + * @returns {int} blocklist create time (unix epoch) in millis (-1 on errors) + */ +export function bareTimestampFrom(tstamp) { + return util.bareTimestampFrom(tstamp); +} + +/** + * @param {string} p + * @returns {boolean} + */ export function isStampQuery(p) { return stampPrefix.test(p); } +/** + * @param {string} p + * @returns {boolean} + */ export function isLogQuery(p) { return logPrefix.test(p); } -// dn -> domain name, ex: you.and.i.example.com -// userBlInfo -> user-selected blocklist-stamp -// {userBlocklistFlagUint, userServiceListUint} -// dnBlInfo -> obj of blocklists stamps for dn and all its subdomains -// {string(sub/domain-name) : u16(blocklist-stamp) } -// FIXME: return block-dnspacket depending on altsvc/https/svcb or cname/a/aaaa +/** + * dn -> domain name, ex: you.and.i.example.com + * userBlInfo -> user-selected blocklist-stamp + * {BlockstampInfo} + * dnBlInfo -> obj of blocklists stamps for dn and all its subdomains + * {string(sub/domain-name) : u16(blocklist-stamp) } + * FIXME: return block-dnspacket depending on altsvc/https/svcb or cname/a/aaaa + * @param {string} dn domain name + * @param {pres.BlockstampInfo} userBlInfo user blocklist info + * @param {pres.BStamp} dnBlInfo domain blockstamp map + */ export function doBlock(dn, userBlInfo, dnBlInfo) { const blockSubdomains = envutil.blockSubdomains(); const version = userBlInfo.flagVersion; const noblock = pres.rdnsNoBlockResponse(); + const userUint = userBlInfo.userBlocklistFlagUint; if ( util.emptyString(dn) || util.emptyObj(dnBlInfo) || @@ -89,32 +111,14 @@ export function doBlock(dn, userBlInfo, dnBlInfo) { // treat every blocklist as a wildcard blocklist if (blockSubdomains) { - return applyWildcardBlocklists( - userBlInfo.userBlocklistFlagUint, - version, - dnBlInfo, - dn - ); + return applyWildcardBlocklists(dn, version, userUint, dnBlInfo); } - const dnUint = new Uint16Array(dnBlInfo[dn]); + const dnUint = dnBlInfo[dn]; // if the domain isn't in block-info, we're done if (util.emptyArray(dnUint)) return noblock; // else, determine if user selected blocklist intersect with the domain's - const r = applyBlocklists(userBlInfo.userBlocklistFlagUint, dnUint, version); - - // if response is blocked, we're done - if (r.isBlocked) return r; - // if user-blockstamp doesn't contain any wildcard blocklists, we're done - if (util.emptyArray(userBlInfo.userServiceListUint)) return r; - - // check if any subdomain is in blocklists that is also in user-blockstamp - return applyWildcardBlocklists( - userBlInfo.userServiceListUint, - version, - dnBlInfo, - dn - ); + return applyBlocklists(version, userUint, dnUint); } /** @@ -131,7 +135,7 @@ export function blockstampFromCache(cr) { } /** - * @param {*} dnsPacket + * @param {any} dnsPacket * @param {BlocklistFilter} blocklistFilter * @returns {pres.BStamp|boolean} */ @@ -156,7 +160,14 @@ export function blockstampFromBlocklistFilter(dnsPacket, blocklistFilter) { return util.emptyMap(m) ? false : util.objOf(m); } -function applyWildcardBlocklists(uint1, flagVersion, dnBlInfo, dn) { +/** + * @param {string} dn domain name + * @param {Uint16Array} usrUint user blocklist flags + * @param {string} flagVersion mosty 0 or 1 + * @param {pres.BStamp} dnBlInfo subdomain blocklist flag group + * @returns {pres.RespData} + */ +function applyWildcardBlocklists(dn, flagVersion, usrUint, dnBlInfo) { const dnSplit = dn.split("."); // iterate through all subdomains one by one, for ex: a.b.c.ex.com: @@ -170,7 +181,7 @@ function applyWildcardBlocklists(uint1, flagVersion, dnBlInfo, dn) { // the subdomain isn't present in any current blocklists if (util.emptyArray(subdomainUint)) continue; - const response = applyBlocklists(uint1, subdomainUint, flagVersion); + const response = applyBlocklists(flagVersion, usrUint, subdomainUint); // if any subdomain is in any blocklist, block the current request if (!util.emptyObj(response) && response.isBlocked) { @@ -181,7 +192,13 @@ function applyWildcardBlocklists(uint1, flagVersion, dnBlInfo, dn) { return pres.rdnsNoBlockResponse(); } -function applyBlocklists(uint1, uint2, flagVersion) { +/** + * @param {string} flagVersion + * @param {Uint16Array} uint1 + * @param {Uint16Array} uint2 + * @returns {pres.RespData} + */ +function applyBlocklists(flagVersion, uint1, uint2) { // uint1 -> user blocklists; uint2 -> blocklists including sub/domains const blockedUint = intersect(uint1, uint2); @@ -194,6 +211,11 @@ function applyBlocklists(uint1, uint2, flagVersion) { } } +/** + * @param {Uint16Array} flag1 + * @param {Uint16Array} flag2 + * @returns {Uint16Array|null} + */ function intersect(flag1, flag2) { if (util.emptyArray(flag1) || util.emptyArray(flag2)) return null; @@ -253,6 +275,11 @@ function intersect(flag1, flag2) { return Uint16Array.of(commonHeader, ...commonBody.reverse()); } +/** + * @param {int} uint + * @param {int} pos + * @returns + */ function clearbit(uint, pos) { return uint & ~(1 << pos); } @@ -326,7 +353,7 @@ export function recBlockstampFrom(url) { /** * @param {string} u - Request URL string - * @returns {Array} s - delim, version, blockstamp (flag), accesskey + * @returns {string[]} s - delim, version, blockstamp (flag), accesskey */ export function extractStamps(u) { const url = new URL(u); @@ -373,6 +400,10 @@ export function extractStamps(u) { return emptystamp; } +/** + * @param {string} b64Flag + * @returns {Uint16Array} + */ export function base64ToUintV0(b64Flag) { // TODO: v0 not in use, remove all occurences // FIXME: Impl not accurate @@ -381,32 +412,45 @@ export function base64ToUintV0(b64Flag) { return bufutil.base64ToUint16(f); } +/** + * @param {string} b64Flag + * @returns {Uint16Array} + */ export function base64ToUintV1(b64Flag) { // TODO: check for empty b64Flag return bufutil.base64ToUint16(b64Flag); } +/** + * @param {string} b64Flag + * @returns {Uint16Array} + */ export function base32ToUintV1(flag) { // TODO: check for empty flag const b32 = decodeURI(flag); return bufutil.decodeFromBinaryArray(rbase32(b32)); } +/** + * @param {string} s + * @returns {string[]} [delim, ver, blockstamp, accesskey] + */ function splitBlockstamp(s) { - // delim, version, blockstamp, accesskey - if (util.emptyString(s)) return emptystamp; if (!isStampQuery(s)) return emptystamp; if (isB32Stamp(s)) { + // delim, version, blockstamp, accesskey return [_b32delim, ...s.split(_b32delim)]; } else { return [_b64delim, ...s.split(_b64delim)]; } - - return out; } +/** + * @param {string} s + * @returns {boolean} + */ export function isB32Stamp(s) { const idx32 = s.indexOf(_b32delim); const idx64 = s.indexOf(_b64delim); @@ -416,21 +460,27 @@ export function isB32Stamp(s) { else return idx32 < idx64; } -// s[0] is version field, if it doesn't exist -// then treat it as if version 0. +/** + * + * @param {string[]} s + * @returns {string} + */ export function stampVersion(s) { + // s[0] is version field, if it doesn't exist + // then treat it as if version 0. if (!util.emptyArray(s)) return s[0]; else return "0"; } // TODO: The logic to parse stamps must be kept in sync with: // github.com/celzero/website-dns/blob/8e6056bb/src/js/flag.js#L260-L425 +/** + * + * @param {string} flag + * @returns {pres.BlockstampInfo} + */ export function unstamp(flag) { - const r = { - userBlocklistFlagUint: null, - flagVersion: "0", - userServiceListUint: null, - }; + const r = new pres.BlockstampInfo(); if (util.emptyString(flag)) return r; @@ -440,29 +490,26 @@ export function unstamp(flag) { const isFlagB32 = isB32Stamp(flag); // "v:b64" or "v-b32" or "uriencoded(b64)", where v is uint version const s = flag.split(isFlagB32 ? _b32delim : _b64delim); - let convertor = (x) => ""; // empty convertor - let f = ""; // stamp flag const v = stampVersion(s); + r.flagVersion = v; if (v === "0") { - // version 0 - convertor = base64ToUintV0; - f = s[0]; + const f = s[0]; + r.userBlocklistFlagUint = base64ToUintV0(f) || null; } else if (v === "1") { - convertor = isFlagB32 ? base32ToUintV1 : base64ToUintV1; - f = s[1]; + const convertor = isFlagB32 ? base32ToUintV1 : base64ToUintV1; + const f = s[1]; + r.userBlocklistFlagUint = convertor(f) || null; } else { log.w("Rdns:unstamp", "unknown blocklist stamp version in " + s); - return r; } - - r.flagVersion = v; - r.userBlocklistFlagUint = convertor(f) || null; - r.userServiceListUint = intersect(r.userBlocklistFlagUint, _wildcardUint16); - return r; } +/** + * @param {pres.BlockstampInfo} blockInfo + * @returns {boolean} + */ export function hasBlockstamp(blockInfo) { return ( !util.emptyObj(blockInfo) && @@ -470,33 +517,15 @@ export function hasBlockstamp(blockInfo) { ); } -// returns true if tstamp is of form yyyy/epochMs -function isValidFullTimestamp(tstamp) { - if (typeof tstamp !== "string") return false; - return tstamp.indexOf("/") === 4; -} - -// from: github.com/celzero/downloads/blob/main/src/timestamp.js -export function bareTimestampFrom(tstamp) { - // strip out "/" if tstamp is of form yyyy/epochMs - if (isValidFullTimestamp(tstamp)) { - tstamp = tstamp.split("/")[1]; - } - const t = parseInt(tstamp); - if (isNaN(t)) { - log.w("Rdns bareTstamp: NaN", tstamp); - return 0; - } - return t; -} - +/** + * @param {string} strflag + * @returns {string[]} blocklist names + */ export function blocklists(strflag) { const { userBlocklistFlagUint, flagVersion } = unstamp(strflag); const blocklists = []; if (flagVersion === "1") { return trie.flagsToTags(userBlocklistFlagUint); - } else { - throw new Error("unknown blocklist version: " + flagVersion); - } + } // unknown blocklist version return blocklists; } diff --git a/src/plugins/rethinkdns/filter.js b/src/plugins/rethinkdns/filter.js index 12f2769bd5..a72d378280 100644 --- a/src/plugins/rethinkdns/filter.js +++ b/src/plugins/rethinkdns/filter.js @@ -6,15 +6,22 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { FrozenTrie } from "@serverless-dns/trie/ftrie.js"; import * as dnsutil from "../../commons/dnsutil.js"; export class BlocklistFilter { constructor() { // see: src/helpers/node/blocklists.js:hasBlocklistFiles + /** @type {FrozenTrie} */ this.ftrie = null; + /** @type {Object} */ this.filetag = null; } + /** + * @param {FrozenTrie} frozentrie + * @param {Object} filetag + */ load(frozentrie, filetag) { this.ftrie = frozentrie; this.filetag = filetag; @@ -28,6 +35,11 @@ export class BlocklistFilter { lookup(n) { const t = this.ftrie; + if (t == null) { + log.w("blocklist filter not loaded"); + return null; + } + try { n = t.transform(n); return t.lookup(n); @@ -55,7 +67,9 @@ export class BlocklistFilter { extract(ids) { const r = {}; - for (const id of ids) r[id] = this.filetag[id]; + if (this.filetag) { + for (const id of ids) r[id] = this.filetag[id]; + } return r; } } diff --git a/src/plugins/rethinkdns/main.js b/src/plugins/rethinkdns/main.js index f2b3ae0dad..485a1a2fc7 100644 --- a/src/plugins/rethinkdns/main.js +++ b/src/plugins/rethinkdns/main.js @@ -7,26 +7,39 @@ */ import { createTrie } from "@serverless-dns/trie/ftrie.js"; -import { BlocklistFilter } from "./filter.js"; -import { withDefaults } from "./trie-config.js"; -import * as pres from "../plugin-response.js"; -import * as cfg from "../../core/cfg.js"; import * as bufutil from "../../commons/bufutil.js"; -import * as util from "../../commons/util.js"; import * as envutil from "../../commons/envutil.js"; +import * as util from "../../commons/util.js"; +import * as cfg from "../../core/cfg.js"; +import * as pres from "../plugin-response.js"; import * as rdnsutil from "../rdns-util.js"; +import { BlocklistFilter } from "./filter.js"; +import { withDefaults } from "./trie-config.js"; // number of range fetches for trie.txt; -1 to disable const maxrangefetches = 2; +const basicconfigDir = "bc"; +const bcFilename = "basicconfig.json"; +const ftFilename = "filetag.json"; +const defaultCodec = "u6"; +const maxRenewAttempts = 5; + export class BlocklistWrapper { constructor() { + /** @type {BlocklistFilter} */ this.blocklistFilter = new BlocklistFilter(); + /** @type {number} */ this.startTime = Date.now(); // blocklist download timestamp + /** @type {boolean} */ this.isBlocklistUnderConstruction = false; + /** @type {string} */ this.exceptionFrom = ""; + /** @type {string} */ this.exceptionStack = ""; + /** @type {boolean} */ this.noop = envutil.disableBlocklists(); + /** @type {boolean} */ this.nowait = envutil.bgDownloadBlocklistWrapper(); this.log = log.withTags("BlocklistWrapper"); @@ -50,11 +63,7 @@ export class BlocklistWrapper { now - this.startTime > envutil.downloadTimeout() * 2 ) { this.log.i(rxid, "download blocklists", now, this.startTime); - const url = envutil.blocklistUrl() + cfg.timestamp() + "/"; - const nc = cfg.tdNodeCount(); - const parts = cfg.tdParts(); - const u6 = cfg.tdCodec6(); - return this.initBlocklistConstruction(rxid, now, url, nc, parts, u6); + return this.initBlocklistConstruction(rxid, now); } else if (this.nowait && !forceget) { // blocklist-construction is in progress, but we don't have to // wait for it to finish. So, return an empty response. @@ -62,7 +71,7 @@ export class BlocklistWrapper { return pres.emptyResponse(); } else { // someone's constructing... wait till finished - return this.waitUntilDone(); + return this.waitUntilDone(rxid); } } catch (e) { this.log.e(rxid, "main", e.stack); @@ -82,7 +91,7 @@ export class BlocklistWrapper { return rdnsutil.isBlocklistFilterSetup(this.blocklistFilter); } - async waitUntilDone() { + async waitUntilDone(rxid) { // res.arrayBuffer() is the most expensive op, taking anywhere // between 700ms to 1.2s for trie. But: We don't want all incoming // reqs to wait until the trie becomes available. 400ms is 1/3rd of @@ -96,6 +105,7 @@ export class BlocklistWrapper { const response = pres.emptyResponse(); while (totalWaitms < envutil.downloadTimeout()) { if (this.isBlocklistFilterSetup()) { + this.log.i(rxid, "blocklistWrapper: download done:", totalWaitms); response.data.blocklistFilter = this.blocklistFilter; return response; } @@ -103,12 +113,20 @@ export class BlocklistWrapper { totalWaitms += waitms; } + this.log.e(rxid, "blocklistWrapper", "download timed out:", totalWaitms); response.isException = true; response.exceptionStack = this.exceptionStack || "download timeout"; response.exceptionFrom = this.exceptionFrom || "blocklistWrapper.js"; return response; } + /** + * + * @param {ArrayBufferLike} td + * @param {ArrayBufferLike} rd + * @param {Object} ftags + * @param {Object} bconfig + */ buildBlocklistFilter(td, rd, ftags, bconfig) { this.isBlocklistUnderConstruction = true; this.startTime = Date.now(); @@ -124,28 +142,44 @@ export class BlocklistWrapper { return createTrie(tdbuf, rdbuf, bconfig); } - async initBlocklistConstruction( - rxid, - when, - url, - tdNodecount, - tdParts, - tdCodec6 - ) { + /** + * @param {string} rxid + * @param {int} when + * @returns {Promise} + */ + async initBlocklistConstruction(rxid, when) { this.isBlocklistUnderConstruction = true; this.startTime = when; + const baseurl = envutil.blocklistUrl(); + + let bconfig = withDefaults(cfg.orig()); + let ft = cfg.filetag(); + // if bconfig.timestamp is older than AUTO_RENEW_BLOCKLISTS_OLDER_THAN + // then download the latest filetag (ft) and basicconfig (bconfig). + if (!envutil.disableBlocklists()) { + const blocklistAgeThresWeeks = envutil.renewBlocklistsThresholdInWeeks(); + const bltimestamp = util.bareTimestampFrom(cfg.timestamp()); + if (isPast(bltimestamp, blocklistAgeThresWeeks)) { + const [renewCfg, renewedFt] = await renew(baseurl); + + if (renewCfg != null && renewedFt != null) { + this.log.i(rxid, "r:", bconfig.timestamp, "=>", renewCfg.timestamp); + bconfig = withDefaults(renewCfg); + ft = renewedFt; + } else { + this.log.w(rxid, "r: failed; got:", renewCfg); + } + } else { + this.log.d(rxid, "r: not needed for:", bltimestamp); + } + } + let response = pres.emptyResponse(); try { - await this.downloadAndBuildBlocklistFilter( - rxid, - url, - tdNodecount, - tdParts, - tdCodec6 - ); - - this.log.i(rxid, "blocklist-filter setup; u6?", tdCodec6); + await this.downloadAndBuildBlocklistFilter(rxid, bconfig, ft); + + this.log.i(rxid, "blocklist-filter setup; u6?", bconfig.useCodec6); if (false) { // test const result = this.blocklistFilter.blockstamp("google.com"); @@ -165,21 +199,15 @@ export class BlocklistWrapper { return response; } - async downloadAndBuildBlocklistFilter(rxid, url, tdNodecount, tdParts, u6) { - !tdNodecount && this.log.e(rxid, "tdNodecount zero or missing!"); + async downloadAndBuildBlocklistFilter(rxid, bconfig, ft) { + const tdNodecount = bconfig.nodecount; // or: cfg.tdNodeCount(); + const tdParts = bconfig.tdparts; // or: cfg.tdParts(); + const u6 = bconfig.useCodec6; // or: cfg.tdCodec6(); - const bconfig = withDefaults(cfg.orig()); - const ft = cfg.filetag(); + let url = envutil.blocklistUrl() + bconfig.timestamp + "/"; + url += u6 ? "u6/" : "u8/"; - if ( - bconfig.useCodec6 !== u6 || - bconfig.nodecount !== tdNodecount || - bconfig.tdparts !== tdParts - ) { - throw new Error(bconfig + "<=cfg; in=>" + u6 + " " + tdNodecount); - } - - url += bconfig.useCodec6 ? "u6/" : "u8/"; + !tdNodecount && this.log.e(rxid, "tdNodecount zero or missing!"); this.log.d(rxid, url, tdNodecount, tdParts); const buf0 = fileFetch(url + "rd.txt", "buffer"); @@ -195,11 +223,12 @@ export class BlocklistWrapper { const ftrie = this.makeTrie(td, rd, bconfig); this.blocklistFilter.load(ftrie, ft); - - return; } triedata() { + if (!rdnsutil.isBlocklistFilterSetup(this.blocklistFilter)) { + throw new Error("no triedata: blocklistFilter not loaded"); + } const blf = this.blocklistFilter; const ftrie = blf.ftrie; const rdir = ftrie.directory; @@ -208,12 +237,63 @@ export class BlocklistWrapper { } rankdata() { + if (!rdnsutil.isBlocklistFilterSetup(this.blocklistFilter)) { + throw new Error("no rankdata: blocklistFilter not loaded"); + } const blf = this.blocklistFilter; const ftrie = blf.ftrie; const rdir = ftrie.directory; const d = rdir.directory; return bufutil.raw(d.bytes); } + + filetag() { + if (!rdnsutil.isBlocklistFilterSetup(this.blocklistFilter)) { + throw new Error("no filetag: blocklistFilter not loaded"); + } + const blf = this.blocklistFilter; + return blf.filetag; + } + + basicconfig() { + if (!rdnsutil.isBlocklistFilterSetup(this.blocklistFilter)) { + throw new Error("no basicconfig: blocklistFilter not loaded"); + } + const blf = this.blocklistFilter; + const ftrie = blf.ftrie; + const rdir = ftrie.directory; + return rdir.config; + } + + /** + * Returns the timestamp of the blocklist (epochMillis or yyyy/epochMillis) + * @param {string} defaultTimestamp + * @returns {string} timestamp + * @throws {Error} if timestamp could not be determined and defaultTimestamp is empty. + */ + timestamp(defaultTimestamp = "") { + try { + const bc = this.basicconfig(); + if (bc == null) { + throw new Error("missing basicconfig"); + } + if (util.emptyString(bc.timestamp)) { + throw new Error("basicconfig missing timestamp"); + } + return bc.timestamp; + } catch (ex) { + // debug: this.log.d("blocklistWrapper: get timestamp", ex); + if (util.emptyString(defaultTimestamp)) { + throw ex; + } + } + return defaultTimestamp; + } + + codec() { + const tdcodec6 = this.basicconfig().useCodec6; + return tdcodec6 ? "u6" : "u8"; + } } async function fileFetch(url, typ, h = {}) { @@ -311,3 +391,107 @@ async function makeTd(baseurl, n) { return bufutil.concat(tds); } + +/** + * @typedef {Object} DateInfo + * @property {number} day + * @property {number} week + * @property {number} month + * @property {number} year + * @property {number} timestamp + */ + +/** + * @returns {DateInfo} + */ +function todayAsDateInfo() { + const date = new Date(); + const day = date.getUTCDate(); + const week = Math.ceil(day / 7); + const month = date.getUTCMonth() + 1; + const year = date.getUTCFullYear(); + const timestamp = date.getTime(); + return { day, week, month, year, timestamp }; +} + +/** + * Main function to prefetch files based on week, month, and year. + * @param {string} baseurl + */ +async function renew(baseurl) { + let { week: wk, month: mm, year: yyyy, timestamp: now } = todayAsDateInfo(); + + for (let i = 0; i <= maxRenewAttempts; i++) { + const configUrl = `${baseurl}${yyyy}/${basicconfigDir}/${mm}-${wk}/${defaultCodec}/${bcFilename}`; + log.i(`attempt ${i}: fetching ${configUrl} at ${now}`); + + try { + // { + // "version":1, + // "nodecount":81551789, + // "inspect":false, + // "debug":false, + // "selectsearch":true, + // "useCodec6":true, + // "optflags":true, + // "tdpartsmaxmb":0, + // "timestamp":"2025/1740866164283", + // "tdparts":-1, + // "tdmd5":"000ed9638e8e0f12e450050997e84365", + // "rdmd5":"75e5eebc71be02d8bef47b93ea58c213", + // "ftmd5":"8c56effb0f3d73232f7090416bb2e7c1", + // "ftlmd5":"54b323eb653451ba8940acb00d20382a" + // } + const bconfig = await fileFetch(configUrl, "json"); + + if (bconfig) { + const fullTimestamp = bconfig.timestamp; + if (fullTimestamp) { + const codec = bconfig.useCodec6 ? "u6" : "u8"; + const tagUrl = `${baseurl}${fullTimestamp}/${codec}/${ftFilename}`; + log.i(`attempt ${i}: fetching ${configUrl} at ${now}`); + + const ft = await fileFetch(tagUrl, "json"); + + if (ft) return [bconfig, ft]; + else log.w(`failed to fetch ${tagUrl}`); + } + } + } catch (ex) { + // ex: 4xx, 5xx + log.w(`renew #${i} err; retrying...`, ex); + } + + // decr week, month, year; try again + wk--; + if (wk <= 0) { + wk = 5; + mm--; + } + if (mm <= 0) { + mm = 12; + yyyy--; + } + } + + log.e("no new filetag or basicconfig: exceeded max retries"); + return [null, null]; +} + +/** + * @param {int} tsms (in unix millis) + * @param {int} wk (in weeks > 0) + * @returns {bool} + */ +function isPast(tsms, wk) { + if (tsms <= 0 || wk <= 0) return false; + + const since = Date.now() - tsms; + const sinceWeeks = Math.floor(since / (1000 * 60 * 60 * 24 * 7)); + + const y = sinceWeeks > wk; + if (y) { + log.w("blocklist is old:", sinceWeeks, ">", wk); + } + return y; +} diff --git a/src/plugins/users/user-op.js b/src/plugins/users/user-op.js index 6f879311ad..3247072af0 100644 --- a/src/plugins/users/user-op.js +++ b/src/plugins/users/user-op.js @@ -8,13 +8,18 @@ import { UserCache } from "./user-cache.js"; import * as pres from "../plugin-response.js"; import * as util from "../../commons/util.js"; +import * as envutil from "../../commons/envutil.js"; import * as rdnsutil from "../rdns-util.js"; import * as token from "./auth-token.js"; -import * as bufutil from "../../commons/bufutil.js"; +import * as dnsutil from "../../commons/dnsutil.js"; // TODO: determine an approp cache-size const cacheSize = 20000; +// use fixed doh upstream for these domains, +// instead of either recursing (on Fly.io) +const delegated = new Set(["ipv4only.arpa"]); + export class UserOp { constructor() { this.userConfigCache = new UserCache(cacheSize); @@ -22,7 +27,7 @@ export class UserOp { } /** - * @param {{request: Request, isDnsMsg: Boolean, rxid: string}} ctx + * @param {{request: Request, requestDecodedDnsPacket: any, isDnsMsg: Boolean, rxid: string}} ctx * @returns {Promise} */ async exec(ctx) { @@ -44,11 +49,11 @@ export class UserOp { } /** - * @param {{request: Request, isDnsMsg: Boolean, rxid: string}} ctx + * @param {{request: Request, requestDecodedDnsPacket: any, isDnsMsg: Boolean, rxid: string}} ctx * @returns {pres.RResp} */ loadUser(ctx) { - let response = pres.emptyResponse(); + const response = pres.emptyResponse(); if (!ctx.isDnsMsg) { this.log.w(ctx.rxid, "not a dns-msg, ignore"); @@ -56,18 +61,29 @@ export class UserOp { } try { - const blocklistFlag = rdnsutil.blockstampFromUrl(ctx.request.url); + const dnsPacket = ctx.requestDecodedDnsPacket; + const domains = dnsutil.extractDomains(dnsPacket); + for (const d of domains) { + if (delegated.has(d)) { + // may be overriden by user-preferred doh upstream + response.data.dnsResolverUrl = envutil.primaryDohResolver(); + } + } - if (util.emptyString(blocklistFlag)) { + const blocklistFlag = rdnsutil.blockstampFromUrl(ctx.request.url); + const hasflag = !util.emptyString(blocklistFlag); + if (!hasflag) { this.log.d(ctx.rxid, "empty blocklist-flag", ctx.request.url); } - // blocklistFlag may be invalid, ref rdnsutil.blockstampFromUrl let r = this.userConfigCache.get(blocklistFlag); - if (!util.emptyString(blocklistFlag) && util.emptyObj(r)) { - r = rdnsutil.unstamp(blocklistFlag); + let hasdata = rdnsutil.hasBlockstamp(r); + if (hasflag && !hasdata) { + // r not in cache + r = rdnsutil.unstamp(blocklistFlag); // r is never null, may throw ex + hasdata = rdnsutil.hasBlockstamp(r); - if (!bufutil.emptyBuf(r.userBlocklistFlagUint)) { + if (hasdata) { this.log.d(ctx.rxid, "new cfg cache kv", blocklistFlag, r); // TODO: blocklistFlag is not normalized, ie b32 used for dot isn't // converted to its b64 form (which doh and rethinkdns modules use) @@ -75,16 +91,18 @@ export class UserOp { this.userConfigCache.put(blocklistFlag, r); } } else { - this.log.d(ctx.rxid, "cfg cache hit?", r != null, blocklistFlag, r); + this.log.d(ctx.rxid, "cfg cache hit?", hasdata, blocklistFlag, r); } - response.data.userBlocklistInfo = r; - response.data.userBlocklistFlag = blocklistFlag; - // sets user-preferred doh upstream - response.data.dnsResolverUrl = null; + if (hasdata) { + response.data.userBlocklistInfo = r; + response.data.userBlocklistFlag = blocklistFlag; + // TODO: override response.data.dnsResolverUrl + } } catch (e) { this.log.e(ctx.rxid, "loadUser", e); - response = pres.errResponse("UserOp:loadUser", e); + // avoid erroring out on invalid blocklist info & flag + // response = pres.errResponse("UserOp:loadUser", e); } return response; diff --git a/src/server-deno.ts b/src/server-deno.ts index f6c6573a64..91da166928 100644 --- a/src/server-deno.ts +++ b/src/server-deno.ts @@ -11,7 +11,6 @@ import "./core/deno/config.ts"; import { handleRequest } from "./core/doh.js"; import { stopAfter, uptime } from "./core/svc.js"; -import { serve, serveTls } from "https://deno.land/std@0.171.0/http/server.ts"; import * as system from "./system.js"; import * as util from "./commons/util.js"; import * as bufutil from "./commons/bufutil.js"; @@ -69,16 +68,25 @@ function systemUp() { const abortctl = new AbortController(); const onDenoDeploy = envutil.onDenoDeploy() as boolean; + const isCleartext = envutil.isCleartext() as boolean; const dohConnOpts = { port: envutil.dohBackendPort() }; const dotConnOpts = { port: envutil.dotBackendPort() }; const sigOpts = { signal: abortctl.signal, onListen: undefined, }; - const tlsOpts = { - certFile: envutil.tlsCrtPath() as string, - keyFile: envutil.tlsKeyPath() as string, - }; + + const crtpath = envutil.tlsCrtPath() as string; + const keypath = envutil.tlsKeyPath() as string; + const dotls = !onDenoDeploy && !isCleartext; + + const tlsOpts = dotls + ? { + // docs.deno.com/runtime/reference/migration_guide/ + cert: Deno.readTextFileSync(crtpath), + key: Deno.readTextFileSync(keypath), + } + : { cert: "", key: "" }; // deno.land/manual@v1.18.0/runtime/http_server_apis_low_level const httpOpts = { alpnProtocols: ["h2", "http/1.1"], @@ -87,19 +95,21 @@ function systemUp() { startDoh(); startDotIfPossible(); - // deno.land/manual@v1.29.1/runtime/http_server_apis - async function startDoh() { + // docs.deno.com/runtime/fundamentals/http_server + // docs.deno.com/api/deno/~/Deno.serve + function startDoh() { if (terminateTls()) { - // deno.land/std@0.170.0/http/server.ts?s=serveTls - serveTls(serveDoh, { - ...dohConnOpts, - ...tlsOpts, - ...httpOpts, - ...sigOpts, - }); + Deno.serve( + { + ...dohConnOpts, + ...tlsOpts, + ...httpOpts, + ...sigOpts, + }, + serveDoh + ); } else { - // deno.land/std@0.171.0/http/server.ts?s=serve - serve(serveDoh, { ...dohConnOpts, ...sigOpts }); + Deno.serve({ ...dohConnOpts, ...sigOpts }, serveDoh); } up("DoH", abortctl, dohConnOpts); @@ -134,14 +144,14 @@ function systemUp() { function terminateTls() { if (onDenoDeploy) return false; - if (util.emptyString(tlsOpts.keyFile)) return false; - if (envutil.isCleartext()) return false; - if (util.emptyString(tlsOpts.certFile)) return false; + if (envutil.isCleartext() as boolean) return false; + if (util.emptyString(tlsOpts.key)) return false; + if (util.emptyString(tlsOpts.cert)) return false; return true; } } -async function serveDoh(req: Request) { +function serveDoh(req: Request) { try { // doc.deno.land/deno/stable/~/Deno.RequestEvent // deno.land/manual/runtime/http_server_apis#http-requests-and-responses diff --git a/src/server-node.js b/src/server-node.js index 74bd180108..ad5fa6af79 100644 --- a/src/server-node.js +++ b/src/server-node.js @@ -6,31 +6,32 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import net, { isIPv6 } from "node:net"; -import * as tls from "node:tls"; -import http2 from "node:http2"; +// should always be the first import +// import whyIsNodeRunning from "why-is-node-running"; +import "./core/node/config.js"; + +import { LfuCache } from "@serverless-dns/lfu-cache"; import * as h2c from "httpx-server"; -import * as os from "node:os"; +import http2 from "node:http2"; +import https from "node:https"; +import net, { isIPv6 } from "node:net"; +import os from "node:os"; +import { finished } from "node:stream"; +import tls, { TLSSocket } from "node:tls"; import v8 from "node:v8"; import { V2ProxyProtocol } from "proxy-protocol-js"; -import * as system from "./system.js"; -import { handleRequest } from "./core/doh.js"; -import { stopAfter, uptime } from "./core/svc.js"; import * as bufutil from "./commons/bufutil.js"; +import * as nodecrypto from "./commons/crypto.js"; import * as dnsutil from "./commons/dnsutil.js"; import * as envutil from "./commons/envutil.js"; -import * as nodeutil from "./core/node/util.js"; import * as util from "./commons/util.js"; -import "./core/node/config.js"; -import { finished } from "node:stream"; -import * as nodecrypto from "./commons/crypto.js"; -// webpack can't handle node-bindings, a dependency of node-memwatch -// github.com/webpack/webpack/issues/16029 -// import * as memwatch from "@airbnb/node-memwatch"; +import { handleRequest } from "./core/doh.js"; +import * as nodeutil from "./core/node/util.js"; +import { stopAfter, uptime } from "./core/svc.js"; +import * as system from "./system.js"; /** * @typedef {net.Socket} Socket - * @typedef {tls.TLSSocket} TLSSocket * @typedef {http2.Http2ServerRequest} Http2ServerRequest * @typedef {http2.Http2ServerResponse} Http2ServerResponse */ @@ -46,30 +47,55 @@ class Stats { this.noreqs = -1; this.nofchecks = 0; this.tlserr = 0; + this.fasttls = 0; + this.totfasttls = 0; + this.noftlsadjs = 0; this.nofdrops = 0; this.nofconns = 0; this.openconns = 0; this.noftimeouts = 0; + this.nofheapsnaps = 0; // avg1, avg5, avg15, adj, maxconns this.bp = [0, 0, 0, 0, 0]; } str() { return ( - `reqs=${this.noreqs} checks=${this.nofchecks} ` + + `reqs=${this.noreqs} c=${this.nofchecks} ` + `drops=${this.nofdrops}/tot=${this.nofconns}/open=${this.openconns} ` + - `timeouts=${this.noftimeouts}/tlserr=${this.tlserr} ` + + `to=${this.noftimeouts}/tlserr=${this.tlserr} ` + + `tls0=${this.fasttls}/tls0miss=${this.totfasttls}/tlsadjs=${this.noftlsadjs} ` + `n=${this.bp[4]}/adj=${this.bp[3]} ` + `load=${this.bp[0]}/${this.bp[1]}/${this.bp[2]}` ); } } +class SoReport { + constructor() { + /** @type {int} total bytes transferred in preceding 1sec */ + this.tx = 0; + /** @type {int} unix timestamp in millis */ + this.lastsnd = 0; + } +} + +class ConnW { + /** + * @param {Socket|TLSSocket} socket + */ + constructor(socket) { + this.socket = socket; + this.rep = new SoReport(); + } +} + class Tracker { constructor() { this.zeroid = ""; - /** @type {Array>} */ + /** @type {Array>} */ this.connmap = []; + this.reports = []; /** @type {Array} */ this.srvs = []; } @@ -95,7 +121,7 @@ class Tracker { } /** - * @param {Socket} sock + * @param {Socket?} sock * @returns {string} */ cid(sock) { @@ -103,19 +129,30 @@ class Tracker { else return sock.remoteAddress + "|" + sock.remotePort; } - trackServer(s) { + /** + * @param {string} id + * @param {net.Server} s + * @returns {string} sid + */ + trackServer(id, s) { if (!s) return this.zeroid; const mapid = this.sid(s); - if (!this.valid(mapid)) return this.zeroid; + if (!this.valid(mapid)) { + log.w("trackServer: server not tracked", id, mapid); + return this.zeroid; + } const cmap = this.connmap[mapid]; if (cmap) { - log.w("trackServer: server already tracked?", sid); - return mapid; + log.w("trackServer: server already tracked?", id, mapid); + return this.zeroid; } + + log.i("trackServer: new server", id, mapid); this.connmap[mapid] = new Map(); this.srvs.push(s); + return mapid; } *servers() { @@ -144,16 +181,36 @@ class Tracker { const connid = this.cid(sock); const cmap = this.connmap[mapid]; if (!this.valid(mapid) || !this.valid(connid) || !cmap) { - log.w("trackConn: server/socket not tracked?", mapid, connid); + log.d("trackConn: server/socket not tracked?", mapid, connid); return this.zeroid; } - cmap.set(connid, sock); + cmap.set(connid, new ConnW(sock)); sock.on("close", (haderr) => cmap.delete(connid)); return connid; } + /** + * + * @param {Socket|TLSSocket|null} sock + * @returns {SoReport?} rep + */ + sorep(sock) { + const connid = this.cid(sock); + if (!this.valid(connid)) return null; // unlikely + + for (const cmap of this.connmap) { + if (!cmap) continue; + const connw = cmap.get(connid); + if (connw != null) return connw.rep; + } + return null; // sock not tracked! + } + + /** + * @returns {[Array, Array>]} + */ end() { const srvs = this.srvs; const cmap = this.connmap; @@ -167,11 +224,15 @@ class Tracker { const zero6 = "::"; const tracker = new Tracker(); const stats = new Stats(); +// blog.cloudflare.com/optimizing-tls-over-tcp-to-reduce-latency +// 1369 - (1500 - 1280) = 1149 +const tlsStartFragmentSize = 1149; // bytes +const tlsMaxFragmentSize = 16 << 10; // 16kb +const tlsSessions = new LfuCache("tlsSessions", 10000); const cpucount = os.cpus().length || 1; const adjPeriodSec = 5; +const maxHeapSnaps = 20; let adjTimer = null; -/** @type {memwatch.HeapDiff} */ -let heapdiff = null; ((main) => { // listen for "go" and start the server @@ -182,7 +243,7 @@ let heapdiff = null; system.pub("prepare"); })(); -async function systemDown() { +function systemDown() { // system-down even may arrive even before the process has had the chance // to start, in which case globals like env and log may not be available const upmins = (uptime() / 60000) | 0; @@ -204,8 +265,8 @@ async function systemDown() { for (const m of cmap) { if (!m) continue; console.warn("W closing...", m.size, "connections"); - for (const sock of m.values()) { - close(sock); + for (const v of m.values()) { + close(v.socket); } } @@ -219,7 +280,8 @@ async function systemDown() { s.unref(); } - bye(); + // test: util.next(whyIsNodeRunning, bye); + util.next(bye); } function systemUp() { @@ -229,10 +291,13 @@ function systemUp() { const downloadmode = envutil.blocklistDownloadOnly(); const profilermode = envutil.profileDnsResolves(); const tlsoffload = envutil.isCleartext(); + // todo: tcp backlog for doh/dot servers not supported on bun 1.1 const tcpbacklog = envutil.tcpBacklog(); const maxconns = envutil.maxconns(); // see also: dns-transport.js:ioTimeout const ioTimeoutMs = envutil.ioTimeoutMs(); + const supportsHttp2 = envutil.isNode() || envutil.isDeno(); + const isBun = envutil.isBun(); if (downloadmode) { log.i("in download mode, not running the dns resolver"); @@ -247,12 +312,33 @@ function systemUp() { } // nodejs.org/api/net.html#netcreateserveroptions-connectionlistener + /** @type {net.ServerOpts} */ const serverOpts = { keepAlive: true, noDelay: true, }; + // default cipher suites + // nodejs.org/api/tls.html#modifying-the-default-tls-cipher-suite + let defaultTlsCiphers = ""; + if (!util.emptyString(tls.DEFAULT_CIPHERS)) { + // nodejs.org/api/tls.html#tlsdefault_ciphers + defaultTlsCiphers = tls.DEFAULT_CIPHERS; + } else { + // nodejs.org/api/tls.html#tlsgetciphers + defaultTlsCiphers = tls + .getCiphers() + .map((c) => c.toUpperCase()) + .join(":"); + } + // aes128 is a 'cipher string' for tls1.2 and below + // docs.openssl.org/1.1.1/man1/ciphers/#cipher-strings + const preferAes128 = + "AES128:TLS_AES_128_CCM_SHA256:TLS_AES_128_CCM_8_SHA256:TLS_AES_128_GCM_SHA256"; // nodejs.org/api/tls.html#tlscreateserveroptions-secureconnectionlistener + /** @type {tls.SecureContextOptions} */ const tlsOpts = { + ciphers: preferAes128 + ":" + defaultTlsCiphers, + honorCipherOrder: true, handshakeTimeout: Math.max((ioTimeoutMs / 2) | 0, 3 * 1000), // 3s in ms // blog.cloudflare.com/tls-session-resumption-full-speed-and-secure sessionTimeout: 60 * 60 * 24 * 7, // 7d in secs @@ -266,16 +352,17 @@ function systemUp() { // fly.io terminated tls? const portdoh = envutil.dohCleartextBackendPort(); const portdot = envutil.dotCleartextBackendPort(); + /** @type {net.ListenOptions} */ + const dohOpts = { port: portdoh, host: zero6, backlog: tcpbacklog }; + /** @type {net.ListenOptions} */ + const dotOpts = { port: portdot, host: zero6, backlog: tcpbacklog }; // TODO: ProxyProtoV2 with TLS ClientHello (unsupported by Fly.io, rn) // DNS over TLS Cleartext - const dotct = net - // serveTCP must eventually call machines-heartbeat - .createServer(serverOpts, serveTCP) - .listen(portdot, zero6, tcpbacklog, () => { - up("DoT Cleartext", dotct.address()); - trapServerEvents(dotct); - }); + const dotct = net.createServer(serverOpts, serveTCP).listen(dotOpts, () => { + up("DoT Cleartext", dotct.address()); + trapServerEvents("dotct", dotct); + }); // DNS over HTTPS Cleartext // Same port for http1.1/h2 does not work on node without tls, that is, @@ -286,11 +373,10 @@ function systemUp() { // Ref (for clients): github.com/nodejs/node/issues/31759 // Impl: stackoverflow.com/a/42019773 const dohct = h2c - // serveHTTPS must eventually invoke machines-heartbeat .createServer(serverOpts, serveHTTPS) - .listen(portdoh, zero6, tcpbacklog, () => { + .listen(dohOpts, () => { up("DoH Cleartext", dohct.address()); - trapServerEvents(dohct); + trapServerEvents("dohct", dohct); }); } else { // terminate tls ourselves @@ -303,56 +389,70 @@ function systemUp() { const portdot1 = envutil.dotBackendPort(); const portdot2 = envutil.dotProxyProtoBackendPort(); const portdoh = envutil.dohBackendPort(); - + /** @type {net.ListenOptions} */ + const dohOpts = { port: portdoh, host: zero6, backlog: tcpbacklog }; + /** @type {net.ListenOptions} */ + const dot1Opts = { port: portdot1, host: zero6, backlog: tcpbacklog }; + /** @type {net.ListenOptions} */ + const dot2Opts = { port: portdot2, host: zero6, backlog: tcpbacklog }; // DNS over TLS - const dot1 = tls - // serveTLS must eventually invoke machines-heartbeat - .createServer(secOpts, serveTLS) - .listen(portdot1, zero6, tcpbacklog, () => { - up("DoT", dot1.address()); - trapSecureServerEvents(dot1); - }); + const dot1 = tls.createServer(secOpts, serveTLS).listen(dot1Opts, () => { + up("DoT", dot1.address()); + trapSecureServerEvents("dot1", dot1); + }); // DNS over TLS w ProxyProto const dot2 = envutil.isDotOverProxyProto() && - net - // serveDoTProxyProto must evenually invoke machines-heartbeat - .createServer(serverOpts, serveDoTProxyProto) - .listen(portdot2, zero6, tcpbacklog, () => { - up("DoT ProxyProto", dot2.address()); - trapServerEvents(dot2); - }); + net.createServer(serverOpts, serveDoTProxyProto).listen(dot2Opts, () => { + up("DoT ProxyProto", dot2.address()); + trapServerEvents("dot2", dot2); + }); // DNS over HTTPS - const doh = http2 - // serveHTTPS must eventually invoke machines-heartbeat - .createSecureServer({ ...secOpts, ...h2Opts }, serveHTTPS) - .listen(portdoh, zero6, tcpbacklog, () => { - up("DoH", doh.address()); - trapSecureServerEvents(doh); - }); + if (supportsHttp2) { + const doh = http2 + .createSecureServer({ ...secOpts, ...h2Opts }, serveHTTPS) + .listen(dohOpts, () => { + up("DoH2", doh.address()); + trapSecureServerEvents("doh2", doh); + }); + } else if (isBun) { + const doh = https + .createServer(secOpts, serveHTTPS) + .listen(dohOpts, () => { + up("DoH1", doh.address()); + trapSecureServerEvents("doh1", doh); + }); + } else { + console.log("unsupported runtime for doh"); + } } const portcheck = envutil.httpCheckPort(); const hcheck = h2c.createServer(serve200).listen(portcheck, () => { up("http-check", hcheck.address()); - trapServerEvents(hcheck); + trapServerEvents("hcheck", hcheck); }); - // if (envutil.measureHeap()) heapdiff = new memwatch.HeapDiff(); heartbeat(); } /** - * @param {... import("http2").Http2Server | net.Server} s + * @param {string} id + * @param {... http2.Http2Server | net.Server} s */ -function trapServerEvents(s) { +function trapServerEvents(id, s) { const ioTimeoutMs = envutil.ioTimeoutMs(); if (!s) return; - tracker.trackServer(s); + const sid = tracker.trackServer(id, s); + + if (sid === tracker.zeroid) { + log.w("tcp: may be already tracking server", id); + return; + } s.on("connection", (/** @type {Socket} */ socket) => { stats.nofconns += 1; @@ -360,7 +460,7 @@ function trapServerEvents(s) { const id = tracker.trackConn(s, socket); if (!tracker.valid(id)) { - log.i("tcp: not tracking; server shutting down?"); + log.i("tcp: not tracking; server shutting down?", id); close(socket); return; } @@ -372,7 +472,7 @@ function trapServerEvents(s) { }); socket.on("error", (err) => { - log.d("tcp: incoming conn closed with err; " + err.message); + log.d("tcp: incoming conn", id, "closed:", err.message); close(socket); }); @@ -399,27 +499,36 @@ function trapServerEvents(s) { } /** + * @param {string} id * @param {http2.Http2SecureServer | tls.Server} s */ -function trapSecureServerEvents(s) { +function trapSecureServerEvents(id, s) { const ioTimeoutMs = envutil.ioTimeoutMs(); if (!s) return; - tracker.trackServer(s); + const sid = tracker.trackServer(id, s); + + if (sid === tracker.zeroid) { + log.w("tls: may be already tracking server", id); + return; + } // github.com/grpc/grpc-node/blob/e6ea6f517epackages/grpc-js/src/server.ts#L392 - s.on("secureConnection", (socket) => { + s.on("secureConnection", (/** @type {TLSSocket} */ socket) => { stats.nofconns += 1; stats.openconns += 1; const id = tracker.trackConn(s, socket); if (!tracker.valid(id)) { - log.i("tls: not tracking; server shutting down?"); + log.i("tls: not tracking; server shutting down?", id); close(socket); return; } + // github.com/nodejs/node-v0.x-archive/issues/6889 + socket.setMaxSendFragment(tlsStartFragmentSize); + socket.setTimeout(ioTimeoutMs, () => { stats.noftimeouts += 1; log.d("tls: incoming conn timed out; " + id); @@ -444,14 +553,36 @@ function trapSecureServerEvents(s) { }); }); - util.repeat(86400000 * 7, () => rotateTkt(s)); // 7d + const rottm = util.repeat(86400000 * 7, () => rotateTkt(s)); // 7d + s.on("close", () => clearInterval(rottm)); s.on("error", (err) => { log.e("tls: stop! server error; " + err.message, err); stopAfter(0); }); - s.on("close", () => clearInterval(rottm)); + // bajtos.net/posts/2013-08-07-improve-the-performance-of-the-node-js-https-server + // session tickets take precedence over session ids; -no_ticket is needed + // openssl s_client -connect :10000 -reconnect -tls1_2 -no_ticket + // on bun, since session tickets cannot be set by programs (though may be vended + // by the runtime itself), session ids may come in handy. + s.on("newSession", (id, data, next) => { + const hid = bufutil.hex(id); + tlsSessions.put(hid, data); + // log.d("tls: new session;", hid); + next(); + }); + + s.on("resumeSession", (id, next) => { + const hid = bufutil.hex(id); + + const data = tlsSessions.get(hid) || null; + // log.d("tls: resume;", hid, "ok?", data != null); + if (data) stats.fasttls += 1; + else stats.totfasttls += 1; + + next(/* err*/ null, null); + }); // emitted when the req is discarded due to maxConnections s.on("drop", (data) => { @@ -462,16 +593,30 @@ function trapSecureServerEvents(s) { s.on("tlsClientError", (err, /** @type {TLSSocket} */ tlsSocket) => { stats.tlserr += 1; // fly tcp healthchecks also trigger tlsClientErrors - log.d("tls: client err; " + err.message); + log.d("tls: client err;", err.message, addrstr(tlsSocket)); close(tlsSocket); }); } +/** + * @param {TLSSocket|Socket} sock + */ +function addrstr(sock) { + if (!sock) return ""; + if (sock.localAddress == null || sock.remoteAddress == null) return ""; + return ( + `[${sock.localAddress}]:${sock.localPort}` + + "->" + + `[${sock.remoteAddress}]:${sock.remotePort}` + ); +} + /** * @param {tls.Server} s * @returns {void} */ function rotateTkt(s) { + if (envutil.isBun()) return; if (!s || !s.listening) return; let seed = bufutil.fromB64(envutil.secretb64()); @@ -485,9 +630,11 @@ function rotateTkt(s) { ctx = cur + ctx; } + // tls session resumption with tickets (or ids) reduce the 3.5kb to 6.5kb + // overhead associated with tls handshake: netsekure.org/2010/03/tls-overhead nodecrypto .tkt48(seed, ctx) - .then((k) => s.setTicketKeys(k)) + .then((k) => s.setTicketKeys(k)) // not supported on bun .catch((err) => log.e("tls: ticket rotation failed:", err)); } @@ -749,8 +896,9 @@ function serveTLS(socket) { const sb = new ScratchBuffer(); log.d("----> dot request", host, flag); - socket.on("data", (data) => { - handleTCPData(socket, data, sb, host, flag); + socket.on("data", async (data) => { + const len = await handleTCPData(socket, data, sb, host, flag); + adjustTLSFragAfterWrites(socket, len); }); } @@ -777,10 +925,11 @@ function serveTCP(socket) { * @param {ScratchBuffer} sb - Scratch buffer * @param {String} host - Hostname * @param {String} flag - Blocklist Flag + * @returns {Promise} n - bytes sent */ -function handleTCPData(socket, chunk, sb, host, flag) { +async function handleTCPData(socket, chunk, sb, host, flag) { const cl = chunk.byteLength; - if (cl <= 0) return; + if (cl <= 0) return 0; // read header first which contains length(dns-query) const rem = dnsutil.dnsHeaderSize - sb.qlenBufOffset; @@ -793,18 +942,18 @@ function handleTCPData(socket, chunk, sb, host, flag) { // header has not been read fully, yet; expect more data // www.rfc-editor.org/rfc/rfc7766#section-8 - if (sb.qlenBufOffset !== dnsutil.dnsHeaderSize) return; + if (sb.qlenBufOffset !== dnsutil.dnsHeaderSize) return 0; const qlen = sb.qlenBuf.readUInt16BE(); if (!dnsutil.validateSize(qlen)) { log.w(`tcp: query size err: ql:${qlen} cl:${cl} rem:${rem}`); close(socket); - return; + return 0; } // rem bytes already read, is any more left in chunk? const size = cl - rem; - if (size <= 0) return; + if (size <= 0) return 0; // gobble up at most qlen bytes from chunk starting rem-th byte const qlimit = rem + Math.min(qlen - sb.qBufOffset, size); // hopefully fast github.com/nodejs/node/issues/20130#issuecomment-382417255 @@ -819,17 +968,20 @@ function handleTCPData(socket, chunk, sb, host, flag) { sb.qBufOffset += data.byteLength; log.d(`tcp: q: ${qlen}, sb.q: ${sb.qBufOffset}, cl: ${cl}, sz: ${size}`); + let n = 0; // exactly qlen bytes read till now, handle the dns query if (sb.qBufOffset === qlen) { // extract out the query and reset the scratch-buffer const b = sb.reset(); - handleTCPQuery(b, socket, host, flag); + n += await handleTCPQuery(b, socket, host, flag); + // if there is any out of band data, handle it if (!bufutil.emptyBuf(oob)) { log.d(`tcp: pipelined, handle oob: ${oob.byteLength}`); - handleTCPData(socket, oob, sb, host, flag); + n += handleTCPData(socket, oob, sb, host, flag); } } // continue reading from socket + return n; } /** @@ -837,41 +989,46 @@ function handleTCPData(socket, chunk, sb, host, flag) { * @param {TLSSocket} socket * @param {String} host * @param {String} flag + * @returns {Promise} n - bytes sent */ async function handleTCPQuery(q, socket, host, flag) { heartbeat(); + let n = 0; let ok = true; - if (bufutil.emptyBuf(q) || !tcpOkay(socket)) return; + if (bufutil.emptyBuf(q) || !tcpOkay(socket)) return 0; + /** @type {Uint8Array?} */ + let r = null; const rxid = util.xid(); - const t = log.startTime("handle-tcp-query-" + rxid); try { - const r = await resolveQuery(rxid, q, host, flag); + r = await resolveQuery(rxid, q, host, flag); if (bufutil.emptyBuf(r)) { log.w(rxid, "tcp: empty ans from resolver"); ok = false; } else { const rlBuf = bufutil.encodeUint8ArrayBE(r.byteLength, 2); const data = new Uint8Array([...rlBuf, ...r]); - measuredWrite(rxid, socket, data); + n = measuredWrite(rxid, socket, data); } } catch (e) { ok = false; log.w(rxid, "tcp: send fail, err", e); } - log.endTime(t); // close socket when !ok if (!ok) { close(socket); } // else: expect pipelined queries on the same socket + + return n; } /** * @param {string} rxid * @param {Socket} socket * @param {Uint8Array} data + * @param {int} n - bytes written to socket */ function measuredWrite(rxid, socket, data) { let ok = tcpOkay(socket); @@ -879,7 +1036,7 @@ function measuredWrite(rxid, socket, data) { if (!ok) { log.w(rxid, "tcp: send fail, socket not writable", bufutil.len(data)); close(socket); - return; + return 0; } // nodejs.org/en/docs/guides/backpressuring-in-streams // stackoverflow.com/a/18933853 @@ -892,6 +1049,7 @@ function measuredWrite(rxid, socket, data) { socket.resume(); }); } + return bufutil.len(data); } /** * @param {String} rxid @@ -927,7 +1085,7 @@ async function resolveQuery(rxid, q, host, flag) { } } -async function serve200(req, res) { +function serve200(req, res) { log.d("-------------> http-check req", req.method, req.url); stats.nofchecks += 1; res.writeHead(200); @@ -939,14 +1097,11 @@ async function serve200(req, res) { * @param {Http2ServerRequest} req * @param {Http2ServerResponse} res */ -async function serveHTTPS(req, res) { +function serveHTTPS(req, res) { trapRequestResponseEvents(req, res); const ua = req.headers["user-agent"]; - const buffers = []; - const t = log.startTime("recv-https"); - // if using for await loop, then it must be wrapped in a // try-catch block: stackoverflow.com/questions/69169226 // if not, errors from reading req escapes unhandled. @@ -958,8 +1113,6 @@ async function serveHTTPS(req, res) { const b = bufutil.concatBuf(buffers); const bLen = b.byteLength; - log.endTime(t); - if (util.isPostRequest(req) && !dnsutil.validResponseSize(b)) { res.writeHead(dnsutil.dohStatusCode(b), util.corsHeadersIfNeeded(ua)); res.end(); @@ -980,7 +1133,6 @@ async function handleHTTPRequest(b, req, res) { heartbeat(); const rxid = util.xid(); - const t = log.startTime("handle-http-req-" + rxid); try { let host = req.headers.host || req.headers[":authority"]; if (isIPv6(host)) host = `[${host}]`; @@ -999,29 +1151,23 @@ async function handleHTTPRequest(b, req, res) { body: req.method === "POST" ? b : null, }); - log.lapTime(t, "upstream-start"); - const fRes = await handleRequest(util.mkFetchEvent(fReq)); - log.lapTime(t, "upstream-end"); - if (!resOkay(res)) { throw new Error("res not writable 1"); } res.writeHead(fRes.status, util.copyHeaders(fRes)); - log.lapTime(t, "send-head"); - // ans may be null on non-2xx responses, such as redirects (3xx) by cc.js // or 4xx responses on timeouts or 5xx on invalid http method const ans = await fRes.arrayBuffer(); - - log.lapTime(t, "recv-ans"); + const sz = bufutil.len(ans); if (!resOkay(res)) { throw new Error("res not writable 2"); - } else if (!bufutil.emptyBuf(ans)) { + } else if (sz > 0) { + adjustTLSFragAfterWrites(res.socket, sz); res.end(bufutil.normalize8(ans)); } else { // expect fRes.status to be set to non 2xx above @@ -1034,8 +1180,6 @@ async function handleHTTPRequest(b, req, res) { if (!ok) resClose(res); log.w(e); } - - log.endTime(t); } /** @@ -1061,29 +1205,67 @@ function trapRequestResponseEvents(req, res) { } function heartbeat() { - const maxc = envutil.maxconns(); const minc = envutil.minconns(); + const maxc = envutil.maxconns(); + const isNode = envutil.isNode(); + const notCloud = envutil.onLocal(); const measureHeap = envutil.measureHeap(); - + const freemem = os.freemem() / (1024 * 1024); // in mb + const totmem = os.totalmem() / (1024 * 1024); // in mb // increment no of requests stats.noreqs += 1; - if (!measureHeap) { - endHeapDiffIfNeeded(heapdiff); - heapdiff = null; - } else if (heapdiff == null) { - // heapdiff = new memwatch.HeapDiff(); - } else if (stats.noreqs % (maxc * 10) === 0) { - endHeapDiffIfNeeded(heapdiff); - // heapdiff = new memwatch.HeapDiff(); - } if (stats.noreqs % (minc * 2) === 0) { log.i(stats.str(), "in", (uptime() / 60000) | 0, "mins"); } + + const mul = notCloud ? 2 : 10; + const writeSnap = notCloud || measureHeap; + const ramthres = notCloud || freemem < 0.2 * totmem; + const reqthres = stats.noreqs > 0 && stats.noreqs % (maxc * mul) === 0; + const withinLimit = stats.nofheapsnaps < maxHeapSnaps; + if (isNode && writeSnap && withinLimit && reqthres && ramthres) { + stats.nofheapsnaps += 1; + const n = "s" + stats.nofheapsnaps + "." + stats.noreqs + ".heapsnapshot"; + const start = Date.now(); + // nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot + v8.writeHeapSnapshot(n); // blocks event loop! + const elapsed = (Date.now() - start) / 1000; + log.i("heap snapshot #", stats.nofheapsnaps, n, "in", elapsed, "s"); + } +} + +/** + * github.com/nodejs/node-v0.x-archive/issues/6889 + * github.com/golang/go/blob/ef3e1dae2f/src/crypto/tls/conn.go#L895 + * @param {TLSSocket?} socket + * @param {SoReport?} rep + * @param {int} sz + */ +function adjustTLSFragAfterWrites(socket, sz, rep = tracker.sorep(socket)) { + if (typeof sz !== "number" || sz <= 0) return; // also skip lastsnd + if (socket == null || !(socket instanceof TLSSocket)) return; + if (rep == null) return; + + const now = Date.now(); + if (now - rep.lastsnd > 1000) { + // reset tx time threshold: 1s + socket.setMaxSendFragment(tlsStartFragmentSize); + rep.tx = sz; + } else if (rep.tx > tlsStartFragmentSize * 1000) { + stats.noftlsadjs += 1; + + // boost upto max thres: 1139 * 1000 = ~128kb or ~1000 frags + socket.setMaxSendFragment(tlsMaxFragmentSize); + } // else: adaptively set to (sz * est fragments rcvd so far) + rep.lastsnd = now; + rep.tx += sz; + // socket.setMaxSendFragment(sz * sb.tx/tlsStartFragmentSize) } function adjustMaxConns(n) { const isNode = envutil.isNode(); + const notCloud = envutil.onLocal(); const maxc = envutil.maxconns(); const minc = envutil.minconns(); const adjsPerSec = 60 / adjPeriodSec; @@ -1097,6 +1279,11 @@ function adjustMaxConns(n) { avg5 = ((avg5 * 100) / cpucount) | 0; avg15 = ((avg15 * 100) / cpucount) | 0; + const freemem = os.freemem() / (1024 * 1024); // in mb + const totmem = os.totalmem() / (1024 * 1024); // in mb + const lowram = freemem < 0.1 * totmem; + const verylowram = freemem < 0.025 * totmem; + let adj = stats.bp[3] || 0; // increase in load if (avg5 > 90) { @@ -1111,7 +1298,7 @@ function adjustMaxConns(n) { n = maxc; if (avg1 > 100) { n = minc; - } else if (avg1 > 90 || avg5 > 80) { + } else if (avg1 > 90 || avg5 > 80 || lowram) { n = Math.max((n * 0.2) | 0, minc); } else if (avg1 > 80 || avg5 > 75) { n = Math.max((n * 0.4) | 0, minc); @@ -1132,48 +1319,34 @@ function adjustMaxConns(n) { } // adjustMaxConns is called every adjPeriodSec - const breakpoint = 10 * adjsPerSec; // 10 mins - const stresspoint = 5 * adjsPerSec; // 5 mins + const breakpoint = 6 * adjsPerSec; // 6 mins + const stresspoint = 4 * adjsPerSec; // 4 mins const nstr = stats.openconns + "/" + n; - if (adj > breakpoint) { - log.w("load: stopping; n:", nstr, "adjs:", adj); + if (adj > breakpoint || (verylowram && !notCloud)) { + log.w("load: verylowram! freemem:", freemem, "totmem:", totmem); + log.w("load: stopping lowram?", verylowram, "; n:", nstr, "adjs:", adj); stopAfter(0); return; } else if (adj > stresspoint) { + log.w("load: stress; lowram?", lowram, "mem:", freemem, " / ", totmem); log.w("load: stress; n:", nstr, "adjs:", adj, "avgs:", avg1, avg5, avg15); n = (minc / 2) | 0; } else if (adj > 0) { + log.d("load: high; lowram?", lowram, "mem:", freemem, " / ", totmem); log.d("load: high; n:", nstr, "adjs:", adj, "avgs:", avg1, avg5, avg15); } - // nodejs.org/en/docs/guides/diagnostics/memory/using-gc-traces - if (adj > 0) { - if (isNode) v8.setFlagsFromString("--trace-gc"); - } else { - if (isNode) v8.setFlagsFromString("--notrace-gc"); - } - stats.bp = [avg1, avg5, avg15, adj, n]; for (const s of tracker.servers()) { if (!s || !s.listening) continue; s.maxConnections = n; } -} -/** - * @param {memwatch.HeapDiff} h - * @returns void - */ -function endHeapDiffIfNeeded(h) { - // disabled; memwatch is not bundled due to a webpack bug - if (!h || true) return; - try { - const diff = h.end(); - log.i("heap before", diff.before); - log.i("heap after", diff.after); - log.i("heap details", diff.change.details); - } catch (ex) { - log.w("heap-diff err", ex.message); + // nodejs.org/en/docs/guides/diagnostics/memory/using-gc-traces + if (adj > 0) { + if (isNode) v8.setFlagsFromString("--trace-gc"); + } else { + if (isNode) v8.setFlagsFromString("--notrace-gc"); } } @@ -1182,5 +1355,8 @@ function bye() { // of other unreleased resources (see: svc.js#systemStop); and so exit with // success (exit code 0) regardless; ref: community.fly.io/t/4547/6 console.warn("W game over"); + + if (envutil.isNode()) v8.writeHeapSnapshot("snap.end.heapsnapshot"); + process.exit(0); } diff --git a/src/server-workers.js b/src/server-workers.js index ed1d86a083..22ae7506e7 100644 --- a/src/server-workers.js +++ b/src/server-workers.js @@ -6,10 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import "./core/workers/config.js"; +import * as util from "./commons/util.js"; import { handleRequest } from "./core/doh.js"; +import "./core/workers/config.js"; import * as system from "./system.js"; -import * as util from "./commons/util.js"; export default { // workers/runtime-apis/fetch-event#syntax-module-worker @@ -34,7 +34,7 @@ function serveDoh(request, env, ctx) { return new Promise((accept) => { system .when("go") - .then((v) => { + .then((_v) => { return handleRequest(event); }) .then((response) => { diff --git a/src/system.js b/src/system.js index 91997f239f..3fe0130a7f 100644 --- a/src/system.js +++ b/src/system.js @@ -9,6 +9,10 @@ import * as util from "./commons/util.js"; // Evaluate if EventTarget APIs can replace this hand-rolled impl // developers.cloudflare.com/workers/platform/changelog#2021-09-24 + +/** @typedef {any[]?} parcel */ +/** @typedef {function(parcel)} listenfn */ + // once emitted, they stick; firing off new listeners forever, just the once. const stickyEvents = new Set([ // when process bring-up is done @@ -21,12 +25,20 @@ const stickyEvents = new Set([ "go", ]); +/** @type {Map} */ +const stickyParcels = new Map(); + const events = new Set([ - // when server should cease + // when process should cease "stop", ]); +/** @type {Set} */ +const ephemeralEvents = new Set(); + +/** @type {Map>} */ const listeners = new Map(); +/** @type {Map>} */ const waitGroup = new Map(); (() => { @@ -41,103 +53,180 @@ const waitGroup = new Map(); } })(); -// fires an event -export function pub(event, parcel = undefined) { - awaiters(event, parcel); - callbacks(event, parcel); +/** + * Fires event. + * @param {string} event + * @param {parcel} parcel + * @returns {int} + */ +export function pub(event, parcel = null) { + if (util.emptyString(event)) return; + + const hadEphemeralEvent = ephemeralEvents.delete(event); + + const tot = awaiters(event, parcel, hadEphemeralEvent); + return tot + callbacks(event, parcel, hadEphemeralEvent); } -// invokes cb when event is fired -export function sub(event, cb) { +/** + * Invokes cb when event is fired. + * @param {string} event + * @param {listenfn} cb + * @param {int} timeout + * @returns {boolean} + */ +export function sub(event, cb, timeout = 0) { + if (util.emptyString(event)) return; + if (typeof cb !== "function") return; + const eventCallbacks = listeners.get(event); - // if such even callbacks don't exist if (!eventCallbacks) { - // but event is sticky, fire off the listener at once + // event is sticky, fire off the listener at once if (stickyEvents.has(event)) { - microtaskBox(cb); + const parcel = stickyParcels.get(event); // may be null + microtaskBox(cb, parcel); return true; } - // but event doesn't exist, then there's nothing to do + // event doesn't exist so make it ephemeral + ephemeralEvents.add(event); + listeners.set(event, new Set()); + waitGroup.set(event, new Set()); return false; } - eventCallbacks.add(cb); + const tid = timeout > 0 ? util.timeout(timeout, cb) : -2; + const fulfiller = + tid > 0 + ? (parcel) => { + clearTimeout(tid); + cb(parcel); + } + : cb; + eventCallbacks.add(fulfiller); return true; } -// waits till event fires or timesout +/** + * Waits till event fires or timesout. + * @param {string} event + * @param {int} timeout + * @returns {Promise} + */ export function when(event, timeout = 0) { + if (util.emptyString(event)) { + return Promise.reject(new Error("empty event")); + } + const wg = waitGroup.get(event); if (!wg) { // if stick event, fulfill promise right away if (stickyEvents.has(event)) { - return Promise.resolve(event); + const parcel = stickyParcels.get(event); // may be null + return Promise.resolve(parcel); } - // no such event - return Promise.reject(new Error(event + " missing")); + // no such event so make it ephemeral + ephemeralEvents.add(event); + listeners.set(event, new Set()); + waitGroup.set(event, new Set()); + return Promise.reject(new Error(event + " missing event")); } return new Promise((accept, reject) => { const tid = timeout > 0 ? util.timeout(timeout, () => { - reject(new Error(event + " elapsed " + timeout)); + reject(new Error(event + " event elapsed " + timeout)); }) : -2; - const fulfiller = function (parcel) { + /** @type {listenfn} */ + const fulfiller = (parcel) => { if (tid >= 0) clearTimeout(tid); - accept(parcel, event); + accept(parcel); }; wg.add(fulfiller); }); } -function awaiters(event, parcel) { - const g = waitGroup.get(event); +/** + * @param {string} event + * @param {parcel} parcel + * @param {boolean} ephemeralEvent + * @returns {int} + */ +function awaiters(event, parcel = null, ephemeralEvent = false) { + if (util.emptyString(event)) return 0; + const wg = waitGroup.get(event); - if (!g) return; + if (!wg) return 0; - // listeners valid just the once for stickyEvents + // listeners valid just the once for stickyEvents & ephemeralEvents if (stickyEvents.has(event)) { waitGroup.delete(event); + stickyParcels.set(event, parcel); + } else if (ephemeralEvent) { + // log.d("sys: wg ephemeralEvent", event, parcel); + waitGroup.delete(event); } - safeBox(g, parcel); + if (wg.size === 0) return 0; + + safeBox(wg, parcel); + return wg.size; } -function callbacks(event, parcel) { +/** + * @param {string} event + * @param {parcel} parcel + * @param {boolean} ephemeralEvent + * @returns {int} + */ +function callbacks(event, parcel = null, ephemeralEvent = false) { + if (util.emptyString(event)) return 0; const cbs = listeners.get(event); - if (!cbs) return; + if (!cbs) return 0; - // listeners valid just the once for stickyEvents + // listeners valid just the once for stickyEvents & ephemeralEvents if (stickyEvents.has(event)) { listeners.delete(event); + stickyParcels.set(event, parcel); + } else if (ephemeralEvent) { + // log.d("sys: cb ephemeralEvent", event, parcel); + listeners.delete(event); } + if (cbs.size === 0) return 0; // callbacks are queued async and don't block the caller. On Workers, // where IOs or timers require event-context aka network-context, // which is only available when fns are invoked in response to an // incoming request (through the fetch event handler), such callbacks // may not even fire. Instead use: awaiters and not callbacks. microtaskBox(cbs, parcel); + return cbs.size; } -// TODO: could be replaced with scheduler.wait -// developers.cloudflare.com/workers/platform/changelog#2021-12-10 -// queues fn in a macro-task queue of the event-loop -// exec order: github.com/nodejs/node/issues/22257 +/** + * Queues fn in a macro-task queue of the event-loop + * exec order: github.com/nodejs/node/issues/22257 + * @param {listenfn} fn + */ export function taskBox(fn) { + // TODO: could be replaced with scheduler.wait + // developers.cloudflare.com/workers/platform/changelog#2021-12-10 util.timeout(/* with 0ms delay*/ 0, () => safeBox(fn)); } -// queues fn in a micro-task queue // ref: MDN: Web/API/HTML_DOM_API/Microtask_guide/In_depth // queue-task polyfill: stackoverflow.com/a/61605098 const taskboxPromise = { p: Promise.resolve() }; +/** + * Queues fns in a micro-task queue + * @param {listenfn[]} fns + * @param {parcel} arg + */ function microtaskBox(fns, arg) { let enqueue = null; if (typeof queueMicrotask === "function") { @@ -149,9 +238,14 @@ function microtaskBox(fns, arg) { enqueue(() => safeBox(fns, arg)); } -// TODO: safeBox for async fns with r.push(await f())? -// stackoverflow.com/questions/38508420 +/** + * stackoverflow.com/questions/38508420 + * @param {listenfn[]|listenfn?} fns + * @param {parcel} arg + * @returns {any[]} + */ function safeBox(fns, arg) { + // TODO: safeBox for async fns with r.push(await f())? if (typeof fns === "function") { fns = [fns]; } @@ -168,7 +262,8 @@ function safeBox(fns, arg) { } try { r.push(f(arg)); - } catch (ignore) { + } catch (_) { + // log.e("sys: safeBox err", _); r.push(null); } } diff --git a/webpack.fly.cjs b/webpack.fly.cjs index bd339b6fc4..427b82ab3e 100644 --- a/webpack.fly.cjs +++ b/webpack.fly.cjs @@ -2,7 +2,7 @@ const webpack = require("webpack"); module.exports = { entry: "./src/server-node.js", - target: ["node", "es2022"], + target: ["node22", "es2022"], mode: "production", // enable devtool in development // devtool: 'eval-cheap-module-source-map', @@ -17,6 +17,12 @@ module.exports = { }), ], + /* externalsType: 'module', + externals: { + '@riaskov/mmap-io': '@riaskov/mmap-io', + },*/ + externals: /@riaskov/, + optimization: { usedExports: true, minimize: false, @@ -47,7 +53,8 @@ module.exports = { module: true, }, - /* or, cjs: stackoverflow.com/a/68916455 + // or, cjs: stackoverflow.com/a/68916455 + /* output: { filename: "fly.cjs", clean: true, // empty dist before output diff --git a/wrangler.toml b/wrangler.toml index c2aefe4a09..69ce1cb9bf 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,6 +8,7 @@ logpush = false compatibility_date = "2023-03-21" send_metrics = false minify = false +upload_source_maps = true # uncomment to enable analytics on serverless-dns # this binding is not inherited by other worker-envs @@ -74,6 +75,7 @@ LOG_LEVEL = "logpush" WORKER_ENV = "production" CLOUD_PLATFORM = "cloudflare" CF_LOGPUSH_R2_PATH = "qlog/" +AUTO_RENEW_BLOCKLISTS_OLDER_THAN = "42" # weeks ################## #-----SECRETS----#